TypeScript emits an informational diagnostic when source files span multiple top-level dirs (src/ + tests/) without explicit rootDir. Setting rootDir="." satisfies the recommendation and matches the include pattern. Updates blog, auth, marketing-pages — and Plan 4 doc so navigation (still pending) starts correct. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
58 KiB
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:
- vitest.config.ts has
resolve.aliasfor@/ - tsconfig.json has NO
rootDir - Source files use relative imports; tests use
@/ - Payload-backed repos take
SanitizedConfigvia constructor (no@repo/core-cmsdep) - Feature
package.jsonhas NO@repo/core-cmsdep z.unknown()schemas should be.optional()
Schema design (this plan introduces)
Marketing-pages
pages collection — generic CMS page (about, contact, etc.):
title: text requiredslug: text unique(sidebar, auto-generated viaslugifyIfMissing)hero: group { heading: text, subheading: textarea, image: upload→media (optional) }layout: blocks [cta](uses genericctablock from@repo/core-shared/payload)status: select draft|published, default draft, required, sidebarpublishedAt: date sidebarseo: group { title: text required, description: textarea }(usesseoFieldsfromcore-shared)
siteSettings global:
- Port from
cms-core/src/globals/site-settings.tsas-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.tspackages/marketing-pages/src/entities/page.ts+.test.tspackages/marketing-pages/src/entities/site-settings.tspackages/marketing-pages/src/entities/errors.tspackages/marketing-pages/src/application/repositories/pages-repository.interface.tspackages/marketing-pages/src/application/repositories/site-settings-repository.interface.tspackages/marketing-pages/src/application/use-cases/get-page-by-slug.use-case.ts+.test.tspackages/marketing-pages/src/application/use-cases/get-site-settings.use-case.ts+.test.tspackages/marketing-pages/src/infrastructure/repositories/mock-pages.repository.tspackages/marketing-pages/src/infrastructure/repositories/payload-pages.repository.tspackages/marketing-pages/src/infrastructure/repositories/mock-site-settings.repository.tspackages/marketing-pages/src/infrastructure/repositories/payload-site-settings.repository.tspackages/marketing-pages/src/di/{symbols.ts,module.ts,container.ts,container.test.ts}packages/marketing-pages/src/interface-adapters/controllers/pages.controller.ts+.test.tspackages/marketing-pages/src/integrations/cms/collections/pages.tspackages/marketing-pages/src/integrations/cms/globals/site-settings.tspackages/marketing-pages/src/integrations/cms/index.tspackages/marketing-pages/src/integrations/api/router.ts+.test.tspackages/marketing-pages/src/ui/query.tspackages/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.tspackages/navigation/src/entities/header.tspackages/navigation/src/application/repositories/header-repository.interface.tspackages/navigation/src/application/use-cases/get-header.use-case.ts+.test.tspackages/navigation/src/infrastructure/repositories/mock-header.repository.tspackages/navigation/src/infrastructure/repositories/payload-header.repository.tspackages/navigation/src/di/{symbols.ts,module.ts,container.ts,container.test.ts}packages/navigation/src/interface-adapters/controllers/header.controller.tspackages/navigation/src/integrations/cms/globals/header.tspackages/navigation/src/integrations/cms/index.tspackages/navigation/src/integrations/api/router.ts+.test.tspackages/navigation/src/ui/query.ts
Modify:
packages/core-cms/src/payload.config.ts— registerpagescollection +siteSettingsandheaderglobals; add@repo/marketing-pagesand@repo/navigationdepspackages/core-cms/package.json— add depspackages/core-api/src/root.ts— composemarketingPagesandnavigationrouterspackages/core-api/package.json— add depstsconfig.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
{
"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/eslint-config": "workspace:*",
"@repo/typescript-config": "workspace:*",
"@types/node": "^22.0.0",
"vitest": "^3.1.0"
}
}
- Step 2: Create
packages/marketing-pages/tsconfig.json
{
"extends": "@repo/typescript-config/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
{
"extends": ["//"],
"tags": ["feature"]
}
- Step 4: Create
packages/marketing-pages/vitest.config.ts
import path from "node:path";
import { baseVitestConfig } from "@repo/typescript-config/vitest.base";
export default {
...baseVitestConfig,
resolve: {
alias: {
"@": path.resolve(__dirname, "./src"),
},
},
};
- Step 5: Create
packages/marketing-pages/src/index.ts
export {};
- Step 6: Add path aliases to
tsconfig.base.json
Add to compilerOptions.paths:
"@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
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
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
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
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
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
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
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
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
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
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
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)
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
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
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)
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
{
"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/eslint-config": "workspace:*",
"@repo/typescript-config": "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
"@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
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
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
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
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
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
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
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
export const NAVIGATION_SYMBOLS = {
IHeaderRepository: Symbol.for("navigation:IHeaderRepository"),
} as const;
- Step 8: Implement
packages/navigation/src/di/module.ts
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
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
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
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
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 —
getHeadertakes no input. No InputParseError needed.
- Step 2: Create
packages/navigation/src/integrations/cms/globals/header.ts
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
export { header } from "./globals/header";
- Step 4: Write test
packages/navigation/src/integrations/api/router.test.ts
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
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
type TrpcClient = {
navigation: {
header: { queryOptions: () => unknown };
};
};
export function headerQuery(client: TrpcClient) {
return client.navigation.header.queryOptions();
}
- Step 7: Replace
packages/navigation/src/index.ts
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
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-pagesand@repo/navigationtopackages/core-cms/package.json
Add to dependencies:
"@repo/marketing-pages": "workspace:*",
"@repo/navigation": "workspace:*",
- Step 2: Replace
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";
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
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.jsondependencies
"@repo/marketing-pages": "workspace:*",
"@repo/navigation": "workspace:*",
- Step 2: Replace
packages/core-api/src/root.ts
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
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:
pnpm dev --filter @repo/cms &
DEV_PID=$!
sleep 15
Test endpoints:
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:
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:
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
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/cmsadmin serves/admin/collections/pages,/admin/globals/site-settings,/admin/globals/header— all 200pnpm generate:typesproduces agenerated-types.tscontaining Pages, SiteSettings, Headercore-cms/src/payload.config.tsregisterspagescollection +siteSettingsandheaderglobalscore-api/src/root.tsexposes 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.