Files
agentic-dev/packages/cms-core/AGENTS.md

324 lines
8.6 KiB
Markdown

# @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`