From 019582054f29fe21c78dc35b718d52677b77bfad Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 16 Apr 2026 16:14:46 +0000 Subject: [PATCH 1/3] feat(alt-text)!: scope alt text to per-collection MIME types Treat alt text as image-only by default, matching WCAG convention and other CMSs. Each collection now accepts a `mimeTypes` filter; documents whose MIME type is not tracked no longer render the field, skip validation, and are excluded from the health widget. BREAKING CHANGE: Bare `collections: ['media']` entries now default to `['image/*']` tracking. Mixed-media collections (e.g. videos alongside images) are no longer counted as broken in the health widget. https://claude.ai/code/session_016MH3cT2FtD21y43onPHJM1 --- alt-text/CHANGELOG.md | 35 ++++++ alt-text/dev/src/collections/Media.ts | 2 +- alt-text/dev/src/payload.config.ts | 7 +- alt-text/src/components/AltTextField.tsx | 12 ++ .../src/endpoints/bulkGenerateAltTexts.ts | 9 ++ alt-text/src/endpoints/generateAltText.ts | 17 +++ alt-text/src/fields/altTextField.ts | 30 ++--- alt-text/src/index.ts | 5 +- alt-text/src/plugin.ts | 44 ++++--- alt-text/src/types/AltTextPluginConfig.ts | 31 ++++- alt-text/src/utilities/altTextHealth.ts | 88 ++++++++------ alt-text/src/utilities/mimeTypes.ts | 112 ++++++++++++++++++ alt-text/test/altTextFieldValidate.test.ts | 97 +++++++++++++++ alt-text/test/altTextHealth.test.ts | 44 +++++++ alt-text/test/mimeTypes.test.ts | 109 +++++++++++++++++ 15 files changed, 557 insertions(+), 85 deletions(-) create mode 100644 alt-text/src/utilities/mimeTypes.ts create mode 100644 alt-text/test/altTextFieldValidate.test.ts create mode 100644 alt-text/test/mimeTypes.test.ts diff --git a/alt-text/CHANGELOG.md b/alt-text/CHANGELOG.md index 51cf2f00..ca71024e 100644 --- a/alt-text/CHANGELOG.md +++ b/alt-text/CHANGELOG.md @@ -1,5 +1,40 @@ # Changelog +## Unreleased + +### 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/*']`) + ## 0.4.4 - style: standardize icons to use Geist icon set (16x16 filled) diff --git a/alt-text/dev/src/collections/Media.ts b/alt-text/dev/src/collections/Media.ts index 00bd4455..d1e589a6 100644 --- a/alt-text/dev/src/collections/Media.ts +++ b/alt-text/dev/src/collections/Media.ts @@ -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 diff --git a/alt-text/dev/src/payload.config.ts b/alt-text/dev/src/payload.config.ts index d69708c3..6f6f65f4 100644 --- a/alt-text/dev/src/payload.config.ts +++ b/alt-text/dev/src/payload.config.ts @@ -46,7 +46,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', diff --git a/alt-text/src/components/AltTextField.tsx b/alt-text/src/components/AltTextField.tsx index c3f2250f..a59d8883 100644 --- a/alt-text/src/components/AltTextField.tsx +++ b/alt-text/src/components/AltTextField.tsx @@ -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({ path }) const { id } = useDocumentInfo() + const { value: mimeType } = useField({ 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) diff --git a/alt-text/src/endpoints/bulkGenerateAltTexts.ts b/alt-text/src/endpoints/bulkGenerateAltTexts.ts index 12f4221d..e75ad2e3 100644 --- a/alt-text/src/endpoints/bulkGenerateAltTexts.ts +++ b/alt-text/src/endpoints/bulkGenerateAltTexts.ts @@ -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. @@ -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 && diff --git a/alt-text/src/endpoints/generateAltText.ts b/alt-text/src/endpoints/generateAltText.ts index 685c46e3..84a03bc3 100644 --- a/alt-text/src/endpoints/generateAltText.ts +++ b/alt-text/src/endpoints/generateAltText.ts @@ -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. * @@ -66,6 +68,21 @@ 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 && diff --git a/alt-text/src/fields/altTextField.ts b/alt-text/src/fields/altTextField.ts index a0ee15d8..c0c0bd09 100644 --- a/alt-text/src/fields/altTextField.ts +++ b/alt-text/src/fields/altTextField.ts @@ -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', @@ -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[1], trackedMimeTypes), } } diff --git a/alt-text/src/index.ts b/alt-text/src/index.ts index 5359c953..82c54ddf 100644 --- a/alt-text/src/index.ts +++ b/alt-text/src/index.ts @@ -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' diff --git a/alt-text/src/plugin.ts b/alt-text/src/plugin.ts index 27b4782d..d3c4ff6b 100644 --- a/alt-text/src/plugin.ts +++ b/alt-text/src/plugin.ts @@ -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 = { @@ -88,9 +89,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, @@ -109,28 +112,18 @@ 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( + 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.`, @@ -138,6 +131,23 @@ export const payloadAltTextPlugin = 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: { diff --git a/alt-text/src/types/AltTextPluginConfig.ts b/alt-text/src/types/AltTextPluginConfig.ts index 3b127cad..6f66aa5d 100644 --- a/alt-text/src/types/AltTextPluginConfig.ts +++ b/alt-text/src/types/AltTextPluginConfig.ts @@ -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 = { @@ -12,8 +19,22 @@ export type IncomingAltTextPluginConfig = { */ access?: (args: { req: PayloadRequest }) => boolean | Promise - /** 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 @@ -63,8 +84,8 @@ export type AltTextPluginConfig = { /** Access control for plugin endpoints. */ access: (args: { req: PayloadRequest }) => boolean | Promise - /** Collection slugs to enable the plugin for. */ - collections: CollectionSlug[] + /** Collections with resolved MIME type filters. */ + collections: NormalizedAltTextCollectionConfig[] /** Whether the plugin is enabled. */ enabled: boolean diff --git a/alt-text/src/utilities/altTextHealth.ts b/alt-text/src/utilities/altTextHealth.ts index 5aaacbff..4dd6292b 100644 --- a/alt-text/src/utilities/altTextHealth.ts +++ b/alt-text/src/utilities/altTextHealth.ts @@ -2,10 +2,14 @@ import type { Payload, PayloadRequest } from 'payload' import { unstable_cache } from 'next/cache.js' -import type { AltTextPluginConfig } from '../types/AltTextPluginConfig.js' +import type { + AltTextPluginConfig, + NormalizedAltTextCollectionConfig, +} from '../types/AltTextPluginConfig.js' import { createCachedAltTextHealthScan } from './altTextHealthCache.js' import { localesFromConfig } from './localesFromConfig.js' +import { filterDocsByMimeType } from './mimeTypes.js' import { summarizeCollection } from './summarizeCollection.js' export const ALT_TEXT_HEALTH_PLUGIN_SLUG = 'alt-text' @@ -50,7 +54,7 @@ export type AltTextHealthWidgetData = { } type AltTextHealthComputationArgs = { - collections: string[] + collections: NormalizedAltTextCollectionConfig[] isLocalized: boolean localeCodes: string[] payload: Payload @@ -85,8 +89,8 @@ async function fetchAllDocs( payload: Payload, collection: string, isLocalized: boolean, -): Promise<{ alt: unknown; id: number | string }[]> { - const docs: { alt: unknown; id: number | string }[] = [] +): Promise<{ alt: unknown; id: number | string; mimeType: unknown }[]> { + const docs: { alt: unknown; id: number | string; mimeType: unknown }[] = [] let page = 1 let hasMore = true @@ -101,6 +105,7 @@ async function fetchAllDocs( page, select: { alt: true, + mimeType: true, }, }) @@ -108,6 +113,7 @@ async function fetchAllDocs( docs.push({ id: doc.id, alt: 'alt' in doc ? doc.alt : undefined, + mimeType: 'mimeType' in doc ? doc.mimeType : undefined, }) } @@ -125,39 +131,42 @@ async function computeAltTextHealthScan({ payload, }: AltTextHealthComputationArgs): Promise { const collectionSummaries = await Promise.all( - collections.map(async (collection): Promise => { - try { - const docs = await fetchAllDocs(payload, collection, isLocalized) - - return summarizeCollection({ - collection, - docs, - isLocalized, - localeCodes, - }) - } catch (error) { - const message = error instanceof Error ? error.message : 'Unknown error' - const collectionError = createCollectionReadError(collection, message) - - payload.logger.error({ - collection, - err: error, - msg: 'Alt text health check failed while reading a collection.', - operation: 'find', - plugin: ALT_TEXT_HEALTH_PLUGIN_SLUG, - }) - - return { - collection, - completeDocs: 0, - error: collectionError, - invalidDocIds: undefined, - missingDocs: 0, - partialDocs: 0, - totalDocs: 0, + collections.map( + async ({ slug, mimeTypes }): Promise => { + try { + const docs = await fetchAllDocs(payload, slug, isLocalized) + const tracked = filterDocsByMimeType(docs, mimeTypes) + + return summarizeCollection({ + collection: slug, + docs: tracked, + isLocalized, + localeCodes, + }) + } catch (error) { + const message = error instanceof Error ? error.message : 'Unknown error' + const collectionError = createCollectionReadError(slug, message) + + payload.logger.error({ + collection: slug, + err: error, + msg: 'Alt text health check failed while reading a collection.', + operation: 'find', + plugin: ALT_TEXT_HEALTH_PLUGIN_SLUG, + }) + + return { + collection: slug, + completeDocs: 0, + error: collectionError, + invalidDocIds: undefined, + missingDocs: 0, + partialDocs: 0, + totalDocs: 0, + } } - } - }), + }, + ), ) const errors = collectionSummaries @@ -198,13 +207,16 @@ async function getAltTextHealthScan(req: PayloadRequest): Promise `${slug}:${[...mimeTypes].sort().join('|')}`) + .sort() + .join(','), localeCodes.join(','), ] const tags = [ ALT_TEXT_HEALTH_GLOBAL_TAG, - ...new Set(collections.map((collection) => getAltTextHealthCollectionTag(collection))), + ...new Set(collections.map(({ slug }) => getAltTextHealthCollectionTag(slug))), ] const getCachedHealthScan = createCachedAltTextHealthScan({ diff --git a/alt-text/src/utilities/mimeTypes.ts b/alt-text/src/utilities/mimeTypes.ts new file mode 100644 index 00000000..640fb037 --- /dev/null +++ b/alt-text/src/utilities/mimeTypes.ts @@ -0,0 +1,112 @@ +import type { CollectionSlug } from 'payload' + +export const DEFAULT_TRACKED_MIME_TYPES: readonly string[] = ['image/*'] + +export type AltTextCollectionConfig = { + /** + * MIME types for which alt text is tracked, validated, and generated in this collection. + * + * Accepts exact MIME types (e.g. `image/png`) or wildcards (e.g. `image/*`). + * For documents whose mime type does not match, the alt text field is hidden, + * its validation is skipped, and the document is excluded from the health widget. + * + * @default ['image/*'] + */ + mimeTypes?: string[] + /** Collection slug to enable the plugin for. */ + slug: CollectionSlug +} + +export type NormalizedAltTextCollectionConfig = { + mimeTypes: string[] + slug: CollectionSlug +} + +export type IncomingCollectionsConfig = (AltTextCollectionConfig | CollectionSlug)[] + +export function normalizeCollectionsConfig( + incoming: IncomingCollectionsConfig, +): NormalizedAltTextCollectionConfig[] { + return incoming.map((entry) => { + if (typeof entry === 'string') { + return { slug: entry, mimeTypes: [...DEFAULT_TRACKED_MIME_TYPES] } + } + + return { + slug: entry.slug, + mimeTypes: entry.mimeTypes ? [...entry.mimeTypes] : [...DEFAULT_TRACKED_MIME_TYPES], + } + }) +} + +export function matchesMimeType(mimeType: string, patterns: readonly string[]): boolean { + return patterns.some((pattern) => { + if (pattern === mimeType) { + return true + } + if (pattern.endsWith('/*')) { + const prefix = pattern.slice(0, -1) + return mimeType.startsWith(prefix) + } + return false + }) +} + +export function filterDocsByMimeType( + docs: T[], + patterns: readonly string[], +): T[] { + return docs.filter((doc) => { + const mimeType = typeof doc.mimeType === 'string' ? doc.mimeType : undefined + if (!mimeType) { + return false + } + return matchesMimeType(mimeType, patterns) + }) +} + +type TFunction = (key: string) => string + +type ValidateAltTextArgs = { + data: Record + operation: string + req: { t: TFunction } +} + +/** + * Shared validation logic for the alt text field. + * + * - Allows an empty value during the initial upload (no regular update has occurred yet). + * - Allows an empty value when the document's mime type is not tracked for alt text. + * - Otherwise requires a non-empty value. + */ +export function validateAltText( + value: unknown, + { data, operation, req: { t } }: ValidateAltTextArgs, + trackedMimeTypes?: readonly string[], +): string | true { + // 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) + + if (isInitialUpload) { + return true + } + + if (trackedMimeTypes && trackedMimeTypes.length > 0) { + const mimeType = typeof data.mimeType === 'string' ? data.mimeType : undefined + if (!mimeType || !matchesMimeType(mimeType, trackedMimeTypes)) { + return true + } + } + + if (typeof value !== 'string' || value.trim().length === 0) { + return t('@jhb.software/payload-alt-text-plugin:theAlternateTextIsRequired') + } + + return true +} diff --git a/alt-text/test/altTextFieldValidate.test.ts b/alt-text/test/altTextFieldValidate.test.ts new file mode 100644 index 00000000..35abf8d4 --- /dev/null +++ b/alt-text/test/altTextFieldValidate.test.ts @@ -0,0 +1,97 @@ +import assert from 'node:assert/strict' +import { describe, test } from 'node:test' + +import { validateAltText } from '../src/utilities/mimeTypes.ts' + +const createdAt = '2024-01-01T00:00:00.000Z' +const updatedAt = '2024-01-02T00:00:00.000Z' + +const t = (key: string) => key + +const regularUpdateMeta = { + data: { createdAt, updatedAt }, + operation: 'update', + req: { t }, +} + +describe('validateAltText', () => { + test('rejects a regular update for a tracked image mime type with an empty alt text', () => { + const result = validateAltText( + '', + { + ...regularUpdateMeta, + data: { ...regularUpdateMeta.data, mimeType: 'image/png' }, + }, + ['image/*'], + ) + + assert.equal( + result, + '@jhb.software/payload-alt-text-plugin:theAlternateTextIsRequired', + ) + }) + + test('allows an empty alt text for a document whose mime type is not tracked', () => { + const result = validateAltText( + '', + { + ...regularUpdateMeta, + data: { ...regularUpdateMeta.data, mimeType: 'video/mp4' }, + }, + ['image/*'], + ) + + assert.equal(result, true) + }) + + test('accepts a filled alt text for a tracked image mime type', () => { + const result = validateAltText( + 'A descriptive alt text', + { + ...regularUpdateMeta, + data: { ...regularUpdateMeta.data, mimeType: 'image/png' }, + }, + ['image/*'], + ) + + assert.equal(result, true) + }) + + test('requires alt text regardless of mime type when no trackedMimeTypes are configured', () => { + const result = validateAltText('', { + ...regularUpdateMeta, + data: { ...regularUpdateMeta.data, mimeType: 'video/mp4' }, + }) + + assert.equal( + result, + '@jhb.software/payload-alt-text-plugin:theAlternateTextIsRequired', + ) + }) + + test('allows an empty alt text on the initial upload regardless of mime type', () => { + const result = validateAltText( + '', + { + data: { createdAt, updatedAt: createdAt, mimeType: 'image/png' }, + operation: 'update', + req: { t }, + }, + ['image/*'], + ) + + assert.equal(result, true) + }) + + test('allows an empty alt text when the document has no mime type (non-upload rows)', () => { + const result = validateAltText( + '', + { + ...regularUpdateMeta, + }, + ['image/*'], + ) + + assert.equal(result, true) + }) +}) diff --git a/alt-text/test/altTextHealth.test.ts b/alt-text/test/altTextHealth.test.ts index d9c470bf..83d6a975 100644 --- a/alt-text/test/altTextHealth.test.ts +++ b/alt-text/test/altTextHealth.test.ts @@ -1,6 +1,7 @@ import assert from 'node:assert/strict' import { describe, test } from 'node:test' +import { filterDocsByMimeType } from '../src/utilities/mimeTypes.ts' import { MAX_INVALID_DOC_IDS, summarizeCollection } from '../src/utilities/summarizeCollection.ts' describe('summarizeCollection (non-localized)', () => { @@ -133,3 +134,46 @@ describe('summarizeCollection (localized)', () => { assert.equal(result.partialDocs, 0) }) }) + +describe('health scan excludes docs with unsupported mime types', () => { + test('videos in a mixed collection are not counted as missing alt text', () => { + const fetched = [ + { alt: 'A photo', id: '1', mimeType: 'image/png' }, + { alt: '', id: '2', mimeType: 'image/png' }, + { alt: '', id: '3', mimeType: 'video/mp4' }, + { alt: '', id: '4', mimeType: 'video/quicktime' }, + ] + + const tracked = filterDocsByMimeType(fetched, ['image/*']) + const result = summarizeCollection({ + collection: 'media', + docs: tracked, + isLocalized: false, + localeCodes: [], + }) + + assert.equal(result.totalDocs, 2) + assert.equal(result.completeDocs, 1) + assert.equal(result.missingDocs, 1) + assert.deepEqual(result.invalidDocIds, ['2']) + }) + + test('SVGs are included when the tracked list has a wildcard image pattern', () => { + const fetched = [ + { alt: 'An icon', id: '1', mimeType: 'image/svg+xml' }, + { alt: '', id: '2', mimeType: 'image/svg+xml' }, + { alt: '', id: '3', mimeType: 'application/pdf' }, + ] + + const tracked = filterDocsByMimeType(fetched, ['image/*']) + const result = summarizeCollection({ + collection: 'media', + docs: tracked, + isLocalized: false, + localeCodes: [], + }) + + assert.equal(result.totalDocs, 2) + assert.equal(result.missingDocs, 1) + }) +}) diff --git a/alt-text/test/mimeTypes.test.ts b/alt-text/test/mimeTypes.test.ts new file mode 100644 index 00000000..dff1a04c --- /dev/null +++ b/alt-text/test/mimeTypes.test.ts @@ -0,0 +1,109 @@ +import assert from 'node:assert/strict' +import { describe, test } from 'node:test' + +import { + DEFAULT_TRACKED_MIME_TYPES, + filterDocsByMimeType, + matchesMimeType, + normalizeCollectionsConfig, +} from '../src/utilities/mimeTypes.ts' + +describe('matchesMimeType', () => { + test('returns true for an exact match', () => { + assert.equal(matchesMimeType('image/png', ['image/png']), true) + }) + + test('returns false when no pattern matches', () => { + assert.equal(matchesMimeType('video/mp4', ['image/png', 'image/jpeg']), false) + }) + + test('matches a wildcard pattern with the given top-level type', () => { + assert.equal(matchesMimeType('image/jpeg', ['image/*']), true) + assert.equal(matchesMimeType('image/svg+xml', ['image/*']), true) + }) + + test('does not cross top-level types for wildcards', () => { + assert.equal(matchesMimeType('video/mp4', ['image/*']), false) + }) + + test('succeeds when any of multiple patterns match', () => { + assert.equal(matchesMimeType('application/pdf', ['image/*', 'application/pdf']), true) + }) + + test('returns false for an empty pattern list', () => { + assert.equal(matchesMimeType('image/png', []), false) + }) +}) + +describe('normalizeCollectionsConfig', () => { + test('expands a bare slug string to the default tracked mime types', () => { + const result = normalizeCollectionsConfig(['media']) + + assert.deepEqual(result, [{ slug: 'media', mimeTypes: [...DEFAULT_TRACKED_MIME_TYPES] }]) + }) + + test('preserves an explicit mimeTypes array', () => { + const result = normalizeCollectionsConfig([ + { slug: 'media', mimeTypes: ['image/png', 'image/svg+xml'] }, + ]) + + assert.deepEqual(result, [{ slug: 'media', mimeTypes: ['image/png', 'image/svg+xml'] }]) + }) + + test('defaults mimeTypes when an object omits them', () => { + const result = normalizeCollectionsConfig([{ slug: 'media' }]) + + assert.deepEqual(result, [{ slug: 'media', mimeTypes: [...DEFAULT_TRACKED_MIME_TYPES] }]) + }) + + test('supports mixed entries', () => { + const result = normalizeCollectionsConfig([ + 'images', + { slug: 'media', mimeTypes: ['image/png'] }, + ]) + + assert.deepEqual(result, [ + { slug: 'images', mimeTypes: [...DEFAULT_TRACKED_MIME_TYPES] }, + { slug: 'media', mimeTypes: ['image/png'] }, + ]) + }) +}) + +describe('filterDocsByMimeType', () => { + test('keeps only docs whose mime type matches a pattern', () => { + const docs = [ + { id: '1', mimeType: 'image/jpeg' }, + { id: '2', mimeType: 'video/mp4' }, + { id: '3', mimeType: 'image/png' }, + { id: '4', mimeType: 'application/pdf' }, + ] + + const result = filterDocsByMimeType(docs, ['image/*']) + + assert.deepEqual( + result.map((doc) => doc.id), + ['1', '3'], + ) + }) + + test('drops docs without a mime type', () => { + const docs = [ + { id: '1', mimeType: 'image/jpeg' }, + { id: '2', mimeType: undefined }, + { id: '3', mimeType: null as unknown as string }, + ] + + const result = filterDocsByMimeType(docs, ['image/*']) + + assert.deepEqual( + result.map((doc) => doc.id), + ['1'], + ) + }) + + test('returns an empty list when no patterns are given', () => { + const docs = [{ id: '1', mimeType: 'image/jpeg' }] + + assert.deepEqual(filterDocsByMimeType(docs, []), []) + }) +}) From 8c9f82db48e22a398199f37649bd330b30290a50 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 17 Apr 2026 17:12:41 +0000 Subject: [PATCH 2/3] chore(alt-text): set changelog version to 0.5.0 and apply formatting https://claude.ai/code/session_016MH3cT2FtD21y43onPHJM1 --- alt-text/CHANGELOG.md | 2 +- alt-text/src/endpoints/generateAltText.ts | 6 +- alt-text/src/utilities/altTextHealth.ts | 68 +++++++++++------------ 3 files changed, 35 insertions(+), 41 deletions(-) diff --git a/alt-text/CHANGELOG.md b/alt-text/CHANGELOG.md index ca71024e..f11e9aa7 100644 --- a/alt-text/CHANGELOG.md +++ b/alt-text/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## Unreleased +## 0.5.0 ### Breaking Changes diff --git a/alt-text/src/endpoints/generateAltText.ts b/alt-text/src/endpoints/generateAltText.ts index 84a03bc3..63e36724 100644 --- a/alt-text/src/endpoints/generateAltText.ts +++ b/alt-text/src/endpoints/generateAltText.ts @@ -70,11 +70,7 @@ export const generateAltTextEndpoint = const collectionConfig = pluginConfig.collections.find((entry) => entry.slug === collection) - if ( - mimeType && - collectionConfig && - !matchesMimeType(mimeType, collectionConfig.mimeTypes) - ) { + 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(', ')}.`, diff --git a/alt-text/src/utilities/altTextHealth.ts b/alt-text/src/utilities/altTextHealth.ts index 4dd6292b..b6393841 100644 --- a/alt-text/src/utilities/altTextHealth.ts +++ b/alt-text/src/utilities/altTextHealth.ts @@ -131,42 +131,40 @@ async function computeAltTextHealthScan({ payload, }: AltTextHealthComputationArgs): Promise { const collectionSummaries = await Promise.all( - collections.map( - async ({ slug, mimeTypes }): Promise => { - try { - const docs = await fetchAllDocs(payload, slug, isLocalized) - const tracked = filterDocsByMimeType(docs, mimeTypes) - - return summarizeCollection({ - collection: slug, - docs: tracked, - isLocalized, - localeCodes, - }) - } catch (error) { - const message = error instanceof Error ? error.message : 'Unknown error' - const collectionError = createCollectionReadError(slug, message) - - payload.logger.error({ - collection: slug, - err: error, - msg: 'Alt text health check failed while reading a collection.', - operation: 'find', - plugin: ALT_TEXT_HEALTH_PLUGIN_SLUG, - }) - - return { - collection: slug, - completeDocs: 0, - error: collectionError, - invalidDocIds: undefined, - missingDocs: 0, - partialDocs: 0, - totalDocs: 0, - } + collections.map(async ({ slug, mimeTypes }): Promise => { + try { + const docs = await fetchAllDocs(payload, slug, isLocalized) + const tracked = filterDocsByMimeType(docs, mimeTypes) + + return summarizeCollection({ + collection: slug, + docs: tracked, + isLocalized, + localeCodes, + }) + } catch (error) { + const message = error instanceof Error ? error.message : 'Unknown error' + const collectionError = createCollectionReadError(slug, message) + + payload.logger.error({ + collection: slug, + err: error, + msg: 'Alt text health check failed while reading a collection.', + operation: 'find', + plugin: ALT_TEXT_HEALTH_PLUGIN_SLUG, + }) + + return { + collection: slug, + completeDocs: 0, + error: collectionError, + invalidDocIds: undefined, + missingDocs: 0, + partialDocs: 0, + totalDocs: 0, } - }, - ), + } + }), ) const errors = collectionSummaries From d35b9078915c64421cd80b4e488168c91b8f06c4 Mon Sep 17 00:00:00 2001 From: Jens Becker Date: Sat, 25 Apr 2026 15:34:24 +0200 Subject: [PATCH 3/3] refactor(alt-text): filter health scan by mime type at the database level --- alt-text/src/utilities/altTextHealth.ts | 20 ++++++---- alt-text/src/utilities/mimeTypes.ts | 47 +++++++++++++++++----- alt-text/test/altTextHealth.test.ts | 44 -------------------- alt-text/test/mimeTypes.test.ts | 53 ++++++++++--------------- 4 files changed, 70 insertions(+), 94 deletions(-) diff --git a/alt-text/src/utilities/altTextHealth.ts b/alt-text/src/utilities/altTextHealth.ts index b6393841..a4bcb8a3 100644 --- a/alt-text/src/utilities/altTextHealth.ts +++ b/alt-text/src/utilities/altTextHealth.ts @@ -9,7 +9,7 @@ import type { import { createCachedAltTextHealthScan } from './altTextHealthCache.js' import { localesFromConfig } from './localesFromConfig.js' -import { filterDocsByMimeType } from './mimeTypes.js' +import { buildMimeTypeWhere } from './mimeTypes.js' import { summarizeCollection } from './summarizeCollection.js' export const ALT_TEXT_HEALTH_PLUGIN_SLUG = 'alt-text' @@ -89,8 +89,14 @@ async function fetchAllDocs( payload: Payload, collection: string, isLocalized: boolean, -): Promise<{ alt: unknown; id: number | string; mimeType: unknown }[]> { - const docs: { alt: unknown; id: number | string; mimeType: unknown }[] = [] + mimeTypes: readonly string[], +): Promise<{ alt: unknown; id: number | string }[]> { + const where = buildMimeTypeWhere(mimeTypes) + if (!where) { + return [] + } + + const docs: { alt: unknown; id: number | string }[] = [] let page = 1 let hasMore = true @@ -105,15 +111,14 @@ async function fetchAllDocs( page, select: { alt: true, - mimeType: true, }, + where, }) for (const doc of result.docs) { docs.push({ id: doc.id, alt: 'alt' in doc ? doc.alt : undefined, - mimeType: 'mimeType' in doc ? doc.mimeType : undefined, }) } @@ -133,12 +138,11 @@ async function computeAltTextHealthScan({ const collectionSummaries = await Promise.all( collections.map(async ({ slug, mimeTypes }): Promise => { try { - const docs = await fetchAllDocs(payload, slug, isLocalized) - const tracked = filterDocsByMimeType(docs, mimeTypes) + const docs = await fetchAllDocs(payload, slug, isLocalized, mimeTypes) return summarizeCollection({ collection: slug, - docs: tracked, + docs, isLocalized, localeCodes, }) diff --git a/alt-text/src/utilities/mimeTypes.ts b/alt-text/src/utilities/mimeTypes.ts index 640fb037..5f292fca 100644 --- a/alt-text/src/utilities/mimeTypes.ts +++ b/alt-text/src/utilities/mimeTypes.ts @@ -1,4 +1,4 @@ -import type { CollectionSlug } from 'payload' +import type { CollectionSlug, Where } from 'payload' export const DEFAULT_TRACKED_MIME_TYPES: readonly string[] = ['image/*'] @@ -39,6 +39,8 @@ export function normalizeCollectionsConfig( }) } +// Payload stores upload mimeType values as the lowercase MIME string (e.g. `image/png`). +// Pattern comparisons here are case-sensitive; callers should pass lowercase patterns. export function matchesMimeType(mimeType: string, patterns: readonly string[]): boolean { return patterns.some((pattern) => { if (pattern === mimeType) { @@ -52,17 +54,40 @@ export function matchesMimeType(mimeType: string, patterns: readonly string[]): }) } -export function filterDocsByMimeType( - docs: T[], - patterns: readonly string[], -): T[] { - return docs.filter((doc) => { - const mimeType = typeof doc.mimeType === 'string' ? doc.mimeType : undefined - if (!mimeType) { - return false +/** + * Builds a Payload `where` clause that matches documents whose `mimeType` + * is in the given list of patterns. Returns `null` when nothing should match + * (empty patterns), so callers can short-circuit the query. + * + * Wildcards like `image/*` are translated to a `like` (case-insensitive + * substring) match on the prefix (`image/`). For valid MIME types this is + * equivalent to a prefix match. + */ +export function buildMimeTypeWhere(patterns: readonly string[]): null | Where { + if (patterns.length === 0) { + return null + } + + const exacts: string[] = [] + const wildcardPrefixes: string[] = [] + + for (const pattern of patterns) { + if (pattern.endsWith('/*')) { + wildcardPrefixes.push(pattern.slice(0, -1)) + } else { + exacts.push(pattern) } - return matchesMimeType(mimeType, patterns) - }) + } + + const clauses: Where[] = [] + if (exacts.length > 0) { + clauses.push({ mimeType: { in: exacts } }) + } + for (const prefix of wildcardPrefixes) { + clauses.push({ mimeType: { like: prefix } }) + } + + return clauses.length === 1 ? clauses[0] : { or: clauses } } type TFunction = (key: string) => string diff --git a/alt-text/test/altTextHealth.test.ts b/alt-text/test/altTextHealth.test.ts index 83d6a975..d9c470bf 100644 --- a/alt-text/test/altTextHealth.test.ts +++ b/alt-text/test/altTextHealth.test.ts @@ -1,7 +1,6 @@ import assert from 'node:assert/strict' import { describe, test } from 'node:test' -import { filterDocsByMimeType } from '../src/utilities/mimeTypes.ts' import { MAX_INVALID_DOC_IDS, summarizeCollection } from '../src/utilities/summarizeCollection.ts' describe('summarizeCollection (non-localized)', () => { @@ -134,46 +133,3 @@ describe('summarizeCollection (localized)', () => { assert.equal(result.partialDocs, 0) }) }) - -describe('health scan excludes docs with unsupported mime types', () => { - test('videos in a mixed collection are not counted as missing alt text', () => { - const fetched = [ - { alt: 'A photo', id: '1', mimeType: 'image/png' }, - { alt: '', id: '2', mimeType: 'image/png' }, - { alt: '', id: '3', mimeType: 'video/mp4' }, - { alt: '', id: '4', mimeType: 'video/quicktime' }, - ] - - const tracked = filterDocsByMimeType(fetched, ['image/*']) - const result = summarizeCollection({ - collection: 'media', - docs: tracked, - isLocalized: false, - localeCodes: [], - }) - - assert.equal(result.totalDocs, 2) - assert.equal(result.completeDocs, 1) - assert.equal(result.missingDocs, 1) - assert.deepEqual(result.invalidDocIds, ['2']) - }) - - test('SVGs are included when the tracked list has a wildcard image pattern', () => { - const fetched = [ - { alt: 'An icon', id: '1', mimeType: 'image/svg+xml' }, - { alt: '', id: '2', mimeType: 'image/svg+xml' }, - { alt: '', id: '3', mimeType: 'application/pdf' }, - ] - - const tracked = filterDocsByMimeType(fetched, ['image/*']) - const result = summarizeCollection({ - collection: 'media', - docs: tracked, - isLocalized: false, - localeCodes: [], - }) - - assert.equal(result.totalDocs, 2) - assert.equal(result.missingDocs, 1) - }) -}) diff --git a/alt-text/test/mimeTypes.test.ts b/alt-text/test/mimeTypes.test.ts index dff1a04c..ab264597 100644 --- a/alt-text/test/mimeTypes.test.ts +++ b/alt-text/test/mimeTypes.test.ts @@ -2,8 +2,8 @@ import assert from 'node:assert/strict' import { describe, test } from 'node:test' import { + buildMimeTypeWhere, DEFAULT_TRACKED_MIME_TYPES, - filterDocsByMimeType, matchesMimeType, normalizeCollectionsConfig, } from '../src/utilities/mimeTypes.ts' @@ -69,41 +69,32 @@ describe('normalizeCollectionsConfig', () => { }) }) -describe('filterDocsByMimeType', () => { - test('keeps only docs whose mime type matches a pattern', () => { - const docs = [ - { id: '1', mimeType: 'image/jpeg' }, - { id: '2', mimeType: 'video/mp4' }, - { id: '3', mimeType: 'image/png' }, - { id: '4', mimeType: 'application/pdf' }, - ] - - const result = filterDocsByMimeType(docs, ['image/*']) - - assert.deepEqual( - result.map((doc) => doc.id), - ['1', '3'], - ) +describe('buildMimeTypeWhere', () => { + test('returns null for an empty pattern list so callers can skip the query', () => { + assert.equal(buildMimeTypeWhere([]), null) }) - test('drops docs without a mime type', () => { - const docs = [ - { id: '1', mimeType: 'image/jpeg' }, - { id: '2', mimeType: undefined }, - { id: '3', mimeType: null as unknown as string }, - ] - - const result = filterDocsByMimeType(docs, ['image/*']) + test('returns an `in` clause for a single exact pattern', () => { + assert.deepEqual(buildMimeTypeWhere(['image/png']), { + mimeType: { in: ['image/png'] }, + }) + }) - assert.deepEqual( - result.map((doc) => doc.id), - ['1'], - ) + test('groups multiple exact patterns into a single `in` clause', () => { + assert.deepEqual(buildMimeTypeWhere(['image/png', 'image/jpeg']), { + mimeType: { in: ['image/png', 'image/jpeg'] }, + }) }) - test('returns an empty list when no patterns are given', () => { - const docs = [{ id: '1', mimeType: 'image/jpeg' }] + test('translates a single wildcard pattern into a `like` prefix match', () => { + assert.deepEqual(buildMimeTypeWhere(['image/*']), { + mimeType: { like: 'image/' }, + }) + }) - assert.deepEqual(filterDocsByMimeType(docs, []), []) + test('combines exacts and wildcards with `or`', () => { + assert.deepEqual(buildMimeTypeWhere(['image/*', 'application/pdf']), { + or: [{ mimeType: { in: ['application/pdf'] } }, { mimeType: { like: 'image/' } }], + }) }) })