No description
  • TypeScript 99.9%
Find a file
2026-06-02 22:01:12 +02:00
src fix: used outdated openai config name 2026-06-02 22:00:42 +02:00
.gitignore extract shared generatePrompt and add safe parseJsonResponse 2026-06-02 10:37:19 +02:00
justfile extract shared generatePrompt and add safe parseJsonResponse 2026-06-02 10:37:19 +02:00
package.json chore: release v0.1.3 2026-06-02 22:01:12 +02:00
plan.md opt into definePlugin API and replace pre-existing alt fields 2026-06-02 21:20:39 +02:00
README.md opt into definePlugin API and replace pre-existing alt fields 2026-06-02 21:20:39 +02:00
tsconfig.json extract shared generatePrompt and add safe parseJsonResponse 2026-06-02 10:37:19 +02:00
tsdown.config.ts add tsdown build step, ship compiled ESM + d.ts 2026-06-02 21:17:47 +02:00

@bl0cka.de/payload-alt-text-plugin

A Payload CMS v3 plugin that adds AI-powered alt text (and keyword) generation to any upload-enabled collection or global — with a swappable AI resolver.

How it works

  1. User selects an image in the Payload admin
  2. The image is resized on the client (OffscreenCanvas, no extra server load)
  3. The small thumbnail is POSTed to a custom Payload endpoint
  4. The endpoint delegates to your chosen resolver (Claude, OpenAI, or custom)
  5. The alt and altKeywords fields are filled in automatically
  6. If the user has already started typing when the generation finishes, a conflict banner appears — offering to Overwrite, Append, Copy, or Keep

The user can always edit the generated text and click Regenerate at any time. Nothing is saved until the user clicks Payload's normal Save button.


Installation

pnpm add @bl0cka.de/payload-alt-text-plugin
pnpm add @anthropic-ai/sdk   # if using claudeResolver
pnpm add openai              # if using openAIResolver

This package ships pre-compiled ESM with bundled type declarations, so it works out of the box in any Next.js or Node.js setup — no transpilePackages entry is required.


File structure

src/
├── index.ts                              # Plugin entry point + public exports
├── types.ts                              # All shared TypeScript types
├── components/
│   └── AltTextGeneratorField.tsx         # 'use client' field component
├── endpoints/
│   ├── generateAltText.ts                # Payload custom endpoint (server only)
│   └── renameMediaFile.ts                # Optional: atomic file rename endpoint
├── lib/
│   └── resizeImage.ts                    # Client-side canvas resize utility
└── resolvers/
    ├── index.ts                          # Re-exports all resolvers
    ├── claudeResolver.ts                 # Anthropic Claude vision resolver
    └── openAIResolver.ts                 # OpenAI GPT-4o resolver

Minimal usage

// payload.config.ts
import { buildConfig } from 'payload'
import { altTextPlugin } from '@bl0cka.de/payload-alt-text-plugin'
import { claudeResolver } from '@bl0cka.de/payload-alt-text-plugin/resolvers'

export default buildConfig({
  plugins: [
    altTextPlugin({
      resolver: claudeResolver({ apiKey: process.env.ANTHROPIC_API_KEY! }),
      collections: ['media'],
    }),
  ],
})

All options

Top-level AltTextPluginOptions

Option Type Default Description
resolver AltTextResolver required AI resolver to use
collections Array<string | AltTextCollectionConfig> [] Upload collections to target
globals Array<string | AltTextGlobalConfig> [] Globals to target
apiEndpoint string '/api/generate-alt-text' URL the client POSTs to
enabled boolean true Set false to disable without removing
replaceExistingFields boolean true Plugin-wide default for replacing a pre-existing top-level alt/keywords field instead of erroring on a duplicate (top-level only)

Per-entity options (AltTextCollectionConfig / AltTextGlobalConfig)

Option Type Default Description
slug string required Collection/global slug
altFieldName string 'alt' Name of the alt text field
keywordsFieldName string 'altKeywords' Name of the keywords field
enableKeywords boolean true Include the keywords hasMany field
required boolean true Make alt text required on save
altFieldOverrides Partial<TextField> {} Merge extra config onto the alt field
keywordsFieldOverrides Partial<TextField> {} Merge extra config onto the keywords field
defaultLocale string 'en' Fallback locale for generation
thumbnailMaxWidth number 512 Client-side resize max width (px)
thumbnailQuality number 0.8 Client-side JPEG quality (01)
autoGenerate boolean true Auto-generate on file select
autoGenerateOnOpen boolean true Auto-generate when opening a saved doc with empty alt
overwriteOnAutoGenerate boolean false Silently overwrite on auto-generate
enableFilenameRename boolean false AI-powered filename suggestion banner
replaceExistingFields boolean true Replace a pre-existing top-level alt/keywords field instead of erroring on a duplicate (top-level only; overrides the plugin-wide value)
uploadFieldName (globals only) string 'image' Form field whose File to use

Existing alt fields: Upload collections often already declare an alt field. By default the plugin replaces any colliding top-level field with its own (which wires up the generator UI), so you don't hit Payload's duplicate field-name error. Set replaceExistingFields: false to keep your own field instead (then use a distinct altFieldName to avoid the collision). Only top-level fields are scanned — not fields nested in tabs/rows/groups.

Payload plugin API: On Payload ≥ 3.83 the plugin registers via the experimental definePlugin API, exposing a slug (payload-alt-text-plugin) for cross-plugin discovery. On older versions it falls back to a plain plugin factory automatically — no minimum-version requirement.


Swapping the AI provider

// Use Claude (fast and cost-effective)
import { claudeResolver } from '@bl0cka.de/payload-alt-text-plugin/resolvers'

resolver: claudeResolver({
  apiKey: process.env.ANTHROPIC_API_KEY!,
  model: 'claude-sonnet-4-5', // optional, default: 'claude-haiku-4-5'
})

// Use OpenAI
import { openAIResolver } from '@bl0cka.de/payload-alt-text-plugin/resolvers'

resolver: openAIResolver({
  apiKey: process.env.OPENAI_API_KEY!,
  model: 'gpt-4o', // optional, default: 'gpt-4o-mini'
})

// Bring your own
const myResolver: AltTextResolver = {
  key: 'my-provider',
  resolve: async ({ fileBuffer, filename, locale }) => {
    // ... call your API
    return { altText: '...', keywords: ['...'] }
  },
}

Advanced example

altTextPlugin({
  resolver: claudeResolver({ apiKey: process.env.ANTHROPIC_API_KEY! }),
  enabled: process.env.NODE_ENV !== 'test',

  collections: [
    // Shorthand — all defaults
    'media',

    // Full config
    {
      slug: 'product-images',
      altFieldName: 'imageAlt',
      keywordsFieldName: 'imageKeywords',
      enableKeywords: true,
      required: true,
      defaultLocale: 'de',
      thumbnailMaxWidth: 768,
      thumbnailQuality: 0.85,
      autoGenerate: true,
      overwriteOnAutoGenerate: false,
      enableFilenameRename: true,
      altFieldOverrides: {
        label: 'Image description',
        localized: true,
        admin: {
          description: 'Required for accessibility. Auto-generated from the image.',
        },
      },
    },

    // No keywords, manual generation only
    {
      slug: 'avatars',
      enableKeywords: false,
      autoGenerate: false,
      required: false,
    },
  ],

  globals: [
    {
      slug: 'seo',
      uploadFieldName: 'openGraphImage',
      altFieldName: 'ogImageAlt',
      enableKeywords: false,
    },
  ],
})

Conflict resolution banner

When autoGenerate: true and the user starts typing in the alt text field before generation finishes, the component shows a banner with four choices:

  • Overwrite — replace typed text with the generated text
  • Append — join the typed text and generated text with a sentence separator
  • Copy to clipboard — copy generated text to clipboard, keep typed text as-is
  • Keep mine — dismiss the banner, discard the generated text

Set overwriteOnAutoGenerate: true to skip the banner and always overwrite silently.


Filename rename (optional)

When enableFilenameRename: true, the AI also evaluates whether the current filename is descriptive and suggests a better one if not. A banner appears offering to rename or keep the original. The rename is applied to the form's filename field and saved when the user clicks Payload's Save button.

For renaming already-saved files atomically (disk + database), a lower-level createRenameEndpoint is available:

import { createRenameEndpoint } from '@bl0cka.de/payload-alt-text-plugin/endpoints/renameMediaFile'

Writing a custom resolver

import type { AltTextResolver } from '@bl0cka.de/payload-alt-text-plugin'

export const myResolver: AltTextResolver = {
  key: 'my-backend',
  resolve: async ({ fileBuffer, filename, locale }) => {
    const base64 = fileBuffer.toString('base64')
    // ... call your API with base64, filename, locale
    return {
      altText: 'A descriptive alt text string',
      keywords: ['keyword1', 'keyword2'],
    }
  },
}