diff --git a/docs/superpowers/plans/2026-05-04-plan-1-foundation.md b/docs/superpowers/plans/2026-05-04-plan-1-foundation.md new file mode 100644 index 0000000..9c8daa0 --- /dev/null +++ b/docs/superpowers/plans/2026-05-04-plan-1-foundation.md @@ -0,0 +1,1544 @@ +# Vertical Refactor — Plan 1: Foundation + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Scaffold the five `core-*` packages, populate `core-shared` with generic primitives + tRPC plumbing, lift the Payload config into a stub `core-cms` package, and repoint `apps/cms` at it — leaving `pnpm install`, `typecheck`, and the CMS dev server all green at the end. + +**Architecture:** Three sequential phases. Phase 1 creates empty package skeletons (no code yet) and updates path aliases. Phase 2 fills `core-shared` with reusable Payload primitives (slug field, SEO fields, CTA block, access helpers, hooks) and tRPC init/context. Phase 3 moves the Payload config into `core-cms` (with empty collection/global arrays), updates `apps/cms` to import from the new package, and verifies the admin UI still boots. + +**Tech Stack:** pnpm workspaces, Turborepo, TypeScript 5.8, Vitest 3.x, Payload 3.14, tRPC 11, Zod 3. + +**Plan position:** This is plan 1 of 4 in the vertical-refactor sequence: +- Plan 1 (this doc): Foundation — Phases 1-3 +- Plan 2: Feature migrations — Phases 4-5 (blog canonical, then auth + marketing-pages + navigation + media) +- Plan 3: App + UI integration — Phases 6-7 (core-trpc, route handlers, example pages, core-ui migration) +- Plan 4: Cleanup, enforcement, e2e, docs — Phases 8-11 + +**Spec reference:** `docs/superpowers/specs/2026-04-21-vertical-monorepo-refactor-design.md` + +--- + +## File Structure (this plan creates/modifies) + +**Create:** +- `tsconfig.base.json` (root — does not exist yet) +- `packages/core-shared/{package.json,tsconfig.json,turbo.json,vitest.config.ts}` +- `packages/core-shared/src/index.ts` +- `packages/core-shared/src/lib/{env.ts,date.ts}` + tests +- `packages/core-shared/src/payload/index.ts` +- `packages/core-shared/src/payload/access/is-admin.ts` + test +- `packages/core-shared/src/payload/fields/{slug-field.ts,seo-fields.ts}` + tests +- `packages/core-shared/src/payload/blocks/cta.ts` + test +- `packages/core-shared/src/payload/hooks/{set-published-at.ts,slugify-if-missing.ts}` + tests +- `packages/core-shared/src/trpc/{init.ts,context.ts}` +- `packages/core-cms/{package.json,tsconfig.json,turbo.json}` +- `packages/core-cms/src/{index.ts,payload.config.ts,generated-types.ts}` (generated-types is an empty stub initially) +- `packages/core-api/{package.json,tsconfig.json,turbo.json}` +- `packages/core-api/src/{index.ts,root.ts}` +- `packages/core-trpc/{package.json,tsconfig.json,turbo.json}` +- `packages/core-trpc/src/index.ts` +- `packages/core-ui/{package.json,tsconfig.json,turbo.json}` +- `packages/core-ui/src/index.ts` +- `packages/typescript-config/vitest.base.ts` (shared vitest config) + +**Modify:** +- `apps/cms/src/payload.config.ts` (change re-export from `@repo/cms-core` → `@repo/core-cms`) +- `apps/cms/package.json` (add `@repo/core-cms`, leave `@repo/cms-core` for now — removed in Plan 4) + +**Do NOT touch in this plan:** +- `packages/core/`, `packages/api/`, `packages/api-client/`, `packages/cms-core/`, `packages/cms-client/`, `packages/ui/` — these continue to exist; deleted in Plan 4. + +--- + +## Phase 1: Scaffold core-* packages + +### Task 1.1: Add root tsconfig.base.json with path aliases + +**Files:** +- Create: `tsconfig.base.json` + +- [ ] **Step 1: Create the file** + +```json +{ + "$schema": "https://json.schemastore.org/tsconfig", + "compilerOptions": { + "baseUrl": ".", + "paths": { + "@repo/core-shared": ["packages/core-shared/src/index.ts"], + "@repo/core-shared/payload": ["packages/core-shared/src/payload/index.ts"], + "@repo/core-shared/trpc/init": ["packages/core-shared/src/trpc/init.ts"], + "@repo/core-shared/trpc/context": ["packages/core-shared/src/trpc/context.ts"], + "@repo/core-cms": ["packages/core-cms/src/index.ts"], + "@repo/core-cms/generated-types": ["packages/core-cms/src/generated-types.ts"], + "@repo/core-api": ["packages/core-api/src/index.ts"], + "@repo/core-trpc": ["packages/core-trpc/src/index.ts"], + "@repo/core-ui": ["packages/core-ui/src/index.ts"] + } + } +} +``` + +- [ ] **Step 2: Commit** + +```bash +git add tsconfig.base.json +git commit -m "build: add root tsconfig.base.json with core-* path aliases" +``` + +--- + +### Task 1.2: Scaffold @repo/core-shared package + +**Files:** +- Create: `packages/core-shared/package.json` +- Create: `packages/core-shared/tsconfig.json` +- Create: `packages/core-shared/turbo.json` +- Create: `packages/core-shared/src/index.ts` + +- [ ] **Step 1: Create package.json** + +```json +{ + "name": "@repo/core-shared", + "private": true, + "version": "0.0.0", + "type": "module", + "exports": { + ".": "./src/index.ts", + "./payload": "./src/payload/index.ts", + "./trpc/init": "./src/trpc/init.ts", + "./trpc/context": "./src/trpc/context.ts" + }, + "scripts": { + "build": "tsc --noEmit", + "lint": "eslint .", + "test": "vitest run", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@trpc/server": "^11.0.0", + "payload": "^3.14.0", + "superjson": "^2.2.1", + "zod": "^3.24.0" + }, + "devDependencies": { + "@repo/eslint-config": "workspace:*", + "@repo/typescript-config": "workspace:*", + "@types/node": "^22.0.0", + "vitest": "^3.1.0" + } +} +``` + +- [ ] **Step 2: Create tsconfig.json** + +```json +{ + "extends": "@repo/typescript-config/base.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src" + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +} +``` + +- [ ] **Step 3: Create turbo.json (with tag)** + +```json +{ + "extends": ["//"], + "tags": ["core"] +} +``` + +- [ ] **Step 4: Create empty index.ts** + +```typescript +export {}; +``` + +- [ ] **Step 5: Commit** + +```bash +git add packages/core-shared +git commit -m "feat(core-shared): scaffold empty package with exports + tags" +``` + +--- + +### Task 1.3: Scaffold @repo/core-cms package + +**Files:** +- Create: `packages/core-cms/package.json` +- Create: `packages/core-cms/tsconfig.json` +- Create: `packages/core-cms/turbo.json` +- Create: `packages/core-cms/src/index.ts` +- Create: `packages/core-cms/src/generated-types.ts` + +- [ ] **Step 1: Create package.json** + +```json +{ + "name": "@repo/core-cms", + "private": true, + "version": "0.0.0", + "type": "module", + "exports": { + ".": "./src/index.ts", + "./generated-types": "./src/generated-types.ts" + }, + "scripts": { + "build": "tsc --noEmit", + "lint": "eslint .", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "payload": "^3.14.0", + "@payloadcms/db-postgres": "^3.14.0", + "@payloadcms/richtext-lexical": "^3.14.0" + }, + "devDependencies": { + "@repo/eslint-config": "workspace:*", + "@repo/typescript-config": "workspace:*", + "@types/node": "^22.0.0" + } +} +``` + +- [ ] **Step 2: Create tsconfig.json** + +```json +{ + "extends": "@repo/typescript-config/base.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src", + "lib": ["ES2022", "DOM"] + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +} +``` + +> Note: `lib: ["ES2022", "DOM"]` is needed because Payload's types reference DOM globals (e.g., `URL`). + +- [ ] **Step 3: Create turbo.json** + +```json +{ + "extends": ["//"], + "tags": ["core"] +} +``` + +- [ ] **Step 4: Create empty index.ts** (will export the config in Phase 3) + +```typescript +export {}; +``` + +- [ ] **Step 5: Create empty generated-types.ts placeholder** + +```typescript +// Generated by Payload — do not edit by hand. +// This file is regenerated by `pnpm generate:types` in apps/cms. +export {}; +``` + +- [ ] **Step 6: Commit** + +```bash +git add packages/core-cms +git commit -m "feat(core-cms): scaffold empty package with exports + tags" +``` + +--- + +### Task 1.4: Scaffold @repo/core-api package + +**Files:** +- Create: `packages/core-api/package.json` +- Create: `packages/core-api/tsconfig.json` +- Create: `packages/core-api/turbo.json` +- Create: `packages/core-api/src/root.ts` +- Create: `packages/core-api/src/index.ts` + +- [ ] **Step 1: Create package.json** + +```json +{ + "name": "@repo/core-api", + "private": true, + "version": "0.0.0", + "type": "module", + "exports": { + ".": "./src/index.ts" + }, + "scripts": { + "build": "tsc --noEmit", + "lint": "eslint .", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@repo/core-shared": "workspace:*", + "@trpc/server": "^11.0.0" + }, + "devDependencies": { + "@repo/eslint-config": "workspace:*", + "@repo/typescript-config": "workspace:*", + "@types/node": "^22.0.0" + } +} +``` + +- [ ] **Step 2: Create tsconfig.json** + +```json +{ + "extends": "@repo/typescript-config/base.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src" + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +} +``` + +- [ ] **Step 3: Create turbo.json** + +```json +{ + "extends": ["//"], + "tags": ["core"] +} +``` + +- [ ] **Step 4: Create root.ts (empty appRouter that compiles)** + +```typescript +import { router } from "@repo/core-shared/trpc/init"; + +export const appRouter = router({}); + +export type AppRouter = typeof appRouter; +``` + +> Note: This depends on `@repo/core-shared/trpc/init` which will exist after Task 2.13. To make typecheck pass in isolation now, this file imports a not-yet-existing module — but Phase 1 verification (Task 1.7) only runs `pnpm install`, not `typecheck`. Typecheck runs after Phase 2 completes. + +- [ ] **Step 5: Create index.ts** + +```typescript +export { appRouter, type AppRouter } from "./root"; +``` + +- [ ] **Step 6: Commit** + +```bash +git add packages/core-api +git commit -m "feat(core-api): scaffold empty appRouter aggregator" +``` + +--- + +### Task 1.5: Scaffold @repo/core-trpc package + +**Files:** +- Create: `packages/core-trpc/package.json` +- Create: `packages/core-trpc/tsconfig.json` +- Create: `packages/core-trpc/turbo.json` +- Create: `packages/core-trpc/src/index.ts` + +- [ ] **Step 1: Create package.json** + +```json +{ + "name": "@repo/core-trpc", + "private": true, + "version": "0.0.0", + "type": "module", + "exports": { + ".": "./src/index.ts" + }, + "scripts": { + "build": "tsc --noEmit", + "lint": "eslint .", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@repo/core-api": "workspace:*", + "@trpc/client": "^11.0.0", + "@trpc/react-query": "^11.0.0", + "@trpc/server": "^11.0.0", + "@tanstack/react-query": "^5.66.0", + "react": "^19.0.0", + "superjson": "^2.2.1" + }, + "devDependencies": { + "@repo/eslint-config": "workspace:*", + "@repo/typescript-config": "workspace:*", + "@types/react": "^19.0.0" + } +} +``` + +- [ ] **Step 2: Create tsconfig.json (jsx for React provider files added in Plan 3)** + +```json +{ + "extends": "@repo/typescript-config/base.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src", + "lib": ["ES2022", "DOM"], + "jsx": "preserve" + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +} +``` + +- [ ] **Step 3: Create turbo.json** + +```json +{ + "extends": ["//"], + "tags": ["core"] +} +``` + +- [ ] **Step 4: Create empty index.ts (client + providers added in Plan 3)** + +```typescript +export {}; +``` + +- [ ] **Step 5: Commit** + +```bash +git add packages/core-trpc +git commit -m "feat(core-trpc): scaffold empty package (client + providers in Plan 3)" +``` + +--- + +### Task 1.6: Scaffold @repo/core-ui package + +**Files:** +- Create: `packages/core-ui/package.json` +- Create: `packages/core-ui/tsconfig.json` +- Create: `packages/core-ui/turbo.json` +- Create: `packages/core-ui/src/index.ts` + +- [ ] **Step 1: Create package.json** + +```json +{ + "name": "@repo/core-ui", + "private": true, + "version": "0.0.0", + "type": "module", + "exports": { + ".": "./src/index.ts" + }, + "scripts": { + "build": "tsc --noEmit", + "lint": "eslint .", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "clsx": "^2.1.1", + "react": "^19.0.0", + "tailwind-merge": "^3.0.0" + }, + "devDependencies": { + "@repo/eslint-config": "workspace:*", + "@repo/typescript-config": "workspace:*", + "@types/react": "^19.0.0" + } +} +``` + +- [ ] **Step 2: Create tsconfig.json** + +```json +{ + "extends": "@repo/typescript-config/base.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src", + "lib": ["ES2022", "DOM"], + "jsx": "preserve" + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +} +``` + +- [ ] **Step 3: Create turbo.json** + +```json +{ + "extends": ["//"], + "tags": ["core"] +} +``` + +- [ ] **Step 4: Create empty index.ts (contents migrated from packages/ui in Plan 3)** + +```typescript +export {}; +``` + +- [ ] **Step 5: Commit** + +```bash +git add packages/core-ui +git commit -m "feat(core-ui): scaffold empty package (contents migrated in Plan 3)" +``` + +--- + +### Task 1.7: Install + verify Phase 1 + +- [ ] **Step 1: Install deps (picks up new workspace packages)** + +Run: `pnpm install` + +Expected: install completes; new packages registered in workspace; lockfile updated. + +- [ ] **Step 2: Verify all five packages registered** + +Run: `pnpm list --recursive --depth=-1 | grep -E "core-(shared|cms|api|trpc|ui)"` + +Expected: all five names appear. + +- [ ] **Step 3: Commit lockfile** + +```bash +git add pnpm-lock.yaml +git commit -m "build: update lockfile for new core-* packages" +``` + +> **Phase 1 Gate:** `pnpm install` green. (Typecheck/test deferred until Phase 2 fills `core-shared` — `core-api/src/root.ts` imports a not-yet-existing `core-shared/trpc/init` module.) + +--- + +## Phase 2: Populate core-shared + +### Task 2.1: Add shared vitest base config + +**Files:** +- Create: `packages/typescript-config/vitest.base.ts` +- Modify: `packages/typescript-config/package.json` + +- [ ] **Step 1: Replace packages/typescript-config/package.json with the version that exposes both `base.json` and `vitest.base.ts`** + +Current contents (verified): +```json +{ + "name": "@repo/typescript-config", + "private": true, + "version": "0.0.0" +} +``` + +Replace with: +```json +{ + "name": "@repo/typescript-config", + "private": true, + "version": "0.0.0", + "type": "module", + "exports": { + "./base.json": "./base.json", + "./vitest.base": "./vitest.base.ts" + }, + "devDependencies": { + "vitest": "^3.1.0" + } +} +``` + +- [ ] **Step 2: Create vitest.base.ts** + +```typescript +import { defineConfig } from "vitest/config"; + +export const baseVitestConfig = defineConfig({ + test: { + globals: true, + environment: "node", + include: ["src/**/*.test.ts", "src/**/*.test.tsx", "tests/**/*.test.ts"], + coverage: { + provider: "v8", + reporter: ["text", "html"], + include: ["src/**"], + exclude: ["src/**/*.test.{ts,tsx}", "src/**/index.ts"], + }, + }, +}); +``` + +- [ ] **Step 3: Install vitest into typescript-config** + +Run: `pnpm install` +Expected: vitest devDep installed; lockfile updated. + +- [ ] **Step 4: Commit** + +```bash +git add packages/typescript-config pnpm-lock.yaml +git commit -m "build(typescript-config): add shared vitest base config" +``` + +--- + +### Task 2.2: Add vitest.config.ts to core-shared + +**Files:** +- Create: `packages/core-shared/vitest.config.ts` +- Modify: `packages/core-shared/package.json` (add typescript-config to devDeps if not already) + +- [ ] **Step 1: Create vitest.config.ts** + +```typescript +import { baseVitestConfig } from "@repo/typescript-config/vitest.base"; + +export default baseVitestConfig; +``` + +- [ ] **Step 2: Run vitest to confirm config loads (no tests yet — should report 0 tests)** + +Run: `cd packages/core-shared && pnpm vitest run` + +Expected: "No test files found, exiting with code 1" — that's fine; the config loads. + +- [ ] **Step 3: Commit** + +```bash +git add packages/core-shared/vitest.config.ts +git commit -m "test(core-shared): wire shared vitest base" +``` + +--- + +### Task 2.3: Implement is-admin access helper + +**Files:** +- Create: `packages/core-shared/src/payload/access/is-admin.ts` +- Create: `packages/core-shared/src/payload/access/is-admin.test.ts` + +- [ ] **Step 1: Write the failing test** + +```typescript +// packages/core-shared/src/payload/access/is-admin.test.ts +import { describe, expect, it } from "vitest"; +import { isAdmin } from "./is-admin"; + +describe("isAdmin", () => { + it("returns true when user role is 'admin'", () => { + expect(isAdmin({ req: { user: { role: "admin" } } })).toBe(true); + }); + + it("returns false when user role is not 'admin'", () => { + expect(isAdmin({ req: { user: { role: "editor" } } })).toBe(false); + }); + + it("returns false when user has no role", () => { + expect(isAdmin({ req: { user: {} } })).toBe(false); + }); + + it("returns false when there is no user", () => { + expect(isAdmin({ req: {} })).toBe(false); + }); +}); +``` + +- [ ] **Step 2: Run test — expect failure** + +Run: `cd packages/core-shared && pnpm vitest run src/payload/access/is-admin.test.ts` +Expected: FAIL — "Cannot find module './is-admin'" + +- [ ] **Step 3: Implement** + +```typescript +// packages/core-shared/src/payload/access/is-admin.ts +export function isAdmin({ + req, +}: { + req: { user?: { role?: string } }; +}): boolean { + return req.user?.role === "admin"; +} +``` + +- [ ] **Step 4: Run test — expect pass** + +Run: `cd packages/core-shared && pnpm vitest run src/payload/access/is-admin.test.ts` +Expected: PASS — 4 tests. + +- [ ] **Step 5: Commit** + +```bash +git add packages/core-shared/src/payload/access +git commit -m "feat(core-shared): add isAdmin access helper" +``` + +--- + +### Task 2.4: Implement slug-field + +**Files:** +- Create: `packages/core-shared/src/payload/fields/slug-field.ts` +- Create: `packages/core-shared/src/payload/fields/slug-field.test.ts` + +- [ ] **Step 1: Write the failing test** + +```typescript +// packages/core-shared/src/payload/fields/slug-field.test.ts +import { describe, expect, it } from "vitest"; +import { slugField } from "./slug-field"; + +describe("slugField", () => { + it("returns a Payload Field with default name 'slug'", () => { + const field = slugField(); + expect(field.name).toBe("slug"); + expect(field.type).toBe("text"); + expect(field.required).toBe(true); + expect(field.unique).toBe(true); + expect(field.index).toBe(true); + }); + + it("accepts a custom field name", () => { + const field = slugField("permalink"); + expect(field.name).toBe("permalink"); + }); +}); +``` + +- [ ] **Step 2: Run — expect failure** + +Run: `cd packages/core-shared && pnpm vitest run src/payload/fields/slug-field.test.ts` +Expected: FAIL — "Cannot find module './slug-field'" + +- [ ] **Step 3: Implement** + +```typescript +// packages/core-shared/src/payload/fields/slug-field.ts +import type { Field } from "payload"; + +export function slugField(name = "slug"): Field { + return { + name, + type: "text", + required: true, + unique: true, + index: true, + }; +} +``` + +- [ ] **Step 4: Run — expect pass** + +Run: `cd packages/core-shared && pnpm vitest run src/payload/fields/slug-field.test.ts` +Expected: PASS — 2 tests. + +- [ ] **Step 5: Commit** + +```bash +git add packages/core-shared/src/payload/fields/slug-field.ts packages/core-shared/src/payload/fields/slug-field.test.ts +git commit -m "feat(core-shared): add slugField helper" +``` + +--- + +### Task 2.5: Implement seo-fields + +**Files:** +- Create: `packages/core-shared/src/payload/fields/seo-fields.ts` +- Create: `packages/core-shared/src/payload/fields/seo-fields.test.ts` + +- [ ] **Step 1: Write the failing test** + +```typescript +// packages/core-shared/src/payload/fields/seo-fields.test.ts +import { describe, expect, it } from "vitest"; +import { seoFields } from "./seo-fields"; + +describe("seoFields", () => { + it("is a group field named 'seo'", () => { + expect(seoFields.name).toBe("seo"); + expect(seoFields.type).toBe("group"); + }); + + it("contains required title and optional description", () => { + if (seoFields.type !== "group") { + throw new Error("seoFields must be a group"); + } + const fieldNames = seoFields.fields.map((f) => + "name" in f ? f.name : null, + ); + expect(fieldNames).toContain("title"); + expect(fieldNames).toContain("description"); + + const titleField = seoFields.fields.find( + (f) => "name" in f && f.name === "title", + ); + expect(titleField && "required" in titleField && titleField.required).toBe( + true, + ); + }); +}); +``` + +- [ ] **Step 2: Run — expect failure** + +Run: `cd packages/core-shared && pnpm vitest run src/payload/fields/seo-fields.test.ts` +Expected: FAIL — "Cannot find module './seo-fields'" + +- [ ] **Step 3: Implement** + +```typescript +// packages/core-shared/src/payload/fields/seo-fields.ts +import type { Field } from "payload"; + +export const seoFields: Field = { + name: "seo", + type: "group", + fields: [ + { name: "title", type: "text", required: true }, + { name: "description", type: "textarea" }, + ], +}; +``` + +- [ ] **Step 4: Run — expect pass** + +Run: `cd packages/core-shared && pnpm vitest run src/payload/fields/seo-fields.test.ts` +Expected: PASS — 2 tests. + +- [ ] **Step 5: Commit** + +```bash +git add packages/core-shared/src/payload/fields/seo-fields.ts packages/core-shared/src/payload/fields/seo-fields.test.ts +git commit -m "feat(core-shared): add seoFields group" +``` + +--- + +### Task 2.6: Implement cta block + +**Files:** +- Create: `packages/core-shared/src/payload/blocks/cta.ts` +- Create: `packages/core-shared/src/payload/blocks/cta.test.ts` + +- [ ] **Step 1: Write the failing test** + +```typescript +// packages/core-shared/src/payload/blocks/cta.test.ts +import { describe, expect, it } from "vitest"; +import { cta } from "./cta"; + +describe("cta block", () => { + it("has slug 'cta'", () => { + expect(cta.slug).toBe("cta"); + }); + + it("requires title, buttonLabel, and href", () => { + const fieldNames = cta.fields.map((f) => ("name" in f ? f.name : null)); + expect(fieldNames).toEqual(["title", "buttonLabel", "href"]); + cta.fields.forEach((f) => { + if ("required" in f) expect(f.required).toBe(true); + }); + }); +}); +``` + +- [ ] **Step 2: Run — expect failure** + +Run: `cd packages/core-shared && pnpm vitest run src/payload/blocks/cta.test.ts` +Expected: FAIL — "Cannot find module './cta'" + +- [ ] **Step 3: Implement** + +```typescript +// packages/core-shared/src/payload/blocks/cta.ts +import type { Block } from "payload"; + +export const cta: Block = { + slug: "cta", + fields: [ + { name: "title", type: "text", required: true }, + { name: "buttonLabel", type: "text", required: true }, + { name: "href", type: "text", required: true }, + ], +}; +``` + +- [ ] **Step 4: Run — expect pass** + +Run: `cd packages/core-shared && pnpm vitest run src/payload/blocks/cta.test.ts` +Expected: PASS — 2 tests. + +- [ ] **Step 5: Commit** + +```bash +git add packages/core-shared/src/payload/blocks +git commit -m "feat(core-shared): add cta block" +``` + +--- + +### Task 2.7: Implement set-published-at hook + +**Files:** +- Create: `packages/core-shared/src/payload/hooks/set-published-at.ts` +- Create: `packages/core-shared/src/payload/hooks/set-published-at.test.ts` + +- [ ] **Step 1: Write the failing test** + +```typescript +// packages/core-shared/src/payload/hooks/set-published-at.test.ts +import { describe, expect, it, beforeEach, afterEach, vi } from "vitest"; +import { setPublishedAt } from "./set-published-at"; + +describe("setPublishedAt", () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-05-04T12:00:00.000Z")); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("sets publishedAt to now when status is published and publishedAt is missing", () => { + const result = setPublishedAt({ data: { status: "published" } }); + expect(result?.publishedAt).toBe("2026-05-04T12:00:00.000Z"); + }); + + it("does not overwrite an existing publishedAt", () => { + const result = setPublishedAt({ + data: { status: "published", publishedAt: "2025-01-01T00:00:00.000Z" }, + }); + expect(result?.publishedAt).toBe("2025-01-01T00:00:00.000Z"); + }); + + it("does not set publishedAt when status is not published", () => { + const result = setPublishedAt({ data: { status: "draft" } }); + expect(result?.publishedAt).toBeUndefined(); + }); + + it("returns data unchanged when data is missing", () => { + expect(setPublishedAt({})).toBeUndefined(); + }); +}); +``` + +- [ ] **Step 2: Run — expect failure** + +Run: `cd packages/core-shared && pnpm vitest run src/payload/hooks/set-published-at.test.ts` +Expected: FAIL — "Cannot find module './set-published-at'" + +- [ ] **Step 3: Implement** + +```typescript +// packages/core-shared/src/payload/hooks/set-published-at.ts +export function setPublishedAt({ + data, +}: { + data?: { status?: string; publishedAt?: string | null }; +}) { + if (!data) return data; + + if (data.status === "published" && !data.publishedAt) { + data.publishedAt = new Date().toISOString(); + } + + return data; +} +``` + +- [ ] **Step 4: Run — expect pass** + +Run: `cd packages/core-shared && pnpm vitest run src/payload/hooks/set-published-at.test.ts` +Expected: PASS — 4 tests. + +- [ ] **Step 5: Commit** + +```bash +git add packages/core-shared/src/payload/hooks/set-published-at.ts packages/core-shared/src/payload/hooks/set-published-at.test.ts +git commit -m "feat(core-shared): add setPublishedAt hook" +``` + +--- + +### Task 2.8: Implement slugify-if-missing hook + +**Files:** +- Create: `packages/core-shared/src/payload/hooks/slugify-if-missing.ts` +- Create: `packages/core-shared/src/payload/hooks/slugify-if-missing.test.ts` + +- [ ] **Step 1: Write the failing test** + +```typescript +// packages/core-shared/src/payload/hooks/slugify-if-missing.test.ts +import { describe, expect, it } from "vitest"; +import { slugifyIfMissing } from "./slugify-if-missing"; + +describe("slugifyIfMissing", () => { + it("derives slug from title on create when slug is empty", () => { + const result = slugifyIfMissing({ + data: { title: "Hello World" }, + operation: "create", + }); + expect(result?.slug).toBe("hello-world"); + }); + + it("does not overwrite an existing slug", () => { + const result = slugifyIfMissing({ + data: { title: "New Title", slug: "kept-slug" }, + operation: "create", + }); + expect(result?.slug).toBe("kept-slug"); + }); + + it("does nothing on update", () => { + const result = slugifyIfMissing({ + data: { title: "Hello World" }, + operation: "update", + }); + expect(result?.slug).toBeUndefined(); + }); + + it("strips non-alphanumerics and trims hyphens", () => { + const result = slugifyIfMissing({ + data: { title: " Hello, World!! 2026 " }, + operation: "create", + }); + expect(result?.slug).toBe("hello-world-2026"); + }); + + it("returns data unchanged when title is missing", () => { + const result = slugifyIfMissing({ + data: {}, + operation: "create", + }); + expect(result?.slug).toBeUndefined(); + }); +}); +``` + +- [ ] **Step 2: Run — expect failure** + +Run: `cd packages/core-shared && pnpm vitest run src/payload/hooks/slugify-if-missing.test.ts` +Expected: FAIL — "Cannot find module './slugify-if-missing'" + +- [ ] **Step 3: Implement** + +```typescript +// packages/core-shared/src/payload/hooks/slugify-if-missing.ts +export function slugifyIfMissing({ + data, + operation, +}: { + data?: { title?: string; slug?: string }; + operation?: string; +}) { + if (!data) return data; + if (operation !== "create") return data; + if (data.slug) return data; + if (!data.title) return data; + + data.slug = data.title + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, ""); + + return data; +} +``` + +- [ ] **Step 4: Run — expect pass** + +Run: `cd packages/core-shared && pnpm vitest run src/payload/hooks/slugify-if-missing.test.ts` +Expected: PASS — 5 tests. + +- [ ] **Step 5: Commit** + +```bash +git add packages/core-shared/src/payload/hooks/slugify-if-missing.ts packages/core-shared/src/payload/hooks/slugify-if-missing.test.ts +git commit -m "feat(core-shared): add slugifyIfMissing hook" +``` + +--- + +### Task 2.9: Implement env helper + +**Files:** +- Create: `packages/core-shared/src/lib/env.ts` +- Create: `packages/core-shared/src/lib/env.test.ts` + +- [ ] **Step 1: Write the failing test** + +```typescript +// packages/core-shared/src/lib/env.test.ts +import { describe, expect, it, beforeEach, afterEach } from "vitest"; +import { requireEnv } from "./env"; + +describe("requireEnv", () => { + const originalEnv = process.env.SOME_KEY; + + afterEach(() => { + if (originalEnv === undefined) delete process.env.SOME_KEY; + else process.env.SOME_KEY = originalEnv; + }); + + it("returns the value when set", () => { + process.env.SOME_KEY = "value"; + expect(requireEnv("SOME_KEY")).toBe("value"); + }); + + it("throws when missing", () => { + delete process.env.SOME_KEY; + expect(() => requireEnv("SOME_KEY")).toThrow( + /Missing required env var: SOME_KEY/, + ); + }); + + it("throws when empty string", () => { + process.env.SOME_KEY = ""; + expect(() => requireEnv("SOME_KEY")).toThrow(); + }); +}); +``` + +- [ ] **Step 2: Run — expect failure** + +Run: `cd packages/core-shared && pnpm vitest run src/lib/env.test.ts` +Expected: FAIL — "Cannot find module './env'" + +- [ ] **Step 3: Implement** + +```typescript +// packages/core-shared/src/lib/env.ts +export function requireEnv(name: string): string { + const value = process.env[name]; + if (!value) { + throw new Error(`Missing required env var: ${name}`); + } + return value; +} +``` + +- [ ] **Step 4: Run — expect pass** + +Run: `cd packages/core-shared && pnpm vitest run src/lib/env.test.ts` +Expected: PASS — 3 tests. + +- [ ] **Step 5: Commit** + +```bash +git add packages/core-shared/src/lib/env.ts packages/core-shared/src/lib/env.test.ts +git commit -m "feat(core-shared): add requireEnv helper" +``` + +--- + +### Task 2.10: Implement date helper + +**Files:** +- Create: `packages/core-shared/src/lib/date.ts` +- Create: `packages/core-shared/src/lib/date.test.ts` + +- [ ] **Step 1: Write the failing test** + +```typescript +// packages/core-shared/src/lib/date.test.ts +import { describe, expect, it } from "vitest"; +import { toIsoString } from "./date"; + +describe("toIsoString", () => { + it("converts a Date to ISO string", () => { + const d = new Date("2026-05-04T12:00:00.000Z"); + expect(toIsoString(d)).toBe("2026-05-04T12:00:00.000Z"); + }); + + it("passes through an existing ISO string", () => { + expect(toIsoString("2026-05-04T12:00:00.000Z")).toBe( + "2026-05-04T12:00:00.000Z", + ); + }); + + it("returns null for null input", () => { + expect(toIsoString(null)).toBeNull(); + }); + + it("returns null for undefined input", () => { + expect(toIsoString(undefined)).toBeNull(); + }); +}); +``` + +- [ ] **Step 2: Run — expect failure** + +Run: `cd packages/core-shared && pnpm vitest run src/lib/date.test.ts` +Expected: FAIL — "Cannot find module './date'" + +- [ ] **Step 3: Implement** + +```typescript +// packages/core-shared/src/lib/date.ts +export function toIsoString(input: Date | string | null | undefined): string | null { + if (input === null || input === undefined) return null; + if (input instanceof Date) return input.toISOString(); + return input; +} +``` + +- [ ] **Step 4: Run — expect pass** + +Run: `cd packages/core-shared && pnpm vitest run src/lib/date.test.ts` +Expected: PASS — 4 tests. + +- [ ] **Step 5: Commit** + +```bash +git add packages/core-shared/src/lib/date.ts packages/core-shared/src/lib/date.test.ts +git commit -m "feat(core-shared): add toIsoString helper" +``` + +--- + +### Task 2.11: Create payload barrel export + +**Files:** +- Create: `packages/core-shared/src/payload/index.ts` + +- [ ] **Step 1: Create barrel** + +```typescript +// packages/core-shared/src/payload/index.ts +export { isAdmin } from "./access/is-admin"; +export { slugField } from "./fields/slug-field"; +export { seoFields } from "./fields/seo-fields"; +export { cta } from "./blocks/cta"; +export { setPublishedAt } from "./hooks/set-published-at"; +export { slugifyIfMissing } from "./hooks/slugify-if-missing"; +``` + +- [ ] **Step 2: Verify import path resolves** + +Run: `cd packages/core-shared && pnpm typecheck` +Expected: PASS — no errors. + +- [ ] **Step 3: Commit** + +```bash +git add packages/core-shared/src/payload/index.ts +git commit -m "feat(core-shared): add payload barrel export" +``` + +--- + +### Task 2.12: Implement trpc init + +**Files:** +- Create: `packages/core-shared/src/trpc/init.ts` + +- [ ] **Step 1: Implement** (no test — exercised indirectly through routers in Plan 2) + +```typescript +// packages/core-shared/src/trpc/init.ts +import { initTRPC } from "@trpc/server"; +import superjson from "superjson"; + +const t = initTRPC.create({ + transformer: superjson, +}); + +export const router = t.router; +export const publicProcedure = t.procedure; +export const middleware = t.middleware; +``` + +- [ ] **Step 2: Verify it compiles** + +Run: `cd packages/core-shared && pnpm typecheck` +Expected: PASS. + +- [ ] **Step 3: Commit** + +```bash +git add packages/core-shared/src/trpc/init.ts +git commit -m "feat(core-shared): add tRPC init with superjson" +``` + +--- + +### Task 2.13: Implement trpc context + +**Files:** +- Create: `packages/core-shared/src/trpc/context.ts` + +- [ ] **Step 1: Implement** + +```typescript +// packages/core-shared/src/trpc/context.ts +export async function createTrpcContext() { + return {}; +} + +export type TrpcContext = Awaited>; +``` + +- [ ] **Step 2: Verify it compiles** + +Run: `cd packages/core-shared && pnpm typecheck` +Expected: PASS. + +- [ ] **Step 3: Commit** + +```bash +git add packages/core-shared/src/trpc/context.ts +git commit -m "feat(core-shared): add tRPC context factory" +``` + +--- + +### Task 2.14: Wire root index.ts and verify all tests + +**Files:** +- Modify: `packages/core-shared/src/index.ts` + +- [ ] **Step 1: Replace empty index with re-exports** + +```typescript +// packages/core-shared/src/index.ts +export { requireEnv } from "./lib/env"; +export { toIsoString } from "./lib/date"; +``` + +> Note: Payload primitives are accessed via `@repo/core-shared/payload` subpath; tRPC via `/trpc/init` and `/trpc/context`. The root export is just the lib helpers. + +- [ ] **Step 2: Run all core-shared tests** + +Run: `cd packages/core-shared && pnpm test` +Expected: PASS — 26 tests across 8 test files: is-admin (4) + slug-field (2) + seo-fields (2) + cta (2) + set-published-at (4) + slugify-if-missing (5) + env (3) + date (4). Use `pnpm vitest run --reporter=verbose` for a full list. + +- [ ] **Step 3: Run typecheck across the whole repo** + +Run: `pnpm typecheck` +Expected: PASS (now that `core-shared/trpc/init` exists, `core-api/src/root.ts` from Task 1.4 resolves). + +- [ ] **Step 4: Commit** + +```bash +git add packages/core-shared/src/index.ts +git commit -m "feat(core-shared): wire root index.ts barrel" +``` + +> **Phase 2 Gate:** `pnpm test --filter @repo/core-shared` green; `pnpm typecheck` green across the whole repo. + +--- + +## Phase 3: Populate core-cms stub + repoint apps/cms + +### Task 3.1: Create core-cms payload.config.ts (stub — empty arrays) + +**Files:** +- Create: `packages/core-cms/src/payload.config.ts` + +- [ ] **Step 1: Implement** + +```typescript +// packages/core-cms/src/payload.config.ts +import { buildConfig } from "payload"; +import { postgresAdapter } from "@payloadcms/db-postgres"; +import { lexicalEditor } from "@payloadcms/richtext-lexical"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const filename = fileURLToPath(import.meta.url); +const dirname = path.dirname(filename); + +export default buildConfig({ + editor: lexicalEditor(), + collections: [], + globals: [], + secret: process.env.PAYLOAD_SECRET || "default-secret-change-me", + db: postgresAdapter({ + pool: { + connectionString: + process.env.DATABASE_URL || + "postgresql://postgres:postgres@localhost:5432/template", + }, + }), + typescript: { + outputFile: path.resolve(dirname, "generated-types.ts"), + }, +}); +``` + +> Note: `collections: []` and `globals: []` initially. Plan 2 wires in feature-owned `@repo//cms` exports here. + +- [ ] **Step 2: Update index.ts to re-export the config** + +```typescript +// packages/core-cms/src/index.ts +export { default } from "./payload.config"; +``` + +- [ ] **Step 3: Verify it compiles** + +Run: `cd packages/core-cms && pnpm typecheck` +Expected: PASS. + +- [ ] **Step 4: Commit** + +```bash +git add packages/core-cms/src +git commit -m "feat(core-cms): add stub payload.config (empty collections/globals)" +``` + +--- + +### Task 3.2: Repoint apps/cms to @repo/core-cms + +**Files:** +- Modify: `apps/cms/src/payload.config.ts` +- Modify: `apps/cms/package.json` + +- [ ] **Step 1: Update the re-export in apps/cms/src/payload.config.ts** + +```typescript +// apps/cms/src/payload.config.ts +// Re-export Payload config from @repo/core-cms. +// This file exists so @payload-config resolves correctly in the CMS app. +export { default } from "@repo/core-cms"; +``` + +- [ ] **Step 2: Update apps/cms/package.json — add @repo/core-cms dependency** + +Add to `dependencies` block (keep `@repo/cms-core` for now — removed in Plan 4): + +```json +"@repo/core-cms": "workspace:*", +``` + +- [ ] **Step 3: Install** + +Run: `pnpm install` +Expected: install completes; new dep added. + +- [ ] **Step 4: Verify cms typecheck** + +Run: `pnpm typecheck --filter @repo/cms` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add apps/cms/src/payload.config.ts apps/cms/package.json pnpm-lock.yaml +git commit -m "feat(cms): repoint @payload-config from cms-core to core-cms" +``` + +--- + +### Task 3.3: Boot apps/cms against new core-cms config (smoke test) + +- [ ] **Step 1: Ensure Postgres is running** + +Run: `docker compose ps postgres` +Expected: postgres container healthy. If not: `docker compose up -d postgres`. + +- [ ] **Step 2: Start the cms dev server in the background** + +Run: `pnpm dev --filter @repo/cms` + +This starts Next.js on port 3001. Wait ~10s for "Ready" / "compiled successfully". + +- [ ] **Step 3: Verify the admin UI responds** + +Run: `curl -sf http://localhost:3001/admin -o /dev/null && echo OK` +Expected: `OK` (the admin UI responds with HTML — even if the empty-collections config means there's nothing to manage yet, the admin shell loads). + +- [ ] **Step 4: Stop the dev server** + +Stop the `pnpm dev` process (Ctrl+C if foreground, or kill the background job). + +- [ ] **Step 5: No commit needed for the smoke test (no file changes)** + +--- + +### Task 3.4: Verify Payload type generation against the stub + +- [ ] **Step 1: Run type generation** + +Run: `cd apps/cms && pnpm generate:types` +Expected: writes a new `generated-types.ts` somewhere (per the config's `outputFile: path.resolve(dirname, "generated-types.ts")` — this puts the file in `packages/core-cms/src/generated-types.ts`). + +- [ ] **Step 2: Inspect the generated file** + +Run: `head -30 packages/core-cms/src/generated-types.ts` +Expected: a valid TypeScript file (probably mostly empty or with just a `Config` interface and Auth types since Users isn't in the empty arrays — Payload may include built-in user/preference types). + +- [ ] **Step 3: Verify the file typechecks** + +Run: `pnpm typecheck --filter @repo/core-cms` +Expected: PASS. + +- [ ] **Step 4: Commit the regenerated types** + +```bash +git add packages/core-cms/src/generated-types.ts +git commit -m "feat(core-cms): generate initial (empty) Payload types" +``` + +> **Phase 3 Gate:** `pnpm dev --filter @repo/cms` boots and serves `/admin`; `pnpm generate:types` succeeds; `pnpm typecheck && pnpm test --filter @repo/core-shared` green. + +--- + +## Final Verification + +- [ ] **Step 1: Repo-wide typecheck** + +Run: `pnpm typecheck` +Expected: PASS across all packages (old `@repo/core`, `@repo/api`, etc. still exist and still typecheck — they're untouched). + +- [ ] **Step 2: Repo-wide tests** + +Run: `pnpm test` +Expected: PASS — `core-shared` tests pass; old `@repo/core` tests still pass (unchanged). + +- [ ] **Step 3: Lint** + +Run: `pnpm lint` +Expected: PASS (no boundary rules added yet — those land in Plan 4). + +- [ ] **Step 4: Build** + +Run: `pnpm build` +Expected: PASS. + +- [ ] **Step 5: Final summary commit (optional — only if any cleanup edits were needed)** + +If everything is already committed phase-by-phase, no extra commit needed. + +--- + +## Plan 1 Done Criteria + +- [ ] All 26 tests in `core-shared` pass (8 test files) +- [ ] `pnpm typecheck` green across whole repo +- [ ] `pnpm dev --filter @repo/cms` serves `/admin` against new `@repo/core-cms` config +- [ ] `pnpm generate:types` succeeds +- [ ] Five new packages (`core-shared`, `core-cms`, `core-api`, `core-trpc`, `core-ui`) registered in workspace +- [ ] `tsconfig.base.json` exists at repo root with all five `@repo/core-*` aliases +- [ ] No deletions yet (old packages still intact for Plan 2-4 to drain) + +**Next plan:** Plan 2 — Feature migrations. Migrate the `blog` feature end-to-end as the canonical example, then `auth`, `marketing-pages`, `navigation`, `media`. Each feature follows the same template (entities → application → infrastructure → di → interface-adapters/controllers → integrations/cms + integrations/api → ui), composes its `/cms` export into `core-cms` and its `/api` export into `core-api`.