import { existsSync } from "node:fs"; import { join } from "node:path"; import type { PlopTypes } from "@turbo/gen"; import { assertAnchors } from "./lib/anchor-validate.js"; /** * Turbo generator: `feature` * * Scaffolds a Lazar-conformant feature package under `packages//` * matching the shape of the existing `navigation` reference feature. * * Phase 1 scope (intentionally limited): * - Single entity, single use case (`getX`) * - Skips Payload CMS collection/global templates (integrations/cms/**) * - Skips UI query helpers (ui/query.ts) — emits an empty barrel * - Skips faker-driven factories — emits empty stubs * - Skips multi-entity / multi-use-case * - Skips aggregator wiring (core-api/root.ts, core-cms/**, apps/web-next/server/bind-production.ts) * * After running, the developer must hand-wire the new feature into: * - apps/web-next/src/server/bind-production.ts (bindAll dispatcher) * - packages/core-api/src/root.ts (mount on app router) * - packages/core-cms/... (when CMS templates are added later) * * The generator prints these manual steps when it finishes. */ export default function generator(plop: PlopTypes.NodePlopAPI): void { plop.setGenerator("feature", { description: "Scaffold a Lazar-conformant feature package (single entity / single use case)", prompts: [ { type: "input", name: "name", message: "Feature package name (kebab-case, becomes @repo/ and packages//):", validate: (input: string) => { if (!input) return "Required"; if (!/^[a-z][a-z0-9-]*$/.test(input)) { return "Must be kebab-case (lowercase letters, digits, hyphens; must start with a letter)"; } return true; }, }, { type: "input", name: "entity", message: "Entity name (PascalCase singular, e.g. 'Widget' for a getWidget use case):", validate: (input: string) => { if (!input) return "Required"; if (!/^[A-Z][A-Za-z0-9]*$/.test(input)) { return "Must be PascalCase (e.g. Widget, BlogPost)"; } return true; }, }, { type: "input", name: "entityPlural", message: "Entity plural slug (kebab-case, used for Payload collection slug; e.g. 'widgets'):", validate: (input: string) => { if (!input) return "Required"; if (!/^[a-z][a-z0-9-]*$/.test(input)) { return "Must be kebab-case"; } return true; }, }, ], actions: [ // Top-level package files { type: "add", path: "packages/{{kebabCase name}}/package.json", templateFile: "templates/feature/package.json.hbs", }, { type: "add", path: "packages/{{kebabCase name}}/tsconfig.json", templateFile: "templates/feature/tsconfig.json.hbs", }, { type: "add", path: "packages/{{kebabCase name}}/vitest.config.ts", templateFile: "templates/feature/vitest.config.ts.hbs", }, { type: "add", path: "packages/{{kebabCase name}}/eslint.config.js", templateFile: "templates/feature/eslint.config.js.hbs", }, { type: "add", path: "packages/{{kebabCase name}}/turbo.json", templateFile: "templates/feature/turbo.json.hbs", }, { type: "add", path: "packages/{{kebabCase name}}/AGENTS.md", templateFile: "templates/feature/AGENTS.md.hbs", }, // Public surface { type: "add", path: "packages/{{kebabCase name}}/src/index.ts", templateFile: "templates/feature/src/index.ts.hbs", }, // Entities — models + errors { type: "add", path: "packages/{{kebabCase name}}/src/entities/models/{{kebabCase entity}}.ts", templateFile: "templates/feature/src/entities/models/entity.ts.hbs", }, { type: "add", path: "packages/{{kebabCase name}}/src/entities/models/{{kebabCase entity}}.test.ts", templateFile: "templates/feature/src/entities/models/entity.test.ts.hbs", }, { type: "add", path: "packages/{{kebabCase name}}/src/entities/errors/common.ts", templateFile: "templates/feature/src/entities/errors/common.ts.hbs", }, { type: "add", path: "packages/{{kebabCase name}}/src/entities/errors/{{kebabCase entity}}.ts", templateFile: "templates/feature/src/entities/errors/entity.ts.hbs", }, // Application layer { type: "add", path: "packages/{{kebabCase name}}/src/application/repositories/{{kebabCase entity}}.repository.interface.ts", templateFile: "templates/feature/src/application/repositories/entity.repository.interface.ts.hbs", }, { type: "add", path: "packages/{{kebabCase name}}/src/application/use-cases/get-{{kebabCase entity}}.use-case.ts", templateFile: "templates/feature/src/application/use-cases/get-entity.use-case.ts.hbs", }, { type: "add", path: "packages/{{kebabCase name}}/src/application/use-cases/get-{{kebabCase entity}}.use-case.test.ts", templateFile: "templates/feature/src/application/use-cases/get-entity.use-case.test.ts.hbs", }, // Infrastructure { type: "add", path: "packages/{{kebabCase name}}/src/infrastructure/repositories/{{kebabCase entity}}.repository.ts", templateFile: "templates/feature/src/infrastructure/repositories/entity.repository.ts.hbs", }, { type: "add", path: "packages/{{kebabCase name}}/src/infrastructure/repositories/{{kebabCase entity}}.repository.test.ts", templateFile: "templates/feature/src/infrastructure/repositories/entity.repository.test.ts.hbs", }, { type: "add", path: "packages/{{kebabCase name}}/src/infrastructure/repositories/{{kebabCase entity}}.repository.mock.ts", templateFile: "templates/feature/src/infrastructure/repositories/entity.repository.mock.ts.hbs", }, { type: "add", path: "packages/{{kebabCase name}}/src/infrastructure/repositories/{{kebabCase entity}}.repository.mock.test.ts", templateFile: "templates/feature/src/infrastructure/repositories/entity.repository.mock.test.ts.hbs", }, { type: "add", path: "packages/{{kebabCase name}}/src/infrastructure/repositories/{{kebabCase entity}}.repository.span.test.ts", templateFile: "templates/feature/src/infrastructure/repositories/entity.repository.span.test.ts.hbs", }, // Interface adapters { type: "add", path: "packages/{{kebabCase name}}/src/interface-adapters/controllers/get-{{kebabCase entity}}.controller.ts", templateFile: "templates/feature/src/interface-adapters/controllers/get-entity.controller.ts.hbs", }, { type: "add", path: "packages/{{kebabCase name}}/src/interface-adapters/controllers/get-{{kebabCase entity}}.controller.test.ts", templateFile: "templates/feature/src/interface-adapters/controllers/get-entity.controller.test.ts.hbs", }, // DI { type: "add", path: "packages/{{kebabCase name}}/src/di/symbols.ts", templateFile: "templates/feature/src/di/symbols.ts.hbs", }, { type: "add", path: "packages/{{kebabCase name}}/src/di/module.ts", templateFile: "templates/feature/src/di/module.ts.hbs", }, { type: "add", path: "packages/{{kebabCase name}}/src/di/container.ts", templateFile: "templates/feature/src/di/container.ts.hbs", }, { type: "add", path: "packages/{{kebabCase name}}/src/di/container.test.ts", templateFile: "templates/feature/src/di/container.test.ts.hbs", }, { type: "add", path: "packages/{{kebabCase name}}/src/di/bind-production.ts", templateFile: "templates/feature/src/di/bind-production.ts.hbs", }, { type: "add", path: "packages/{{kebabCase name}}/src/di/bind-dev-seed.ts", templateFile: "templates/feature/src/di/bind-dev-seed.ts.hbs", }, { type: "add", path: "packages/{{kebabCase name}}/src/di/bind-dev-seed.test.ts", templateFile: "templates/feature/src/di/bind-dev-seed.test.ts.hbs", }, // Integrations: api { type: "add", path: "packages/{{kebabCase name}}/src/integrations/api/procedures.ts", templateFile: "templates/feature/src/integrations/api/procedures.ts.hbs", }, { type: "add", path: "packages/{{kebabCase name}}/src/integrations/api/router.ts", templateFile: "templates/feature/src/integrations/api/router.ts.hbs", }, { type: "add", path: "packages/{{kebabCase name}}/src/integrations/api/router.test.ts", templateFile: "templates/feature/src/integrations/api/router.test.ts.hbs", }, // Seeds + factories + contracts (Phase 1: minimal stubs that still typecheck/test) { type: "add", path: "packages/{{kebabCase name}}/src/__seeds__/dev.ts", templateFile: "templates/feature/src/__seeds__/dev.ts.hbs", }, { type: "add", path: "packages/{{kebabCase name}}/src/__factories__/index.ts", templateFile: "templates/feature/src/__factories__/index.ts.hbs", }, { type: "add", path: "packages/{{kebabCase name}}/src/__factories__/{{kebabCase entity}}.factory.ts", templateFile: "templates/feature/src/__factories__/entity.factory.ts.hbs", }, { type: "add", path: "packages/{{kebabCase name}}/src/__contracts__/{{kebabCase entity}}-repository.contract.ts", templateFile: "templates/feature/src/__contracts__/entity-repository.contract.ts.hbs", }, // ui — empty barrel for Phase 1 (./ui subpath kept reserved) { type: "add", path: "packages/{{kebabCase name}}/src/ui/index.ts", templateFile: "templates/feature/src/ui/index.ts.hbs", }, // Final manual-wiring instructions printed to the user function printNextSteps(answers: Record): string { const a = answers as { name: string; entity: string; entityPlural: string }; const kebab = a.name; const constSym = a.name.toUpperCase().replace(/-/g, "_"); const pkg = `@repo/${kebab}`; return [ "", "─────────────────────────────────────────────────────────────", `Feature package ${pkg} scaffolded.`, "", "Next steps (manual aggregator wiring — not generated):", "", ` 1. pnpm install # link the new workspace package`, "", ` 2. apps/web-next/src/server/bind-production.ts:`, ` - import { bindProduction${cap(a.name)} } from "${pkg}/di/bind-production";`, ` - import { bindDevSeed${cap(a.name)} } from "${pkg}/di/bind-dev-seed";`, ` - call bindProduction${cap(a.name)}(config, tracer, logger) (production branch)`, ` - call await bindDevSeed${cap(a.name)}(tracer, logger) (dev-seed branch)`, "", ` 3. packages/core-api/src/root.ts:`, ` - import { ${camel(a.name)}Router } from "${pkg}/api";`, ` - mount it on the app router (e.g. ${camel(a.name)}: ${camel(a.name)}Router)`, "", ` 4. packages/core-api/package.json:`, ` - add "${pkg}": "workspace:*" to dependencies`, "", ` 5. apps/web-next/package.json:`, ` - add "${pkg}": "workspace:*" to dependencies`, "", ` 6. (Later) Add Payload CMS collection/global at:`, ` packages/${kebab}/src/integrations/cms/collections/${a.entityPlural}.ts`, ` and register in packages/core-cms/...`, "", ` 7. Verify: pnpm --filter ${pkg} lint typecheck test`, "", `Reference symbols exported by this package:`, ` - ${constSym}_SYMBOLS (from ${pkg}/* internal)`, ` - bindProduction${cap(a.name)}, bindDevSeed${cap(a.name)}`, ` - ${camel(a.name)}Router (tRPC)`, "─────────────────────────────────────────────────────────────", "", ].join("\n"); }, ], }); /** * Turbo generator: `event` * * Mode `publish` scaffolds an event contract under * `packages//src/events/.event.ts` (+ test) and * re-exports it from the feature's public surface at the `// ` * anchor. Mode `consume` (Task 40) scaffolds a handler in the consumer * feature. */ plop.setGenerator("event", { description: "Scaffold an event contract (publish) or handler (consume)", prompts: [ { type: "list", name: "mode", message: "Mode:", choices: ["publish", "consume"], }, { type: "input", name: "feature", message: "Owning feature (kebab-case; for publish: contract package; for consume: consumer package):", validate(input: string) { if (!/^[a-z][a-z0-9-]*$/.test(input)) return "Must be kebab-case"; if (!existsSync(join(process.cwd(), "packages", input, "src"))) { return `packages/${input}/src does not exist`; } return true; }, }, { type: "input", name: "event", message: "Event slug (dotted-kebab past tense, e.g. 'user.signed-up'):", validate(input: string) { if (!/^[a-z][a-z0-9-]*(\.[a-z][a-z0-9-]*)+$/.test(input)) { return "Must be dotted-kebab past tense (e.g. user.signed-up)"; } return true; }, }, { type: "input", name: "publisher", message: "Publisher feature (kebab-case; only for consume mode):", when(answers: { mode: string }) { return answers.mode === "consume"; }, validate(input: string, answers: { event: string }) { if (!/^[a-z][a-z0-9-]*$/.test(input)) return "Must be kebab-case"; const eventKebab = answers.event.replace(/\./g, "-"); const path = join( process.cwd(), "packages", input, "src", "events", `${eventKebab}.event.ts`, ); if (!existsSync(path)) { return `Publisher contract not found: ${path}`; } return true; }, }, ], actions(answers) { const a = answers as { mode: "publish" | "consume"; feature: string; event: string; publisher?: string; }; if (a.mode === "publish") return publishActions(a); throw new Error("consume mode is wired in Task 40"); }, }); } function publishActions(a: { feature: string; event: string; }): PlopTypes.ActionType[] { const eventKebab = a.event.replace(/\./g, "-"); const indexPath = `packages/${a.feature}/src/index.ts`; return [ () => { assertAnchors(process.cwd(), indexPath, ["// "]); return `Anchors verified in ${indexPath}`; }, { type: "add", path: `packages/${a.feature}/src/events/${eventKebab}.event.ts`, templateFile: "templates/event/publish/event.ts.hbs", data: { event: a.event, feature: a.feature, eventKebab }, }, { type: "add", path: `packages/${a.feature}/src/events/${eventKebab}.event.test.ts`, templateFile: "templates/event/publish/event.test.ts.hbs", data: { event: a.event, feature: a.feature, eventKebab }, }, { type: "modify", path: indexPath, pattern: /\/\/ /, template: `// \nexport {\n {{camelCase event}}Event,\n {{camelCase event}}EventSchema,\n type {{pascalCase event}}Event,\n} from "./events/{{kebabCase event}}.event";`, data: { event: a.event }, }, () => printPublishNextSteps(a), ]; } function printPublishNextSteps(a: { feature: string; event: string }): string { const eventKebab = a.event.replace(/\./g, "-"); return [ "", "─────────────────────────────────────────────────────────────", `Event ${a.feature}.${a.event} contract scaffolded.`, "", "Next steps (manual):", ` 1. Fill in the schema in packages/${a.feature}/src/events/${eventKebab}.event.ts`, ` 2. Pick a use case in packages/${a.feature}/src/application/use-cases/ that should publish.`, ` - Add 'bus: IEventBus' to the factory signature.`, ` - Call 'await bus.publish(${camel(a.event.replace(/\./g, "-"))}Event, payload)' after success.`, ` - Update the use case's DI binding to inject the bus.`, ` 3. Verify: pnpm --filter @repo/${a.feature} lint typecheck test`, "─────────────────────────────────────────────────────────────", "", ].join("\n"); } // Local helpers used inside the printNextSteps action — Plop's helpers aren't // available outside template strings, so we replicate the bits we need. function cap(input: string): string { return input .split(/[-_\s]+/) .filter(Boolean) .map((s) => s[0]!.toUpperCase() + s.slice(1)) .join(""); } function camel(input: string): string { const p = cap(input); return p ? p[0]!.toLowerCase() + p.slice(1) : ""; }