Two diagnostics from the IDE on turbo/generators/config.ts: 1. `Cannot find module '@turbo/gen'` — there was no tsconfig in turbo/generators/, so the IDE was opening config.ts in loose mode without proper module resolution context. Added a small tsconfig.json that extends tsconfig.base, sets NodeNext module resolution, and includes only ./**/*.ts (templates excluded). 2. `Parameter 'answers' implicitly has an 'any' type` — annotated the printNextSteps custom-action function param as `Record<string, unknown>` (Plop's runtime answers shape). The inner cast to the typed Answers shape was already in place. `npx tsc --noEmit -p turbo/generators/tsconfig.json` is now clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
343 lines
13 KiB
TypeScript
343 lines
13 KiB
TypeScript
import type { PlopTypes } from "@turbo/gen";
|
|
|
|
/**
|
|
* Turbo generator: `feature`
|
|
*
|
|
* Scaffolds a Lazar-conformant feature package under `packages/<name>/`
|
|
* 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/<name> and packages/<name>/):",
|
|
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, unknown>): 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");
|
|
},
|
|
],
|
|
});
|
|
}
|
|
|
|
// 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) : "";
|
|
}
|