Skip to content

Commit db76041

Browse files
authored
feat(alt-text)!: scope alt text to per-collection MIME types (#112)
1 parent 671c7d6 commit db76041

14 files changed

Lines changed: 497 additions & 58 deletions

File tree

alt-text/CHANGELOG.md

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,39 @@
11
# Changelog
22

3-
## Unreleased
3+
## 0.5.0
44

5+
### Breaking Changes
6+
7+
Alt text is now scoped to image MIME types by default. Documents whose MIME type is not tracked no longer render the alt text field, are not validated for a required alt text, and are excluded from the alt text health widget.
8+
9+
This aligns the plugin with how other CMSs (WordPress, Drupal) handle alt text — as an image-only concept — and fixes mixed-media collections (e.g. videos alongside images) being counted as broken in the health widget. Projects whose configured upload collections only accept images see no behavior change.
10+
11+
The `collections` option now also accepts per-collection entries with a `mimeTypes` override. Bare slug strings continue to work as a shorthand for `['image/*']`:
12+
13+
**Before (v0.4.x):**
14+
15+
```typescript
16+
payloadAltTextPlugin({
17+
collections: ['media'],
18+
})
19+
```
20+
21+
**After (v0.5.0):**
22+
23+
```typescript
24+
payloadAltTextPlugin({
25+
// Bare slug — defaults to ['image/*']
26+
collections: ['media'],
27+
28+
// Or restrict / extend MIME types per collection
29+
collections: [
30+
{ slug: 'media', mimeTypes: ['image/*'] },
31+
{ slug: 'documents', mimeTypes: ['application/pdf'] },
32+
],
33+
})
34+
```
35+
36+
- feat: scope alt text tracking, validation, and health to configurable per-collection MIME types (default `['image/*']`)
537
- refactor: stop auto-injecting the alt text health widget into `admin.dashboard.defaultLayout`. The widget is still registered under `admin.dashboard.widgets`; add `{ widgetSlug: 'alt-text-health', width: 'full' }` to your `defaultLayout` to show it by default.
638
- fix: support both Next.js 15 and 16 `revalidateTag` type signatures in the alt text health invalidation hook
739

alt-text/dev/src/collections/Media.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ export const Media: CollectionConfig = {
77
singular: { de: 'Medium', en: 'Media' },
88
},
99
upload: {
10-
mimeTypes: ['image/*'],
10+
mimeTypes: ['image/*', 'video/*'],
1111
},
1212
fields: [
1313
// The plugin will automatically inject context, alt, and keywords fields

alt-text/dev/src/payload.config.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,12 @@ export default buildConfig({
4949
},
5050
plugins: [
5151
payloadAltTextPlugin({
52-
collections: ['media', 'images'], // Specify which upload collections should have alt text fields
52+
collections: [
53+
// `media` accepts both images and videos — restrict alt text tracking to images only.
54+
{ slug: 'media', mimeTypes: ['image/*'] },
55+
// Bare slug defaults to `['image/*']`.
56+
'images',
57+
],
5358
resolver: openAIResolver({
5459
apiKey: process.env.OPENAI_API_KEY!,
5560
model: 'gpt-4.1-mini',

alt-text/src/components/AltTextField.tsx

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,15 +4,27 @@ import type { TextareaFieldClientProps } from 'payload'
44

55
import { FieldLabel, TextareaInput, useDocumentInfo, useField } from '@payloadcms/ui'
66

7+
import { matchesMimeType } from '../utilities/mimeTypes.js'
78
import { GenerateAltTextButton } from './GenerateAltTextButton.js'
89

910
export const AltTextField = (clientProps: TextareaFieldClientProps) => {
1011
const { field, path } = clientProps
1112

1213
const supportedMimeTypes = field.admin?.custom?.supportedMimeTypes as string[] | undefined
14+
const trackedMimeTypes = field.admin?.custom?.trackedMimeTypes as string[] | undefined
1315

1416
const { setValue, value } = useField<string>({ path })
1517
const { id } = useDocumentInfo()
18+
const { value: mimeType } = useField<string>({ path: 'mimeType' })
19+
20+
const isTrackedMimeType =
21+
!trackedMimeTypes ||
22+
trackedMimeTypes.length === 0 ||
23+
(!!mimeType && matchesMimeType(mimeType, trackedMimeTypes))
24+
25+
if (!isTrackedMimeType) {
26+
return null
27+
}
1628

1729
// the field should be optional when the document is created
1830
// (since the alt text generation can only be used once the document is created and the image uploaded)

alt-text/src/endpoints/bulkGenerateAltTexts.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { z, ZodError } from 'zod'
66
import type { AltTextPluginConfig } from '../types/AltTextPluginConfig.js'
77

88
import { localesFromConfig } from '../utilities/localesFromConfig.js'
9+
import { matchesMimeType } from '../utilities/mimeTypes.js'
910

1011
/**
1112
* Generates and updates alt text for multiple images in all locales.
@@ -142,6 +143,14 @@ async function generateAndUpdateAltText({
142143
const mimeType =
143144
'mimeType' in imageDoc && typeof imageDoc.mimeType === 'string' ? imageDoc.mimeType : undefined
144145

146+
const collectionConfig = pluginConfig.collections.find((entry) => entry.slug === collection)
147+
148+
if (mimeType && collectionConfig && !matchesMimeType(mimeType, collectionConfig.mimeTypes)) {
149+
throw new Error(
150+
`Alt text is not tracked for files of type "${mimeType}" in the "${collection}" collection. Tracked types: ${collectionConfig.mimeTypes.join(', ')}.`,
151+
)
152+
}
153+
145154
if (
146155
mimeType &&
147156
pluginConfig.resolver.supportedMimeTypes &&

alt-text/src/endpoints/generateAltText.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ import { z, ZodError } from 'zod'
44

55
import type { AltTextPluginConfig } from '../types/AltTextPluginConfig.js'
66

7+
import { matchesMimeType } from '../utilities/mimeTypes.js'
8+
79
/**
810
* Generates alt text for a single image using the configured resolver.
911
*
@@ -66,6 +68,17 @@ export const generateAltTextEndpoint =
6668
? imageDoc.mimeType
6769
: undefined
6870

71+
const collectionConfig = pluginConfig.collections.find((entry) => entry.slug === collection)
72+
73+
if (mimeType && collectionConfig && !matchesMimeType(mimeType, collectionConfig.mimeTypes)) {
74+
return Response.json(
75+
{
76+
error: `Alt text is not tracked for files of type "${mimeType}" in the "${collection}" collection. Tracked types: ${collectionConfig.mimeTypes.join(', ')}.`,
77+
},
78+
{ status: 400 },
79+
)
80+
}
81+
6982
if (
7083
mimeType &&
7184
pluginConfig.resolver.supportedMimeTypes &&
Lines changed: 8 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,18 @@
11
import type { TextareaField } from 'payload'
22

3+
import { validateAltText } from '../utilities/mimeTypes.js'
34
import { translatedLabel } from '../utils/translatedLabel.js'
45

56
export function altTextField({
67
localized,
78
supportedMimeTypes,
9+
trackedMimeTypes,
810
}: {
911
localized?: TextareaField['localized']
12+
/** MIME types the resolver can generate for — used to disable the Generate button. */
1013
supportedMimeTypes?: string[]
14+
/** MIME types for which alt text is tracked — used to hide the field and skip validation for others. */
15+
trackedMimeTypes?: string[]
1116
}): TextareaField {
1217
return {
1318
name: 'alt',
@@ -18,32 +23,13 @@ export function altTextField({
1823
},
1924
custom: {
2025
supportedMimeTypes,
26+
trackedMimeTypes,
2127
},
2228
},
2329
label: translatedLabel('alternateText'),
2430
localized,
2531
required: true,
26-
validate: (value, { data, operation, req: { t } }) => {
27-
// Since https://github.com/payloadcms/payload/pull/14988, when using external storage (e.g., S3),
28-
// it is no longer possible to detect whether this validation runs during the initial upload
29-
// or a regular update by checking the existence of the ID.
30-
// Instead, compare the timestamps of the createdAt and updatedAt fields.
31-
const isInitialUpload =
32-
operation === 'create' ||
33-
('createdAt' in data && 'updatedAt' in data && data.createdAt === data.updatedAt)
34-
35-
// initial upload: allow without alt text
36-
if (isInitialUpload) {
37-
return true
38-
}
39-
40-
// regular update: require alt text
41-
if (!value || value.trim().length === 0) {
42-
// @ts-expect-error - the translation key type does not include the custom key
43-
return t('@jhb.software/payload-alt-text-plugin:theAlternateTextIsRequired')
44-
}
45-
46-
return true
47-
},
32+
validate: (value, args) =>
33+
validateAltText(value, args as Parameters<typeof validateAltText>[1], trackedMimeTypes),
4834
}
4935
}

alt-text/src/index.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,7 @@
11
export { payloadAltTextPlugin } from './plugin.js'
22
export { openAIResolver } from './resolvers/openAI.js'
33
export * from './resolvers/types.js'
4-
export type { IncomingAltTextPluginConfig as AltTextPluginConfig } from './types/AltTextPluginConfig.js'
4+
export type {
5+
AltTextCollectionConfig,
6+
IncomingAltTextPluginConfig as AltTextPluginConfig,
7+
} from './types/AltTextPluginConfig.js'

alt-text/src/plugin.ts

Lines changed: 27 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import {
1515
createRevalidateAltTextHealthAfterDeleteHook,
1616
} from './hooks/revalidateAltTextHealth.js'
1717
import { translations } from './translations/index.js'
18+
import { normalizeCollectionsConfig } from './utilities/mimeTypes.js'
1819
import { deepMergeSimple } from './utils/deepMergeSimple.js'
1920

2021
const altTextHealthWidgetDefinition = {
@@ -48,9 +49,11 @@ export const payloadAltTextPlugin =
4849

4950
const enableHealthCheck = incomingPluginConfig.healthCheck !== false
5051

52+
const normalizedCollections = normalizeCollectionsConfig(incomingPluginConfig.collections)
53+
5154
const pluginConfig: AltTextPluginConfig = {
5255
access: incomingPluginConfig.access ?? (({ req }) => !!req.user),
53-
collections: incomingPluginConfig.collections,
56+
collections: normalizedCollections,
5457
enabled: incomingPluginConfig.enabled ?? true,
5558
fieldsOverride: incomingPluginConfig.fieldsOverride,
5659
getImageThumbnail: incomingPluginConfig.getImageThumbnail,
@@ -69,35 +72,42 @@ export const payloadAltTextPlugin =
6972
)
7073
}
7174

72-
const defaultFields = [
73-
altTextField({
74-
localized: Boolean(config.localization),
75-
supportedMimeTypes: pluginConfig.resolver.supportedMimeTypes,
76-
}),
77-
keywordsField({
78-
localized: Boolean(config.localization),
79-
}),
80-
]
81-
82-
const fields =
83-
incomingPluginConfig.fieldsOverride &&
84-
typeof incomingPluginConfig.fieldsOverride === 'function'
85-
? incomingPluginConfig.fieldsOverride({ defaultFields })
86-
: defaultFields
75+
const collectionConfigBySlug = new Map<string, (typeof normalizedCollections)[number]>(
76+
normalizedCollections.map((entry) => [entry.slug, entry]),
77+
)
8778

8879
// Ensure collections array exists
8980
config.collections = config.collections || []
9081

9182
// Map over collections and inject AI alt text fields into specified ones
9283
config.collections = config.collections.map((collectionConfig) => {
93-
if (pluginConfig.collections.includes(collectionConfig.slug)) {
84+
const altTextCollectionConfig = collectionConfigBySlug.get(collectionConfig.slug)
85+
86+
if (altTextCollectionConfig) {
9487
if (!collectionConfig.upload) {
9588
console.warn(
9689
`AI Alt Text Plugin: Collection "${collectionConfig.slug}" is not an upload collection. Skipping field injection.`,
9790
)
9891
return collectionConfig
9992
}
10093

94+
const defaultFields = [
95+
altTextField({
96+
localized: Boolean(config.localization),
97+
supportedMimeTypes: pluginConfig.resolver.supportedMimeTypes,
98+
trackedMimeTypes: altTextCollectionConfig.mimeTypes,
99+
}),
100+
keywordsField({
101+
localized: Boolean(config.localization),
102+
}),
103+
]
104+
105+
const fields =
106+
incomingPluginConfig.fieldsOverride &&
107+
typeof incomingPluginConfig.fieldsOverride === 'function'
108+
? incomingPluginConfig.fieldsOverride({ defaultFields })
109+
: defaultFields
110+
101111
return {
102112
...collectionConfig,
103113
admin: {

alt-text/src/types/AltTextPluginConfig.ts

Lines changed: 26 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,13 @@
1-
import type { CollectionSlug, Field, PayloadRequest } from 'payload'
1+
import type { Field, PayloadRequest } from 'payload'
22

33
import type { AltTextResolver } from '../resolvers/types.js'
4+
import type {
5+
AltTextCollectionConfig,
6+
IncomingCollectionsConfig,
7+
NormalizedAltTextCollectionConfig,
8+
} from '../utilities/mimeTypes.js'
9+
10+
export type { AltTextCollectionConfig, NormalizedAltTextCollectionConfig }
411

512
/** Configuration options for the alt text plugin. */
613
export type IncomingAltTextPluginConfig = {
@@ -12,8 +19,22 @@ export type IncomingAltTextPluginConfig = {
1219
*/
1320
access?: (args: { req: PayloadRequest }) => boolean | Promise<boolean>
1421

15-
/** Collection slugs to enable the plugin for. */
16-
collections: CollectionSlug[]
22+
/**
23+
* Collections to enable the plugin for.
24+
*
25+
* Each entry may be a bare collection slug or an object with a `slug` and an
26+
* optional `mimeTypes` array restricting which MIME types are tracked,
27+
* validated, and generated. Bare slugs default to `['image/*']`.
28+
*
29+
* @example
30+
* ```typescript
31+
* collections: [
32+
* 'images', // shorthand — defaults to ['image/*']
33+
* { slug: 'media', mimeTypes: ['image/*', 'application/pdf'] },
34+
* ]
35+
* ```
36+
*/
37+
collections: IncomingCollectionsConfig
1738

1839
/** Whether the plugin is enabled. */
1940
enabled?: boolean
@@ -63,8 +84,8 @@ export type AltTextPluginConfig = {
6384
/** Access control for plugin endpoints. */
6485
access: (args: { req: PayloadRequest }) => boolean | Promise<boolean>
6586

66-
/** Collection slugs to enable the plugin for. */
67-
collections: CollectionSlug[]
87+
/** Collections with resolved MIME type filters. */
88+
collections: NormalizedAltTextCollectionConfig[]
6889

6990
/** Whether the plugin is enabled. */
7091
enabled: boolean

0 commit comments

Comments
 (0)