@jhb.software/payload-alt-text-plugin 0.7.0 → 0.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.
@@ -1,3 +1,4 @@
1
+ import { APIError } from 'payload';
1
2
  import { ZodError } from 'zod';
2
3
  import { matchesMimeType } from '../utilities/mimeTypes.js';
3
4
  import { formatZodError, generateAltTextRequestSchema } from './schemas.js';
@@ -22,10 +23,33 @@ import { formatZodError, generateAltTextRequestSchema } from './schemas.js';
22
23
  }
23
24
  const data = 'json' in req && typeof req.json === 'function' ? await req.json() : null;
24
25
  const { id, collection, locale, update } = generateAltTextRequestSchema.parse(data);
26
+ const pluginConfig = req.payload.config.custom?.altTextPluginConfig;
27
+ if (!pluginConfig) {
28
+ return Response.json({
29
+ error: 'Plugin config not found'
30
+ }, {
31
+ status: 500
32
+ });
33
+ }
34
+ // Treat the configured collections as an allowlist. Reject any other
35
+ // collection before touching the Local API, so the endpoint can only ever
36
+ // operate on the upload collections the plugin manages.
37
+ const collectionConfig = pluginConfig.collections.find((entry)=>entry.slug === collection);
38
+ if (!collectionConfig) {
39
+ return Response.json({
40
+ error: `Collection "${collection}" is not managed by the alt text plugin.`
41
+ }, {
42
+ status: 403
43
+ });
44
+ }
25
45
  const imageDoc = await req.payload.findByID({
26
46
  id,
27
47
  collection,
28
- depth: 0
48
+ depth: 0,
49
+ // Run under the requesting user's access, not Payload's default
50
+ // `overrideAccess: true`, so collection-level access control applies.
51
+ overrideAccess: false,
52
+ user: req.user
29
53
  });
30
54
  if (!imageDoc) {
31
55
  return Response.json({
@@ -34,14 +58,6 @@ import { formatZodError, generateAltTextRequestSchema } from './schemas.js';
34
58
  status: 404
35
59
  });
36
60
  }
37
- const pluginConfig = req.payload.config.custom?.altTextPluginConfig;
38
- if (!pluginConfig) {
39
- return Response.json({
40
- error: 'Plugin config not found'
41
- }, {
42
- status: 500
43
- });
44
- }
45
61
  if (!pluginConfig.getImageThumbnail) {
46
62
  return Response.json({
47
63
  error: 'getImageThumbnail function not configured'
@@ -57,8 +73,7 @@ import { formatZodError, generateAltTextRequestSchema } from './schemas.js';
57
73
  });
58
74
  }
59
75
  const mimeType = 'mimeType' in imageDoc && typeof imageDoc.mimeType === 'string' ? imageDoc.mimeType : undefined;
60
- const collectionConfig = pluginConfig.collections.find((entry)=>entry.slug === collection);
61
- if (mimeType && collectionConfig && !matchesMimeType(mimeType, collectionConfig.mimeTypes)) {
76
+ if (mimeType && !matchesMimeType(mimeType, collectionConfig.mimeTypes)) {
62
77
  return Response.json({
63
78
  error: `Alt text is not tracked for files of type "${mimeType}" in the "${collection}" collection. Tracked types: ${collectionConfig.mimeTypes.join(', ')}.`
64
79
  }, {
@@ -72,6 +87,16 @@ import { formatZodError, generateAltTextRequestSchema } from './schemas.js';
72
87
  status: 400
73
88
  });
74
89
  }
90
+ // When localization is enabled, the requested locale must be one of the
91
+ // configured locales. Reject anything else before it can be written to an
92
+ // unconfigured locale or interpolated into the resolver's prompt.
93
+ if (locale != null && pluginConfig.locales.length > 0 && !pluginConfig.locales.includes(locale)) {
94
+ return Response.json({
95
+ error: `Locale "${locale}" is not configured. Configured locales: ${pluginConfig.locales.join(', ')}.`
96
+ }, {
97
+ status: 400
98
+ });
99
+ }
75
100
  // determine target locale
76
101
  const targetLocale = locale ?? pluginConfig.locale;
77
102
  if (!targetLocale) {
@@ -103,7 +128,11 @@ import { formatZodError, generateAltTextRequestSchema } from './schemas.js';
103
128
  alt: result.result.altText,
104
129
  keywords: result.result.keywords
105
130
  },
106
- locale: targetLocale
131
+ locale: targetLocale,
132
+ // Run under the requesting user's access, not Payload's default
133
+ // `overrideAccess: true`, so collection-level access control applies.
134
+ overrideAccess: false,
135
+ user: req.user
107
136
  });
108
137
  }
109
138
  return Response.json({
@@ -117,6 +146,16 @@ import { formatZodError, generateAltTextRequestSchema } from './schemas.js';
117
146
  status: 400
118
147
  });
119
148
  }
149
+ // Surface Payload access errors (Forbidden 403 / NotFound 404) with their
150
+ // real status so an agent gets an accurate, non-retryable signal instead
151
+ // of a misleading 500.
152
+ if (error instanceof APIError) {
153
+ return Response.json({
154
+ error: error.message
155
+ }, {
156
+ status: error.status
157
+ });
158
+ }
120
159
  console.error('Error generating alt text:', error);
121
160
  return Response.json({
122
161
  error: `Error generating alt text: ${error instanceof Error ? error.message : 'Unknown error'}`
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/endpoints/generateAltText.ts"],"sourcesContent":["import type { PayloadHandler, PayloadRequest } from 'payload'\n\nimport { ZodError } from 'zod'\n\nimport type { AltTextPluginConfig } from '../types/AltTextPluginConfig.js'\n\nimport { 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 imageDoc = await req.payload.findByID({\n id,\n collection,\n depth: 0,\n })\n\n if (!imageDoc) {\n return Response.json({ error: 'Image not found' }, { status: 404 })\n }\n\n const pluginConfig = req.payload.config.custom?.altTextPluginConfig as\n | AltTextPluginConfig\n | undefined\n\n if (!pluginConfig) {\n return Response.json({ error: 'Plugin config not found' }, { status: 500 })\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 const collectionConfig = pluginConfig.collections.find((entry) => entry.slug === collection)\n\n if (mimeType && collectionConfig && !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 if (\n mimeType &&\n pluginConfig.resolver.supportedMimeTypes &&\n !pluginConfig.resolver.supportedMimeTypes.includes(mimeType)\n ) {\n return Response.json(\n {\n error: `Alt text generation is not supported for files of type \"${mimeType}\". Supported types: ${pluginConfig.resolver.supportedMimeTypes.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 = pluginConfig.getImageThumbnail(imageDoc)\n\n const result = await pluginConfig.resolver.resolve({\n filename:\n 'filename' in imageDoc && typeof imageDoc.filename === 'string'\n ? imageDoc.filename\n : undefined,\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 })\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 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":["ZodError","matchesMimeType","formatZodError","generateAltTextRequestSchema","generateAltTextEndpoint","access","req","Response","json","error","status","data","id","collection","locale","update","parse","imageDoc","payload","findByID","depth","pluginConfig","config","custom","altTextPluginConfig","getImageThumbnail","resolver","mimeType","undefined","collectionConfig","collections","find","entry","slug","mimeTypes","join","supportedMimeTypes","includes","targetLocale","imageThumbnailUrl","result","resolve","filename","success","alt","altText","keywords","console","Error","message"],"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 { 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\n | 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 if (\n mimeType &&\n pluginConfig.resolver.supportedMimeTypes &&\n !pluginConfig.resolver.supportedMimeTypes.includes(mimeType)\n ) {\n return Response.json(\n {\n error: `Alt text generation is not supported for files of type \"${mimeType}\". Supported types: ${pluginConfig.resolver.supportedMimeTypes.join(', ')}.`,\n },\n { status: 400 },\n )\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 = pluginConfig.getImageThumbnail(imageDoc)\n\n const result = await pluginConfig.resolver.resolve({\n filename:\n 'filename' in imageDoc && typeof imageDoc.filename === 'string'\n ? imageDoc.filename\n : undefined,\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","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","supportedMimeTypes","includes","locales","length","targetLocale","imageThumbnailUrl","result","resolve","filename","success","alt","altText","keywords","message","console","Error"],"mappings":"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"}
package/dist/plugin.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { PLUGIN_SLUG } from './constants.js';
1
2
  import { altTextHealthEndpoint } from './endpoints/altTextHealth.js';
2
3
  import { bulkGenerateAltTextsEndpoint } from './endpoints/bulkGenerateAltTexts.js';
3
4
  import { generateAltTextEndpoint } from './endpoints/generateAltText.js';
@@ -30,16 +31,22 @@ export const payloadAltTextPlugin = (incomingPluginConfig)=>(incomingConfig)=>{
30
31
  const locales = config.localization ? config.localization.locales.map((localeConfig)=>typeof localeConfig === 'string' ? localeConfig : localeConfig.code) : [];
31
32
  const enableHealthCheck = incomingPluginConfig.healthCheck !== false;
32
33
  const normalizedCollections = normalizeCollectionsConfig(incomingPluginConfig.collections);
34
+ const access = incomingPluginConfig.access ?? (({ req })=>!!req.user);
35
+ // A function form of `healthCheck` doubles as the health report's access
36
+ // gate; otherwise it falls back to the shared `access`.
37
+ const healthCheckAccess = typeof incomingPluginConfig.healthCheck === 'function' ? incomingPluginConfig.healthCheck : access;
33
38
  const pluginConfig = {
34
- access: incomingPluginConfig.access ?? (({ req })=>!!req.user),
39
+ access,
35
40
  collections: normalizedCollections,
36
41
  enabled: incomingPluginConfig.enabled ?? true,
37
42
  fieldsOverride: incomingPluginConfig.fieldsOverride,
38
43
  getImageThumbnail: incomingPluginConfig.getImageThumbnail,
39
44
  healthCheck: enableHealthCheck,
45
+ healthCheckAccess,
40
46
  locale: incomingPluginConfig.locale,
41
47
  locales,
42
48
  maxBulkGenerateConcurrency: incomingPluginConfig.maxBulkGenerateConcurrency ?? 16,
49
+ maxBulkGenerateIds: incomingPluginConfig.maxBulkGenerateIds ?? 100,
43
50
  resolver: incomingPluginConfig.resolver
44
51
  };
45
52
  // Validate locale requirement for non-localized mode
@@ -143,18 +150,18 @@ export const payloadAltTextPlugin = (incomingPluginConfig)=>(incomingConfig)=>{
143
150
  {
144
151
  handler: generateAltTextEndpoint(pluginConfig.access),
145
152
  method: 'post',
146
- path: '/alt-text-plugin/generate'
153
+ path: `/${PLUGIN_SLUG}/generate`
147
154
  },
148
155
  {
149
156
  handler: bulkGenerateAltTextsEndpoint(pluginConfig.access),
150
157
  method: 'post',
151
- path: '/alt-text-plugin/generate/bulk'
158
+ path: `/${PLUGIN_SLUG}/generate/bulk`
152
159
  },
153
160
  ...enableHealthCheck ? [
154
161
  {
155
- handler: altTextHealthEndpoint(pluginConfig.access),
162
+ handler: altTextHealthEndpoint(pluginConfig.healthCheckAccess),
156
163
  method: 'get',
157
- path: '/alt-text-plugin/health'
164
+ path: `/${PLUGIN_SLUG}/health`
158
165
  }
159
166
  ] : []
160
167
  ],
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/plugin.ts"],"sourcesContent":["import type { Config, Widget } from 'payload'\n\nimport type {\n AltTextPluginConfig,\n IncomingAltTextPluginConfig,\n} from './types/AltTextPluginConfig.js'\n\nimport { altTextHealthEndpoint } from './endpoints/altTextHealth.js'\nimport { bulkGenerateAltTextsEndpoint } from './endpoints/bulkGenerateAltTexts.js'\nimport { generateAltTextEndpoint } from './endpoints/generateAltText.js'\nimport { altTextField } from './fields/altTextField.js'\nimport { keywordsField } from './fields/keywordsField.js'\nimport {\n createRevalidateAltTextHealthAfterChangeHook,\n createRevalidateAltTextHealthAfterDeleteHook,\n} from './hooks/revalidateAltTextHealth.js'\nimport { translations } from './translations/index.js'\nimport { normalizeCollectionsConfig } from './utilities/mimeTypes.js'\nimport { deepMergeSimple } from './utils/deepMergeSimple.js'\n\nconst altTextHealthWidgetDefinition = {\n slug: 'alt-text-health',\n // `Component` was renamed from `ComponentPath` in Payload 3.79.0. Set both for backward compatibility.\n Component: '@jhb.software/payload-alt-text-plugin/server#AltTextHealthWidget',\n ComponentPath: '@jhb.software/payload-alt-text-plugin/server#AltTextHealthWidget',\n label: {\n de: 'Alternativtexte Zustand',\n en: 'Alt text health',\n },\n maxWidth: 'full',\n minWidth: 'medium',\n} satisfies { ComponentPath: string } & Widget\n\nexport const payloadAltTextPlugin =\n (incomingPluginConfig: IncomingAltTextPluginConfig) =>\n (incomingConfig: Config): Config => {\n const config = { ...incomingConfig }\n\n // If the plugin is disabled, return the config without modifying it\n if (incomingPluginConfig.enabled === false) {\n return config\n }\n\n const locales = config.localization\n ? config.localization.locales.map((localeConfig) =>\n typeof localeConfig === 'string' ? localeConfig : localeConfig.code,\n )\n : []\n\n const enableHealthCheck = incomingPluginConfig.healthCheck !== false\n\n const normalizedCollections = normalizeCollectionsConfig(incomingPluginConfig.collections)\n\n const pluginConfig: AltTextPluginConfig = {\n access: incomingPluginConfig.access ?? (({ req }) => !!req.user),\n collections: normalizedCollections,\n enabled: incomingPluginConfig.enabled ?? true,\n fieldsOverride: incomingPluginConfig.fieldsOverride,\n getImageThumbnail: incomingPluginConfig.getImageThumbnail,\n healthCheck: enableHealthCheck,\n locale: incomingPluginConfig.locale,\n locales,\n maxBulkGenerateConcurrency: incomingPluginConfig.maxBulkGenerateConcurrency ?? 16,\n resolver: incomingPluginConfig.resolver,\n }\n\n // Validate locale requirement for non-localized mode\n if (locales.length === 0 && !incomingPluginConfig.locale) {\n throw new Error(\n 'The alt-text plugin requires a \"locale\" option when Payload localization is disabled. ' +\n 'Please add { locale: \"en\" } (or your preferred locale) to your plugin configuration.',\n )\n }\n\n const collectionConfigBySlug = new Map<string, (typeof normalizedCollections)[number]>(\n normalizedCollections.map((entry) => [entry.slug, entry]),\n )\n\n // Ensure collections array exists\n config.collections = config.collections || []\n\n // Map over collections and inject AI alt text fields into specified ones\n config.collections = config.collections.map((collectionConfig) => {\n const altTextCollectionConfig = collectionConfigBySlug.get(collectionConfig.slug)\n\n if (altTextCollectionConfig) {\n if (!collectionConfig.upload) {\n console.warn(\n `AI Alt Text Plugin: Collection \"${collectionConfig.slug}\" is not an upload collection. Skipping field injection.`,\n )\n return collectionConfig\n }\n\n const defaultFields = [\n altTextField({\n localized: Boolean(config.localization),\n supportedMimeTypes: pluginConfig.resolver.supportedMimeTypes,\n trackedMimeTypes: altTextCollectionConfig.mimeTypes,\n validate: altTextCollectionConfig.validate,\n }),\n keywordsField({\n localized: Boolean(config.localization),\n }),\n ]\n\n const fields =\n incomingPluginConfig.fieldsOverride &&\n typeof incomingPluginConfig.fieldsOverride === 'function'\n ? incomingPluginConfig.fieldsOverride({ defaultFields })\n : defaultFields\n\n return {\n ...collectionConfig,\n admin: {\n ...collectionConfig.admin,\n components: {\n ...(collectionConfig.admin?.components ?? {}),\n // TODO: use the beforeBulkAction custom component slot once available: https://github.com/payloadcms/payload/pull/11719\n beforeListTable: [\n ...(collectionConfig.admin?.components?.beforeListTable ?? []),\n {\n path: '@jhb.software/payload-alt-text-plugin/client#BulkGenerateAltTextsButton',\n props: {\n collectionSlug: collectionConfig.slug,\n },\n },\n ],\n },\n // enhance the search by adding the filename, keywords and alt fields (if the user has not provided their own listSearchableFields)\n listSearchableFields: collectionConfig.admin?.listSearchableFields ?? [\n 'filename',\n 'keywords',\n 'alt',\n ],\n },\n fields: [...(collectionConfig.fields ?? []), ...fields],\n hooks: {\n ...collectionConfig.hooks,\n ...(enableHealthCheck && {\n afterChange: [\n ...(collectionConfig.hooks?.afterChange ?? []),\n createRevalidateAltTextHealthAfterChangeHook(collectionConfig.slug),\n ],\n afterDelete: [\n ...(collectionConfig.hooks?.afterDelete ?? []),\n createRevalidateAltTextHealthAfterDeleteHook(collectionConfig.slug),\n ],\n }),\n },\n }\n }\n\n return collectionConfig\n })\n\n const existingWidgets = config.admin?.dashboard?.widgets ?? []\n const widgets =\n !enableHealthCheck || existingWidgets.some((widget) => widget.slug === 'alt-text-health')\n ? existingWidgets\n : [...existingWidgets, altTextHealthWidgetDefinition]\n\n return {\n ...config,\n admin: {\n ...config.admin,\n dashboard: {\n ...config.admin?.dashboard,\n widgets,\n },\n },\n custom: {\n ...config.custom,\n // Make plugin config available in hooks/actions\n altTextPluginConfig: pluginConfig,\n },\n endpoints: [\n ...(config.endpoints ?? []),\n {\n handler: generateAltTextEndpoint(pluginConfig.access),\n method: 'post',\n path: '/alt-text-plugin/generate',\n },\n {\n handler: bulkGenerateAltTextsEndpoint(pluginConfig.access),\n method: 'post',\n path: '/alt-text-plugin/generate/bulk',\n },\n ...(enableHealthCheck\n ? [\n {\n handler: altTextHealthEndpoint(pluginConfig.access),\n method: 'get' as const,\n path: '/alt-text-plugin/health',\n },\n ]\n : []),\n ],\n i18n: {\n ...config.i18n,\n translations: deepMergeSimple(translations, incomingConfig.i18n?.translations ?? {}),\n },\n }\n }\n"],"names":["altTextHealthEndpoint","bulkGenerateAltTextsEndpoint","generateAltTextEndpoint","altTextField","keywordsField","createRevalidateAltTextHealthAfterChangeHook","createRevalidateAltTextHealthAfterDeleteHook","translations","normalizeCollectionsConfig","deepMergeSimple","altTextHealthWidgetDefinition","slug","Component","ComponentPath","label","de","en","maxWidth","minWidth","payloadAltTextPlugin","incomingPluginConfig","incomingConfig","config","enabled","locales","localization","map","localeConfig","code","enableHealthCheck","healthCheck","normalizedCollections","collections","pluginConfig","access","req","user","fieldsOverride","getImageThumbnail","locale","maxBulkGenerateConcurrency","resolver","length","Error","collectionConfigBySlug","Map","entry","collectionConfig","altTextCollectionConfig","get","upload","console","warn","defaultFields","localized","Boolean","supportedMimeTypes","trackedMimeTypes","mimeTypes","validate","fields","admin","components","beforeListTable","path","props","collectionSlug","listSearchableFields","hooks","afterChange","afterDelete","existingWidgets","dashboard","widgets","some","widget","custom","altTextPluginConfig","endpoints","handler","method","i18n"],"mappings":"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"}
1
+ {"version":3,"sources":["../src/plugin.ts"],"sourcesContent":["import type { Config, Widget } from 'payload'\n\nimport type {\n AltTextPluginConfig,\n IncomingAltTextPluginConfig,\n} from './types/AltTextPluginConfig.js'\n\nimport { PLUGIN_SLUG } from './constants.js'\nimport { altTextHealthEndpoint } from './endpoints/altTextHealth.js'\nimport { bulkGenerateAltTextsEndpoint } from './endpoints/bulkGenerateAltTexts.js'\nimport { generateAltTextEndpoint } from './endpoints/generateAltText.js'\nimport { altTextField } from './fields/altTextField.js'\nimport { keywordsField } from './fields/keywordsField.js'\nimport {\n createRevalidateAltTextHealthAfterChangeHook,\n createRevalidateAltTextHealthAfterDeleteHook,\n} from './hooks/revalidateAltTextHealth.js'\nimport { translations } from './translations/index.js'\nimport { normalizeCollectionsConfig } from './utilities/mimeTypes.js'\nimport { deepMergeSimple } from './utils/deepMergeSimple.js'\n\nconst altTextHealthWidgetDefinition = {\n slug: 'alt-text-health',\n // `Component` was renamed from `ComponentPath` in Payload 3.79.0. Set both for backward compatibility.\n Component: '@jhb.software/payload-alt-text-plugin/server#AltTextHealthWidget',\n ComponentPath: '@jhb.software/payload-alt-text-plugin/server#AltTextHealthWidget',\n label: {\n de: 'Alternativtexte Zustand',\n en: 'Alt text health',\n },\n maxWidth: 'full',\n minWidth: 'medium',\n} satisfies { ComponentPath: string } & Widget\n\nexport const payloadAltTextPlugin =\n (incomingPluginConfig: IncomingAltTextPluginConfig) =>\n (incomingConfig: Config): Config => {\n const config = { ...incomingConfig }\n\n // If the plugin is disabled, return the config without modifying it\n if (incomingPluginConfig.enabled === false) {\n return config\n }\n\n const locales = config.localization\n ? config.localization.locales.map((localeConfig) =>\n typeof localeConfig === 'string' ? localeConfig : localeConfig.code,\n )\n : []\n\n const enableHealthCheck = incomingPluginConfig.healthCheck !== false\n\n const normalizedCollections = normalizeCollectionsConfig(incomingPluginConfig.collections)\n\n const access = incomingPluginConfig.access ?? (({ req }) => !!req.user)\n\n // A function form of `healthCheck` doubles as the health report's access\n // gate; otherwise it falls back to the shared `access`.\n const healthCheckAccess =\n typeof incomingPluginConfig.healthCheck === 'function'\n ? incomingPluginConfig.healthCheck\n : access\n\n const pluginConfig: AltTextPluginConfig = {\n access,\n collections: normalizedCollections,\n enabled: incomingPluginConfig.enabled ?? true,\n fieldsOverride: incomingPluginConfig.fieldsOverride,\n getImageThumbnail: incomingPluginConfig.getImageThumbnail,\n healthCheck: enableHealthCheck,\n healthCheckAccess,\n locale: incomingPluginConfig.locale,\n locales,\n maxBulkGenerateConcurrency: incomingPluginConfig.maxBulkGenerateConcurrency ?? 16,\n maxBulkGenerateIds: incomingPluginConfig.maxBulkGenerateIds ?? 100,\n resolver: incomingPluginConfig.resolver,\n }\n\n // Validate locale requirement for non-localized mode\n if (locales.length === 0 && !incomingPluginConfig.locale) {\n throw new Error(\n 'The alt-text plugin requires a \"locale\" option when Payload localization is disabled. ' +\n 'Please add { locale: \"en\" } (or your preferred locale) to your plugin configuration.',\n )\n }\n\n const collectionConfigBySlug = new Map<string, (typeof normalizedCollections)[number]>(\n normalizedCollections.map((entry) => [entry.slug, entry]),\n )\n\n // Ensure collections array exists\n config.collections = config.collections || []\n\n // Map over collections and inject AI alt text fields into specified ones\n config.collections = config.collections.map((collectionConfig) => {\n const altTextCollectionConfig = collectionConfigBySlug.get(collectionConfig.slug)\n\n if (altTextCollectionConfig) {\n if (!collectionConfig.upload) {\n console.warn(\n `AI Alt Text Plugin: Collection \"${collectionConfig.slug}\" is not an upload collection. Skipping field injection.`,\n )\n return collectionConfig\n }\n\n const defaultFields = [\n altTextField({\n localized: Boolean(config.localization),\n supportedMimeTypes: pluginConfig.resolver.supportedMimeTypes,\n trackedMimeTypes: altTextCollectionConfig.mimeTypes,\n validate: altTextCollectionConfig.validate,\n }),\n keywordsField({\n localized: Boolean(config.localization),\n }),\n ]\n\n const fields =\n incomingPluginConfig.fieldsOverride &&\n typeof incomingPluginConfig.fieldsOverride === 'function'\n ? incomingPluginConfig.fieldsOverride({ defaultFields })\n : defaultFields\n\n return {\n ...collectionConfig,\n admin: {\n ...collectionConfig.admin,\n components: {\n ...(collectionConfig.admin?.components ?? {}),\n // TODO: use the beforeBulkAction custom component slot once available: https://github.com/payloadcms/payload/pull/11719\n beforeListTable: [\n ...(collectionConfig.admin?.components?.beforeListTable ?? []),\n {\n path: '@jhb.software/payload-alt-text-plugin/client#BulkGenerateAltTextsButton',\n props: {\n collectionSlug: collectionConfig.slug,\n },\n },\n ],\n },\n // enhance the search by adding the filename, keywords and alt fields (if the user has not provided their own listSearchableFields)\n listSearchableFields: collectionConfig.admin?.listSearchableFields ?? [\n 'filename',\n 'keywords',\n 'alt',\n ],\n },\n fields: [...(collectionConfig.fields ?? []), ...fields],\n hooks: {\n ...collectionConfig.hooks,\n ...(enableHealthCheck && {\n afterChange: [\n ...(collectionConfig.hooks?.afterChange ?? []),\n createRevalidateAltTextHealthAfterChangeHook(collectionConfig.slug),\n ],\n afterDelete: [\n ...(collectionConfig.hooks?.afterDelete ?? []),\n createRevalidateAltTextHealthAfterDeleteHook(collectionConfig.slug),\n ],\n }),\n },\n }\n }\n\n return collectionConfig\n })\n\n const existingWidgets = config.admin?.dashboard?.widgets ?? []\n const widgets =\n !enableHealthCheck || existingWidgets.some((widget) => widget.slug === 'alt-text-health')\n ? existingWidgets\n : [...existingWidgets, altTextHealthWidgetDefinition]\n\n return {\n ...config,\n admin: {\n ...config.admin,\n dashboard: {\n ...config.admin?.dashboard,\n widgets,\n },\n },\n custom: {\n ...config.custom,\n // Make plugin config available in hooks/actions\n altTextPluginConfig: pluginConfig,\n },\n endpoints: [\n ...(config.endpoints ?? []),\n {\n handler: generateAltTextEndpoint(pluginConfig.access),\n method: 'post',\n path: `/${PLUGIN_SLUG}/generate`,\n },\n {\n handler: bulkGenerateAltTextsEndpoint(pluginConfig.access),\n method: 'post',\n path: `/${PLUGIN_SLUG}/generate/bulk`,\n },\n ...(enableHealthCheck\n ? [\n {\n handler: altTextHealthEndpoint(pluginConfig.healthCheckAccess),\n method: 'get' as const,\n path: `/${PLUGIN_SLUG}/health`,\n },\n ]\n : []),\n ],\n i18n: {\n ...config.i18n,\n translations: deepMergeSimple(translations, incomingConfig.i18n?.translations ?? {}),\n },\n }\n }\n"],"names":["PLUGIN_SLUG","altTextHealthEndpoint","bulkGenerateAltTextsEndpoint","generateAltTextEndpoint","altTextField","keywordsField","createRevalidateAltTextHealthAfterChangeHook","createRevalidateAltTextHealthAfterDeleteHook","translations","normalizeCollectionsConfig","deepMergeSimple","altTextHealthWidgetDefinition","slug","Component","ComponentPath","label","de","en","maxWidth","minWidth","payloadAltTextPlugin","incomingPluginConfig","incomingConfig","config","enabled","locales","localization","map","localeConfig","code","enableHealthCheck","healthCheck","normalizedCollections","collections","access","req","user","healthCheckAccess","pluginConfig","fieldsOverride","getImageThumbnail","locale","maxBulkGenerateConcurrency","maxBulkGenerateIds","resolver","length","Error","collectionConfigBySlug","Map","entry","collectionConfig","altTextCollectionConfig","get","upload","console","warn","defaultFields","localized","Boolean","supportedMimeTypes","trackedMimeTypes","mimeTypes","validate","fields","admin","components","beforeListTable","path","props","collectionSlug","listSearchableFields","hooks","afterChange","afterDelete","existingWidgets","dashboard","widgets","some","widget","custom","altTextPluginConfig","endpoints","handler","method","i18n"],"mappings":"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"}
@@ -42,10 +42,14 @@ import { z } from 'zod';
42
42
  * ```
43
43
  */ export const openAIResolver = (config)=>{
44
44
  const { apiKey, baseUrl, model = 'gpt-4.1-nano' } = config;
45
- const openai = new OpenAI({
46
- apiKey,
47
- baseURL: baseUrl
48
- });
45
+ // Build the client lazily (once, on first use): the `resolver` argument is
46
+ // evaluated even when the plugin is disabled, so eager construction would
47
+ // throw on a keyless `enabled: !!process.env.OPENAI_API_KEY` setup.
48
+ let openai;
49
+ const getClient = ()=>openai ??= new OpenAI({
50
+ apiKey,
51
+ baseURL: baseUrl
52
+ });
49
53
  return {
50
54
  key: 'openai',
51
55
  resolve: async ({ filename, imageThumbnailUrl, locale })=>{
@@ -54,7 +58,7 @@ import { z } from 'zod';
54
58
  altText: z.string().describe('A concise, descriptive alt text for the image'),
55
59
  keywords: z.array(z.string()).describe('Keywords that describe the content of the image')
56
60
  });
57
- const response = await openai.chat.completions.parse({
61
+ const response = await getClient().chat.completions.parse({
58
62
  max_completion_tokens: 150,
59
63
  messages: [
60
64
  {
@@ -120,7 +124,7 @@ import { z } from 'zod';
120
124
  keywords: z.array(z.string()).describe('Keywords that describe the content of the image')
121
125
  })
122
126
  ])));
123
- const response = await openai.chat.completions.parse({
127
+ const response = await getClient().chat.completions.parse({
124
128
  max_completion_tokens: 300,
125
129
  messages: [
126
130
  {
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/resolvers/openAI.ts"],"sourcesContent":["import type { AutoParseableResponseFormat } from 'openai/lib/parser.mjs'\nimport type { ChatCompletionContentPartText } from 'openai/resources/chat/completions.mjs'\nimport type { ResponseFormatJSONSchema } from 'openai/resources/shared.mjs'\n\nimport OpenAI from 'openai'\nimport { makeParseableResponseFormat } from 'openai/lib/parser.mjs'\nimport { z } from 'zod'\n\nimport type {\n AltTextBulkResolverArgs,\n AltTextBulkResolverResponse,\n AltTextResolver,\n AltTextResolverArgs,\n AltTextResolverResponse,\n} from './types.js'\n\nexport type OpenAIResolverConfig = {\n /** OpenAI API key for authentication */\n apiKey: string\n /**\n * Base URL for the OpenAI-compatible API.\n * Use this to point at alternative providers (e.g. Azure, Nebius, local inference).\n * @default undefined — the OpenAI SDK defaults to 'https://api.openai.com/v1'\n */\n baseUrl?: string\n /**\n * The OpenAI LLM model to use for alt text generation.\n * @default 'gpt-4.1-nano'\n */\n model?: string\n}\n\n/**\n * Creates a chat completion `JSONSchema` response format object from\n * the given Zod schema.\n *\n * This is a temporary drop in replacement for the zodResponseFormat from openai/helpers/zod.ts\n * because of issue https://github.com/openai/openai-node/issues/1576\n */\nfunction zodResponseFormat<ZodInput extends z.ZodType>(\n zodObject: ZodInput,\n name: string,\n props?: Omit<ResponseFormatJSONSchema.JSONSchema, 'name' | 'schema' | 'strict'>,\n): AutoParseableResponseFormat<z.infer<ZodInput>> {\n return makeParseableResponseFormat(\n {\n type: 'json_schema',\n json_schema: {\n ...props,\n name,\n schema: z.toJSONSchema(zodObject, { target: 'draft-7' }),\n strict: true,\n },\n },\n (content) => zodObject.parse(JSON.parse(content)),\n )\n}\n\n/**\n * Creates an OpenAI-based resolver for alt text generation.\n *\n * @example\n * ```typescript\n * import { openAIResolver } from '@jhb.software/payload-alt-text-plugin'\n *\n * // OpenAI\n * openAIResolver({\n * apiKey: process.env.OPENAI_API_KEY,\n * model: 'gpt-4.1-mini', // optional, defaults to 'gpt-4.1-nano'\n * })\n *\n * // OpenAI-compatible provider (e.g. Nebius)\n * openAIResolver({\n * apiKey: process.env.NEBIUS_API_KEY,\n * baseUrl: 'https://api.tokenfactory.us-central1.nebius.com/v1',\n * model: 'Qwen/Qwen2.5-VL-72B-Instruct',\n * })\n * ```\n */\nexport const openAIResolver = (config: OpenAIResolverConfig): AltTextResolver => {\n const { apiKey, baseUrl, model = 'gpt-4.1-nano' } = config\n const openai = new OpenAI({ apiKey, baseURL: baseUrl })\n\n return {\n key: 'openai',\n resolve: async ({\n filename,\n imageThumbnailUrl,\n locale,\n }: AltTextResolverArgs): Promise<AltTextResolverResponse> => {\n try {\n const modelResponseSchema = z.object({\n altText: z.string().describe('A concise, descriptive alt text for the image'),\n keywords: z.array(z.string()).describe('Keywords that describe the content of the image'),\n })\n\n const response = await openai.chat.completions.parse({\n max_completion_tokens: 150,\n messages: [\n {\n content: `\n You are an expert at analyzing images and creating descriptive image alt text.\n\n Please analyze the given image and provide the following:\n - A concise, descriptive alt text (1-2 sentences) as \"altText\". Focus on the subject, action, and setting. Avoid phrases like 'Image of', 'A picture of', or 'Photo showing'. Be specific and include relevant details like location or context if visible. Make no assumptions.\n - A list of keywords that describe the content (e.g., [\"Camel\", \"Palm trees\", \"Desert\"]) as \"keywords\"\n\n If a context is provided, use it to enhance the alt text.\n\n Format your response as a JSON object. You must respond in the ${locale} language.\n `,\n role: 'system',\n },\n {\n content: [\n {\n type: 'image_url',\n image_url: { url: imageThumbnailUrl },\n },\n ...(filename\n ? [\n {\n type: 'text',\n text: filename,\n } satisfies ChatCompletionContentPartText,\n ]\n : []),\n ],\n role: 'user',\n },\n ],\n model,\n response_format: zodResponseFormat(modelResponseSchema, 'data'),\n })\n\n const result = response.choices[0]?.message?.parsed\n\n if (!result) {\n return { error: 'No result from OpenAI', success: false }\n }\n\n return {\n result,\n success: true,\n }\n } catch (error) {\n console.error('Error generating alt text:', error)\n return {\n error: error instanceof Error ? error.message : 'Unknown error',\n success: false,\n }\n }\n },\n resolveBulk: async ({\n filename,\n imageThumbnailUrl,\n locales,\n }: AltTextBulkResolverArgs): Promise<AltTextBulkResolverResponse> => {\n try {\n const modelResponseSchema = z.object(\n Object.fromEntries(\n locales.map((locale) => [\n locale,\n z.object({\n altText: z.string().describe('A concise, descriptive alt text for the image'),\n keywords: z\n .array(z.string())\n .describe('Keywords that describe the content of the image'),\n }),\n ]),\n ),\n )\n\n const response = await openai.chat.completions.parse({\n max_completion_tokens: 300,\n messages: [\n {\n content: `\n You are an expert at analyzing images and creating descriptive image alt text.\n\n Please analyze the given image and provide the following in ${locales.join(', ')}:\n - A concise, localized descriptive alt text (1-2 sentences) as \"altText\". Focus on the subject, action, and setting. Avoid phrases like 'Image of', 'A picture of', or 'Photo showing'. Be specific and include relevant details like location or context if visible. Make no assumptions.\n - A localized list of keywords that describe the content (e.g., [\"Camel\", \"Palm trees\", \"Desert\"]) as \"keywords\"\n\n If a context is provided, use it to enhance the alt text.\n\n Format your response as a JSON object with ${locales.join(', ')} keys, each containing \"altText\" and \"keywords\".\n `,\n role: 'system',\n },\n {\n content: [\n {\n type: 'image_url',\n image_url: { url: imageThumbnailUrl },\n },\n ...(filename\n ? [\n {\n type: 'text',\n text: filename,\n } satisfies ChatCompletionContentPartText,\n ]\n : []),\n ],\n role: 'user',\n },\n ],\n model,\n response_format: zodResponseFormat(modelResponseSchema, 'data'),\n })\n\n const result = response.choices[0]?.message?.parsed\n\n if (!result) {\n return { error: 'No result from OpenAI', success: false }\n }\n\n return {\n results: result,\n success: true,\n }\n } catch (error) {\n console.error('Error generating bulk alt text:', error)\n return {\n error: error instanceof Error ? error.message : 'Unknown error',\n success: false,\n }\n }\n },\n // https://platform.openai.com/docs/guides/images-vision\n supportedMimeTypes: ['image/jpeg', 'image/png', 'image/gif', 'image/webp'],\n }\n}\n"],"names":["OpenAI","makeParseableResponseFormat","z","zodResponseFormat","zodObject","name","props","type","json_schema","schema","toJSONSchema","target","strict","content","parse","JSON","openAIResolver","config","apiKey","baseUrl","model","openai","baseURL","key","resolve","filename","imageThumbnailUrl","locale","modelResponseSchema","object","altText","string","describe","keywords","array","response","chat","completions","max_completion_tokens","messages","role","image_url","url","text","response_format","result","choices","message","parsed","error","success","console","Error","resolveBulk","locales","Object","fromEntries","map","join","results","supportedMimeTypes"],"mappings":"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"}
1
+ {"version":3,"sources":["../../src/resolvers/openAI.ts"],"sourcesContent":["import type { AutoParseableResponseFormat } from 'openai/lib/parser.mjs'\nimport type { ChatCompletionContentPartText } from 'openai/resources/chat/completions.mjs'\nimport type { ResponseFormatJSONSchema } from 'openai/resources/shared.mjs'\n\nimport OpenAI from 'openai'\nimport { makeParseableResponseFormat } from 'openai/lib/parser.mjs'\nimport { z } from 'zod'\n\nimport type {\n AltTextBulkResolverArgs,\n AltTextBulkResolverResponse,\n AltTextResolver,\n AltTextResolverArgs,\n AltTextResolverResponse,\n} from './types.js'\n\nexport type OpenAIResolverConfig = {\n /** OpenAI API key for authentication */\n apiKey: string\n /**\n * Base URL for the OpenAI-compatible API.\n * Use this to point at alternative providers (e.g. Azure, Nebius, local inference).\n * @default undefined — the OpenAI SDK defaults to 'https://api.openai.com/v1'\n */\n baseUrl?: string\n /**\n * The OpenAI LLM model to use for alt text generation.\n * @default 'gpt-4.1-nano'\n */\n model?: string\n}\n\n/**\n * Creates a chat completion `JSONSchema` response format object from\n * the given Zod schema.\n *\n * This is a temporary drop in replacement for the zodResponseFormat from openai/helpers/zod.ts\n * because of issue https://github.com/openai/openai-node/issues/1576\n */\nfunction zodResponseFormat<ZodInput extends z.ZodType>(\n zodObject: ZodInput,\n name: string,\n props?: Omit<ResponseFormatJSONSchema.JSONSchema, 'name' | 'schema' | 'strict'>,\n): AutoParseableResponseFormat<z.infer<ZodInput>> {\n return makeParseableResponseFormat(\n {\n type: 'json_schema',\n json_schema: {\n ...props,\n name,\n schema: z.toJSONSchema(zodObject, { target: 'draft-7' }),\n strict: true,\n },\n },\n (content) => zodObject.parse(JSON.parse(content)),\n )\n}\n\n/**\n * Creates an OpenAI-based resolver for alt text generation.\n *\n * @example\n * ```typescript\n * import { openAIResolver } from '@jhb.software/payload-alt-text-plugin'\n *\n * // OpenAI\n * openAIResolver({\n * apiKey: process.env.OPENAI_API_KEY,\n * model: 'gpt-4.1-mini', // optional, defaults to 'gpt-4.1-nano'\n * })\n *\n * // OpenAI-compatible provider (e.g. Nebius)\n * openAIResolver({\n * apiKey: process.env.NEBIUS_API_KEY,\n * baseUrl: 'https://api.tokenfactory.us-central1.nebius.com/v1',\n * model: 'Qwen/Qwen2.5-VL-72B-Instruct',\n * })\n * ```\n */\nexport const openAIResolver = (config: OpenAIResolverConfig): AltTextResolver => {\n const { apiKey, baseUrl, model = 'gpt-4.1-nano' } = config\n\n // Build the client lazily (once, on first use): the `resolver` argument is\n // evaluated even when the plugin is disabled, so eager construction would\n // throw on a keyless `enabled: !!process.env.OPENAI_API_KEY` setup.\n let openai: OpenAI | undefined\n const getClient = (): OpenAI => (openai ??= new OpenAI({ apiKey, baseURL: baseUrl }))\n\n return {\n key: 'openai',\n resolve: async ({\n filename,\n imageThumbnailUrl,\n locale,\n }: AltTextResolverArgs): Promise<AltTextResolverResponse> => {\n try {\n const modelResponseSchema = z.object({\n altText: z.string().describe('A concise, descriptive alt text for the image'),\n keywords: z.array(z.string()).describe('Keywords that describe the content of the image'),\n })\n\n const response = await getClient().chat.completions.parse({\n max_completion_tokens: 150,\n messages: [\n {\n content: `\n You are an expert at analyzing images and creating descriptive image alt text.\n\n Please analyze the given image and provide the following:\n - A concise, descriptive alt text (1-2 sentences) as \"altText\". Focus on the subject, action, and setting. Avoid phrases like 'Image of', 'A picture of', or 'Photo showing'. Be specific and include relevant details like location or context if visible. Make no assumptions.\n - A list of keywords that describe the content (e.g., [\"Camel\", \"Palm trees\", \"Desert\"]) as \"keywords\"\n\n If a context is provided, use it to enhance the alt text.\n\n Format your response as a JSON object. You must respond in the ${locale} language.\n `,\n role: 'system',\n },\n {\n content: [\n {\n type: 'image_url',\n image_url: { url: imageThumbnailUrl },\n },\n ...(filename\n ? [\n {\n type: 'text',\n text: filename,\n } satisfies ChatCompletionContentPartText,\n ]\n : []),\n ],\n role: 'user',\n },\n ],\n model,\n response_format: zodResponseFormat(modelResponseSchema, 'data'),\n })\n\n const result = response.choices[0]?.message?.parsed\n\n if (!result) {\n return { error: 'No result from OpenAI', success: false }\n }\n\n return {\n result,\n success: true,\n }\n } catch (error) {\n console.error('Error generating alt text:', error)\n return {\n error: error instanceof Error ? error.message : 'Unknown error',\n success: false,\n }\n }\n },\n resolveBulk: async ({\n filename,\n imageThumbnailUrl,\n locales,\n }: AltTextBulkResolverArgs): Promise<AltTextBulkResolverResponse> => {\n try {\n const modelResponseSchema = z.object(\n Object.fromEntries(\n locales.map((locale) => [\n locale,\n z.object({\n altText: z.string().describe('A concise, descriptive alt text for the image'),\n keywords: z\n .array(z.string())\n .describe('Keywords that describe the content of the image'),\n }),\n ]),\n ),\n )\n\n const response = await getClient().chat.completions.parse({\n max_completion_tokens: 300,\n messages: [\n {\n content: `\n You are an expert at analyzing images and creating descriptive image alt text.\n\n Please analyze the given image and provide the following in ${locales.join(', ')}:\n - A concise, localized descriptive alt text (1-2 sentences) as \"altText\". Focus on the subject, action, and setting. Avoid phrases like 'Image of', 'A picture of', or 'Photo showing'. Be specific and include relevant details like location or context if visible. Make no assumptions.\n - A localized list of keywords that describe the content (e.g., [\"Camel\", \"Palm trees\", \"Desert\"]) as \"keywords\"\n\n If a context is provided, use it to enhance the alt text.\n\n Format your response as a JSON object with ${locales.join(', ')} keys, each containing \"altText\" and \"keywords\".\n `,\n role: 'system',\n },\n {\n content: [\n {\n type: 'image_url',\n image_url: { url: imageThumbnailUrl },\n },\n ...(filename\n ? [\n {\n type: 'text',\n text: filename,\n } satisfies ChatCompletionContentPartText,\n ]\n : []),\n ],\n role: 'user',\n },\n ],\n model,\n response_format: zodResponseFormat(modelResponseSchema, 'data'),\n })\n\n const result = response.choices[0]?.message?.parsed\n\n if (!result) {\n return { error: 'No result from OpenAI', success: false }\n }\n\n return {\n results: result,\n success: true,\n }\n } catch (error) {\n console.error('Error generating bulk alt text:', error)\n return {\n error: error instanceof Error ? error.message : 'Unknown error',\n success: false,\n }\n }\n },\n // https://platform.openai.com/docs/guides/images-vision\n supportedMimeTypes: ['image/jpeg', 'image/png', 'image/gif', 'image/webp'],\n }\n}\n"],"names":["OpenAI","makeParseableResponseFormat","z","zodResponseFormat","zodObject","name","props","type","json_schema","schema","toJSONSchema","target","strict","content","parse","JSON","openAIResolver","config","apiKey","baseUrl","model","openai","getClient","baseURL","key","resolve","filename","imageThumbnailUrl","locale","modelResponseSchema","object","altText","string","describe","keywords","array","response","chat","completions","max_completion_tokens","messages","role","image_url","url","text","response_format","result","choices","message","parsed","error","success","console","Error","resolveBulk","locales","Object","fromEntries","map","join","results","supportedMimeTypes"],"mappings":"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"}
@@ -45,12 +45,22 @@ export type IncomingAltTextPluginConfig = {
45
45
  */
46
46
  getImageThumbnail: (doc: Record<string, unknown>) => string;
47
47
  /**
48
- * Enable alt text health tracking (REST endpoint, cache revalidation hooks, and dashboard widget).
49
- * Set to `false` to disable the entire feature.
48
+ * Controls the alt text health feature (REST endpoint, cache revalidation hooks, and dashboard widget).
49
+ *
50
+ * - `false` disables the entire feature.
51
+ * - `true` enables it, gated by `access`.
52
+ * - A function enables it and gates both the endpoint and the dashboard widget
53
+ * with that access check — use this to restrict the collection-wide report
54
+ * more strictly than the per-document generate endpoints (e.g. to admins).
55
+ *
56
+ * Regardless of the gate, the report is always filtered to the collections the
57
+ * requesting user can read.
50
58
  *
51
59
  * @default true
52
60
  */
53
- healthCheck?: boolean;
61
+ healthCheck?: ((args: {
62
+ req: PayloadRequest;
63
+ }) => boolean | Promise<boolean>) | boolean;
54
64
  /**
55
65
  * The locale to generate alt texts in when localization is disabled.
56
66
  *
@@ -64,6 +74,16 @@ export type IncomingAltTextPluginConfig = {
64
74
  * @default 16
65
75
  */
66
76
  maxBulkGenerateConcurrency?: number;
77
+ /**
78
+ * Maximum number of image IDs accepted in a single bulk generate request.
79
+ * Requests exceeding this are rejected with `400`. Duplicate IDs are collapsed
80
+ * before the limit is applied, so each image counts once.
81
+ *
82
+ * Raise it for large libraries that need to process more images per request.
83
+ *
84
+ * @default 100
85
+ */
86
+ maxBulkGenerateIds?: number;
67
87
  /** The resolver to use for generating alt text (e.g., openAIResolver) */
68
88
  resolver: AltTextResolver;
69
89
  };
@@ -85,12 +105,18 @@ export type AltTextPluginConfig = {
85
105
  getImageThumbnail: (doc: Record<string, unknown>) => string;
86
106
  /** Whether alt text health tracking is enabled. */
87
107
  healthCheck: boolean;
108
+ /** Access control for the health endpoint. Defaults to `access`. */
109
+ healthCheckAccess: (args: {
110
+ req: PayloadRequest;
111
+ }) => boolean | Promise<boolean>;
88
112
  /** The locale to generate alt texts in when localization is disabled. */
89
113
  locale?: string;
90
114
  /** The locales to generate alt texts for. */
91
115
  locales: string[];
92
116
  /** Maximum number of concurrent API requests for bulk generate operations. */
93
117
  maxBulkGenerateConcurrency: number;
118
+ /** Maximum number of image IDs accepted per bulk generate request. */
119
+ maxBulkGenerateIds: number;
94
120
  /** The resolver to use for generating alt text */
95
121
  resolver: AltTextResolver;
96
122
  };
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/types/AltTextPluginConfig.ts"],"sourcesContent":["import type { Field, PayloadRequest } from 'payload'\n\nimport type { AltTextResolver } from '../resolvers/types.js'\nimport type {\n AltTextCollectionConfig,\n IncomingCollectionsConfig,\n NormalizedAltTextCollectionConfig,\n} from '../utilities/mimeTypes.js'\n\nexport type { AltTextCollectionConfig, NormalizedAltTextCollectionConfig }\n\n/** Configuration options for the alt text plugin. */\nexport type IncomingAltTextPluginConfig = {\n /**\n * Custom access control for plugin endpoints.\n * Return `true` to allow access, `false` to deny.\n *\n * @default ({ req }) => !!req.user — requires authentication\n */\n access?: (args: { req: PayloadRequest }) => boolean | Promise<boolean>\n\n /**\n * Collections to enable the plugin for.\n *\n * Each entry may be a bare collection slug or an object with a `slug` and an\n * optional `mimeTypes` array restricting which MIME types are tracked,\n * validated, and generated. Bare slugs default to `['image/*']`.\n *\n * @example\n * ```typescript\n * collections: [\n * 'images', // shorthand — defaults to ['image/*']\n * { slug: 'media', mimeTypes: ['image/*', 'application/pdf'] },\n * ]\n * ```\n */\n collections: IncomingCollectionsConfig\n\n /** Whether the plugin is enabled. */\n enabled?: boolean\n\n /** Override the default fields inserted by the plugin via a function that receives the default fields and returns the new fields */\n fieldsOverride?: (args: { defaultFields: Field[] }) => Field[]\n\n /**\n * Function to get the thumbnail URL of an image document.\n * This URL will be sent to the LLM for analysis.\n *\n * @remarks\n * - The URL must be publicly accessible so the LLM can fetch it\n * - Use a thumbnail/preview version of the image when possible (e.g. from the sizes field)\n */\n getImageThumbnail: (doc: Record<string, unknown>) => string\n\n /**\n * Enable alt text health tracking (REST endpoint, cache revalidation hooks, and dashboard widget).\n * Set to `false` to disable the entire feature.\n *\n * @default true\n */\n healthCheck?: boolean\n\n /**\n * The locale to generate alt texts in when localization is disabled.\n *\n * Required when localization is disabled, ignored when localization is enabled.\n * @example 'en'\n */\n locale?: string\n\n /**\n * Maximum number of concurrent API requests for bulk generate operations.\n *\n * @default 16\n */\n maxBulkGenerateConcurrency?: number\n\n /** The resolver to use for generating alt text (e.g., openAIResolver) */\n resolver: AltTextResolver\n}\n\n/** Configuration of the alt text plugin after defaults have been applied. */\nexport type AltTextPluginConfig = {\n /** Access control for plugin endpoints. */\n access: (args: { req: PayloadRequest }) => boolean | Promise<boolean>\n\n /** Collections with resolved MIME type filters. */\n collections: NormalizedAltTextCollectionConfig[]\n\n /** Whether the plugin is enabled. */\n enabled: boolean\n\n /** Override the default fields inserted by the plugin via a function that receives the default fields and returns the new fields */\n fieldsOverride?: (args: { defaultFields: Field[] }) => Field[]\n\n /** Function to get the thumbnail URL of an image document. */\n getImageThumbnail: (doc: Record<string, unknown>) => string\n\n /** Whether alt text health tracking is enabled. */\n healthCheck: boolean\n\n /** The locale to generate alt texts in when localization is disabled. */\n locale?: string\n\n /** The locales to generate alt texts for. */\n locales: string[]\n\n /** Maximum number of concurrent API requests for bulk generate operations. */\n maxBulkGenerateConcurrency: number\n\n /** The resolver to use for generating alt text */\n resolver: AltTextResolver\n}\n"],"names":[],"mappings":"AAiFA,2EAA2E,GAC3E,WA8BC"}
1
+ {"version":3,"sources":["../../src/types/AltTextPluginConfig.ts"],"sourcesContent":["import type { Field, PayloadRequest } from 'payload'\n\nimport type { AltTextResolver } from '../resolvers/types.js'\nimport type {\n AltTextCollectionConfig,\n IncomingCollectionsConfig,\n NormalizedAltTextCollectionConfig,\n} from '../utilities/mimeTypes.js'\n\nexport type { AltTextCollectionConfig, NormalizedAltTextCollectionConfig }\n\n/** Configuration options for the alt text plugin. */\nexport type IncomingAltTextPluginConfig = {\n /**\n * Custom access control for plugin endpoints.\n * Return `true` to allow access, `false` to deny.\n *\n * @default ({ req }) => !!req.user — requires authentication\n */\n access?: (args: { req: PayloadRequest }) => boolean | Promise<boolean>\n\n /**\n * Collections to enable the plugin for.\n *\n * Each entry may be a bare collection slug or an object with a `slug` and an\n * optional `mimeTypes` array restricting which MIME types are tracked,\n * validated, and generated. Bare slugs default to `['image/*']`.\n *\n * @example\n * ```typescript\n * collections: [\n * 'images', // shorthand — defaults to ['image/*']\n * { slug: 'media', mimeTypes: ['image/*', 'application/pdf'] },\n * ]\n * ```\n */\n collections: IncomingCollectionsConfig\n\n /** Whether the plugin is enabled. */\n enabled?: boolean\n\n /** Override the default fields inserted by the plugin via a function that receives the default fields and returns the new fields */\n fieldsOverride?: (args: { defaultFields: Field[] }) => Field[]\n\n /**\n * Function to get the thumbnail URL of an image document.\n * This URL will be sent to the LLM for analysis.\n *\n * @remarks\n * - The URL must be publicly accessible so the LLM can fetch it\n * - Use a thumbnail/preview version of the image when possible (e.g. from the sizes field)\n */\n getImageThumbnail: (doc: Record<string, unknown>) => string\n\n /**\n * Controls the alt text health feature (REST endpoint, cache revalidation hooks, and dashboard widget).\n *\n * - `false` disables the entire feature.\n * - `true` enables it, gated by `access`.\n * - A function enables it and gates both the endpoint and the dashboard widget\n * with that access check — use this to restrict the collection-wide report\n * more strictly than the per-document generate endpoints (e.g. to admins).\n *\n * Regardless of the gate, the report is always filtered to the collections the\n * requesting user can read.\n *\n * @default true\n */\n healthCheck?: ((args: { req: PayloadRequest }) => boolean | Promise<boolean>) | boolean\n\n /**\n * The locale to generate alt texts in when localization is disabled.\n *\n * Required when localization is disabled, ignored when localization is enabled.\n * @example 'en'\n */\n locale?: string\n\n /**\n * Maximum number of concurrent API requests for bulk generate operations.\n *\n * @default 16\n */\n maxBulkGenerateConcurrency?: number\n\n /**\n * Maximum number of image IDs accepted in a single bulk generate request.\n * Requests exceeding this are rejected with `400`. Duplicate IDs are collapsed\n * before the limit is applied, so each image counts once.\n *\n * Raise it for large libraries that need to process more images per request.\n *\n * @default 100\n */\n maxBulkGenerateIds?: number\n\n /** The resolver to use for generating alt text (e.g., openAIResolver) */\n resolver: AltTextResolver\n}\n\n/** Configuration of the alt text plugin after defaults have been applied. */\nexport type AltTextPluginConfig = {\n /** Access control for plugin endpoints. */\n access: (args: { req: PayloadRequest }) => boolean | Promise<boolean>\n\n /** Collections with resolved MIME type filters. */\n collections: NormalizedAltTextCollectionConfig[]\n\n /** Whether the plugin is enabled. */\n enabled: boolean\n\n /** Override the default fields inserted by the plugin via a function that receives the default fields and returns the new fields */\n fieldsOverride?: (args: { defaultFields: Field[] }) => Field[]\n\n /** Function to get the thumbnail URL of an image document. */\n getImageThumbnail: (doc: Record<string, unknown>) => string\n\n /** Whether alt text health tracking is enabled. */\n healthCheck: boolean\n\n /** Access control for the health endpoint. Defaults to `access`. */\n healthCheckAccess: (args: { req: PayloadRequest }) => boolean | Promise<boolean>\n\n /** The locale to generate alt texts in when localization is disabled. */\n locale?: string\n\n /** The locales to generate alt texts for. */\n locales: string[]\n\n /** Maximum number of concurrent API requests for bulk generate operations. */\n maxBulkGenerateConcurrency: number\n\n /** Maximum number of image IDs accepted per bulk generate request. */\n maxBulkGenerateIds: number\n\n /** The resolver to use for generating alt text */\n resolver: AltTextResolver\n}\n"],"names":[],"mappings":"AAoGA,2EAA2E,GAC3E,WAoCC"}
@@ -33,5 +33,19 @@ export type AltTextHealthWidgetData = {
33
33
  totalDocs: number;
34
34
  };
35
35
  export declare const getAltTextHealthCollectionTag: (collectionSlug: string) => string;
36
+ /**
37
+ * Filters a shared, elevated-access health scan down to the collections the
38
+ * requesting user may read. The scan is computed once with `overrideAccess: true`
39
+ * so it stays complete and cacheable; access is applied per request at
40
+ * collection granularity, matching the aggregate's altitude.
41
+ */
42
+ export declare function filterScanByReadAccess(req: PayloadRequest, scan: AltTextHealthScan): Promise<AltTextHealthScan>;
43
+ /**
44
+ * Whether the requesting user may view the health report at all, per the
45
+ * configured `healthCheck` access gate. The dashboard widget uses this to hide
46
+ * itself, mirroring the gate enforced on the health endpoint.
47
+ */
48
+ export declare function canViewHealthReport(req: PayloadRequest): Promise<boolean>;
49
+ export declare function toWidgetData(scan: AltTextHealthScan): AltTextHealthWidgetData;
36
50
  export declare function getAltTextHealth(req: PayloadRequest): Promise<AltTextHealthScan>;
37
51
  export declare function getAltTextHealthWidgetData(req: PayloadRequest): Promise<AltTextHealthWidgetData>;
@@ -141,11 +141,55 @@ async function getAltTextHealthScan(req) {
141
141
  });
142
142
  return getCachedHealthScan();
143
143
  }
144
- export async function getAltTextHealth(req) {
145
- return getAltTextHealthScan(req);
144
+ /**
145
+ * Whether `req.user` is allowed to read the given collection at the collection
146
+ * level. The collection's `read` access is evaluated with the request; `false`
147
+ * denies, while `true` or a scoped `Where` constraint grants visibility of the
148
+ * collection's health aggregate. A thrown access function (e.g. `Forbidden`)
149
+ * counts as denied so a restricted collection never leaks.
150
+ */ async function userCanReadCollection(req, slug) {
151
+ const readAccess = req.payload.collections?.[slug]?.config.access?.read;
152
+ if (typeof readAccess !== 'function') {
153
+ return true;
154
+ }
155
+ try {
156
+ return await readAccess({
157
+ req
158
+ }) !== false;
159
+ } catch {
160
+ return false;
161
+ }
146
162
  }
147
- export async function getAltTextHealthWidgetData(req) {
148
- const scan = await getAltTextHealthScan(req);
163
+ /**
164
+ * Filters a shared, elevated-access health scan down to the collections the
165
+ * requesting user may read. The scan is computed once with `overrideAccess: true`
166
+ * so it stays complete and cacheable; access is applied per request at
167
+ * collection granularity, matching the aggregate's altitude.
168
+ */ export async function filterScanByReadAccess(req, scan) {
169
+ const visibility = await Promise.all(scan.collections.map((collection)=>userCanReadCollection(req, collection.collection)));
170
+ const collections = scan.collections.filter((_, index)=>visibility[index]);
171
+ const allowedSlugs = new Set(collections.map((collection)=>collection.collection));
172
+ const errors = scan.errors.filter((error)=>!error.collection || allowedSlugs.has(error.collection));
173
+ return {
174
+ ...scan,
175
+ collections,
176
+ errors
177
+ };
178
+ }
179
+ /**
180
+ * Whether the requesting user may view the health report at all, per the
181
+ * configured `healthCheck` access gate. The dashboard widget uses this to hide
182
+ * itself, mirroring the gate enforced on the health endpoint.
183
+ */ export async function canViewHealthReport(req) {
184
+ const pluginConfig = req.payload.config.custom?.altTextPluginConfig;
185
+ if (!pluginConfig) {
186
+ return false;
187
+ }
188
+ return pluginConfig.healthCheckAccess({
189
+ req
190
+ });
191
+ }
192
+ export function toWidgetData(scan) {
149
193
  return {
150
194
  collections: scan.collections,
151
195
  errors: scan.errors,
@@ -154,5 +198,11 @@ export async function getAltTextHealthWidgetData(req) {
154
198
  totalDocs: scan.collections.reduce((total, c)=>total + c.totalDocs, 0)
155
199
  };
156
200
  }
201
+ export async function getAltTextHealth(req) {
202
+ return filterScanByReadAccess(req, await getAltTextHealthScan(req));
203
+ }
204
+ export async function getAltTextHealthWidgetData(req) {
205
+ return toWidgetData(await filterScanByReadAccess(req, await getAltTextHealthScan(req)));
206
+ }
157
207
 
158
208
  //# sourceMappingURL=altTextHealth.js.map