diff --git a/packages/payload/src/config/__tests__/sanitize.spec.ts b/packages/payload/src/config/__tests__/sanitize.spec.ts new file mode 100644 index 00000000000..1c0377f1b79 --- /dev/null +++ b/packages/payload/src/config/__tests__/sanitize.spec.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from 'vitest' + +import { sanitizeConfig } from '../sanitize.js' + +describe('sanitizeConfig', () => { + it('should populate collectionsBySlug map from collections array', async () => { + const config = await sanitizeConfig({ + collections: [ + { slug: 'posts', fields: [] }, + { slug: 'users', auth: true, fields: [] }, + ], + globals: [{ slug: 'settings', fields: [] }], + } as any) + + expect(config.collectionsBySlug).toBeInstanceOf(Map) + expect(config.collectionsBySlug.size).toBe(config.collections.length) + expect(config.collectionsBySlug.get('posts')).toBe( + config.collections.find((c) => c.slug === 'posts'), + ) + }) + + it('should populate globalsBySlug map from globals array', async () => { + const config = await sanitizeConfig({ + collections: [], + globals: [ + { slug: 'settings', fields: [] }, + { slug: 'nav', fields: [] }, + ], + } as any) + + expect(config.globalsBySlug).toBeInstanceOf(Map) + expect(config.globalsBySlug.size).toBe(config.globals.length) + expect(config.globalsBySlug.get('settings')).toBe( + config.globals.find((g) => g.slug === 'settings'), + ) + }) +}) diff --git a/packages/payload/src/config/sanitize.ts b/packages/payload/src/config/sanitize.ts index e65799c6e6d..b781dca175e 100644 --- a/packages/payload/src/config/sanitize.ts +++ b/packages/payload/src/config/sanitize.ts @@ -4,7 +4,12 @@ import { en } from '@payloadcms/translations/languages/en' import { deepMergeSimple } from '@payloadcms/translations/utilities' import type { OrderableJoinInfo } from '../fields/config/sanitizeJoinField.js' -import type { CollectionSlug, GlobalSlug, SanitizedCollectionConfig } from '../index.js' +import type { + CollectionSlug, + GlobalSlug, + SanitizedCollectionConfig, + SanitizedGlobalConfig, +} from '../index.js' import type { SanitizedJobsConfig } from '../queues/config/types/index.js' import type { Config, @@ -460,15 +465,6 @@ export const sanitizeConfig = async (incomingConfig: Config): Promise() - // interact with all collections - for (const collection of config.collections!) { - // deduped upload adapters - if (collection.upload?.adapter) { - uploadAdapters.add(collection.upload.adapter) - } - } - if (!config.upload) { config.upload = { adapters: [] } } @@ -504,5 +500,18 @@ export const sanitizeConfig = async (incomingConfig: Config): Promise() + for (const collection of config.collections as SanitizedCollectionConfig[]) { + collectionsBySlug.set(collection.slug, collection) + } + + const globalsBySlug = new Map() + for (const global of config.globals ?? []) { + globalsBySlug.set(global.slug, global) + } + + ;(config as any).collectionsBySlug = collectionsBySlug + ;(config as any).globalsBySlug = globalsBySlug + return config as SanitizedConfig } diff --git a/packages/payload/src/config/types.ts b/packages/payload/src/config/types.ts index 8bbfcbf8eb3..949638be3be 100644 --- a/packages/payload/src/config/types.ts +++ b/packages/payload/src/config/types.ts @@ -1586,10 +1586,12 @@ export type SanitizedConfig = { } & DeepRequired blocks?: FlattenedBlock[] collections: SanitizedCollectionConfig[] + collectionsBySlug: Map /** Default richtext editor to use for richText fields */ editor?: RichTextAdapter endpoints: Endpoint[] globals: SanitizedGlobalConfig[] + globalsBySlug: Map i18n: Required jobs: SanitizedJobsConfig localization: false | SanitizedLocalizationConfig diff --git a/packages/plugin-ecommerce/src/index.ts b/packages/plugin-ecommerce/src/index.ts index 1a5059719c6..3658f29ed88 100644 --- a/packages/plugin-ecommerce/src/index.ts +++ b/packages/plugin-ecommerce/src/index.ts @@ -1,8 +1,6 @@ import type { AcceptedLanguages } from '@payloadcms/translations' import type { Config, Endpoint } from 'payload' -import { deepMergeSimple } from 'payload/shared' - import type { PluginDefaultTranslationsObject } from './translations/types.js' import type { EcommercePluginConfig, SanitizedEcommercePluginConfig } from './types/index.js' @@ -311,41 +309,35 @@ export const ecommercePlugin = incomingConfig.i18n = {} } - if (!incomingConfig.i18n?.translations) { + if (!incomingConfig.i18n.translations) { incomingConfig.i18n.translations = {} } - incomingConfig.i18n.translations = deepMergeSimple( - translations, - incomingConfig.i18n?.translations, - ) - /** - * Merge plugin translations + * Merge plugin translations — only for languages the user has enabled. + * Plugins run before sanitize, so `supportedLanguages` may be undefined; sanitize will + * default it to `{ en }`, so we mirror that here. Plugin-ecommerce translations always + * win over user-provided ones for the `plugin-ecommerce` namespace. */ - if (!incomingConfig.i18n) { - incomingConfig.i18n = {} - } - Object.entries(translations).forEach(([locale, pluginI18nObject]) => { - const typedLocale = locale as AcceptedLanguages - if (!incomingConfig.i18n!.translations) { - incomingConfig.i18n!.translations = {} + const supportedLanguageKeys = incomingConfig.i18n?.supportedLanguages + ? Object.keys(incomingConfig.i18n.supportedLanguages) + : ['en'] + + for (const lang of supportedLanguageKeys) { + const pluginEntry = translations[lang as keyof typeof translations] + if (!pluginEntry) { + continue } - if (!(typedLocale in incomingConfig.i18n!.translations)) { - incomingConfig.i18n!.translations[typedLocale] = {} - } - if (!('plugin-ecommerce' in incomingConfig.i18n!.translations[typedLocale]!)) { - ;(incomingConfig.i18n!.translations[typedLocale] as PluginDefaultTranslationsObject)[ - 'plugin-ecommerce' - ] = {} as PluginDefaultTranslationsObject['plugin-ecommerce'] - } - - ;(incomingConfig.i18n!.translations[typedLocale] as PluginDefaultTranslationsObject)[ - 'plugin-ecommerce' - ] = { - ...pluginI18nObject.translations['plugin-ecommerce'], - } - }) + const typedLocale = lang as AcceptedLanguages + const existing = (incomingConfig.i18n.translations[typedLocale] ?? {}) as Record< + string, + unknown + > + incomingConfig.i18n.translations[typedLocale] = { + ...existing, + 'plugin-ecommerce': pluginEntry.translations['plugin-ecommerce'], + } as PluginDefaultTranslationsObject + } if (!incomingConfig.typescript) { incomingConfig.typescript = {} diff --git a/packages/plugin-import-export/src/index.ts b/packages/plugin-import-export/src/index.ts index 2904f4abdf8..6d956b02069 100644 --- a/packages/plugin-import-export/src/index.ts +++ b/packages/plugin-import-export/src/index.ts @@ -191,15 +191,21 @@ export const importExportPlugin = } /** - * Merge plugin translations + * Merge plugin translations — only for languages the user has enabled. + * Plugins run before sanitize, so `supportedLanguages` may be undefined; sanitize will + * default it to `{ en }`, so we mirror that here to avoid merging 30+ unused tables. */ - const simplifiedTranslations = Object.entries(translations).reduce( - (acc, [key, value]) => { - acc[key] = value.translations - return acc - }, - {} as Record, - ) + const supportedLanguageKeys = config.i18n?.supportedLanguages + ? Object.keys(config.i18n.supportedLanguages) + : ['en'] + + const simplifiedTranslations: Record = {} + for (const lang of supportedLanguageKeys) { + const entry = translations[lang as keyof typeof translations] + if (entry) { + simplifiedTranslations[lang] = entry.translations + } + } config.i18n = { ...config.i18n, diff --git a/packages/plugin-search/src/index.ts b/packages/plugin-search/src/index.ts index f7fcc27ac6f..883a7d13e16 100644 --- a/packages/plugin-search/src/index.ts +++ b/packages/plugin-search/src/index.ts @@ -22,11 +22,16 @@ export const searchPlugin = incomingPluginConfig.localize = shouldLocalize if (collections) { - const labels = Object.fromEntries( - collections - .filter(({ slug }) => incomingPluginConfig.collections?.includes(slug)) - .map((collection) => [collection.slug, collection.labels]), - ) + // O(1) slug lookup for enabled-collection checks; replaces an Array.indexOf in the + // hook-attachment pass that was O(M) per collection. + const enabledSlugSet = new Set(incomingPluginConfig.collections ?? []) + + const labels: Record = {} + for (const collection of collections) { + if (enabledSlugSet.has(collection.slug)) { + labels[collection.slug] = collection.labels + } + } const pluginConfig: SanitizedSearchPluginConfig = { // write any config defaults here @@ -42,8 +47,7 @@ export const searchPlugin = ?.map((collection) => { const { hooks: existingHooks } = collection - const enabledCollections = pluginConfig.collections || [] - const isEnabled = enabledCollections.indexOf(collection.slug) > -1 + const isEnabled = enabledSlugSet.has(collection.slug) if (isEnabled) { return { ...collection, diff --git a/packages/plugin-seo/src/index.ts b/packages/plugin-seo/src/index.ts index dc81ce03bf9..0e0c2477dcd 100644 --- a/packages/plugin-seo/src/index.ts +++ b/packages/plugin-seo/src/index.ts @@ -55,6 +55,15 @@ export const seoPlugin = }, ] + const collectionConfigBySlug = new Map[number]>() + for (const c of config.collections ?? []) { + collectionConfigBySlug.set(c.slug, c) + } + const globalConfigBySlug = new Map[number]>() + for (const g of config.globals ?? []) { + globalConfigBySlug.set(g.slug, g) + } + return { ...config, collections: @@ -144,10 +153,12 @@ export const seoPlugin = const result = pluginConfig.generateTitle ? await pluginConfig.generateTitle({ ...data, - collectionConfig: config.collections?.find( - (c) => c.slug === reqData.collectionSlug, - ), - globalConfig: config.globals?.find((g) => g.slug === reqData.globalSlug), + collectionConfig: reqData.collectionSlug + ? collectionConfigBySlug.get(reqData.collectionSlug) + : undefined, + globalConfig: reqData.globalSlug + ? globalConfigBySlug.get(reqData.globalSlug) + : undefined, req, } satisfies Parameters[0]) : '' @@ -168,10 +179,12 @@ export const seoPlugin = const result = pluginConfig.generateDescription ? await pluginConfig.generateDescription({ ...data, - collectionConfig: config.collections?.find( - (c) => c.slug === reqData.collectionSlug, - ), - globalConfig: config.globals?.find((g) => g.slug === reqData.globalSlug), + collectionConfig: reqData.collectionSlug + ? collectionConfigBySlug.get(reqData.collectionSlug) + : undefined, + globalConfig: reqData.globalSlug + ? globalConfigBySlug.get(reqData.globalSlug) + : undefined, req, } satisfies Parameters[0]) : '' @@ -192,10 +205,12 @@ export const seoPlugin = const result = pluginConfig.generateURL ? await pluginConfig.generateURL({ ...data, - collectionConfig: config.collections?.find( - (c) => c.slug === reqData.collectionSlug, - ), - globalConfig: config.globals?.find((g) => g.slug === reqData.globalSlug), + collectionConfig: reqData.collectionSlug + ? collectionConfigBySlug.get(reqData.collectionSlug) + : undefined, + globalConfig: reqData.globalSlug + ? globalConfigBySlug.get(reqData.globalSlug) + : undefined, req, } satisfies Parameters[0]) : '' @@ -216,10 +231,12 @@ export const seoPlugin = const result = pluginConfig.generateImage ? await pluginConfig.generateImage({ ...data, - collectionConfig: config.collections?.find( - (c) => c.slug === reqData.collectionSlug, - ), - globalConfig: config.globals?.find((g) => g.slug === reqData.globalSlug), + collectionConfig: reqData.collectionSlug + ? collectionConfigBySlug.get(reqData.collectionSlug) + : undefined, + globalConfig: reqData.globalSlug + ? globalConfigBySlug.get(reqData.globalSlug) + : undefined, req, } satisfies Parameters[0]) : '' diff --git a/packages/storage-s3/src/adapter.ts b/packages/storage-s3/src/adapter.ts index 03a084c1e70..00f40962763 100644 --- a/packages/storage-s3/src/adapter.ts +++ b/packages/storage-s3/src/adapter.ts @@ -1,4 +1,4 @@ -import type * as AWS from '@aws-sdk/client-s3' +import type { S3, S3ClientConfig } from '@aws-sdk/client-s3' import type { Adapter, ClientUploadsConfig, @@ -7,17 +7,14 @@ import type { import type { SignedDownloadsConfig } from './getFile.js' -import { deleteFile } from './deleteFile.js' import { generateURL } from './generateURL.js' -import { getFile } from './getFile.js' -import { uploadFile } from './uploadFile.js' interface CreateS3AdapterArgs { acl?: 'private' | 'public-read' bucket: string clientUploads?: ClientUploadsConfig - config: AWS.S3ClientConfig - getStorageClient: () => AWS.S3 + config: S3ClientConfig + getStorageClient: () => S3 signedDownloads: SignedDownloadsConfig useCompositePrefixes?: boolean } @@ -45,17 +42,22 @@ export function createS3Adapter({ useCompositePrefixes, }), - handleDelete: ({ doc: { prefix: docPrefix = '' }, filename }) => - deleteFile({ + // Helpers below dynamic-import their @aws-sdk dependencies so the SDK only + // loads on the first request that actually needs it. + handleDelete: async ({ doc: { prefix: docPrefix = '' }, filename }) => { + const { deleteFile } = await import('./deleteFile.js') + return deleteFile({ bucket, client: getStorageClient(), collectionPrefix: prefix, docPrefix, filename, useCompositePrefixes, - }), + }) + }, handleUpload: async ({ data, file }) => { + const { uploadFile } = await import('./uploadFile.js') await uploadFile({ acl, bucket, @@ -72,11 +74,12 @@ export function createS3Adapter({ return data }, - staticHandler: ( + staticHandler: async ( req, { headers, params: { clientUploadContext, filename, prefix: prefixQueryParam } }, - ) => - getFile({ + ) => { + const { getFile } = await import('./getFile.js') + return getFile({ bucket, client: getStorageClient(), clientUploadContext, @@ -88,6 +91,7 @@ export function createS3Adapter({ req, signedDownloads, useCompositePrefixes, - }), + }) + }, }) } diff --git a/packages/storage-s3/src/generateSignedURL.ts b/packages/storage-s3/src/generateSignedURL.ts index 8992b66f27b..5308dde3cb3 100644 --- a/packages/storage-s3/src/generateSignedURL.ts +++ b/packages/storage-s3/src/generateSignedURL.ts @@ -1,7 +1,8 @@ +import type { S3 } from '@aws-sdk/client-s3' import type { ClientUploadsAccess } from '@payloadcms/plugin-cloud-storage/types' import type { PayloadHandler } from 'payload' -import * as AWS from '@aws-sdk/client-s3' +import { PutObjectCommand } from '@aws-sdk/client-s3' import { getSignedUrl } from '@aws-sdk/s3-request-presigner' import { resolveSignedURLKey } from '@payloadcms/plugin-cloud-storage/utilities' import { APIError, Forbidden } from 'payload' @@ -17,7 +18,7 @@ interface Args { acl?: 'private' | 'public-read' bucket: string collections: S3StorageOptions['collections'] - getStorageClient: () => AWS.S3 + getStorageClient: () => S3 useCompositePrefixes?: boolean } @@ -87,7 +88,7 @@ export const getGenerateSignedURLHandler = ({ const url = await getSignedUrl( getStorageClient(), - new AWS.PutObjectCommand({ + new PutObjectCommand({ ACL: acl, Bucket: bucket, ContentLength: filesizeLimit ? Math.min(filesize, filesizeLimit) : undefined, diff --git a/packages/storage-s3/src/index.ts b/packages/storage-s3/src/index.ts index 5fbb0f3568d..e12c48aae8c 100644 --- a/packages/storage-s3/src/index.ts +++ b/packages/storage-s3/src/index.ts @@ -1,3 +1,4 @@ +import type { S3ClientConfig } from '@aws-sdk/client-s3' import type { ClientUploadsConfig, PluginOptions as CloudStoragePluginOptions, @@ -6,7 +7,7 @@ import type { import type { NodeHttpHandlerOptions } from '@smithy/node-http-handler' import type { Config, Plugin, UploadCollectionSlug } from 'payload' -import * as AWS from '@aws-sdk/client-s3' +import { S3 } from '@aws-sdk/client-s3' import { cloudStoragePlugin } from '@payloadcms/plugin-cloud-storage' import { initClientUploads } from '@payloadcms/plugin-cloud-storage/utilities' @@ -69,7 +70,7 @@ export type S3StorageOptions = { * * [AWS.S3ClientConfig Docs](https://docs.aws.amazon.com/AWSJavaScriptSDK/v3/latest/clients/client-s3/interfaces/s3clientconfig.html) */ - config: AWS.S3ClientConfig + config: S3ClientConfig /** * Whether or not to disable local storage @@ -106,7 +107,7 @@ export type S3StorageOptions = { type S3StoragePlugin = (storageS3Args: S3StorageOptions) => Plugin -const s3Clients = new Map() +const s3Clients = new Map() const defaultRequestHandlerOpts: NodeHttpHandlerOptions = { httpAgent: { @@ -124,14 +125,16 @@ export const s3Storage: S3StoragePlugin = (incomingConfig: Config): Config => { const cacheKey = s3StorageOptions.clientCacheKey || `s3:${s3StorageOptions.bucket}` - const getStorageClient: () => AWS.S3 = () => { + const isPluginDisabled = s3StorageOptions.enabled === false + + const getStorageClient: () => S3 = () => { if (s3Clients.has(cacheKey)) { return s3Clients.get(cacheKey)! } s3Clients.set( cacheKey, - new AWS.S3({ + new S3({ requestHandler: defaultRequestHandlerOpts, ...(s3StorageOptions.config ?? {}), }), @@ -140,8 +143,6 @@ export const s3Storage: S3StoragePlugin = return s3Clients.get(cacheKey)! } - const isPluginDisabled = s3StorageOptions.enabled === false - initClientUploads({ clientHandler: '@payloadcms/storage-s3/client#S3ClientUploadHandler', collections: s3StorageOptions.collections, diff --git a/test/plugin-ecommerce/int.spec.ts b/test/plugin-ecommerce/int.spec.ts index ab2ba64ed4d..b1ca613b878 100644 --- a/test/plugin-ecommerce/int.spec.ts +++ b/test/plugin-ecommerce/int.spec.ts @@ -71,6 +71,23 @@ describe('ecommerce', () => { expect(variants).toBeTruthy() }) + it('should only merge plugin translations for supportedLanguages', () => { + // The shared test buildConfig defaults supportedLanguages to { de, en, es }. + const supportedLangKeys = Object.keys(payload.config.i18n.supportedLanguages).sort() + expect(supportedLangKeys).toEqual(['de', 'en', 'es']) + + for (const lang of supportedLangKeys) { + expect(payload.config.i18n.translations[lang]).toHaveProperty('plugin-ecommerce') + } + + // Plugin must not pollute non-supported languages (e.g. ar, fr). + const arTranslations = payload.config.i18n.translations.ar as + | Record + | undefined + + expect(arTranslations?.['plugin-ecommerce']).toBeUndefined() + }) + describe('guest cart access', () => { it('should allow guest users to create carts', async () => { // Create a cart without authentication diff --git a/test/plugin-import-export/int.spec.ts b/test/plugin-import-export/int.spec.ts index 6923ce5ee5e..da6f0ff9a78 100644 --- a/test/plugin-import-export/int.spec.ts +++ b/test/plugin-import-export/int.spec.ts @@ -50,6 +50,25 @@ describe('@payloadcms/plugin-import-export', () => { await payload.destroy() }) + describe('i18n scoping', () => { + it('should only merge plugin translations for supportedLanguages', () => { + const supportedLangKeys = Object.keys(payload.config.i18n.supportedLanguages) + expect(supportedLangKeys.sort()).toEqual(['en', 'es', 'he']) + + // German is not in supportedLanguages — plugin-import-export must not contribute keys to it. + const deTranslations = payload.config.i18n.translations.de as + | Record + | undefined + + expect(deTranslations?.['plugin-import-export']).toBeUndefined() + + // It should be present for supportedLanguages. + expect(payload.config.i18n.translations.en).toHaveProperty('plugin-import-export') + expect(payload.config.i18n.translations.es).toHaveProperty('plugin-import-export') + expect(payload.config.i18n.translations.he).toHaveProperty('plugin-import-export') + }) + }) + describe('graphql', () => { it('should not break graphql', async () => { const query = `query {