Files
agentic-dev/docs/superpowers/plans/2026-05-04-plan-4-marketing-pages-navigation.md
Danijel Martinek 0972645ebb refactor: rename eslint-config + typescript-config to core-eslint + core-typescript
Aligns tooling packages with the core-* naming convention used by all
other foundation packages (core-shared, core-cms, core-api, core-trpc,
core-ui). Updates ~50 files: package.json names, devDependencies,
tsconfig extends, eslint.config imports, vitest.config imports, AGENTS.md
references, and the boundaries plugin patterns to match the new paths.

The tooling-specific patterns in boundaries/elements are now ordered BEFORE
the broader core-* pattern to ensure correct first-match-wins behavior.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 10:37:07 +02:00

1876 lines
58 KiB
Markdown

# Vertical Refactor — Plan 4: Marketing-pages + Navigation
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task.
**Goal:** Add `@repo/marketing-pages` (Pages collection + SiteSettings global + read use-case + tRPC router) and `@repo/navigation` (Header global + read use-case + tRPC router). Compose both into `core-cms` and `core-api`. Demonstrates that the canonical pattern scales smoothly to features that own globals (not just collections) and to features with read-only surfaces.
**Architecture:** Both features follow the proven canonical shape from Plans 2-3. Marketing-pages is comparable in size to blog. Navigation is smaller — globals are simpler than collections (no per-record CRUD, just one document). Both use the per-feature InversifyJS container + payload-backed repository (constructor-injected with `SanitizedConfig`) pattern.
**Tech Stack:** Same as Plans 2-3.
**Plan position:** Plan 4 of 6.
- Plan 1 ✅ Foundation
- Plan 2 ✅ Blog feature
- Plan 3 ✅ Auth + Media + restore blog relations
- **Plan 4 (this doc):** Marketing-pages + Navigation
- Plan 5: App + UI integration
- Plan 6: Cleanup + boundary enforcement + Playwright + docs rewrite
**Spec reference:** `docs/superpowers/specs/2026-04-21-vertical-monorepo-refactor-design.md`
**All Plan 2/3 lessons apply:**
1. vitest.config.ts has `resolve.alias` for `@/`
2. tsconfig.json has NO `rootDir`
3. Source files use relative imports; tests use `@/`
4. Payload-backed repos take `SanitizedConfig` via constructor (no `@repo/core-cms` dep)
5. Feature `package.json` has NO `@repo/core-cms` dep
6. `z.unknown()` schemas should be `.optional()`
---
## Schema design (this plan introduces)
### Marketing-pages
`pages` collection — generic CMS page (about, contact, etc.):
- `title: text required`
- `slug: text unique` (sidebar, auto-generated via `slugifyIfMissing`)
- `hero: group { heading: text, subheading: textarea, image: upload→media (optional) }`
- `layout: blocks [cta]` (uses generic `cta` block from `@repo/core-shared/payload`)
- `status: select draft|published, default draft, required, sidebar`
- `publishedAt: date sidebar`
- `seo: group { title: text required, description: textarea }` (uses `seoFields` from `core-shared`)
`siteSettings` global:
- Port from `cms-core/src/globals/site-settings.ts` as-is
- Slug: `site-settings`
- Fields: `siteName: text required defaultValue "My App"`, `siteDescription: textarea`
### Navigation
`header` global:
- `logo: upload → media (optional)`
- `items: array { label: text required, href: text required, external: checkbox default false }`
---
## File Structure
**Create — `@repo/marketing-pages` package:**
- `packages/marketing-pages/{package.json,tsconfig.json,turbo.json,vitest.config.ts}`
- `packages/marketing-pages/src/index.ts`
- `packages/marketing-pages/src/entities/page.ts` + `.test.ts`
- `packages/marketing-pages/src/entities/site-settings.ts`
- `packages/marketing-pages/src/entities/errors.ts`
- `packages/marketing-pages/src/application/repositories/pages-repository.interface.ts`
- `packages/marketing-pages/src/application/repositories/site-settings-repository.interface.ts`
- `packages/marketing-pages/src/application/use-cases/get-page-by-slug.use-case.ts` + `.test.ts`
- `packages/marketing-pages/src/application/use-cases/get-site-settings.use-case.ts` + `.test.ts`
- `packages/marketing-pages/src/infrastructure/repositories/mock-pages.repository.ts`
- `packages/marketing-pages/src/infrastructure/repositories/payload-pages.repository.ts`
- `packages/marketing-pages/src/infrastructure/repositories/mock-site-settings.repository.ts`
- `packages/marketing-pages/src/infrastructure/repositories/payload-site-settings.repository.ts`
- `packages/marketing-pages/src/di/{symbols.ts,module.ts,container.ts,container.test.ts}`
- `packages/marketing-pages/src/interface-adapters/controllers/pages.controller.ts` + `.test.ts`
- `packages/marketing-pages/src/integrations/cms/collections/pages.ts`
- `packages/marketing-pages/src/integrations/cms/globals/site-settings.ts`
- `packages/marketing-pages/src/integrations/cms/index.ts`
- `packages/marketing-pages/src/integrations/api/router.ts` + `.test.ts`
- `packages/marketing-pages/src/ui/query.ts`
- `packages/marketing-pages/tests/page-by-slug.feature.test.ts`
**Create — `@repo/navigation` package:**
- `packages/navigation/{package.json,tsconfig.json,turbo.json,vitest.config.ts}`
- `packages/navigation/src/index.ts`
- `packages/navigation/src/entities/header.ts`
- `packages/navigation/src/application/repositories/header-repository.interface.ts`
- `packages/navigation/src/application/use-cases/get-header.use-case.ts` + `.test.ts`
- `packages/navigation/src/infrastructure/repositories/mock-header.repository.ts`
- `packages/navigation/src/infrastructure/repositories/payload-header.repository.ts`
- `packages/navigation/src/di/{symbols.ts,module.ts,container.ts,container.test.ts}`
- `packages/navigation/src/interface-adapters/controllers/header.controller.ts`
- `packages/navigation/src/integrations/cms/globals/header.ts`
- `packages/navigation/src/integrations/cms/index.ts`
- `packages/navigation/src/integrations/api/router.ts` + `.test.ts`
- `packages/navigation/src/ui/query.ts`
**Modify:**
- `packages/core-cms/src/payload.config.ts` — register `pages` collection + `siteSettings` and `header` globals; add `@repo/marketing-pages` and `@repo/navigation` deps
- `packages/core-cms/package.json` — add deps
- `packages/core-api/src/root.ts` — compose `marketingPages` and `navigation` routers
- `packages/core-api/package.json` — add deps
- `tsconfig.base.json` — add aliases for both new packages
---
## Phase A: Marketing-pages feature
### Task 4.1: Scaffold @repo/marketing-pages package
**Files:** `packages/marketing-pages/{package.json,tsconfig.json,turbo.json,vitest.config.ts,src/index.ts}` + path aliases.
- [ ] **Step 1: Create `packages/marketing-pages/package.json`**
```json
{
"name": "@repo/marketing-pages",
"private": true,
"version": "0.0.0",
"type": "module",
"exports": {
".": "./src/index.ts",
"./cms": "./src/integrations/cms/index.ts",
"./api": "./src/integrations/api/router.ts"
},
"scripts": {
"build": "tsc --noEmit",
"lint": "eslint .",
"test": "vitest run --passWithNoTests",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@repo/core-shared": "workspace:*",
"@trpc/server": "^11.0.0",
"inversify": "^6.2.0",
"payload": "^3.14.0",
"reflect-metadata": "^0.2.2",
"zod": "^3.24.0"
},
"devDependencies": {
"@repo/core-eslint": "workspace:*",
"@repo/core-typescript": "workspace:*",
"@types/node": "^22.0.0",
"vitest": "^3.1.0"
}
}
```
- [ ] **Step 2: Create `packages/marketing-pages/tsconfig.json`**
```json
{
"extends": "@repo/core-typescript/base.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": ".",
"lib": ["ES2022", "DOM"],
"jsx": "preserve",
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["src/**/*", "tests/**/*"],
"exclude": ["node_modules", "dist"]
}
```
- [ ] **Step 3: Create `packages/marketing-pages/turbo.json`**
```json
{
"extends": ["//"],
"tags": ["feature"]
}
```
- [ ] **Step 4: Create `packages/marketing-pages/vitest.config.ts`**
```typescript
import path from "node:path";
import { baseVitestConfig } from "@repo/core-typescript/vitest.base";
export default {
...baseVitestConfig,
resolve: {
alias: {
"@": path.resolve(__dirname, "./src"),
},
},
};
```
- [ ] **Step 5: Create `packages/marketing-pages/src/index.ts`**
```typescript
export {};
```
- [ ] **Step 6: Add path aliases to `tsconfig.base.json`**
Add to `compilerOptions.paths`:
```json
"@repo/marketing-pages": ["packages/marketing-pages/src/index.ts"],
"@repo/marketing-pages/cms": ["packages/marketing-pages/src/integrations/cms/index.ts"],
"@repo/marketing-pages/api": ["packages/marketing-pages/src/integrations/api/router.ts"]
```
- [ ] **Step 7: Install + verify**
Run: `pnpm install`
Verify: `pnpm list --recursive --depth=-1 | grep marketing-pages`
- [ ] **Step 8: Commit**
```bash
git add packages/marketing-pages tsconfig.base.json pnpm-lock.yaml
git commit -m "feat(marketing-pages): scaffold empty package with feature tag + path aliases"
```
---
### Task 4.2: Page + SiteSettings entities + tests
- [ ] **Step 1: Write `packages/marketing-pages/src/entities/page.test.ts`**
```typescript
import { describe, expect, it } from "vitest";
import { pageSchema, pageStatusSchema } from "./page";
describe("pageSchema", () => {
it("accepts a minimal valid page", () => {
const result = pageSchema.parse({
id: "p1",
title: "About",
slug: "about",
hero: { heading: "About us" },
layout: [],
seo: { title: "About — My App" },
createdAt: new Date(),
updatedAt: new Date(),
});
expect(result.status).toBe("draft");
expect(result.publishedAt).toBeNull();
});
it("accepts a published page with publishedAt", () => {
const result = pageSchema.parse({
id: "p1",
title: "About",
slug: "about",
hero: { heading: "About us" },
layout: [],
status: "published",
publishedAt: new Date(),
seo: { title: "About" },
createdAt: new Date(),
updatedAt: new Date(),
});
expect(result.status).toBe("published");
expect(result.publishedAt).toBeInstanceOf(Date);
});
it("rejects empty title", () => {
expect(() =>
pageSchema.parse({
id: "p1",
title: "",
slug: "about",
hero: { heading: "h" },
layout: [],
seo: { title: "x" },
createdAt: new Date(),
updatedAt: new Date(),
}),
).toThrow();
});
});
describe("pageStatusSchema", () => {
it("accepts draft and published", () => {
expect(pageStatusSchema.parse("draft")).toBe("draft");
expect(pageStatusSchema.parse("published")).toBe("published");
});
});
```
- [ ] **Step 2: Run — expect failure**
Run: `cd packages/marketing-pages && pnpm vitest run src/entities/page.test.ts`
Expected: FAIL — "Cannot find module './page'".
- [ ] **Step 3: Implement `packages/marketing-pages/src/entities/page.ts`**
```typescript
import { z } from "zod";
export const pageStatusSchema = z.enum(["draft", "published"]);
export const heroSchema = z.object({
heading: z.string().min(1).max(255),
subheading: z.string().optional(),
imageId: z.string().optional(),
});
export const pageSchema = z.object({
id: z.string(),
title: z.string().min(1).max(255),
slug: z.string().min(1).max(255),
hero: heroSchema,
layout: z.array(z.unknown()),
status: pageStatusSchema.default("draft"),
publishedAt: z.date().nullable().default(null),
seo: z.object({
title: z.string().min(1),
description: z.string().optional(),
}),
createdAt: z.date(),
updatedAt: z.date(),
});
export type Page = z.infer<typeof pageSchema>;
export type PageStatus = z.infer<typeof pageStatusSchema>;
export type Hero = z.infer<typeof heroSchema>;
```
- [ ] **Step 4: Implement `packages/marketing-pages/src/entities/site-settings.ts`**
```typescript
import { z } from "zod";
export const siteSettingsSchema = z.object({
siteName: z.string().min(1),
siteDescription: z.string().optional(),
});
export type SiteSettings = z.infer<typeof siteSettingsSchema>;
```
- [ ] **Step 5: Implement `packages/marketing-pages/src/entities/errors.ts`**
```typescript
export class PageNotFoundError extends Error {
constructor(message = "Page not found", options?: ErrorOptions) {
super(message, options);
}
}
export class InputParseError extends Error {
constructor(message: string, options?: ErrorOptions) {
super(message, options);
}
}
```
- [ ] **Step 6: Run — expect 4 tests pass**
Run: `cd packages/marketing-pages && pnpm vitest run src/entities`
Expected: PASS — 4 tests.
- [ ] **Step 7: Commit**
```bash
git add packages/marketing-pages/src/entities
git commit -m "feat(marketing-pages): add Page + SiteSettings entities + errors"
```
---
### Task 4.3: Repository interfaces (pages + site-settings)
- [ ] **Step 1: Create `packages/marketing-pages/src/application/repositories/pages-repository.interface.ts`**
```typescript
import type { Page } from "../../entities/page";
export interface IPagesRepository {
getPageBySlug(slug: string): Promise<Page | undefined>;
getPages(options?: {
status?: string;
limit?: number;
offset?: number;
}): Promise<Page[]>;
}
```
- [ ] **Step 2: Create `packages/marketing-pages/src/application/repositories/site-settings-repository.interface.ts`**
```typescript
import type { SiteSettings } from "../../entities/site-settings";
export interface ISiteSettingsRepository {
getSiteSettings(): Promise<SiteSettings>;
}
```
- [ ] **Step 3: Verify compiles**
Run: `cd packages/marketing-pages && pnpm typecheck`
Expected: PASS.
- [ ] **Step 4: Commit**
```bash
git add packages/marketing-pages/src/application
git commit -m "feat(marketing-pages): add IPagesRepository + ISiteSettingsRepository interfaces"
```
---
### Task 4.4: Use-cases + tests (RED until DI lands)
- [ ] **Step 1: Write test `packages/marketing-pages/src/application/use-cases/get-page-by-slug.use-case.test.ts`**
```typescript
import { beforeEach, describe, expect, it } from "vitest";
import { marketingPagesContainer } from "@/di/container";
import { MARKETING_PAGES_SYMBOLS } from "@/di/symbols";
import { MockPagesRepository } from "@/infrastructure/repositories/mock-pages.repository";
import type { IPagesRepository } from "@/application/repositories/pages-repository.interface";
import { getPageBySlugUseCase } from "./get-page-by-slug.use-case";
describe("getPageBySlugUseCase", () => {
let repo: MockPagesRepository;
beforeEach(() => {
if (marketingPagesContainer.isBound(MARKETING_PAGES_SYMBOLS.IPagesRepository)) {
marketingPagesContainer.unbind(MARKETING_PAGES_SYMBOLS.IPagesRepository);
}
repo = new MockPagesRepository();
marketingPagesContainer
.bind<IPagesRepository>(MARKETING_PAGES_SYMBOLS.IPagesRepository)
.toConstantValue(repo);
});
it("returns the page when found", async () => {
const result = await getPageBySlugUseCase("about");
expect(result?.slug).toBe("about");
});
it("returns undefined when not found", async () => {
const result = await getPageBySlugUseCase("missing-page");
expect(result).toBeUndefined();
});
});
```
- [ ] **Step 2: Implement `packages/marketing-pages/src/application/use-cases/get-page-by-slug.use-case.ts`**
```typescript
import type { Page } from "../../entities/page";
import { marketingPagesContainer } from "../../di/container";
import { MARKETING_PAGES_SYMBOLS } from "../../di/symbols";
import type { IPagesRepository } from "../repositories/pages-repository.interface";
export async function getPageBySlugUseCase(
slug: string,
): Promise<Page | undefined> {
const repo = marketingPagesContainer.get<IPagesRepository>(
MARKETING_PAGES_SYMBOLS.IPagesRepository,
);
return repo.getPageBySlug(slug);
}
```
- [ ] **Step 3: Commit (test RED)**
```bash
git add packages/marketing-pages/src/application/use-cases/get-page-by-slug.use-case.ts packages/marketing-pages/src/application/use-cases/get-page-by-slug.use-case.test.ts
git commit -m "feat(marketing-pages): add getPageBySlugUseCase (test red until DI lands)"
```
- [ ] **Step 4: Write test `packages/marketing-pages/src/application/use-cases/get-site-settings.use-case.test.ts`**
```typescript
import { beforeEach, describe, expect, it } from "vitest";
import { marketingPagesContainer } from "@/di/container";
import { MARKETING_PAGES_SYMBOLS } from "@/di/symbols";
import { MockSiteSettingsRepository } from "@/infrastructure/repositories/mock-site-settings.repository";
import type { ISiteSettingsRepository } from "@/application/repositories/site-settings-repository.interface";
import { getSiteSettingsUseCase } from "./get-site-settings.use-case";
describe("getSiteSettingsUseCase", () => {
let repo: MockSiteSettingsRepository;
beforeEach(() => {
if (marketingPagesContainer.isBound(MARKETING_PAGES_SYMBOLS.ISiteSettingsRepository)) {
marketingPagesContainer.unbind(MARKETING_PAGES_SYMBOLS.ISiteSettingsRepository);
}
repo = new MockSiteSettingsRepository();
marketingPagesContainer
.bind<ISiteSettingsRepository>(MARKETING_PAGES_SYMBOLS.ISiteSettingsRepository)
.toConstantValue(repo);
});
it("returns the seeded site settings", async () => {
const result = await getSiteSettingsUseCase();
expect(result.siteName).toBe("My App");
});
});
```
- [ ] **Step 5: Implement `packages/marketing-pages/src/application/use-cases/get-site-settings.use-case.ts`**
```typescript
import type { SiteSettings } from "../../entities/site-settings";
import { marketingPagesContainer } from "../../di/container";
import { MARKETING_PAGES_SYMBOLS } from "../../di/symbols";
import type { ISiteSettingsRepository } from "../repositories/site-settings-repository.interface";
export async function getSiteSettingsUseCase(): Promise<SiteSettings> {
const repo = marketingPagesContainer.get<ISiteSettingsRepository>(
MARKETING_PAGES_SYMBOLS.ISiteSettingsRepository,
);
return repo.getSiteSettings();
}
```
- [ ] **Step 6: Commit (test RED)**
```bash
git add packages/marketing-pages/src/application/use-cases/get-site-settings.use-case.ts packages/marketing-pages/src/application/use-cases/get-site-settings.use-case.test.ts
git commit -m "feat(marketing-pages): add getSiteSettingsUseCase (test red until DI lands)"
```
---
### Task 4.5: Mock repositories (pages + site-settings)
- [ ] **Step 1: Create `packages/marketing-pages/src/infrastructure/repositories/mock-pages.repository.ts`**
```typescript
import "reflect-metadata";
import { injectable } from "inversify";
import type { IPagesRepository } from "../../application/repositories/pages-repository.interface";
import type { Page } from "../../entities/page";
const SEED_DATE = new Date("2026-01-01T00:00:00.000Z");
@injectable()
export class MockPagesRepository implements IPagesRepository {
private _pages: Page[] = [
{
id: "p1",
title: "About",
slug: "about",
hero: { heading: "About us" },
layout: [],
status: "published",
publishedAt: SEED_DATE,
seo: { title: "About — My App" },
createdAt: SEED_DATE,
updatedAt: SEED_DATE,
},
];
async getPageBySlug(slug: string): Promise<Page | undefined> {
return this._pages.find((p) => p.slug === slug);
}
async getPages(options?: {
status?: string;
limit?: number;
offset?: number;
}): Promise<Page[]> {
let result = [...this._pages];
if (options?.status) {
result = result.filter((p) => p.status === options.status);
}
const offset = options?.offset ?? 0;
const limit = options?.limit ?? 50;
return result.slice(offset, offset + limit);
}
}
```
- [ ] **Step 2: Create `packages/marketing-pages/src/infrastructure/repositories/mock-site-settings.repository.ts`**
```typescript
import "reflect-metadata";
import { injectable } from "inversify";
import type { ISiteSettingsRepository } from "../../application/repositories/site-settings-repository.interface";
import type { SiteSettings } from "../../entities/site-settings";
@injectable()
export class MockSiteSettingsRepository implements ISiteSettingsRepository {
async getSiteSettings(): Promise<SiteSettings> {
return {
siteName: "My App",
siteDescription: "A vertical-feature monorepo template",
};
}
}
```
- [ ] **Step 3: Verify compiles**
Run: `cd packages/marketing-pages && pnpm typecheck`
Expected: PASS.
- [ ] **Step 4: Commit**
```bash
git add packages/marketing-pages/src/infrastructure/repositories
git commit -m "feat(marketing-pages): add Mock pages + site-settings repositories"
```
---
### Task 4.6: Per-feature DI container + tests
- [ ] **Step 1: Write `packages/marketing-pages/src/di/container.test.ts`**
```typescript
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { marketingPagesContainer } from "./container";
import { MARKETING_PAGES_SYMBOLS } from "./symbols";
import { MarketingPagesModule } from "./module";
import { MockPagesRepository } from "@/infrastructure/repositories/mock-pages.repository";
import { MockSiteSettingsRepository } from "@/infrastructure/repositories/mock-site-settings.repository";
import type { IPagesRepository } from "@/application/repositories/pages-repository.interface";
import type { ISiteSettingsRepository } from "@/application/repositories/site-settings-repository.interface";
describe("marketingPagesContainer", () => {
beforeEach(() => {
marketingPagesContainer.unbindAll();
marketingPagesContainer.load(MarketingPagesModule);
});
afterEach(() => {
marketingPagesContainer.unbindAll();
});
it("resolves IPagesRepository to MockPagesRepository", () => {
const repo = marketingPagesContainer.get<IPagesRepository>(
MARKETING_PAGES_SYMBOLS.IPagesRepository,
);
expect(repo).toBeInstanceOf(MockPagesRepository);
});
it("resolves ISiteSettingsRepository to MockSiteSettingsRepository", () => {
const repo = marketingPagesContainer.get<ISiteSettingsRepository>(
MARKETING_PAGES_SYMBOLS.ISiteSettingsRepository,
);
expect(repo).toBeInstanceOf(MockSiteSettingsRepository);
});
});
```
- [ ] **Step 2: Implement `packages/marketing-pages/src/di/symbols.ts`**
```typescript
export const MARKETING_PAGES_SYMBOLS = {
IPagesRepository: Symbol.for("marketing-pages:IPagesRepository"),
ISiteSettingsRepository: Symbol.for("marketing-pages:ISiteSettingsRepository"),
} as const;
```
- [ ] **Step 3: Implement `packages/marketing-pages/src/di/module.ts`**
```typescript
import { ContainerModule, type interfaces } from "inversify";
import type { IPagesRepository } from "../application/repositories/pages-repository.interface";
import type { ISiteSettingsRepository } from "../application/repositories/site-settings-repository.interface";
import { MockPagesRepository } from "../infrastructure/repositories/mock-pages.repository";
import { MockSiteSettingsRepository } from "../infrastructure/repositories/mock-site-settings.repository";
import { MARKETING_PAGES_SYMBOLS } from "./symbols";
export const MarketingPagesModule = new ContainerModule(
(bind: interfaces.Bind) => {
bind<IPagesRepository>(MARKETING_PAGES_SYMBOLS.IPagesRepository).to(
MockPagesRepository,
);
bind<ISiteSettingsRepository>(
MARKETING_PAGES_SYMBOLS.ISiteSettingsRepository,
).to(MockSiteSettingsRepository);
},
);
```
- [ ] **Step 4: Implement `packages/marketing-pages/src/di/container.ts`**
```typescript
import "reflect-metadata";
import { Container } from "inversify";
import { MarketingPagesModule } from "./module";
export const marketingPagesContainer = new Container({
defaultScope: "Singleton",
});
marketingPagesContainer.load(MarketingPagesModule);
```
- [ ] **Step 5: Run container test — expect 2 PASS**
Run: `cd packages/marketing-pages && pnpm vitest run src/di/container.test.ts`
Expected: PASS — 2 tests.
- [ ] **Step 6: Run previously-RED use-case tests**
Run: `cd packages/marketing-pages && pnpm vitest run src/application/use-cases`
Expected: PASS — 3 tests (page-by-slug 2 + site-settings 1).
- [ ] **Step 7: Commit**
```bash
git add packages/marketing-pages/src/di
git commit -m "feat(marketing-pages): add per-feature InversifyJS container"
```
---
### Task 4.7: Payload-backed repositories
Both repositories take `SanitizedConfig` via constructor (per Plan 2 lesson #4).
- [ ] **Step 1: Implement `packages/marketing-pages/src/infrastructure/repositories/payload-pages.repository.ts`**
```typescript
import "reflect-metadata";
import { injectable } from "inversify";
import { getPayload } from "payload";
import type { SanitizedConfig } from "payload";
import type { IPagesRepository } from "../../application/repositories/pages-repository.interface";
import type { Page } from "../../entities/page";
type PayloadPageDoc = {
id: string | number;
title?: string | null;
slug?: string | null;
hero?:
| {
heading?: string | null;
subheading?: string | null;
image?: string | number | { id: string | number } | null;
}
| null;
layout?: unknown[] | null;
status?: string | null;
publishedAt?: string | null;
seo?: { title?: string | null; description?: string | null } | null;
createdAt?: string | null;
updatedAt?: string | null;
};
function mapDoc(doc: PayloadPageDoc): Page {
const imageId =
doc.hero && typeof doc.hero.image === "object" && doc.hero.image !== null
? String(doc.hero.image.id)
: doc.hero?.image != null
? String(doc.hero.image)
: undefined;
return {
id: String(doc.id),
title: doc.title ?? "",
slug: doc.slug ?? "",
hero: {
heading: doc.hero?.heading ?? "",
subheading: doc.hero?.subheading ?? undefined,
imageId,
},
layout: doc.layout ?? [],
status: doc.status === "published" ? "published" : "draft",
publishedAt: doc.publishedAt ? new Date(doc.publishedAt) : null,
seo: {
title: doc.seo?.title ?? "",
description: doc.seo?.description ?? undefined,
},
createdAt: doc.createdAt ? new Date(doc.createdAt) : new Date(0),
updatedAt: doc.updatedAt ? new Date(doc.updatedAt) : new Date(0),
};
}
@injectable()
export class PayloadPagesRepository implements IPagesRepository {
private config: SanitizedConfig;
constructor(config: SanitizedConfig) {
this.config = config;
}
async getPageBySlug(slug: string): Promise<Page | undefined> {
const payload = await getPayload({ config: this.config });
const result = await payload.find({
collection: "pages",
where: { slug: { equals: slug } },
limit: 1,
overrideAccess: false,
});
const doc = result.docs[0] as PayloadPageDoc | undefined;
return doc ? mapDoc(doc) : undefined;
}
async getPages(options?: {
status?: string;
limit?: number;
offset?: number;
}): Promise<Page[]> {
const payload = await getPayload({ config: this.config });
const where: Record<string, { equals: string }> = {};
if (options?.status) where.status = { equals: options.status };
const result = await payload.find({
collection: "pages",
where: where as never,
limit: options?.limit ?? 50,
page: options?.offset
? Math.floor(options.offset / (options.limit ?? 50)) + 1
: 1,
overrideAccess: false,
});
return result.docs.map((d) => mapDoc(d as PayloadPageDoc));
}
}
```
- [ ] **Step 2: Implement `packages/marketing-pages/src/infrastructure/repositories/payload-site-settings.repository.ts`**
```typescript
import "reflect-metadata";
import { injectable } from "inversify";
import { getPayload } from "payload";
import type { SanitizedConfig } from "payload";
import type { ISiteSettingsRepository } from "../../application/repositories/site-settings-repository.interface";
import type { SiteSettings } from "../../entities/site-settings";
type PayloadSiteSettings = {
siteName?: string | null;
siteDescription?: string | null;
};
@injectable()
export class PayloadSiteSettingsRepository implements ISiteSettingsRepository {
private config: SanitizedConfig;
constructor(config: SanitizedConfig) {
this.config = config;
}
async getSiteSettings(): Promise<SiteSettings> {
const payload = await getPayload({ config: this.config });
const doc = (await payload.findGlobal({
slug: "site-settings",
overrideAccess: false,
})) as PayloadSiteSettings;
return {
siteName: doc.siteName ?? "My App",
siteDescription: doc.siteDescription ?? undefined,
};
}
}
```
- [ ] **Step 3: Verify compiles**
Run: `cd packages/marketing-pages && pnpm typecheck`
Expected: PASS.
- [ ] **Step 4: Commit**
```bash
git add packages/marketing-pages/src/infrastructure/repositories
git commit -m "feat(marketing-pages): add Payload-backed pages + site-settings repos (constructor-injected config)"
```
---
### Task 4.8: Pages controller + tests
- [ ] **Step 1: Write test `packages/marketing-pages/src/interface-adapters/controllers/pages.controller.test.ts`**
```typescript
import { beforeEach, describe, expect, it } from "vitest";
import { marketingPagesContainer } from "@/di/container";
import { MARKETING_PAGES_SYMBOLS } from "@/di/symbols";
import { MockPagesRepository } from "@/infrastructure/repositories/mock-pages.repository";
import { MockSiteSettingsRepository } from "@/infrastructure/repositories/mock-site-settings.repository";
import type { IPagesRepository } from "@/application/repositories/pages-repository.interface";
import type { ISiteSettingsRepository } from "@/application/repositories/site-settings-repository.interface";
import { InputParseError } from "@/entities/errors";
import {
getPageBySlugController,
getSiteSettingsController,
} from "./pages.controller";
describe("pages controller", () => {
beforeEach(() => {
if (marketingPagesContainer.isBound(MARKETING_PAGES_SYMBOLS.IPagesRepository)) {
marketingPagesContainer.unbind(MARKETING_PAGES_SYMBOLS.IPagesRepository);
}
if (marketingPagesContainer.isBound(MARKETING_PAGES_SYMBOLS.ISiteSettingsRepository)) {
marketingPagesContainer.unbind(MARKETING_PAGES_SYMBOLS.ISiteSettingsRepository);
}
marketingPagesContainer
.bind<IPagesRepository>(MARKETING_PAGES_SYMBOLS.IPagesRepository)
.toConstantValue(new MockPagesRepository());
marketingPagesContainer
.bind<ISiteSettingsRepository>(MARKETING_PAGES_SYMBOLS.ISiteSettingsRepository)
.toConstantValue(new MockSiteSettingsRepository());
});
describe("getPageBySlugController", () => {
it("returns the page when found", async () => {
const result = await getPageBySlugController({ slug: "about" });
expect(result?.slug).toBe("about");
});
it("throws InputParseError on missing slug", async () => {
await expect(
getPageBySlugController({} as { slug: string }),
).rejects.toBeInstanceOf(InputParseError);
});
});
describe("getSiteSettingsController", () => {
it("returns site settings", async () => {
const result = await getSiteSettingsController();
expect(result.siteName).toBe("My App");
});
});
});
```
- [ ] **Step 2: Implement `packages/marketing-pages/src/interface-adapters/controllers/pages.controller.ts`**
```typescript
import { z } from "zod";
import { InputParseError } from "../../entities/errors";
import type { Page } from "../../entities/page";
import type { SiteSettings } from "../../entities/site-settings";
import { getPageBySlugUseCase } from "../../application/use-cases/get-page-by-slug.use-case";
import { getSiteSettingsUseCase } from "../../application/use-cases/get-site-settings.use-case";
const getBySlugInputSchema = z.object({
slug: z.string().min(1),
});
export async function getPageBySlugController(input: {
slug: string;
}): Promise<Page | undefined> {
const parsed = getBySlugInputSchema.safeParse(input);
if (!parsed.success) {
throw new InputParseError("Invalid get-page-by-slug input", {
cause: parsed.error,
});
}
return getPageBySlugUseCase(parsed.data.slug);
}
export async function getSiteSettingsController(): Promise<SiteSettings> {
return getSiteSettingsUseCase();
}
```
- [ ] **Step 3: Run — expect 3 PASS**
Run: `cd packages/marketing-pages && pnpm vitest run src/interface-adapters`
Expected: PASS — 3 tests.
- [ ] **Step 4: Commit**
```bash
git add packages/marketing-pages/src/interface-adapters
git commit -m "feat(marketing-pages): add pages controller (getBySlug + getSiteSettings)"
```
---
### Task 4.9: Pages collection + SiteSettings global
- [ ] **Step 1: Create `packages/marketing-pages/src/integrations/cms/collections/pages.ts`**
```typescript
import type { CollectionConfig } from "payload";
import {
cta,
seoFields,
slugifyIfMissing,
} from "@repo/core-shared/payload";
export const pages: CollectionConfig = {
slug: "pages",
admin: {
useAsTitle: "title",
defaultColumns: ["title", "status", "updatedAt"],
},
hooks: {
beforeChange: [slugifyIfMissing],
},
versions: {
drafts: true,
},
fields: [
{
name: "title",
type: "text",
required: true,
maxLength: 255,
},
{
name: "slug",
type: "text",
unique: true,
admin: {
position: "sidebar",
description: "Auto-generated from title if left empty",
},
},
{
name: "hero",
type: "group",
fields: [
{ name: "heading", type: "text", required: true },
{ name: "subheading", type: "textarea" },
{
name: "image",
type: "upload",
relationTo: "media",
},
],
},
{
name: "layout",
type: "blocks",
blocks: [cta],
},
{
name: "status",
type: "select",
options: [
{ label: "Draft", value: "draft" },
{ label: "Published", value: "published" },
],
defaultValue: "draft",
required: true,
admin: { position: "sidebar" },
},
{
name: "publishedAt",
type: "date",
admin: {
position: "sidebar",
date: { pickerAppearance: "dayAndTime" },
},
},
seoFields,
],
};
```
- [ ] **Step 2: Create `packages/marketing-pages/src/integrations/cms/globals/site-settings.ts`**
```typescript
import type { GlobalConfig } from "payload";
export const siteSettings: GlobalConfig = {
slug: "site-settings",
admin: {
group: "Settings",
},
fields: [
{
name: "siteName",
type: "text",
required: true,
defaultValue: "My App",
},
{
name: "siteDescription",
type: "textarea",
},
],
};
```
- [ ] **Step 3: Create `packages/marketing-pages/src/integrations/cms/index.ts`**
```typescript
export { pages } from "./collections/pages";
export { siteSettings } from "./globals/site-settings";
```
- [ ] **Step 4: Verify compiles**
Run: `cd packages/marketing-pages && pnpm typecheck`
Expected: PASS.
- [ ] **Step 5: Commit**
```bash
git add packages/marketing-pages/src/integrations/cms
git commit -m "feat(marketing-pages): add pages collection + siteSettings global"
```
---
### Task 4.10: tRPC router + tests
- [ ] **Step 1: Write test `packages/marketing-pages/src/integrations/api/router.test.ts`**
```typescript
import { beforeEach, describe, expect, it } from "vitest";
import { marketingPagesContainer } from "@/di/container";
import { MARKETING_PAGES_SYMBOLS } from "@/di/symbols";
import { MockPagesRepository } from "@/infrastructure/repositories/mock-pages.repository";
import { MockSiteSettingsRepository } from "@/infrastructure/repositories/mock-site-settings.repository";
import type { IPagesRepository } from "@/application/repositories/pages-repository.interface";
import type { ISiteSettingsRepository } from "@/application/repositories/site-settings-repository.interface";
import { marketingPagesRouter } from "./router";
describe("marketingPagesRouter", () => {
beforeEach(() => {
if (marketingPagesContainer.isBound(MARKETING_PAGES_SYMBOLS.IPagesRepository)) {
marketingPagesContainer.unbind(MARKETING_PAGES_SYMBOLS.IPagesRepository);
}
if (marketingPagesContainer.isBound(MARKETING_PAGES_SYMBOLS.ISiteSettingsRepository)) {
marketingPagesContainer.unbind(MARKETING_PAGES_SYMBOLS.ISiteSettingsRepository);
}
marketingPagesContainer
.bind<IPagesRepository>(MARKETING_PAGES_SYMBOLS.IPagesRepository)
.toConstantValue(new MockPagesRepository());
marketingPagesContainer
.bind<ISiteSettingsRepository>(MARKETING_PAGES_SYMBOLS.ISiteSettingsRepository)
.toConstantValue(new MockSiteSettingsRepository());
});
it("exposes pageBySlug + siteSettings procedures", () => {
const names = Object.keys(marketingPagesRouter._def.procedures);
expect(names).toContain("pageBySlug");
expect(names).toContain("siteSettings");
});
it("pageBySlug returns the seeded About page", async () => {
const caller = marketingPagesRouter.createCaller({});
const result = await caller.pageBySlug({ slug: "about" });
expect(result?.title).toBe("About");
});
it("siteSettings returns site name", async () => {
const caller = marketingPagesRouter.createCaller({});
const result = await caller.siteSettings();
expect(result.siteName).toBe("My App");
});
});
```
- [ ] **Step 2: Implement `packages/marketing-pages/src/integrations/api/router.ts`**
```typescript
import { z } from "zod";
import { router, publicProcedure } from "@repo/core-shared/trpc/init";
import {
getPageBySlugController,
getSiteSettingsController,
} from "../../interface-adapters/controllers/pages.controller";
export const marketingPagesRouter = router({
pageBySlug: publicProcedure
.input(z.object({ slug: z.string().min(1) }))
.query(({ input }) => getPageBySlugController(input)),
siteSettings: publicProcedure.query(() => getSiteSettingsController()),
});
export type MarketingPagesRouter = typeof marketingPagesRouter;
```
- [ ] **Step 3: Run — expect 3 PASS**
Run: `cd packages/marketing-pages && pnpm vitest run src/integrations/api/router.test.ts`
Expected: PASS — 3 tests.
- [ ] **Step 4: Commit**
```bash
git add packages/marketing-pages/src/integrations/api
git commit -m "feat(marketing-pages): add tRPC router with pageBySlug + siteSettings"
```
---
### Task 4.11: ui/query + barrel + feature test
- [ ] **Step 1: Create `packages/marketing-pages/src/ui/query.ts`**
```typescript
type TrpcClient = {
marketingPages: {
pageBySlug: { queryOptions: (input: { slug: string }) => unknown };
siteSettings: { queryOptions: () => unknown };
};
};
export function pageBySlugQuery(client: TrpcClient, slug: string) {
return client.marketingPages.pageBySlug.queryOptions({ slug });
}
export function siteSettingsQuery(client: TrpcClient) {
return client.marketingPages.siteSettings.queryOptions();
}
```
- [ ] **Step 2: Replace `packages/marketing-pages/src/index.ts`**
```typescript
export type { Page, PageStatus, Hero } from "./entities/page";
export type { SiteSettings } from "./entities/site-settings";
export { PageNotFoundError, InputParseError } from "./entities/errors";
export { pageBySlugQuery, siteSettingsQuery } from "./ui/query";
```
- [ ] **Step 3: Create feature test `packages/marketing-pages/tests/page-by-slug.feature.test.ts`**
```typescript
import { beforeEach, describe, expect, it } from "vitest";
import { marketingPagesContainer } from "../src/di/container";
import { MARKETING_PAGES_SYMBOLS } from "../src/di/symbols";
import { MockPagesRepository } from "../src/infrastructure/repositories/mock-pages.repository";
import { MockSiteSettingsRepository } from "../src/infrastructure/repositories/mock-site-settings.repository";
import type { IPagesRepository } from "../src/application/repositories/pages-repository.interface";
import type { ISiteSettingsRepository } from "../src/application/repositories/site-settings-repository.interface";
import { marketingPagesRouter } from "../src/integrations/api/router";
describe("marketing-pages feature: page-by-slug end-to-end", () => {
beforeEach(() => {
if (marketingPagesContainer.isBound(MARKETING_PAGES_SYMBOLS.IPagesRepository)) {
marketingPagesContainer.unbind(MARKETING_PAGES_SYMBOLS.IPagesRepository);
}
if (marketingPagesContainer.isBound(MARKETING_PAGES_SYMBOLS.ISiteSettingsRepository)) {
marketingPagesContainer.unbind(MARKETING_PAGES_SYMBOLS.ISiteSettingsRepository);
}
marketingPagesContainer
.bind<IPagesRepository>(MARKETING_PAGES_SYMBOLS.IPagesRepository)
.toConstantValue(new MockPagesRepository());
marketingPagesContainer
.bind<ISiteSettingsRepository>(MARKETING_PAGES_SYMBOLS.ISiteSettingsRepository)
.toConstantValue(new MockSiteSettingsRepository());
});
it("fetches a page by slug via tRPC and returns the domain entity", async () => {
const caller = marketingPagesRouter.createCaller({});
const page = await caller.pageBySlug({ slug: "about" });
expect(page?.title).toBe("About");
expect(page?.hero.heading).toBe("About us");
expect(page?.status).toBe("published");
});
});
```
- [ ] **Step 4: Run all marketing-pages tests**
Run: `cd packages/marketing-pages && pnpm test`
Expected: PASS — entities (4) + di (2) + use-cases (3) + controller (3) + router (3) + feature (1) = 16 tests.
- [ ] **Step 5: Commit**
```bash
git add packages/marketing-pages/src/ui packages/marketing-pages/src/index.ts packages/marketing-pages/tests
git commit -m "feat(marketing-pages): add ui/query + barrel + feature test"
```
---
## Phase B: Navigation feature
### Task 4.12: Scaffold @repo/navigation package
- [ ] **Step 1: Create `packages/navigation/package.json`**
```json
{
"name": "@repo/navigation",
"private": true,
"version": "0.0.0",
"type": "module",
"exports": {
".": "./src/index.ts",
"./cms": "./src/integrations/cms/index.ts",
"./api": "./src/integrations/api/router.ts"
},
"scripts": {
"build": "tsc --noEmit",
"lint": "eslint .",
"test": "vitest run --passWithNoTests",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@repo/core-shared": "workspace:*",
"@trpc/server": "^11.0.0",
"inversify": "^6.2.0",
"payload": "^3.14.0",
"reflect-metadata": "^0.2.2",
"zod": "^3.24.0"
},
"devDependencies": {
"@repo/core-eslint": "workspace:*",
"@repo/core-typescript": "workspace:*",
"@types/node": "^22.0.0",
"vitest": "^3.1.0"
}
}
```
- [ ] **Step 2: Create `packages/navigation/tsconfig.json`** (same shape as marketing-pages tsconfig from Task 4.1 step 2)
- [ ] **Step 3: Create `packages/navigation/turbo.json`** (same as Task 4.1 step 3)
- [ ] **Step 4: Create `packages/navigation/vitest.config.ts`** (same as Task 4.1 step 4)
- [ ] **Step 5: Create `packages/navigation/src/index.ts`** (`export {};`)
- [ ] **Step 6: Add path aliases to `tsconfig.base.json`**
```json
"@repo/navigation": ["packages/navigation/src/index.ts"],
"@repo/navigation/cms": ["packages/navigation/src/integrations/cms/index.ts"],
"@repo/navigation/api": ["packages/navigation/src/integrations/api/router.ts"]
```
- [ ] **Step 7: Install + verify**
Run: `pnpm install`
Verify: `pnpm list --recursive --depth=-1 | grep navigation`
- [ ] **Step 8: Commit**
```bash
git add packages/navigation tsconfig.base.json pnpm-lock.yaml
git commit -m "feat(navigation): scaffold empty package with feature tag + path aliases"
```
---
### Task 4.13: Header entity, repo interface, use-case + tests, mock repo, payload repo, DI
This task batches all the application + infrastructure + DI for navigation since it's small.
- [ ] **Step 1: Create `packages/navigation/src/entities/header.ts`**
```typescript
import { z } from "zod";
export const headerItemSchema = z.object({
label: z.string().min(1).max(64),
href: z.string().min(1),
external: z.boolean().default(false),
});
export const headerSchema = z.object({
logoId: z.string().optional(),
items: z.array(headerItemSchema),
});
export type Header = z.infer<typeof headerSchema>;
export type HeaderItem = z.infer<typeof headerItemSchema>;
```
- [ ] **Step 2: Create `packages/navigation/src/application/repositories/header-repository.interface.ts`**
```typescript
import type { Header } from "../../entities/header";
export interface IHeaderRepository {
getHeader(): Promise<Header>;
}
```
- [ ] **Step 3: Write test `packages/navigation/src/application/use-cases/get-header.use-case.test.ts`**
```typescript
import { beforeEach, describe, expect, it } from "vitest";
import { navigationContainer } from "@/di/container";
import { NAVIGATION_SYMBOLS } from "@/di/symbols";
import { MockHeaderRepository } from "@/infrastructure/repositories/mock-header.repository";
import type { IHeaderRepository } from "@/application/repositories/header-repository.interface";
import { getHeaderUseCase } from "./get-header.use-case";
describe("getHeaderUseCase", () => {
beforeEach(() => {
if (navigationContainer.isBound(NAVIGATION_SYMBOLS.IHeaderRepository)) {
navigationContainer.unbind(NAVIGATION_SYMBOLS.IHeaderRepository);
}
navigationContainer
.bind<IHeaderRepository>(NAVIGATION_SYMBOLS.IHeaderRepository)
.toConstantValue(new MockHeaderRepository());
});
it("returns the seeded header items", async () => {
const result = await getHeaderUseCase();
expect(result.items.length).toBeGreaterThan(0);
expect(result.items[0]?.label).toBe("Home");
});
});
```
- [ ] **Step 4: Implement `packages/navigation/src/application/use-cases/get-header.use-case.ts`**
```typescript
import type { Header } from "../../entities/header";
import { navigationContainer } from "../../di/container";
import { NAVIGATION_SYMBOLS } from "../../di/symbols";
import type { IHeaderRepository } from "../repositories/header-repository.interface";
export async function getHeaderUseCase(): Promise<Header> {
const repo = navigationContainer.get<IHeaderRepository>(
NAVIGATION_SYMBOLS.IHeaderRepository,
);
return repo.getHeader();
}
```
- [ ] **Step 5: Implement `packages/navigation/src/infrastructure/repositories/mock-header.repository.ts`**
```typescript
import "reflect-metadata";
import { injectable } from "inversify";
import type { IHeaderRepository } from "../../application/repositories/header-repository.interface";
import type { Header } from "../../entities/header";
@injectable()
export class MockHeaderRepository implements IHeaderRepository {
async getHeader(): Promise<Header> {
return {
items: [
{ label: "Home", href: "/", external: false },
{ label: "Blog", href: "/blog", external: false },
{ label: "About", href: "/about", external: false },
],
};
}
}
```
- [ ] **Step 6: Implement `packages/navigation/src/infrastructure/repositories/payload-header.repository.ts`**
```typescript
import "reflect-metadata";
import { injectable } from "inversify";
import { getPayload } from "payload";
import type { SanitizedConfig } from "payload";
import type { IHeaderRepository } from "../../application/repositories/header-repository.interface";
import type { Header, HeaderItem } from "../../entities/header";
type PayloadHeaderGlobal = {
logo?: string | number | { id: string | number } | null;
items?: Array<{
label?: string | null;
href?: string | null;
external?: boolean | null;
}> | null;
};
@injectable()
export class PayloadHeaderRepository implements IHeaderRepository {
private config: SanitizedConfig;
constructor(config: SanitizedConfig) {
this.config = config;
}
async getHeader(): Promise<Header> {
const payload = await getPayload({ config: this.config });
const doc = (await payload.findGlobal({
slug: "header",
overrideAccess: false,
})) as PayloadHeaderGlobal;
const logoId =
typeof doc.logo === "object" && doc.logo !== null
? String(doc.logo.id)
: doc.logo != null
? String(doc.logo)
: undefined;
const items: HeaderItem[] = (doc.items ?? []).map((item) => ({
label: item.label ?? "",
href: item.href ?? "",
external: item.external ?? false,
}));
return { logoId, items };
}
}
```
- [ ] **Step 7: Implement `packages/navigation/src/di/symbols.ts`**
```typescript
export const NAVIGATION_SYMBOLS = {
IHeaderRepository: Symbol.for("navigation:IHeaderRepository"),
} as const;
```
- [ ] **Step 8: Implement `packages/navigation/src/di/module.ts`**
```typescript
import { ContainerModule, type interfaces } from "inversify";
import type { IHeaderRepository } from "../application/repositories/header-repository.interface";
import { MockHeaderRepository } from "../infrastructure/repositories/mock-header.repository";
import { NAVIGATION_SYMBOLS } from "./symbols";
export const NavigationModule = new ContainerModule((bind: interfaces.Bind) => {
bind<IHeaderRepository>(NAVIGATION_SYMBOLS.IHeaderRepository).to(
MockHeaderRepository,
);
});
```
- [ ] **Step 9: Implement `packages/navigation/src/di/container.ts`**
```typescript
import "reflect-metadata";
import { Container } from "inversify";
import { NavigationModule } from "./module";
export const navigationContainer = new Container({ defaultScope: "Singleton" });
navigationContainer.load(NavigationModule);
```
- [ ] **Step 10: Write `packages/navigation/src/di/container.test.ts`**
```typescript
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { navigationContainer } from "./container";
import { NAVIGATION_SYMBOLS } from "./symbols";
import { NavigationModule } from "./module";
import { MockHeaderRepository } from "@/infrastructure/repositories/mock-header.repository";
import type { IHeaderRepository } from "@/application/repositories/header-repository.interface";
describe("navigationContainer", () => {
beforeEach(() => {
navigationContainer.unbindAll();
navigationContainer.load(NavigationModule);
});
afterEach(() => {
navigationContainer.unbindAll();
});
it("resolves IHeaderRepository to MockHeaderRepository", () => {
const repo = navigationContainer.get<IHeaderRepository>(
NAVIGATION_SYMBOLS.IHeaderRepository,
);
expect(repo).toBeInstanceOf(MockHeaderRepository);
});
});
```
- [ ] **Step 11: Run all tests**
Run: `cd packages/navigation && pnpm test`
Expected: PASS — 2 tests (use-case 1 + container 1).
- [ ] **Step 12: Commit**
```bash
git add packages/navigation/src
git commit -m "feat(navigation): add Header entity + use-case + mock/payload repos + DI container"
```
---
### Task 4.14: Header controller + tRPC router + tests + CMS global + barrel
- [ ] **Step 1: Create `packages/navigation/src/interface-adapters/controllers/header.controller.ts`**
```typescript
import type { Header } from "../../entities/header";
import { getHeaderUseCase } from "../../application/use-cases/get-header.use-case";
export async function getHeaderController(): Promise<Header> {
return getHeaderUseCase();
}
```
> Note: No Zod input — `getHeader` takes no input. No InputParseError needed.
- [ ] **Step 2: Create `packages/navigation/src/integrations/cms/globals/header.ts`**
```typescript
import type { GlobalConfig } from "payload";
export const header: GlobalConfig = {
slug: "header",
admin: {
group: "Navigation",
},
fields: [
{
name: "logo",
type: "upload",
relationTo: "media",
},
{
name: "items",
type: "array",
fields: [
{ name: "label", type: "text", required: true },
{ name: "href", type: "text", required: true },
{ name: "external", type: "checkbox", defaultValue: false },
],
},
],
};
```
- [ ] **Step 3: Create `packages/navigation/src/integrations/cms/index.ts`**
```typescript
export { header } from "./globals/header";
```
- [ ] **Step 4: Write test `packages/navigation/src/integrations/api/router.test.ts`**
```typescript
import { beforeEach, describe, expect, it } from "vitest";
import { navigationContainer } from "@/di/container";
import { NAVIGATION_SYMBOLS } from "@/di/symbols";
import { MockHeaderRepository } from "@/infrastructure/repositories/mock-header.repository";
import type { IHeaderRepository } from "@/application/repositories/header-repository.interface";
import { navigationRouter } from "./router";
describe("navigationRouter", () => {
beforeEach(() => {
if (navigationContainer.isBound(NAVIGATION_SYMBOLS.IHeaderRepository)) {
navigationContainer.unbind(NAVIGATION_SYMBOLS.IHeaderRepository);
}
navigationContainer
.bind<IHeaderRepository>(NAVIGATION_SYMBOLS.IHeaderRepository)
.toConstantValue(new MockHeaderRepository());
});
it("exposes header procedure", () => {
const names = Object.keys(navigationRouter._def.procedures);
expect(names).toContain("header");
});
it("header returns 3 items", async () => {
const caller = navigationRouter.createCaller({});
const result = await caller.header();
expect(result.items).toHaveLength(3);
});
});
```
- [ ] **Step 5: Implement `packages/navigation/src/integrations/api/router.ts`**
```typescript
import { router, publicProcedure } from "@repo/core-shared/trpc/init";
import { getHeaderController } from "../../interface-adapters/controllers/header.controller";
export const navigationRouter = router({
header: publicProcedure.query(() => getHeaderController()),
});
export type NavigationRouter = typeof navigationRouter;
```
- [ ] **Step 6: Create `packages/navigation/src/ui/query.ts`**
```typescript
type TrpcClient = {
navigation: {
header: { queryOptions: () => unknown };
};
};
export function headerQuery(client: TrpcClient) {
return client.navigation.header.queryOptions();
}
```
- [ ] **Step 7: Replace `packages/navigation/src/index.ts`**
```typescript
export type { Header, HeaderItem } from "./entities/header";
export { headerQuery } from "./ui/query";
```
- [ ] **Step 8: Run all navigation tests**
Run: `cd packages/navigation && pnpm test`
Expected: PASS — 4 tests (use-case 1 + container 1 + router 2).
- [ ] **Step 9: Commit**
```bash
git add packages/navigation/src
git commit -m "feat(navigation): add controller + tRPC router + header global + barrel"
```
---
## Phase C: Compose into core-cms + core-api
### Task 4.15: Wire pages + globals into core-cms
- [ ] **Step 1: Add `@repo/marketing-pages` and `@repo/navigation` to `packages/core-cms/package.json`**
Add to `dependencies`:
```json
"@repo/marketing-pages": "workspace:*",
"@repo/navigation": "workspace:*",
```
- [ ] **Step 2: Replace `packages/core-cms/src/payload.config.ts`**
```typescript
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";
import { users } from "@repo/auth/cms";
import { articles } from "@repo/blog/cms";
import { media } from "@repo/media/cms";
import { pages, siteSettings } from "@repo/marketing-pages/cms";
import { header } from "@repo/navigation/cms";
const filename = fileURLToPath(import.meta.url);
const dirname = path.dirname(filename);
export default buildConfig({
editor: lexicalEditor(),
collections: [users, articles, pages, media],
globals: [siteSettings, header],
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"),
},
});
```
- [ ] **Step 3: Install + typecheck**
Run: `pnpm install` then `pnpm typecheck --filter @repo/core-cms`
Expected: PASS.
- [ ] **Step 4: Commit**
```bash
git add packages/core-cms pnpm-lock.yaml
git commit -m "feat(core-cms): compose pages + siteSettings + header into payload config"
```
---
### Task 4.16: Wire marketingPagesRouter + navigationRouter into core-api
- [ ] **Step 1: Add to `packages/core-api/package.json` dependencies**
```json
"@repo/marketing-pages": "workspace:*",
"@repo/navigation": "workspace:*",
```
- [ ] **Step 2: Replace `packages/core-api/src/root.ts`**
```typescript
import { router } from "@repo/core-shared/trpc/init";
import { authRouter } from "@repo/auth/api";
import { blogRouter } from "@repo/blog/api";
import { marketingPagesRouter } from "@repo/marketing-pages/api";
import { navigationRouter } from "@repo/navigation/api";
export const appRouter = router({
auth: authRouter,
blog: blogRouter,
marketingPages: marketingPagesRouter,
navigation: navigationRouter,
});
export type AppRouter = typeof appRouter;
```
- [ ] **Step 3: Install + typecheck**
Run: `pnpm install` then `pnpm typecheck --filter @repo/core-api --filter @repo/marketing-pages --filter @repo/navigation`
Expected: ALL PASS.
- [ ] **Step 4: Commit**
```bash
git add packages/core-api pnpm-lock.yaml
git commit -m "feat(core-api): compose marketingPages + navigation routers into appRouter"
```
---
### Task 4.17: Final verification + cms smoke test
- [ ] **Step 1: Run all tests**
Run: `pnpm test --filter @repo/marketing-pages --filter @repo/navigation --filter @repo/blog --filter @repo/auth --filter @repo/core-shared`
Expected: PASS. Marketing-pages: 16, Navigation: 4, Blog: 26, Auth: 24, Core-shared: 26 = 96 total.
- [ ] **Step 2: Repo-wide typecheck**
Run: `pnpm typecheck`
Expected: PASS for all packages except pre-existing `@repo/api` and `@repo/ui` failures.
- [ ] **Step 3: CMS admin smoke test**
Verify postgres: `docker ps | grep postgres`. If not running: `docker compose up -d postgres`.
Start cms in background:
```bash
pnpm dev --filter @repo/cms &
DEV_PID=$!
sleep 15
```
Test endpoints:
```bash
curl -sf -o /dev/null -w "%{http_code}\n" http://localhost:3001/admin
curl -sf -o /dev/null -w "%{http_code}\n" http://localhost:3001/admin/collections/pages
curl -sf -o /dev/null -w "%{http_code}\n" http://localhost:3001/admin/globals/site-settings
curl -sf -o /dev/null -w "%{http_code}\n" http://localhost:3001/admin/globals/header
```
Expected: ALL return 200.
Kill:
```bash
kill $DEV_PID 2>/dev/null
pkill -f "next.*3001" 2>/dev/null
```
- [ ] **Step 4: Regenerate types**
Run: `cd apps/cms && pnpm generate:types`
Verify:
```bash
cd /Users/danijel/Documents/Projects/template-vertical/.worktrees/vertical-refactor
grep -E "Page|SiteSetting|Header" packages/core-cms/src/generated-types.ts | wc -l
```
Expected: at least 5 occurrences.
- [ ] **Step 5: Commit regenerated types if changed**
```bash
git add packages/core-cms/src/generated-types.ts
git diff --cached --quiet || git commit -m "feat(core-cms): regenerate types — now includes Pages, SiteSettings, Header"
```
---
## Plan 4 Done Criteria
- [ ] Marketing-pages tests pass (16)
- [ ] Navigation tests pass (4)
- [ ] All other features still pass (blog 26, auth 24)
- [ ] `apps/cms` admin serves `/admin/collections/pages`, `/admin/globals/site-settings`, `/admin/globals/header` — all 200
- [ ] `pnpm generate:types` produces a `generated-types.ts` containing Pages, SiteSettings, Header
- [ ] `core-cms/src/payload.config.ts` registers `pages` collection + `siteSettings` and `header` globals
- [ ] `core-api/src/root.ts` exposes 4 namespaces: `auth`, `blog`, `marketingPages`, `navigation`
**Next plan:** Plan 5 — App + UI integration. Wire `core-trpc` (client + per-framework providers), add tRPC route handlers in `apps/web-next` and `apps/web-tanstack`, build example pages (`/`, `/about`, `/blog/[slug]`) that consume the feature ui/query helpers. First plan that actually renders the features in a browser.