@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.
- package/dist/components/RegenerationButton.js +85 -21
- package/dist/components/RegenerationButton.js.map +1 -1
- package/dist/defaults.js +14 -2
- package/dist/defaults.js.map +1 -1
- package/dist/endpoints/regenerate.d.ts +1 -0
- package/dist/endpoints/regenerate.js +77 -1
- package/dist/endpoints/regenerate.js.map +1 -1
- package/dist/hooks/beforeChange.js +15 -18
- package/dist/hooks/beforeChange.js.map +1 -1
- package/dist/index.d.ts +2 -1
- package/dist/index.js +32 -5
- package/dist/index.js.map +1 -1
- package/dist/tasks/regenerateDocument.js +27 -4
- package/dist/tasks/regenerateDocument.js.map +1 -1
- package/dist/types.d.ts +49 -2
- package/dist/types.js.map +1 -1
- package/dist/utilities/filenameStrategies.d.ts +25 -0
- package/dist/utilities/filenameStrategies.js +46 -0
- package/dist/utilities/filenameStrategies.js.map +1 -0
- package/dist/utilities/stripDiacritics.d.ts +9 -0
- package/dist/utilities/stripDiacritics.js +10 -0
- package/dist/utilities/stripDiacritics.js.map +1 -0
- package/dist/utilities/toKebabCase.d.ts +10 -0
- package/dist/utilities/toKebabCase.js +11 -0
- package/dist/utilities/toKebabCase.js.map +1 -0
- package/package.json +1 -1
- package/src/components/RegenerationButton.tsx +92 -24
- package/src/defaults.ts +15 -1
- package/src/endpoints/regenerate.ts +68 -0
- package/src/hooks/beforeChange.ts +15 -18
- package/src/index.ts +27 -6
- package/src/tasks/regenerateDocument.ts +24 -4
- package/src/types.ts +51 -2
- package/src/utilities/filenameStrategies.ts +61 -0
- package/src/utilities/stripDiacritics.ts +10 -0
- 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
|
|
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
|
-
|
|
227
|
-
? Math.round((
|
|
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:
|
|
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:
|
|
308
|
+
cursor: 'pointer',
|
|
263
309
|
}}
|
|
264
310
|
>
|
|
265
|
-
{
|
|
266
|
-
?
|
|
267
|
-
:
|
|
268
|
-
|
|
269
|
-
|
|
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
|
-
{
|
|
431
|
+
{Math.max(batchProcessed, 0)} / {batchTotal} complete
|
|
364
432
|
</span>
|
|
365
|
-
{
|
|
366
|
-
<span style={{ color: '#ef4444' }}>{
|
|
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: `${
|
|
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
|
-
{
|
|
455
|
+
{batchErrored > 0 && (
|
|
388
456
|
<div
|
|
389
457
|
style={{
|
|
390
458
|
height: '100%',
|
|
391
|
-
width: `${
|
|
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 &&
|
|
469
|
+
{!isRunning && !stalled && !cancelled && progress && batchProcessed > 0 && queued !== 0 && !confirming && (
|
|
402
470
|
<span style={{ fontSize: '13px' }}>
|
|
403
|
-
<span style={{ color:
|
|
404
|
-
Done! {
|
|
471
|
+
<span style={{ color: batchErrored > 0 ? '#f59e0b' : '#10b981' }}>
|
|
472
|
+
Done! {Math.max(batchProcessed - batchErrored, 0)}/{batchTotal} optimized.
|
|
405
473
|
</span>
|
|
406
|
-
{
|
|
474
|
+
{batchErrored > 0 && (
|
|
407
475
|
<span style={{ color: '#ef4444' }}>
|
|
408
|
-
{' '}{
|
|
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
|
-
//
|
|
20
|
-
//
|
|
21
|
-
//
|
|
22
|
-
|
|
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
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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
|
-
|
|
73
|
-
|
|
74
|
-
|
|
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
|
|
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.
|
|
92
|
+
if (resolvedConfig.generateFilename) {
|
|
81
93
|
const ext = path.extname(newFilename)
|
|
82
|
-
|
|
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, '')
|