@jhb.software/payload-alt-text-plugin 0.4.4 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -12,20 +12,17 @@ A [Payload CMS](https://payloadcms.com/) plugin that adds AI-powered alt text ge
12
12
  - Full localization support
13
13
  - Dashboard health widget with cached coverage insights across all configured upload collections
14
14
 
15
-
16
15
  When the plugin is enabled for an upload collection, it will:
17
16
 
18
17
  1. Add an alt text field to the collection
19
18
  - A button to AI-generate the alt text
20
19
  - This field will include a description of what the alt text should be
21
20
  2. Add a keywords fields to the collection
22
- - This field will be automatically filled when generating the alt text
21
+ - This field will be automatically filled when generating the alt text
23
22
  - It will be used for improving the search of images in the admin panel
24
- 2. Add a bulk generate button to the collection list view
23
+ 3. Add a bulk generate button to the collection list view
25
24
  - This button will allow you to generate alt text for multiple images at once
26
- 3. Register an `Alt text health` dashboard widget
27
- - The widget is available in Payload's dashboard editor
28
- - It is added to the default dashboard layout for first-time and reset layouts
25
+ 4. Register an `Alt text health` dashboard widget
29
26
  - Results are cached and revalidated when documents in the configured upload collections change
30
27
 
31
28
  ## Installation
@@ -39,10 +36,7 @@ pnpm add @jhb.software/payload-alt-text-plugin
39
36
  Install the plugin and add it to your Payload config:
40
37
 
41
38
  ```ts
42
- import {
43
- payloadAltTextPlugin,
44
- openAIResolver,
45
- } from '@jhb.software/payload-alt-text-plugin'
39
+ import { payloadAltTextPlugin, openAIResolver } from '@jhb.software/payload-alt-text-plugin'
46
40
 
47
41
  export default buildConfig({
48
42
  plugins: [
@@ -60,25 +54,60 @@ export default buildConfig({
60
54
 
61
55
  Note: When localization is disabled in your Payload config (default), you need to specify the locale to generate the alt texts in via the `locale` plugin option.
62
56
 
57
+ ### Admin list search
58
+
59
+ By default, the plugin sets `admin.listSearchableFields` on the configured upload collections to `['filename', 'keywords', 'alt']` so the admin list-view search matches against these fields. To opt out, set `admin.listSearchableFields` on the collection yourself — any explicit value is preserved as-is:
60
+
61
+ ```ts
62
+ {
63
+ slug: 'media',
64
+ upload: true,
65
+ admin: {
66
+ listSearchableFields: ['filename', 'alt'],
67
+ },
68
+ // ...
69
+ }
70
+ ```
71
+
72
+ This is also the recommended escape hatch if you hit Payload's Postgres SQL-builder bug for `hasMany` localized text fields in `listSearchableFields` (see [#92](https://github.com/jhb-software/payload-plugins/issues/92)).
73
+
63
74
  ## Configuration
64
75
 
65
76
  ### Plugin Options
66
77
 
67
- | Option | Type | Required | Description |
68
- | ---------------------------- | ------------------ | -------- | ------------------------------------------------------------------------ |
69
- | `collections` | `CollectionSlug[]` | Yes | Collections to enable alt text generation for |
70
- | `resolver` | `AltTextResolver` | Yes | Alt text resolver to use (e.g., `openAIResolver`) |
71
- | `getImageThumbnail` | `Function` | Yes | Function to get the thumbnail URL from an image document |
72
- | `enabled` | `boolean` | No | Whether to enable the plugin |
73
- | `locale` | `string` | No | Locale for alt text generation (required when localization is disabled) |
74
- | `maxBulkGenerateConcurrency` | `number` | No | Maximum concurrent API requests for bulk operations (default: 16) |
75
- | `fieldsOverride` | `Function` | No | Override the default fields inserted by the plugin |
78
+ | Option | Type | Required | Description |
79
+ | ---------------------------- | ------------------ | -------- | ---------------------------------------------------------------------------------------------------------------- |
80
+ | `collections` | `CollectionSlug[]` | Yes | Collections to enable alt text generation for |
81
+ | `resolver` | `AltTextResolver` | Yes | Alt text resolver to use (e.g., `openAIResolver`) |
82
+ | `getImageThumbnail` | `Function` | Yes | Function to get the thumbnail URL from an image document |
83
+ | `enabled` | `boolean` | No | Whether to enable the plugin |
84
+ | `locale` | `string` | No | Locale for alt text generation (required when localization is disabled) |
85
+ | `maxBulkGenerateConcurrency` | `number` | No | Maximum concurrent API requests for bulk operations (default: 16) |
86
+ | `fieldsOverride` | `Function` | No | Override the default fields inserted by the plugin |
76
87
  | `healthCheck` | `boolean` | No | Enable alt text health tracking: REST endpoint, cache revalidation hooks, and dashboard widget (default: `true`) |
77
88
 
78
89
  ## Dashboard Widget
79
90
 
80
91
  The plugin registers an `Alt text health` dashboard widget that shows alt text coverage across all configured upload collections, with cached queries that revalidate on document changes. Collections with missing alt text show a clickable badge linking to the affected images.
81
92
 
93
+ <img width="696" height="246" alt="image" src="https://github.com/user-attachments/assets/75df7349-0307-4047-b1ac-6b2ee0814464" />
94
+
95
+ The widget is registered under `admin.dashboard.widgets` with the slug `alt-text-health`. To show it by default on the dashboard, add it to your `admin.dashboard.defaultLayout`:
96
+
97
+ ```ts
98
+ buildConfig({
99
+ admin: {
100
+ dashboard: {
101
+ defaultLayout: [
102
+ // ...other default widgets
103
+ { widgetSlug: 'alt-text-health', width: 'full' },
104
+ ],
105
+ },
106
+ },
107
+ // ...
108
+ })
109
+ ```
110
+
82
111
  Set `healthCheck: false` in the plugin config to disable the REST endpoint, cache revalidation hooks, and dashboard widget. If your project replaces the default dashboard via `admin.components.views.dashboard`, you need to integrate the widget into your custom dashboard yourself.
83
112
 
84
113
  ### Resolvers
@@ -117,12 +146,12 @@ export const customResolver = (): AltTextResolver => ({
117
146
  }
118
147
  },
119
148
  resolveBulk: async ({ imageThumbnailUrl, filename, locales, req }) => {
120
- // Your custom alt text generation logic here
149
+ // Your custom alt text generation logic here
121
150
  const altTexts = await generateAltTextBulk(imageThumbnailUrl, filename, locales, req)
122
151
 
123
- return {
124
- success: true,
125
- results: altTexts
152
+ return {
153
+ success: true,
154
+ results: altTexts,
126
155
  }
127
156
  },
128
157
  })
@@ -138,12 +167,12 @@ Generates alt text for a single image. By default, returns the result without sa
138
167
 
139
168
  **Request body:**
140
169
 
141
- | Field | Type | Required | Description |
142
- | ------------ | ------------------ | -------- | ------------------------------------------------------------------------ |
143
- | `id` | `string \| number` | Yes | The document ID |
144
- | `collection` | `string` | Yes | The collection slug |
145
- | `locale` | `string \| null` | Yes | Target locale (use `null` for non-localized setups) |
146
- | `update` | `boolean` | No | When `true`, persists the result to the document (default: `false`) |
170
+ | Field | Type | Required | Description |
171
+ | ------------ | ------------------ | -------- | ------------------------------------------------------------------- |
172
+ | `id` | `string \| number` | Yes | The document ID |
173
+ | `collection` | `string` | Yes | The collection slug |
174
+ | `locale` | `string \| null` | Yes | Target locale (use `null` for non-localized setups) |
175
+ | `update` | `boolean` | No | When `true`, persists the result to the document (default: `false`) |
147
176
 
148
177
  **Response:**
149
178
 
@@ -162,10 +191,10 @@ Generates and persists alt text for multiple images across all configured locale
162
191
 
163
192
  **Request body:**
164
193
 
165
- | Field | Type | Required | Description |
166
- | ------------ | -------------------- | -------- | ------------------------------------------------------------------- |
167
- | `collection` | `string` | Yes | The collection slug |
168
- | `ids` | `(string \| number)[]` | Yes | Array of document IDs to process |
194
+ | Field | Type | Required | Description |
195
+ | ------------ | ---------------------- | -------- | -------------------------------- |
196
+ | `collection` | `string` | Yes | The collection slug |
197
+ | `ids` | `(string \| number)[]` | Yes | Array of document IDs to process |
169
198
 
170
199
  **Response:**
171
200
 
@@ -1,2 +1,2 @@
1
1
  import type { TextareaFieldClientProps } from 'payload';
2
- export declare const AltTextField: (clientProps: TextareaFieldClientProps) => import("react").JSX.Element;
2
+ export declare const AltTextField: (clientProps: TextareaFieldClientProps) => import("react").JSX.Element | null;
@@ -1,14 +1,23 @@
1
1
  'use client';
2
2
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
3
  import { FieldLabel, TextareaInput, useDocumentInfo, useField } from '@payloadcms/ui';
4
+ import { matchesMimeType } from '../utilities/mimeTypes.js';
4
5
  import { GenerateAltTextButton } from './GenerateAltTextButton.js';
5
6
  export const AltTextField = (clientProps)=>{
6
7
  const { field, path } = clientProps;
7
8
  const supportedMimeTypes = field.admin?.custom?.supportedMimeTypes;
9
+ const trackedMimeTypes = field.admin?.custom?.trackedMimeTypes;
8
10
  const { setValue, value } = useField({
9
11
  path
10
12
  });
11
13
  const { id } = useDocumentInfo();
14
+ const { value: mimeType } = useField({
15
+ path: 'mimeType'
16
+ });
17
+ const isTrackedMimeType = !trackedMimeTypes || trackedMimeTypes.length === 0 || !!mimeType && matchesMimeType(mimeType, trackedMimeTypes);
18
+ if (!isTrackedMimeType) {
19
+ return null;
20
+ }
12
21
  // the field should be optional when the document is created
13
22
  // (since the alt text generation can only be used once the document is created and the image uploaded)
14
23
  const required = id ? field.required : false;
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/components/AltTextField.tsx"],"sourcesContent":["'use client'\n\nimport type { TextareaFieldClientProps } from 'payload'\n\nimport { FieldLabel, TextareaInput, useDocumentInfo, useField } from '@payloadcms/ui'\n\nimport { GenerateAltTextButton } from './GenerateAltTextButton.js'\n\nexport const AltTextField = (clientProps: TextareaFieldClientProps) => {\n const { field, path } = clientProps\n\n const supportedMimeTypes = field.admin?.custom?.supportedMimeTypes as string[] | undefined\n\n const { setValue, value } = useField<string>({ path })\n const { id } = useDocumentInfo()\n\n // the field should be optional when the document is created\n // (since the alt text generation can only be used once the document is created and the image uploaded)\n const required = id ? field.required : false\n\n return (\n <div className=\"field-type textarea\" style={{ flex: '1 1 auto' }}>\n <FieldLabel\n htmlFor={`field-${path}`}\n label={field.label}\n localized={field.localized}\n required={required}\n />\n\n <div className=\"field-type__wrap\">\n <TextareaInput\n AfterInput={<GenerateAltTextButton supportedMimeTypes={supportedMimeTypes} />}\n onChange={(e: React.ChangeEvent<HTMLTextAreaElement>) => setValue(e.target.value)}\n path={path}\n required={required}\n value={value}\n />\n </div>\n </div>\n )\n}\n"],"names":["FieldLabel","TextareaInput","useDocumentInfo","useField","GenerateAltTextButton","AltTextField","clientProps","field","path","supportedMimeTypes","admin","custom","setValue","value","id","required","div","className","style","flex","htmlFor","label","localized","AfterInput","onChange","e","target"],"mappings":"AAAA;;AAIA,SAASA,UAAU,EAAEC,aAAa,EAAEC,eAAe,EAAEC,QAAQ,QAAQ,iBAAgB;AAErF,SAASC,qBAAqB,QAAQ,6BAA4B;AAElE,OAAO,MAAMC,eAAe,CAACC;IAC3B,MAAM,EAAEC,KAAK,EAAEC,IAAI,EAAE,GAAGF;IAExB,MAAMG,qBAAqBF,MAAMG,KAAK,EAAEC,QAAQF;IAEhD,MAAM,EAAEG,QAAQ,EAAEC,KAAK,EAAE,GAAGV,SAAiB;QAAEK;IAAK;IACpD,MAAM,EAAEM,EAAE,EAAE,GAAGZ;IAEf,4DAA4D;IAC5D,uGAAuG;IACvG,MAAMa,WAAWD,KAAKP,MAAMQ,QAAQ,GAAG;IAEvC,qBACE,MAACC;QAAIC,WAAU;QAAsBC,OAAO;YAAEC,MAAM;QAAW;;0BAC7D,KAACnB;gBACCoB,SAAS,CAAC,MAAM,EAAEZ,MAAM;gBACxBa,OAAOd,MAAMc,KAAK;gBAClBC,WAAWf,MAAMe,SAAS;gBAC1BP,UAAUA;;0BAGZ,KAACC;gBAAIC,WAAU;0BACb,cAAA,KAAChB;oBACCsB,0BAAY,KAACnB;wBAAsBK,oBAAoBA;;oBACvDe,UAAU,CAACC,IAA8Cb,SAASa,EAAEC,MAAM,CAACb,KAAK;oBAChFL,MAAMA;oBACNO,UAAUA;oBACVF,OAAOA;;;;;AAKjB,EAAC"}
1
+ {"version":3,"sources":["../../src/components/AltTextField.tsx"],"sourcesContent":["'use client'\n\nimport type { TextareaFieldClientProps } from 'payload'\n\nimport { FieldLabel, TextareaInput, useDocumentInfo, useField } from '@payloadcms/ui'\n\nimport { matchesMimeType } from '../utilities/mimeTypes.js'\nimport { GenerateAltTextButton } from './GenerateAltTextButton.js'\n\nexport const AltTextField = (clientProps: TextareaFieldClientProps) => {\n const { field, path } = clientProps\n\n const supportedMimeTypes = field.admin?.custom?.supportedMimeTypes as string[] | undefined\n const trackedMimeTypes = field.admin?.custom?.trackedMimeTypes as string[] | undefined\n\n const { setValue, value } = useField<string>({ path })\n const { id } = useDocumentInfo()\n const { value: mimeType } = useField<string>({ path: 'mimeType' })\n\n const isTrackedMimeType =\n !trackedMimeTypes ||\n trackedMimeTypes.length === 0 ||\n (!!mimeType && matchesMimeType(mimeType, trackedMimeTypes))\n\n if (!isTrackedMimeType) {\n return null\n }\n\n // the field should be optional when the document is created\n // (since the alt text generation can only be used once the document is created and the image uploaded)\n const required = id ? field.required : false\n\n return (\n <div className=\"field-type textarea\" style={{ flex: '1 1 auto' }}>\n <FieldLabel\n htmlFor={`field-${path}`}\n label={field.label}\n localized={field.localized}\n required={required}\n />\n\n <div className=\"field-type__wrap\">\n <TextareaInput\n AfterInput={<GenerateAltTextButton supportedMimeTypes={supportedMimeTypes} />}\n onChange={(e: React.ChangeEvent<HTMLTextAreaElement>) => setValue(e.target.value)}\n path={path}\n required={required}\n value={value}\n />\n </div>\n </div>\n )\n}\n"],"names":["FieldLabel","TextareaInput","useDocumentInfo","useField","matchesMimeType","GenerateAltTextButton","AltTextField","clientProps","field","path","supportedMimeTypes","admin","custom","trackedMimeTypes","setValue","value","id","mimeType","isTrackedMimeType","length","required","div","className","style","flex","htmlFor","label","localized","AfterInput","onChange","e","target"],"mappings":"AAAA;;AAIA,SAASA,UAAU,EAAEC,aAAa,EAAEC,eAAe,EAAEC,QAAQ,QAAQ,iBAAgB;AAErF,SAASC,eAAe,QAAQ,4BAA2B;AAC3D,SAASC,qBAAqB,QAAQ,6BAA4B;AAElE,OAAO,MAAMC,eAAe,CAACC;IAC3B,MAAM,EAAEC,KAAK,EAAEC,IAAI,EAAE,GAAGF;IAExB,MAAMG,qBAAqBF,MAAMG,KAAK,EAAEC,QAAQF;IAChD,MAAMG,mBAAmBL,MAAMG,KAAK,EAAEC,QAAQC;IAE9C,MAAM,EAAEC,QAAQ,EAAEC,KAAK,EAAE,GAAGZ,SAAiB;QAAEM;IAAK;IACpD,MAAM,EAAEO,EAAE,EAAE,GAAGd;IACf,MAAM,EAAEa,OAAOE,QAAQ,EAAE,GAAGd,SAAiB;QAAEM,MAAM;IAAW;IAEhE,MAAMS,oBACJ,CAACL,oBACDA,iBAAiBM,MAAM,KAAK,KAC3B,CAAC,CAACF,YAAYb,gBAAgBa,UAAUJ;IAE3C,IAAI,CAACK,mBAAmB;QACtB,OAAO;IACT;IAEA,4DAA4D;IAC5D,uGAAuG;IACvG,MAAME,WAAWJ,KAAKR,MAAMY,QAAQ,GAAG;IAEvC,qBACE,MAACC;QAAIC,WAAU;QAAsBC,OAAO;YAAEC,MAAM;QAAW;;0BAC7D,KAACxB;gBACCyB,SAAS,CAAC,MAAM,EAAEhB,MAAM;gBACxBiB,OAAOlB,MAAMkB,KAAK;gBAClBC,WAAWnB,MAAMmB,SAAS;gBAC1BP,UAAUA;;0BAGZ,KAACC;gBAAIC,WAAU;0BACb,cAAA,KAACrB;oBACC2B,0BAAY,KAACvB;wBAAsBK,oBAAoBA;;oBACvDmB,UAAU,CAACC,IAA8ChB,SAASgB,EAAEC,MAAM,CAAChB,KAAK;oBAChFN,MAAMA;oBACNW,UAAUA;oBACVL,OAAOA;;;;;AAKjB,EAAC"}
@@ -1,6 +1,7 @@
1
1
  import pMap from 'p-map';
2
2
  import { z, ZodError } from 'zod';
3
3
  import { localesFromConfig } from '../utilities/localesFromConfig.js';
4
+ import { matchesMimeType } from '../utilities/mimeTypes.js';
4
5
  /**
5
6
  * Generates and updates alt text for multiple images in all locales.
6
7
  */ export const bulkGenerateAltTextsEndpoint = (access)=>async (req)=>{
@@ -111,6 +112,10 @@ async function generateAndUpdateAltText({ id, collection, locales, payload, plug
111
112
  throw new Error('Image not found');
112
113
  }
113
114
  const mimeType = 'mimeType' in imageDoc && typeof imageDoc.mimeType === 'string' ? imageDoc.mimeType : undefined;
115
+ const collectionConfig = pluginConfig.collections.find((entry)=>entry.slug === collection);
116
+ if (mimeType && collectionConfig && !matchesMimeType(mimeType, collectionConfig.mimeTypes)) {
117
+ throw new Error(`Alt text is not tracked for files of type "${mimeType}" in the "${collection}" collection. Tracked types: ${collectionConfig.mimeTypes.join(', ')}.`);
118
+ }
114
119
  if (mimeType && pluginConfig.resolver.supportedMimeTypes && !pluginConfig.resolver.supportedMimeTypes.includes(mimeType)) {
115
120
  throw new Error(`Alt text generation is not supported for files of type "${mimeType}". Supported types: ${pluginConfig.resolver.supportedMimeTypes.join(', ')}.`);
116
121
  }
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/endpoints/bulkGenerateAltTexts.ts"],"sourcesContent":["import type { BasePayload, CollectionSlug, PayloadHandler, PayloadRequest } from 'payload'\n\nimport pMap from 'p-map'\nimport { z, ZodError } from 'zod'\n\nimport type { AltTextPluginConfig } from '../types/AltTextPluginConfig.js'\n\nimport { localesFromConfig } from '../utilities/localesFromConfig.js'\n\n/**\n * Generates and updates alt text for multiple images in all locales.\n */\nexport const bulkGenerateAltTextsEndpoint =\n (access: AltTextPluginConfig['access']): PayloadHandler =>\n async (req: PayloadRequest) => {\n try {\n if (!(await access({ req }))) {\n return Response.json({ error: 'Unauthorized' }, { status: 401 })\n }\n\n const data = 'json' in req && typeof req.json === 'function' ? await req.json() : null\n\n const schema = z.object({\n collection: z.string(),\n ids: z.array(z.union([z.string(), z.number()])),\n })\n\n const { collection, ids } = schema.parse(data)\n\n let updatedDocs = 0\n const erroredDocs: (number | string)[] = []\n\n // Get plugin config from payload config\n const pluginConfig = req.payload.config.custom?.altTextPluginConfig as\n | AltTextPluginConfig\n | undefined\n\n if (!pluginConfig) {\n return Response.json({ error: 'Plugin config not found' }, { status: 500 })\n }\n\n if (!pluginConfig.resolver) {\n return Response.json({ error: 'No alt text resolver configured' }, { status: 500 })\n }\n\n const concurrency = pluginConfig.maxBulkGenerateConcurrency\n\n // determine target locales based on config\n const locales = localesFromConfig(req.payload.config)\n const targetLocales = locales ?? [pluginConfig.locale!]\n if (!targetLocales) {\n return Response.json(\n {\n error:\n 'Could not determine target locales for alt text generation. Please check your plugin configuration.',\n },\n { status: 500 },\n )\n }\n\n await pMap(\n ids,\n async (id) => {\n try {\n await generateAndUpdateAltText({\n id,\n collection,\n locales: targetLocales,\n payload: req.payload,\n pluginConfig,\n req,\n })\n updatedDocs++\n console.log(\n `${updatedDocs}/${ids.length} updated (${Math.round((updatedDocs / ids.length) * 100)}%)`,\n )\n } catch (error) {\n console.error(`Error generating alt text for ${id}:`, error)\n erroredDocs.push(id)\n }\n },\n { concurrency },\n )\n\n if (erroredDocs.length > 0) {\n console.error(`Failed for: ${erroredDocs.join(', ')}`)\n }\n\n return Response.json({\n erroredDocs,\n totalDocs: ids.length,\n updatedDocs,\n })\n } catch (error) {\n if (error instanceof ZodError) {\n return Response.json(\n {\n details: error.issues.map((e) => ({\n message: e.message,\n path: e.path.join('.'),\n })),\n error: 'Validation failed',\n },\n { status: 400 },\n )\n }\n console.error('Error in bulk generation:', error)\n return Response.json(\n {\n error: `Error generating alt text: ${error instanceof Error ? error.message : 'Unknown error'}`,\n },\n { status: 500 },\n )\n }\n }\n\nasync function generateAndUpdateAltText({\n id,\n collection,\n locales,\n payload,\n pluginConfig,\n req,\n}: {\n collection: CollectionSlug\n id: number | string\n locales: string[]\n payload: BasePayload\n pluginConfig: AltTextPluginConfig\n req: PayloadRequest\n}) {\n const imageDoc = await payload.findByID({\n id,\n collection,\n depth: 0,\n })\n\n if (!imageDoc) {\n throw new Error('Image not found')\n }\n\n const mimeType =\n 'mimeType' in imageDoc && typeof imageDoc.mimeType === 'string' ? imageDoc.mimeType : undefined\n\n if (\n mimeType &&\n pluginConfig.resolver.supportedMimeTypes &&\n !pluginConfig.resolver.supportedMimeTypes.includes(mimeType)\n ) {\n throw new Error(\n `Alt text generation is not supported for files of type \"${mimeType}\". Supported types: ${pluginConfig.resolver.supportedMimeTypes.join(', ')}.`,\n )\n }\n\n const imageThumbnailUrl = pluginConfig.getImageThumbnail(imageDoc)\n\n const result = await pluginConfig.resolver.resolveBulk({\n filename:\n 'filename' in imageDoc && typeof imageDoc.filename === 'string'\n ? imageDoc.filename\n : undefined,\n imageThumbnailUrl,\n locales,\n req,\n })\n\n if (!result.success) {\n throw new Error(result.error || 'Failed to generate alt text')\n }\n\n for (const locale of locales) {\n const localeResult = result.results[locale]\n if (localeResult) {\n await payload.update({\n id,\n collection,\n data: {\n alt: localeResult.altText,\n keywords: localeResult.keywords,\n },\n locale,\n })\n }\n }\n}\n"],"names":["pMap","z","ZodError","localesFromConfig","bulkGenerateAltTextsEndpoint","access","req","Response","json","error","status","data","schema","object","collection","string","ids","array","union","number","parse","updatedDocs","erroredDocs","pluginConfig","payload","config","custom","altTextPluginConfig","resolver","concurrency","maxBulkGenerateConcurrency","locales","targetLocales","locale","id","generateAndUpdateAltText","console","log","length","Math","round","push","join","totalDocs","details","issues","map","e","message","path","Error","imageDoc","findByID","depth","mimeType","undefined","supportedMimeTypes","includes","imageThumbnailUrl","getImageThumbnail","result","resolveBulk","filename","success","localeResult","results","update","alt","altText","keywords"],"mappings":"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"}
1
+ {"version":3,"sources":["../../src/endpoints/bulkGenerateAltTexts.ts"],"sourcesContent":["import type { BasePayload, CollectionSlug, PayloadHandler, PayloadRequest } from 'payload'\n\nimport pMap from 'p-map'\nimport { z, ZodError } from 'zod'\n\nimport type { AltTextPluginConfig } from '../types/AltTextPluginConfig.js'\n\nimport { localesFromConfig } from '../utilities/localesFromConfig.js'\nimport { matchesMimeType } from '../utilities/mimeTypes.js'\n\n/**\n * Generates and updates alt text for multiple images in all locales.\n */\nexport const bulkGenerateAltTextsEndpoint =\n (access: AltTextPluginConfig['access']): PayloadHandler =>\n async (req: PayloadRequest) => {\n try {\n if (!(await access({ req }))) {\n return Response.json({ error: 'Unauthorized' }, { status: 401 })\n }\n\n const data = 'json' in req && typeof req.json === 'function' ? await req.json() : null\n\n const schema = z.object({\n collection: z.string(),\n ids: z.array(z.union([z.string(), z.number()])),\n })\n\n const { collection, ids } = schema.parse(data)\n\n let updatedDocs = 0\n const erroredDocs: (number | string)[] = []\n\n // Get plugin config from payload config\n const pluginConfig = req.payload.config.custom?.altTextPluginConfig as\n | AltTextPluginConfig\n | undefined\n\n if (!pluginConfig) {\n return Response.json({ error: 'Plugin config not found' }, { status: 500 })\n }\n\n if (!pluginConfig.resolver) {\n return Response.json({ error: 'No alt text resolver configured' }, { status: 500 })\n }\n\n const concurrency = pluginConfig.maxBulkGenerateConcurrency\n\n // determine target locales based on config\n const locales = localesFromConfig(req.payload.config)\n const targetLocales = locales ?? [pluginConfig.locale!]\n if (!targetLocales) {\n return Response.json(\n {\n error:\n 'Could not determine target locales for alt text generation. Please check your plugin configuration.',\n },\n { status: 500 },\n )\n }\n\n await pMap(\n ids,\n async (id) => {\n try {\n await generateAndUpdateAltText({\n id,\n collection,\n locales: targetLocales,\n payload: req.payload,\n pluginConfig,\n req,\n })\n updatedDocs++\n console.log(\n `${updatedDocs}/${ids.length} updated (${Math.round((updatedDocs / ids.length) * 100)}%)`,\n )\n } catch (error) {\n console.error(`Error generating alt text for ${id}:`, error)\n erroredDocs.push(id)\n }\n },\n { concurrency },\n )\n\n if (erroredDocs.length > 0) {\n console.error(`Failed for: ${erroredDocs.join(', ')}`)\n }\n\n return Response.json({\n erroredDocs,\n totalDocs: ids.length,\n updatedDocs,\n })\n } catch (error) {\n if (error instanceof ZodError) {\n return Response.json(\n {\n details: error.issues.map((e) => ({\n message: e.message,\n path: e.path.join('.'),\n })),\n error: 'Validation failed',\n },\n { status: 400 },\n )\n }\n console.error('Error in bulk generation:', error)\n return Response.json(\n {\n error: `Error generating alt text: ${error instanceof Error ? error.message : 'Unknown error'}`,\n },\n { status: 500 },\n )\n }\n }\n\nasync function generateAndUpdateAltText({\n id,\n collection,\n locales,\n payload,\n pluginConfig,\n req,\n}: {\n collection: CollectionSlug\n id: number | string\n locales: string[]\n payload: BasePayload\n pluginConfig: AltTextPluginConfig\n req: PayloadRequest\n}) {\n const imageDoc = await payload.findByID({\n id,\n collection,\n depth: 0,\n })\n\n if (!imageDoc) {\n throw new Error('Image not found')\n }\n\n const mimeType =\n 'mimeType' in imageDoc && typeof imageDoc.mimeType === 'string' ? imageDoc.mimeType : undefined\n\n const collectionConfig = pluginConfig.collections.find((entry) => entry.slug === collection)\n\n if (mimeType && collectionConfig && !matchesMimeType(mimeType, collectionConfig.mimeTypes)) {\n throw new Error(\n `Alt text is not tracked for files of type \"${mimeType}\" in the \"${collection}\" collection. Tracked types: ${collectionConfig.mimeTypes.join(', ')}.`,\n )\n }\n\n if (\n mimeType &&\n pluginConfig.resolver.supportedMimeTypes &&\n !pluginConfig.resolver.supportedMimeTypes.includes(mimeType)\n ) {\n throw new Error(\n `Alt text generation is not supported for files of type \"${mimeType}\". Supported types: ${pluginConfig.resolver.supportedMimeTypes.join(', ')}.`,\n )\n }\n\n const imageThumbnailUrl = pluginConfig.getImageThumbnail(imageDoc)\n\n const result = await pluginConfig.resolver.resolveBulk({\n filename:\n 'filename' in imageDoc && typeof imageDoc.filename === 'string'\n ? imageDoc.filename\n : undefined,\n imageThumbnailUrl,\n locales,\n req,\n })\n\n if (!result.success) {\n throw new Error(result.error || 'Failed to generate alt text')\n }\n\n for (const locale of locales) {\n const localeResult = result.results[locale]\n if (localeResult) {\n await payload.update({\n id,\n collection,\n data: {\n alt: localeResult.altText,\n keywords: localeResult.keywords,\n },\n locale,\n })\n }\n }\n}\n"],"names":["pMap","z","ZodError","localesFromConfig","matchesMimeType","bulkGenerateAltTextsEndpoint","access","req","Response","json","error","status","data","schema","object","collection","string","ids","array","union","number","parse","updatedDocs","erroredDocs","pluginConfig","payload","config","custom","altTextPluginConfig","resolver","concurrency","maxBulkGenerateConcurrency","locales","targetLocales","locale","id","generateAndUpdateAltText","console","log","length","Math","round","push","join","totalDocs","details","issues","map","e","message","path","Error","imageDoc","findByID","depth","mimeType","undefined","collectionConfig","collections","find","entry","slug","mimeTypes","supportedMimeTypes","includes","imageThumbnailUrl","getImageThumbnail","result","resolveBulk","filename","success","localeResult","results","update","alt","altText","keywords"],"mappings":"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"}
@@ -1,4 +1,5 @@
1
1
  import { z, ZodError } from 'zod';
2
+ import { matchesMimeType } from '../utilities/mimeTypes.js';
2
3
  /**
3
4
  * Generates alt text for a single image using the configured resolver.
4
5
  *
@@ -64,6 +65,14 @@ import { z, ZodError } from 'zod';
64
65
  });
65
66
  }
66
67
  const mimeType = 'mimeType' in imageDoc && typeof imageDoc.mimeType === 'string' ? imageDoc.mimeType : undefined;
68
+ const collectionConfig = pluginConfig.collections.find((entry)=>entry.slug === collection);
69
+ if (mimeType && collectionConfig && !matchesMimeType(mimeType, collectionConfig.mimeTypes)) {
70
+ return Response.json({
71
+ error: `Alt text is not tracked for files of type "${mimeType}" in the "${collection}" collection. Tracked types: ${collectionConfig.mimeTypes.join(', ')}.`
72
+ }, {
73
+ status: 400
74
+ });
75
+ }
67
76
  if (mimeType && pluginConfig.resolver.supportedMimeTypes && !pluginConfig.resolver.supportedMimeTypes.includes(mimeType)) {
68
77
  return Response.json({
69
78
  error: `Alt text generation is not supported for files of type "${mimeType}". Supported types: ${pluginConfig.resolver.supportedMimeTypes.join(', ')}.`
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/endpoints/generateAltText.ts"],"sourcesContent":["import type { PayloadHandler, PayloadRequest } from 'payload'\n\nimport { z, ZodError } from 'zod'\n\nimport type { AltTextPluginConfig } from '../types/AltTextPluginConfig.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 requestSchema = z.object({\n id: z.union([z.string(), z.number()]),\n collection: z.string(),\n locale: z.string().nullable(),\n update: z.boolean().optional().default(false),\n })\n\n const { id, collection, locale, update } = requestSchema.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 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(\n {\n details: error.issues.map((e) => ({\n message: e.message,\n path: e.path.join('.'),\n })),\n error: 'Validation failed',\n },\n { status: 400 },\n )\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":["z","ZodError","generateAltTextEndpoint","access","req","Response","json","error","status","data","requestSchema","object","id","union","string","number","collection","locale","nullable","update","boolean","optional","default","parse","imageDoc","payload","findByID","depth","pluginConfig","config","custom","altTextPluginConfig","getImageThumbnail","resolver","mimeType","undefined","supportedMimeTypes","includes","join","targetLocale","imageThumbnailUrl","result","resolve","filename","success","alt","altText","keywords","details","issues","map","e","message","path","console","Error"],"mappings":"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"}
1
+ {"version":3,"sources":["../../src/endpoints/generateAltText.ts"],"sourcesContent":["import type { PayloadHandler, PayloadRequest } from 'payload'\n\nimport { z, ZodError } from 'zod'\n\nimport type { AltTextPluginConfig } from '../types/AltTextPluginConfig.js'\n\nimport { matchesMimeType } from '../utilities/mimeTypes.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 requestSchema = z.object({\n id: z.union([z.string(), z.number()]),\n collection: z.string(),\n locale: z.string().nullable(),\n update: z.boolean().optional().default(false),\n })\n\n const { id, collection, locale, update } = requestSchema.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(\n {\n details: error.issues.map((e) => ({\n message: e.message,\n path: e.path.join('.'),\n })),\n error: 'Validation failed',\n },\n { status: 400 },\n )\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":["z","ZodError","matchesMimeType","generateAltTextEndpoint","access","req","Response","json","error","status","data","requestSchema","object","id","union","string","number","collection","locale","nullable","update","boolean","optional","default","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","details","issues","map","e","message","path","console","Error"],"mappings":"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"}
@@ -1,5 +1,10 @@
1
- import type { TextareaField } from 'payload';
2
- export declare function altTextField({ localized, supportedMimeTypes, }: {
1
+ import type { TextareaField, TextareaFieldValidation } from 'payload';
2
+ export declare function altTextField({ localized, supportedMimeTypes, trackedMimeTypes, validate, }: {
3
3
  localized?: TextareaField['localized'];
4
+ /** MIME types the resolver can generate for — used to disable the Generate button. */
4
5
  supportedMimeTypes?: string[];
6
+ /** MIME types for which alt text is tracked — used to hide the field and skip validation for others. */
7
+ trackedMimeTypes?: string[];
8
+ /** Custom validator that fully replaces the default. */
9
+ validate?: TextareaFieldValidation;
5
10
  }): TextareaField;
@@ -1,5 +1,6 @@
1
+ import { validateAltText } from '../utilities/mimeTypes.js';
1
2
  import { translatedLabel } from '../utils/translatedLabel.js';
2
- export function altTextField({ localized, supportedMimeTypes }) {
3
+ export function altTextField({ localized, supportedMimeTypes, trackedMimeTypes, validate }) {
3
4
  return {
4
5
  name: 'alt',
5
6
  type: 'textarea',
@@ -8,29 +9,14 @@ export function altTextField({ localized, supportedMimeTypes }) {
8
9
  Field: '@jhb.software/payload-alt-text-plugin/client#AltTextField'
9
10
  },
10
11
  custom: {
11
- supportedMimeTypes
12
+ supportedMimeTypes,
13
+ trackedMimeTypes
12
14
  }
13
15
  },
14
16
  label: translatedLabel('alternateText'),
15
17
  localized,
16
18
  required: true,
17
- validate: (value, { data, operation, req: { t } })=>{
18
- // Since https://github.com/payloadcms/payload/pull/14988, when using external storage (e.g., S3),
19
- // it is no longer possible to detect whether this validation runs during the initial upload
20
- // or a regular update by checking the existence of the ID.
21
- // Instead, compare the timestamps of the createdAt and updatedAt fields.
22
- const isInitialUpload = operation === 'create' || 'createdAt' in data && 'updatedAt' in data && data.createdAt === data.updatedAt;
23
- // initial upload: allow without alt text
24
- if (isInitialUpload) {
25
- return true;
26
- }
27
- // regular update: require alt text
28
- if (!value || value.trim().length === 0) {
29
- // @ts-expect-error - the translation key type does not include the custom key
30
- return t('@jhb.software/payload-alt-text-plugin:theAlternateTextIsRequired');
31
- }
32
- return true;
33
- }
19
+ validate: validate ?? ((value, args)=>validateAltText(value, args, trackedMimeTypes))
34
20
  };
35
21
  }
36
22
 
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/fields/altTextField.ts"],"sourcesContent":["import type { TextareaField } from 'payload'\n\nimport { translatedLabel } from '../utils/translatedLabel.js'\n\nexport function altTextField({\n localized,\n supportedMimeTypes,\n}: {\n localized?: TextareaField['localized']\n supportedMimeTypes?: string[]\n}): TextareaField {\n return {\n name: 'alt',\n type: 'textarea',\n admin: {\n components: {\n Field: '@jhb.software/payload-alt-text-plugin/client#AltTextField',\n },\n custom: {\n supportedMimeTypes,\n },\n },\n label: translatedLabel('alternateText'),\n localized,\n required: true,\n validate: (value, { data, operation, req: { t } }) => {\n // Since https://github.com/payloadcms/payload/pull/14988, when using external storage (e.g., S3),\n // it is no longer possible to detect whether this validation runs during the initial upload\n // or a regular update by checking the existence of the ID.\n // Instead, compare the timestamps of the createdAt and updatedAt fields.\n const isInitialUpload =\n operation === 'create' ||\n ('createdAt' in data && 'updatedAt' in data && data.createdAt === data.updatedAt)\n\n // initial upload: allow without alt text\n if (isInitialUpload) {\n return true\n }\n\n // regular update: require alt text\n if (!value || value.trim().length === 0) {\n // @ts-expect-error - the translation key type does not include the custom key\n return t('@jhb.software/payload-alt-text-plugin:theAlternateTextIsRequired')\n }\n\n return true\n },\n }\n}\n"],"names":["translatedLabel","altTextField","localized","supportedMimeTypes","name","type","admin","components","Field","custom","label","required","validate","value","data","operation","req","t","isInitialUpload","createdAt","updatedAt","trim","length"],"mappings":"AAEA,SAASA,eAAe,QAAQ,8BAA6B;AAE7D,OAAO,SAASC,aAAa,EAC3BC,SAAS,EACTC,kBAAkB,EAInB;IACC,OAAO;QACLC,MAAM;QACNC,MAAM;QACNC,OAAO;YACLC,YAAY;gBACVC,OAAO;YACT;YACAC,QAAQ;gBACNN;YACF;QACF;QACAO,OAAOV,gBAAgB;QACvBE;QACAS,UAAU;QACVC,UAAU,CAACC,OAAO,EAAEC,IAAI,EAAEC,SAAS,EAAEC,KAAK,EAAEC,CAAC,EAAE,EAAE;YAC/C,kGAAkG;YAClG,4FAA4F;YAC5F,2DAA2D;YAC3D,yEAAyE;YACzE,MAAMC,kBACJH,cAAc,YACb,eAAeD,QAAQ,eAAeA,QAAQA,KAAKK,SAAS,KAAKL,KAAKM,SAAS;YAElF,yCAAyC;YACzC,IAAIF,iBAAiB;gBACnB,OAAO;YACT;YAEA,mCAAmC;YACnC,IAAI,CAACL,SAASA,MAAMQ,IAAI,GAAGC,MAAM,KAAK,GAAG;gBACvC,8EAA8E;gBAC9E,OAAOL,EAAE;YACX;YAEA,OAAO;QACT;IACF;AACF"}
1
+ {"version":3,"sources":["../../src/fields/altTextField.ts"],"sourcesContent":["import type { TextareaField, TextareaFieldValidation } from 'payload'\n\nimport { validateAltText } from '../utilities/mimeTypes.js'\nimport { translatedLabel } from '../utils/translatedLabel.js'\n\nexport function altTextField({\n localized,\n supportedMimeTypes,\n trackedMimeTypes,\n validate,\n}: {\n localized?: TextareaField['localized']\n /** MIME types the resolver can generate for — used to disable the Generate button. */\n supportedMimeTypes?: string[]\n /** MIME types for which alt text is tracked — used to hide the field and skip validation for others. */\n trackedMimeTypes?: string[]\n /** Custom validator that fully replaces the default. */\n validate?: TextareaFieldValidation\n}): TextareaField {\n return {\n name: 'alt',\n type: 'textarea',\n admin: {\n components: {\n Field: '@jhb.software/payload-alt-text-plugin/client#AltTextField',\n },\n custom: {\n supportedMimeTypes,\n trackedMimeTypes,\n },\n },\n label: translatedLabel('alternateText'),\n localized,\n required: true,\n validate: validate ?? ((value, args) => validateAltText(value, args, trackedMimeTypes)),\n }\n}\n"],"names":["validateAltText","translatedLabel","altTextField","localized","supportedMimeTypes","trackedMimeTypes","validate","name","type","admin","components","Field","custom","label","required","value","args"],"mappings":"AAEA,SAASA,eAAe,QAAQ,4BAA2B;AAC3D,SAASC,eAAe,QAAQ,8BAA6B;AAE7D,OAAO,SAASC,aAAa,EAC3BC,SAAS,EACTC,kBAAkB,EAClBC,gBAAgB,EAChBC,QAAQ,EAST;IACC,OAAO;QACLC,MAAM;QACNC,MAAM;QACNC,OAAO;YACLC,YAAY;gBACVC,OAAO;YACT;YACAC,QAAQ;gBACNR;gBACAC;YACF;QACF;QACAQ,OAAOZ,gBAAgB;QACvBE;QACAW,UAAU;QACVR,UAAUA,YAAa,CAAA,CAACS,OAAOC,OAAShB,gBAAgBe,OAAOC,MAAMX,iBAAgB;IACvF;AACF"}
@@ -2,6 +2,11 @@ import { revalidateTag } from 'next/cache.js';
2
2
  import { ALT_TEXT_HEALTH_PLUGIN_SLUG, getAltTextHealthCollectionTag } from '../utilities/altTextHealth.js';
3
3
  function safeRevalidateTag(req, tag) {
4
4
  try {
5
+ // Support both Next 15 and Next 16. Next 15 types `revalidateTag(tag)` as 1-arg; Next 16
6
+ // added a required second `profile` arg (a 1-arg call still works at runtime, with a
7
+ // deprecation warning). Cast so the build succeeds regardless of which Next types are
8
+ // resolved, while keeping runtime behaviour correct on both versions.
9
+ ;
5
10
  revalidateTag(tag);
6
11
  } catch (error) {
7
12
  const message = error instanceof Error ? error.message : String(error);
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/hooks/revalidateAltTextHealth.ts"],"sourcesContent":["import type { CollectionAfterChangeHook, CollectionAfterDeleteHook, PayloadRequest } from 'payload'\n\nimport { revalidateTag } from 'next/cache.js'\n\nimport {\n ALT_TEXT_HEALTH_PLUGIN_SLUG,\n getAltTextHealthCollectionTag,\n} from '../utilities/altTextHealth.js'\n\nfunction safeRevalidateTag(req: PayloadRequest, tag: string): void {\n try {\n revalidateTag(tag)\n } catch (error) {\n const message = error instanceof Error ? error.message : String(error)\n\n if (message.includes('static generation store missing')) {\n req.payload.logger.warn({\n msg: 'Skipping alt text health cache revalidation outside a Next.js request context.',\n plugin: ALT_TEXT_HEALTH_PLUGIN_SLUG,\n tag,\n })\n return\n }\n\n throw error\n }\n}\n\nexport const createRevalidateAltTextHealthAfterChangeHook =\n (collectionSlug: string): CollectionAfterChangeHook =>\n ({ doc, req }) => {\n if (!req.context?.disableRevalidate) {\n safeRevalidateTag(req, getAltTextHealthCollectionTag(collectionSlug))\n }\n\n return doc\n }\n\nexport const createRevalidateAltTextHealthAfterDeleteHook =\n (collectionSlug: string): CollectionAfterDeleteHook =>\n ({ doc, req }) => {\n if (!req.context?.disableRevalidate) {\n safeRevalidateTag(req, getAltTextHealthCollectionTag(collectionSlug))\n }\n\n return doc\n }\n"],"names":["revalidateTag","ALT_TEXT_HEALTH_PLUGIN_SLUG","getAltTextHealthCollectionTag","safeRevalidateTag","req","tag","error","message","Error","String","includes","payload","logger","warn","msg","plugin","createRevalidateAltTextHealthAfterChangeHook","collectionSlug","doc","context","disableRevalidate","createRevalidateAltTextHealthAfterDeleteHook"],"mappings":"AAEA,SAASA,aAAa,QAAQ,gBAAe;AAE7C,SACEC,2BAA2B,EAC3BC,6BAA6B,QACxB,gCAA+B;AAEtC,SAASC,kBAAkBC,GAAmB,EAAEC,GAAW;IACzD,IAAI;QACFL,cAAcK;IAChB,EAAE,OAAOC,OAAO;QACd,MAAMC,UAAUD,iBAAiBE,QAAQF,MAAMC,OAAO,GAAGE,OAAOH;QAEhE,IAAIC,QAAQG,QAAQ,CAAC,oCAAoC;YACvDN,IAAIO,OAAO,CAACC,MAAM,CAACC,IAAI,CAAC;gBACtBC,KAAK;gBACLC,QAAQd;gBACRI;YACF;YACA;QACF;QAEA,MAAMC;IACR;AACF;AAEA,OAAO,MAAMU,+CACX,CAACC,iBACD,CAAC,EAAEC,GAAG,EAAEd,GAAG,EAAE;QACX,IAAI,CAACA,IAAIe,OAAO,EAAEC,mBAAmB;YACnCjB,kBAAkBC,KAAKF,8BAA8Be;QACvD;QAEA,OAAOC;IACT,EAAC;AAEH,OAAO,MAAMG,+CACX,CAACJ,iBACD,CAAC,EAAEC,GAAG,EAAEd,GAAG,EAAE;QACX,IAAI,CAACA,IAAIe,OAAO,EAAEC,mBAAmB;YACnCjB,kBAAkBC,KAAKF,8BAA8Be;QACvD;QAEA,OAAOC;IACT,EAAC"}
1
+ {"version":3,"sources":["../../src/hooks/revalidateAltTextHealth.ts"],"sourcesContent":["import type { CollectionAfterChangeHook, CollectionAfterDeleteHook, PayloadRequest } from 'payload'\n\nimport { revalidateTag } from 'next/cache.js'\n\nimport {\n ALT_TEXT_HEALTH_PLUGIN_SLUG,\n getAltTextHealthCollectionTag,\n} from '../utilities/altTextHealth.js'\n\nfunction safeRevalidateTag(req: PayloadRequest, tag: string): void {\n try {\n // Support both Next 15 and Next 16. Next 15 types `revalidateTag(tag)` as 1-arg; Next 16\n // added a required second `profile` arg (a 1-arg call still works at runtime, with a\n // deprecation warning). Cast so the build succeeds regardless of which Next types are\n // resolved, while keeping runtime behaviour correct on both versions.\n ;(revalidateTag as (tag: string) => void)(tag)\n } catch (error) {\n const message = error instanceof Error ? error.message : String(error)\n\n if (message.includes('static generation store missing')) {\n req.payload.logger.warn({\n msg: 'Skipping alt text health cache revalidation outside a Next.js request context.',\n plugin: ALT_TEXT_HEALTH_PLUGIN_SLUG,\n tag,\n })\n return\n }\n\n throw error\n }\n}\n\nexport const createRevalidateAltTextHealthAfterChangeHook =\n (collectionSlug: string): CollectionAfterChangeHook =>\n ({ doc, req }) => {\n if (!req.context?.disableRevalidate) {\n safeRevalidateTag(req, getAltTextHealthCollectionTag(collectionSlug))\n }\n\n return doc\n }\n\nexport const createRevalidateAltTextHealthAfterDeleteHook =\n (collectionSlug: string): CollectionAfterDeleteHook =>\n ({ doc, req }) => {\n if (!req.context?.disableRevalidate) {\n safeRevalidateTag(req, getAltTextHealthCollectionTag(collectionSlug))\n }\n\n return doc\n }\n"],"names":["revalidateTag","ALT_TEXT_HEALTH_PLUGIN_SLUG","getAltTextHealthCollectionTag","safeRevalidateTag","req","tag","error","message","Error","String","includes","payload","logger","warn","msg","plugin","createRevalidateAltTextHealthAfterChangeHook","collectionSlug","doc","context","disableRevalidate","createRevalidateAltTextHealthAfterDeleteHook"],"mappings":"AAEA,SAASA,aAAa,QAAQ,gBAAe;AAE7C,SACEC,2BAA2B,EAC3BC,6BAA6B,QACxB,gCAA+B;AAEtC,SAASC,kBAAkBC,GAAmB,EAAEC,GAAW;IACzD,IAAI;QACF,yFAAyF;QACzF,qFAAqF;QACrF,sFAAsF;QACtF,sEAAsE;;QACpEL,cAAwCK;IAC5C,EAAE,OAAOC,OAAO;QACd,MAAMC,UAAUD,iBAAiBE,QAAQF,MAAMC,OAAO,GAAGE,OAAOH;QAEhE,IAAIC,QAAQG,QAAQ,CAAC,oCAAoC;YACvDN,IAAIO,OAAO,CAACC,MAAM,CAACC,IAAI,CAAC;gBACtBC,KAAK;gBACLC,QAAQd;gBACRI;YACF;YACA;QACF;QAEA,MAAMC;IACR;AACF;AAEA,OAAO,MAAMU,+CACX,CAACC,iBACD,CAAC,EAAEC,GAAG,EAAEd,GAAG,EAAE;QACX,IAAI,CAACA,IAAIe,OAAO,EAAEC,mBAAmB;YACnCjB,kBAAkBC,KAAKF,8BAA8Be;QACvD;QAEA,OAAOC;IACT,EAAC;AAEH,OAAO,MAAMG,+CACX,CAACJ,iBACD,CAAC,EAAEC,GAAG,EAAEd,GAAG,EAAE;QACX,IAAI,CAACA,IAAIe,OAAO,EAAEC,mBAAmB;YACnCjB,kBAAkBC,KAAKF,8BAA8Be;QACvD;QAEA,OAAOC;IACT,EAAC"}
package/dist/index.d.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  export { payloadAltTextPlugin } from './plugin.js';
2
2
  export { openAIResolver } from './resolvers/openAI.js';
3
3
  export * from './resolvers/types.js';
4
- export type { IncomingAltTextPluginConfig as AltTextPluginConfig } from './types/AltTextPluginConfig.js';
4
+ export type { AltTextCollectionConfig, IncomingAltTextPluginConfig as AltTextPluginConfig, } from './types/AltTextPluginConfig.js';
5
+ export { matchesMimeType, validateAltText } from './utilities/mimeTypes.js';
package/dist/index.js CHANGED
@@ -1,5 +1,6 @@
1
1
  export { payloadAltTextPlugin } from './plugin.js';
2
2
  export { openAIResolver } from './resolvers/openAI.js';
3
3
  export * from './resolvers/types.js';
4
+ export { matchesMimeType, validateAltText } from './utilities/mimeTypes.js';
4
5
 
5
6
  //# sourceMappingURL=index.js.map
package/dist/index.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/index.ts"],"sourcesContent":["export { payloadAltTextPlugin } from './plugin.js'\nexport { openAIResolver } from './resolvers/openAI.js'\nexport * from './resolvers/types.js'\nexport type { IncomingAltTextPluginConfig as AltTextPluginConfig } from './types/AltTextPluginConfig.js'\n"],"names":["payloadAltTextPlugin","openAIResolver"],"mappings":"AAAA,SAASA,oBAAoB,QAAQ,cAAa;AAClD,SAASC,cAAc,QAAQ,wBAAuB;AACtD,cAAc,uBAAsB"}
1
+ {"version":3,"sources":["../src/index.ts"],"sourcesContent":["export { payloadAltTextPlugin } from './plugin.js'\nexport { openAIResolver } from './resolvers/openAI.js'\nexport * from './resolvers/types.js'\nexport type {\n AltTextCollectionConfig,\n IncomingAltTextPluginConfig as AltTextPluginConfig,\n} from './types/AltTextPluginConfig.js'\nexport { matchesMimeType, validateAltText } from './utilities/mimeTypes.js'\n"],"names":["payloadAltTextPlugin","openAIResolver","matchesMimeType","validateAltText"],"mappings":"AAAA,SAASA,oBAAoB,QAAQ,cAAa;AAClD,SAASC,cAAc,QAAQ,wBAAuB;AACtD,cAAc,uBAAsB;AAKpC,SAASC,eAAe,EAAEC,eAAe,QAAQ,2BAA0B"}
package/dist/plugin.js CHANGED
@@ -5,6 +5,7 @@ import { altTextField } from './fields/altTextField.js';
5
5
  import { keywordsField } from './fields/keywordsField.js';
6
6
  import { createRevalidateAltTextHealthAfterChangeHook, createRevalidateAltTextHealthAfterDeleteHook } from './hooks/revalidateAltTextHealth.js';
7
7
  import { translations } from './translations/index.js';
8
+ import { normalizeCollectionsConfig } from './utilities/mimeTypes.js';
8
9
  import { deepMergeSimple } from './utils/deepMergeSimple.js';
9
10
  const altTextHealthWidgetDefinition = {
10
11
  slug: 'alt-text-health',
@@ -18,36 +19,6 @@ const altTextHealthWidgetDefinition = {
18
19
  maxWidth: 'full',
19
20
  minWidth: 'medium'
20
21
  };
21
- const defaultAltTextHealthWidgetLayout = {
22
- widgetSlug: 'alt-text-health',
23
- width: 'full'
24
- };
25
- function appendAltTextHealthWidgetToLayout(layout) {
26
- if (layout.some((widget)=>widget.widgetSlug === 'alt-text-health')) {
27
- return layout;
28
- }
29
- return [
30
- ...layout,
31
- defaultAltTextHealthWidgetLayout
32
- ];
33
- }
34
- function getDashboardDefaultLayout(defaultLayout) {
35
- if (!defaultLayout) {
36
- return [
37
- {
38
- widgetSlug: 'collections',
39
- width: 'full'
40
- },
41
- defaultAltTextHealthWidgetLayout
42
- ];
43
- }
44
- if (Array.isArray(defaultLayout)) {
45
- return appendAltTextHealthWidgetToLayout(defaultLayout);
46
- }
47
- return async ({ req })=>appendAltTextHealthWidgetToLayout(await defaultLayout({
48
- req
49
- }));
50
- }
51
22
  export const payloadAltTextPlugin = (incomingPluginConfig)=>(incomingConfig)=>{
52
23
  const config = {
53
24
  ...incomingConfig
@@ -58,9 +29,10 @@ export const payloadAltTextPlugin = (incomingPluginConfig)=>(incomingConfig)=>{
58
29
  }
59
30
  const locales = config.localization ? config.localization.locales.map((localeConfig)=>typeof localeConfig === 'string' ? localeConfig : localeConfig.code) : [];
60
31
  const enableHealthCheck = incomingPluginConfig.healthCheck !== false;
32
+ const normalizedCollections = normalizeCollectionsConfig(incomingPluginConfig.collections);
61
33
  const pluginConfig = {
62
34
  access: incomingPluginConfig.access ?? (({ req })=>!!req.user),
63
- collections: incomingPluginConfig.collections,
35
+ collections: normalizedCollections,
64
36
  enabled: incomingPluginConfig.enabled ?? true,
65
37
  fieldsOverride: incomingPluginConfig.fieldsOverride,
66
38
  getImageThumbnail: incomingPluginConfig.getImageThumbnail,
@@ -74,27 +46,34 @@ export const payloadAltTextPlugin = (incomingPluginConfig)=>(incomingConfig)=>{
74
46
  if (locales.length === 0 && !incomingPluginConfig.locale) {
75
47
  throw new Error('The alt-text plugin requires a "locale" option when Payload localization is disabled. ' + 'Please add { locale: "en" } (or your preferred locale) to your plugin configuration.');
76
48
  }
77
- const defaultFields = [
78
- altTextField({
79
- localized: Boolean(config.localization),
80
- supportedMimeTypes: pluginConfig.resolver.supportedMimeTypes
81
- }),
82
- keywordsField({
83
- localized: Boolean(config.localization)
84
- })
85
- ];
86
- const fields = incomingPluginConfig.fieldsOverride && typeof incomingPluginConfig.fieldsOverride === 'function' ? incomingPluginConfig.fieldsOverride({
87
- defaultFields
88
- }) : defaultFields;
49
+ const collectionConfigBySlug = new Map(normalizedCollections.map((entry)=>[
50
+ entry.slug,
51
+ entry
52
+ ]));
89
53
  // Ensure collections array exists
90
54
  config.collections = config.collections || [];
91
55
  // Map over collections and inject AI alt text fields into specified ones
92
56
  config.collections = config.collections.map((collectionConfig)=>{
93
- if (pluginConfig.collections.includes(collectionConfig.slug)) {
57
+ const altTextCollectionConfig = collectionConfigBySlug.get(collectionConfig.slug);
58
+ if (altTextCollectionConfig) {
94
59
  if (!collectionConfig.upload) {
95
60
  console.warn(`AI Alt Text Plugin: Collection "${collectionConfig.slug}" is not an upload collection. Skipping field injection.`);
96
61
  return collectionConfig;
97
62
  }
63
+ const defaultFields = [
64
+ altTextField({
65
+ localized: Boolean(config.localization),
66
+ supportedMimeTypes: pluginConfig.resolver.supportedMimeTypes,
67
+ trackedMimeTypes: altTextCollectionConfig.mimeTypes,
68
+ validate: altTextCollectionConfig.validate
69
+ }),
70
+ keywordsField({
71
+ localized: Boolean(config.localization)
72
+ })
73
+ ];
74
+ const fields = incomingPluginConfig.fieldsOverride && typeof incomingPluginConfig.fieldsOverride === 'function' ? incomingPluginConfig.fieldsOverride({
75
+ defaultFields
76
+ }) : defaultFields;
98
77
  return {
99
78
  ...collectionConfig,
100
79
  admin: {
@@ -151,9 +130,6 @@ export const payloadAltTextPlugin = (incomingPluginConfig)=>(incomingConfig)=>{
151
130
  ...config.admin,
152
131
  dashboard: {
153
132
  ...config.admin?.dashboard,
154
- ...enableHealthCheck && {
155
- defaultLayout: getDashboardDefaultLayout(config.admin?.dashboard?.defaultLayout)
156
- },
157
133
  widgets
158
134
  }
159
135
  },
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/plugin.ts"],"sourcesContent":["import type { Config, PayloadRequest, Widget, WidgetInstance } 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 { 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\ntype DashboardDefaultLayout = Config['admin'] extends infer TAdmin\n ? TAdmin extends { dashboard?: infer TDashboard }\n ? TDashboard extends { defaultLayout?: infer TDefaultLayout }\n ? TDefaultLayout\n : never\n : never\n : never\n\nconst defaultAltTextHealthWidgetLayout: WidgetInstance = {\n widgetSlug: 'alt-text-health',\n width: 'full',\n}\n\nfunction appendAltTextHealthWidgetToLayout(layout: WidgetInstance[]): WidgetInstance[] {\n if (layout.some((widget) => widget.widgetSlug === 'alt-text-health')) {\n return layout\n }\n\n return [...layout, defaultAltTextHealthWidgetLayout]\n}\n\nfunction getDashboardDefaultLayout(defaultLayout: DashboardDefaultLayout | undefined) {\n if (!defaultLayout) {\n return [\n {\n widgetSlug: 'collections',\n width: 'full',\n },\n defaultAltTextHealthWidgetLayout,\n ] satisfies WidgetInstance[]\n }\n\n if (Array.isArray(defaultLayout)) {\n return appendAltTextHealthWidgetToLayout(defaultLayout)\n }\n\n return async ({ req }: { req: PayloadRequest }) =>\n appendAltTextHealthWidgetToLayout(await defaultLayout({ req }))\n}\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 pluginConfig: AltTextPluginConfig = {\n access: incomingPluginConfig.access ?? (({ req }) => !!req.user),\n collections: incomingPluginConfig.collections,\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 defaultFields = [\n altTextField({\n localized: Boolean(config.localization),\n supportedMimeTypes: pluginConfig.resolver.supportedMimeTypes,\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 // 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 if (pluginConfig.collections.includes(collectionConfig.slug)) {\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 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 ...(enableHealthCheck && {\n defaultLayout: getDashboardDefaultLayout(config.admin?.dashboard?.defaultLayout),\n }),\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","deepMergeSimple","altTextHealthWidgetDefinition","slug","Component","ComponentPath","label","de","en","maxWidth","minWidth","defaultAltTextHealthWidgetLayout","widgetSlug","width","appendAltTextHealthWidgetToLayout","layout","some","widget","getDashboardDefaultLayout","defaultLayout","Array","isArray","req","payloadAltTextPlugin","incomingPluginConfig","incomingConfig","config","enabled","locales","localization","map","localeConfig","code","enableHealthCheck","healthCheck","pluginConfig","access","user","collections","fieldsOverride","getImageThumbnail","locale","maxBulkGenerateConcurrency","resolver","length","Error","defaultFields","localized","Boolean","supportedMimeTypes","fields","collectionConfig","includes","upload","console","warn","admin","components","beforeListTable","path","props","collectionSlug","listSearchableFields","hooks","afterChange","afterDelete","existingWidgets","dashboard","widgets","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 { 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,5 +1,7 @@
1
- import type { CollectionSlug, Field, PayloadRequest } from 'payload';
1
+ import type { Field, PayloadRequest } from 'payload';
2
2
  import type { AltTextResolver } from '../resolvers/types.js';
3
+ import type { AltTextCollectionConfig, IncomingCollectionsConfig, NormalizedAltTextCollectionConfig } from '../utilities/mimeTypes.js';
4
+ export type { AltTextCollectionConfig, NormalizedAltTextCollectionConfig };
3
5
  /** Configuration options for the alt text plugin. */
4
6
  export type IncomingAltTextPluginConfig = {
5
7
  /**
@@ -11,8 +13,22 @@ export type IncomingAltTextPluginConfig = {
11
13
  access?: (args: {
12
14
  req: PayloadRequest;
13
15
  }) => boolean | Promise<boolean>;
14
- /** Collection slugs to enable the plugin for. */
15
- collections: CollectionSlug[];
16
+ /**
17
+ * Collections to enable the plugin for.
18
+ *
19
+ * Each entry may be a bare collection slug or an object with a `slug` and an
20
+ * optional `mimeTypes` array restricting which MIME types are tracked,
21
+ * validated, and generated. Bare slugs default to `['image/*']`.
22
+ *
23
+ * @example
24
+ * ```typescript
25
+ * collections: [
26
+ * 'images', // shorthand — defaults to ['image/*']
27
+ * { slug: 'media', mimeTypes: ['image/*', 'application/pdf'] },
28
+ * ]
29
+ * ```
30
+ */
31
+ collections: IncomingCollectionsConfig;
16
32
  /** Whether the plugin is enabled. */
17
33
  enabled?: boolean;
18
34
  /** Override the default fields inserted by the plugin via a function that receives the default fields and returns the new fields */
@@ -57,8 +73,8 @@ export type AltTextPluginConfig = {
57
73
  access: (args: {
58
74
  req: PayloadRequest;
59
75
  }) => boolean | Promise<boolean>;
60
- /** Collection slugs to enable the plugin for. */
61
- collections: CollectionSlug[];
76
+ /** Collections with resolved MIME type filters. */
77
+ collections: NormalizedAltTextCollectionConfig[];
62
78
  /** Whether the plugin is enabled. */
63
79
  enabled: boolean;
64
80
  /** Override the default fields inserted by the plugin via a function that receives the default fields and returns the new fields */
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/types/AltTextPluginConfig.ts"],"sourcesContent":["import type { CollectionSlug, Field, PayloadRequest } from 'payload'\n\nimport type { AltTextResolver } from '../resolvers/types.js'\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 /** Collection slugs to enable the plugin for. */\n collections: CollectionSlug[]\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 /** Collection slugs to enable the plugin for. */\n collections: CollectionSlug[]\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":"AA4DA,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 * 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,6 +1,7 @@
1
1
  import { unstable_cache } from 'next/cache.js';
2
2
  import { createCachedAltTextHealthScan } from './altTextHealthCache.js';
3
3
  import { localesFromConfig } from './localesFromConfig.js';
4
+ import { buildMimeTypeWhere } from './mimeTypes.js';
4
5
  import { summarizeCollection } from './summarizeCollection.js';
5
6
  export const ALT_TEXT_HEALTH_PLUGIN_SLUG = 'alt-text';
6
7
  export const ALT_TEXT_HEALTH_CACHE_TTL = 3600;
@@ -21,7 +22,11 @@ const createCollectionReadError = (collection, message)=>({
21
22
  operation: 'find'
22
23
  });
23
24
  const PAGE_SIZE = 500;
24
- async function fetchAllDocs(payload, collection, isLocalized) {
25
+ async function fetchAllDocs(payload, collection, isLocalized, mimeTypes) {
26
+ const where = buildMimeTypeWhere(mimeTypes);
27
+ if (!where) {
28
+ return [];
29
+ }
25
30
  const docs = [];
26
31
  let page = 1;
27
32
  let hasMore = true;
@@ -36,7 +41,8 @@ async function fetchAllDocs(payload, collection, isLocalized) {
36
41
  page,
37
42
  select: {
38
43
  alt: true
39
- }
44
+ },
45
+ where
40
46
  });
41
47
  for (const doc of result.docs){
42
48
  docs.push({
@@ -50,27 +56,27 @@ async function fetchAllDocs(payload, collection, isLocalized) {
50
56
  return docs;
51
57
  }
52
58
  async function computeAltTextHealthScan({ collections, isLocalized, localeCodes, payload }) {
53
- const collectionSummaries = await Promise.all(collections.map(async (collection)=>{
59
+ const collectionSummaries = await Promise.all(collections.map(async ({ slug, mimeTypes })=>{
54
60
  try {
55
- const docs = await fetchAllDocs(payload, collection, isLocalized);
61
+ const docs = await fetchAllDocs(payload, slug, isLocalized, mimeTypes);
56
62
  return summarizeCollection({
57
- collection,
63
+ collection: slug,
58
64
  docs,
59
65
  isLocalized,
60
66
  localeCodes
61
67
  });
62
68
  } catch (error) {
63
69
  const message = error instanceof Error ? error.message : 'Unknown error';
64
- const collectionError = createCollectionReadError(collection, message);
70
+ const collectionError = createCollectionReadError(slug, message);
65
71
  payload.logger.error({
66
- collection,
72
+ collection: slug,
67
73
  err: error,
68
74
  msg: 'Alt text health check failed while reading a collection.',
69
75
  operation: 'find',
70
76
  plugin: ALT_TEXT_HEALTH_PLUGIN_SLUG
71
77
  });
72
78
  return {
73
- collection,
79
+ collection: slug,
74
80
  completeDocs: 0,
75
81
  error: collectionError,
76
82
  invalidDocIds: undefined,
@@ -112,12 +118,14 @@ async function getAltTextHealthScan(req) {
112
118
  ALT_TEXT_HEALTH_GLOBAL_TAG,
113
119
  [
114
120
  ...collections
115
- ].sort().join(','),
121
+ ].map(({ slug, mimeTypes })=>`${slug}:${[
122
+ ...mimeTypes
123
+ ].sort().join('|')}`).sort().join(','),
116
124
  localeCodes.join(',')
117
125
  ];
118
126
  const tags = [
119
127
  ALT_TEXT_HEALTH_GLOBAL_TAG,
120
- ...new Set(collections.map((collection)=>getAltTextHealthCollectionTag(collection)))
128
+ ...new Set(collections.map(({ slug })=>getAltTextHealthCollectionTag(slug)))
121
129
  ];
122
130
  const getCachedHealthScan = createCachedAltTextHealthScan({
123
131
  cacheFactory: unstable_cache,
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/utilities/altTextHealth.ts"],"sourcesContent":["import type { Payload, PayloadRequest } from 'payload'\n\nimport { unstable_cache } from 'next/cache.js'\n\nimport type { AltTextPluginConfig } from '../types/AltTextPluginConfig.js'\n\nimport { createCachedAltTextHealthScan } from './altTextHealthCache.js'\nimport { localesFromConfig } from './localesFromConfig.js'\nimport { summarizeCollection } from './summarizeCollection.js'\n\nexport const ALT_TEXT_HEALTH_PLUGIN_SLUG = 'alt-text'\nexport const ALT_TEXT_HEALTH_CACHE_TTL = 3600\nexport const ALT_TEXT_HEALTH_GLOBAL_TAG = 'alt-text-health'\n\nexport type AltTextHealthErrorCode =\n | 'ALT_TEXT_COLLECTION_READ_FAILED'\n | 'ALT_TEXT_PLUGIN_CONFIG_MISSING'\n\nexport type AltTextHealthError = {\n code: AltTextHealthErrorCode\n collection?: string\n message: string\n operation?: 'find'\n}\n\nexport type AltTextHealthScanCollection = {\n collection: string\n completeDocs: number\n error?: AltTextHealthError\n invalidDocIds: (number | string)[] | undefined\n missingDocs: number\n partialDocs: number\n totalDocs: number\n}\n\nexport type AltTextHealthScan = {\n checkedAt: string\n collections: AltTextHealthScanCollection[]\n errors: AltTextHealthError[]\n isLocalized: boolean\n localeCodes: string[]\n}\n\nexport type AltTextHealthWidgetData = {\n collections: AltTextHealthScanCollection[]\n errors: AltTextHealthError[]\n isLocalized: boolean\n localeCount: number\n totalDocs: number\n}\n\ntype AltTextHealthComputationArgs = {\n collections: string[]\n isLocalized: boolean\n localeCodes: string[]\n payload: Payload\n}\n\nconst createUnknownScan = ({\n error,\n isLocalized,\n localeCodes,\n}: {\n error: AltTextHealthScan['errors'][number]\n isLocalized: boolean\n localeCodes: string[]\n}): AltTextHealthScan => ({\n checkedAt: new Date().toISOString(),\n collections: [],\n errors: [error],\n isLocalized,\n localeCodes,\n})\n\nconst createCollectionReadError = (collection: string, message: string) => ({\n code: 'ALT_TEXT_COLLECTION_READ_FAILED' as const,\n collection,\n message,\n operation: 'find' as const,\n})\n\nconst PAGE_SIZE = 500\n\nasync function fetchAllDocs(\n payload: Payload,\n collection: string,\n isLocalized: boolean,\n): Promise<{ alt: unknown; id: number | string }[]> {\n const docs: { alt: unknown; id: number | string }[] = []\n let page = 1\n let hasMore = true\n\n while (hasMore) {\n const result = await payload.find({\n collection,\n depth: 0,\n fallbackLocale: isLocalized ? false : undefined,\n limit: PAGE_SIZE,\n locale: isLocalized ? 'all' : undefined,\n overrideAccess: true,\n page,\n select: {\n alt: true,\n },\n })\n\n for (const doc of result.docs) {\n docs.push({\n id: doc.id,\n alt: 'alt' in doc ? doc.alt : undefined,\n })\n }\n\n hasMore = result.hasNextPage\n page++\n }\n\n return docs\n}\n\nasync function computeAltTextHealthScan({\n collections,\n isLocalized,\n localeCodes,\n payload,\n}: AltTextHealthComputationArgs): Promise<AltTextHealthScan> {\n const collectionSummaries = await Promise.all(\n collections.map(async (collection): Promise<AltTextHealthScanCollection> => {\n try {\n const docs = await fetchAllDocs(payload, collection, isLocalized)\n\n return summarizeCollection({\n collection,\n docs,\n isLocalized,\n localeCodes,\n })\n } catch (error) {\n const message = error instanceof Error ? error.message : 'Unknown error'\n const collectionError = createCollectionReadError(collection, message)\n\n payload.logger.error({\n collection,\n err: error,\n msg: 'Alt text health check failed while reading a collection.',\n operation: 'find',\n plugin: ALT_TEXT_HEALTH_PLUGIN_SLUG,\n })\n\n return {\n collection,\n completeDocs: 0,\n error: collectionError,\n invalidDocIds: undefined,\n missingDocs: 0,\n partialDocs: 0,\n totalDocs: 0,\n }\n }\n }),\n )\n\n const errors = collectionSummaries\n .filter((summary) => summary.error)\n .map((summary) => summary.error!)\n\n return {\n checkedAt: new Date().toISOString(),\n collections: collectionSummaries,\n errors,\n isLocalized,\n localeCodes,\n }\n}\n\nexport const getAltTextHealthCollectionTag = (collectionSlug: string): string =>\n `${ALT_TEXT_HEALTH_GLOBAL_TAG}:${collectionSlug}`\n\nasync function getAltTextHealthScan(req: PayloadRequest): Promise<AltTextHealthScan> {\n const { payload } = req\n const pluginConfig = payload.config.custom?.altTextPluginConfig as AltTextPluginConfig | undefined\n const localeCodes =\n localesFromConfig(payload.config) ?? (pluginConfig?.locale ? [pluginConfig.locale] : [])\n const isLocalized = Boolean(payload.config.localization)\n\n if (!pluginConfig) {\n return createUnknownScan({\n error: {\n code: 'ALT_TEXT_PLUGIN_CONFIG_MISSING',\n message: 'Alt text plugin config not found',\n },\n isLocalized,\n localeCodes,\n })\n }\n\n const collections = pluginConfig.collections\n\n const cacheKeyParts = [\n ALT_TEXT_HEALTH_GLOBAL_TAG,\n [...collections].sort().join(','),\n localeCodes.join(','),\n ]\n\n const tags = [\n ALT_TEXT_HEALTH_GLOBAL_TAG,\n ...new Set(collections.map((collection) => getAltTextHealthCollectionTag(collection))),\n ]\n\n const getCachedHealthScan = createCachedAltTextHealthScan({\n cacheFactory: unstable_cache,\n cacheKeyParts,\n compute: async () =>\n computeAltTextHealthScan({\n collections,\n isLocalized,\n localeCodes,\n payload,\n }),\n revalidate: ALT_TEXT_HEALTH_CACHE_TTL,\n tags,\n })\n\n return getCachedHealthScan()\n}\n\nexport async function getAltTextHealth(req: PayloadRequest): Promise<AltTextHealthScan> {\n return getAltTextHealthScan(req)\n}\n\nexport async function getAltTextHealthWidgetData(\n req: PayloadRequest,\n): Promise<AltTextHealthWidgetData> {\n const scan = await getAltTextHealthScan(req)\n\n return {\n collections: scan.collections,\n errors: scan.errors,\n isLocalized: scan.isLocalized,\n localeCount: scan.localeCodes.length,\n totalDocs: scan.collections.reduce((total, c) => total + c.totalDocs, 0),\n }\n}\n"],"names":["unstable_cache","createCachedAltTextHealthScan","localesFromConfig","summarizeCollection","ALT_TEXT_HEALTH_PLUGIN_SLUG","ALT_TEXT_HEALTH_CACHE_TTL","ALT_TEXT_HEALTH_GLOBAL_TAG","createUnknownScan","error","isLocalized","localeCodes","checkedAt","Date","toISOString","collections","errors","createCollectionReadError","collection","message","code","operation","PAGE_SIZE","fetchAllDocs","payload","docs","page","hasMore","result","find","depth","fallbackLocale","undefined","limit","locale","overrideAccess","select","alt","doc","push","id","hasNextPage","computeAltTextHealthScan","collectionSummaries","Promise","all","map","Error","collectionError","logger","err","msg","plugin","completeDocs","invalidDocIds","missingDocs","partialDocs","totalDocs","filter","summary","getAltTextHealthCollectionTag","collectionSlug","getAltTextHealthScan","req","pluginConfig","config","custom","altTextPluginConfig","Boolean","localization","cacheKeyParts","sort","join","tags","Set","getCachedHealthScan","cacheFactory","compute","revalidate","getAltTextHealth","getAltTextHealthWidgetData","scan","localeCount","length","reduce","total","c"],"mappings":"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"}
1
+ {"version":3,"sources":["../../src/utilities/altTextHealth.ts"],"sourcesContent":["import type { Payload, PayloadRequest } from 'payload'\n\nimport { unstable_cache } from 'next/cache.js'\n\nimport type {\n AltTextPluginConfig,\n NormalizedAltTextCollectionConfig,\n} from '../types/AltTextPluginConfig.js'\n\nimport { createCachedAltTextHealthScan } from './altTextHealthCache.js'\nimport { localesFromConfig } from './localesFromConfig.js'\nimport { buildMimeTypeWhere } from './mimeTypes.js'\nimport { summarizeCollection } from './summarizeCollection.js'\n\nexport const ALT_TEXT_HEALTH_PLUGIN_SLUG = 'alt-text'\nexport const ALT_TEXT_HEALTH_CACHE_TTL = 3600\nexport const ALT_TEXT_HEALTH_GLOBAL_TAG = 'alt-text-health'\n\nexport type AltTextHealthErrorCode =\n | 'ALT_TEXT_COLLECTION_READ_FAILED'\n | 'ALT_TEXT_PLUGIN_CONFIG_MISSING'\n\nexport type AltTextHealthError = {\n code: AltTextHealthErrorCode\n collection?: string\n message: string\n operation?: 'find'\n}\n\nexport type AltTextHealthScanCollection = {\n collection: string\n completeDocs: number\n error?: AltTextHealthError\n invalidDocIds: (number | string)[] | undefined\n missingDocs: number\n partialDocs: number\n totalDocs: number\n}\n\nexport type AltTextHealthScan = {\n checkedAt: string\n collections: AltTextHealthScanCollection[]\n errors: AltTextHealthError[]\n isLocalized: boolean\n localeCodes: string[]\n}\n\nexport type AltTextHealthWidgetData = {\n collections: AltTextHealthScanCollection[]\n errors: AltTextHealthError[]\n isLocalized: boolean\n localeCount: number\n totalDocs: number\n}\n\ntype AltTextHealthComputationArgs = {\n collections: NormalizedAltTextCollectionConfig[]\n isLocalized: boolean\n localeCodes: string[]\n payload: Payload\n}\n\nconst createUnknownScan = ({\n error,\n isLocalized,\n localeCodes,\n}: {\n error: AltTextHealthScan['errors'][number]\n isLocalized: boolean\n localeCodes: string[]\n}): AltTextHealthScan => ({\n checkedAt: new Date().toISOString(),\n collections: [],\n errors: [error],\n isLocalized,\n localeCodes,\n})\n\nconst createCollectionReadError = (collection: string, message: string) => ({\n code: 'ALT_TEXT_COLLECTION_READ_FAILED' as const,\n collection,\n message,\n operation: 'find' as const,\n})\n\nconst PAGE_SIZE = 500\n\nasync function fetchAllDocs(\n payload: Payload,\n collection: string,\n isLocalized: boolean,\n mimeTypes: readonly string[],\n): Promise<{ alt: unknown; id: number | string }[]> {\n const where = buildMimeTypeWhere(mimeTypes)\n if (!where) {\n return []\n }\n\n const docs: { alt: unknown; id: number | string }[] = []\n let page = 1\n let hasMore = true\n\n while (hasMore) {\n const result = await payload.find({\n collection,\n depth: 0,\n fallbackLocale: isLocalized ? false : undefined,\n limit: PAGE_SIZE,\n locale: isLocalized ? 'all' : undefined,\n overrideAccess: true,\n page,\n select: {\n alt: true,\n },\n where,\n })\n\n for (const doc of result.docs) {\n docs.push({\n id: doc.id,\n alt: 'alt' in doc ? doc.alt : undefined,\n })\n }\n\n hasMore = result.hasNextPage\n page++\n }\n\n return docs\n}\n\nasync function computeAltTextHealthScan({\n collections,\n isLocalized,\n localeCodes,\n payload,\n}: AltTextHealthComputationArgs): Promise<AltTextHealthScan> {\n const collectionSummaries = await Promise.all(\n collections.map(async ({ slug, mimeTypes }): Promise<AltTextHealthScanCollection> => {\n try {\n const docs = await fetchAllDocs(payload, slug, isLocalized, mimeTypes)\n\n return summarizeCollection({\n collection: slug,\n docs,\n isLocalized,\n localeCodes,\n })\n } catch (error) {\n const message = error instanceof Error ? error.message : 'Unknown error'\n const collectionError = createCollectionReadError(slug, message)\n\n payload.logger.error({\n collection: slug,\n err: error,\n msg: 'Alt text health check failed while reading a collection.',\n operation: 'find',\n plugin: ALT_TEXT_HEALTH_PLUGIN_SLUG,\n })\n\n return {\n collection: slug,\n completeDocs: 0,\n error: collectionError,\n invalidDocIds: undefined,\n missingDocs: 0,\n partialDocs: 0,\n totalDocs: 0,\n }\n }\n }),\n )\n\n const errors = collectionSummaries\n .filter((summary) => summary.error)\n .map((summary) => summary.error!)\n\n return {\n checkedAt: new Date().toISOString(),\n collections: collectionSummaries,\n errors,\n isLocalized,\n localeCodes,\n }\n}\n\nexport const getAltTextHealthCollectionTag = (collectionSlug: string): string =>\n `${ALT_TEXT_HEALTH_GLOBAL_TAG}:${collectionSlug}`\n\nasync function getAltTextHealthScan(req: PayloadRequest): Promise<AltTextHealthScan> {\n const { payload } = req\n const pluginConfig = payload.config.custom?.altTextPluginConfig as AltTextPluginConfig | undefined\n const localeCodes =\n localesFromConfig(payload.config) ?? (pluginConfig?.locale ? [pluginConfig.locale] : [])\n const isLocalized = Boolean(payload.config.localization)\n\n if (!pluginConfig) {\n return createUnknownScan({\n error: {\n code: 'ALT_TEXT_PLUGIN_CONFIG_MISSING',\n message: 'Alt text plugin config not found',\n },\n isLocalized,\n localeCodes,\n })\n }\n\n const collections = pluginConfig.collections\n\n const cacheKeyParts = [\n ALT_TEXT_HEALTH_GLOBAL_TAG,\n [...collections]\n .map(({ slug, mimeTypes }) => `${slug}:${[...mimeTypes].sort().join('|')}`)\n .sort()\n .join(','),\n localeCodes.join(','),\n ]\n\n const tags = [\n ALT_TEXT_HEALTH_GLOBAL_TAG,\n ...new Set(collections.map(({ slug }) => getAltTextHealthCollectionTag(slug))),\n ]\n\n const getCachedHealthScan = createCachedAltTextHealthScan({\n cacheFactory: unstable_cache,\n cacheKeyParts,\n compute: async () =>\n computeAltTextHealthScan({\n collections,\n isLocalized,\n localeCodes,\n payload,\n }),\n revalidate: ALT_TEXT_HEALTH_CACHE_TTL,\n tags,\n })\n\n return getCachedHealthScan()\n}\n\nexport async function getAltTextHealth(req: PayloadRequest): Promise<AltTextHealthScan> {\n return getAltTextHealthScan(req)\n}\n\nexport async function getAltTextHealthWidgetData(\n req: PayloadRequest,\n): Promise<AltTextHealthWidgetData> {\n const scan = await getAltTextHealthScan(req)\n\n return {\n collections: scan.collections,\n errors: scan.errors,\n isLocalized: scan.isLocalized,\n localeCount: scan.localeCodes.length,\n totalDocs: scan.collections.reduce((total, c) => total + c.totalDocs, 0),\n }\n}\n"],"names":["unstable_cache","createCachedAltTextHealthScan","localesFromConfig","buildMimeTypeWhere","summarizeCollection","ALT_TEXT_HEALTH_PLUGIN_SLUG","ALT_TEXT_HEALTH_CACHE_TTL","ALT_TEXT_HEALTH_GLOBAL_TAG","createUnknownScan","error","isLocalized","localeCodes","checkedAt","Date","toISOString","collections","errors","createCollectionReadError","collection","message","code","operation","PAGE_SIZE","fetchAllDocs","payload","mimeTypes","where","docs","page","hasMore","result","find","depth","fallbackLocale","undefined","limit","locale","overrideAccess","select","alt","doc","push","id","hasNextPage","computeAltTextHealthScan","collectionSummaries","Promise","all","map","slug","Error","collectionError","logger","err","msg","plugin","completeDocs","invalidDocIds","missingDocs","partialDocs","totalDocs","filter","summary","getAltTextHealthCollectionTag","collectionSlug","getAltTextHealthScan","req","pluginConfig","config","custom","altTextPluginConfig","Boolean","localization","cacheKeyParts","sort","join","tags","Set","getCachedHealthScan","cacheFactory","compute","revalidate","getAltTextHealth","getAltTextHealthWidgetData","scan","localeCount","length","reduce","total","c"],"mappings":"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"}
@@ -0,0 +1,70 @@
1
+ import type { CollectionSlug, TextareaFieldValidation, Where } from 'payload';
2
+ export declare const DEFAULT_TRACKED_MIME_TYPES: readonly string[];
3
+ export type AltTextCollectionConfig = {
4
+ /**
5
+ * MIME types for which alt text is tracked, validated, and generated in this collection.
6
+ *
7
+ * Accepts exact MIME types (e.g. `image/png`) or wildcards (e.g. `image/*`).
8
+ * For documents whose mime type does not match, the alt text field is hidden,
9
+ * its validation is skipped, and the document is excluded from the health widget.
10
+ *
11
+ * @default ['image/*']
12
+ */
13
+ mimeTypes?: string[];
14
+ /** Collection slug to enable the plugin for. */
15
+ slug: CollectionSlug;
16
+ /**
17
+ * Custom validate function for the alt text field on this collection.
18
+ * When provided, it fully replaces the default validator (`validateAltText`).
19
+ *
20
+ * Use this to relax or extend the default — for example, to skip the
21
+ * required-alt check when the request body does not touch `alt`
22
+ * (folder moves, partial API updates).
23
+ *
24
+ * @example
25
+ * ```typescript
26
+ * import { validateAltText } from '@jhb.software/payload-alt-text-plugin'
27
+ *
28
+ * collections: [
29
+ * {
30
+ * slug: 'media',
31
+ * validate: (value, args) => {
32
+ * const { req } = args
33
+ * if (!req.data || !('alt' in req.data)) return true
34
+ * return validateAltText(value, args)
35
+ * },
36
+ * },
37
+ * ]
38
+ * ```
39
+ */
40
+ validate?: TextareaFieldValidation;
41
+ };
42
+ export type NormalizedAltTextCollectionConfig = {
43
+ mimeTypes: string[];
44
+ slug: CollectionSlug;
45
+ validate?: TextareaFieldValidation;
46
+ };
47
+ export type IncomingCollectionsConfig = (AltTextCollectionConfig | CollectionSlug)[];
48
+ export declare function normalizeCollectionsConfig(incoming: IncomingCollectionsConfig): NormalizedAltTextCollectionConfig[];
49
+ export declare function matchesMimeType(mimeType: string, patterns: readonly string[]): boolean;
50
+ /**
51
+ * Builds a Payload `where` clause that matches documents whose `mimeType`
52
+ * is in the given list of patterns. Returns `null` when nothing should match
53
+ * (empty patterns), so callers can short-circuit the query.
54
+ *
55
+ * Wildcards like `image/*` are translated to a `like` (case-insensitive
56
+ * substring) match on the prefix (`image/`). For valid MIME types this is
57
+ * equivalent to a prefix match.
58
+ */
59
+ export declare function buildMimeTypeWhere(patterns: readonly string[]): null | Where;
60
+ /**
61
+ * Default validation logic for the alt text field.
62
+ *
63
+ * - Allows an empty value during the initial upload (no regular update has occurred yet).
64
+ * - Allows an empty value when the document's mime type is not tracked for alt text.
65
+ * - Otherwise requires a non-empty value.
66
+ *
67
+ * Projects with stricter or looser requirements can pass a custom function to
68
+ * a collection's `validate` option instead.
69
+ */
70
+ export declare function validateAltText(value: Parameters<TextareaFieldValidation>[0], args: Parameters<TextareaFieldValidation>[1], trackedMimeTypes?: readonly string[]): string | true;
@@ -0,0 +1,115 @@
1
+ export const DEFAULT_TRACKED_MIME_TYPES = [
2
+ 'image/*'
3
+ ];
4
+ export function normalizeCollectionsConfig(incoming) {
5
+ return incoming.map((entry)=>{
6
+ if (typeof entry === 'string') {
7
+ return {
8
+ slug: entry,
9
+ mimeTypes: [
10
+ ...DEFAULT_TRACKED_MIME_TYPES
11
+ ]
12
+ };
13
+ }
14
+ const normalized = {
15
+ slug: entry.slug,
16
+ mimeTypes: entry.mimeTypes ? [
17
+ ...entry.mimeTypes
18
+ ] : [
19
+ ...DEFAULT_TRACKED_MIME_TYPES
20
+ ]
21
+ };
22
+ if (entry.validate) {
23
+ normalized.validate = entry.validate;
24
+ }
25
+ return normalized;
26
+ });
27
+ }
28
+ // Payload stores upload mimeType values as the lowercase MIME string (e.g. `image/png`).
29
+ // Pattern comparisons here are case-sensitive; callers should pass lowercase patterns.
30
+ export function matchesMimeType(mimeType, patterns) {
31
+ return patterns.some((pattern)=>{
32
+ if (pattern === mimeType) {
33
+ return true;
34
+ }
35
+ if (pattern.endsWith('/*')) {
36
+ const prefix = pattern.slice(0, -1);
37
+ return mimeType.startsWith(prefix);
38
+ }
39
+ return false;
40
+ });
41
+ }
42
+ /**
43
+ * Builds a Payload `where` clause that matches documents whose `mimeType`
44
+ * is in the given list of patterns. Returns `null` when nothing should match
45
+ * (empty patterns), so callers can short-circuit the query.
46
+ *
47
+ * Wildcards like `image/*` are translated to a `like` (case-insensitive
48
+ * substring) match on the prefix (`image/`). For valid MIME types this is
49
+ * equivalent to a prefix match.
50
+ */ export function buildMimeTypeWhere(patterns) {
51
+ if (patterns.length === 0) {
52
+ return null;
53
+ }
54
+ const exacts = [];
55
+ const wildcardPrefixes = [];
56
+ for (const pattern of patterns){
57
+ if (pattern.endsWith('/*')) {
58
+ wildcardPrefixes.push(pattern.slice(0, -1));
59
+ } else {
60
+ exacts.push(pattern);
61
+ }
62
+ }
63
+ const clauses = [];
64
+ if (exacts.length > 0) {
65
+ clauses.push({
66
+ mimeType: {
67
+ in: exacts
68
+ }
69
+ });
70
+ }
71
+ for (const prefix of wildcardPrefixes){
72
+ clauses.push({
73
+ mimeType: {
74
+ like: prefix
75
+ }
76
+ });
77
+ }
78
+ return clauses.length === 1 ? clauses[0] : {
79
+ or: clauses
80
+ };
81
+ }
82
+ /**
83
+ * Default validation logic for the alt text field.
84
+ *
85
+ * - Allows an empty value during the initial upload (no regular update has occurred yet).
86
+ * - Allows an empty value when the document's mime type is not tracked for alt text.
87
+ * - Otherwise requires a non-empty value.
88
+ *
89
+ * Projects with stricter or looser requirements can pass a custom function to
90
+ * a collection's `validate` option instead.
91
+ */ export function validateAltText(value, args, trackedMimeTypes) {
92
+ const data = args.data ?? {};
93
+ const { operation, req } = args;
94
+ // Since https://github.com/payloadcms/payload/pull/14988, when using external storage (e.g., S3),
95
+ // it is no longer possible to detect whether this validation runs during the initial upload
96
+ // or a regular update by checking the existence of the ID.
97
+ // Instead, compare the timestamps of the createdAt and updatedAt fields.
98
+ const isInitialUpload = operation === 'create' || 'createdAt' in data && 'updatedAt' in data && data.createdAt === data.updatedAt;
99
+ if (isInitialUpload) {
100
+ return true;
101
+ }
102
+ if (trackedMimeTypes && trackedMimeTypes.length > 0) {
103
+ const mimeType = typeof data.mimeType === 'string' ? data.mimeType : undefined;
104
+ if (!mimeType || !matchesMimeType(mimeType, trackedMimeTypes)) {
105
+ return true;
106
+ }
107
+ }
108
+ if (typeof value !== 'string' || value.trim().length === 0) {
109
+ // @ts-expect-error - the translation key type does not include the custom key
110
+ return req.t('@jhb.software/payload-alt-text-plugin:theAlternateTextIsRequired');
111
+ }
112
+ return true;
113
+ }
114
+
115
+ //# sourceMappingURL=mimeTypes.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../src/utilities/mimeTypes.ts"],"sourcesContent":["import type { CollectionSlug, TextareaFieldValidation, Where } from 'payload'\n\nexport const DEFAULT_TRACKED_MIME_TYPES: readonly string[] = ['image/*']\n\nexport type AltTextCollectionConfig = {\n /**\n * MIME types for which alt text is tracked, validated, and generated in this collection.\n *\n * Accepts exact MIME types (e.g. `image/png`) or wildcards (e.g. `image/*`).\n * For documents whose mime type does not match, the alt text field is hidden,\n * its validation is skipped, and the document is excluded from the health widget.\n *\n * @default ['image/*']\n */\n mimeTypes?: string[]\n /** Collection slug to enable the plugin for. */\n slug: CollectionSlug\n /**\n * Custom validate function for the alt text field on this collection.\n * When provided, it fully replaces the default validator (`validateAltText`).\n *\n * Use this to relax or extend the default — for example, to skip the\n * required-alt check when the request body does not touch `alt`\n * (folder moves, partial API updates).\n *\n * @example\n * ```typescript\n * import { validateAltText } from '@jhb.software/payload-alt-text-plugin'\n *\n * collections: [\n * {\n * slug: 'media',\n * validate: (value, args) => {\n * const { req } = args\n * if (!req.data || !('alt' in req.data)) return true\n * return validateAltText(value, args)\n * },\n * },\n * ]\n * ```\n */\n validate?: TextareaFieldValidation\n}\n\nexport type NormalizedAltTextCollectionConfig = {\n mimeTypes: string[]\n slug: CollectionSlug\n validate?: TextareaFieldValidation\n}\n\nexport type IncomingCollectionsConfig = (AltTextCollectionConfig | CollectionSlug)[]\n\nexport function normalizeCollectionsConfig(\n incoming: IncomingCollectionsConfig,\n): NormalizedAltTextCollectionConfig[] {\n return incoming.map((entry) => {\n if (typeof entry === 'string') {\n return { slug: entry, mimeTypes: [...DEFAULT_TRACKED_MIME_TYPES] }\n }\n\n const normalized: NormalizedAltTextCollectionConfig = {\n slug: entry.slug,\n mimeTypes: entry.mimeTypes ? [...entry.mimeTypes] : [...DEFAULT_TRACKED_MIME_TYPES],\n }\n if (entry.validate) {\n normalized.validate = entry.validate\n }\n return normalized\n })\n}\n\n// Payload stores upload mimeType values as the lowercase MIME string (e.g. `image/png`).\n// Pattern comparisons here are case-sensitive; callers should pass lowercase patterns.\nexport function matchesMimeType(mimeType: string, patterns: readonly string[]): boolean {\n return patterns.some((pattern) => {\n if (pattern === mimeType) {\n return true\n }\n if (pattern.endsWith('/*')) {\n const prefix = pattern.slice(0, -1)\n return mimeType.startsWith(prefix)\n }\n return false\n })\n}\n\n/**\n * Builds a Payload `where` clause that matches documents whose `mimeType`\n * is in the given list of patterns. Returns `null` when nothing should match\n * (empty patterns), so callers can short-circuit the query.\n *\n * Wildcards like `image/*` are translated to a `like` (case-insensitive\n * substring) match on the prefix (`image/`). For valid MIME types this is\n * equivalent to a prefix match.\n */\nexport function buildMimeTypeWhere(patterns: readonly string[]): null | Where {\n if (patterns.length === 0) {\n return null\n }\n\n const exacts: string[] = []\n const wildcardPrefixes: string[] = []\n\n for (const pattern of patterns) {\n if (pattern.endsWith('/*')) {\n wildcardPrefixes.push(pattern.slice(0, -1))\n } else {\n exacts.push(pattern)\n }\n }\n\n const clauses: Where[] = []\n if (exacts.length > 0) {\n clauses.push({ mimeType: { in: exacts } })\n }\n for (const prefix of wildcardPrefixes) {\n clauses.push({ mimeType: { like: prefix } })\n }\n\n return clauses.length === 1 ? clauses[0] : { or: clauses }\n}\n\n/**\n * Default validation logic for the alt text field.\n *\n * - Allows an empty value during the initial upload (no regular update has occurred yet).\n * - Allows an empty value when the document's mime type is not tracked for alt text.\n * - Otherwise requires a non-empty value.\n *\n * Projects with stricter or looser requirements can pass a custom function to\n * a collection's `validate` option instead.\n */\nexport function validateAltText(\n value: Parameters<TextareaFieldValidation>[0],\n args: Parameters<TextareaFieldValidation>[1],\n trackedMimeTypes?: readonly string[],\n): string | true {\n const data = (args.data ?? {}) as Record<string, unknown>\n const { operation, req } = args\n\n // Since https://github.com/payloadcms/payload/pull/14988, when using external storage (e.g., S3),\n // it is no longer possible to detect whether this validation runs during the initial upload\n // or a regular update by checking the existence of the ID.\n // Instead, compare the timestamps of the createdAt and updatedAt fields.\n const isInitialUpload =\n operation === 'create' ||\n ('createdAt' in data && 'updatedAt' in data && data.createdAt === data.updatedAt)\n\n if (isInitialUpload) {\n return true\n }\n\n if (trackedMimeTypes && trackedMimeTypes.length > 0) {\n const mimeType = typeof data.mimeType === 'string' ? data.mimeType : undefined\n if (!mimeType || !matchesMimeType(mimeType, trackedMimeTypes)) {\n return true\n }\n }\n\n if (typeof value !== 'string' || value.trim().length === 0) {\n // @ts-expect-error - the translation key type does not include the custom key\n return req.t('@jhb.software/payload-alt-text-plugin:theAlternateTextIsRequired')\n }\n\n return true\n}\n"],"names":["DEFAULT_TRACKED_MIME_TYPES","normalizeCollectionsConfig","incoming","map","entry","slug","mimeTypes","normalized","validate","matchesMimeType","mimeType","patterns","some","pattern","endsWith","prefix","slice","startsWith","buildMimeTypeWhere","length","exacts","wildcardPrefixes","push","clauses","in","like","or","validateAltText","value","args","trackedMimeTypes","data","operation","req","isInitialUpload","createdAt","updatedAt","undefined","trim","t"],"mappings":"AAEA,OAAO,MAAMA,6BAAgD;IAAC;CAAU,CAAA;AAkDxE,OAAO,SAASC,2BACdC,QAAmC;IAEnC,OAAOA,SAASC,GAAG,CAAC,CAACC;QACnB,IAAI,OAAOA,UAAU,UAAU;YAC7B,OAAO;gBAAEC,MAAMD;gBAAOE,WAAW;uBAAIN;iBAA2B;YAAC;QACnE;QAEA,MAAMO,aAAgD;YACpDF,MAAMD,MAAMC,IAAI;YAChBC,WAAWF,MAAME,SAAS,GAAG;mBAAIF,MAAME,SAAS;aAAC,GAAG;mBAAIN;aAA2B;QACrF;QACA,IAAII,MAAMI,QAAQ,EAAE;YAClBD,WAAWC,QAAQ,GAAGJ,MAAMI,QAAQ;QACtC;QACA,OAAOD;IACT;AACF;AAEA,yFAAyF;AACzF,uFAAuF;AACvF,OAAO,SAASE,gBAAgBC,QAAgB,EAAEC,QAA2B;IAC3E,OAAOA,SAASC,IAAI,CAAC,CAACC;QACpB,IAAIA,YAAYH,UAAU;YACxB,OAAO;QACT;QACA,IAAIG,QAAQC,QAAQ,CAAC,OAAO;YAC1B,MAAMC,SAASF,QAAQG,KAAK,CAAC,GAAG,CAAC;YACjC,OAAON,SAASO,UAAU,CAACF;QAC7B;QACA,OAAO;IACT;AACF;AAEA;;;;;;;;CAQC,GACD,OAAO,SAASG,mBAAmBP,QAA2B;IAC5D,IAAIA,SAASQ,MAAM,KAAK,GAAG;QACzB,OAAO;IACT;IAEA,MAAMC,SAAmB,EAAE;IAC3B,MAAMC,mBAA6B,EAAE;IAErC,KAAK,MAAMR,WAAWF,SAAU;QAC9B,IAAIE,QAAQC,QAAQ,CAAC,OAAO;YAC1BO,iBAAiBC,IAAI,CAACT,QAAQG,KAAK,CAAC,GAAG,CAAC;QAC1C,OAAO;YACLI,OAAOE,IAAI,CAACT;QACd;IACF;IAEA,MAAMU,UAAmB,EAAE;IAC3B,IAAIH,OAAOD,MAAM,GAAG,GAAG;QACrBI,QAAQD,IAAI,CAAC;YAAEZ,UAAU;gBAAEc,IAAIJ;YAAO;QAAE;IAC1C;IACA,KAAK,MAAML,UAAUM,iBAAkB;QACrCE,QAAQD,IAAI,CAAC;YAAEZ,UAAU;gBAAEe,MAAMV;YAAO;QAAE;IAC5C;IAEA,OAAOQ,QAAQJ,MAAM,KAAK,IAAII,OAAO,CAAC,EAAE,GAAG;QAAEG,IAAIH;IAAQ;AAC3D;AAEA;;;;;;;;;CASC,GACD,OAAO,SAASI,gBACdC,KAA6C,EAC7CC,IAA4C,EAC5CC,gBAAoC;IAEpC,MAAMC,OAAQF,KAAKE,IAAI,IAAI,CAAC;IAC5B,MAAM,EAAEC,SAAS,EAAEC,GAAG,EAAE,GAAGJ;IAE3B,kGAAkG;IAClG,4FAA4F;IAC5F,2DAA2D;IAC3D,yEAAyE;IACzE,MAAMK,kBACJF,cAAc,YACb,eAAeD,QAAQ,eAAeA,QAAQA,KAAKI,SAAS,KAAKJ,KAAKK,SAAS;IAElF,IAAIF,iBAAiB;QACnB,OAAO;IACT;IAEA,IAAIJ,oBAAoBA,iBAAiBX,MAAM,GAAG,GAAG;QACnD,MAAMT,WAAW,OAAOqB,KAAKrB,QAAQ,KAAK,WAAWqB,KAAKrB,QAAQ,GAAG2B;QACrE,IAAI,CAAC3B,YAAY,CAACD,gBAAgBC,UAAUoB,mBAAmB;YAC7D,OAAO;QACT;IACF;IAEA,IAAI,OAAOF,UAAU,YAAYA,MAAMU,IAAI,GAAGnB,MAAM,KAAK,GAAG;QAC1D,8EAA8E;QAC9E,OAAOc,IAAIM,CAAC,CAAC;IACf;IAEA,OAAO;AACT"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jhb.software/payload-alt-text-plugin",
3
- "version": "0.4.4",
3
+ "version": "0.5.0",
4
4
  "description": "A Payload CMS plugin that adds AI-powered alt text generation for images.",
5
5
  "bugs": "https://github.com/jhb-software/payload-plugins/issues",
6
6
  "repository": "https://github.com/jhb-software/payload-plugins",
@@ -17,28 +17,28 @@
17
17
  "main": "./dist/index.js",
18
18
  "types": "./dist/index.d.ts",
19
19
  "dependencies": {
20
- "openai": "^6.33.0",
20
+ "openai": "^6.34.0",
21
21
  "p-map": "^7.0.4",
22
22
  "zod": "^4.3.6"
23
23
  },
24
24
  "peerDependencies": {
25
- "@payloadcms/translations": "^3.81.0",
26
- "@payloadcms/ui": "^3.81.0",
25
+ "@payloadcms/translations": "^3.83.0",
26
+ "@payloadcms/ui": "^3.83.0",
27
27
  "next": "15.4.11",
28
- "payload": "^3.81.0",
29
- "react": "19.2.4",
30
- "react-dom": "19.2.4"
28
+ "payload": "^3.83.0",
29
+ "react": "19.2.5",
30
+ "react-dom": "19.2.5"
31
31
  },
32
32
  "devDependencies": {
33
33
  "@payloadcms/eslint-config": "^3.28.0",
34
- "@payloadcms/translations": "^3.81.0",
34
+ "@payloadcms/translations": "^3.83.0",
35
35
  "@swc/cli": "^0.8.1",
36
- "@swc/core": "^1.15.24",
36
+ "@swc/core": "^1.15.26",
37
37
  "@types/react": "19.2.14",
38
38
  "@types/react-dom": "19.2.3",
39
39
  "copyfiles": "2.4.1",
40
40
  "eslint": "^9.0.0",
41
- "prettier": "^3.8.1",
41
+ "prettier": "^3.8.3",
42
42
  "rimraf": "6.1.3",
43
43
  "typescript": "5.9.3"
44
44
  },
@@ -72,9 +72,11 @@
72
72
  "copyfiles": "copyfiles -u 1 \"src/**/*.{html,css,scss,ttf,woff,woff2,eot,svg,jpg,png,json}\" dist/",
73
73
  "clean": "rimraf --glob {dist,*.tsbuildinfo}",
74
74
  "dev": "tsc -w",
75
- "format": "prettier --write src",
75
+ "format": "prettier --write src \"*.{json,md,js,mjs,cjs}\"",
76
76
  "lint": "eslint src",
77
77
  "lint:fix": "eslint src --fix",
78
- "test:health": "node --experimental-strip-types --test test/altTextHealth.test.ts"
78
+ "test": "node --experimental-strip-types --test test/*.test.ts",
79
+ "test:health": "node --experimental-strip-types --test test/altTextHealth.test.ts",
80
+ "typecheck": "tsc --noEmit"
79
81
  }
80
82
  }