diff --git a/AGENTS.md b/AGENTS.md index 55b3097..861d02c 100644 --- a/AGENTS.md +++ b/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//cms` only | +| `@repo/core-api` | core (composition) | tRPC router aggregator — imports `@repo//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; +``` +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//cms` subpath exports only (to compose Payload collections). +2. **`core-api`** may import `@repo//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; - getComments(options?: { - articleId?: string; - limit?: number; - offset?: number; - }): Promise; - createComment(input: Comment): Promise; -} -``` +### 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 { - return this._comments.find((c) => c.id === id); - } - - async getComments(options?: { - articleId?: string; - limit?: number; - offset?: number; - }): Promise { - 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 { - 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(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 { - 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> -): Promise { - 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//api` imports. + - `core-cms` restricted to `@repo//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>( - ({ className, ...props }, ref) => ( -