feat(generators): core-ui-component generator + action helpers + tests

Adds the setGenerator("core-ui-component") block, coreUiComponentActions
helper (2 guards + 4 add + 1 modify + 1 print = 8 actions), and
printCoreUiComponentNextSteps to config.ts; covers all three paths with
3 new unit tests in config.test.ts (registration shape, action shape per
tier, PascalCase validator). Generator test count: 17 → 20.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-11 09:20:38 +02:00
parent 9108122d00
commit 47627f1a54
2 changed files with 235 additions and 0 deletions

View File

@@ -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: `<name>.tsx`, `<name>.stories.tsx`,
* `<name>.test.tsx`, `index.ts`. Splices the new export into the matching
* tier barrel via the `// <gen:<tier>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, [`// <gen:${tierPlural}>`]);
return `Anchor // <gen:${tierPlural}> 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(`// <gen:${tierPlural}>`),
template: `// <gen:${tierPlural}>\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 {