diff --git a/.claude-plugin/README.md b/.claude-plugin/README.md new file mode 100644 index 00000000000..f2f7d03b0b7 --- /dev/null +++ b/.claude-plugin/README.md @@ -0,0 +1,67 @@ +# Payload Skill for Claude Code + +Claude Code skill providing comprehensive guidance for Payload 3.x development with TypeScript patterns, field configurations, hooks, access control, and API examples. + +## Installation + +### From GitHub + +Install this skill directly from the Payload repository: + +```bash +/plugin install github:payloadcms/payload +``` + +## What's Included + +The `payload` skill provides expert guidance on: + +- **Collections**: Auth, uploads, drafts, live preview configurations +- **Fields**: All field types including relationships, arrays, blocks, joins, virtual fields +- **Hooks**: beforeChange, afterChange, beforeValidate, field hooks +- **Access Control**: Collection, field, and global access patterns including RBAC and multi-tenant +- **Queries**: Local API, REST, and GraphQL with complex operators +- **Database Adapters**: MongoDB, Postgres, SQLite configurations and transactions +- **Advanced Features**: Jobs queue, custom endpoints, localization, plugins + +## Usage + +Once installed, Claude will automatically invoke the skill when you're working on Payload CMS projects. The skill activates when you: + +- Edit `payload.config.ts` files +- Work with collection or global configurations +- Ask about Payload-specific patterns +- Need guidance on fields, hooks, or access control + +You can also explicitly invoke it: + +``` +@payload how do I implement row-level access control? +``` + +## Documentation Structure + +``` +skills/payload/ +├── SKILL.md # Main skill file with quick reference +└── reference/ + ├── FIELDS.md # All field types and configurations + ├── COLLECTIONS.md # Collection patterns + ├── HOOKS.md # Hook patterns and examples + ├── ACCESS-CONTROL.md # Basic access control + ├── ACCESS-CONTROL-ADVANCED.md # Advanced access patterns + ├── QUERIES.md # Query patterns and APIs + ├── ADAPTERS.md # Database and storage adapters + └── ADVANCED.md # Jobs, endpoints, localization +``` + +## Resources + +- [Payload Documentation](https://payloadcms.com/docs) +- [GitHub Repository](https://github.com/payloadcms/payload) +- [Examples](https://github.com/payloadcms/payload/tree/main/examples) +- [Templates](https://github.com/payloadcms/payload/tree/main/templates) + +## License + +MIT diff --git a/.claude/skills/payload-cms/SKILL.md b/.claude-plugin/commands/payload/SKILL.md similarity index 90% rename from .claude/skills/payload-cms/SKILL.md rename to .claude-plugin/commands/payload/SKILL.md index e1475748248..55b4b5b0144 100644 --- a/.claude/skills/payload-cms/SKILL.md +++ b/.claude-plugin/commands/payload/SKILL.md @@ -1,5 +1,5 @@ --- -name: payload-cms +name: payload description: Use when working with Payload CMS projects, payload.config.ts, collections, fields, hooks, access control, or Payload API. Provides TypeScript patterns and examples for Payload 3.x development. --- @@ -27,6 +27,10 @@ Payload 3.x is a Next.js native CMS with TypeScript-first architecture, providin | Custom API routes | Collection/root endpoints | [ADVANCED.md#custom-endpoints](reference/ADVANCED.md#custom-endpoints) | | Cloud storage | Storage adapter plugins | [ADAPTERS.md#storage-adapters](reference/ADAPTERS.md#storage-adapters) | | Multi-language | `localization` config + `localized: true` | [ADVANCED.md#localization](reference/ADVANCED.md#localization) | +| Create plugin | `(options) => (config) => Config` | [PLUGIN-DEVELOPMENT.md#plugin-architecture](reference/PLUGIN-DEVELOPMENT.md#plugin-architecture) | +| Plugin package setup | Package structure with SWC | [PLUGIN-DEVELOPMENT.md#plugin-package-structure](reference/PLUGIN-DEVELOPMENT.md#plugin-package-structure) | +| Add fields to collection | Map collections, spread fields | [PLUGIN-DEVELOPMENT.md#adding-fields-to-collections](reference/PLUGIN-DEVELOPMENT.md#adding-fields-to-collections) | +| Plugin hooks | Preserve existing hooks in array | [PLUGIN-DEVELOPMENT.md#adding-hooks](reference/PLUGIN-DEVELOPMENT.md#adding-hooks) | ## Quick Start @@ -199,6 +203,7 @@ import type { Post, User } from '@/payload-types' - **[QUERIES.md](reference/QUERIES.md)** - Query operators, Local/REST/GraphQL APIs - **[ADAPTERS.md](reference/ADAPTERS.md)** - Database, storage, email adapters, transactions - **[ADVANCED.md](reference/ADVANCED.md)** - Authentication, jobs, endpoints, components, plugins, localization +- **[PLUGIN-DEVELOPMENT.md](reference/PLUGIN-DEVELOPMENT.md)** - Plugin architecture, monorepo structure, patterns, best practices ## Resources diff --git a/.claude/skills/payload-cms/reference/ACCESS-CONTROL-ADVANCED.md b/.claude-plugin/commands/payload/reference/ACCESS-CONTROL-ADVANCED.md similarity index 100% rename from .claude/skills/payload-cms/reference/ACCESS-CONTROL-ADVANCED.md rename to .claude-plugin/commands/payload/reference/ACCESS-CONTROL-ADVANCED.md diff --git a/.claude/skills/payload-cms/reference/ACCESS-CONTROL.md b/.claude-plugin/commands/payload/reference/ACCESS-CONTROL.md similarity index 100% rename from .claude/skills/payload-cms/reference/ACCESS-CONTROL.md rename to .claude-plugin/commands/payload/reference/ACCESS-CONTROL.md diff --git a/.claude/skills/payload-cms/reference/ADAPTERS.md b/.claude-plugin/commands/payload/reference/ADAPTERS.md similarity index 100% rename from .claude/skills/payload-cms/reference/ADAPTERS.md rename to .claude-plugin/commands/payload/reference/ADAPTERS.md diff --git a/.claude/skills/payload-cms/reference/ADVANCED.md b/.claude-plugin/commands/payload/reference/ADVANCED.md similarity index 100% rename from .claude/skills/payload-cms/reference/ADVANCED.md rename to .claude-plugin/commands/payload/reference/ADVANCED.md diff --git a/.claude/skills/payload-cms/reference/COLLECTIONS.md b/.claude-plugin/commands/payload/reference/COLLECTIONS.md similarity index 100% rename from .claude/skills/payload-cms/reference/COLLECTIONS.md rename to .claude-plugin/commands/payload/reference/COLLECTIONS.md diff --git a/.claude/skills/payload-cms/reference/FIELDS.md b/.claude-plugin/commands/payload/reference/FIELDS.md similarity index 100% rename from .claude/skills/payload-cms/reference/FIELDS.md rename to .claude-plugin/commands/payload/reference/FIELDS.md diff --git a/.claude/skills/payload-cms/reference/HOOKS.md b/.claude-plugin/commands/payload/reference/HOOKS.md similarity index 100% rename from .claude/skills/payload-cms/reference/HOOKS.md rename to .claude-plugin/commands/payload/reference/HOOKS.md diff --git a/.claude-plugin/commands/payload/reference/PLUGIN-DEVELOPMENT.md b/.claude-plugin/commands/payload/reference/PLUGIN-DEVELOPMENT.md new file mode 100644 index 00000000000..d957b32b7e3 --- /dev/null +++ b/.claude-plugin/commands/payload/reference/PLUGIN-DEVELOPMENT.md @@ -0,0 +1,1430 @@ +# Payload Plugin Development + +Complete guide to creating Payload CMS plugins with TypeScript patterns, package structure, and best practices from the official Payload plugin template. + +## Plugin Architecture + +Plugins are functions that receive configuration options and return a function that transforms the Payload config: + +```ts +import type { Config, Plugin } from 'payload' + +interface MyPluginConfig { + enabled?: boolean + collections?: string[] +} + +export const myPlugin = + (options: MyPluginConfig): Plugin => + (config: Config): Config => ({ + ...config, + // Transform config here + }) +``` + +**Key Pattern:** Double arrow function (currying) + +- First function: Accepts plugin options, returns plugin function +- Second function: Accepts Payload config, returns modified config + +## Plugin Package Structure + +### Simple Structure + +``` +plugin-/ +├── package.json # Package metadata and dependencies +├── README.md # Plugin documentation +├── LICENSE.md # License file +└── src/ + ├── index.ts # Entry point, re-exports plugin and config types + ├── plugin.ts # Plugin implementation + ├── types.ts # TypeScript type definitions + └── exports/ # Additional entry points (optional) + └── types.ts # Type-only exports +``` + +### Exhaustive Structure + +``` +plugin-/ +├── .swcrc # SWC compiler config +├── package.json # Package metadata and dependencies +├── tsconfig.json # TypeScript config +├── README.md # Plugin documentation +├── LICENSE.md # License file +├── eslint.config.js # ESLint configuration (optional) +├── vitest.config.js # Vitest test configuration (optional) +├── playwright.config.js # Playwright e2e tests (optional) +└── src/ + ├── index.ts # Entry point, re-exports plugin and config types + ├── plugin.ts # Plugin implementation + ├── types.ts # TypeScript type definitions + ├── defaults.ts # Default configuration values (optional) + ├── endpoints/ # Custom API endpoints (optional) + │ └── handler.ts + ├── components/ # React components (optional) + │ ├── ClientComponent.tsx # 'use client' components + │ └── ServerComponent.tsx # RSC components + ├── fields/ # Custom field components (optional) + │ ├── FieldName/ + │ │ ├── index.ts # Field config + │ │ └── Component.tsx # Client component + ├── exports/ # Additional entry points + │ ├── types.ts # Type-only exports + │ ├── fields.ts # Field-only exports + │ ├── client.ts # Re-export client components + │ └── rsc.ts # Re-export server components (RSC) + ├── translations/ # i18n translations (optional) + │ └── index.ts + └── ui/ # Admin UI components (optional) + └── Component.tsx +``` + +**Key additions from official template:** + +- **dev/** directory with complete Payload project for local testing +- **src/exports/rsc.ts** for React Server Component exports +- **src/components/** for organizing React components +- **src/endpoints/** for custom API endpoint handlers +- Test configuration files (vitest.config.js, playwright.config.js) + +## Package.json Configuration + +```json +{ + "name": "payload-plugin-example", + "version": "1.0.0", + "description": "A Payload CMS plugin", + "type": "module", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "import": "./dist/index.js", + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + }, + "./types": { + "import": "./dist/exports/types.js", + "types": "./dist/exports/types.d.ts" + }, + "./client": { + "import": "./dist/exports/client.js", + "types": "./dist/exports/client.d.ts" + }, + "./rsc": { + "import": "./dist/exports/rsc.js", + "types": "./dist/exports/rsc.d.ts" + } + }, + "files": ["dist"], + "scripts": { + "build": "npm run copyfiles && npm run build:types && npm run build:swc", + "build:swc": "swc ./src -d ./dist --config-file .swcrc --strip-leading-paths", + "build:types": "tsc --emitDeclarationOnly --outDir dist", + "clean": "rimraf dist *.tsbuildinfo", + "copyfiles": "copyfiles -u 1 \"src/**/*.{html,css,scss,ttf,woff,woff2,eot,svg,jpg,png,json}\" dist/", + "dev": "next dev dev --turbo", + "dev:generate-types": "cross-env PAYLOAD_CONFIG_PATH=./dev/payload.config.ts payload generate:types", + "dev:payload": "cross-env PAYLOAD_CONFIG_PATH=./dev/payload.config.ts payload", + "test": "npm run test:int && npm run test:e2e", + "test:int": "vitest", + "test:e2e": "playwright test", + "lint": "eslint", + "lint:fix": "eslint ./src --fix", + "prepublishOnly": "npm run clean && npm run build" + }, + "dependencies": { + "@payloadcms/translations": "^3.0.0", + "@payloadcms/ui": "^3.0.0" + }, + "devDependencies": { + "@payloadcms/db-mongodb": "^3.0.0", + "@payloadcms/next": "^3.0.0", + "@payloadcms/richtext-lexical": "^3.0.0", + "@playwright/test": "^1.40.0", + "@swc/cli": "^0.1.62", + "@swc/core": "^1.3.0", + "copyfiles": "^2.4.1", + "cross-env": "^7.0.3", + "eslint": "^9.0.0", + "next": "^15.0.0", + "payload": "^3.0.0", + "react": "^19.0.0", + "react-dom": "^19.0.0", + "rimraf": "^5.0.0", + "typescript": "^5.0.0", + "vitest": "^3.0.0" + }, + "peerDependencies": { + "payload": "^3.0.0" + } +} +``` + +**Key Points:** + +- `type: "module"` for ESM +- Compiled output in `./dist`, source in `./src` +- Payload as peer dependency (user installs it) +- Multiple export entry points: main, `/types`, `/client`, `/rsc` +- `/client` for client components, `/rsc` for React Server Components +- SWC for fast compilation +- Dev scripts for local development with Next.js +- Test scripts for both integration (Vitest) and e2e (Playwright) tests +- `prepublishOnly` ensures build before publish + +## Plugin Patterns + +### Adding Fields to Collections + +```ts +import type { Config, Plugin, Field } from 'payload' + +export const seoPlugin = + (options: { collections?: string[] }): Plugin => + (config: Config): Config => { + const seoFields: Field[] = [ + { + name: 'meta', + type: 'group', + fields: [ + { name: 'title', type: 'text' }, + { name: 'description', type: 'textarea' }, + ], + }, + ] + + return { + ...config, + collections: config.collections?.map((collection) => { + if (options.collections?.includes(collection.slug)) { + return { + ...collection, + fields: [...(collection.fields || []), ...seoFields], + } + } + return collection + }), + } + } +``` + +### Adding New Collections + +```ts +import type { Config, Plugin, CollectionConfig } from 'payload' + +export const redirectsPlugin = + (options: { overrides?: Partial }): Plugin => + (config: Config): Config => { + const redirectsCollection: CollectionConfig = { + slug: 'redirects', + access: { read: () => true }, + fields: [ + { name: 'from', type: 'text', required: true, unique: true }, + { name: 'to', type: 'text', required: true }, + ], + ...options.overrides, + } + + return { + ...config, + collections: [...(config.collections || []), redirectsCollection], + } + } +``` + +### Adding Hooks + +```ts +import type { Config, Plugin, CollectionAfterChangeHook } from 'payload' + +const resaveChildrenHook: CollectionAfterChangeHook = async ({ doc, req, operation }) => { + if (operation === 'update') { + // Resave child documents + const children = await req.payload.find({ + collection: 'pages', + where: { parent: { equals: doc.id } }, + }) + + for (const child of children.docs) { + await req.payload.update({ + collection: 'pages', + id: child.id, + data: child, + }) + } + } + return doc +} + +export const nestedDocsPlugin = + (options: { collections: string[] }): Plugin => + (config: Config): Config => ({ + ...config, + collections: (config.collections || []).map((collection) => { + if (options.collections.includes(collection.slug)) { + return { + ...collection, + hooks: { + ...(collection.hooks || {}), + afterChange: [resaveChildrenHook, ...(collection.hooks?.afterChange || [])], + }, + } + } + return collection + }), + }) +``` + +### Adding Root-Level Endpoints + +Add endpoints at the root config level (accessible at `/api/`): + +```ts +import type { Config, Plugin, Endpoint } from 'payload' + +export const seoPlugin = + (options: { generateTitle?: (doc: any) => string }): Plugin => + (config: Config): Config => { + const generateTitleEndpoint: Endpoint = { + path: '/plugin-seo/generate-title', + method: 'post', + handler: async (req) => { + const data = await req.json?.() + const result = options.generateTitle ? options.generateTitle(data.doc) : '' + return Response.json({ result }) + }, + } + + return { + ...config, + endpoints: [...(config.endpoints ?? []), generateTitleEndpoint], + } + } +``` + +**Example webhook endpoint:** + +```ts +// Useful for integrations like Stripe +const webhookEndpoint: Endpoint = { + path: '/stripe/webhook', + method: 'post', + handler: async (req) => { + const signature = req.headers.get('stripe-signature') + const event = stripe.webhooks.constructEvent( + await req.text(), + signature, + process.env.STRIPE_WEBHOOK_SECRET, + ) + // Handle webhook + return Response.json({ received: true }) + }, +} +``` + +### Field Overrides with Defaults + +```ts +import type { Config, Plugin, Field } from 'payload' + +type FieldsOverride = (args: { defaultFields: Field[] }) => Field[] + +interface PluginConfig { + collections?: string[] + fields?: FieldsOverride +} + +export const myPlugin = + (options: PluginConfig): Plugin => + (config: Config): Config => { + const defaultFields: Field[] = [ + { name: 'title', type: 'text' }, + { name: 'description', type: 'textarea' }, + ] + + const fields = + options.fields && typeof options.fields === 'function' + ? options.fields({ defaultFields }) + : defaultFields + + return { + ...config, + collections: config.collections?.map((collection) => { + if (options.collections?.includes(collection.slug)) { + return { + ...collection, + fields: [...(collection.fields || []), ...fields], + } + } + return collection + }), + } + } +``` + +### Tabs UI Pattern + +```ts +import type { Config, Plugin, TabsField, GroupField } from 'payload' + +export const seoPlugin = + (options: { tabbedUI?: boolean }): Plugin => + (config: Config): Config => { + const seoFields: GroupField[] = [ + { + name: 'meta', + type: 'group', + fields: [{ name: 'title', type: 'text' }], + }, + ] + + return { + ...config, + collections: config.collections?.map((collection) => { + if (options.tabbedUI) { + const seoTabs: TabsField[] = [ + { + type: 'tabs', + tabs: [ + // If existing tabs, preserve them + ...(collection.fields?.[0]?.type === 'tabs' + ? collection.fields[0].tabs + : [ + { + label: 'Content', + fields: collection.fields || [], + }, + ]), + // Add SEO tab + { + label: 'SEO', + fields: seoFields, + }, + ], + }, + ] + + return { + ...collection, + fields: [ + ...seoTabs, + ...(collection.fields?.[0]?.type === 'tabs' ? collection.fields.slice(1) : []), + ], + } + } + + return { + ...collection, + fields: [...(collection.fields || []), ...seoFields], + } + }), + } + } +``` + +### Disable Plugin Pattern + +Allow users to disable plugin without removing it (important for database schema consistency): + +```ts +import type { Config, Plugin } from 'payload' + +interface PluginConfig { + disabled?: boolean + collections?: string[] +} + +export const myPlugin = + (options: PluginConfig): Plugin => + (config: Config): Config => { + // Always add collections/fields for database schema consistency + if (!config.collections) { + config.collections = [] + } + + config.collections.push({ + slug: 'plugin-collection', + fields: [{ name: 'title', type: 'text' }], + }) + + // Add fields to specified collections + if (options.collections) { + for (const collectionSlug of options.collections) { + const collection = config.collections.find((c) => c.slug === collectionSlug) + if (collection) { + collection.fields.push({ + name: 'addedByPlugin', + type: 'text', + }) + } + } + } + + // If disabled, return early but keep schema changes + if (options.disabled) { + return config + } + + // Add endpoints, hooks, components only when enabled + config.endpoints = [ + ...(config.endpoints ?? []), + { + path: '/my-endpoint', + method: 'get', + handler: async () => Response.json({ message: 'Hello' }), + }, + ] + + return config + } +``` + +### Admin Components + +Add custom UI components to the admin panel: + +```ts +import type { Config, Plugin } from 'payload' + +export const myPlugin = + (options: PluginConfig): Plugin => + (config: Config): Config => { + if (!config.admin) config.admin = {} + if (!config.admin.components) config.admin.components = {} + if (!config.admin.components.beforeDashboard) { + config.admin.components.beforeDashboard = [] + } + + // Add client component + config.admin.components.beforeDashboard.push('my-plugin-name/client#BeforeDashboardClient') + + // Add server component (RSC) + config.admin.components.beforeDashboard.push('my-plugin-name/rsc#BeforeDashboardServer') + + return config + } +``` + +**Component file structure:** + +```tsx +// src/components/BeforeDashboardClient.tsx +'use client' +import { useConfig } from '@payloadcms/ui' +import { useEffect, useState } from 'react' + +export const BeforeDashboardClient = () => { + const { config } = useConfig() + const [data, setData] = useState('') + + useEffect(() => { + fetch(`${config.serverURL}${config.routes.api}/my-endpoint`) + .then((res) => res.json()) + .then(setData) + }, [config.serverURL, config.routes.api]) + + return
Client Component: {data}
+} + +// src/components/BeforeDashboardServer.tsx +export const BeforeDashboardServer = () => { + return
Server Component
+} + +// src/exports/client.ts +export { BeforeDashboardClient } from '../components/BeforeDashboardClient.js' + +// src/exports/rsc.ts +export { BeforeDashboardServer } from '../components/BeforeDashboardServer.js' +``` + +### Translations (i18n) + +```ts +// src/translations/index.ts +export const translations = { + en: { + 'plugin-name:fieldLabel': 'Field Label', + 'plugin-name:fieldDescription': 'Field description', + }, + es: { + 'plugin-name:fieldLabel': 'Etiqueta del campo', + 'plugin-name:fieldDescription': 'Descripción del campo', + }, +} + +// src/plugin.ts +import { deepMergeSimple } from 'payload/shared' +import { translations } from './translations/index.js' + +export const myPlugin = + (options: PluginConfig): Plugin => + (config: Config): Config => ({ + ...config, + i18n: { + ...config.i18n, + translations: deepMergeSimple(translations, config.i18n?.translations ?? {}), + }, + }) +``` + +### onInit Hook + +```ts +export const myPlugin = + (options: PluginConfig): Plugin => + (config: Config): Config => { + const incomingOnInit = config.onInit + + config.onInit = async (payload) => { + // IMPORTANT: Call existing onInit first + if (incomingOnInit) await incomingOnInit(payload) + + // Plugin initialization + payload.logger.info('Plugin initialized') + + // Example: Seed data + const { totalDocs } = await payload.count({ + collection: 'plugin-collection', + where: { id: { equals: 'seeded-by-plugin' } }, + }) + + if (totalDocs === 0) { + await payload.create({ + collection: 'plugin-collection', + data: { id: 'seeded-by-plugin' }, + }) + } + } + + return config + } +``` + +## TypeScript Patterns + +### Plugin Config Types + +```ts +import type { CollectionSlug, GlobalSlug, Field, CollectionConfig } from 'payload' + +export type FieldsOverride = (args: { defaultFields: Field[] }) => Field[] + +export interface MyPluginConfig { + /** + * Collections to enable this plugin for + */ + collections?: CollectionSlug[] + /** + * Globals to enable this plugin for + */ + globals?: GlobalSlug[] + /** + * Override default fields + */ + fields?: FieldsOverride + /** + * Enable tabbed UI + */ + tabbedUI?: boolean + /** + * Override collection config + */ + overrides?: Partial +} +``` + +### Export Types + +```ts +// src/exports/types.ts +export type { MyPluginConfig, FieldsOverride } from '../types.js' + +// Usage +import type { MyPluginConfig } from '@payloadcms/plugin-example/types' +``` + +## Client Components + +### Custom Field Component + +```tsx +// src/fields/CustomField/Component.tsx +'use client' +import { useField } from '@payloadcms/ui' +import type { TextFieldClientComponent } from 'payload' + +export const CustomFieldComponent: TextFieldClientComponent = ({ field, path }) => { + const { value, setValue } = useField({ path }) + + return ( +
+ + setValue(e.target.value)} /> +
+ ) +} +``` + +```ts +// src/fields/CustomField/index.ts +import type { Field } from 'payload' + +export const CustomField = (overrides?: Partial): Field => ({ + name: 'customField', + type: 'text', + admin: { + components: { + Field: '/fields/CustomField/Component#CustomFieldComponent', + }, + }, + ...overrides, +}) +``` + +## Best Practices + +### Preserve Existing Config + +Always spread existing config and add to arrays: + +```ts +// ✅ Good +collections: [...(config.collections || []), newCollection] + +// ❌ Bad +collections: [newCollection] +``` + +### Respect User Overrides + +Allow users to override plugin defaults: + +```ts +const collection: CollectionConfig = { + slug: 'redirects', + fields: defaultFields, + ...options.overrides, // User overrides last +} +``` + +### Conditional Logic + +Check if collections/globals are enabled: + +```ts +collections: config.collections?.map((collection) => { + const isEnabled = options.collections?.includes(collection.slug) + if (isEnabled) { + // Transform collection + } + return collection +}) +``` + +### Hook Composition + +Preserve existing hooks: + +```ts +hooks: { + ...collection.hooks, + afterChange: [ + myHook, + ...(collection.hooks?.afterChange || []), + ], +} +``` + +### Type Safety + +Use Payload's exported types: + +```ts +import type { Config, Plugin, CollectionConfig, Field, CollectionSlug, GlobalSlug } from 'payload' +``` + +### Field Path Imports + +Use absolute paths for client components: + +```ts +admin: { + components: { + Field: '/fields/CustomField/Component#CustomFieldComponent', + }, +} +``` + +### onInit Pattern + +Always call existing `onInit` before your initialization. See [onInit Hook](#oninit-hook) pattern for full example. + +## Advanced Patterns + +These patterns are extracted from official Payload plugins and represent production-ready techniques for complex plugin development. + +### Advanced Configuration + +#### Async Plugin Function + +Allow plugin function to be async for awaiting collection overrides or async operations: + +```ts +export const myPlugin = + (pluginConfig?: PluginConfig) => + async (incomingConfig: Config): Promise => { + // Can await async operations during initialization + const customCollection = await pluginConfig.collectionOverride?.({ + defaultCollection, + }) + + return { + ...incomingConfig, + collections: [...incomingConfig.collections, customCollection], + } + } +``` + +#### Collection Override with Async Support + +Allow users to override entire collections with async functions: + +```ts +type CollectionOverride = (args: { + defaultCollection: CollectionConfig +}) => CollectionConfig | Promise + +interface PluginConfig { + products?: { + collectionOverride?: CollectionOverride + } +} + +// In plugin +const defaultCollection = createProductsCollection(config) +const finalCollection = config.products?.collectionOverride + ? await config.products.collectionOverride({ defaultCollection }) + : defaultCollection +``` + +#### Config Sanitization Pattern + +Normalize plugin configuration with defaults: + +```ts +export const sanitizePluginConfig = ({ pluginConfig }: Props): SanitizedPluginConfig => { + const config = { ...pluginConfig } as Partial + + // Normalize boolean|object configs + if (typeof config.addresses === 'undefined' || config.addresses === true) { + config.addresses = { addressFields: defaultAddressFields() } + } else if (config.addresses === false) { + config.addresses = null + } + + // Validate required fields + if (!config.stripeSecretKey) { + throw new Error('Stripe secret key is required') + } + + return config as SanitizedPluginConfig +} + +// Use at plugin start +export const myPlugin = + (pluginConfig: PluginConfig): Plugin => + (config) => { + const sanitized = sanitizePluginConfig({ pluginConfig }) + // Use sanitized config throughout + } +``` + +#### Collection Slug Mapping + +Track collection slugs when users can override them: + +```ts +type CollectionSlugMap = { + products: string + variants: string + orders: string +} + +const getCollectionSlugMap = ({ config }: { config: PluginConfig }): CollectionSlugMap => ({ + products: config.products?.slug || 'products', + variants: config.variants?.slug || 'variants', + orders: config.orders?.slug || 'orders', +}) + +// Use throughout plugin +const collectionSlugMap = getCollectionSlugMap({ config: pluginConfig }) + +// When creating relationship fields +{ + name: 'product', + type: 'relationship', + relationTo: collectionSlugMap.products, +} +``` + +#### Multi-Collection Configuration + +Plugin operates on multiple collections with collection-specific config: + +```ts +interface PluginConfig { + sync: Array<{ + collection: string + fields?: string[] + onSync?: (doc: any) => Promise + }> +} + +// In plugin +for (const collection of config.collections!) { + const syncConfig = pluginConfig.sync?.find((s) => s.collection === collection.slug) + if (!syncConfig) continue + + collection.hooks.afterChange = [ + ...(collection.hooks?.afterChange || []), + async ({ doc, operation }) => { + if (operation === 'create' || operation === 'update') { + await syncConfig.onSync?.(doc) + } + }, + ] +} +``` + +### TypeScript Extensions + +#### TypeScript Schema Extension + +Add custom properties to generated TypeScript schema: + +```ts +incomingConfig.typescript = incomingConfig.typescript || {} +incomingConfig.typescript.schema = incomingConfig.typescript.schema || [] + +incomingConfig.typescript.schema.push((args) => { + const { jsonSchema } = args + + jsonSchema.properties.ecommerce = { + type: 'object', + properties: { + collections: { + type: 'object', + properties: { + products: { type: 'string' }, + orders: { type: 'string' }, + }, + }, + }, + } + + return jsonSchema +}) +``` + +#### Module Declaration Augmentation + +Extend Payload types for plugin-specific field properties: + +```ts +// In plugin types file +declare module 'payload' { + export interface FieldCustom { + 'plugin-import-export'?: { + disabled?: boolean + toCSV?: (value: any) => string + fromCSV?: (value: string) => any + } + } +} + +// Usage with TypeScript support +{ + name: 'price', + type: 'number', + custom: { + 'plugin-import-export': { + toCSV: (value) => `$${value.toFixed(2)}`, + fromCSV: (value) => parseFloat(value.replace('$', '')), + }, + }, +} +``` + +### Advanced Hooks + +#### Global Error Hooks + +Add global error handling: + +```ts +return { + ...config, + hooks: { + afterError: [ + ...(config.hooks?.afterError ?? []), + async (args) => { + const { error } = args + const status = (error as APIError).status ?? 500 + + if (status >= 500 || captureErrors.includes(status)) { + captureException(error, { + tags: { + collection: args.collection?.slug, + operation: args.operation, + }, + user: args.req?.user ? { id: args.req.user.id } : undefined, + }) + } + }, + ], + }, +} +``` + +#### Multiple Hook Types on Same Collection + +Coordinate multiple lifecycle hooks together for complex workflows (e.g., validation → sync → cache → cleanup): + +```ts +collection.hooks = { + ...collection.hooks, + + beforeValidate: [ + ...(collection.hooks?.beforeValidate || []), + async ({ data }) => { + // Normalize before validation + return data + }, + ], + + beforeChange: [ + ...(collection.hooks?.beforeChange || []), + async ({ data, operation }) => { + // Sync to external service + if (operation === 'create') { + data.externalId = await externalService.create(data) + } + return data + }, + ], + + afterChange: [ + ...(collection.hooks?.afterChange || []), + async ({ doc }) => { + // Invalidate cache + await cache.invalidate(`doc:${doc.id}`) + }, + ], + + afterDelete: [ + ...(collection.hooks?.afterDelete || []), + async ({ doc }) => { + // Cleanup external resources + await externalService.delete(doc.externalId) + }, + ], +} +``` + +### Access Control & Filtering + +#### Access Control Wrapper Pattern + +Wrap existing access control with plugin-specific logic: + +```ts +// From plugin-multi-tenant +export const multiTenantPlugin = + (pluginOptions: PluginOptions) => + (config: Config): Config => ({ + ...config, + collections: (config.collections || []).map((collection) => { + if (!pluginOptions.collections.includes(collection.slug)) { + return collection + } + + return { + ...collection, + access: { + ...collection.access, + read: ({ req }) => { + // Inject tenant filter + return { + and: [ + collection.access?.read ? collection.access.read({ req }) : {}, + { tenant: { equals: req.user?.tenant } }, + ], + } + }, + }, + } + }), + }) +``` + +#### BaseFilter Composition + +Combine plugin filters with existing baseListFilter: + +```ts +// From plugin-multi-tenant +const existingBaseFilter = collection.admin?.baseListFilter +const tenantFilter = { tenant: { equals: req.user?.tenant } } + +collection.admin = { + ...collection.admin, + baseListFilter: existingBaseFilter ? { and: [existingBaseFilter, tenantFilter] } : tenantFilter, +} +``` + +#### Relationship FilterOptions Modification + +Add filters to relationship field options: + +```ts +// From plugin-multi-tenant +collection.fields = collection.fields.map((field) => { + if (field.type === 'relationship') { + return { + ...field, + filterOptions: ({ relationTo }) => { + return { + and: [field.filterOptions?.(relationTo) || {}, { tenant: { equals: req.user?.tenant } }], + } + }, + } + } + return field +}) +``` + +### Admin UI Customization + +#### Metadata Storage Pattern + +Use admin.meta for plugin-specific UI state without database fields: + +```ts +// From plugin-nested-docs +export const nestedDocsPlugin = + (pluginOptions: PluginOptions) => + (config: Config): Config => ({ + ...config, + collections: config.collections?.map((collection) => ({ + ...collection, + admin: { + ...collection.admin, + meta: { + ...collection.admin?.meta, + nestedDocs: { + breadcrumbsFieldSlug: pluginOptions.breadcrumbsFieldSlug || 'breadcrumbs', + parentFieldSlug: pluginOptions.parentFieldSlug || 'parent', + }, + }, + }, + })), + }) +``` + +#### Conditional Component Rendering + +Add components based on plugin configuration: + +```ts +// From plugin-seo +const beforeFields = collection.admin?.components?.beforeFields || [] + +if (pluginOptions.uploadsCollection === collection.slug) { + beforeFields.push('/path/to/ImagePreview#ImagePreview') +} + +collection.admin = { + ...collection.admin, + components: { + ...collection.admin?.components, + beforeFields, + }, +} +``` + +#### Custom Provider Pattern + +Inject context providers for shared state: + +```ts +// From plugin-nested-docs +collection.admin = { + ...collection.admin, + components: { + ...collection.admin?.components, + providers: [ + ...(collection.admin?.components?.providers || []), + '/components/NestedDocsProvider#NestedDocsProvider', + ], + }, +} +``` + +#### Custom Actions + +Add collection-level action buttons: + +```ts +// From plugin-import-export +collection.admin = { + ...collection.admin, + components: { + ...collection.admin?.components, + actions: [ + ...(collection.admin?.components?.actions || []), + '/components/ImportButton#ImportButton', + '/components/ExportButton#ExportButton', + ], + }, +} +``` + +#### Custom List Item Views + +Modify how items appear in collection lists: + +```ts +// From plugin-ecommerce +collection.admin = { + ...collection.admin, + components: { + ...collection.admin?.components, + views: { + ...collection.admin?.components?.views, + list: { + ...collection.admin?.components?.views?.list, + Component: '/views/ProductList#ProductList', + }, + }, + }, +} +``` + +#### Custom Collection Endpoints + +Add collection-scoped endpoints (accessible at `/api//`): + +```ts +// From plugin-import-export +collection.endpoints = [ + ...(collection.endpoints || []), + { + path: '/import', + method: 'post', + handler: async (req) => { + // Import logic accessible at /api/posts/import + return Response.json({ success: true }) + }, + }, + { + path: '/export', + method: 'get', + handler: async (req) => { + // Export logic accessible at /api/posts/export + return Response.json({ data: exportedData }) + }, + }, +] +``` + +### Field & Collection Modifications + +#### Admin Folders Override + +Control admin UI organization: + +```ts +// From plugin-redirects +collection.admin = { + ...collection.admin, + group: pluginOptions.group || 'Settings', + hidden: pluginOptions.hidden, + defaultColumns: pluginOptions.defaultColumns || ['from', 'to', 'updatedAt'], +} +``` + +### Background Jobs & Async Operations + +#### Jobs Registration + +Register plugin background tasks: + +```ts +// From plugin-stripe +export const stripePlugin = + (pluginOptions: PluginOptions) => + (config: Config): Config => ({ + ...config, + jobs: { + ...config.jobs, + tasks: [ + ...(config.jobs?.tasks || []), + { + slug: 'syncStripeProducts', + handler: async ({ req }) => { + const products = await stripe.products.list() + // Sync to Payload + return { output: { synced: products.data.length } } + }, + }, + ], + }, + }) +``` + +## Testing Plugins + +### Local Development with dev/ Directory (optional) + +Include a `dev/` directory with a complete Payload project for local development: + +1. Create `dev/.env` from `.env.example`: + +```bash +DATABASE_URI=mongodb://127.0.0.1/plugin-dev +PAYLOAD_SECRET=your-secret-here +``` + +2. Configure `dev/payload.config.ts`: + +```ts +import { buildConfig } from 'payload' +import { mongooseAdapter } from '@payloadcms/db-mongodb' +import { myPlugin } from '../src/index.js' + +export default buildConfig({ + secret: process.env.PAYLOAD_SECRET!, + db: mongooseAdapter({ url: process.env.DATABASE_URI! }), + plugins: [ + myPlugin({ + collections: ['posts'], + }), + ], + collections: [ + { + slug: 'posts', + fields: [{ name: 'title', type: 'text' }], + }, + ], +}) +``` + +3. Run development server: + +```bash +npm run dev # Starts Next.js on http://localhost:3000 +``` + +### Integration Tests (Vitest) (optional) + +Create `dev/int.spec.ts`: + +```ts +import type { Payload } from 'payload' +import config from '@payload-config' +import { createPayloadRequest, getPayload } from 'payload' +import { afterAll, beforeAll, describe, expect, test } from 'vitest' +import { customEndpointHandler } from '../src/endpoints/handler.js' + +let payload: Payload + +beforeAll(async () => { + payload = await getPayload({ config }) +}) + +afterAll(async () => { + await payload.destroy() +}) + +describe('Plugin integration tests', () => { + test('should add field to collection', async () => { + const post = await payload.create({ + collection: 'posts', + data: { + title: 'Test', + addedByPlugin: 'plugin value', + }, + }) + expect(post.addedByPlugin).toBe('plugin value') + }) + + test('should create plugin collection', async () => { + expect(payload.collections['plugin-collection']).toBeDefined() + const { docs } = await payload.find({ collection: 'plugin-collection' }) + expect(docs.length).toBeGreaterThan(0) + }) + + test('should query custom endpoint', async () => { + const request = new Request('http://localhost:3000/api/my-endpoint') + const payloadRequest = await createPayloadRequest({ config, request }) + const response = await customEndpointHandler(payloadRequest) + const data = await response.json() + expect(data).toMatchObject({ message: 'Hello' }) + }) +}) +``` + +Run: `npm run test:int` + +### End-to-End Tests (Playwright) + +Create `dev/e2e.spec.ts`: + +```ts +import { test, expect } from '@playwright/test' + +test.describe('Plugin e2e tests', () => { + test('should render custom admin component', async ({ page }) => { + await page.goto('http://localhost:3000/admin') + await expect(page.getByText('Added by the plugin')).toBeVisible() + }) +}) +``` + +Run: `npm run test:e2e` + +## Common Plugin Types + +### Field Enhancer + +Adds fields to existing collections (SEO, timestamps, audit logs) + +### Collection Provider + +Adds new collections (redirects, forms, logs) + +### Hook Injector + +Adds hooks to collections (nested docs, cache invalidation) + +### UI Enhancer + +Adds custom components (dashboards, field types) + +### Integration + +Connects external services (Stripe, Sentry, storage adapters) + +### Adapter + +Provides infrastructure (database, storage, email) + +## Resources + +- [Plugin Examples](https://github.com/payloadcms/payload/tree/main/packages/) - Official plugins source code, payload-\* prefix +- [Plugin Template](https://github.com/payloadcms/payload/tree/main/templates/plugin) - Starter template for new plugins diff --git a/.claude/skills/payload-cms/reference/QUERIES.md b/.claude-plugin/commands/payload/reference/QUERIES.md similarity index 100% rename from .claude/skills/payload-cms/reference/QUERIES.md rename to .claude-plugin/commands/payload/reference/QUERIES.md diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json new file mode 100644 index 00000000000..0bc78cdba42 --- /dev/null +++ b/.claude-plugin/marketplace.json @@ -0,0 +1,13 @@ +{ + "name": "payload", + "owner": { + "name": "Payload", + "email": "info@payloadcms.com" + }, + "plugins": [ + { + "name": "payload", + "source": "./" + } + ] +} diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json new file mode 100644 index 00000000000..82b28264b51 --- /dev/null +++ b/.claude-plugin/plugin.json @@ -0,0 +1,15 @@ +{ + "name": "payload", + "version": "0.0.1", + "description": "Payload CMS 3.x development skill with TypeScript patterns, field configs, hooks, access control, and API examples", + "author": { + "name": "Payload", + "email": "info@payloadcms.com", + "url": "https://payloadcms.com" + }, + "homepage": "https://github.com/payloadcms/payload", + "repository": "https://github.com/payloadcms/payload", + "license": "MIT", + "keywords": ["payload", "cms", "payload-cms", "nextjs", "typescript", "headless-cms"], + "commands": "./commands" +}