@inoo-ch/payload-image-optimizer 1.8.1 → 1.9.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.
Files changed (36) hide show
  1. package/dist/components/RegenerationButton.js +85 -21
  2. package/dist/components/RegenerationButton.js.map +1 -1
  3. package/dist/defaults.js +14 -2
  4. package/dist/defaults.js.map +1 -1
  5. package/dist/endpoints/regenerate.d.ts +1 -0
  6. package/dist/endpoints/regenerate.js +77 -1
  7. package/dist/endpoints/regenerate.js.map +1 -1
  8. package/dist/hooks/beforeChange.js +15 -18
  9. package/dist/hooks/beforeChange.js.map +1 -1
  10. package/dist/index.d.ts +2 -1
  11. package/dist/index.js +32 -5
  12. package/dist/index.js.map +1 -1
  13. package/dist/tasks/regenerateDocument.js +27 -4
  14. package/dist/tasks/regenerateDocument.js.map +1 -1
  15. package/dist/types.d.ts +49 -2
  16. package/dist/types.js.map +1 -1
  17. package/dist/utilities/filenameStrategies.d.ts +25 -0
  18. package/dist/utilities/filenameStrategies.js +46 -0
  19. package/dist/utilities/filenameStrategies.js.map +1 -0
  20. package/dist/utilities/stripDiacritics.d.ts +9 -0
  21. package/dist/utilities/stripDiacritics.js +10 -0
  22. package/dist/utilities/stripDiacritics.js.map +1 -0
  23. package/dist/utilities/toKebabCase.d.ts +10 -0
  24. package/dist/utilities/toKebabCase.js +11 -0
  25. package/dist/utilities/toKebabCase.js.map +1 -0
  26. package/package.json +1 -1
  27. package/src/components/RegenerationButton.tsx +92 -24
  28. package/src/defaults.ts +15 -1
  29. package/src/endpoints/regenerate.ts +68 -0
  30. package/src/hooks/beforeChange.ts +15 -18
  31. package/src/index.ts +27 -6
  32. package/src/tasks/regenerateDocument.ts +24 -4
  33. package/src/types.ts +51 -2
  34. package/src/utilities/filenameStrategies.ts +61 -0
  35. package/src/utilities/stripDiacritics.ts +10 -0
  36. package/src/utilities/toKebabCase.ts +16 -0
@@ -25,11 +25,15 @@ export const RegenerationButton: React.FC = () => {
25
25
  const [force, setForce] = useState(false)
26
26
  const [error, setError] = useState<string | null>(null)
27
27
  const [stalled, setStalled] = useState(false)
28
+ const [cancelled, setCancelled] = useState(false)
28
29
  const [collectionSlug, setCollectionSlug] = useState<string | null>(null)
29
30
  const [stats, setStats] = useState<RegenerationProgress | null>(null)
30
31
  const [confirming, setConfirming] = useState(false)
31
32
  const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null)
32
33
  const stallRef = useRef({ lastProcessed: 0, stallCount: 0 })
34
+ // Snapshot of complete+errored at the moment regeneration starts,
35
+ // so we can compute batch-relative progress for selective regeneration.
36
+ const baselineRef = useRef<number | null>(null)
33
37
  const prevIsRunningRef = useRef(false)
34
38
 
35
39
  // Extract collection slug from URL after mount to avoid hydration mismatch
@@ -77,9 +81,19 @@ export const RegenerationButton: React.FC = () => {
77
81
  `/api/image-optimizer/regenerate?collection=${collectionSlug}`,
78
82
  )
79
83
  if (res.ok) {
80
- const data: RegenerationProgress = await res.json()
84
+ const data = await res.json()
81
85
  setProgress(data)
82
86
 
87
+ // Stop polling if server reports cancellation
88
+ if (data.cancelled) {
89
+ setCancelled(true)
90
+ setIsRunning(false)
91
+ setStalled(false)
92
+ stopPolling()
93
+ sessionStorage.removeItem(SESSION_KEY)
94
+ return
95
+ }
96
+
83
97
  // Stop polling when no more pending
84
98
  if (data.pending <= 0) {
85
99
  setIsRunning(false)
@@ -169,16 +183,38 @@ export const RegenerationButton: React.FC = () => {
169
183
  setConfirming(false)
170
184
  }
171
185
 
186
+ const handleStop = async () => {
187
+ if (!collectionSlug) return
188
+ try {
189
+ await fetch('/api/image-optimizer/regenerate', {
190
+ method: 'DELETE',
191
+ headers: { 'Content-Type': 'application/json' },
192
+ body: JSON.stringify({ collectionSlug }),
193
+ })
194
+ setCancelled(true)
195
+ setIsRunning(false)
196
+ setStalled(false)
197
+ stopPolling()
198
+ sessionStorage.removeItem(SESSION_KEY)
199
+ fetchStats()
200
+ } catch {
201
+ // ignore cancel errors
202
+ }
203
+ }
204
+
172
205
  // Phase 2: Actually start regeneration (after user confirms)
173
206
  const handleConfirm = async () => {
174
207
  if (!collectionSlug) return
175
208
  setConfirming(false)
176
209
  setError(null)
177
210
  setStalled(false)
211
+ setCancelled(false)
178
212
  setIsRunning(true)
179
213
  setQueued(null)
180
214
  setProgress(null)
181
215
  stallRef.current = { lastProcessed: 0, stallCount: 0 }
216
+ // Capture current complete+errored as baseline before new jobs run
217
+ baselineRef.current = stats ? stats.complete + stats.errored : 0
182
218
 
183
219
  try {
184
220
  const requestBody: Record<string, unknown> = { collectionSlug, force }
@@ -222,9 +258,20 @@ export const RegenerationButton: React.FC = () => {
222
258
 
223
259
  if (!collectionSlug) return null
224
260
 
261
+ // When a batch is running, compute progress relative to the queued count
262
+ // (not the total collection) so selective regeneration shows e.g. 1/2, not 1/167.
263
+ const batchTotal = queued ?? progress?.total ?? 0
264
+ const batchProcessed = progress
265
+ ? (progress.complete + progress.errored) - (baselineRef.current ?? 0)
266
+ : 0
267
+ const batchComplete = progress
268
+ ? progress.complete - Math.max((baselineRef.current ?? 0) - (progress.errored), 0)
269
+ : 0
270
+ const batchErrored = progress ? Math.max(batchProcessed - Math.max(batchComplete, 0), 0) : 0
271
+
225
272
  const progressPercent =
226
- progress && progress.total > 0
227
- ? Math.round(((progress.complete + progress.errored) / progress.total) * 100)
273
+ batchTotal > 0
274
+ ? Math.min(Math.round((batchProcessed / batchTotal) * 100), 100)
228
275
  : 0
229
276
 
230
277
  const showProgressBar = (isRunning && progress) || (stalled && progress)
@@ -247,26 +294,41 @@ export const RegenerationButton: React.FC = () => {
247
294
  flexWrap: 'wrap',
248
295
  }}
249
296
  >
250
- {!confirming && (
297
+ {!confirming && !isRunning && (
251
298
  <button
252
299
  onClick={handlePreflight}
253
- disabled={isRunning}
254
300
  style={{
255
- backgroundColor: isRunning ? '#9ca3af' : '#4f46e5',
301
+ backgroundColor: '#4f46e5',
256
302
  color: '#fff',
257
303
  border: 'none',
258
304
  borderRadius: '6px',
259
305
  padding: '8px 16px',
260
306
  fontSize: '14px',
261
307
  fontWeight: 500,
262
- cursor: isRunning ? 'not-allowed' : 'pointer',
308
+ cursor: 'pointer',
263
309
  }}
264
310
  >
265
- {isRunning
266
- ? 'Processing images...'
267
- : hasSelection
268
- ? `Regenerate ${selectionCount} Selected`
269
- : 'Regenerate All Images'}
311
+ {hasSelection
312
+ ? `Regenerate ${selectionCount} Selected`
313
+ : 'Regenerate All Images'}
314
+ </button>
315
+ )}
316
+
317
+ {!confirming && isRunning && (
318
+ <button
319
+ onClick={handleStop}
320
+ style={{
321
+ backgroundColor: '#ef4444',
322
+ color: '#fff',
323
+ border: 'none',
324
+ borderRadius: '6px',
325
+ padding: '8px 16px',
326
+ fontSize: '14px',
327
+ fontWeight: 500,
328
+ cursor: 'pointer',
329
+ }}
330
+ >
331
+ Stop Processing
270
332
  </button>
271
333
  )}
272
334
 
@@ -336,12 +398,18 @@ export const RegenerationButton: React.FC = () => {
336
398
  </span>
337
399
  )}
338
400
 
339
- {queued === 0 && !isRunning && !stalled && !confirming && (
401
+ {queued === 0 && !isRunning && !stalled && !confirming && !cancelled && (
340
402
  <span style={{ color: '#10b981', fontSize: '13px' }}>
341
403
  All images already optimized.
342
404
  </span>
343
405
  )}
344
406
 
407
+ {cancelled && !isRunning && !confirming && (
408
+ <span style={{ color: '#f59e0b', fontSize: '13px' }}>
409
+ Processing cancelled.
410
+ </span>
411
+ )}
412
+
345
413
  {stalled && progress && (
346
414
  <span style={{ color: '#f59e0b', fontSize: '13px' }}>
347
415
  Processing appears slow — {progress.pending} image{progress.pending !== 1 ? 's' : ''} still pending.
@@ -360,10 +428,10 @@ export const RegenerationButton: React.FC = () => {
360
428
  }}
361
429
  >
362
430
  <span>
363
- {progress.complete} / {progress.total} complete
431
+ {Math.max(batchProcessed, 0)} / {batchTotal} complete
364
432
  </span>
365
- {progress.errored > 0 && (
366
- <span style={{ color: '#ef4444' }}>{progress.errored} errors</span>
433
+ {batchErrored > 0 && (
434
+ <span style={{ color: '#ef4444' }}>{batchErrored} errors</span>
367
435
  )}
368
436
  <span>{progressPercent}%</span>
369
437
  </div>
@@ -379,16 +447,16 @@ export const RegenerationButton: React.FC = () => {
379
447
  <div
380
448
  style={{
381
449
  height: '100%',
382
- width: `${progress.total > 0 ? Math.round((progress.complete / progress.total) * 100) : 0}%`,
450
+ width: `${batchTotal > 0 ? Math.min(Math.round(((batchProcessed - batchErrored) / batchTotal) * 100), 100) : 0}%`,
383
451
  backgroundColor: '#10b981',
384
452
  transition: 'width 0.3s ease',
385
453
  }}
386
454
  />
387
- {progress.errored > 0 && (
455
+ {batchErrored > 0 && (
388
456
  <div
389
457
  style={{
390
458
  height: '100%',
391
- width: `${progress.total > 0 ? Math.round((progress.errored / progress.total) * 100) : 0}%`,
459
+ width: `${batchTotal > 0 ? Math.round((batchErrored / batchTotal) * 100) : 0}%`,
392
460
  backgroundColor: '#ef4444',
393
461
  transition: 'width 0.3s ease',
394
462
  }}
@@ -398,14 +466,14 @@ export const RegenerationButton: React.FC = () => {
398
466
  </div>
399
467
  )}
400
468
 
401
- {!isRunning && !stalled && progress && progress.complete > 0 && queued !== 0 && !confirming && (
469
+ {!isRunning && !stalled && !cancelled && progress && batchProcessed > 0 && queued !== 0 && !confirming && (
402
470
  <span style={{ fontSize: '13px' }}>
403
- <span style={{ color: progress.errored > 0 ? '#f59e0b' : '#10b981' }}>
404
- Done! {progress.complete}/{progress.total} optimized (across entire collection).
471
+ <span style={{ color: batchErrored > 0 ? '#f59e0b' : '#10b981' }}>
472
+ Done! {Math.max(batchProcessed - batchErrored, 0)}/{batchTotal} optimized.
405
473
  </span>
406
- {progress.errored > 0 && (
474
+ {batchErrored > 0 && (
407
475
  <span style={{ color: '#ef4444' }}>
408
- {' '}{progress.errored} failed.
476
+ {' '}{batchErrored} failed.
409
477
  </span>
410
478
  )}
411
479
  </span>
package/src/defaults.ts CHANGED
@@ -1,6 +1,19 @@
1
1
  import type { CollectionSlug } from 'payload'
2
2
 
3
3
  import type { ImageOptimizerConfig, ResolvedCollectionOptimizerConfig, ResolvedImageOptimizerConfig } from './types.js'
4
+ import { uuidFilename } from './utilities/filenameStrategies.js'
5
+
6
+ /**
7
+ * Resolve the generateFilename option:
8
+ * - Explicit `generateFilename` callback takes priority
9
+ * - `uniqueFileNames: true` maps to `uuidFilename` for backwards compat
10
+ * - Otherwise undefined (keep original filename)
11
+ */
12
+ const resolveGenerateFilename = (config: ImageOptimizerConfig) => {
13
+ if (config.generateFilename) return config.generateFilename
14
+ if (config.uniqueFileNames) return uuidFilename
15
+ return undefined
16
+ }
4
17
 
5
18
  export const resolveConfig = (config: ImageOptimizerConfig): ResolvedImageOptimizerConfig => ({
6
19
  clientOptimization: config.clientOptimization ?? true,
@@ -9,11 +22,12 @@ export const resolveConfig = (config: ImageOptimizerConfig): ResolvedImageOptimi
9
22
  formats: config.formats ?? [
10
23
  { format: 'webp', quality: 80 },
11
24
  ],
25
+ generateFilename: resolveGenerateFilename(config),
12
26
  generateThumbHash: config.generateThumbHash ?? true,
13
27
  maxDimensions: config.maxDimensions ?? { width: 2560, height: 2560 },
28
+ regenerateButton: config.regenerateButton ?? true,
14
29
  replaceOriginal: config.replaceOriginal ?? true,
15
30
  stripMetadata: config.stripMetadata ?? true,
16
- uniqueFileNames: config.uniqueFileNames ?? false,
17
31
  })
18
32
 
19
33
  export const resolveCollectionConfig = (
@@ -4,6 +4,32 @@ import type { CollectionSlug, Where } from 'payload'
4
4
  import type { ResolvedImageOptimizerConfig } from '../types.js'
5
5
  import { waitUntil } from '../utilities/waitUntil.js'
6
6
 
7
+ type CollectionState = { startedAt?: number; cancelledAt?: number; queued?: number }
8
+ type StateCollections = Record<string, CollectionState>
9
+
10
+ const GLOBAL_SLUG = 'image-optimizer-state'
11
+
12
+ async function getCollectionState(payload: any, slug: string): Promise<CollectionState> {
13
+ try {
14
+ const state = await payload.findGlobal({ slug: GLOBAL_SLUG })
15
+ return (state?.collections as StateCollections)?.[slug] || {}
16
+ } catch {
17
+ return {}
18
+ }
19
+ }
20
+
21
+ async function setCollectionState(payload: any, slug: string, update: Partial<CollectionState>): Promise<void> {
22
+ let existing: StateCollections = {}
23
+ try {
24
+ const state = await payload.findGlobal({ slug: GLOBAL_SLUG })
25
+ existing = (state?.collections as StateCollections) || {}
26
+ } catch {
27
+ // Global may not exist yet
28
+ }
29
+ existing[slug] = { ...existing[slug], ...update }
30
+ await payload.updateGlobal({ slug: GLOBAL_SLUG, data: { collections: existing } })
31
+ }
32
+
7
33
  export const createRegenerateHandler = (resolvedConfig: ResolvedImageOptimizerConfig) => {
8
34
  const handler: PayloadHandler = async (req) => {
9
35
  if (!req.user) {
@@ -87,6 +113,13 @@ export const createRegenerateHandler = (resolvedConfig: ResolvedImageOptimizerCo
87
113
 
88
114
  req.payload.logger.info(`Image optimizer: queued ${queued} images from '${collectionSlug}' for regeneration`)
89
115
 
116
+ // Clear any previous cancellation and record the start time + batch size
117
+ await setCollectionState(req.payload, collectionSlug, {
118
+ startedAt: Date.now(),
119
+ cancelledAt: undefined,
120
+ queued,
121
+ })
122
+
90
123
  // Fire the job runner — use waitUntil to keep the serverless function alive
91
124
  // after the response is sent, so jobs actually complete on Vercel/serverless.
92
125
  if (queued > 0) {
@@ -136,13 +169,48 @@ export const createRegenerateStatusHandler = (resolvedConfig: ResolvedImageOptim
136
169
  },
137
170
  })
138
171
 
172
+ // Include cancellation state so the UI can react
173
+ const collState = await getCollectionState(req.payload, collectionSlug)
174
+ const cancelled = !!(collState.cancelledAt && collState.startedAt && collState.cancelledAt > collState.startedAt)
175
+
139
176
  return Response.json({
140
177
  collectionSlug,
141
178
  total: total.totalDocs,
142
179
  complete: complete.totalDocs,
143
180
  errored: errored.totalDocs,
144
181
  pending: total.totalDocs - complete.totalDocs - errored.totalDocs,
182
+ cancelled,
183
+ })
184
+ }
185
+
186
+ return handler
187
+ }
188
+
189
+ export const createCancelHandler = (resolvedConfig: ResolvedImageOptimizerConfig) => {
190
+ const handler: PayloadHandler = async (req) => {
191
+ if (!req.user) {
192
+ return Response.json({ error: 'Unauthorized' }, { status: 401 })
193
+ }
194
+
195
+ let body: { collectionSlug?: string }
196
+ try {
197
+ body = await req.json!()
198
+ } catch {
199
+ body = {}
200
+ }
201
+
202
+ const collectionSlug = body.collectionSlug
203
+ if (!collectionSlug || !resolvedConfig.collections[collectionSlug as CollectionSlug]) {
204
+ return Response.json({ error: 'Invalid or unconfigured collection slug' }, { status: 400 })
205
+ }
206
+
207
+ await setCollectionState(req.payload, collectionSlug, {
208
+ cancelledAt: Date.now(),
145
209
  })
210
+
211
+ req.payload.logger.info(`Image optimizer: cancellation requested for '${collectionSlug}'`)
212
+
213
+ return Response.json({ cancelled: true, collectionSlug })
146
214
  }
147
215
 
148
216
  return handler
@@ -1,4 +1,3 @@
1
- import crypto from 'crypto'
2
1
  import path from 'path'
3
2
  import type { CollectionBeforeChangeHook } from 'payload'
4
3
 
@@ -16,24 +15,22 @@ export const createBeforeChangeHook = (
16
15
 
17
16
  if (!req.file || !req.file.data || !req.file.mimetype?.startsWith('image/')) return data
18
17
 
19
- // Rename file to UUID before any processing, so the storage adapter
20
- // never sees the original filename. Prevents Vercel Blob "already exists"
21
- // errors and avoids leaking original filenames to storage.
22
- // On focal-point or crop re-uploads (where Payload re-sends the same file),
23
- // reuse the existing UUID filename to avoid unnecessary file churn and
24
- // broken previews.
25
- if (resolvedConfig.uniqueFileNames) {
18
+ // Apply custom filename strategy (seoFilename, uuidFilename, or user-provided).
19
+ // The callback returns a stem (no extension) we append the original extension here,
20
+ // and replaceOriginal may swap it to the target format extension later.
21
+ if (resolvedConfig.generateFilename) {
26
22
  const existingFilename = (originalDoc as Record<string, unknown> | undefined)?.filename as string | undefined
27
- if (existingFilename) {
28
- // Reuse the existing filename (may get a new extension below if replaceOriginal changes format)
29
- req.file.name = existingFilename
30
- data.filename = existingFilename
31
- } else {
32
- const ext = path.extname(req.file.name)
33
- const uuid = crypto.randomUUID()
34
- req.file.name = `${uuid}${ext}`
35
- data.filename = req.file.name
36
- }
23
+ const ext = path.extname(req.file.name)
24
+ const stem = resolvedConfig.generateFilename({
25
+ altText: (data as Record<string, unknown>).alt as string | undefined,
26
+ originalFilename: req.file.name,
27
+ mimeType: req.file.mimetype,
28
+ collectionSlug,
29
+ existingFilename,
30
+ })
31
+ const newFilename = `${stem}${ext}`
32
+ req.file.name = newFilename
33
+ data.filename = newFilename
37
34
  }
38
35
 
39
36
  const originalSize = req.file.data.length
package/src/index.ts CHANGED
@@ -9,12 +9,13 @@ import { createBeforeChangeHook } from './hooks/beforeChange.js'
9
9
  import { createAfterChangeHook } from './hooks/afterChange.js'
10
10
  import { createConvertFormatsHandler } from './tasks/convertFormats.js'
11
11
  import { createRegenerateDocumentHandler } from './tasks/regenerateDocument.js'
12
- import { createRegenerateHandler, createRegenerateStatusHandler } from './endpoints/regenerate.js'
12
+ import { createRegenerateHandler, createRegenerateStatusHandler, createCancelHandler } from './endpoints/regenerate.js'
13
13
 
14
- export type { ImageOptimizerConfig, ImageFormat, FormatQuality, CollectionOptimizerConfig, ImageOptimizerData, MediaResource, MediaSizeVariant, FieldsOverride } from './types.js'
14
+ export type { ImageOptimizerConfig, ImageFormat, FormatQuality, CollectionOptimizerConfig, ImageOptimizerData, MediaResource, MediaSizeVariant, FieldsOverride, GenerateFilename, GenerateFilenameArgs } from './types.js'
15
15
  export { defaultImageOptimizerFields } from './fields/imageOptimizerField.js'
16
16
 
17
17
  export { encodeImageToThumbHash, decodeThumbHashToDataURL } from './utilities/thumbhash.js'
18
+ export { uuidFilename, seoFilename } from './utilities/filenameStrategies.js'
18
19
 
19
20
  /**
20
21
  * Recommended maxDuration for the Payload API route on Vercel.
@@ -69,10 +70,14 @@ export const imageOptimizer =
69
70
  },
70
71
  }
71
72
  : {}),
72
- beforeListTable: [
73
- ...(collection.admin?.components?.beforeListTable || []),
74
- '@inoo-ch/payload-image-optimizer/client#RegenerationButton',
75
- ],
73
+ ...(resolvedConfig.regenerateButton
74
+ ? {
75
+ beforeListTable: [
76
+ ...(collection.admin?.components?.beforeListTable || []),
77
+ '@inoo-ch/payload-image-optimizer/client#RegenerationButton',
78
+ ],
79
+ }
80
+ : {}),
76
81
  },
77
82
  },
78
83
  }
@@ -91,6 +96,17 @@ export const imageOptimizer =
91
96
  return {
92
97
  ...config,
93
98
  collections,
99
+ globals: [
100
+ ...(config.globals || []),
101
+ {
102
+ slug: 'image-optimizer-state',
103
+ admin: { hidden: true },
104
+ access: { read: () => true, update: () => true },
105
+ fields: [
106
+ { name: 'collections', type: 'json' },
107
+ ],
108
+ },
109
+ ],
94
110
  i18n,
95
111
  jobs: {
96
112
  ...config.jobs,
@@ -135,6 +151,11 @@ export const imageOptimizer =
135
151
  method: 'get',
136
152
  handler: createRegenerateStatusHandler(resolvedConfig),
137
153
  },
154
+ {
155
+ path: '/image-optimizer/regenerate',
156
+ method: 'delete',
157
+ handler: createCancelHandler(resolvedConfig),
158
+ },
138
159
  ],
139
160
  }
140
161
  }
@@ -1,4 +1,3 @@
1
- import crypto from 'crypto'
2
1
  import fs from 'fs/promises'
3
2
  import path from 'path'
4
3
 
@@ -10,9 +9,22 @@ import { stripAndResize, generateThumbHash, convertFormat } from '../processing/
10
9
  import { resolveStaticDir } from '../utilities/resolveStaticDir.js'
11
10
  import { fetchFileBuffer, isCloudStorage } from '../utilities/storage.js'
12
11
 
12
+ const GLOBAL_SLUG = 'image-optimizer-state'
13
+
13
14
  export const createRegenerateDocumentHandler = (resolvedConfig: ResolvedImageOptimizerConfig) => {
14
15
  return async ({ input, req }: { input: { collectionSlug: string; docId: string }; req: any }) => {
15
16
  try {
17
+ // Check cancellation before processing
18
+ try {
19
+ const state = await req.payload.findGlobal({ slug: GLOBAL_SLUG })
20
+ const collState = (state?.collections as Record<string, any>)?.[input.collectionSlug]
21
+ if (collState?.cancelledAt && collState.cancelledAt > (collState.startedAt || 0)) {
22
+ return { output: { status: 'cancelled', reason: 'user-cancelled' } }
23
+ }
24
+ } catch {
25
+ // Global may not exist yet — proceed normally
26
+ }
27
+
16
28
  const doc = await req.payload.findByID({
17
29
  collection: input.collectionSlug as CollectionSlug,
18
30
  id: input.docId,
@@ -75,11 +87,19 @@ export const createRegenerateDocumentHandler = (resolvedConfig: ResolvedImageOpt
75
87
  if (cloudStorage) {
76
88
  // Cloud storage: re-upload the optimized file via Payload's update API.
77
89
  // This triggers the cloud adapter's afterChange hook which uploads to cloud.
78
- // When uniqueFileNames is enabled, generate a new UUID filename to avoid
90
+ // When a filename strategy is configured, generate a new filename to avoid
79
91
  // Vercel Blob "already exists" errors (the adapter doesn't support allowOverwrite).
80
- if (resolvedConfig.uniqueFileNames) {
92
+ if (resolvedConfig.generateFilename) {
81
93
  const ext = path.extname(newFilename)
82
- newFilename = `${crypto.randomUUID()}${ext}`
94
+ const stem = resolvedConfig.generateFilename({
95
+ altText: doc.alt as string | undefined,
96
+ originalFilename: safeFilename,
97
+ mimeType: doc.mimeType as string,
98
+ collectionSlug: input.collectionSlug,
99
+ // No existingFilename — regeneration should always create a fresh name
100
+ // to avoid cloud storage "already exists" errors
101
+ })
102
+ newFilename = `${stem}${ext}`
83
103
  }
84
104
 
85
105
  const updateData: Record<string, any> = {
package/src/types.ts CHANGED
@@ -2,6 +2,29 @@ import type { CollectionSlug, Field } from 'payload'
2
2
 
3
3
  export type ImageFormat = 'webp' | 'avif'
4
4
 
5
+ export type GenerateFilenameArgs = {
6
+ /** Alt text from the document (if the collection has an `alt` field) */
7
+ altText?: string
8
+ /** Original uploaded filename (e.g., "IMG_2847.jpg") */
9
+ originalFilename: string
10
+ /** The MIME type (e.g., "image/jpeg") */
11
+ mimeType: string
12
+ /** The collection slug this file belongs to */
13
+ collectionSlug: string
14
+ /** Existing filename from a previous upload (set on re-uploads / focal point changes).
15
+ * Strategies should typically reuse this to avoid cloud storage churn. */
16
+ existingFilename?: string
17
+ }
18
+
19
+ /**
20
+ * Custom filename generation function.
21
+ * Return the filename **stem** (without extension) — the plugin appends the
22
+ * correct extension based on format conversion settings.
23
+ *
24
+ * Built-in strategies: `uuidFilename`, `seoFilename`
25
+ */
26
+ export type GenerateFilename = (args: GenerateFilenameArgs) => string
27
+
5
28
  export type FormatQuality = {
6
29
  format: ImageFormat
7
30
  quality: number // 1-100
@@ -21,13 +44,37 @@ export type ImageOptimizerConfig = {
21
44
  disabled?: boolean
22
45
  fieldsOverride?: FieldsOverride
23
46
  formats?: FormatQuality[]
47
+ /** Custom filename generation strategy. Return the filename **stem** (no extension).
48
+ * The plugin appends the correct extension based on format conversion settings.
49
+ *
50
+ * Built-in strategies:
51
+ * - `uuidFilename` — UUID-based, collision-free (same as `uniqueFileNames: true`)
52
+ * - `seoFilename` — Human-readable from alt text + timestamp
53
+ *
54
+ * When set, `uniqueFileNames` is ignored.
55
+ *
56
+ * @example
57
+ * ```ts
58
+ * import { imageOptimizer, seoFilename } from '@inoo-ch/payload-image-optimizer'
59
+ *
60
+ * imageOptimizer({
61
+ * collections: { media: true },
62
+ * generateFilename: seoFilename,
63
+ * })
64
+ * ```
65
+ */
66
+ generateFilename?: GenerateFilename
24
67
  generateThumbHash?: boolean
25
68
  maxDimensions?: { width: number; height: number }
69
+ /** Show the "Regenerate All Images" button in the collection list view.
70
+ * Defaults to `true`. */
71
+ regenerateButton?: boolean
26
72
  replaceOriginal?: boolean
27
73
  stripMetadata?: boolean
28
74
  /** Replace original filenames with UUIDs (e.g., `photo.jpg` → `a1b2c3d4.webp`).
29
75
  * Prevents Vercel Blob "already exists" errors and avoids leaking original filenames.
30
- * Defaults to `false`. */
76
+ * Defaults to `false`.
77
+ * @deprecated Use `generateFilename: uuidFilename` instead. */
31
78
  uniqueFileNames?: boolean
32
79
  }
33
80
 
@@ -43,8 +90,10 @@ export type ResolvedImageOptimizerConfig = Required<
43
90
  clientOptimization: boolean
44
91
  collections: ImageOptimizerConfig['collections']
45
92
  disabled: boolean
93
+ /** Resolved filename generator. `undefined` means keep original filename. */
94
+ generateFilename?: GenerateFilename
95
+ regenerateButton: boolean
46
96
  replaceOriginal: boolean
47
- uniqueFileNames: boolean
48
97
  }
49
98
 
50
99
  export type ImageOptimizerData = {
@@ -0,0 +1,61 @@
1
+ import crypto from 'crypto'
2
+ import path from 'path'
3
+
4
+ import type { GenerateFilenameArgs } from '../types.js'
5
+ import { stripDiacritics } from './stripDiacritics.js'
6
+ import { toKebabCase } from './toKebabCase.js'
7
+
8
+ const MAX_STEM_LENGTH = 60
9
+
10
+ /**
11
+ * UUID-based filename strategy.
12
+ *
13
+ * Generates collision-free filenames like `a1b2c3d4-e5f6-7890-abcd-ef1234567890`.
14
+ * On re-uploads (focal point / crop changes), reuses the existing filename stem
15
+ * to avoid unnecessary file churn on cloud storage.
16
+ */
17
+ export const uuidFilename = ({ existingFilename }: GenerateFilenameArgs): string => {
18
+ if (existingFilename) {
19
+ return path.parse(existingFilename).name
20
+ }
21
+ return crypto.randomUUID()
22
+ }
23
+
24
+ /**
25
+ * SEO-friendly filename strategy.
26
+ *
27
+ * Generates human-readable, URL-safe filenames from alt text:
28
+ * "Geländer aus Edelstahl" → `gelander-aus-edelstahl-20260327T120000Z`
29
+ *
30
+ * Processing pipeline:
31
+ * 1. Uses alt text, falls back to original filename stem, then "media"
32
+ * 2. Strips diacritics (ä→a, ö→o, ü→u, é→e)
33
+ * 3. Converts to kebab-case
34
+ * 4. Truncates to 60 characters (clean break, no trailing hyphens)
35
+ * 5. Appends ISO timestamp for uniqueness (YYYYMMDDTHHMMSSmmm)
36
+ *
37
+ * On re-uploads, reuses the existing filename stem to avoid cloud storage churn.
38
+ */
39
+ export const seoFilename = ({
40
+ altText,
41
+ existingFilename,
42
+ originalFilename,
43
+ }: GenerateFilenameArgs): string => {
44
+ if (existingFilename) {
45
+ return path.parse(existingFilename).name
46
+ }
47
+
48
+ const source = altText?.trim() || path.parse(originalFilename).name || 'media'
49
+ const slug = toKebabCase(stripDiacritics(source))
50
+
51
+ // Truncate cleanly — don't leave a trailing hyphen
52
+ const truncated = slug.length > MAX_STEM_LENGTH
53
+ ? slug.slice(0, MAX_STEM_LENGTH).replace(/-$/, '')
54
+ : slug
55
+
56
+ // Append timestamp for uniqueness (ISO-ish, no colons for filesystem safety)
57
+ const now = new Date()
58
+ const timestamp = now.toISOString().replace(/[-:]/g, '').replace(/\.\d{3}Z$/, 'Z')
59
+
60
+ return `${truncated || 'media'}-${timestamp}`
61
+ }
@@ -0,0 +1,10 @@
1
+ /**
2
+ * Strip diacritics (combining marks) from a string using Unicode NFKD normalization.
3
+ *
4
+ * Examples: ä→a, ö→o, ü→u, é→e, ñ→n
5
+ *
6
+ * Note: This maps ä→a (not ä→ae). For German, the ae/oe/ue transliteration
7
+ * is sometimes preferred for SEO — but plain ASCII is simpler and works well for URLs.
8
+ */
9
+ export const stripDiacritics = (input: string): string =>
10
+ input.normalize('NFKD').replace(/[\u0300-\u036f]/g, '')