# @repo/cms-core -- ALL Payload CMS Configuration ## Purpose This package contains **all** Payload CMS configuration: `payload.config.ts`, collections, globals, hooks, and access control. The `apps/cms` application is a thin shell that imports from this package -- it contains no CMS logic of its own. ## Hard Rules - **ALL** collections, globals, hooks, and access patterns live here, **never** in `apps/cms` - Can import from `@repo/core/application` (for use case delegation in hooks) - **NEVER** import from `@repo/core/infrastructure` - **NEVER** import from any `apps/*` package - Keep hooks thin (5-10 lines max) -- delegate to use cases for business logic ## File Structure ``` packages/cms-core/ src/ payload.config.ts # Main Payload config (db, editor, collections, globals) collections/ users/ index.ts # Users CollectionConfig (auth: true) articles/ index.ts # Articles CollectionConfig (versions, hooks) fields.ts # Article field definitions hooks/ before-change.ts # Auto-generate slug from title media/ index.ts # Media CollectionConfig (upload) globals/ site-settings.ts # SiteSettings GlobalConfig index.ts # Package entry: exports all configs package.json AGENTS.md ``` ## Existing Collections ### Users (auth collection) - **Slug:** `users` - **Auth:** `true` (provides login, password hashing, session management) - **Fields:** `displayName` (text), `role` (select: admin/editor/author, default: author) - **Admin title:** email ### Articles (content with versioning) - **Slug:** `articles` - **Versions:** `{ drafts: true }` -- enables draft/published workflow - **Hooks:** `beforeChange: [autoGenerateSlug]` -- generates slug from title if slug is empty - **Fields:** title (text, required), slug (text, unique, sidebar), content (richText), status (select: draft/published, default: draft), author (relationship to users, required), featuredImage (upload to media), publishedAt (date) - **Admin title:** title, default columns: title, status, author, updatedAt ### Media (file uploads) - **Slug:** `media` - **Upload:** Accepts `image/*` and `application/pdf` - **Fields:** `alt` (text, required) - **Admin title:** filename ## Existing Globals ### SiteSettings - **Slug:** `site-settings` - **Admin group:** Settings - **Fields:** `siteName` (text, required, default: "My App"), `siteDescription` (textarea) ## Hook Rules | Category | Where it lives | Description | Examples | |---|---|---|---| | CMS-operational | Stays in the hook file | Logic tied to CMS data shaping, not business rules | Slug generation, image resizing, setting default values, formatting fields | | Business logic | Delegated to `@repo/core` use case | Logic that enforces a business rule or triggers side effects | Sending notifications, validating against external data, cross-domain updates | ### DO - Keep hooks to 5-10 lines max - Import use cases from `@repo/core/application` (application layer only) - Map Payload hook arguments (`data`, `operation`, `req`) to use case input types - Return `data` from `beforeChange` / `beforeValidate` hooks ### DON'T - Import from `@repo/core/infrastructure` -- violates Clean Architecture - Put business validation logic directly in hooks - Call external services (email, analytics, APIs) directly from hooks - Duplicate logic that already exists in a use case - Access `req.payload` for cross-collection operations (delegate to use case instead) **Rule of thumb:** If deleting the hook would break a business requirement, the logic must live in a use case in `@repo/core`. ## Recipe: Adding a New Collection This example adds a `Tags` collection. ### Step 1: Create the collection folder and fields Create `src/collections/tags/fields.ts`: ```typescript import type { Field } from "payload"; export const tagFields: Field[] = [ { name: "name", type: "text", required: true, unique: true, maxLength: 100, }, { name: "slug", type: "text", unique: true, admin: { position: "sidebar", description: "Auto-generated from name if left empty", }, }, { name: "description", type: "textarea", }, ]; ``` ### Step 2: Create the CollectionConfig Create `src/collections/tags/index.ts`: ```typescript import type { CollectionConfig } from "payload"; import { tagFields } from "./fields"; import { autoGenerateSlug } from "./hooks/before-change"; export const Tags: CollectionConfig = { slug: "tags", admin: { useAsTitle: "name", defaultColumns: ["name", "slug", "updatedAt"], }, hooks: { beforeChange: [autoGenerateSlug], }, fields: tagFields, }; ``` ### Step 3: Add a CMS-operational hook (slug generation) Create `src/collections/tags/hooks/before-change.ts`: ```typescript import type { CollectionBeforeChangeHook } from "payload"; function generateSlug(text: string): string { return text .toLowerCase() .replace(/[^a-z0-9]+/g, "-") .replace(/^-|-$/g, ""); } export const autoGenerateSlug: CollectionBeforeChangeHook = ({ data, operation, }) => { if (operation === "create" || operation === "update") { if (data && data.name && !data.slug) { data.slug = generateSlug(data.name); } } return data; }; ``` ### Step 4: (Optional) Add a business-logic hook delegating to a use case Create `src/collections/tags/hooks/after-change.ts`: ```typescript import type { CollectionAfterChangeHook } from "payload"; import { syncTagToSearchIndex } from "@repo/core/application"; export const syncTagAfterChange: CollectionAfterChangeHook = async ({ doc, operation, }) => { // Delegate to use case -- this hook is just a thin bridge await syncTagToSearchIndex({ id: doc.id, name: doc.name, slug: doc.slug, operation, }); return doc; }; ``` ### Step 5: (Optional) Add access control Create `src/collections/tags/access/is-admin.ts`: ```typescript import type { Access } from "payload"; export const isAdmin: Access = ({ req: { user } }) => { return user?.role === "admin"; }; ``` Then reference it in the CollectionConfig: ```typescript export const Tags: CollectionConfig = { slug: "tags", access: { create: isAdmin, update: isAdmin, delete: isAdmin, // read is open by default }, // ...rest }; ``` ### Step 6: Register in payload.config.ts Edit `src/payload.config.ts`: ```typescript import { Tags } from "./collections/tags"; export default buildConfig({ collections: [Users, Articles, Media, Tags], // <-- add Tags // ...rest }); ``` ### Step 7: Export from package entry Edit `src/index.ts`: ```typescript export { Tags } from "./collections/tags"; // <-- add export ``` ## Recipe: Adding a New Global This example adds a `Navigation` global. ### Step 1: Create the GlobalConfig Create `src/globals/navigation.ts`: ```typescript import type { GlobalConfig } from "payload"; export const Navigation: GlobalConfig = { slug: "navigation", admin: { group: "Settings", }, fields: [ { name: "mainMenu", type: "array", fields: [ { name: "label", type: "text", required: true, }, { name: "url", type: "text", required: true, }, ], }, ], }; ``` ### Step 2: Register in payload.config.ts ```typescript import { Navigation } from "./globals/navigation"; export default buildConfig({ globals: [SiteSettings, Navigation], // <-- add Navigation // ...rest }); ``` ### Step 3: Export from package entry ```typescript export { Navigation } from "./globals/navigation"; // <-- add export ``` ## Payload Config Overview The `payload.config.ts` uses: - **Database:** `@payloadcms/db-postgres` (PostgreSQL via `DATABASE_URL` env var) - **Editor:** `@payloadcms/richtext-lexical` (Lexical rich text editor) - **Secret:** `PAYLOAD_SECRET` env var (required for production) - **TypeScript output:** Generates `payload-types.ts` in this package's `src/` directory ## Dependencies | Dependency | Purpose | |---|---| | `payload` | Payload CMS core | | `@payloadcms/db-postgres` | PostgreSQL database adapter | | `@payloadcms/richtext-lexical` | Lexical rich text editor | ## Cross-References - **CMS app shell:** `apps/cms/` -- see `apps/cms/AGENTS.md` - **Use cases for hooks:** `packages/core/src/application/use-cases/` -- see `packages/core/AGENTS.md` - **CMS client for querying:** `packages/cms-client/` -- see `packages/cms-client/AGENTS.md`