@jhb.software/payload-alt-text-plugin 0.10.0 → 0.11.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/README.md CHANGED
@@ -6,11 +6,12 @@ A [Payload CMS](https://payloadcms.com/) plugin that adds AI-powered alt text ge
6
6
 
7
7
  - Generate alt text for images using AI in the Payload Admin UI
8
8
  - Supports any AI provider using a resolver pattern (e.g., OpenAI, Anthropic, etc.)
9
- - Comes with ready-to-use OpenAI and Mistral resolvers out of the box
9
+ - Comes with ready-to-use OpenAI, Anthropic and Mistral resolvers out of the box
10
10
  - Automatic keyword extraction for improved admin search
11
11
  - Bulk generation for processing multiple images at once
12
12
  - Full localization support
13
13
  - Dashboard health widget with cached coverage insights across all configured upload collections
14
+ - Multi-tenant aware: the health report can be scoped to the tenant the request is for
14
15
 
15
16
  When the plugin is enabled for an upload collection, it will:
16
17
 
@@ -77,19 +78,19 @@ This is also the recommended escape hatch if you hit Payload's Postgres SQL-buil
77
78
 
78
79
  ### Plugin Options
79
80
 
80
- | Option | Type | Required | Description |
81
- | ---------------------------- | ------------------------------------------ | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
82
- | `collections` | `(CollectionSlug \| CollectionObj)[]` | Yes | Collections to enable alt text generation for (see [Per-collection options](#per-collection-options)) |
83
- | `resolver` | `AltTextResolver` | Yes | Alt text resolver to use (e.g., `openAIResolver`) |
84
- | `getImageThumbnail` | `Function` | Yes | Function to get the thumbnail URL from an image document |
85
- | `enabled` | `boolean` | No | Whether to enable the plugin |
86
- | `access` | `({ req }) => boolean \| Promise<boolean>` | No | Access control for the plugin's REST endpoints. Defaults to `({ req }) => !!req.user` (any authenticated user) — see [Authentication](#authentication) |
87
- | `locale` | `string` | No | Locale for alt text generation (required when localization is disabled) |
88
- | `maxBulkGenerateConcurrency` | `number` | No | Maximum concurrent API requests for bulk operations (default: 16) |
89
- | `maxBulkGenerateIds` | `number` | No | Maximum number of image IDs accepted per bulk generate request; larger requests are rejected with `400`. Duplicate IDs are collapsed before the limit is applied (default: 100) |
90
- | `fieldsOverride` | `Function` | No | Override the default fields inserted by the plugin |
91
- | `healthCheck` | `boolean \| Function` | No | Alt text health tracking (REST endpoint, cache revalidation hooks, dashboard widget). `false` disables it; `true` enables it gated by `access`; a `({ req }) => boolean` function enables it and gates both the endpoint and the widget use it to restrict the collection-wide report, e.g. to admins (default: `true`) |
92
- | `imageThumbnailMimeType` | `string` | No | The MIME type `getImageThumbnail` delivers. Set it when your thumbnail URL transcodes the image, so the stored format no longer decides whether generation is possible (see [Transcoding thumbnails](#transcoding-thumbnails)) |
81
+ | Option | Type | Required | Description |
82
+ | ---------------------------- | ------------------------------------------ | -------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
83
+ | `collections` | `(CollectionSlug \| CollectionObj)[]` | Yes | Collections to enable alt text generation for (see [Per-collection options](#per-collection-options)) |
84
+ | `resolver` | `AltTextResolver` | Yes | Alt text resolver to use (e.g., `openAIResolver`) |
85
+ | `getImageThumbnail` | `Function` | Yes | Function to get the thumbnail URL from an image document |
86
+ | `enabled` | `boolean` | No | Whether to enable the plugin |
87
+ | `access` | `({ req }) => boolean \| Promise<boolean>` | No | Access control for the plugin's REST endpoints. Defaults to `({ req }) => !!req.user` (any authenticated user) — see [Authentication](#authentication) |
88
+ | `locale` | `string` | No | Locale for alt text generation (required when localization is disabled) |
89
+ | `maxBulkGenerateConcurrency` | `number` | No | Maximum concurrent API requests for bulk operations (default: 16) |
90
+ | `maxBulkGenerateIds` | `number` | No | Maximum number of image IDs accepted per bulk generate request; larger requests are rejected with `400`. Duplicate IDs are collapsed before the limit is applied (default: 100) |
91
+ | `fieldsOverride` | `Function` | No | Override the default fields inserted by the plugin |
92
+ | `healthCheck` | `boolean \| AltTextHealthCheckConfig` | No | Alt text health tracking (REST endpoint, cache revalidation hooks, dashboard widget). `false` disables it; `true` enables it for every document, gated by `access`; an object enables it and configures its `access` gate and `baseFilter` (see [Health report](#dashboard-widget)) (default: `true`) |
93
+ | `imageThumbnailMimeType` | `string` | No | The MIME type `getImageThumbnail` delivers. Set it when your thumbnail URL transcodes the image, so the stored format no longer decides whether generation is possible (see [Transcoding thumbnails](#transcoding-thumbnails)) |
93
94
 
94
95
  `getImageThumbnail` receives the document and `{ collection, req }`, so a single function can build different URLs per collection:
95
96
 
@@ -205,6 +206,32 @@ buildConfig({
205
206
 
206
207
  Set `healthCheck: false` in the plugin config to disable the REST endpoint, cache revalidation hooks, and dashboard widget. If your project replaces the default dashboard via `admin.components.views.dashboard`, you need to integrate the widget into your custom dashboard yourself.
207
208
 
209
+ #### Gating and scoping the report
210
+
211
+ `healthCheck` also takes an object. The main use of `baseFilter` is multi-tenancy: scope the report to the tenant selected in the admin panel, whose id [@payloadcms/plugin-multi-tenant](https://payloadcms.com/docs/plugins/multi-tenant) keeps in the `payload-tenant` cookie.
212
+
213
+ ```ts
214
+ import { getTenantFromCookie } from '@payloadcms/plugin-multi-tenant/utilities'
215
+
216
+ healthCheck: {
217
+ // Restrict the collection-wide report more strictly than the per-document
218
+ // generate endpoints. Gates the REST endpoint and hides the widget.
219
+ access: ({ req }) => req.user?.role === 'admin',
220
+ // Narrow what the report counts, e.g. to the tenant selected in the admin panel.
221
+ baseFilter: ({ collection, req }) => {
222
+ const tenant = getTenantFromCookie(req.headers, req.payload.db.defaultIDType)
223
+
224
+ return tenant ? { tenant: { equals: tenant } } : {}
225
+ },
226
+ }
227
+ ```
228
+
229
+ `baseFilter` returns a `Where` that is ANDed onto the scan's MIME type filter. It is resolved once per configured collection, so a collection that does not carry the constraining field — a media library shared across tenants, say — can return `{}` and be scanned whole. Returning `{}` for every collection is the default behaviour.
230
+
231
+ The scan is cached across requests, and its cache key is derived from the resolved filters: a narrowed scan always gets its own cache entry, so one tenant's counts can never be served to another. Cache invalidation stays per collection, so a write in one tenant refreshes the report for all of them.
232
+
233
+ This scopes what the report counts, not who may see it — use `access` for that. Independently of both, the report always omits the collections the requesting user cannot read.
234
+
208
235
  #### Skipping cache revalidation for individual writes
209
236
 
210
237
  The plugin invalidates the cached health scan via `afterChange` and `afterDelete` hooks. For writes that don't need to invalidate the cache — typically seed data created from `payload.onInit`, batch imports, or migrations — pass `context: { disableRevalidate: true }` to skip the revalidation:
@@ -238,8 +265,10 @@ openAIResolver({
238
265
  | -------------------- | ---------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
239
266
  | `apiKey` | `string` | Yes | API key for authentication |
240
267
  | `model` | `string` | No | Model to use (default: `gpt-4.1-nano`) |
241
- | `baseUrl` | `string` | No | Base URL for an OpenAI-compatible provider (e.g. Nebius, Azure) |
268
+ | `baseUrl` | `string` | No | Base URL for an OpenAI-compatible provider, version segment included (default: `https://api.openai.com/v1`; e.g. Nebius, Azure) |
242
269
  | `supportedMimeTypes` | `string[]` | No | Image formats the provider accepts (default: `['image/jpeg', 'image/png', 'image/gif', 'image/webp']`, per OpenAI's vision docs). Override it when using a `baseUrl` whose provider differs |
270
+ | `timeoutMs` | `number` | No | Abort after this many milliseconds, retries included (default: `30000`) |
271
+ | `instructions` | `function` | No | Customizes the prompt, see [Customizing the instructions](#customizing-the-instructions) |
243
272
 
244
273
  #### Mistral Resolver
245
274
 
@@ -263,11 +292,128 @@ Because there is no image conversion step, `supportedMimeTypes` is limited to
263
292
  what the Mistral API accepts directly: JPEG, PNG, GIF and WebP. Documents in
264
293
  other formats — SVG or AVIF, for instance — keep their generate button disabled.
265
294
 
295
+ | Option | Type | Required | Description |
296
+ | -------------- | ---------- | -------- | ---------------------------------------------------------------------------------------- |
297
+ | `apiKey` | `string` | Yes | API key for authentication |
298
+ | `model` | `string` | No | Model to use (default: `mistral-medium-latest`) |
299
+ | `baseUrl` | `string` | No | Base URL of the Mistral API (default: `https://api.mistral.ai/v1`) |
300
+ | `timeoutMs` | `number` | No | Abort after this many milliseconds, image download included (default: `30000`) |
301
+ | `instructions` | `function` | No | Customizes the prompt, see [Customizing the instructions](#customizing-the-instructions) |
302
+
303
+ #### Anthropic Resolver
304
+
305
+ ```ts
306
+ import { anthropicResolver } from '@jhb.software/payload-alt-text-plugin'
307
+
308
+ anthropicResolver({
309
+ apiKey: process.env.ANTHROPIC_API_KEY,
310
+ model: 'claude-opus-5', // default; `claude-sonnet-5` is cheaper for a large library
311
+ effort: 'low', // optional; describing an image needs little thinking
312
+ })
313
+ ```
314
+
315
+ Like the Mistral resolver, this one downloads the image and sends the bytes.
316
+ Claude can fetch an image URL itself, but that requires the file to be reachable
317
+ from the public internet, which is never the case in local development and not
318
+ the case for private buckets. Sending the bytes also supplies the `media_type`
319
+ that a base64 image block requires and a URL cannot carry.
320
+
321
+ `supportedMimeTypes` is limited to what the Messages API accepts: JPEG, PNG, GIF
322
+ and WebP. Documents in other formats keep their generate button disabled.
323
+
324
+ | Option | Type | Required | Description |
325
+ | -------------- | ------------------------------------------------- | -------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
326
+ | `apiKey` | `string` | Yes | API key for authentication |
327
+ | `model` | `string` | No | Model to use (default: `claude-opus-5`). `claude-sonnet-5` is cheaper; `claude-haiku-4-5` works too, but only without `effort` |
328
+ | `effort` | `'low' \| 'medium' \| 'high' \| 'xhigh' \| 'max'` | No | How long Claude thinks before answering. `low` is plenty for describing an image and keeps the spend down. Omitted, the field is not sent and Claude uses its default (`high`), so models without effort support stay usable |
329
+ | `baseUrl` | `string` | No | Base URL of the Anthropic API (default: `https://api.anthropic.com`) |
330
+ | `timeoutMs` | `number` | No | Abort after this many milliseconds, image download included (default: `30000`) |
331
+ | `instructions` | `function` | No | Customizes the prompt, see [Customizing the instructions](#customizing-the-instructions) |
332
+
333
+ ### Customizing the instructions
334
+
335
+ Every bundled resolver accepts an `instructions` function that receives the
336
+ instructions the resolver would send on its own, so a house style rule can be
337
+ appended without restating the rules the plugin depends on:
338
+
339
+ ```ts
340
+ openAIResolver({
341
+ apiKey: process.env.OPENAI_API_KEY!,
342
+ instructions: ({ defaultInstructions }) =>
343
+ `${defaultInstructions}\n\nName the product line when its packaging is legible. Never guess at a person's role.`,
344
+ })
345
+ ```
346
+
347
+ It is called once per generation and receives `{ defaultInstructions, locales, filename }`.
348
+ Returning something entirely different is allowed — the image and the required
349
+ response shape travel separately from the instructions, so a replacement cannot
350
+ break the resolver's contract with its provider.
351
+
266
352
  ## Custom Resolver
267
353
 
268
- You can create your own resolver by implementing the `AltTextResolver` interface.
354
+ For another LLM provider, `createVisionResolver` is usually the shortest path: it
355
+ owns the prompt, the per-locale response schema, the optional image download and
356
+ the strict reading of the response, leaving only the provider call to `generate`.
357
+ Every bundled resolver is built on it.
358
+
359
+ ```ts
360
+ import { createVisionResolver, VisionProviderError } from '@jhb.software/payload-alt-text-plugin'
361
+
362
+ export const myResolver = ({ apiKey }: { apiKey: string }) =>
363
+ createVisionResolver({
364
+ apiKey,
365
+ // `image` is only present when `inlineImage` is set; otherwise pass
366
+ // `imageThumbnailUrl` to the provider and let it fetch the file.
367
+ generate: async ({ image, instructions, maxTokens, responseSchema, signal }) => {
368
+ if (!image) {
369
+ throw new Error('The image was not downloaded')
370
+ }
371
+
372
+ const response = await fetch('https://api.example.com/v1/vision', {
373
+ body: JSON.stringify({ instructions, image: image.dataUri, schema: responseSchema }),
374
+ headers: { Authorization: `Bearer ${apiKey}` },
375
+ method: 'POST',
376
+ signal,
377
+ })
378
+
379
+ // A rate limit or an outage is worth another attempt: throwing
380
+ // `VisionProviderError` lets the factory retry it. Any other error fails
381
+ // the generation immediately, with its message shown in the admin panel.
382
+ if (!response.ok) {
383
+ throw new VisionProviderError({
384
+ body: await response.text(),
385
+ label: 'My Provider',
386
+ status: response.status,
387
+ })
388
+ }
389
+
390
+ // Return the parsed JSON object.
391
+ return await response.json()
392
+ },
393
+ inlineImage: true,
394
+ key: 'my-provider',
395
+ label: 'My Provider',
396
+ supportedMimeTypes: ['image/jpeg', 'image/png', 'image/webp'],
397
+ })
398
+ ```
399
+
400
+ A provider call that fails with `VisionProviderError` is retried twice, with a
401
+ short backoff, when the status is a rate limit (`429`) or a server-side failure
402
+ (`5xx`) — a bulk generation trips those routinely, and giving up on the first one
403
+ leaves documents without an alt text. Any other status fails immediately: a `4xx`
404
+ would fail identically on every attempt. The resolver's `timeoutMs` covers the
405
+ attempts together.
406
+
407
+ Pass the provider's response as `body` and it is written to the server log, never
408
+ to the error the admin panel shows — that message reaches everyone allowed to
409
+ generate an alt text, and a provider's error text is not written with them in
410
+ mind: OpenAI quotes the rejected API key back in a 401. The panel gets the
411
+ provider name and the status.
412
+
413
+ For a provider that does not fit that shape at all, implement the
414
+ `AltTextResolver` interface directly.
269
415
 
270
- Alongside `imageThumbnailUrl`, the resolver receives `imageThumbnailMimeType` — the format served at that URL, when the collection declares one via [`imageThumbnailMimeType`](#transcoding-thumbnails). Resolvers that hand the URL to the provider can ignore it. Resolvers that inline the bytes need it, because an explicit media type cannot be sniffed from a URL: Anthropic image blocks require `media_type` and Gemini's `inline_data` requires `mime_type`. It is `undefined` when nothing was declared.
416
+ Alongside `imageThumbnailUrl`, the resolver receives `imageThumbnailMimeType` — the format served at that URL, when the collection declares one via [`imageThumbnailMimeType`](#transcoding-thumbnails). Resolvers that hand the URL to the provider can ignore it. Resolvers that inline the bytes need it, because an explicit media type cannot be sniffed from a URL: Anthropic image blocks require `media_type` and Gemini's `inline_data` requires `mime_type`. It is `undefined` when nothing was declared. Resolvers built on `createVisionResolver` get this for free: `image.mediaType` is the type the host actually served, with the declaration standing in when the host sent none or a generic `application/octet-stream`.
271
417
 
272
418
  ```ts
273
419
  import type { AltTextResolver } from '@jhb.software/payload-alt-text-plugin'
@@ -314,7 +460,7 @@ That default fits a setup where every Payload user is trusted staff. Generating
314
460
  access: ({ req }) => req.user?.role === 'editor'
315
461
  ```
316
462
 
317
- Beyond that gate, the generate endpoints enforce each collection's own access control on the documents they read and write, and the health endpoint reports only the collections the requesting user can read (and can be gated separately via the `healthCheck` function).
463
+ Beyond that gate, the generate endpoints enforce each collection's own access control on the documents they read and write, and the health endpoint reports only the collections the requesting user can read (and can be gated separately via `healthCheck.access`).
318
464
 
319
465
  ### `POST /api/alt-text/generate`
320
466
 
@@ -85,7 +85,7 @@ import { bulkGenerateAltTextsRequestSchema, formatZodError } from './schemas.js'
85
85
  req
86
86
  });
87
87
  updatedDocs++;
88
- console.log(`${updatedDocs}/${uniqueIds.length} updated (${Math.round(updatedDocs / uniqueIds.length * 100)}%)`);
88
+ req.payload.logger.info(`${updatedDocs}/${uniqueIds.length} updated (${Math.round(updatedDocs / uniqueIds.length * 100)}%)`);
89
89
  } catch (error) {
90
90
  // A Forbidden means the user has no read/update access to the
91
91
  // collection at all — it applies to every id, so fail the whole
@@ -94,14 +94,16 @@ import { bulkGenerateAltTextsRequestSchema, formatZodError } from './schemas.js'
94
94
  if (error instanceof Forbidden) {
95
95
  throw error;
96
96
  }
97
- console.error(`Error generating alt text for ${id}:`, error);
97
+ req.payload.logger.error({
98
+ err: error
99
+ }, `Error generating alt text for ${id}`);
98
100
  erroredDocs.push(id);
99
101
  }
100
102
  }, {
101
103
  concurrency
102
104
  });
103
105
  if (erroredDocs.length > 0) {
104
- console.error(`Failed for: ${erroredDocs.join(', ')}`);
106
+ req.payload.logger.error(`Failed for: ${erroredDocs.join(', ')}`);
105
107
  }
106
108
  return Response.json({
107
109
  erroredDocs,
@@ -123,7 +125,9 @@ import { bulkGenerateAltTextsRequestSchema, formatZodError } from './schemas.js'
123
125
  status: error.status
124
126
  });
125
127
  }
126
- console.error('Error in bulk generation:', error);
128
+ req.payload.logger.error({
129
+ err: error
130
+ }, 'Error in bulk generation');
127
131
  return Response.json({
128
132
  error: `Error generating alt text: ${error instanceof Error ? error.message : 'Unknown error'}`
129
133
  }, {
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/endpoints/bulkGenerateAltTexts.ts"],"sourcesContent":["import type { BasePayload, CollectionSlug, PayloadHandler, PayloadRequest } from 'payload'\n\nimport pMap from 'p-map'\nimport { APIError, Forbidden } from 'payload'\nimport { ZodError } from 'zod'\n\nimport type { AltTextPluginConfig } from '../types/AltTextPluginConfig.js'\n\nimport { localesFromConfig } from '../utilities/localesFromConfig.js'\nimport { getUnsupportedSourceMimeTypeError, matchesMimeType } from '../utilities/mimeTypes.js'\nimport { bulkGenerateAltTextsRequestSchema, formatZodError } from './schemas.js'\n\n/**\n * Generates and updates alt text for multiple images in all locales.\n */\nexport const bulkGenerateAltTextsEndpoint =\n (access: AltTextPluginConfig['access']): PayloadHandler =>\n async (req: PayloadRequest) => {\n try {\n if (!(await access({ req }))) {\n return Response.json({ error: 'Unauthorized' }, { status: 401 })\n }\n\n const data = 'json' in req && typeof req.json === 'function' ? await req.json() : null\n\n const { collection, ids } = bulkGenerateAltTextsRequestSchema.parse(data)\n\n let updatedDocs = 0\n const erroredDocs: (number | string)[] = []\n\n // Get plugin config from payload config\n const pluginConfig = req.payload.config.custom?.altTextPluginConfig as\n AltTextPluginConfig | undefined\n\n if (!pluginConfig) {\n return Response.json({ error: 'Plugin config not found' }, { status: 500 })\n }\n\n // Treat the configured collections as an allowlist. Reject any other\n // collection before touching the Local API, so the endpoint can only ever\n // operate on the upload collections the plugin manages.\n const collectionConfig = pluginConfig.collections.find((entry) => entry.slug === collection)\n\n if (!collectionConfig) {\n return Response.json(\n { error: `Collection \"${collection}\" is not managed by the alt text plugin.` },\n { status: 403 },\n )\n }\n\n if (!pluginConfig.resolver) {\n return Response.json({ error: 'No alt text resolver configured' }, { status: 500 })\n }\n\n const concurrency = pluginConfig.maxBulkGenerateConcurrency\n\n // De-duplicate so the same image is never generated (and billed) twice,\n // then bound the batch so a single request cannot fan out into an\n // unbounded number of paid resolver calls.\n const uniqueIds = [...new Set(ids)]\n\n if (uniqueIds.length > pluginConfig.maxBulkGenerateIds) {\n return Response.json(\n {\n error: `Too many ids: ${uniqueIds.length} exceeds the maximum of ${pluginConfig.maxBulkGenerateIds} per request.`,\n },\n { status: 400 },\n )\n }\n\n // determine target locales based on config\n const locales = localesFromConfig(req.payload.config)\n const targetLocales = locales ?? [pluginConfig.locale!]\n if (!targetLocales) {\n return Response.json(\n {\n error:\n 'Could not determine target locales for alt text generation. Please check your plugin configuration.',\n },\n { status: 500 },\n )\n }\n\n await pMap(\n uniqueIds,\n async (id) => {\n try {\n await generateAndUpdateAltText({\n id,\n collection,\n locales: targetLocales,\n payload: req.payload,\n pluginConfig,\n req,\n })\n updatedDocs++\n console.log(\n `${updatedDocs}/${uniqueIds.length} updated (${Math.round((updatedDocs / uniqueIds.length) * 100)}%)`,\n )\n } catch (error) {\n // A Forbidden means the user has no read/update access to the\n // collection at all — it applies to every id, so fail the whole\n // request with a real 403 instead of silently listing all ids as\n // errored. Row-level NotFound stays a per-doc error (partial success).\n if (error instanceof Forbidden) {\n throw error\n }\n console.error(`Error generating alt text for ${id}:`, error)\n erroredDocs.push(id)\n }\n },\n { concurrency },\n )\n\n if (erroredDocs.length > 0) {\n console.error(`Failed for: ${erroredDocs.join(', ')}`)\n }\n\n return Response.json({\n erroredDocs,\n totalDocs: uniqueIds.length,\n updatedDocs,\n })\n } catch (error) {\n if (error instanceof ZodError) {\n return Response.json(formatZodError(error), { status: 400 })\n }\n // Surface Payload access errors (Forbidden 403) with their real status so\n // an agent gets an accurate, non-retryable signal instead of a 500.\n if (error instanceof APIError) {\n return Response.json({ error: error.message }, { status: error.status })\n }\n console.error('Error in bulk generation:', error)\n return Response.json(\n {\n error: `Error generating alt text: ${error instanceof Error ? error.message : 'Unknown error'}`,\n },\n { status: 500 },\n )\n }\n }\n\nasync function generateAndUpdateAltText({\n id,\n collection,\n locales,\n payload,\n pluginConfig,\n req,\n}: {\n collection: CollectionSlug\n id: number | string\n locales: string[]\n payload: BasePayload\n pluginConfig: AltTextPluginConfig\n req: PayloadRequest\n}) {\n const imageDoc = await payload.findByID({\n id,\n collection,\n depth: 0,\n // Run under the requesting user's access, not Payload's default\n // `overrideAccess: true`, so collection-level access control applies.\n overrideAccess: false,\n user: req.user,\n })\n\n if (!imageDoc) {\n throw new Error('Image not found')\n }\n\n const mimeType =\n 'mimeType' in imageDoc && typeof imageDoc.mimeType === 'string' ? imageDoc.mimeType : undefined\n\n // The handler validates `collection` against the configured collections before\n // reaching this helper, so a matching entry is guaranteed.\n const collectionConfig = pluginConfig.collections.find((entry) => entry.slug === collection)!\n\n if (mimeType && !matchesMimeType(mimeType, collectionConfig.mimeTypes)) {\n throw new Error(\n `Alt text is not tracked for files of type \"${mimeType}\" in the \"${collection}\" collection. Tracked types: ${collectionConfig.mimeTypes.join(', ')}.`,\n )\n }\n\n const unsupportedSourceError = getUnsupportedSourceMimeTypeError({\n declaredThumbnailMimeType: collectionConfig.imageThumbnailMimeType,\n mimeType,\n supportedMimeTypes: pluginConfig.resolver.supportedMimeTypes,\n })\n if (unsupportedSourceError) {\n throw new Error(unsupportedSourceError)\n }\n\n const imageThumbnailUrl = await pluginConfig.getImageThumbnail(imageDoc, { collection, req })\n\n const result = await pluginConfig.resolver.resolveBulk({\n filename:\n 'filename' in imageDoc && typeof imageDoc.filename === 'string'\n ? imageDoc.filename\n : undefined,\n imageThumbnailMimeType: collectionConfig.imageThumbnailMimeType,\n imageThumbnailUrl,\n locales,\n req,\n })\n\n if (!result.success) {\n throw new Error(result.error || 'Failed to generate alt text')\n }\n\n for (const locale of locales) {\n const localeResult = result.results[locale]\n if (localeResult) {\n await payload.update({\n id,\n collection,\n data: {\n alt: localeResult.altText,\n keywords: localeResult.keywords,\n },\n locale,\n // Run under the requesting user's access, not Payload's default\n // `overrideAccess: true`, so collection-level access control applies.\n overrideAccess: false,\n user: req.user,\n })\n }\n }\n}\n"],"names":["pMap","APIError","Forbidden","ZodError","localesFromConfig","getUnsupportedSourceMimeTypeError","matchesMimeType","bulkGenerateAltTextsRequestSchema","formatZodError","bulkGenerateAltTextsEndpoint","access","req","Response","json","error","status","data","collection","ids","parse","updatedDocs","erroredDocs","pluginConfig","payload","config","custom","altTextPluginConfig","collectionConfig","collections","find","entry","slug","resolver","concurrency","maxBulkGenerateConcurrency","uniqueIds","Set","length","maxBulkGenerateIds","locales","targetLocales","locale","id","generateAndUpdateAltText","console","log","Math","round","push","join","totalDocs","message","Error","imageDoc","findByID","depth","overrideAccess","user","mimeType","undefined","mimeTypes","unsupportedSourceError","declaredThumbnailMimeType","imageThumbnailMimeType","supportedMimeTypes","imageThumbnailUrl","getImageThumbnail","result","resolveBulk","filename","success","localeResult","results","update","alt","altText","keywords"],"mappings":"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"}
1
+ {"version":3,"sources":["../../src/endpoints/bulkGenerateAltTexts.ts"],"sourcesContent":["import type { BasePayload, CollectionSlug, PayloadHandler, PayloadRequest } from 'payload'\n\nimport pMap from 'p-map'\nimport { APIError, Forbidden } from 'payload'\nimport { ZodError } from 'zod'\n\nimport type { AltTextPluginConfig } from '../types/AltTextPluginConfig.js'\n\nimport { localesFromConfig } from '../utilities/localesFromConfig.js'\nimport { getUnsupportedSourceMimeTypeError, matchesMimeType } from '../utilities/mimeTypes.js'\nimport { bulkGenerateAltTextsRequestSchema, formatZodError } from './schemas.js'\n\n/**\n * Generates and updates alt text for multiple images in all locales.\n */\nexport const bulkGenerateAltTextsEndpoint =\n (access: AltTextPluginConfig['access']): PayloadHandler =>\n async (req: PayloadRequest) => {\n try {\n if (!(await access({ req }))) {\n return Response.json({ error: 'Unauthorized' }, { status: 401 })\n }\n\n const data = 'json' in req && typeof req.json === 'function' ? await req.json() : null\n\n const { collection, ids } = bulkGenerateAltTextsRequestSchema.parse(data)\n\n let updatedDocs = 0\n const erroredDocs: (number | string)[] = []\n\n // Get plugin config from payload config\n const pluginConfig = req.payload.config.custom?.altTextPluginConfig as\n AltTextPluginConfig | undefined\n\n if (!pluginConfig) {\n return Response.json({ error: 'Plugin config not found' }, { status: 500 })\n }\n\n // Treat the configured collections as an allowlist. Reject any other\n // collection before touching the Local API, so the endpoint can only ever\n // operate on the upload collections the plugin manages.\n const collectionConfig = pluginConfig.collections.find((entry) => entry.slug === collection)\n\n if (!collectionConfig) {\n return Response.json(\n { error: `Collection \"${collection}\" is not managed by the alt text plugin.` },\n { status: 403 },\n )\n }\n\n if (!pluginConfig.resolver) {\n return Response.json({ error: 'No alt text resolver configured' }, { status: 500 })\n }\n\n const concurrency = pluginConfig.maxBulkGenerateConcurrency\n\n // De-duplicate so the same image is never generated (and billed) twice,\n // then bound the batch so a single request cannot fan out into an\n // unbounded number of paid resolver calls.\n const uniqueIds = [...new Set(ids)]\n\n if (uniqueIds.length > pluginConfig.maxBulkGenerateIds) {\n return Response.json(\n {\n error: `Too many ids: ${uniqueIds.length} exceeds the maximum of ${pluginConfig.maxBulkGenerateIds} per request.`,\n },\n { status: 400 },\n )\n }\n\n // determine target locales based on config\n const locales = localesFromConfig(req.payload.config)\n const targetLocales = locales ?? [pluginConfig.locale!]\n if (!targetLocales) {\n return Response.json(\n {\n error:\n 'Could not determine target locales for alt text generation. Please check your plugin configuration.',\n },\n { status: 500 },\n )\n }\n\n await pMap(\n uniqueIds,\n async (id) => {\n try {\n await generateAndUpdateAltText({\n id,\n collection,\n locales: targetLocales,\n payload: req.payload,\n pluginConfig,\n req,\n })\n updatedDocs++\n req.payload.logger.info(\n `${updatedDocs}/${uniqueIds.length} updated (${Math.round((updatedDocs / uniqueIds.length) * 100)}%)`,\n )\n } catch (error) {\n // A Forbidden means the user has no read/update access to the\n // collection at all — it applies to every id, so fail the whole\n // request with a real 403 instead of silently listing all ids as\n // errored. Row-level NotFound stays a per-doc error (partial success).\n if (error instanceof Forbidden) {\n throw error\n }\n req.payload.logger.error({ err: error }, `Error generating alt text for ${id}`)\n erroredDocs.push(id)\n }\n },\n { concurrency },\n )\n\n if (erroredDocs.length > 0) {\n req.payload.logger.error(`Failed for: ${erroredDocs.join(', ')}`)\n }\n\n return Response.json({\n erroredDocs,\n totalDocs: uniqueIds.length,\n updatedDocs,\n })\n } catch (error) {\n if (error instanceof ZodError) {\n return Response.json(formatZodError(error), { status: 400 })\n }\n // Surface Payload access errors (Forbidden 403) with their real status so\n // an agent gets an accurate, non-retryable signal instead of a 500.\n if (error instanceof APIError) {\n return Response.json({ error: error.message }, { status: error.status })\n }\n req.payload.logger.error({ err: error }, 'Error in bulk generation')\n return Response.json(\n {\n error: `Error generating alt text: ${error instanceof Error ? error.message : 'Unknown error'}`,\n },\n { status: 500 },\n )\n }\n }\n\nasync function generateAndUpdateAltText({\n id,\n collection,\n locales,\n payload,\n pluginConfig,\n req,\n}: {\n collection: CollectionSlug\n id: number | string\n locales: string[]\n payload: BasePayload\n pluginConfig: AltTextPluginConfig\n req: PayloadRequest\n}) {\n const imageDoc = await payload.findByID({\n id,\n collection,\n depth: 0,\n // Run under the requesting user's access, not Payload's default\n // `overrideAccess: true`, so collection-level access control applies.\n overrideAccess: false,\n user: req.user,\n })\n\n if (!imageDoc) {\n throw new Error('Image not found')\n }\n\n const mimeType =\n 'mimeType' in imageDoc && typeof imageDoc.mimeType === 'string' ? imageDoc.mimeType : undefined\n\n // The handler validates `collection` against the configured collections before\n // reaching this helper, so a matching entry is guaranteed.\n const collectionConfig = pluginConfig.collections.find((entry) => entry.slug === collection)!\n\n if (mimeType && !matchesMimeType(mimeType, collectionConfig.mimeTypes)) {\n throw new Error(\n `Alt text is not tracked for files of type \"${mimeType}\" in the \"${collection}\" collection. Tracked types: ${collectionConfig.mimeTypes.join(', ')}.`,\n )\n }\n\n const unsupportedSourceError = getUnsupportedSourceMimeTypeError({\n declaredThumbnailMimeType: collectionConfig.imageThumbnailMimeType,\n mimeType,\n supportedMimeTypes: pluginConfig.resolver.supportedMimeTypes,\n })\n if (unsupportedSourceError) {\n throw new Error(unsupportedSourceError)\n }\n\n const imageThumbnailUrl = await pluginConfig.getImageThumbnail(imageDoc, { collection, req })\n\n const result = await pluginConfig.resolver.resolveBulk({\n filename:\n 'filename' in imageDoc && typeof imageDoc.filename === 'string'\n ? imageDoc.filename\n : undefined,\n imageThumbnailMimeType: collectionConfig.imageThumbnailMimeType,\n imageThumbnailUrl,\n locales,\n req,\n })\n\n if (!result.success) {\n throw new Error(result.error || 'Failed to generate alt text')\n }\n\n for (const locale of locales) {\n const localeResult = result.results[locale]\n if (localeResult) {\n await payload.update({\n id,\n collection,\n data: {\n alt: localeResult.altText,\n keywords: localeResult.keywords,\n },\n locale,\n // Run under the requesting user's access, not Payload's default\n // `overrideAccess: true`, so collection-level access control applies.\n overrideAccess: false,\n user: req.user,\n })\n }\n }\n}\n"],"names":["pMap","APIError","Forbidden","ZodError","localesFromConfig","getUnsupportedSourceMimeTypeError","matchesMimeType","bulkGenerateAltTextsRequestSchema","formatZodError","bulkGenerateAltTextsEndpoint","access","req","Response","json","error","status","data","collection","ids","parse","updatedDocs","erroredDocs","pluginConfig","payload","config","custom","altTextPluginConfig","collectionConfig","collections","find","entry","slug","resolver","concurrency","maxBulkGenerateConcurrency","uniqueIds","Set","length","maxBulkGenerateIds","locales","targetLocales","locale","id","generateAndUpdateAltText","logger","info","Math","round","err","push","join","totalDocs","message","Error","imageDoc","findByID","depth","overrideAccess","user","mimeType","undefined","mimeTypes","unsupportedSourceError","declaredThumbnailMimeType","imageThumbnailMimeType","supportedMimeTypes","imageThumbnailUrl","getImageThumbnail","result","resolveBulk","filename","success","localeResult","results","update","alt","altText","keywords"],"mappings":"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"}
@@ -165,7 +165,9 @@ import { formatZodError, generateAltTextRequestSchema } from './schemas.js';
165
165
  status: error.status
166
166
  });
167
167
  }
168
- console.error('Error generating alt text:', error);
168
+ req.payload.logger.error({
169
+ err: error
170
+ }, 'Error generating alt text');
169
171
  return Response.json({
170
172
  error: `Error generating alt text: ${error instanceof Error ? error.message : 'Unknown error'}`
171
173
  }, {
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/endpoints/generateAltText.ts"],"sourcesContent":["import type { PayloadHandler, PayloadRequest } from 'payload'\n\nimport { APIError } from 'payload'\nimport { ZodError } from 'zod'\n\nimport type { AltTextPluginConfig } from '../types/AltTextPluginConfig.js'\n\nimport { getUnsupportedSourceMimeTypeError, matchesMimeType } from '../utilities/mimeTypes.js'\nimport { formatZodError, generateAltTextRequestSchema } from './schemas.js'\n\n/**\n * Generates alt text for a single image using the configured resolver.\n *\n * By default, returns the result without updating the document (preview mode).\n * Pass `update: true` in the request body to also persist the generated alt text\n * and keywords to the document — useful for programmatic/agent workflows.\n *\n * The response always includes the `id` and `collection` for easy correlation.\n */\nexport const generateAltTextEndpoint =\n (access: AltTextPluginConfig['access']): PayloadHandler =>\n async (req: PayloadRequest) => {\n try {\n if (!(await access({ req }))) {\n return Response.json({ error: 'Unauthorized' }, { status: 401 })\n }\n\n const data = 'json' in req && typeof req.json === 'function' ? await req.json() : null\n\n const { id, collection, locale, update } = generateAltTextRequestSchema.parse(data)\n\n const pluginConfig = req.payload.config.custom?.altTextPluginConfig as\n AltTextPluginConfig | undefined\n\n if (!pluginConfig) {\n return Response.json({ error: 'Plugin config not found' }, { status: 500 })\n }\n\n // Treat the configured collections as an allowlist. Reject any other\n // collection before touching the Local API, so the endpoint can only ever\n // operate on the upload collections the plugin manages.\n const collectionConfig = pluginConfig.collections.find((entry) => entry.slug === collection)\n\n if (!collectionConfig) {\n return Response.json(\n { error: `Collection \"${collection}\" is not managed by the alt text plugin.` },\n { status: 403 },\n )\n }\n\n const imageDoc = await req.payload.findByID({\n id,\n collection,\n depth: 0,\n // Run under the requesting user's access, not Payload's default\n // `overrideAccess: true`, so collection-level access control applies.\n overrideAccess: false,\n user: req.user,\n })\n\n if (!imageDoc) {\n return Response.json({ error: 'Image not found' }, { status: 404 })\n }\n\n if (!pluginConfig.getImageThumbnail) {\n return Response.json(\n { error: 'getImageThumbnail function not configured' },\n { status: 500 },\n )\n }\n\n if (!pluginConfig.resolver) {\n return Response.json({ error: 'No alt text resolver configured' }, { status: 500 })\n }\n\n const mimeType =\n 'mimeType' in imageDoc && typeof imageDoc.mimeType === 'string'\n ? imageDoc.mimeType\n : undefined\n\n if (mimeType && !matchesMimeType(mimeType, collectionConfig.mimeTypes)) {\n return Response.json(\n {\n error: `Alt text is not tracked for files of type \"${mimeType}\" in the \"${collection}\" collection. Tracked types: ${collectionConfig.mimeTypes.join(', ')}.`,\n },\n { status: 400 },\n )\n }\n\n const unsupportedSourceError = getUnsupportedSourceMimeTypeError({\n declaredThumbnailMimeType: collectionConfig.imageThumbnailMimeType,\n mimeType,\n supportedMimeTypes: pluginConfig.resolver.supportedMimeTypes,\n })\n if (unsupportedSourceError) {\n return Response.json({ error: unsupportedSourceError }, { status: 400 })\n }\n\n // When localization is enabled, the requested locale must be one of the\n // configured locales. Reject anything else before it can be written to an\n // unconfigured locale or interpolated into the resolver's prompt.\n if (\n locale != null &&\n pluginConfig.locales.length > 0 &&\n !pluginConfig.locales.includes(locale)\n ) {\n return Response.json(\n {\n error: `Locale \"${locale}\" is not configured. Configured locales: ${pluginConfig.locales.join(', ')}.`,\n },\n { status: 400 },\n )\n }\n\n // determine target locale\n const targetLocale = locale ?? pluginConfig.locale\n if (!targetLocale) {\n return Response.json(\n {\n error:\n 'Could not determine target locale for alt text generation. Please check your plugin configuration.',\n },\n { status: 500 },\n )\n }\n\n const imageThumbnailUrl = await pluginConfig.getImageThumbnail(imageDoc, { collection, req })\n\n const result = await pluginConfig.resolver.resolve({\n filename:\n 'filename' in imageDoc && typeof imageDoc.filename === 'string'\n ? imageDoc.filename\n : undefined,\n imageThumbnailMimeType: collectionConfig.imageThumbnailMimeType,\n imageThumbnailUrl,\n locale: targetLocale,\n req,\n })\n\n if (!result.success) {\n return Response.json(\n { error: result.error || 'Failed to generate alt text' },\n { status: 500 },\n )\n }\n\n if (update) {\n await req.payload.update({\n id,\n collection,\n data: {\n alt: result.result.altText,\n keywords: result.result.keywords,\n },\n locale: targetLocale,\n // Run under the requesting user's access, not Payload's default\n // `overrideAccess: true`, so collection-level access control applies.\n overrideAccess: false,\n user: req.user,\n })\n }\n\n return Response.json({ id, collection, ...result.result })\n } catch (error) {\n if (error instanceof ZodError) {\n return Response.json(formatZodError(error), { status: 400 })\n }\n // Surface Payload access errors (Forbidden 403 / NotFound 404) with their\n // real status so an agent gets an accurate, non-retryable signal instead\n // of a misleading 500.\n if (error instanceof APIError) {\n return Response.json({ error: error.message }, { status: error.status })\n }\n console.error('Error generating alt text:', error)\n return Response.json(\n {\n error: `Error generating alt text: ${error instanceof Error ? error.message : 'Unknown error'}`,\n },\n { status: 500 },\n )\n }\n }\n"],"names":["APIError","ZodError","getUnsupportedSourceMimeTypeError","matchesMimeType","formatZodError","generateAltTextRequestSchema","generateAltTextEndpoint","access","req","Response","json","error","status","data","id","collection","locale","update","parse","pluginConfig","payload","config","custom","altTextPluginConfig","collectionConfig","collections","find","entry","slug","imageDoc","findByID","depth","overrideAccess","user","getImageThumbnail","resolver","mimeType","undefined","mimeTypes","join","unsupportedSourceError","declaredThumbnailMimeType","imageThumbnailMimeType","supportedMimeTypes","locales","length","includes","targetLocale","imageThumbnailUrl","result","resolve","filename","success","alt","altText","keywords","message","console","Error"],"mappings":"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"}
1
+ {"version":3,"sources":["../../src/endpoints/generateAltText.ts"],"sourcesContent":["import type { PayloadHandler, PayloadRequest } from 'payload'\n\nimport { APIError } from 'payload'\nimport { ZodError } from 'zod'\n\nimport type { AltTextPluginConfig } from '../types/AltTextPluginConfig.js'\n\nimport { getUnsupportedSourceMimeTypeError, matchesMimeType } from '../utilities/mimeTypes.js'\nimport { formatZodError, generateAltTextRequestSchema } from './schemas.js'\n\n/**\n * Generates alt text for a single image using the configured resolver.\n *\n * By default, returns the result without updating the document (preview mode).\n * Pass `update: true` in the request body to also persist the generated alt text\n * and keywords to the document — useful for programmatic/agent workflows.\n *\n * The response always includes the `id` and `collection` for easy correlation.\n */\nexport const generateAltTextEndpoint =\n (access: AltTextPluginConfig['access']): PayloadHandler =>\n async (req: PayloadRequest) => {\n try {\n if (!(await access({ req }))) {\n return Response.json({ error: 'Unauthorized' }, { status: 401 })\n }\n\n const data = 'json' in req && typeof req.json === 'function' ? await req.json() : null\n\n const { id, collection, locale, update } = generateAltTextRequestSchema.parse(data)\n\n const pluginConfig = req.payload.config.custom?.altTextPluginConfig as\n AltTextPluginConfig | undefined\n\n if (!pluginConfig) {\n return Response.json({ error: 'Plugin config not found' }, { status: 500 })\n }\n\n // Treat the configured collections as an allowlist. Reject any other\n // collection before touching the Local API, so the endpoint can only ever\n // operate on the upload collections the plugin manages.\n const collectionConfig = pluginConfig.collections.find((entry) => entry.slug === collection)\n\n if (!collectionConfig) {\n return Response.json(\n { error: `Collection \"${collection}\" is not managed by the alt text plugin.` },\n { status: 403 },\n )\n }\n\n const imageDoc = await req.payload.findByID({\n id,\n collection,\n depth: 0,\n // Run under the requesting user's access, not Payload's default\n // `overrideAccess: true`, so collection-level access control applies.\n overrideAccess: false,\n user: req.user,\n })\n\n if (!imageDoc) {\n return Response.json({ error: 'Image not found' }, { status: 404 })\n }\n\n if (!pluginConfig.getImageThumbnail) {\n return Response.json(\n { error: 'getImageThumbnail function not configured' },\n { status: 500 },\n )\n }\n\n if (!pluginConfig.resolver) {\n return Response.json({ error: 'No alt text resolver configured' }, { status: 500 })\n }\n\n const mimeType =\n 'mimeType' in imageDoc && typeof imageDoc.mimeType === 'string'\n ? imageDoc.mimeType\n : undefined\n\n if (mimeType && !matchesMimeType(mimeType, collectionConfig.mimeTypes)) {\n return Response.json(\n {\n error: `Alt text is not tracked for files of type \"${mimeType}\" in the \"${collection}\" collection. Tracked types: ${collectionConfig.mimeTypes.join(', ')}.`,\n },\n { status: 400 },\n )\n }\n\n const unsupportedSourceError = getUnsupportedSourceMimeTypeError({\n declaredThumbnailMimeType: collectionConfig.imageThumbnailMimeType,\n mimeType,\n supportedMimeTypes: pluginConfig.resolver.supportedMimeTypes,\n })\n if (unsupportedSourceError) {\n return Response.json({ error: unsupportedSourceError }, { status: 400 })\n }\n\n // When localization is enabled, the requested locale must be one of the\n // configured locales. Reject anything else before it can be written to an\n // unconfigured locale or interpolated into the resolver's prompt.\n if (\n locale != null &&\n pluginConfig.locales.length > 0 &&\n !pluginConfig.locales.includes(locale)\n ) {\n return Response.json(\n {\n error: `Locale \"${locale}\" is not configured. Configured locales: ${pluginConfig.locales.join(', ')}.`,\n },\n { status: 400 },\n )\n }\n\n // determine target locale\n const targetLocale = locale ?? pluginConfig.locale\n if (!targetLocale) {\n return Response.json(\n {\n error:\n 'Could not determine target locale for alt text generation. Please check your plugin configuration.',\n },\n { status: 500 },\n )\n }\n\n const imageThumbnailUrl = await pluginConfig.getImageThumbnail(imageDoc, { collection, req })\n\n const result = await pluginConfig.resolver.resolve({\n filename:\n 'filename' in imageDoc && typeof imageDoc.filename === 'string'\n ? imageDoc.filename\n : undefined,\n imageThumbnailMimeType: collectionConfig.imageThumbnailMimeType,\n imageThumbnailUrl,\n locale: targetLocale,\n req,\n })\n\n if (!result.success) {\n return Response.json(\n { error: result.error || 'Failed to generate alt text' },\n { status: 500 },\n )\n }\n\n if (update) {\n await req.payload.update({\n id,\n collection,\n data: {\n alt: result.result.altText,\n keywords: result.result.keywords,\n },\n locale: targetLocale,\n // Run under the requesting user's access, not Payload's default\n // `overrideAccess: true`, so collection-level access control applies.\n overrideAccess: false,\n user: req.user,\n })\n }\n\n return Response.json({ id, collection, ...result.result })\n } catch (error) {\n if (error instanceof ZodError) {\n return Response.json(formatZodError(error), { status: 400 })\n }\n // Surface Payload access errors (Forbidden 403 / NotFound 404) with their\n // real status so an agent gets an accurate, non-retryable signal instead\n // of a misleading 500.\n if (error instanceof APIError) {\n return Response.json({ error: error.message }, { status: error.status })\n }\n req.payload.logger.error({ err: error }, 'Error generating alt text')\n return Response.json(\n {\n error: `Error generating alt text: ${error instanceof Error ? error.message : 'Unknown error'}`,\n },\n { status: 500 },\n )\n }\n }\n"],"names":["APIError","ZodError","getUnsupportedSourceMimeTypeError","matchesMimeType","formatZodError","generateAltTextRequestSchema","generateAltTextEndpoint","access","req","Response","json","error","status","data","id","collection","locale","update","parse","pluginConfig","payload","config","custom","altTextPluginConfig","collectionConfig","collections","find","entry","slug","imageDoc","findByID","depth","overrideAccess","user","getImageThumbnail","resolver","mimeType","undefined","mimeTypes","join","unsupportedSourceError","declaredThumbnailMimeType","imageThumbnailMimeType","supportedMimeTypes","locales","length","includes","targetLocale","imageThumbnailUrl","result","resolve","filename","success","alt","altText","keywords","message","logger","err","Error"],"mappings":"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"}
package/dist/index.d.ts CHANGED
@@ -1,9 +1,14 @@
1
1
  export { payloadAltTextPlugin } from './plugin.js';
2
+ export { anthropicResolver } from './resolvers/anthropic.js';
3
+ export type { AnthropicResolverConfig } from './resolvers/anthropic.js';
4
+ export { createVisionResolver, VisionProviderError } from './resolvers/createVisionResolver.js';
5
+ export type { VisionGenerateArgs, VisionImage, VisionInstructions, VisionInstructionsArgs, VisionResolverConfig, } from './resolvers/createVisionResolver.js';
2
6
  export { mistralResolver } from './resolvers/mistral.js';
3
7
  export type { MistralResolverConfig } from './resolvers/mistral.js';
4
8
  export { openAIResolver } from './resolvers/openAI.js';
9
+ export type { OpenAIResolverConfig } from './resolvers/openAI.js';
5
10
  export * from './resolvers/types.js';
6
- export type { AltTextCollectionConfig, GetImageThumbnail, IncomingAltTextPluginConfig as AltTextPluginConfig, } from './types/AltTextPluginConfig.js';
11
+ export type { AltTextCollectionConfig, AltTextHealthBaseFilter, AltTextHealthCheckConfig, GetImageThumbnail, IncomingAltTextPluginConfig as AltTextPluginConfig, } from './types/AltTextPluginConfig.js';
7
12
  export { getAltTextHealth } from './utilities/altTextHealth.js';
8
13
  export type { AltTextHealthError, AltTextHealthErrorCode, AltTextHealthScan, AltTextHealthScanCollection, } from './utilities/altTextHealth.js';
9
14
  export { matchesMimeType, validateAltText } from './utilities/mimeTypes.js';
package/dist/index.js CHANGED
@@ -1,4 +1,6 @@
1
1
  export { payloadAltTextPlugin } from './plugin.js';
2
+ export { anthropicResolver } from './resolvers/anthropic.js';
3
+ export { createVisionResolver, VisionProviderError } from './resolvers/createVisionResolver.js';
2
4
  export { mistralResolver } from './resolvers/mistral.js';
3
5
  export { openAIResolver } from './resolvers/openAI.js';
4
6
  export * from './resolvers/types.js';
package/dist/index.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/index.ts"],"sourcesContent":["export { payloadAltTextPlugin } from './plugin.js'\nexport { mistralResolver } from './resolvers/mistral.js'\nexport type { MistralResolverConfig } from './resolvers/mistral.js'\nexport { openAIResolver } from './resolvers/openAI.js'\nexport * from './resolvers/types.js'\nexport type {\n AltTextCollectionConfig,\n GetImageThumbnail,\n IncomingAltTextPluginConfig as AltTextPluginConfig,\n} from './types/AltTextPluginConfig.js'\nexport { getAltTextHealth } from './utilities/altTextHealth.js'\nexport type {\n AltTextHealthError,\n AltTextHealthErrorCode,\n AltTextHealthScan,\n AltTextHealthScanCollection,\n} from './utilities/altTextHealth.js'\nexport { matchesMimeType, validateAltText } from './utilities/mimeTypes.js'\n"],"names":["payloadAltTextPlugin","mistralResolver","openAIResolver","getAltTextHealth","matchesMimeType","validateAltText"],"mappings":"AAAA,SAASA,oBAAoB,QAAQ,cAAa;AAClD,SAASC,eAAe,QAAQ,yBAAwB;AAExD,SAASC,cAAc,QAAQ,wBAAuB;AACtD,cAAc,uBAAsB;AAMpC,SAASC,gBAAgB,QAAQ,+BAA8B;AAO/D,SAASC,eAAe,EAAEC,eAAe,QAAQ,2BAA0B"}
1
+ {"version":3,"sources":["../src/index.ts"],"sourcesContent":["export { payloadAltTextPlugin } from './plugin.js'\nexport { anthropicResolver } from './resolvers/anthropic.js'\nexport type { AnthropicResolverConfig } from './resolvers/anthropic.js'\nexport { createVisionResolver, VisionProviderError } from './resolvers/createVisionResolver.js'\nexport type {\n VisionGenerateArgs,\n VisionImage,\n VisionInstructions,\n VisionInstructionsArgs,\n VisionResolverConfig,\n} from './resolvers/createVisionResolver.js'\nexport { mistralResolver } from './resolvers/mistral.js'\nexport type { MistralResolverConfig } from './resolvers/mistral.js'\nexport { openAIResolver } from './resolvers/openAI.js'\nexport type { OpenAIResolverConfig } from './resolvers/openAI.js'\nexport * from './resolvers/types.js'\nexport type {\n AltTextCollectionConfig,\n AltTextHealthBaseFilter,\n AltTextHealthCheckConfig,\n GetImageThumbnail,\n IncomingAltTextPluginConfig as AltTextPluginConfig,\n} from './types/AltTextPluginConfig.js'\nexport { getAltTextHealth } from './utilities/altTextHealth.js'\nexport type {\n AltTextHealthError,\n AltTextHealthErrorCode,\n AltTextHealthScan,\n AltTextHealthScanCollection,\n} from './utilities/altTextHealth.js'\nexport { matchesMimeType, validateAltText } from './utilities/mimeTypes.js'\n"],"names":["payloadAltTextPlugin","anthropicResolver","createVisionResolver","VisionProviderError","mistralResolver","openAIResolver","getAltTextHealth","matchesMimeType","validateAltText"],"mappings":"AAAA,SAASA,oBAAoB,QAAQ,cAAa;AAClD,SAASC,iBAAiB,QAAQ,2BAA0B;AAE5D,SAASC,oBAAoB,EAAEC,mBAAmB,QAAQ,sCAAqC;AAQ/F,SAASC,eAAe,QAAQ,yBAAwB;AAExD,SAASC,cAAc,QAAQ,wBAAuB;AAEtD,cAAc,uBAAsB;AAQpC,SAASC,gBAAgB,QAAQ,+BAA8B;AAO/D,SAASC,eAAe,EAAEC,eAAe,QAAQ,2BAA0B"}
package/dist/plugin.js CHANGED
@@ -50,9 +50,14 @@ export const payloadAltTextPlugin = (incomingPluginConfig)=>(incomingConfig)=>{
50
50
  }
51
51
  }
52
52
  const access = incomingPluginConfig.access ?? (({ req })=>!!req.user);
53
- // A function form of `healthCheck` doubles as the health report's access
54
- // gate; otherwise it falls back to the shared `access`.
55
- const healthCheckAccess = typeof incomingPluginConfig.healthCheck === 'function' ? incomingPluginConfig.healthCheck : access;
53
+ // The former function form was the health report's access gate. Accepting it
54
+ // silently would widen that gate to the plugin's `access`, so it fails at boot.
55
+ if (typeof incomingPluginConfig.healthCheck === 'function') {
56
+ throw new Error('The alt-text plugin no longer accepts a function for `healthCheck`. ' + 'Move the access check to `healthCheck: { access: ({ req }) => ... }`.');
57
+ }
58
+ const healthCheckConfig = typeof incomingPluginConfig.healthCheck === 'object' ? incomingPluginConfig.healthCheck : {};
59
+ // The health report's own gate, falling back to the shared `access`.
60
+ const healthCheckAccess = healthCheckConfig.access ?? access;
56
61
  const pluginConfig = {
57
62
  access,
58
63
  collections: normalizedCollections,
@@ -61,6 +66,7 @@ export const payloadAltTextPlugin = (incomingPluginConfig)=>(incomingConfig)=>{
61
66
  getImageThumbnail: incomingPluginConfig.getImageThumbnail,
62
67
  healthCheck: enableHealthCheck,
63
68
  healthCheckAccess,
69
+ healthCheckBaseFilter: healthCheckConfig.baseFilter,
64
70
  locale: incomingPluginConfig.locale,
65
71
  locales,
66
72
  maxBulkGenerateConcurrency: incomingPluginConfig.maxBulkGenerateConcurrency ?? 16,
@@ -75,6 +81,9 @@ export const payloadAltTextPlugin = (incomingPluginConfig)=>(incomingConfig)=>{
75
81
  entry.slug,
76
82
  entry
77
83
  ]));
84
+ // Collected while collections are mapped and flushed in `onInit`: no Payload instance —
85
+ // and therefore no logger — exists while the config is still being built.
86
+ const configWarnings = [];
78
87
  // Ensure collections array exists
79
88
  config.collections = config.collections || [];
80
89
  // Map over collections and inject AI alt text fields into specified ones
@@ -82,7 +91,7 @@ export const payloadAltTextPlugin = (incomingPluginConfig)=>(incomingConfig)=>{
82
91
  const altTextCollectionConfig = collectionConfigBySlug.get(collectionConfig.slug);
83
92
  if (altTextCollectionConfig) {
84
93
  if (!collectionConfig.upload) {
85
- console.warn(`AI Alt Text Plugin: Collection "${collectionConfig.slug}" is not an upload collection. Skipping field injection.`);
94
+ configWarnings.push(`AI Alt Text Plugin: Collection "${collectionConfig.slug}" is not an upload collection. Skipping field injection.`);
86
95
  return collectionConfig;
87
96
  }
88
97
  const defaultFields = [
@@ -189,6 +198,12 @@ export const payloadAltTextPlugin = (incomingPluginConfig)=>(incomingConfig)=>{
189
198
  i18n: {
190
199
  ...config.i18n,
191
200
  translations: deepMergeSimple(translations, incomingConfig.i18n?.translations ?? {})
201
+ },
202
+ onInit: async (payload)=>{
203
+ for (const warning of configWarnings){
204
+ payload.logger.warn(warning);
205
+ }
206
+ await config.onInit?.(payload);
192
207
  }
193
208
  };
194
209
  };