@jhb.software/payload-alt-text-plugin 0.9.1 → 0.11.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 +275 -20
- package/dist/endpoints/bulkGenerateAltTexts.js +21 -8
- package/dist/endpoints/bulkGenerateAltTexts.js.map +1 -1
- package/dist/endpoints/generateAltText.js +16 -5
- package/dist/endpoints/generateAltText.js.map +1 -1
- package/dist/hooks/revalidateAltTextHealth.js +31 -17
- package/dist/hooks/revalidateAltTextHealth.js.map +1 -1
- package/dist/index.d.ts +8 -1
- package/dist/index.js +3 -0
- package/dist/index.js.map +1 -1
- package/dist/plugin.js +43 -7
- package/dist/plugin.js.map +1 -1
- package/dist/resolvers/anthropic.d.ts +65 -0
- package/dist/resolvers/anthropic.js +141 -0
- package/dist/resolvers/anthropic.js.map +1 -0
- package/dist/resolvers/createVisionResolver.d.ts +148 -0
- package/dist/resolvers/createVisionResolver.js +300 -0
- package/dist/resolvers/createVisionResolver.js.map +1 -0
- package/dist/resolvers/mistral.d.ts +53 -0
- package/dist/resolvers/mistral.js +114 -0
- package/dist/resolvers/mistral.js.map +1 -0
- package/dist/resolvers/openAI.d.ts +32 -3
- package/dist/resolvers/openAI.js +63 -144
- package/dist/resolvers/openAI.js.map +1 -1
- package/dist/resolvers/types.d.ts +24 -1
- package/dist/resolvers/types.js.map +1 -1
- package/dist/translations/index.js.map +1 -1
- package/dist/types/AltTextPluginConfig.d.ts +86 -18
- package/dist/types/AltTextPluginConfig.js.map +1 -1
- package/dist/utilities/altTextHealth.d.ts +3 -1
- package/dist/utilities/altTextHealth.js +74 -8
- package/dist/utilities/altTextHealth.js.map +1 -1
- package/dist/utilities/mimeTypes.d.ts +54 -1
- package/dist/utilities/mimeTypes.js +41 -2
- package/dist/utilities/mimeTypes.js.map +1 -1
- package/dist/utilities/stableStringify.d.ts +9 -0
- package/dist/utilities/stableStringify.js +19 -0
- package/dist/utilities/stableStringify.js.map +1 -0
- package/package.json +14 -15
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import type { PayloadRequest } from 'payload';
|
|
2
|
+
import type { AltTextHealthCacheFactory } from './altTextHealthCache.js';
|
|
2
3
|
export declare const ALT_TEXT_HEALTH_PLUGIN_SLUG = "alt-text";
|
|
3
4
|
export declare const ALT_TEXT_HEALTH_CACHE_TTL = 3600;
|
|
4
5
|
export declare const ALT_TEXT_HEALTH_GLOBAL_TAG = "alt-text-health";
|
|
5
|
-
export type AltTextHealthErrorCode = 'ALT_TEXT_COLLECTION_READ_FAILED' | 'ALT_TEXT_PLUGIN_CONFIG_MISSING';
|
|
6
|
+
export type AltTextHealthErrorCode = 'ALT_TEXT_BASE_FILTER_FAILED' | 'ALT_TEXT_COLLECTION_READ_FAILED' | 'ALT_TEXT_PLUGIN_CONFIG_MISSING';
|
|
6
7
|
export type AltTextHealthError = {
|
|
7
8
|
code: AltTextHealthErrorCode;
|
|
8
9
|
collection?: string;
|
|
@@ -33,6 +34,7 @@ export type AltTextHealthWidgetData = {
|
|
|
33
34
|
totalDocs: number;
|
|
34
35
|
};
|
|
35
36
|
export declare const getAltTextHealthCollectionTag: (collectionSlug: string) => string;
|
|
37
|
+
export declare function getAltTextHealthScan(req: PayloadRequest, cacheFactory?: AltTextHealthCacheFactory<AltTextHealthScan>): Promise<AltTextHealthScan>;
|
|
36
38
|
/**
|
|
37
39
|
* Filters a shared, elevated-access health scan down to the collections the
|
|
38
40
|
* requesting user may read. The scan is computed once with `overrideAccess: true`
|
|
@@ -2,6 +2,7 @@ import { unstable_cache } from 'next/cache.js';
|
|
|
2
2
|
import { createCachedAltTextHealthScan } from './altTextHealthCache.js';
|
|
3
3
|
import { localesFromConfig } from './localesFromConfig.js';
|
|
4
4
|
import { buildMimeTypeWhere } from './mimeTypes.js';
|
|
5
|
+
import { stableStringify } from './stableStringify.js';
|
|
5
6
|
import { summarizeCollection } from './summarizeCollection.js';
|
|
6
7
|
export const ALT_TEXT_HEALTH_PLUGIN_SLUG = 'alt-text';
|
|
7
8
|
export const ALT_TEXT_HEALTH_CACHE_TTL = 3600;
|
|
@@ -22,11 +23,20 @@ const createCollectionReadError = (collection, message)=>({
|
|
|
22
23
|
operation: 'find'
|
|
23
24
|
});
|
|
24
25
|
const PAGE_SIZE = 500;
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
26
|
+
const isEmptyWhere = (where)=>!where || Object.keys(where).length === 0;
|
|
27
|
+
async function fetchAllDocs(payload, collection, isLocalized, mimeTypes, baseFilter) {
|
|
28
|
+
const mimeTypeWhere = buildMimeTypeWhere(mimeTypes);
|
|
29
|
+
if (!mimeTypeWhere) {
|
|
28
30
|
return [];
|
|
29
31
|
}
|
|
32
|
+
// The scan runs with `overrideAccess: true`, so a narrowing constraint has to be
|
|
33
|
+
// part of the query itself — this is what keeps the aggregate within one tenant.
|
|
34
|
+
const where = isEmptyWhere(baseFilter) ? mimeTypeWhere : {
|
|
35
|
+
and: [
|
|
36
|
+
mimeTypeWhere,
|
|
37
|
+
baseFilter
|
|
38
|
+
]
|
|
39
|
+
};
|
|
30
40
|
const docs = [];
|
|
31
41
|
let page = 1;
|
|
32
42
|
let hasMore = true;
|
|
@@ -55,10 +65,10 @@ async function fetchAllDocs(payload, collection, isLocalized, mimeTypes) {
|
|
|
55
65
|
}
|
|
56
66
|
return docs;
|
|
57
67
|
}
|
|
58
|
-
async function computeAltTextHealthScan({ collections, isLocalized, localeCodes, payload }) {
|
|
68
|
+
async function computeAltTextHealthScan({ baseFilters, collections, isLocalized, localeCodes, payload }) {
|
|
59
69
|
const collectionSummaries = await Promise.all(collections.map(async ({ slug, mimeTypes })=>{
|
|
60
70
|
try {
|
|
61
|
-
const docs = await fetchAllDocs(payload, slug, isLocalized, mimeTypes);
|
|
71
|
+
const docs = await fetchAllDocs(payload, slug, isLocalized, mimeTypes, baseFilters[slug]);
|
|
62
72
|
return summarizeCollection({
|
|
63
73
|
collection: slug,
|
|
64
74
|
docs,
|
|
@@ -96,7 +106,48 @@ async function computeAltTextHealthScan({ collections, isLocalized, localeCodes,
|
|
|
96
106
|
};
|
|
97
107
|
}
|
|
98
108
|
export const getAltTextHealthCollectionTag = (collectionSlug)=>`${ALT_TEXT_HEALTH_GLOBAL_TAG}:${collectionSlug}`;
|
|
99
|
-
|
|
109
|
+
/**
|
|
110
|
+
* Resolves the configured base filter for every scanned collection.
|
|
111
|
+
*
|
|
112
|
+
* A throwing filter (a tenant cookie pointing at a deleted tenant, say) must not
|
|
113
|
+
* take the dashboard down, and must never fall back to an unfiltered scan — so it
|
|
114
|
+
* ends the scan with an error the widget and the endpoint report. The error carries
|
|
115
|
+
* the slug in its message rather than in `collection`, so it survives the read-access
|
|
116
|
+
* filter that drops errors for collections the caller cannot read.
|
|
117
|
+
*/ async function resolveBaseFilters(req, collections, baseFilter) {
|
|
118
|
+
const baseFilters = {};
|
|
119
|
+
if (!baseFilter) {
|
|
120
|
+
return {
|
|
121
|
+
baseFilters
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
for (const { slug } of collections){
|
|
125
|
+
try {
|
|
126
|
+
baseFilters[slug] = await baseFilter({
|
|
127
|
+
collection: slug,
|
|
128
|
+
req
|
|
129
|
+
});
|
|
130
|
+
} catch (error) {
|
|
131
|
+
const message = error instanceof Error ? error.message : 'Unknown error';
|
|
132
|
+
req.payload.logger.error({
|
|
133
|
+
collection: slug,
|
|
134
|
+
err: error,
|
|
135
|
+
msg: 'Alt text health check failed while resolving the base filter.',
|
|
136
|
+
plugin: ALT_TEXT_HEALTH_PLUGIN_SLUG
|
|
137
|
+
});
|
|
138
|
+
return {
|
|
139
|
+
error: {
|
|
140
|
+
code: 'ALT_TEXT_BASE_FILTER_FAILED',
|
|
141
|
+
message: `Failed to resolve the alt text health base filter for the "${slug}" collection: ${message}`
|
|
142
|
+
}
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
return {
|
|
147
|
+
baseFilters
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
export async function getAltTextHealthScan(req, cacheFactory = unstable_cache) {
|
|
100
151
|
const { payload } = req;
|
|
101
152
|
const pluginConfig = payload.config.custom?.altTextPluginConfig;
|
|
102
153
|
const localeCodes = localesFromConfig(payload.config) ?? (pluginConfig?.locale ? [
|
|
@@ -114,6 +165,15 @@ async function getAltTextHealthScan(req) {
|
|
|
114
165
|
});
|
|
115
166
|
}
|
|
116
167
|
const collections = pluginConfig.collections;
|
|
168
|
+
const resolved = await resolveBaseFilters(req, collections, pluginConfig.healthCheckBaseFilter);
|
|
169
|
+
if ('error' in resolved) {
|
|
170
|
+
return createUnknownScan({
|
|
171
|
+
error: resolved.error,
|
|
172
|
+
isLocalized,
|
|
173
|
+
localeCodes
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
const { baseFilters } = resolved;
|
|
117
177
|
const cacheKeyParts = [
|
|
118
178
|
ALT_TEXT_HEALTH_GLOBAL_TAG,
|
|
119
179
|
[
|
|
@@ -121,16 +181,22 @@ async function getAltTextHealthScan(req) {
|
|
|
121
181
|
].map(({ slug, mimeTypes })=>`${slug}:${[
|
|
122
182
|
...mimeTypes
|
|
123
183
|
].sort().join('|')}`).sort().join(','),
|
|
124
|
-
localeCodes.join(',')
|
|
184
|
+
localeCodes.join(','),
|
|
185
|
+
// The scan is shared across requests, so a scoped scan needs a scoped cache
|
|
186
|
+
// entry. Deriving the key from the resolved filters — rather than taking one
|
|
187
|
+
// from the caller — makes it impossible to narrow the scan without also
|
|
188
|
+
// narrowing its cache, which would serve one tenant's counts to another.
|
|
189
|
+
`filter:${stableStringify(baseFilters)}`
|
|
125
190
|
];
|
|
126
191
|
const tags = [
|
|
127
192
|
ALT_TEXT_HEALTH_GLOBAL_TAG,
|
|
128
193
|
...new Set(collections.map(({ slug })=>getAltTextHealthCollectionTag(slug)))
|
|
129
194
|
];
|
|
130
195
|
const getCachedHealthScan = createCachedAltTextHealthScan({
|
|
131
|
-
cacheFactory
|
|
196
|
+
cacheFactory,
|
|
132
197
|
cacheKeyParts,
|
|
133
198
|
compute: async ()=>computeAltTextHealthScan({
|
|
199
|
+
baseFilters,
|
|
134
200
|
collections,
|
|
135
201
|
isLocalized,
|
|
136
202
|
localeCodes,
|
|
@@ -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 {\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\n/**\n * Whether `req.user` is allowed to read the given collection at the collection\n * level. The collection's `read` access is evaluated with the request; `false`\n * denies, while `true` or a scoped `Where` constraint grants visibility of the\n * collection's health aggregate. A thrown access function (e.g. `Forbidden`)\n * counts as denied so a restricted collection never leaks.\n */\nasync function userCanReadCollection(req: PayloadRequest, slug: string): Promise<boolean> {\n const readAccess = req.payload.collections?.[slug]?.config.access?.read\n\n if (typeof readAccess !== 'function') {\n return true\n }\n\n try {\n return (await readAccess({ req })) !== false\n } catch {\n return false\n }\n}\n\n/**\n * Filters a shared, elevated-access health scan down to the collections the\n * requesting user may read. The scan is computed once with `overrideAccess: true`\n * so it stays complete and cacheable; access is applied per request at\n * collection granularity, matching the aggregate's altitude.\n */\nexport async function filterScanByReadAccess(\n req: PayloadRequest,\n scan: AltTextHealthScan,\n): Promise<AltTextHealthScan> {\n const visibility = await Promise.all(\n scan.collections.map((collection) => userCanReadCollection(req, collection.collection)),\n )\n\n const collections = scan.collections.filter((_, index) => visibility[index])\n const allowedSlugs = new Set(collections.map((collection) => collection.collection))\n const errors = scan.errors.filter(\n (error) => !error.collection || allowedSlugs.has(error.collection),\n )\n\n return { ...scan, collections, errors }\n}\n\n/**\n * Whether the requesting user may view the health report at all, per the\n * configured `healthCheck` access gate. The dashboard widget uses this to hide\n * itself, mirroring the gate enforced on the health endpoint.\n */\nexport async function canViewHealthReport(req: PayloadRequest): Promise<boolean> {\n const pluginConfig = req.payload.config.custom?.altTextPluginConfig as\n | AltTextPluginConfig\n | undefined\n\n if (!pluginConfig) {\n return false\n }\n\n return pluginConfig.healthCheckAccess({ req })\n}\n\nexport function toWidgetData(scan: AltTextHealthScan): AltTextHealthWidgetData {\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\nexport async function getAltTextHealth(req: PayloadRequest): Promise<AltTextHealthScan> {\n return filterScanByReadAccess(req, await getAltTextHealthScan(req))\n}\n\nexport async function getAltTextHealthWidgetData(\n req: PayloadRequest,\n): Promise<AltTextHealthWidgetData> {\n return toWidgetData(await filterScanByReadAccess(req, await getAltTextHealthScan(req)))\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","userCanReadCollection","readAccess","access","read","filterScanByReadAccess","scan","visibility","_","index","allowedSlugs","has","canViewHealthReport","healthCheckAccess","toWidgetData","localeCount","length","reduce","total","c","getAltTextHealth","getAltTextHealthWidgetData"],"mappings":"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"}
|
|
1
|
+
{"version":3,"sources":["../../src/utilities/altTextHealth.ts"],"sourcesContent":["import type { Payload, PayloadRequest, Where } from 'payload'\n\nimport { unstable_cache } from 'next/cache.js'\n\nimport type {\n AltTextPluginConfig,\n NormalizedAltTextCollectionConfig,\n} from '../types/AltTextPluginConfig.js'\nimport type { AltTextHealthCacheFactory } from './altTextHealthCache.js'\n\nimport { createCachedAltTextHealthScan } from './altTextHealthCache.js'\nimport { localesFromConfig } from './localesFromConfig.js'\nimport { buildMimeTypeWhere } from './mimeTypes.js'\nimport { stableStringify } from './stableStringify.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_BASE_FILTER_FAILED'\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 /** The resolved base filter per collection slug, if one is configured. */\n baseFilters: Record<string, undefined | Where>\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\nconst isEmptyWhere = (where: undefined | Where): boolean =>\n !where || Object.keys(where).length === 0\n\nasync function fetchAllDocs(\n payload: Payload,\n collection: string,\n isLocalized: boolean,\n mimeTypes: readonly string[],\n baseFilter: undefined | Where,\n): Promise<{ alt: unknown; id: number | string }[]> {\n const mimeTypeWhere = buildMimeTypeWhere(mimeTypes)\n if (!mimeTypeWhere) {\n return []\n }\n\n // The scan runs with `overrideAccess: true`, so a narrowing constraint has to be\n // part of the query itself — this is what keeps the aggregate within one tenant.\n const where = isEmptyWhere(baseFilter) ? mimeTypeWhere : { and: [mimeTypeWhere, baseFilter!] }\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 baseFilters,\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, baseFilters[slug])\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\n/**\n * Resolves the configured base filter for every scanned collection.\n *\n * A throwing filter (a tenant cookie pointing at a deleted tenant, say) must not\n * take the dashboard down, and must never fall back to an unfiltered scan — so it\n * ends the scan with an error the widget and the endpoint report. The error carries\n * the slug in its message rather than in `collection`, so it survives the read-access\n * filter that drops errors for collections the caller cannot read.\n */\nasync function resolveBaseFilters(\n req: PayloadRequest,\n collections: NormalizedAltTextCollectionConfig[],\n baseFilter: AltTextPluginConfig['healthCheckBaseFilter'],\n): Promise<{ baseFilters: Record<string, undefined | Where> } | { error: AltTextHealthError }> {\n const baseFilters: Record<string, undefined | Where> = {}\n\n if (!baseFilter) {\n return { baseFilters }\n }\n\n for (const { slug } of collections) {\n try {\n baseFilters[slug] = await baseFilter({ collection: slug, req })\n } catch (error) {\n const message = error instanceof Error ? error.message : 'Unknown error'\n\n req.payload.logger.error({\n collection: slug,\n err: error,\n msg: 'Alt text health check failed while resolving the base filter.',\n plugin: ALT_TEXT_HEALTH_PLUGIN_SLUG,\n })\n\n return {\n error: {\n code: 'ALT_TEXT_BASE_FILTER_FAILED',\n message: `Failed to resolve the alt text health base filter for the \"${slug}\" collection: ${message}`,\n },\n }\n }\n }\n\n return { baseFilters }\n}\n\nexport async function getAltTextHealthScan(\n req: PayloadRequest,\n cacheFactory: AltTextHealthCacheFactory<AltTextHealthScan> = unstable_cache,\n): 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 resolved = await resolveBaseFilters(req, collections, pluginConfig.healthCheckBaseFilter)\n\n if ('error' in resolved) {\n return createUnknownScan({ error: resolved.error, isLocalized, localeCodes })\n }\n\n const { baseFilters } = resolved\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 // The scan is shared across requests, so a scoped scan needs a scoped cache\n // entry. Deriving the key from the resolved filters — rather than taking one\n // from the caller — makes it impossible to narrow the scan without also\n // narrowing its cache, which would serve one tenant's counts to another.\n `filter:${stableStringify(baseFilters)}`,\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,\n cacheKeyParts,\n compute: async () =>\n computeAltTextHealthScan({\n baseFilters,\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\n/**\n * Whether `req.user` is allowed to read the given collection at the collection\n * level. The collection's `read` access is evaluated with the request; `false`\n * denies, while `true` or a scoped `Where` constraint grants visibility of the\n * collection's health aggregate. A thrown access function (e.g. `Forbidden`)\n * counts as denied so a restricted collection never leaks.\n */\nasync function userCanReadCollection(req: PayloadRequest, slug: string): Promise<boolean> {\n const readAccess = req.payload.collections?.[slug]?.config.access?.read\n\n if (typeof readAccess !== 'function') {\n return true\n }\n\n try {\n return (await readAccess({ req })) !== false\n } catch {\n return false\n }\n}\n\n/**\n * Filters a shared, elevated-access health scan down to the collections the\n * requesting user may read. The scan is computed once with `overrideAccess: true`\n * so it stays complete and cacheable; access is applied per request at\n * collection granularity, matching the aggregate's altitude.\n */\nexport async function filterScanByReadAccess(\n req: PayloadRequest,\n scan: AltTextHealthScan,\n): Promise<AltTextHealthScan> {\n const visibility = await Promise.all(\n scan.collections.map((collection) => userCanReadCollection(req, collection.collection)),\n )\n\n const collections = scan.collections.filter((_, index) => visibility[index])\n const allowedSlugs = new Set(collections.map((collection) => collection.collection))\n const errors = scan.errors.filter(\n (error) => !error.collection || allowedSlugs.has(error.collection),\n )\n\n return { ...scan, collections, errors }\n}\n\n/**\n * Whether the requesting user may view the health report at all, per the\n * configured `healthCheck` access gate. The dashboard widget uses this to hide\n * itself, mirroring the gate enforced on the health endpoint.\n */\nexport async function canViewHealthReport(req: PayloadRequest): Promise<boolean> {\n const pluginConfig = req.payload.config.custom?.altTextPluginConfig as\n AltTextPluginConfig | undefined\n\n if (!pluginConfig) {\n return false\n }\n\n return pluginConfig.healthCheckAccess({ req })\n}\n\nexport function toWidgetData(scan: AltTextHealthScan): AltTextHealthWidgetData {\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\nexport async function getAltTextHealth(req: PayloadRequest): Promise<AltTextHealthScan> {\n return filterScanByReadAccess(req, await getAltTextHealthScan(req))\n}\n\nexport async function getAltTextHealthWidgetData(\n req: PayloadRequest,\n): Promise<AltTextHealthWidgetData> {\n return toWidgetData(await filterScanByReadAccess(req, await getAltTextHealthScan(req)))\n}\n"],"names":["unstable_cache","createCachedAltTextHealthScan","localesFromConfig","buildMimeTypeWhere","stableStringify","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","isEmptyWhere","where","Object","keys","length","fetchAllDocs","payload","mimeTypes","baseFilter","mimeTypeWhere","and","docs","page","hasMore","result","find","depth","fallbackLocale","undefined","limit","locale","overrideAccess","select","alt","doc","push","id","hasNextPage","computeAltTextHealthScan","baseFilters","collectionSummaries","Promise","all","map","slug","Error","collectionError","logger","err","msg","plugin","completeDocs","invalidDocIds","missingDocs","partialDocs","totalDocs","filter","summary","getAltTextHealthCollectionTag","collectionSlug","resolveBaseFilters","req","getAltTextHealthScan","cacheFactory","pluginConfig","config","custom","altTextPluginConfig","Boolean","localization","resolved","healthCheckBaseFilter","cacheKeyParts","sort","join","tags","Set","getCachedHealthScan","compute","revalidate","userCanReadCollection","readAccess","access","read","filterScanByReadAccess","scan","visibility","_","index","allowedSlugs","has","canViewHealthReport","healthCheckAccess","toWidgetData","localeCount","reduce","total","c","getAltTextHealth","getAltTextHealthWidgetData"],"mappings":"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"}
|
|
@@ -1,6 +1,30 @@
|
|
|
1
1
|
import type { CollectionSlug, TextareaFieldValidation, Where } from 'payload';
|
|
2
2
|
export declare const DEFAULT_TRACKED_MIME_TYPES: readonly string[];
|
|
3
3
|
export type AltTextCollectionConfig = {
|
|
4
|
+
/**
|
|
5
|
+
* The MIME type `getImageThumbnail` delivers for documents in this collection.
|
|
6
|
+
*
|
|
7
|
+
* The resolver only receives the bytes at that URL, never the stored file. When
|
|
8
|
+
* the URL transcodes (e.g. a Cloudinary `f_webp` transformation), the stored
|
|
9
|
+
* `mimeType` says nothing about what arrives, so AVIF and HEIC uploads are
|
|
10
|
+
* rejected on their source format even though the delivered bytes are fine.
|
|
11
|
+
*
|
|
12
|
+
* Declaring the delivered format replaces that per-document source check, and
|
|
13
|
+
* passes the type to the resolver as `imageThumbnailMimeType`. Which source
|
|
14
|
+
* formats get alt text at all is still governed by `mimeTypes`.
|
|
15
|
+
*
|
|
16
|
+
* Only declare a format the transform *always* produces. An `f_auto`-style
|
|
17
|
+
* transformation negotiates via the client's `Accept` header and may return the
|
|
18
|
+
* source format, so leave this unset there.
|
|
19
|
+
*
|
|
20
|
+
* Validated at config load — for syntax, and against the resolver's
|
|
21
|
+
* `supportedMimeTypes` when it declares any.
|
|
22
|
+
*
|
|
23
|
+
* Falls back to the plugin-level option of the same name; `null` opts out of it.
|
|
24
|
+
*
|
|
25
|
+
* @example 'image/webp'
|
|
26
|
+
*/
|
|
27
|
+
imageThumbnailMimeType?: null | string;
|
|
4
28
|
/**
|
|
5
29
|
* MIME types for which alt text is tracked, validated, and generated in this collection.
|
|
6
30
|
*
|
|
@@ -40,12 +64,41 @@ export type AltTextCollectionConfig = {
|
|
|
40
64
|
validate?: TextareaFieldValidation;
|
|
41
65
|
};
|
|
42
66
|
export type NormalizedAltTextCollectionConfig = {
|
|
67
|
+
/**
|
|
68
|
+
* The delivered thumbnail MIME type in effect for this collection, after the
|
|
69
|
+
* plugin-level default has been applied. Absent when the collection opted out
|
|
70
|
+
* (`null`) or neither level declared one.
|
|
71
|
+
*/
|
|
72
|
+
imageThumbnailMimeType?: string;
|
|
43
73
|
mimeTypes: string[];
|
|
44
74
|
slug: CollectionSlug;
|
|
45
75
|
validate?: TextareaFieldValidation;
|
|
46
76
|
};
|
|
47
77
|
export type IncomingCollectionsConfig = (AltTextCollectionConfig | CollectionSlug)[];
|
|
48
|
-
export declare function normalizeCollectionsConfig(incoming: IncomingCollectionsConfig
|
|
78
|
+
export declare function normalizeCollectionsConfig(incoming: IncomingCollectionsConfig, defaults?: {
|
|
79
|
+
imageThumbnailMimeType?: string;
|
|
80
|
+
}): NormalizedAltTextCollectionConfig[];
|
|
81
|
+
/**
|
|
82
|
+
* Whether a declared `imageThumbnailMimeType` is syntactically a MIME type.
|
|
83
|
+
*
|
|
84
|
+
* Checked independently of the resolver's `supportedMimeTypes`: declaring a type
|
|
85
|
+
* switches off the per-document source check, so a typo that booted cleanly would
|
|
86
|
+
* remove the guard while declaring nothing.
|
|
87
|
+
*/
|
|
88
|
+
export declare function isValidMimeType(value: string): boolean;
|
|
89
|
+
/**
|
|
90
|
+
* Whether a document's stored `mimeType` blocks alt text generation. Returns the
|
|
91
|
+
* error message when it does, `null` when generation may proceed.
|
|
92
|
+
*
|
|
93
|
+
* The stored type is only a proxy — the resolver receives the bytes at the
|
|
94
|
+
* thumbnail URL, not the stored file. A collection that declares what its URL
|
|
95
|
+
* delivers settles support at config load, making the source format irrelevant.
|
|
96
|
+
*/
|
|
97
|
+
export declare function getUnsupportedSourceMimeTypeError({ declaredThumbnailMimeType, mimeType, supportedMimeTypes, }: {
|
|
98
|
+
declaredThumbnailMimeType?: string;
|
|
99
|
+
mimeType?: string;
|
|
100
|
+
supportedMimeTypes?: readonly string[];
|
|
101
|
+
}): null | string;
|
|
49
102
|
export declare function matchesMimeType(mimeType: string, patterns: readonly string[]): boolean;
|
|
50
103
|
/**
|
|
51
104
|
* Builds a Payload `where` clause that matches documents whose `mimeType`
|
|
@@ -1,14 +1,18 @@
|
|
|
1
1
|
export const DEFAULT_TRACKED_MIME_TYPES = [
|
|
2
2
|
'image/*'
|
|
3
3
|
];
|
|
4
|
-
export function normalizeCollectionsConfig(incoming) {
|
|
4
|
+
export function normalizeCollectionsConfig(incoming, defaults) {
|
|
5
|
+
const defaultThumbnailMimeType = defaults?.imageThumbnailMimeType;
|
|
5
6
|
return incoming.map((entry)=>{
|
|
6
7
|
if (typeof entry === 'string') {
|
|
7
8
|
return {
|
|
8
9
|
slug: entry,
|
|
9
10
|
mimeTypes: [
|
|
10
11
|
...DEFAULT_TRACKED_MIME_TYPES
|
|
11
|
-
]
|
|
12
|
+
],
|
|
13
|
+
...typeof defaultThumbnailMimeType === 'string' && {
|
|
14
|
+
imageThumbnailMimeType: defaultThumbnailMimeType
|
|
15
|
+
}
|
|
12
16
|
};
|
|
13
17
|
}
|
|
14
18
|
const normalized = {
|
|
@@ -19,12 +23,47 @@ export function normalizeCollectionsConfig(incoming) {
|
|
|
19
23
|
...DEFAULT_TRACKED_MIME_TYPES
|
|
20
24
|
]
|
|
21
25
|
};
|
|
26
|
+
// `undefined` inherits the plugin-level default, `null` explicitly opts out of
|
|
27
|
+
// it. Resolving with `??` would silently collapse those two into "inherit".
|
|
28
|
+
const thumbnailMimeType = entry.imageThumbnailMimeType !== undefined ? entry.imageThumbnailMimeType : defaultThumbnailMimeType;
|
|
29
|
+
// Only `null` (opt-out) and an absent declaration drop the key. An empty
|
|
30
|
+
// string is kept so config-load validation rejects it, rather than it
|
|
31
|
+
// silently degrading into "nothing declared".
|
|
32
|
+
if (typeof thumbnailMimeType === 'string') {
|
|
33
|
+
normalized.imageThumbnailMimeType = thumbnailMimeType;
|
|
34
|
+
}
|
|
22
35
|
if (entry.validate) {
|
|
23
36
|
normalized.validate = entry.validate;
|
|
24
37
|
}
|
|
25
38
|
return normalized;
|
|
26
39
|
});
|
|
27
40
|
}
|
|
41
|
+
/** `type/subtype`, per RFC 6838's restricted-name grammar, lowercase. */ const MIME_TYPE_PATTERN = /^[a-z]+\/[a-z0-9][a-z0-9!#$&^_.+-]*$/;
|
|
42
|
+
/**
|
|
43
|
+
* Whether a declared `imageThumbnailMimeType` is syntactically a MIME type.
|
|
44
|
+
*
|
|
45
|
+
* Checked independently of the resolver's `supportedMimeTypes`: declaring a type
|
|
46
|
+
* switches off the per-document source check, so a typo that booted cleanly would
|
|
47
|
+
* remove the guard while declaring nothing.
|
|
48
|
+
*/ export function isValidMimeType(value) {
|
|
49
|
+
return MIME_TYPE_PATTERN.test(value);
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Whether a document's stored `mimeType` blocks alt text generation. Returns the
|
|
53
|
+
* error message when it does, `null` when generation may proceed.
|
|
54
|
+
*
|
|
55
|
+
* The stored type is only a proxy — the resolver receives the bytes at the
|
|
56
|
+
* thumbnail URL, not the stored file. A collection that declares what its URL
|
|
57
|
+
* delivers settles support at config load, making the source format irrelevant.
|
|
58
|
+
*/ export function getUnsupportedSourceMimeTypeError({ declaredThumbnailMimeType, mimeType, supportedMimeTypes }) {
|
|
59
|
+
if (declaredThumbnailMimeType || !mimeType || !supportedMimeTypes) {
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
if (supportedMimeTypes.includes(mimeType)) {
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
return `Alt text generation is not supported for files of type "${mimeType}". Supported types: ${supportedMimeTypes.join(', ')}.`;
|
|
66
|
+
}
|
|
28
67
|
// Payload stores upload mimeType values as the lowercase MIME string (e.g. `image/png`).
|
|
29
68
|
// Pattern comparisons here are case-sensitive; callers should pass lowercase patterns.
|
|
30
69
|
export function matchesMimeType(mimeType, patterns) {
|
|
@@ -1 +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"}
|
|
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 * The MIME type `getImageThumbnail` delivers for documents in this collection.\n *\n * The resolver only receives the bytes at that URL, never the stored file. When\n * the URL transcodes (e.g. a Cloudinary `f_webp` transformation), the stored\n * `mimeType` says nothing about what arrives, so AVIF and HEIC uploads are\n * rejected on their source format even though the delivered bytes are fine.\n *\n * Declaring the delivered format replaces that per-document source check, and\n * passes the type to the resolver as `imageThumbnailMimeType`. Which source\n * formats get alt text at all is still governed by `mimeTypes`.\n *\n * Only declare a format the transform *always* produces. An `f_auto`-style\n * transformation negotiates via the client's `Accept` header and may return the\n * source format, so leave this unset there.\n *\n * Validated at config load — for syntax, and against the resolver's\n * `supportedMimeTypes` when it declares any.\n *\n * Falls back to the plugin-level option of the same name; `null` opts out of it.\n *\n * @example 'image/webp'\n */\n imageThumbnailMimeType?: null | string\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 /**\n * The delivered thumbnail MIME type in effect for this collection, after the\n * plugin-level default has been applied. Absent when the collection opted out\n * (`null`) or neither level declared one.\n */\n imageThumbnailMimeType?: string\n mimeTypes: string[]\n slug: CollectionSlug\n validate?: TextareaFieldValidation\n}\n\nexport type IncomingCollectionsConfig = (AltTextCollectionConfig | CollectionSlug)[]\n\nexport function normalizeCollectionsConfig(\n incoming: IncomingCollectionsConfig,\n defaults?: { imageThumbnailMimeType?: string },\n): NormalizedAltTextCollectionConfig[] {\n const defaultThumbnailMimeType = defaults?.imageThumbnailMimeType\n\n return incoming.map((entry) => {\n if (typeof entry === 'string') {\n return {\n slug: entry,\n mimeTypes: [...DEFAULT_TRACKED_MIME_TYPES],\n ...(typeof defaultThumbnailMimeType === 'string' && {\n imageThumbnailMimeType: defaultThumbnailMimeType,\n }),\n }\n }\n\n const normalized: NormalizedAltTextCollectionConfig = {\n slug: entry.slug,\n mimeTypes: entry.mimeTypes ? [...entry.mimeTypes] : [...DEFAULT_TRACKED_MIME_TYPES],\n }\n\n // `undefined` inherits the plugin-level default, `null` explicitly opts out of\n // it. Resolving with `??` would silently collapse those two into \"inherit\".\n const thumbnailMimeType =\n entry.imageThumbnailMimeType !== undefined\n ? entry.imageThumbnailMimeType\n : defaultThumbnailMimeType\n // Only `null` (opt-out) and an absent declaration drop the key. An empty\n // string is kept so config-load validation rejects it, rather than it\n // silently degrading into \"nothing declared\".\n if (typeof thumbnailMimeType === 'string') {\n normalized.imageThumbnailMimeType = thumbnailMimeType\n }\n\n if (entry.validate) {\n normalized.validate = entry.validate\n }\n return normalized\n })\n}\n\n/** `type/subtype`, per RFC 6838's restricted-name grammar, lowercase. */\nconst MIME_TYPE_PATTERN = /^[a-z]+\\/[a-z0-9][a-z0-9!#$&^_.+-]*$/\n\n/**\n * Whether a declared `imageThumbnailMimeType` is syntactically a MIME type.\n *\n * Checked independently of the resolver's `supportedMimeTypes`: declaring a type\n * switches off the per-document source check, so a typo that booted cleanly would\n * remove the guard while declaring nothing.\n */\nexport function isValidMimeType(value: string): boolean {\n return MIME_TYPE_PATTERN.test(value)\n}\n\n/**\n * Whether a document's stored `mimeType` blocks alt text generation. Returns the\n * error message when it does, `null` when generation may proceed.\n *\n * The stored type is only a proxy — the resolver receives the bytes at the\n * thumbnail URL, not the stored file. A collection that declares what its URL\n * delivers settles support at config load, making the source format irrelevant.\n */\nexport function getUnsupportedSourceMimeTypeError({\n declaredThumbnailMimeType,\n mimeType,\n supportedMimeTypes,\n}: {\n declaredThumbnailMimeType?: string\n mimeType?: string\n supportedMimeTypes?: readonly string[]\n}): null | string {\n if (declaredThumbnailMimeType || !mimeType || !supportedMimeTypes) {\n return null\n }\n\n if (supportedMimeTypes.includes(mimeType)) {\n return null\n }\n\n return `Alt text generation is not supported for files of type \"${mimeType}\". Supported types: ${supportedMimeTypes.join(', ')}.`\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","defaults","defaultThumbnailMimeType","imageThumbnailMimeType","map","entry","slug","mimeTypes","normalized","thumbnailMimeType","undefined","validate","MIME_TYPE_PATTERN","isValidMimeType","value","test","getUnsupportedSourceMimeTypeError","declaredThumbnailMimeType","mimeType","supportedMimeTypes","includes","join","matchesMimeType","patterns","some","pattern","endsWith","prefix","slice","startsWith","buildMimeTypeWhere","length","exacts","wildcardPrefixes","push","clauses","in","like","or","validateAltText","args","trackedMimeTypes","data","operation","req","isInitialUpload","createdAt","updatedAt","trim","t"],"mappings":"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"}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Serializes a value with object keys sorted, so two structurally equal values
|
|
3
|
+
* produce the same string regardless of the order their keys were written in.
|
|
4
|
+
*
|
|
5
|
+
* The health scan uses it to derive a cache key from the resolved base filter:
|
|
6
|
+
* narrowing the scan then always narrows its cache entry, which no amount of
|
|
7
|
+
* documentation can guarantee for a caller-supplied key.
|
|
8
|
+
*/
|
|
9
|
+
export declare function stableStringify(value: unknown): string;
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Serializes a value with object keys sorted, so two structurally equal values
|
|
3
|
+
* produce the same string regardless of the order their keys were written in.
|
|
4
|
+
*
|
|
5
|
+
* The health scan uses it to derive a cache key from the resolved base filter:
|
|
6
|
+
* narrowing the scan then always narrows its cache entry, which no amount of
|
|
7
|
+
* documentation can guarantee for a caller-supplied key.
|
|
8
|
+
*/ export function stableStringify(value) {
|
|
9
|
+
if (Array.isArray(value)) {
|
|
10
|
+
return `[${value.map(stableStringify).join(',')}]`;
|
|
11
|
+
}
|
|
12
|
+
if (value && typeof value === 'object') {
|
|
13
|
+
const entries = Object.entries(value).filter(([, entryValue])=>entryValue !== undefined).sort(([a], [b])=>a < b ? -1 : a > b ? 1 : 0).map(([key, entryValue])=>`${JSON.stringify(key)}:${stableStringify(entryValue)}`);
|
|
14
|
+
return `{${entries.join(',')}}`;
|
|
15
|
+
}
|
|
16
|
+
return JSON.stringify(value) ?? 'null';
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
//# sourceMappingURL=stableStringify.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../src/utilities/stableStringify.ts"],"sourcesContent":["/**\n * Serializes a value with object keys sorted, so two structurally equal values\n * produce the same string regardless of the order their keys were written in.\n *\n * The health scan uses it to derive a cache key from the resolved base filter:\n * narrowing the scan then always narrows its cache entry, which no amount of\n * documentation can guarantee for a caller-supplied key.\n */\nexport function stableStringify(value: unknown): string {\n if (Array.isArray(value)) {\n return `[${value.map(stableStringify).join(',')}]`\n }\n\n if (value && typeof value === 'object') {\n const entries = Object.entries(value as Record<string, unknown>)\n .filter(([, entryValue]) => entryValue !== undefined)\n .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))\n .map(([key, entryValue]) => `${JSON.stringify(key)}:${stableStringify(entryValue)}`)\n\n return `{${entries.join(',')}}`\n }\n\n return JSON.stringify(value) ?? 'null'\n}\n"],"names":["stableStringify","value","Array","isArray","map","join","entries","Object","filter","entryValue","undefined","sort","a","b","key","JSON","stringify"],"mappings":"AAAA;;;;;;;CAOC,GACD,OAAO,SAASA,gBAAgBC,KAAc;IAC5C,IAAIC,MAAMC,OAAO,CAACF,QAAQ;QACxB,OAAO,CAAC,CAAC,EAAEA,MAAMG,GAAG,CAACJ,iBAAiBK,IAAI,CAAC,KAAK,CAAC,CAAC;IACpD;IAEA,IAAIJ,SAAS,OAAOA,UAAU,UAAU;QACtC,MAAMK,UAAUC,OAAOD,OAAO,CAACL,OAC5BO,MAAM,CAAC,CAAC,GAAGC,WAAW,GAAKA,eAAeC,WAC1CC,IAAI,CAAC,CAAC,CAACC,EAAE,EAAE,CAACC,EAAE,GAAMD,IAAIC,IAAI,CAAC,IAAID,IAAIC,IAAI,IAAI,GAC7CT,GAAG,CAAC,CAAC,CAACU,KAAKL,WAAW,GAAK,GAAGM,KAAKC,SAAS,CAACF,KAAK,CAAC,EAAEd,gBAAgBS,aAAa;QAErF,OAAO,CAAC,CAAC,EAAEH,QAAQD,IAAI,CAAC,KAAK,CAAC,CAAC;IACjC;IAEA,OAAOU,KAAKC,SAAS,CAACf,UAAU;AAClC"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jhb.software/payload-alt-text-plugin",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.11.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": {
|
|
@@ -20,31 +20,30 @@
|
|
|
20
20
|
"main": "./dist/index.js",
|
|
21
21
|
"types": "./dist/index.d.ts",
|
|
22
22
|
"dependencies": {
|
|
23
|
-
"
|
|
24
|
-
"p-map": "^7.0.5",
|
|
23
|
+
"p-map": "^7.0.6",
|
|
25
24
|
"zod": "^4.4.3"
|
|
26
25
|
},
|
|
27
26
|
"peerDependencies": {
|
|
28
|
-
"@payloadcms/translations": "^3.
|
|
29
|
-
"@payloadcms/ui": "^3.
|
|
30
|
-
"next": "^15.
|
|
31
|
-
"payload": "^3.
|
|
32
|
-
"react": "19.2.
|
|
33
|
-
"react-dom": "19.2.
|
|
27
|
+
"@payloadcms/translations": "^3.88.0",
|
|
28
|
+
"@payloadcms/ui": "^3.88.0",
|
|
29
|
+
"next": "^15.1.0 || ^16.0.0",
|
|
30
|
+
"payload": "^3.88.0",
|
|
31
|
+
"react": "19.2.8",
|
|
32
|
+
"react-dom": "19.2.8"
|
|
34
33
|
},
|
|
35
34
|
"devDependencies": {
|
|
36
35
|
"@payloadcms/eslint-config": "^3.28.0",
|
|
37
|
-
"@payloadcms/translations": "^3.
|
|
36
|
+
"@payloadcms/translations": "^3.88.0",
|
|
38
37
|
"@swc/cli": "^0.8.1",
|
|
39
|
-
"@swc/core": "^1.
|
|
40
|
-
"@types/react": "19.2.
|
|
41
|
-
"@types/react-dom": "19.2.
|
|
38
|
+
"@swc/core": "^1.16.1",
|
|
39
|
+
"@types/react": "19.2.18",
|
|
40
|
+
"@types/react-dom": "19.2.5",
|
|
42
41
|
"copyfiles": "2.4.1",
|
|
43
42
|
"eslint": "^9.39.4",
|
|
44
|
-
"prettier": "
|
|
43
|
+
"prettier": "3.9.6",
|
|
45
44
|
"rimraf": "6.1.3",
|
|
46
45
|
"typescript": "^6.0.3",
|
|
47
|
-
"vitest": "^4.1.
|
|
46
|
+
"vitest": "^4.1.11"
|
|
48
47
|
},
|
|
49
48
|
"files": [
|
|
50
49
|
"dist"
|