@jhb.software/payload-alt-text-plugin 0.9.0 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/plugin.js CHANGED
@@ -6,7 +6,7 @@ import { altTextField } from './fields/altTextField.js';
6
6
  import { keywordsField } from './fields/keywordsField.js';
7
7
  import { createRevalidateAltTextHealthAfterChangeHook, createRevalidateAltTextHealthAfterDeleteHook } from './hooks/revalidateAltTextHealth.js';
8
8
  import { translations } from './translations/index.js';
9
- import { normalizeCollectionsConfig } from './utilities/mimeTypes.js';
9
+ import { isValidMimeType, normalizeCollectionsConfig } from './utilities/mimeTypes.js';
10
10
  import { deepMergeSimple } from './utils/deepMergeSimple.js';
11
11
  const altTextHealthWidgetDefinition = {
12
12
  slug: 'alt-text-health',
@@ -30,7 +30,25 @@ export const payloadAltTextPlugin = (incomingPluginConfig)=>(incomingConfig)=>{
30
30
  }
31
31
  const locales = config.localization ? config.localization.locales.map((localeConfig)=>typeof localeConfig === 'string' ? localeConfig : localeConfig.code) : [];
32
32
  const enableHealthCheck = incomingPluginConfig.healthCheck !== false;
33
- const normalizedCollections = normalizeCollectionsConfig(incomingPluginConfig.collections);
33
+ const normalizedCollections = normalizeCollectionsConfig(incomingPluginConfig.collections, {
34
+ imageThumbnailMimeType: incomingPluginConfig.imageThumbnailMimeType
35
+ });
36
+ // A declared thumbnail MIME type replaces the per-document source check, so a
37
+ // wrong one fails at boot rather than as a silently missing guard or a 500 per
38
+ // image.
39
+ const supportedMimeTypes = incomingPluginConfig.resolver.supportedMimeTypes;
40
+ for (const collection of normalizedCollections){
41
+ const declared = collection.imageThumbnailMimeType;
42
+ if (declared === undefined) {
43
+ continue;
44
+ }
45
+ if (!isValidMimeType(declared)) {
46
+ throw new Error(`The alt-text plugin is configured with imageThumbnailMimeType "${declared}" for the "${collection.slug}" collection, ` + 'but that is not a valid MIME type. Expected something like "image/webp".');
47
+ }
48
+ if (supportedMimeTypes && !supportedMimeTypes.includes(declared)) {
49
+ throw new Error(`The alt-text plugin is configured with imageThumbnailMimeType "${declared}" for the "${collection.slug}" collection, ` + `but the "${incomingPluginConfig.resolver.key}" resolver does not support it. ` + `Supported types: ${supportedMimeTypes.join(', ')}. ` + "Either change the transformation in getImageThumbnail, or remove the declaration to fall back to checking each document's own mime type.");
50
+ }
51
+ }
34
52
  const access = incomingPluginConfig.access ?? (({ req })=>!!req.user);
35
53
  // A function form of `healthCheck` doubles as the health report's access
36
54
  // gate; otherwise it falls back to the shared `access`.
@@ -70,7 +88,10 @@ export const payloadAltTextPlugin = (incomingPluginConfig)=>(incomingConfig)=>{
70
88
  const defaultFields = [
71
89
  altTextField({
72
90
  localized: Boolean(config.localization),
73
- supportedMimeTypes: pluginConfig.resolver.supportedMimeTypes,
91
+ // When the collection declares what getImageThumbnail delivers, the
92
+ // document's own mime type says nothing about whether generation can
93
+ // succeed — so don't let the admin UI disable the button on it.
94
+ supportedMimeTypes: altTextCollectionConfig.imageThumbnailMimeType ? undefined : pluginConfig.resolver.supportedMimeTypes,
74
95
  trackedMimeTypes: altTextCollectionConfig.mimeTypes,
75
96
  validate: altTextCollectionConfig.validate
76
97
  }),
@@ -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 { 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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"}
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 { isValidMimeType, 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 imageThumbnailMimeType: incomingPluginConfig.imageThumbnailMimeType,\n })\n\n // A declared thumbnail MIME type replaces the per-document source check, so a\n // wrong one fails at boot rather than as a silently missing guard or a 500 per\n // image.\n const supportedMimeTypes = incomingPluginConfig.resolver.supportedMimeTypes\n for (const collection of normalizedCollections) {\n const declared = collection.imageThumbnailMimeType\n if (declared === undefined) {\n continue\n }\n\n if (!isValidMimeType(declared)) {\n throw new Error(\n `The alt-text plugin is configured with imageThumbnailMimeType \"${declared}\" for the \"${collection.slug}\" collection, ` +\n 'but that is not a valid MIME type. Expected something like \"image/webp\".',\n )\n }\n\n if (supportedMimeTypes && !supportedMimeTypes.includes(declared)) {\n throw new Error(\n `The alt-text plugin is configured with imageThumbnailMimeType \"${declared}\" for the \"${collection.slug}\" collection, ` +\n `but the \"${incomingPluginConfig.resolver.key}\" resolver does not support it. ` +\n `Supported types: ${supportedMimeTypes.join(', ')}. ` +\n \"Either change the transformation in getImageThumbnail, or remove the declaration to fall back to checking each document's own mime type.\",\n )\n }\n }\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 // When the collection declares what getImageThumbnail delivers, the\n // document's own mime type says nothing about whether generation can\n // succeed — so don't let the admin UI disable the button on it.\n supportedMimeTypes: altTextCollectionConfig.imageThumbnailMimeType\n ? undefined\n : 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","isValidMimeType","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","imageThumbnailMimeType","supportedMimeTypes","resolver","collection","declared","undefined","Error","includes","key","join","access","req","user","healthCheckAccess","pluginConfig","fieldsOverride","getImageThumbnail","locale","maxBulkGenerateConcurrency","maxBulkGenerateIds","length","collectionConfigBySlug","Map","entry","collectionConfig","altTextCollectionConfig","get","upload","console","warn","defaultFields","localized","Boolean","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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"}
@@ -0,0 +1,40 @@
1
+ import type { AltTextResolver } from './types.js';
2
+ export type MistralResolverConfig = {
3
+ /** Mistral API key for authentication */
4
+ apiKey: string;
5
+ /**
6
+ * Base URL of the Mistral API.
7
+ * @default 'https://api.mistral.ai/v1'
8
+ */
9
+ baseUrl?: string;
10
+ /**
11
+ * The vision-capable Mistral model to use for alt text generation.
12
+ *
13
+ * Must be able to read images — `mistral-medium-latest`,
14
+ * `mistral-large-latest`, `mistral-small-latest` and the `ministral-*` models
15
+ * all are.
16
+ *
17
+ * @default 'mistral-medium-latest'
18
+ */
19
+ model?: string;
20
+ /**
21
+ * Abort after this many milliseconds. Covers downloading the image and the
22
+ * completion call together.
23
+ * @default 30000
24
+ */
25
+ timeoutMs?: number;
26
+ };
27
+ /**
28
+ * Creates a Mistral-based resolver for alt text generation.
29
+ *
30
+ * @example
31
+ * ```typescript
32
+ * import { mistralResolver } from '@jhb.software/payload-alt-text-plugin'
33
+ *
34
+ * mistralResolver({
35
+ * apiKey: process.env.MISTRAL_API_KEY,
36
+ * model: 'mistral-medium-latest', // optional, this is the default
37
+ * })
38
+ * ```
39
+ */
40
+ export declare const mistralResolver: (config: MistralResolverConfig) => AltTextResolver;
@@ -0,0 +1,280 @@
1
+ import { z } from 'zod';
2
+ /**
3
+ * Image formats the Mistral API accepts.
4
+ *
5
+ * Narrower than what an upload collection may hold — SVG and AVIF are missing,
6
+ * so the endpoint rejects those documents and their generate button stays
7
+ * disabled instead of failing at the provider.
8
+ */ const SUPPORTED_MIME_TYPES = [
9
+ 'image/jpeg',
10
+ 'image/png',
11
+ 'image/gif',
12
+ 'image/webp'
13
+ ];
14
+ /** Mistral rejects images above 20 MB. */ const MAX_IMAGE_BYTES = 20 * 1024 * 1024;
15
+ const altTextSchema = z.object({
16
+ altText: z.string().describe('A concise, descriptive alt text for the image'),
17
+ keywords: z.array(z.string()).describe('Keywords that describe the content of the image')
18
+ });
19
+ /** One schema entry per requested locale, so the model must answer for all of them. */ const schemaForLocales = (locales)=>z.object(Object.fromEntries(locales.map((locale)=>[
20
+ locale,
21
+ altTextSchema
22
+ ])));
23
+ /**
24
+ * Downloads the image and returns it as a data URI.
25
+ *
26
+ * Mistral can fetch an image URL itself, but that path is not dependable for a
27
+ * CMS. It requires the file to be reachable from the public internet — never
28
+ * true in local development, and not true for private buckets — and some hosts
29
+ * refuse Mistral's fetcher outright, which surfaces as `File could not be
30
+ * fetched from url` (error 3310). Sending the bytes removes that whole class of
31
+ * failure for the price of one extra download.
32
+ */ async function fetchImageAsDataUri(url, signal) {
33
+ let response;
34
+ try {
35
+ response = await fetch(url, {
36
+ signal
37
+ });
38
+ } catch (error) {
39
+ return {
40
+ error: `Could not download the image from ${url}: ${error instanceof Error ? error.message : 'unknown error'}`
41
+ };
42
+ }
43
+ if (!response.ok) {
44
+ return {
45
+ error: `Could not download the image from ${url}: status ${response.status}`
46
+ };
47
+ }
48
+ // The document's mime type is checked by the endpoint before the resolver
49
+ // runs, but `getImageThumbnail` may point at a derivative in a different
50
+ // format, so trust what was actually served.
51
+ const contentType = response.headers.get('content-type')?.split(';')[0]?.trim().toLowerCase();
52
+ if (!contentType || !SUPPORTED_MIME_TYPES.includes(contentType)) {
53
+ return {
54
+ error: `The image at ${url} was served as "${contentType ?? 'an unknown type'}", which Mistral cannot read. Supported types: ${SUPPORTED_MIME_TYPES.join(', ')}.`
55
+ };
56
+ }
57
+ const bytes = Buffer.from(await response.arrayBuffer());
58
+ if (bytes.byteLength === 0) {
59
+ return {
60
+ error: `The image at ${url} was empty.`
61
+ };
62
+ }
63
+ if (bytes.byteLength > MAX_IMAGE_BYTES) {
64
+ return {
65
+ error: `The image at ${url} is ${Math.round(bytes.byteLength / 1024 / 1024)} MB, above Mistral's 20 MB limit. Point getImageThumbnail at a smaller image size.`
66
+ };
67
+ }
68
+ return {
69
+ dataUri: `data:${contentType};base64,${bytes.toString('base64')}`
70
+ };
71
+ }
72
+ function buildPrompt(locales) {
73
+ const languages = locales.join(', ');
74
+ return `
75
+ You are an expert at analyzing images and creating descriptive image alt text.
76
+
77
+ Please analyze the given image and provide the following in ${languages}:
78
+ - 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.
79
+ - A list of keywords that describe the content (e.g., ["Camel", "Palm trees", "Desert"]) as "keywords"
80
+
81
+ If a context is provided, use it to enhance the alt text.
82
+
83
+ Format your response as a JSON object with ${locales.map((locale)=>`"${locale}"`).join(', ')} keys, each containing "altText" and "keywords".
84
+ `;
85
+ }
86
+ /**
87
+ * Reads the model's response.
88
+ *
89
+ * Deliberately strict about a blank `altText`: the field is required on the
90
+ * collection, so an empty string would satisfy that requirement while telling a
91
+ * screen reader nothing — and nobody looks at an alt text again once it is set.
92
+ */ function parseResults(content, locales) {
93
+ const parsed = schemaForLocales(locales).safeParse(content);
94
+ if (!parsed.success) {
95
+ return null;
96
+ }
97
+ const results = {};
98
+ for (const locale of locales){
99
+ const entry = parsed.data[locale];
100
+ if (entry.altText.trim().length === 0) {
101
+ return null;
102
+ }
103
+ results[locale] = {
104
+ altText: entry.altText.trim(),
105
+ keywords: entry.keywords
106
+ };
107
+ }
108
+ return results;
109
+ }
110
+ /**
111
+ * One request, one or more locales.
112
+ *
113
+ * All locales go into a single call rather than one call each: the image is
114
+ * uploaded and analyzed once — the expensive part — and every language ends up
115
+ * describing the same reading of it.
116
+ */ async function generate({ apiKey, baseUrl, filename, imageThumbnailUrl, locales, model, timeoutMs }) {
117
+ if (!apiKey) {
118
+ return {
119
+ error: 'No Mistral API key configured',
120
+ success: false
121
+ };
122
+ }
123
+ if (locales.length === 0) {
124
+ return {
125
+ error: 'No locale requested',
126
+ success: false
127
+ };
128
+ }
129
+ const signal = AbortSignal.timeout(timeoutMs);
130
+ const image = await fetchImageAsDataUri(imageThumbnailUrl, signal);
131
+ if ('error' in image) {
132
+ return {
133
+ error: image.error,
134
+ success: false
135
+ };
136
+ }
137
+ try {
138
+ const response = await fetch(`${baseUrl}/chat/completions`, {
139
+ body: JSON.stringify({
140
+ max_tokens: 150 * locales.length,
141
+ messages: [
142
+ {
143
+ content: buildPrompt(locales),
144
+ role: 'system'
145
+ },
146
+ {
147
+ content: [
148
+ {
149
+ type: 'image_url',
150
+ image_url: image.dataUri
151
+ },
152
+ ...filename ? [
153
+ {
154
+ type: 'text',
155
+ text: filename
156
+ }
157
+ ] : []
158
+ ],
159
+ role: 'user'
160
+ }
161
+ ],
162
+ model,
163
+ response_format: {
164
+ type: 'json_schema',
165
+ json_schema: {
166
+ name: 'data',
167
+ schema: z.toJSONSchema(schemaForLocales(locales), {
168
+ target: 'draft-7'
169
+ }),
170
+ strict: true
171
+ }
172
+ }
173
+ }),
174
+ headers: {
175
+ Authorization: `Bearer ${apiKey}`,
176
+ 'Content-Type': 'application/json'
177
+ },
178
+ method: 'POST',
179
+ signal
180
+ });
181
+ if (!response.ok) {
182
+ const body = await response.text().catch(()=>'');
183
+ return {
184
+ error: `Mistral responded with status ${response.status}${body ? `: ${body}` : ''}`,
185
+ success: false
186
+ };
187
+ }
188
+ const completion = await response.json();
189
+ const content = completion.choices?.[0]?.message?.content;
190
+ if (typeof content !== 'string') {
191
+ return {
192
+ error: 'No result from Mistral',
193
+ success: false
194
+ };
195
+ }
196
+ const results = parseResults(JSON.parse(content), locales);
197
+ if (!results) {
198
+ return {
199
+ error: `Mistral did not return a usable alt text for every requested locale (${locales.join(', ')})`,
200
+ success: false
201
+ };
202
+ }
203
+ return {
204
+ results,
205
+ success: true
206
+ };
207
+ } catch (error) {
208
+ console.error('Error generating alt text:', error);
209
+ return {
210
+ error: error instanceof Error ? error.message : 'Unknown error',
211
+ success: false
212
+ };
213
+ }
214
+ }
215
+ /**
216
+ * Creates a Mistral-based resolver for alt text generation.
217
+ *
218
+ * @example
219
+ * ```typescript
220
+ * import { mistralResolver } from '@jhb.software/payload-alt-text-plugin'
221
+ *
222
+ * mistralResolver({
223
+ * apiKey: process.env.MISTRAL_API_KEY,
224
+ * model: 'mistral-medium-latest', // optional, this is the default
225
+ * })
226
+ * ```
227
+ */ export const mistralResolver = (config)=>{
228
+ const { apiKey, baseUrl = 'https://api.mistral.ai/v1', model = 'mistral-medium-latest', timeoutMs = 30_000 } = config;
229
+ return {
230
+ key: 'mistral',
231
+ resolve: async ({ filename, imageThumbnailUrl, locale })=>{
232
+ const result = await generate({
233
+ apiKey,
234
+ baseUrl,
235
+ filename,
236
+ imageThumbnailUrl,
237
+ locales: [
238
+ locale
239
+ ],
240
+ model,
241
+ timeoutMs
242
+ });
243
+ if (!result.success) {
244
+ return {
245
+ error: result.error,
246
+ success: false
247
+ };
248
+ }
249
+ return {
250
+ result: result.results[locale],
251
+ success: true
252
+ };
253
+ },
254
+ resolveBulk: async ({ filename, imageThumbnailUrl, locales })=>{
255
+ const result = await generate({
256
+ apiKey,
257
+ baseUrl,
258
+ filename,
259
+ imageThumbnailUrl,
260
+ locales,
261
+ model,
262
+ timeoutMs
263
+ });
264
+ if (!result.success) {
265
+ return {
266
+ error: result.error,
267
+ success: false
268
+ };
269
+ }
270
+ return {
271
+ results: result.results,
272
+ success: true
273
+ };
274
+ },
275
+ // https://docs.mistral.ai/capabilities/vision/
276
+ supportedMimeTypes: SUPPORTED_MIME_TYPES
277
+ };
278
+ };
279
+
280
+ //# sourceMappingURL=mistral.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../src/resolvers/mistral.ts"],"sourcesContent":["import { z } from 'zod'\n\nimport type {\n AltTextBulkResolverArgs,\n AltTextBulkResolverResponse,\n AltTextResolver,\n AltTextResolverArgs,\n AltTextResolverResponse,\n AltTextResult,\n} from './types.js'\n\nexport type MistralResolverConfig = {\n /** Mistral API key for authentication */\n apiKey: string\n /**\n * Base URL of the Mistral API.\n * @default 'https://api.mistral.ai/v1'\n */\n baseUrl?: string\n /**\n * The vision-capable Mistral model to use for alt text generation.\n *\n * Must be able to read images — `mistral-medium-latest`,\n * `mistral-large-latest`, `mistral-small-latest` and the `ministral-*` models\n * all are.\n *\n * @default 'mistral-medium-latest'\n */\n model?: string\n /**\n * Abort after this many milliseconds. Covers downloading the image and the\n * completion call together.\n * @default 30000\n */\n timeoutMs?: number\n}\n\n/**\n * Image formats the Mistral API accepts.\n *\n * Narrower than what an upload collection may hold — SVG and AVIF are missing,\n * so the endpoint rejects those documents and their generate button stays\n * disabled instead of failing at the provider.\n */\nconst SUPPORTED_MIME_TYPES = ['image/jpeg', 'image/png', 'image/gif', 'image/webp']\n\n/** Mistral rejects images above 20 MB. */\nconst MAX_IMAGE_BYTES = 20 * 1024 * 1024\n\nconst altTextSchema = 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/** One schema entry per requested locale, so the model must answer for all of them. */\nconst schemaForLocales = (locales: string[]) =>\n z.object(Object.fromEntries(locales.map((locale) => [locale, altTextSchema])))\n\n/**\n * Downloads the image and returns it as a data URI.\n *\n * Mistral can fetch an image URL itself, but that path is not dependable for a\n * CMS. It requires the file to be reachable from the public internet — never\n * true in local development, and not true for private buckets — and some hosts\n * refuse Mistral's fetcher outright, which surfaces as `File could not be\n * fetched from url` (error 3310). Sending the bytes removes that whole class of\n * failure for the price of one extra download.\n */\nasync function fetchImageAsDataUri(\n url: string,\n signal: AbortSignal,\n): Promise<{ dataUri: string } | { error: string }> {\n let response: Response\n\n try {\n response = await fetch(url, { signal })\n } catch (error) {\n return {\n error: `Could not download the image from ${url}: ${error instanceof Error ? error.message : 'unknown error'}`,\n }\n }\n\n if (!response.ok) {\n return { error: `Could not download the image from ${url}: status ${response.status}` }\n }\n\n // The document's mime type is checked by the endpoint before the resolver\n // runs, but `getImageThumbnail` may point at a derivative in a different\n // format, so trust what was actually served.\n const contentType = response.headers.get('content-type')?.split(';')[0]?.trim().toLowerCase()\n\n if (!contentType || !SUPPORTED_MIME_TYPES.includes(contentType)) {\n return {\n error: `The image at ${url} was served as \"${contentType ?? 'an unknown type'}\", which Mistral cannot read. Supported types: ${SUPPORTED_MIME_TYPES.join(', ')}.`,\n }\n }\n\n const bytes = Buffer.from(await response.arrayBuffer())\n\n if (bytes.byteLength === 0) {\n return { error: `The image at ${url} was empty.` }\n }\n\n if (bytes.byteLength > MAX_IMAGE_BYTES) {\n return {\n error: `The image at ${url} is ${Math.round(bytes.byteLength / 1024 / 1024)} MB, above Mistral's 20 MB limit. Point getImageThumbnail at a smaller image size.`,\n }\n }\n\n return { dataUri: `data:${contentType};base64,${bytes.toString('base64')}` }\n}\n\nfunction buildPrompt(locales: string[]): string {\n const languages = locales.join(', ')\n\n return `\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 ${languages}:\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 with ${locales.map((locale) => `\"${locale}\"`).join(', ')} keys, each containing \"altText\" and \"keywords\".\n `\n}\n\n/**\n * Reads the model's response.\n *\n * Deliberately strict about a blank `altText`: the field is required on the\n * collection, so an empty string would satisfy that requirement while telling a\n * screen reader nothing — and nobody looks at an alt text again once it is set.\n */\nfunction parseResults(content: unknown, locales: string[]): null | Record<string, AltTextResult> {\n const parsed = schemaForLocales(locales).safeParse(content)\n\n if (!parsed.success) {\n return null\n }\n\n const results: Record<string, AltTextResult> = {}\n\n for (const locale of locales) {\n const entry = parsed.data[locale]\n\n if (entry.altText.trim().length === 0) {\n return null\n }\n\n results[locale] = { altText: entry.altText.trim(), keywords: entry.keywords }\n }\n\n return results\n}\n\n/**\n * One request, one or more locales.\n *\n * All locales go into a single call rather than one call each: the image is\n * uploaded and analyzed once — the expensive part — and every language ends up\n * describing the same reading of it.\n */\nasync function generate({\n apiKey,\n baseUrl,\n filename,\n imageThumbnailUrl,\n locales,\n model,\n timeoutMs,\n}: {\n apiKey: string\n baseUrl: string\n filename?: string\n imageThumbnailUrl: string\n locales: string[]\n model: string\n timeoutMs: number\n}): Promise<\n { error: string; success: false } | { results: Record<string, AltTextResult>; success: true }\n> {\n if (!apiKey) {\n return { error: 'No Mistral API key configured', success: false }\n }\n\n if (locales.length === 0) {\n return { error: 'No locale requested', success: false }\n }\n\n const signal = AbortSignal.timeout(timeoutMs)\n const image = await fetchImageAsDataUri(imageThumbnailUrl, signal)\n\n if ('error' in image) {\n return { error: image.error, success: false }\n }\n\n try {\n const response = await fetch(`${baseUrl}/chat/completions`, {\n body: JSON.stringify({\n max_tokens: 150 * locales.length,\n messages: [\n { content: buildPrompt(locales), role: 'system' },\n {\n content: [\n { type: 'image_url', image_url: image.dataUri },\n ...(filename ? [{ type: 'text', text: filename }] : []),\n ],\n role: 'user',\n },\n ],\n model,\n response_format: {\n type: 'json_schema',\n json_schema: {\n name: 'data',\n schema: z.toJSONSchema(schemaForLocales(locales), { target: 'draft-7' }),\n strict: true,\n },\n },\n }),\n headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json' },\n method: 'POST',\n signal,\n })\n\n if (!response.ok) {\n const body = await response.text().catch(() => '')\n\n return {\n error: `Mistral responded with status ${response.status}${body ? `: ${body}` : ''}`,\n success: false,\n }\n }\n\n const completion = (await response.json()) as {\n choices?: { message?: { content?: unknown } }[]\n }\n const content = completion.choices?.[0]?.message?.content\n\n if (typeof content !== 'string') {\n return { error: 'No result from Mistral', success: false }\n }\n\n const results = parseResults(JSON.parse(content), locales)\n\n if (!results) {\n return {\n error: `Mistral did not return a usable alt text for every requested locale (${locales.join(', ')})`,\n success: false,\n }\n }\n\n return { results, success: true }\n } catch (error) {\n console.error('Error generating alt text:', error)\n\n return { error: error instanceof Error ? error.message : 'Unknown error', success: false }\n }\n}\n\n/**\n * Creates a Mistral-based resolver for alt text generation.\n *\n * @example\n * ```typescript\n * import { mistralResolver } from '@jhb.software/payload-alt-text-plugin'\n *\n * mistralResolver({\n * apiKey: process.env.MISTRAL_API_KEY,\n * model: 'mistral-medium-latest', // optional, this is the default\n * })\n * ```\n */\nexport const mistralResolver = (config: MistralResolverConfig): AltTextResolver => {\n const {\n apiKey,\n baseUrl = 'https://api.mistral.ai/v1',\n model = 'mistral-medium-latest',\n timeoutMs = 30_000,\n } = config\n\n return {\n key: 'mistral',\n resolve: async ({\n filename,\n imageThumbnailUrl,\n locale,\n }: AltTextResolverArgs): Promise<AltTextResolverResponse> => {\n const result = await generate({\n apiKey,\n baseUrl,\n filename,\n imageThumbnailUrl,\n locales: [locale],\n model,\n timeoutMs,\n })\n\n if (!result.success) {\n return { error: result.error, success: false }\n }\n\n return { result: result.results[locale], success: true }\n },\n resolveBulk: async ({\n filename,\n imageThumbnailUrl,\n locales,\n }: AltTextBulkResolverArgs): Promise<AltTextBulkResolverResponse> => {\n const result = await generate({\n apiKey,\n baseUrl,\n filename,\n imageThumbnailUrl,\n locales,\n model,\n timeoutMs,\n })\n\n if (!result.success) {\n return { error: result.error, success: false }\n }\n\n return { results: result.results, success: true }\n },\n // https://docs.mistral.ai/capabilities/vision/\n supportedMimeTypes: SUPPORTED_MIME_TYPES,\n }\n}\n"],"names":["z","SUPPORTED_MIME_TYPES","MAX_IMAGE_BYTES","altTextSchema","object","altText","string","describe","keywords","array","schemaForLocales","locales","Object","fromEntries","map","locale","fetchImageAsDataUri","url","signal","response","fetch","error","Error","message","ok","status","contentType","headers","get","split","trim","toLowerCase","includes","join","bytes","Buffer","from","arrayBuffer","byteLength","Math","round","dataUri","toString","buildPrompt","languages","parseResults","content","parsed","safeParse","success","results","entry","data","length","generate","apiKey","baseUrl","filename","imageThumbnailUrl","model","timeoutMs","AbortSignal","timeout","image","body","JSON","stringify","max_tokens","messages","role","type","image_url","text","response_format","json_schema","name","schema","toJSONSchema","target","strict","Authorization","method","catch","completion","json","choices","parse","console","mistralResolver","config","key","resolve","result","resolveBulk","supportedMimeTypes"],"mappings":"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"}
@@ -13,6 +13,16 @@ export type OpenAIResolverConfig = {
13
13
  * @default 'gpt-4.1-nano'
14
14
  */
15
15
  model?: string;
16
+ /**
17
+ * The MIME types the provider accepts for the image URL.
18
+ *
19
+ * Defaults to the formats documented for OpenAI's vision models. Override it
20
+ * when pointing `baseUrl` at another provider whose accepted formats differ —
21
+ * the person choosing the provider is the one who knows.
22
+ *
23
+ * @default ['image/jpeg', 'image/png', 'image/gif', 'image/webp']
24
+ */
25
+ supportedMimeTypes?: string[];
16
26
  };
17
27
  /**
18
28
  * Creates an OpenAI-based resolver for alt text generation.
@@ -1,6 +1,12 @@
1
1
  import OpenAI from 'openai';
2
2
  import { makeParseableResponseFormat } from 'openai/lib/parser.mjs';
3
3
  import { z } from 'zod';
4
+ /** @see https://platform.openai.com/docs/guides/images-vision */ const OPENAI_SUPPORTED_MIME_TYPES = [
5
+ 'image/jpeg',
6
+ 'image/png',
7
+ 'image/gif',
8
+ 'image/webp'
9
+ ];
4
10
  /**
5
11
  * Creates a chat completion `JSONSchema` response format object from
6
12
  * the given Zod schema.
@@ -181,13 +187,7 @@ import { z } from 'zod';
181
187
  };
182
188
  }
183
189
  },
184
- // https://platform.openai.com/docs/guides/images-vision
185
- supportedMimeTypes: [
186
- 'image/jpeg',
187
- 'image/png',
188
- 'image/gif',
189
- 'image/webp'
190
- ]
190
+ supportedMimeTypes: config.supportedMimeTypes ?? OPENAI_SUPPORTED_MIME_TYPES
191
191
  };
192
192
  };
193
193
 
@@ -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\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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"}
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 * The MIME types the provider accepts for the image URL.\n *\n * Defaults to the formats documented for OpenAI's vision models. Override it\n * when pointing `baseUrl` at another provider whose accepted formats differ —\n * the person choosing the provider is the one who knows.\n *\n * @default ['image/jpeg', 'image/png', 'image/gif', 'image/webp']\n */\n supportedMimeTypes?: string[]\n}\n\n/** @see https://platform.openai.com/docs/guides/images-vision */\nconst OPENAI_SUPPORTED_MIME_TYPES = ['image/jpeg', 'image/png', 'image/gif', 'image/webp']\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 supportedMimeTypes: config.supportedMimeTypes ?? OPENAI_SUPPORTED_MIME_TYPES,\n }\n}\n"],"names":["OpenAI","makeParseableResponseFormat","z","OPENAI_SUPPORTED_MIME_TYPES","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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"}
@@ -14,6 +14,16 @@ export type AltTextResult = {
14
14
  export type AltTextResolverArgs = {
15
15
  /** Optional filename for additional context */
16
16
  filename?: string;
17
+ /**
18
+ * The format served at `imageThumbnailUrl`, when the collection declares one
19
+ * via the plugin's `imageThumbnailMimeType` option. Undefined otherwise.
20
+ *
21
+ * Resolvers that hand the URL to the provider can ignore this. Resolvers that
22
+ * inline the bytes need it — Anthropic image blocks require `media_type`,
23
+ * Gemini's `inline_data` requires `mime_type`, and neither can be sniffed from
24
+ * a URL.
25
+ */
26
+ imageThumbnailMimeType?: string;
17
27
  /** URL of the image thumbnail (must be publicly accessible) */
18
28
  imageThumbnailUrl: string;
19
29
  /** Target locale for the generated alt text */
@@ -27,6 +37,11 @@ export type AltTextResolverArgs = {
27
37
  export type AltTextBulkResolverArgs = {
28
38
  /** Optional filename for additional context */
29
39
  filename?: string;
40
+ /**
41
+ * The format served at `imageThumbnailUrl`, when the collection declares one
42
+ * via `imageThumbnailMimeType`. See {@link AltTextResolverArgs.imageThumbnailMimeType}.
43
+ */
44
+ imageThumbnailMimeType?: string;
30
45
  /** URL of the image thumbnail (must be publicly accessible) */
31
46
  imageThumbnailUrl: string;
32
47
  /** Target locales for the generated alt texts */
@@ -65,6 +80,14 @@ export type AltTextResolver = {
65
80
  resolve: (args: AltTextResolverArgs) => Promise<AltTextResolverResponse>;
66
81
  /** Generate alt text for a single image in multiple locales (bulk operation) */
67
82
  resolveBulk: (args: AltTextBulkResolverArgs) => Promise<AltTextBulkResolverResponse>;
68
- /** MIME types this resolver can process. When set, the endpoint rejects files whose mimeType is not in this list. */
83
+ /**
84
+ * Formats the provider accepts for the bytes served at `imageThumbnailUrl`.
85
+ *
86
+ * When set, the endpoints reject documents whose stored `mimeType` is not in
87
+ * this list — a conservative proxy, since the resolver never sees the stored
88
+ * file. A project whose `getImageThumbnail` transcodes should declare the
89
+ * delivered format via the plugin's `imageThumbnailMimeType` option, which
90
+ * replaces the proxy with a one-time check against this list at config load.
91
+ */
69
92
  supportedMimeTypes?: string[];
70
93
  };