diff --git a/turbo/generators/config.test.ts b/turbo/generators/config.test.ts index 69b1db9..fc89c34 100644 --- a/turbo/generators/config.test.ts +++ b/turbo/generators/config.test.ts @@ -56,3 +56,95 @@ describe("core-package events", () => { expect(actions.length).toBeGreaterThanOrEqual(18); }); }); + +describe("core-ui-component generator", () => { + it("is registered with tier and name prompts", () => { + const captured: Array<{ name: string; def: PlopTypes.PlopGeneratorConfig }> = []; + const plopMock = { + setHelper: () => {}, + setGenerator: (name: string, def: PlopTypes.PlopGeneratorConfig) => + captured.push({ name, def }), + } as unknown as PlopTypes.NodePlopAPI; + generator(plopMock); + + const entry = captured.find((c) => c.name === "core-ui-component"); + expect(entry).toBeDefined(); + const prompts = entry!.def.prompts as Array<{ name: string; choices?: unknown[] }>; + expect(prompts.map((p) => p.name)).toEqual(["tier", "name"]); + expect(prompts[0]!.choices).toEqual(["atom", "molecule", "organism"]); + }); + + it("for each tier, emits 4 add actions, 1 modify, plus guards and print", () => { + const captured: Array<{ name: string; def: PlopTypes.PlopGeneratorConfig }> = []; + const plopMock = { + setHelper: () => {}, + setGenerator: (name: string, def: PlopTypes.PlopGeneratorConfig) => + captured.push({ name, def }), + } as unknown as PlopTypes.NodePlopAPI; + generator(plopMock); + + const corePkg = captured.find((c) => c.name === "core-ui-component")!.def; + + for (const tier of ["atom", "molecule", "organism"] as const) { + const actions = (corePkg.actions as (a: { tier: string; name: string }) => PlopTypes.ActionType[])( + { tier, name: "Spinner" }, + ); + + const tierPlural = `${tier}s`; + + // 4 `add` actions, one per emitted file + const adds = actions.filter( + (a): a is PlopTypes.AddActionConfig => + typeof a === "object" && "type" in a && (a as { type: string }).type === "add", + ); + expect(adds).toHaveLength(4); + const addPaths = adds.map((a) => a.path); + expect(addPaths).toContain( + `packages/core-ui/src/${tierPlural}/{{kebabCase name}}/{{kebabCase name}}.tsx`, + ); + expect(addPaths).toContain( + `packages/core-ui/src/${tierPlural}/{{kebabCase name}}/{{kebabCase name}}.stories.tsx`, + ); + expect(addPaths).toContain( + `packages/core-ui/src/${tierPlural}/{{kebabCase name}}/{{kebabCase name}}.test.tsx`, + ); + expect(addPaths).toContain( + `packages/core-ui/src/${tierPlural}/{{kebabCase name}}/index.ts`, + ); + + // 1 `modify` action targeting the tier barrel + const modifies = actions.filter( + (a): a is PlopTypes.ModifyActionConfig => + typeof a === "object" && "type" in a && (a as { type: string }).type === "modify", + ); + expect(modifies).toHaveLength(1); + expect(modifies[0]!.path).toBe(`packages/core-ui/src/${tierPlural}/index.ts`); + expect(String(modifies[0]!.pattern)).toContain(``); + + // 3 function actions (2 guards + 1 print) + const fns = actions.filter((a) => typeof a === "function"); + expect(fns).toHaveLength(3); + } + }); + + it("PascalCase validator rejects bad names", () => { + const captured: Array<{ name: string; def: PlopTypes.PlopGeneratorConfig }> = []; + const plopMock = { + setHelper: () => {}, + setGenerator: (name: string, def: PlopTypes.PlopGeneratorConfig) => + captured.push({ name, def }), + } as unknown as PlopTypes.NodePlopAPI; + generator(plopMock); + + const corePkg = captured.find((c) => c.name === "core-ui-component")!.def; + const nameValidate = (corePkg.prompts as Array<{ name: string; validate?: (i: string) => string | true }>) + .find((p) => p.name === "name")!.validate!; + + expect(nameValidate("")).toBe("Required"); + expect(nameValidate("spinner")).toContain("PascalCase"); + expect(nameValidate("123Foo")).toContain("PascalCase"); + expect(nameValidate("Foo-Bar")).toContain("PascalCase"); + expect(nameValidate("Spinner")).toBe(true); + expect(nameValidate("IconButton")).toBe(true); + }); +}); diff --git a/turbo/generators/config.ts b/turbo/generators/config.ts index 8d1c1c9..bd5051c 100644 --- a/turbo/generators/config.ts +++ b/turbo/generators/config.ts @@ -736,6 +736,49 @@ import noRealtimeHandlerReexport from "./rules/no-realtime-handler-reexport.js"; return jobActions(a); }, }); + + /** + * Turbo generator: `core-ui-component` + * + * Scaffolds a core-ui atomic-design component (atom / molecule / organism) + * using the established 4-file pattern: `.tsx`, `.stories.tsx`, + * `.test.tsx`, `index.ts`. Splices the new export into the matching + * tier barrel via the `// s>` anchor. + * + * Refuses to run unless `packages/core-ui/` exists (see + * `pnpm turbo gen core-package ui`). + */ + plop.setGenerator("core-ui-component", { + description: + "Scaffold a core-ui atomic-design component (atom / molecule / organism)", + prompts: [ + { + type: "list", + name: "tier", + message: "Tier:", + choices: ["atom", "molecule", "organism"], + }, + { + type: "input", + name: "name", + message: "Component name (PascalCase, e.g. Spinner):", + validate: (input: string) => { + if (!input) return "Required"; + if (!/^[A-Z][A-Za-z0-9]*$/.test(input)) { + return "Must be PascalCase (e.g. Spinner, IconButton)"; + } + return true; + }, + }, + ], + actions: (answers) => { + const a = answers as { + tier: "atom" | "molecule" | "organism"; + name: string; + }; + return coreUiComponentActions(a); + }, + }); } function jobActions(a: { @@ -1281,6 +1324,106 @@ function printRealtimeNextSteps(): string { ].join("\n"); } +function coreUiComponentActions(a: { + tier: "atom" | "molecule" | "organism"; + name: string; +}): PlopTypes.ActionType[] { + const tierPlural = `${a.tier}s` as const; + const tierTitle = tierPlural[0]!.toUpperCase() + tierPlural.slice(1); + const tierBarrel = `packages/core-ui/src/${tierPlural}/index.ts`; + const componentDir = `packages/core-ui/src/${tierPlural}/{{kebabCase name}}`; + + return [ + // 1. Guard: core-ui must be scaffolded + () => { + const pkgRoot = join(process.cwd(), "packages", "core-ui"); + if (!existsSync(pkgRoot)) { + throw new Error( + `packages/core-ui/ does not exist. Run \`pnpm turbo gen core-package ui\` first.`, + ); + } + return "Guard passed — packages/core-ui exists."; + }, + + // 2. Guard: tier barrel must have its anchor + () => { + assertAnchors(process.cwd(), tierBarrel, [`// `]); + return `Anchor // present in ${tierBarrel}.`; + }, + + // 3. Emit the 4 component files + { + type: "add", + path: `${componentDir}/{{kebabCase name}}.tsx`, + templateFile: "templates/core-ui-component/component.tsx.hbs", + data: { name: a.name }, + }, + { + type: "add", + path: `${componentDir}/{{kebabCase name}}.stories.tsx`, + templateFile: "templates/core-ui-component/component.stories.tsx.hbs", + data: { name: a.name, tierTitle }, + }, + { + type: "add", + path: `${componentDir}/{{kebabCase name}}.test.tsx`, + templateFile: "templates/core-ui-component/component.test.tsx.hbs", + data: { name: a.name }, + }, + { + type: "add", + path: `${componentDir}/index.ts`, + templateFile: "templates/core-ui-component/index.ts.hbs", + data: { name: a.name }, + }, + + // 4. Splice export into tier barrel. + // The `template` string is itself Handlebars-rendered by plop, so + // {{pascalCase name}} and {{kebabCase name}} are resolved at apply- + // time. This matches the pattern used by the `realtime` and `event` + // generators (no kebabCase JS helper exists in config.ts). + { + type: "modify", + path: tierBarrel, + pattern: new RegExp(`// `), + template: `// \nexport { {{pascalCase name}}, type {{pascalCase name}}Props } from "./{{kebabCase name}}/index";`, + }, + + // 5. Print next-steps + () => printCoreUiComponentNextSteps(a), + ]; +} + +function printCoreUiComponentNextSteps(a: { + tier: "atom" | "molecule" | "organism"; + name: string; +}): string { + const tierPlural = `${a.tier}s`; + const kebab = a.name + .replace(/([a-z0-9])([A-Z])/g, "$1-$2") + .replace(/([A-Z]+)([A-Z][a-z])/g, "$1-$2") + .toLowerCase(); + const pascal = a.name; + return [ + "─────────────────────────────────────────────────────────────", + `${pascal} scaffolded into packages/core-ui/src/${tierPlural}/${kebab}/.`, + "", + "Next steps (manual):", + ` 1. Implement the component body in ${kebab}.tsx (currently a div`, + ` passthrough — change the element/type to fit, add variants/sizes`, + ` if needed; see button.tsx for the canonical rich pattern).`, + ` 2. Flesh out the Default story in ${kebab}.stories.tsx; add variant`, + ` stories if your component takes a variant prop.`, + ` 3. Tighten the tests in ${kebab}.test.tsx beyond the smoke-test`, + ` baseline.`, + "", + "Verify:", + " pnpm --filter @repo/core-ui lint typecheck test", + " pnpm dev --filter @repo/storybook # view in Storybook", + "─────────────────────────────────────────────────────────────", + ].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 {