docs(agents): rewrite root AGENTS.md for vertical feature architecture
This commit is contained in:
688
AGENTS.md
688
AGENTS.md
@@ -1,555 +1,211 @@
|
||||
# AGENTS.md -- Root Monorepo
|
||||
# AGENTS.md — Vertical Feature Monorepo
|
||||
|
||||
This is a Turborepo + pnpm monorepo implementing Clean Architecture (Uncle Bob / Lazar Nikolov). It supports Next.js 15 and TanStack Start as frontend frameworks, Payload CMS v3 for content management, and tRPC v11 for type-safe API communication.
|
||||
This is a **Turborepo + pnpm monorepo** organized by vertical features. Each feature package owns its own Clean Architecture layers (entities, application, infrastructure, interface-adapters) and integrations (CMS collections, tRPC routers, UI components). Core packages provide foundation: primitives, design system, CMS composition, API aggregation, and tRPC client platform.
|
||||
|
||||
---
|
||||
|
||||
## Monorepo Package Map
|
||||
## Package Map
|
||||
|
||||
| Package | Path | Purpose | Depends On |
|
||||
|---|---|---|---|
|
||||
| `@repo/core` | `packages/core` | Clean Architecture business logic: entities, use cases, repository/service interfaces, controllers, InversifyJS DI container | `zod`, `inversify`, `reflect-metadata` |
|
||||
| `@repo/api` | `packages/api` | tRPC v11 routers that call `@repo/core` controllers | `@repo/core`, `@trpc/server`, `zod` |
|
||||
| `@repo/api-client` | `packages/api-client` | Shared React Query hooks, `ApiProvider`, and `useTRPC` for frontend apps | `@repo/api`, `@trpc/client`, `@trpc/tanstack-react-query`, `@tanstack/react-query` |
|
||||
| `@repo/cms-core` | `packages/cms-core` | Payload CMS config, collections (Users, Articles, Media), globals (SiteSettings), hooks | `payload`, `@payloadcms/db-postgres`, `@payloadcms/richtext-lexical` |
|
||||
| `@repo/cms-client` | `packages/cms-client` | Dual-mode Payload client (local via Payload instance, HTTP via REST API). STANDALONE -- no monorepo deps | `payload` (types only) |
|
||||
| `@repo/ui` | `packages/ui` | Atomic Design component library (atoms/molecules/organisms/templates) built with shadcn/ui patterns + Tailwind v4 | `clsx`, `tailwind-merge`, `react` |
|
||||
| `@repo/eslint-config` | `packages/eslint-config` | Shared ESLint 9 flat configs for the entire monorepo | (tooling) |
|
||||
| `@repo/typescript-config` | `packages/typescript-config` | Shared `tsconfig` base configs (`base.json`) with `experimentalDecorators` + `emitDecoratorMetadata` | (tooling) |
|
||||
| `@repo/web-next` | `apps/web-next` | Next.js 15 App Router frontend (port 3000) | `@repo/api`, `@repo/api-client`, `@repo/ui` |
|
||||
| `@repo/web-tanstack` | `apps/web-tanstack` | TanStack Start frontend (port 3002) | `@repo/api`, `@repo/api-client`, `@repo/ui` |
|
||||
| `@repo/cms` | `apps/cms` | Thin Next.js shell hosting the Payload Admin UI (port 3001) | `@repo/cms-core`, `@payloadcms/next`, `@payloadcms/ui` |
|
||||
| `@repo/storybook` | `apps/storybook` | Storybook 8 for `@repo/ui` components (port 6006) | `@repo/ui` |
|
||||
|
||||
---
|
||||
|
||||
## Dependency Flow Diagram
|
||||
|
||||
```
|
||||
+-----------------+ +-----------------+
|
||||
| apps/web-next | | apps/web-tanstack|
|
||||
+--------+--------+ +--------+--------+
|
||||
| |
|
||||
+---------+-----------+-----------+
|
||||
| |
|
||||
+-----v------+ +------v-------+
|
||||
| @repo/api- | | @repo/ui |
|
||||
| client | | (Atomic |
|
||||
+-----+------+ | Design) |
|
||||
| +--------------+
|
||||
+-----v------+
|
||||
| @repo/api | +----------+ +------------+
|
||||
| (tRPC v11) | | apps/cms | | apps/ |
|
||||
+-----+------+ +----+-----+ | storybook |
|
||||
| | +------+-----+
|
||||
+-----v------+ +------v-------+ |
|
||||
| @repo/core | | @repo/ | +-----v------+
|
||||
| (Clean | | cms-core | | @repo/ui |
|
||||
| Arch) | | (Payload | +-----------+
|
||||
+-----+------+ | collections) |
|
||||
| +--------------+
|
||||
|
|
||||
+-----v----------+
|
||||
| @repo/ |
|
||||
| cms-client |
|
||||
| (optional, |
|
||||
| standalone) |
|
||||
+----------------+
|
||||
|
||||
@repo/eslint-config ---------> used by all packages (devDependency)
|
||||
@repo/typescript-config -----> used by all packages (devDependency)
|
||||
```
|
||||
|
||||
Key rule: arrows point DOWN. A package may only depend on packages below it in this diagram. Apps sit at the top; `@repo/core` and `@repo/cms-client` sit at the bottom.
|
||||
|
||||
---
|
||||
|
||||
## Complete Data Flow
|
||||
|
||||
Every user interaction follows this path:
|
||||
|
||||
```
|
||||
UI Component (React)
|
||||
|
|
||||
v
|
||||
useTRPC().content.listArticles.useQuery() <-- @repo/api-client hook
|
||||
|
|
||||
v
|
||||
tRPC Router Procedure (.query / .mutation) <-- @repo/api router
|
||||
|
|
||||
v
|
||||
Controller (Zod safeParse -> InputParseError) <-- @repo/core interface-adapters
|
||||
|
|
||||
v
|
||||
Use Case (business logic + getInjection()) <-- @repo/core application
|
||||
|
|
||||
v
|
||||
Repository / Service Interface <-- @repo/core application (abstract)
|
||||
|
|
||||
v
|
||||
Implementation (@injectable class) <-- @repo/core infrastructure (concrete)
|
||||
|
|
||||
v
|
||||
Data Store (Payload CMS / in-memory mock)
|
||||
```
|
||||
|
||||
Example -- listing articles end-to-end:
|
||||
|
||||
```typescript
|
||||
// 1. UI: apps/web-next -- a React Server Component or client component
|
||||
const trpc = useTRPC();
|
||||
const articles = trpc.content.listArticles.useQuery({ status: "published" });
|
||||
|
||||
// 2. tRPC router: packages/api/src/router/content.router.ts
|
||||
contentRouter = router({
|
||||
listArticles: publicProcedure
|
||||
.input(z.object({ status: z.string().optional(), /* ... */ }).optional())
|
||||
.query(async ({ input }) => {
|
||||
return await getArticlesController(input ?? {});
|
||||
}),
|
||||
});
|
||||
|
||||
// 3. Controller: packages/core/src/interface-adapters/controllers/content/articles.controller.ts
|
||||
export async function getArticlesController(input) {
|
||||
const { data, error } = getInputSchema.safeParse(input);
|
||||
if (error) throw new InputParseError("Invalid data", { cause: error });
|
||||
return await getArticlesUseCase(data);
|
||||
}
|
||||
|
||||
// 4. Use Case: packages/core/src/application/use-cases/content/get-articles.use-case.ts
|
||||
export async function getArticlesUseCase(options) {
|
||||
const articlesRepository = getInjection("IArticlesRepository");
|
||||
return await articlesRepository.getArticles(options);
|
||||
}
|
||||
|
||||
// 5. Repository: resolved at runtime via InversifyJS DI container
|
||||
// Mock: packages/core/src/infrastructure/repositories/mock-articles.repository.ts
|
||||
// Production: a PayloadArticlesRepository using @repo/cms-client (future)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Hard Rules
|
||||
|
||||
| # | Rule | Reason |
|
||||
| Package | Tag | Purpose |
|
||||
|---|---|---|
|
||||
| 1 | `@repo/core` NEVER imports from apps/* or framework packages (Next.js, TanStack) | Core business logic must be framework-agnostic. It must be portable across any UI framework or transport layer. |
|
||||
| 2 | `@repo/cms-core` NEVER imports from `@repo/core` or `@repo/infrastructure` | CMS config is Payload-native. The bridge between Payload and Clean Architecture is `@repo/cms-client`, consumed in `@repo/core`'s infrastructure layer. |
|
||||
| 3 | `@repo/cms-client` NEVER imports from any other `@repo/*` package | `cms-client` is standalone. It defines a `PayloadClient` interface with `local` and `http` modes. It has zero monorepo dependencies so it can be used anywhere. |
|
||||
| 4 | `@repo/core`'s `entities/` layer NEVER imports from `application/`, `infrastructure/`, `interface-adapters/`, or `di/` | Entities are the innermost layer of Clean Architecture. They define pure domain types and errors with zero dependencies. |
|
||||
| 5 | `@repo/core`'s `application/` layer NEVER imports from `infrastructure/` | Use cases and interfaces depend on abstractions (interfaces), never on concrete implementations. The DI container resolves implementations at runtime. |
|
||||
| 6 | `@repo/core`'s `interface-adapters/` layer NEVER imports from `infrastructure/` | Controllers validate input and delegate to use cases. They must not know about concrete data access or external services. |
|
||||
| `@repo/core-shared` | core | Generic primitives (Zod, env, Payload hooks/fields/blocks, tRPC init/context) |
|
||||
| `@repo/core-cms` | core (composition) | Payload config aggregator — imports `@repo/<feature>/cms` only |
|
||||
| `@repo/core-api` | core (composition) | tRPC router aggregator — imports `@repo/<feature>/api` only |
|
||||
| `@repo/core-trpc` | core | Frontend tRPC client + framework-specific providers (Next.js, TanStack) |
|
||||
| `@repo/core-ui` | core | Design system (atoms, molecules, generic organisms, templates) |
|
||||
| `@repo/auth` | feature | Users collection + sign-in/up/out |
|
||||
| `@repo/blog` | feature | Articles collection + article use-cases |
|
||||
| `@repo/media` | feature | Media collection + upload helpers |
|
||||
| `@repo/marketing-pages` | feature | Pages collection + SiteSettings global |
|
||||
| `@repo/navigation` | feature | Header global |
|
||||
| `@repo/eslint-config` | tooling | Shared ESLint 9 flat configs (base, next, react-internal, boundaries) |
|
||||
| `@repo/typescript-config` | tooling | Shared TypeScript base configs + Vitest base |
|
||||
|
||||
---
|
||||
|
||||
## How to Add a New Feature (End-to-End Recipe)
|
||||
## Boundary Rules
|
||||
|
||||
This recipe walks through adding a "comments" feature. Follow every step in order.
|
||||
### Three tags
|
||||
|
||||
### Step 1: Define the Entity (packages/core/src/entities/models/comment.ts)
|
||||
- **app** — `apps/web-next`, `apps/web-tanstack`, `apps/cms`
|
||||
- **feature** — `packages/auth`, `blog`, `media`, `marketing-pages`, `navigation`
|
||||
- **core** — `packages/core-shared`, `core-cms`, `core-api`, `core-trpc`, `core-ui`
|
||||
- (untagged) — `packages/eslint-config`, `typescript-config`
|
||||
|
||||
```typescript
|
||||
import { z } from "zod";
|
||||
### Allowed dependency directions
|
||||
|
||||
export const commentSchema = z.object({
|
||||
id: z.string(),
|
||||
articleId: z.string(),
|
||||
authorId: z.string(),
|
||||
body: z.string().min(1).max(2000),
|
||||
createdAt: z.date(),
|
||||
});
|
||||
|
||||
export type Comment = z.infer<typeof commentSchema>;
|
||||
```
|
||||
app → feature, core
|
||||
feature → core
|
||||
core → core (restricted; see exceptions below)
|
||||
```
|
||||
|
||||
Export it from `packages/core/src/entities/models/index.ts`:
|
||||
**Disallowed:** `core → feature`, `core → app`, `feature → app`, `feature → feature`.
|
||||
|
||||
```typescript
|
||||
export { commentSchema, type Comment } from "./comment";
|
||||
```
|
||||
### Composition exceptions
|
||||
|
||||
The barrel export chain is: `models/index.ts` -> `entities/index.ts` -> `core/src/index.ts`. Only `models/index.ts` needs updating; the other two already re-export with `*`.
|
||||
Two packages may cross normal boundaries:
|
||||
|
||||
### Step 2: Define the Repository Interface (packages/core/src/application/repositories/comments.repository.interface.ts)
|
||||
1. **`core-cms`** may import `@repo/<feature>/cms` subpath exports only (to compose Payload collections).
|
||||
2. **`core-api`** may import `@repo/<feature>/api` subpath exports only (to compose tRPC routers).
|
||||
|
||||
```typescript
|
||||
import type { Comment } from "@/entities/models/comment";
|
||||
No other cross-package boundary deviations are permitted.
|
||||
|
||||
export interface ICommentsRepository {
|
||||
getComment(id: string): Promise<Comment | undefined>;
|
||||
getComments(options?: {
|
||||
articleId?: string;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
}): Promise<Comment[]>;
|
||||
createComment(input: Comment): Promise<Comment>;
|
||||
}
|
||||
```
|
||||
### Three enforcement layers
|
||||
|
||||
Export from `packages/core/src/application/repositories/index.ts`:
|
||||
|
||||
```typescript
|
||||
export type { ICommentsRepository } from "./comments.repository.interface";
|
||||
```
|
||||
|
||||
### Step 3: Create Mock Implementation (packages/core/src/infrastructure/repositories/mock-comments.repository.ts)
|
||||
|
||||
```typescript
|
||||
import { injectable } from "inversify";
|
||||
|
||||
import type { ICommentsRepository } from "@/application/repositories/comments.repository.interface";
|
||||
import type { Comment } from "@/entities/models/comment";
|
||||
|
||||
@injectable()
|
||||
export class MockCommentsRepository implements ICommentsRepository {
|
||||
private _comments: Comment[] = [];
|
||||
|
||||
async getComment(id: string): Promise<Comment | undefined> {
|
||||
return this._comments.find((c) => c.id === id);
|
||||
}
|
||||
|
||||
async getComments(options?: {
|
||||
articleId?: string;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
}): Promise<Comment[]> {
|
||||
let result = [...this._comments];
|
||||
if (options?.articleId) {
|
||||
result = result.filter((c) => c.articleId === options.articleId);
|
||||
}
|
||||
const offset = options?.offset ?? 0;
|
||||
const limit = options?.limit ?? 50;
|
||||
return result.slice(offset, offset + limit);
|
||||
}
|
||||
|
||||
async createComment(input: Comment): Promise<Comment> {
|
||||
this._comments.push(input);
|
||||
return input;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Critical: the `@injectable()` decorator is required for InversifyJS. Without it, the container cannot resolve this class.
|
||||
|
||||
### Step 4: Register in DI (packages/core/src/di/)
|
||||
|
||||
**4a. Add symbol to `types.ts`:**
|
||||
|
||||
```typescript
|
||||
import type { ICommentsRepository } from "@/application/repositories/comments.repository.interface";
|
||||
|
||||
// Add to DI_SYMBOLS:
|
||||
export const DI_SYMBOLS = {
|
||||
// ...existing...
|
||||
ICommentsRepository: Symbol.for("ICommentsRepository"),
|
||||
};
|
||||
|
||||
// Add to DI_RETURN_TYPES:
|
||||
export interface DI_RETURN_TYPES {
|
||||
// ...existing...
|
||||
ICommentsRepository: ICommentsRepository;
|
||||
}
|
||||
```
|
||||
|
||||
**4b. Create module `modules/comments.module.ts`:**
|
||||
|
||||
```typescript
|
||||
import { ContainerModule, interfaces } from "inversify";
|
||||
|
||||
import type { ICommentsRepository } from "@/application/repositories/comments.repository.interface";
|
||||
import { MockCommentsRepository } from "@/infrastructure/repositories/mock-comments.repository";
|
||||
import { DI_SYMBOLS } from "../types";
|
||||
|
||||
const initializeModule = (bind: interfaces.Bind) => {
|
||||
bind<ICommentsRepository>(DI_SYMBOLS.ICommentsRepository).to(
|
||||
MockCommentsRepository
|
||||
);
|
||||
};
|
||||
|
||||
export const CommentsModule = new ContainerModule(initializeModule);
|
||||
```
|
||||
|
||||
**4c. Load module in `container.ts`:**
|
||||
|
||||
```typescript
|
||||
import { CommentsModule } from "./modules/comments.module";
|
||||
|
||||
export const initializeContainer = () => {
|
||||
ApplicationContainer.load(AuthModule);
|
||||
ApplicationContainer.load(ContentModule);
|
||||
ApplicationContainer.load(CommentsModule); // <-- add
|
||||
};
|
||||
|
||||
export const destroyContainer = () => {
|
||||
ApplicationContainer.unload(AuthModule);
|
||||
ApplicationContainer.unload(ContentModule);
|
||||
ApplicationContainer.unload(CommentsModule); // <-- add
|
||||
};
|
||||
```
|
||||
|
||||
### Step 5: Create Use Case (packages/core/src/application/use-cases/content/create-comment.use-case.ts)
|
||||
|
||||
```typescript
|
||||
import type { Comment } from "@/entities/models/comment";
|
||||
import { getInjection } from "@/di/container";
|
||||
|
||||
export async function createCommentUseCase(input: {
|
||||
articleId: string;
|
||||
authorId: string;
|
||||
body: string;
|
||||
}): Promise<Comment> {
|
||||
const commentsRepository = getInjection("ICommentsRepository");
|
||||
|
||||
const now = new Date();
|
||||
const comment: Comment = {
|
||||
id: crypto.randomUUID(),
|
||||
articleId: input.articleId,
|
||||
authorId: input.authorId,
|
||||
body: input.body,
|
||||
createdAt: now,
|
||||
};
|
||||
|
||||
return await commentsRepository.createComment(comment);
|
||||
}
|
||||
```
|
||||
|
||||
### Step 6: Create Controller (packages/core/src/interface-adapters/controllers/content/comments.controller.ts)
|
||||
|
||||
```typescript
|
||||
import { z } from "zod";
|
||||
|
||||
import { InputParseError } from "@/entities/errors/common";
|
||||
import type { Comment } from "@/entities/models/comment";
|
||||
import { createCommentUseCase } from "@/application/use-cases/content/create-comment.use-case";
|
||||
|
||||
const createInputSchema = z.object({
|
||||
articleId: z.string(),
|
||||
authorId: z.string(),
|
||||
body: z.string().min(1).max(2000),
|
||||
});
|
||||
|
||||
export async function createCommentController(
|
||||
input: Partial<z.infer<typeof createInputSchema>>
|
||||
): Promise<Comment> {
|
||||
const { data, error: inputParseError } = createInputSchema.safeParse(input);
|
||||
|
||||
if (inputParseError) {
|
||||
throw new InputParseError("Invalid data", { cause: inputParseError });
|
||||
}
|
||||
|
||||
return await createCommentUseCase(data);
|
||||
}
|
||||
```
|
||||
|
||||
### Step 7: Export from core (packages/core/src/index.ts)
|
||||
|
||||
```typescript
|
||||
export { createCommentController } from "./interface-adapters/controllers/content/comments.controller";
|
||||
export { createCommentUseCase } from "./application/use-cases/content/create-comment.use-case";
|
||||
```
|
||||
|
||||
### Step 8: Create tRPC Router Procedure (packages/api/src/router/content.router.ts)
|
||||
|
||||
Add to the existing content router:
|
||||
|
||||
```typescript
|
||||
import { createCommentController } from "@repo/core";
|
||||
|
||||
// Inside contentRouter:
|
||||
createComment: publicProcedure
|
||||
.input(
|
||||
z.object({
|
||||
articleId: z.string(),
|
||||
authorId: z.string(),
|
||||
body: z.string().min(1).max(2000),
|
||||
})
|
||||
)
|
||||
.mutation(async ({ input }) => {
|
||||
return await createCommentController(input);
|
||||
}),
|
||||
```
|
||||
|
||||
### Step 9: Use from UI (apps/web-next)
|
||||
|
||||
```typescript
|
||||
"use client";
|
||||
import { useTRPC } from "@repo/api-client";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
|
||||
export function AddCommentForm({ articleId }: { articleId: string }) {
|
||||
const trpc = useTRPC();
|
||||
const mutation = trpc.content.createComment.useMutation();
|
||||
|
||||
const handleSubmit = (body: string) => {
|
||||
mutation.mutate({ articleId, authorId: "current-user-id", body });
|
||||
};
|
||||
|
||||
// ... render form using @repo/ui components
|
||||
}
|
||||
```
|
||||
|
||||
### Step 10: Add Payload Collection (optional -- packages/cms-core)
|
||||
|
||||
If the data is CMS-managed, create a collection. See "How to Add a Payload Collection" below.
|
||||
1. **`package.json` dependencies** — only allowed deps are declared; illegal imports fail at install time.
|
||||
2. **`exports` maps** — feature packages expose `.`, `./cms`, `./api`, `./di/bind-production` only; no deep source paths exist.
|
||||
3. **ESLint `eslint-plugin-boundaries`** — configured in `packages/eslint-config/`:
|
||||
- Feature packages may import from `core-*` and tooling only.
|
||||
- `core-shared`, `core-trpc`, `core-ui` may not import any feature.
|
||||
- `core-api` restricted to `@repo/<feature>/api` imports.
|
||||
- `core-cms` restricted to `@repo/<feature>/cms` imports.
|
||||
- No `../../../` cross-package relative imports.
|
||||
|
||||
---
|
||||
|
||||
## How to Add a UI Component
|
||||
## Adding a Feature
|
||||
|
||||
### Classification Guide
|
||||
|
||||
Components live in `packages/ui/src/` organized by Atomic Design:
|
||||
|
||||
| Level | Directory | Description | Examples |
|
||||
|---|---|---|---|
|
||||
| **Atoms** | `src/atoms/{name}/` | Smallest building blocks. Single HTML element wrappers. No composition of other atoms. | `Button`, `Input`, `Label` |
|
||||
| **Molecules** | `src/molecules/{name}/` | Combine 2+ atoms into a reusable unit. | `FormField` (Label + Input + error text) |
|
||||
| **Organisms** | `src/organisms/{name}/` | Complex UI sections combining molecules/atoms. May contain local state. | `LoginForm`, `ArticleCard` |
|
||||
| **Templates** | `src/templates/{name}/` | Page-level layout structures with slots for organisms/molecules. No data fetching. | `DashboardLayout`, `AuthLayout` |
|
||||
|
||||
### File Structure for Each Component
|
||||
|
||||
```
|
||||
src/atoms/my-component/
|
||||
my-component.tsx # Component implementation
|
||||
my-component.stories.tsx # Storybook story
|
||||
index.ts # Barrel export
|
||||
```
|
||||
|
||||
### Import Rules
|
||||
|
||||
| From | Can Import | NEVER Import |
|
||||
|---|---|---|
|
||||
| Atoms | `lib/utils` only | Other atoms, molecules, organisms, templates |
|
||||
| Molecules | Atoms, `lib/utils` | Other molecules, organisms, templates |
|
||||
| Organisms | Atoms, Molecules, `lib/utils` | Other organisms, templates |
|
||||
| Templates | Atoms, Molecules, Organisms, `lib/utils` | Other templates |
|
||||
| Apps | Any `@repo/ui` export | Internal `@repo/ui` paths (always use package export) |
|
||||
|
||||
### Export Chain
|
||||
|
||||
1. Export from `src/atoms/{name}/index.ts`
|
||||
2. Re-export from `src/atoms/index.ts`
|
||||
3. Everything flows through `src/index.ts` which re-exports all levels
|
||||
|
||||
### Component Pattern
|
||||
|
||||
Use `cn()` from `lib/utils` for className merging. Use `forwardRef` for atoms wrapping native elements:
|
||||
|
||||
```typescript
|
||||
import { forwardRef, type ButtonHTMLAttributes } from "react";
|
||||
import { cn } from "../../lib/utils";
|
||||
|
||||
export const MyButton = forwardRef<HTMLButtonElement, ButtonHTMLAttributes<HTMLButtonElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<button className={cn("base-classes", className)} ref={ref} {...props} />
|
||||
)
|
||||
);
|
||||
MyButton.displayName = "MyButton";
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## How to Add a Payload Collection
|
||||
|
||||
### Step 1: Create the collection (packages/cms-core/src/collections/{name}/index.ts)
|
||||
|
||||
```typescript
|
||||
import type { CollectionConfig } from "payload";
|
||||
import { myFields } from "./fields";
|
||||
|
||||
export const MyCollection: CollectionConfig = {
|
||||
slug: "my-collection",
|
||||
admin: {
|
||||
useAsTitle: "title",
|
||||
},
|
||||
fields: myFields,
|
||||
};
|
||||
```
|
||||
|
||||
### Step 2: Define fields (packages/cms-core/src/collections/{name}/fields.ts)
|
||||
|
||||
```typescript
|
||||
import type { Field } from "payload";
|
||||
|
||||
export const myFields: Field[] = [
|
||||
{ name: "title", type: "text", required: true },
|
||||
{ name: "content", type: "richText" },
|
||||
];
|
||||
```
|
||||
|
||||
### Step 3: Register in payload.config.ts
|
||||
|
||||
```typescript
|
||||
import { MyCollection } from "./collections/my-collection";
|
||||
|
||||
export default buildConfig({
|
||||
collections: [Users, Articles, Media, MyCollection], // add here
|
||||
// ...
|
||||
});
|
||||
```
|
||||
|
||||
### Step 4: Export from index.ts
|
||||
|
||||
```typescript
|
||||
export { MyCollection } from "./collections/my-collection";
|
||||
```
|
||||
|
||||
### Hook Rules
|
||||
|
||||
- Hooks live in `collections/{name}/hooks/` with descriptive names like `before-change.ts`
|
||||
- Hook types: `beforeChange`, `afterChange`, `beforeRead`, `afterRead`, `beforeDelete`, `afterDelete`, `beforeValidate`, `afterValidate`
|
||||
- Hooks receive `({ data, operation, req })` and must return `data` (for before hooks)
|
||||
- Hooks MUST NOT import from `@repo/core`. The CMS is independent. If you need to sync with core business logic, use the `@repo/cms-client` bridge in the infrastructure layer, not hooks calling core directly.
|
||||
- Example hook:
|
||||
|
||||
```typescript
|
||||
import type { CollectionBeforeChangeHook } from "payload";
|
||||
|
||||
export const myBeforeChangeHook: CollectionBeforeChangeHook = ({ data, operation }) => {
|
||||
if (operation === "create" && data && !data.slug) {
|
||||
data.slug = data.title.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
|
||||
}
|
||||
return data;
|
||||
};
|
||||
```
|
||||
Start with `docs/guides/adding-a-feature.md` for a step-by-step walkthrough covering:
|
||||
- New feature scaffold (folder structure, `package.json`, `tsconfig.json`, `vitest.config.ts`)
|
||||
- Clean Architecture layers (entities, use cases, repositories, DI container, controllers)
|
||||
- Payload integration (collections, hooks, Payload repository binding)
|
||||
- tRPC integration (routers, procedure binding)
|
||||
- Core wiring (`core-api`, `core-cms`, path aliases, app bootstrap)
|
||||
- Testing and lint validation
|
||||
|
||||
---
|
||||
|
||||
## Key Commands
|
||||
|
||||
```bash
|
||||
pnpm install # Install all dependencies
|
||||
pnpm dev # Start all dev servers (Next.js :3000, CMS :3001, Storybook :6006)
|
||||
pnpm build # Build all packages (via Turborepo)
|
||||
pnpm test # Run all tests (via Turborepo)
|
||||
pnpm typecheck # Type-check all packages
|
||||
pnpm lint # Lint all packages
|
||||
pnpm format # Format all files with Prettier
|
||||
pnpm format:check # Check formatting without writing
|
||||
docker compose up -d # Start PostgreSQL (required for CMS)
|
||||
pnpm install # Install all dependencies
|
||||
pnpm dev # Start all dev servers (Next.js :3000, CMS :3001, Storybook :6006)
|
||||
pnpm typecheck # Type-check all packages
|
||||
pnpm lint # Lint all packages (boundaries enforced)
|
||||
pnpm test # Run all unit + integration tests (Vitest)
|
||||
pnpm test:e2e # Run e2e tests (Playwright across both apps)
|
||||
pnpm build # Build all packages (Turborepo)
|
||||
docker compose up -d # Start PostgreSQL
|
||||
|
||||
# Filtered commands
|
||||
pnpm dev --filter @repo/web-next # Only start Next.js app
|
||||
pnpm test --filter @repo/core # Only test core package
|
||||
pnpm dev --filter @repo/storybook # Only start Storybook
|
||||
|
||||
# Core package direct commands
|
||||
cd packages/core && pnpm vitest run # Run core unit tests
|
||||
cd packages/core && pnpm vitest --ui # Run tests with UI
|
||||
pnpm dev --filter @repo/web-next # Only Next.js app
|
||||
pnpm dev --filter @repo/cms # Only CMS admin
|
||||
pnpm dev --filter @repo/storybook # Only Storybook
|
||||
pnpm typecheck --filter @repo/blog # Only blog feature
|
||||
pnpm test --filter @repo/blog # Only blog unit/integration tests
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Cross-References
|
||||
## Per-Package Conventions
|
||||
|
||||
Each package and key directory has its own `AGENTS.md` with domain-specific rules and recipes:
|
||||
### Source files use RELATIVE imports (not @/)
|
||||
|
||||
- `packages/core/AGENTS.md` -- Clean Architecture layers, import rules, DI resolution
|
||||
- `packages/core/src/entities/AGENTS.md` -- Entity models and errors
|
||||
- `packages/core/src/application/AGENTS.md` -- Use cases and interfaces
|
||||
- `packages/core/src/infrastructure/AGENTS.md` -- Concrete implementations
|
||||
- `packages/core/src/interface-adapters/controllers/AGENTS.md` -- Controllers
|
||||
- `packages/core/src/di/AGENTS.md` -- InversifyJS container configuration
|
||||
- `packages/core/src/application/use-cases/auth/AGENTS.md` -- Auth domain rules
|
||||
- `packages/core/src/application/use-cases/content/AGENTS.md` -- Content domain rules
|
||||
Inside `src/` files, import from sibling layers using relative paths:
|
||||
|
||||
```typescript
|
||||
// packages/blog/src/application/use-cases/get-article.use-case.ts
|
||||
import type { IArticlesRepository } from "../repositories/articles.repository.interface.js";
|
||||
import { ARTICLES_REPOSITORY } from "../../di/symbols.js";
|
||||
import type { Article } from "../../entities/article.js";
|
||||
```
|
||||
|
||||
This keeps source code portable and avoids circular alias issues.
|
||||
|
||||
### Test files use @/ alias
|
||||
|
||||
Test files (`*.test.ts`) use the `@/` alias to import from `src/`:
|
||||
|
||||
```typescript
|
||||
// packages/blog/src/application/use-cases/get-article.use-case.test.ts
|
||||
import { getArticleUseCase } from "@/application/use-cases/get-article.use-case.js";
|
||||
```
|
||||
|
||||
### vitest.config.ts MUST declare @/ alias
|
||||
|
||||
Every package's `vitest.config.ts` must define the alias:
|
||||
|
||||
```typescript
|
||||
import path from "path";
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
test: { environment: "node", globals: true },
|
||||
resolve: {
|
||||
alias: {
|
||||
"@": path.resolve(__dirname, "./src"),
|
||||
},
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
### tsconfig.json rootDir = "."
|
||||
|
||||
TypeScript configs must set `"rootDir": "."` to allow both `src/` and test files to coexist:
|
||||
|
||||
```json
|
||||
{
|
||||
"extends": "@repo/typescript-config/base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": ".",
|
||||
"outDir": "dist"
|
||||
},
|
||||
"include": ["src/**/*", "tests/**/*"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
```
|
||||
|
||||
### Payload-backed features use constructor injection
|
||||
|
||||
Feature packages that need Payload (e.g., `@repo/blog/infrastructure/repositories/payload-articles.repository.ts`) receive the Payload config via constructor, not via `@repo/core-cms` dependency:
|
||||
|
||||
```typescript
|
||||
export class PayloadArticlesRepository implements IArticlesRepository {
|
||||
constructor(private config: Config) {}
|
||||
|
||||
async getById(id: string): Promise<Article | null> {
|
||||
const payload = await getPayload({ config: this.config });
|
||||
return payload.findByID({ collection: "articles", id });
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The config comes from the app at boot time (see below).
|
||||
|
||||
### Apps call `bindProduction*()` per feature at boot
|
||||
|
||||
Each app (`web-next`, `web-tanstack`, `cms`) imports feature containers and binds production Payload repos at startup:
|
||||
|
||||
```typescript
|
||||
// apps/web-next/src/app/layout.tsx (Next.js)
|
||||
import { bindProductionArticles } from "@repo/blog/di/bind-production";
|
||||
import { bindProductionUsers } from "@repo/auth/di/bind-production";
|
||||
import { bindProductionMedia } from "@repo/media/di/bind-production";
|
||||
import { payloadConfig } from "@repo/core-cms";
|
||||
|
||||
// At app boot:
|
||||
await bindProductionArticles(blogContainer, payloadConfig);
|
||||
await bindProductionUsers(authContainer, payloadConfig);
|
||||
// ... etc for each feature
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Specification & Guides
|
||||
|
||||
- **Vertical Feature Spec** — `docs/architecture/vertical-feature-spec.md` — full design, rationale, decision log
|
||||
- **Architecture Overview** — `docs/architecture/overview.md` — package responsibilities, data flow
|
||||
- **Dependency Flow** — `docs/architecture/dependency-flow.md` — allowed directions and composition pattern
|
||||
- **Adding a Feature Guide** — `docs/guides/adding-a-feature.md` — step-by-step new feature walkthrough
|
||||
- **Testing Strategy** — `docs/guides/testing-strategy.md` — test placement, Vitest per-package, Playwright e2e
|
||||
|
||||
Per-package documentation lives in each `AGENTS.md`:
|
||||
- `packages/core-shared/AGENTS.md`
|
||||
- `packages/core-cms/AGENTS.md`
|
||||
- `packages/core-api/AGENTS.md`
|
||||
- `packages/core-trpc/AGENTS.md`
|
||||
- `packages/core-ui/AGENTS.md`
|
||||
- `packages/auth/AGENTS.md`, `blog/AGENTS.md`, `media/AGENTS.md`, `marketing-pages/AGENTS.md`, `navigation/AGENTS.md`
|
||||
- `packages/eslint-config/AGENTS.md`, `typescript-config/AGENTS.md`
|
||||
- `apps/cms/AGENTS.md`, `web-next/AGENTS.md`, `web-tanstack/AGENTS.md`, `storybook/AGENTS.md`
|
||||
|
||||
Reference in New Issue
Block a user