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:
@@ -56,3 +56,95 @@ describe("core-package events", () => {
|
|||||||
expect(actions.length).toBeGreaterThanOrEqual(18);
|
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(`<gen:${tierPlural}>`);
|
||||||
|
|
||||||
|
// 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -736,6 +736,49 @@ import noRealtimeHandlerReexport from "./rules/no-realtime-handler-reexport.js";
|
|||||||
return jobActions(a);
|
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: {
|
function jobActions(a: {
|
||||||
@@ -1281,6 +1324,106 @@ function printRealtimeNextSteps(): string {
|
|||||||
].join("\n");
|
].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
|
// Local helpers used inside the printNextSteps action — Plop's helpers aren't
|
||||||
// available outside template strings, so we replicate the bits we need.
|
// available outside template strings, so we replicate the bits we need.
|
||||||
function cap(input: string): string {
|
function cap(input: string): string {
|
||||||
|
|||||||
Reference in New Issue
Block a user