Generated handler + Payload event-task via gen event consume,
threaded through symbols / both binders / cms re-export at the
configured anchors. bus.subscribe wires the in-memory delivery in
dev-seed; the __events.auth.user.signed-up.marketing-pages Payload
task closes the production-bus loop.
Also fixes two generator-level issues found during Phase 8:
- Drop publisher prompt's `when` clause so --args can supply the
4th positional argument (Plop limitation: --args cannot bypass
conditional prompts). Validate runs only in consume mode.
- Switch event-task.ts.hbs from TaskConfig<"slug-string"> to
TaskConfig<{ input: ...; output: object }> since runtime-generated
event slugs are not keys of TypedJobs['tasks'].
772 lines
29 KiB
TypeScript
772 lines
29 KiB
TypeScript
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/<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 {
|
|
// Handlebars helper used by templates that branch on prompt values
|
|
// (e.g. `gen job`'s inputShape void/typed split).
|
|
plop.setHelper("eq", (a: unknown, b: unknown) => a === b);
|
|
|
|
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");
|
|
},
|
|
],
|
|
});
|
|
|
|
/**
|
|
* Turbo generator: `event`
|
|
*
|
|
* Mode `publish` scaffolds an event contract under
|
|
* `packages/<feature>/src/events/<event-kebab>.event.ts` (+ test) and
|
|
* re-exports it from the feature's public surface at the `// <gen:events>`
|
|
* 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; required for consume, ignored for publish):",
|
|
// Always shown — `--args` cannot bypass conditional prompts (Plop limitation).
|
|
// Publish mode accepts any value (it's ignored by publishActions).
|
|
validate(input: string, answers: { mode: string; event: string }) {
|
|
if (answers.mode === "publish") return true;
|
|
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);
|
|
if (!a.publisher) {
|
|
throw new Error("consume mode requires a publisher feature");
|
|
}
|
|
return consumeActions(a as Required<typeof a>);
|
|
},
|
|
});
|
|
|
|
/**
|
|
* Turbo generator: `job`
|
|
*
|
|
* Scaffolds a background job inside an existing feature: factory + test +
|
|
* Payload TaskConfig, plus the per-feature DI binding (span+capture wrap)
|
|
* and re-export from `integrations/cms/index.ts`.
|
|
*/
|
|
plop.setGenerator("job", {
|
|
description: "Scaffold a background job in an existing feature",
|
|
prompts: [
|
|
{
|
|
type: "input",
|
|
name: "feature",
|
|
message: "Feature (kebab-case, must exist):",
|
|
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: "job",
|
|
message: "Job slug (verb-noun kebab, e.g. 'send-welcome-email'):",
|
|
validate(input: string) {
|
|
if (!/^[a-z][a-z0-9-]+$/.test(input)) return "Must be kebab-case";
|
|
return true;
|
|
},
|
|
},
|
|
{
|
|
type: "list",
|
|
name: "inputShape",
|
|
message: "Input shape:",
|
|
choices: ["void", "typed"],
|
|
default: "void",
|
|
},
|
|
],
|
|
actions(answers) {
|
|
const a = answers as {
|
|
feature: string;
|
|
job: string;
|
|
inputShape: "void" | "typed";
|
|
};
|
|
return jobActions(a);
|
|
},
|
|
});
|
|
}
|
|
|
|
function jobActions(a: {
|
|
feature: string;
|
|
job: string;
|
|
inputShape: "void" | "typed";
|
|
}): PlopTypes.ActionType[] {
|
|
const symbolFile = `packages/${a.feature}/src/di/symbols.ts`;
|
|
const bindProdFile = `packages/${a.feature}/src/di/bind-production.ts`;
|
|
const bindDevFile = `packages/${a.feature}/src/di/bind-dev-seed.ts`;
|
|
const cmsIndexFile = `packages/${a.feature}/src/integrations/cms/index.ts`;
|
|
return [
|
|
() => {
|
|
assertAnchors(process.cwd(), symbolFile, ["// <gen:job-symbols>"]);
|
|
assertAnchors(process.cwd(), bindProdFile, ["// <gen:jobs>"]);
|
|
assertAnchors(process.cwd(), bindDevFile, ["// <gen:jobs>"]);
|
|
assertAnchors(process.cwd(), cmsIndexFile, ["// <gen:job-tasks>"]);
|
|
return "All required anchors present";
|
|
},
|
|
{
|
|
type: "add",
|
|
path: `packages/${a.feature}/src/jobs/${a.job}.job.ts`,
|
|
templateFile: "templates/job/job.ts.hbs",
|
|
data: a,
|
|
},
|
|
{
|
|
type: "add",
|
|
path: `packages/${a.feature}/src/jobs/${a.job}.job.test.ts`,
|
|
templateFile: "templates/job/job.test.ts.hbs",
|
|
data: a,
|
|
},
|
|
{
|
|
type: "add",
|
|
path: `packages/${a.feature}/src/integrations/cms/jobs/${a.job}.task.ts`,
|
|
templateFile: "templates/job/task.ts.hbs",
|
|
data: a,
|
|
},
|
|
{
|
|
type: "modify",
|
|
path: cmsIndexFile,
|
|
pattern: /\/\/ <gen:job-tasks>/,
|
|
template: `// <gen:job-tasks>\nexport { ${camel(a.job)}Task } from "./jobs/${a.job}.task";`,
|
|
},
|
|
{
|
|
type: "modify",
|
|
path: symbolFile,
|
|
pattern: /\/\/ <gen:job-symbols>/,
|
|
template: `// <gen:job-symbols>\n I${pascalCase(a.job)}Job: Symbol.for("@repo/${a.feature}/${camel(a.job)}Job"),`,
|
|
},
|
|
{
|
|
type: "modify",
|
|
path: bindProdFile,
|
|
pattern: /\/\/ <gen:jobs>/,
|
|
template: jobBindBlock(a),
|
|
},
|
|
{
|
|
type: "modify",
|
|
path: bindDevFile,
|
|
pattern: /\/\/ <gen:jobs>/,
|
|
template: jobBindBlock(a),
|
|
},
|
|
() => printJobNextSteps(a),
|
|
];
|
|
}
|
|
|
|
function jobBindBlock(a: { feature: string; job: string }): string {
|
|
const factoryFn = `${camel(a.job)}Job`;
|
|
const symbol = `${constantCase(a.feature)}_SYMBOLS.I${pascalCase(a.job)}Job`;
|
|
const containerVar = `${camel(a.feature)}Container`;
|
|
return `// <gen:jobs>
|
|
const wrapped${pascalCase(a.job)} = withSpan(
|
|
tracer,
|
|
{ name: "${a.feature}.${camel(a.job)}", op: "job" },
|
|
withCapture(
|
|
logger,
|
|
{
|
|
feature: "${a.feature}",
|
|
layer: "job",
|
|
name: "${a.feature}.${camel(a.job)}",
|
|
},
|
|
${factoryFn}(),
|
|
),
|
|
);
|
|
if (${containerVar}.isBound(${symbol})) {
|
|
${containerVar}.unbind(${symbol});
|
|
}
|
|
${containerVar}.bind(${symbol}).toConstantValue(wrapped${pascalCase(a.job)});`;
|
|
}
|
|
|
|
function printJobNextSteps(a: { feature: string; job: string }): string {
|
|
return [
|
|
"",
|
|
"─────────────────────────────────────────────────────────────",
|
|
`Job ${a.feature}.${a.job} scaffolded.`,
|
|
"",
|
|
"Next steps (manual):",
|
|
` 1. Fill in the job body in packages/${a.feature}/src/jobs/${a.job}.job.ts`,
|
|
` 2. Add Payload field config to inputSchema in ${a.job}.task.ts (matches your Zod schema).`,
|
|
` 3. Add the import for ${camel(a.job)}Job at the top of bind-production.ts and bind-dev-seed.ts.`,
|
|
` 4. (Optional) Add a cron schedule in core-cms's buildConfig if this job runs periodically.`,
|
|
` 5. Verify: pnpm --filter @repo/${a.feature} lint typecheck test`,
|
|
"─────────────────────────────────────────────────────────────",
|
|
"",
|
|
].join("\n");
|
|
}
|
|
|
|
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, ["// <gen:events>"]);
|
|
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: /\/\/ <gen:events>/,
|
|
template: `// <gen:events>\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 consumeActions(a: {
|
|
feature: string;
|
|
event: string;
|
|
publisher: string;
|
|
}): PlopTypes.ActionType[] {
|
|
const eventKebab = a.event.replace(/\./g, "-");
|
|
const handlerName = `on${pascalCase(a.publisher)}${pascalCase(a.event)}`;
|
|
const symbolFile = `packages/${a.feature}/src/di/symbols.ts`;
|
|
const bindProdFile = `packages/${a.feature}/src/di/bind-production.ts`;
|
|
const bindDevFile = `packages/${a.feature}/src/di/bind-dev-seed.ts`;
|
|
const cmsIndexFile = `packages/${a.feature}/src/integrations/cms/index.ts`;
|
|
return [
|
|
() => {
|
|
assertAnchors(process.cwd(), symbolFile, ["// <gen:event-handler-symbols>"]);
|
|
assertAnchors(process.cwd(), bindProdFile, ["// <gen:event-handlers>"]);
|
|
assertAnchors(process.cwd(), bindDevFile, ["// <gen:event-handlers>"]);
|
|
assertAnchors(process.cwd(), cmsIndexFile, ["// <gen:job-tasks>"]);
|
|
return "All required anchors present";
|
|
},
|
|
{
|
|
type: "add",
|
|
path: `packages/${a.feature}/src/events/handlers/on-${a.publisher}-${eventKebab}.handler.ts`,
|
|
templateFile: "templates/event/consume/handler.ts.hbs",
|
|
data: a,
|
|
},
|
|
{
|
|
type: "add",
|
|
path: `packages/${a.feature}/src/events/handlers/on-${a.publisher}-${eventKebab}.handler.test.ts`,
|
|
templateFile: "templates/event/consume/handler.test.ts.hbs",
|
|
data: a,
|
|
},
|
|
{
|
|
type: "add",
|
|
path: `packages/${a.feature}/src/integrations/cms/jobs/__events-${a.publisher}-${eventKebab}.task.ts`,
|
|
templateFile: "templates/event/consume/event-task.ts.hbs",
|
|
data: a,
|
|
},
|
|
{
|
|
type: "modify",
|
|
path: symbolFile,
|
|
pattern: /\/\/ <gen:event-handler-symbols>/,
|
|
template: `// <gen:event-handler-symbols>\n IOn${pascalCase(a.publisher)}${pascalCase(a.event)}Handler: Symbol.for("@repo/${a.feature}/${handlerName}"),`,
|
|
},
|
|
{
|
|
type: "modify",
|
|
path: bindProdFile,
|
|
pattern: /\/\/ <gen:event-handlers>/,
|
|
template: handlerBindBlock(a),
|
|
},
|
|
{
|
|
type: "modify",
|
|
path: bindDevFile,
|
|
pattern: /\/\/ <gen:event-handlers>/,
|
|
template: handlerBindBlock(a),
|
|
},
|
|
{
|
|
type: "modify",
|
|
path: cmsIndexFile,
|
|
pattern: /\/\/ <gen:job-tasks>/,
|
|
template: `// <gen:job-tasks>\nexport { on${pascalCase(a.publisher)}${pascalCase(a.event)}EventTask } from "./jobs/__events-${a.publisher}-${eventKebab}.task";`,
|
|
},
|
|
() => printConsumeNextSteps(a),
|
|
];
|
|
}
|
|
|
|
function handlerBindBlock(a: {
|
|
feature: string;
|
|
event: string;
|
|
publisher: string;
|
|
}): string {
|
|
const handlerFn = `on${pascalCase(a.publisher)}${pascalCase(a.event)}Handler`;
|
|
const eventConst = `${camel(a.event.replace(/\./g, "-"))}Event`;
|
|
const handlerSymbol = `${constantCase(a.feature)}_SYMBOLS.IOn${pascalCase(a.publisher)}${pascalCase(a.event)}Handler`;
|
|
const wrappedVar = `wrapped${pascalCase(a.publisher)}${pascalCase(a.event)}`;
|
|
const containerVar = `${camel(a.feature)}Container`;
|
|
return `// <gen:event-handlers>
|
|
// ${handlerFn} subscription — generated, edit the handler file (not this block) for behavior.
|
|
const ${wrappedVar} = withSpan(
|
|
tracer,
|
|
{ name: "${a.feature}.${handlerFn}", op: "event-handler" },
|
|
withCapture(
|
|
logger,
|
|
{
|
|
feature: "${a.feature}",
|
|
layer: "event-handler",
|
|
name: "${a.feature}.${handlerFn}",
|
|
},
|
|
${handlerFn}(),
|
|
),
|
|
);
|
|
if (${containerVar}.isBound(${handlerSymbol})) {
|
|
${containerVar}.unbind(${handlerSymbol});
|
|
}
|
|
${containerVar}.bind(${handlerSymbol}).toConstantValue(${wrappedVar});
|
|
bus.subscribe(${eventConst}, "${a.feature}", ${wrappedVar});`;
|
|
}
|
|
|
|
function printConsumeNextSteps(a: {
|
|
feature: string;
|
|
event: string;
|
|
publisher: string;
|
|
}): string {
|
|
const eventKebab = a.event.replace(/\./g, "-");
|
|
const eventConst = `${camel(a.event.replace(/\./g, "-"))}Event`;
|
|
return [
|
|
"",
|
|
"─────────────────────────────────────────────────────────────",
|
|
`Handler on-${a.publisher}-${eventKebab} scaffolded in ${a.feature}.`,
|
|
"",
|
|
"Next steps (manual):",
|
|
` 1. Implement the handler body in packages/${a.feature}/src/events/handlers/on-${a.publisher}-${eventKebab}.handler.ts`,
|
|
` 2. Add the import at the top of bind-production.ts AND bind-dev-seed.ts:`,
|
|
` import { ${eventConst} } from "@repo/${a.publisher}";`,
|
|
` import { ${camel(`on-${a.publisher}-${a.event.replace(/\./g, "-")}-handler`)} } from "../events/handlers/on-${a.publisher}-${eventKebab}.handler";`,
|
|
` 3. If your handler needs deps, extend the factory signature and pass them in the generated bind block.`,
|
|
` 4. Add "@repo/${a.publisher}": "workspace:*" to packages/${a.feature}/package.json dependencies (if not already present).`,
|
|
` 5. Verify: pnpm --filter @repo/${a.feature} lint typecheck test`,
|
|
"─────────────────────────────────────────────────────────────",
|
|
"",
|
|
].join("\n");
|
|
}
|
|
|
|
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) : "";
|
|
}
|
|
function pascalCase(input: string): string {
|
|
// Same as `cap` but also splits on dots, so "user.signed-up" → "UserSignedUp".
|
|
return input
|
|
.split(/[-_.\s]+/)
|
|
.filter(Boolean)
|
|
.map((s) => s[0]!.toUpperCase() + s.slice(1))
|
|
.join("");
|
|
}
|
|
function constantCase(input: string): string {
|
|
return input.toUpperCase().replace(/[-.\s]/g, "_");
|
|
}
|