Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 33 additions & 1 deletion alt-text/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,39 @@
# Changelog

## Unreleased
## 0.5.0

### Breaking Changes

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.

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.

The `collections` option now also accepts per-collection entries with a `mimeTypes` override. Bare slug strings continue to work as a shorthand for `['image/*']`:

**Before (v0.4.x):**

```typescript
payloadAltTextPlugin({
collections: ['media'],
})
```

**After (v0.5.0):**

```typescript
payloadAltTextPlugin({
// Bare slug — defaults to ['image/*']
collections: ['media'],

// Or restrict / extend MIME types per collection
collections: [
{ slug: 'media', mimeTypes: ['image/*'] },
{ slug: 'documents', mimeTypes: ['application/pdf'] },
],
})
```

- feat: scope alt text tracking, validation, and health to configurable per-collection MIME types (default `['image/*']`)
- 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.
- fix: support both Next.js 15 and 16 `revalidateTag` type signatures in the alt text health invalidation hook

Expand Down
2 changes: 1 addition & 1 deletion alt-text/dev/src/collections/Media.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ export const Media: CollectionConfig = {
singular: { de: 'Medium', en: 'Media' },
},
upload: {
mimeTypes: ['image/*'],
mimeTypes: ['image/*', 'video/*'],
},
fields: [
// The plugin will automatically inject context, alt, and keywords fields
Expand Down
7 changes: 6 additions & 1 deletion alt-text/dev/src/payload.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,12 @@ export default buildConfig({
},
plugins: [
payloadAltTextPlugin({
collections: ['media', 'images'], // Specify which upload collections should have alt text fields
collections: [
// `media` accepts both images and videos — restrict alt text tracking to images only.
{ slug: 'media', mimeTypes: ['image/*'] },
// Bare slug defaults to `['image/*']`.
'images',
],
resolver: openAIResolver({
apiKey: process.env.OPENAI_API_KEY!,
model: 'gpt-4.1-mini',
Expand Down
12 changes: 12 additions & 0 deletions alt-text/src/components/AltTextField.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,15 +4,27 @@ import type { TextareaFieldClientProps } from 'payload'

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

import { matchesMimeType } from '../utilities/mimeTypes.js'
import { GenerateAltTextButton } from './GenerateAltTextButton.js'

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

const supportedMimeTypes = field.admin?.custom?.supportedMimeTypes as string[] | undefined
const trackedMimeTypes = field.admin?.custom?.trackedMimeTypes as string[] | undefined

const { setValue, value } = useField<string>({ path })
const { id } = useDocumentInfo()
const { value: mimeType } = useField<string>({ path: 'mimeType' })

const isTrackedMimeType =
!trackedMimeTypes ||
trackedMimeTypes.length === 0 ||
(!!mimeType && matchesMimeType(mimeType, trackedMimeTypes))

if (!isTrackedMimeType) {
return null
}

// the field should be optional when the document is created
// (since the alt text generation can only be used once the document is created and the image uploaded)
Expand Down
9 changes: 9 additions & 0 deletions alt-text/src/endpoints/bulkGenerateAltTexts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { z, ZodError } from 'zod'
import type { AltTextPluginConfig } from '../types/AltTextPluginConfig.js'

import { localesFromConfig } from '../utilities/localesFromConfig.js'
import { matchesMimeType } from '../utilities/mimeTypes.js'

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

const collectionConfig = pluginConfig.collections.find((entry) => entry.slug === collection)

if (mimeType && collectionConfig && !matchesMimeType(mimeType, collectionConfig.mimeTypes)) {
throw new Error(
`Alt text is not tracked for files of type "${mimeType}" in the "${collection}" collection. Tracked types: ${collectionConfig.mimeTypes.join(', ')}.`,
)
}

if (
mimeType &&
pluginConfig.resolver.supportedMimeTypes &&
Expand Down
13 changes: 13 additions & 0 deletions alt-text/src/endpoints/generateAltText.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import { z, ZodError } from 'zod'

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

import { matchesMimeType } from '../utilities/mimeTypes.js'

/**
* Generates alt text for a single image using the configured resolver.
*
Expand Down Expand Up @@ -66,6 +68,17 @@ export const generateAltTextEndpoint =
? imageDoc.mimeType
: undefined

const collectionConfig = pluginConfig.collections.find((entry) => entry.slug === collection)

if (mimeType && collectionConfig && !matchesMimeType(mimeType, collectionConfig.mimeTypes)) {
return Response.json(
{
error: `Alt text is not tracked for files of type "${mimeType}" in the "${collection}" collection. Tracked types: ${collectionConfig.mimeTypes.join(', ')}.`,
},
{ status: 400 },
)
}

if (
mimeType &&
pluginConfig.resolver.supportedMimeTypes &&
Expand Down
30 changes: 8 additions & 22 deletions alt-text/src/fields/altTextField.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,18 @@
import type { TextareaField } from 'payload'

import { validateAltText } from '../utilities/mimeTypes.js'
import { translatedLabel } from '../utils/translatedLabel.js'

export function altTextField({
localized,
supportedMimeTypes,
trackedMimeTypes,
}: {
localized?: TextareaField['localized']
/** MIME types the resolver can generate for — used to disable the Generate button. */
supportedMimeTypes?: string[]
/** MIME types for which alt text is tracked — used to hide the field and skip validation for others. */
trackedMimeTypes?: string[]
}): TextareaField {
return {
name: 'alt',
Expand All @@ -18,32 +23,13 @@ export function altTextField({
},
custom: {
supportedMimeTypes,
trackedMimeTypes,
},
},
label: translatedLabel('alternateText'),
localized,
required: true,
validate: (value, { data, operation, req: { t } }) => {
// Since https://github.com/payloadcms/payload/pull/14988, when using external storage (e.g., S3),
// it is no longer possible to detect whether this validation runs during the initial upload
// or a regular update by checking the existence of the ID.
// Instead, compare the timestamps of the createdAt and updatedAt fields.
const isInitialUpload =
operation === 'create' ||
('createdAt' in data && 'updatedAt' in data && data.createdAt === data.updatedAt)

// initial upload: allow without alt text
if (isInitialUpload) {
return true
}

// regular update: require alt text
if (!value || value.trim().length === 0) {
// @ts-expect-error - the translation key type does not include the custom key
return t('@jhb.software/payload-alt-text-plugin:theAlternateTextIsRequired')
}

return true
},
validate: (value, args) =>
validateAltText(value, args as Parameters<typeof validateAltText>[1], trackedMimeTypes),
}
}
5 changes: 4 additions & 1 deletion alt-text/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
export { payloadAltTextPlugin } from './plugin.js'
export { openAIResolver } from './resolvers/openAI.js'
export * from './resolvers/types.js'
export type { IncomingAltTextPluginConfig as AltTextPluginConfig } from './types/AltTextPluginConfig.js'
export type {
AltTextCollectionConfig,
IncomingAltTextPluginConfig as AltTextPluginConfig,
} from './types/AltTextPluginConfig.js'
44 changes: 27 additions & 17 deletions alt-text/src/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
createRevalidateAltTextHealthAfterDeleteHook,
} from './hooks/revalidateAltTextHealth.js'
import { translations } from './translations/index.js'
import { normalizeCollectionsConfig } from './utilities/mimeTypes.js'
import { deepMergeSimple } from './utils/deepMergeSimple.js'

const altTextHealthWidgetDefinition = {
Expand Down Expand Up @@ -48,9 +49,11 @@ export const payloadAltTextPlugin =

const enableHealthCheck = incomingPluginConfig.healthCheck !== false

const normalizedCollections = normalizeCollectionsConfig(incomingPluginConfig.collections)

const pluginConfig: AltTextPluginConfig = {
access: incomingPluginConfig.access ?? (({ req }) => !!req.user),
collections: incomingPluginConfig.collections,
collections: normalizedCollections,
enabled: incomingPluginConfig.enabled ?? true,
fieldsOverride: incomingPluginConfig.fieldsOverride,
getImageThumbnail: incomingPluginConfig.getImageThumbnail,
Expand All @@ -69,35 +72,42 @@ export const payloadAltTextPlugin =
)
}

const defaultFields = [
altTextField({
localized: Boolean(config.localization),
supportedMimeTypes: pluginConfig.resolver.supportedMimeTypes,
}),
keywordsField({
localized: Boolean(config.localization),
}),
]

const fields =
incomingPluginConfig.fieldsOverride &&
typeof incomingPluginConfig.fieldsOverride === 'function'
? incomingPluginConfig.fieldsOverride({ defaultFields })
: defaultFields
const collectionConfigBySlug = new Map<string, (typeof normalizedCollections)[number]>(
normalizedCollections.map((entry) => [entry.slug, entry]),
)

// Ensure collections array exists
config.collections = config.collections || []

// Map over collections and inject AI alt text fields into specified ones
config.collections = config.collections.map((collectionConfig) => {
if (pluginConfig.collections.includes(collectionConfig.slug)) {
const altTextCollectionConfig = collectionConfigBySlug.get(collectionConfig.slug)

if (altTextCollectionConfig) {
if (!collectionConfig.upload) {
console.warn(
`AI Alt Text Plugin: Collection "${collectionConfig.slug}" is not an upload collection. Skipping field injection.`,
)
return collectionConfig
}

const defaultFields = [
altTextField({
localized: Boolean(config.localization),
supportedMimeTypes: pluginConfig.resolver.supportedMimeTypes,
trackedMimeTypes: altTextCollectionConfig.mimeTypes,
}),
keywordsField({
localized: Boolean(config.localization),
}),
]

const fields =
incomingPluginConfig.fieldsOverride &&
typeof incomingPluginConfig.fieldsOverride === 'function'
? incomingPluginConfig.fieldsOverride({ defaultFields })
: defaultFields

return {
...collectionConfig,
admin: {
Expand Down
31 changes: 26 additions & 5 deletions alt-text/src/types/AltTextPluginConfig.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,13 @@
import type { CollectionSlug, Field, PayloadRequest } from 'payload'
import type { Field, PayloadRequest } from 'payload'

import type { AltTextResolver } from '../resolvers/types.js'
import type {
AltTextCollectionConfig,
IncomingCollectionsConfig,
NormalizedAltTextCollectionConfig,
} from '../utilities/mimeTypes.js'

export type { AltTextCollectionConfig, NormalizedAltTextCollectionConfig }

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

/** Collection slugs to enable the plugin for. */
collections: CollectionSlug[]
/**
* Collections to enable the plugin for.
*
* Each entry may be a bare collection slug or an object with a `slug` and an
* optional `mimeTypes` array restricting which MIME types are tracked,
* validated, and generated. Bare slugs default to `['image/*']`.
*
* @example
* ```typescript
* collections: [
* 'images', // shorthand — defaults to ['image/*']
* { slug: 'media', mimeTypes: ['image/*', 'application/pdf'] },
* ]
* ```
*/
collections: IncomingCollectionsConfig

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

/** Collection slugs to enable the plugin for. */
collections: CollectionSlug[]
/** Collections with resolved MIME type filters. */
collections: NormalizedAltTextCollectionConfig[]

/** Whether the plugin is enabled. */
enabled: boolean
Expand Down
Loading
Loading