Merge branch 'worktree-core-ui-component-generator': core-ui component generator
This commit is contained in:
@@ -95,6 +95,7 @@ pnpm lint # Lint all packages (ESLint boundaries enforced)
|
||||
pnpm turbo boundaries # Validate workspace dependency graph (Turbo boundaries)
|
||||
pnpm turbo gen feature # Scaffold a new Lazar-conformant feature package (see docs/guides/scaffolding-a-feature.md)
|
||||
pnpm turbo gen core-package # Scaffold an optional core package back (realtime, events, trpc, ui — see docs/scaffolding/core-package-generator.md)
|
||||
pnpm turbo gen core-ui-component # Scaffold a core-ui atomic-design component (atom/molecule/organism — see docs/scaffolding/core-ui-component-generator.md)
|
||||
pnpm test # Run all unit + integration tests (Vitest)
|
||||
pnpm test:e2e # Run e2e tests (Playwright across both apps)
|
||||
pnpm build # Build all packages (Turborepo)
|
||||
|
||||
@@ -13,6 +13,7 @@ pnpm turbo gen event # Scaffold an event contract (publish) or handler (consum
|
||||
pnpm turbo gen job # Scaffold a background job
|
||||
pnpm turbo gen realtime # Scaffold a realtime channel or inbound handler
|
||||
pnpm turbo gen core-package # Scaffold an optional core package (see docs/scaffolding/core-package-generator.md)
|
||||
pnpm turbo gen core-ui-component # Scaffold a core-ui atomic-design component (see docs/scaffolding/core-ui-component-generator.md)
|
||||
docker compose up -d # Start PostgreSQL
|
||||
```
|
||||
|
||||
|
||||
62
docs/scaffolding/core-ui-component-generator.md
Normal file
62
docs/scaffolding/core-ui-component-generator.md
Normal file
@@ -0,0 +1,62 @@
|
||||
# core-ui component generator
|
||||
|
||||
`pnpm turbo gen core-ui-component` scaffolds an atomic-design component (atom / molecule / organism) into `packages/core-ui/` using the established 4-file pattern.
|
||||
|
||||
**Prerequisite:** `packages/core-ui/` must exist. If your project started from the slim template, scaffold core-ui first via `pnpm turbo gen core-package ui`.
|
||||
|
||||
## Usage
|
||||
|
||||
```bash
|
||||
pnpm turbo gen core-ui-component
|
||||
# → Tier: (use arrow keys)
|
||||
# ❯ atom
|
||||
# molecule
|
||||
# organism
|
||||
# → Component name (PascalCase, e.g. Spinner):
|
||||
# › Spinner
|
||||
```
|
||||
|
||||
The generator emits 4 files into `packages/core-ui/src/<tier>s/<kebab-name>/`:
|
||||
|
||||
| File | Purpose |
|
||||
|---|---|
|
||||
| `<kebab-name>.tsx` | Component implementation (forwardRef + cn + className passthrough) |
|
||||
| `<kebab-name>.stories.tsx` | Storybook stories (Meta + StoryObj + one Default story; tier-prefixed title) |
|
||||
| `<kebab-name>.test.tsx` | Vitest + Testing Library smoke tests (renders, className passthrough, ref forwarding) |
|
||||
| `index.ts` | Barrel that re-exports the component + Props type |
|
||||
|
||||
The new export is also spliced into `packages/core-ui/src/<tier>s/index.ts` immediately after the `// <gen:<tier>s>` anchor, so the component is reachable from the tier barrel (and transitively from the root `@repo/core-ui` export) without any manual wiring.
|
||||
|
||||
## Generated scaffold (example: `pnpm turbo gen core-ui-component` → atom → Spinner)
|
||||
|
||||
```
|
||||
packages/core-ui/src/atoms/spinner/
|
||||
├── spinner.tsx # forwardRef<HTMLDivElement, SpinnerProps>
|
||||
├── spinner.stories.tsx # title: "Atoms/Spinner"
|
||||
├── spinner.test.tsx # 3 smoke tests
|
||||
└── index.ts # export { Spinner, type SpinnerProps }
|
||||
```
|
||||
|
||||
And in `packages/core-ui/src/atoms/index.ts`:
|
||||
|
||||
```ts
|
||||
// <gen:atoms>
|
||||
export { Spinner, type SpinnerProps } from "./spinner/index";
|
||||
export { Button, type ButtonProps } from "./button/index";
|
||||
// ...
|
||||
```
|
||||
|
||||
## Customizing the scaffold
|
||||
|
||||
The generated component is a minimal `<div>` passthrough — change the element/type and add variants/sizes to fit. The existing `button.tsx` in `src/atoms/button/` is the canonical reference for a richer component with `variant` and `size` props plus variant lookup tables.
|
||||
|
||||
## Verification
|
||||
|
||||
After scaffolding:
|
||||
|
||||
```bash
|
||||
pnpm --filter @repo/core-ui lint typecheck test
|
||||
pnpm dev --filter @repo/storybook # view the new component in Storybook
|
||||
```
|
||||
|
||||
The Storybook stories glob (`packages/core-ui/src/**/*.stories.@(ts|tsx)`) picks up the new file automatically — no Storybook config changes needed.
|
||||
@@ -1,7 +1,7 @@
|
||||
[
|
||||
{
|
||||
"path": "AGENTS.md",
|
||||
"sha256": "ea4a4eeea6cb08364fb7d9cf5c5757147442df1e9b1907b9118fcd313cd1c4a7"
|
||||
"sha256": "626329a8a409b4428307ba0014fb8be179a5db7d1fc2a43b017941ab44476188"
|
||||
},
|
||||
{
|
||||
"path": "eslint.config.js",
|
||||
@@ -29,7 +29,7 @@
|
||||
},
|
||||
{
|
||||
"path": "src/atoms/index.ts",
|
||||
"sha256": "9ec29d14a7a7729b4eca9ba95b4016812a4c1c2cc9a46510d5f2d019744852b3"
|
||||
"sha256": "2764f7cbc20b84cba7bde6ab1bb3a0726060e3db227dfd41c3781ce11cba6239"
|
||||
},
|
||||
{
|
||||
"path": "src/atoms/input/index.ts",
|
||||
@@ -85,11 +85,11 @@
|
||||
},
|
||||
{
|
||||
"path": "src/molecules/index.ts",
|
||||
"sha256": "eeb24cfbb8b6d2c1a5f5c144eae4db37f6829ad0f62eb163f428a666bc742774"
|
||||
"sha256": "6c5a7132e904cf0592599347713093926b678422a4f0e9515216434fd3a643c1"
|
||||
},
|
||||
{
|
||||
"path": "src/organisms/index.ts",
|
||||
"sha256": "8e609bb71c20b858c77f0e9f90bb1319db8477b13f9f965f1a1e18524bf50881"
|
||||
"sha256": "3cef9504d6eca5a949207145c8e8787c988f2a389cb19d293c128a1afd617dcd"
|
||||
},
|
||||
{
|
||||
"path": "src/styles/globals.css",
|
||||
|
||||
@@ -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(`<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);
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* 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 {
|
||||
|
||||
@@ -44,6 +44,8 @@ import { Modal, Tabs } from "@repo/core-ui";
|
||||
|
||||
## Structure
|
||||
|
||||
> To scaffold a new component, use `pnpm turbo gen core-ui-component` rather than creating files manually. The generator emits the 4-file pattern below and splices the export into the tier barrel.
|
||||
|
||||
```
|
||||
src/
|
||||
atoms/
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
// <gen:atoms>
|
||||
export { Button, type ButtonProps } from "./button/index";
|
||||
export { Input, type InputProps } from "./input/index";
|
||||
export { Label, type LabelProps } from "./label/index";
|
||||
|
||||
@@ -1 +1,2 @@
|
||||
// <gen:molecules>
|
||||
export { FormField, type FormFieldProps } from "./form-field/index";
|
||||
|
||||
@@ -1 +1,2 @@
|
||||
// <gen:organisms>
|
||||
export {};
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react";
|
||||
import { {{pascalCase name}} } from "./{{kebabCase name}}";
|
||||
|
||||
const meta = {
|
||||
title: "{{tierTitle}}/{{pascalCase name}}",
|
||||
component: {{pascalCase name}},
|
||||
tags: ["autodocs"],
|
||||
} satisfies Meta<typeof {{pascalCase name}}>;
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Default: Story = { args: {} };
|
||||
@@ -0,0 +1,23 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { createRef } from "react";
|
||||
import { renderWithProviders } from "@repo/core-testing/react";
|
||||
import { screen } from "@testing-library/react";
|
||||
import { {{pascalCase name}} } from "./{{kebabCase name}}";
|
||||
|
||||
describe("{{pascalCase name}}", () => {
|
||||
it("renders without crashing", () => {
|
||||
renderWithProviders(<{{pascalCase name}} data-testid="root" />);
|
||||
expect(screen.getByTestId("root")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("applies a passed className alongside its own", () => {
|
||||
renderWithProviders(<{{pascalCase name}} data-testid="root" className="custom" />);
|
||||
expect(screen.getByTestId("root")).toHaveClass("custom");
|
||||
});
|
||||
|
||||
it("forwards ref to the underlying element", () => {
|
||||
const ref = createRef<HTMLDivElement>();
|
||||
renderWithProviders(<{{pascalCase name}} ref={ref} />);
|
||||
expect(ref.current).toBeInstanceOf(HTMLDivElement);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,11 @@
|
||||
import { forwardRef, type HTMLAttributes } from "react";
|
||||
import { cn } from "../../lib/utils";
|
||||
|
||||
export interface {{pascalCase name}}Props extends HTMLAttributes<HTMLDivElement> {}
|
||||
|
||||
export const {{pascalCase name}} = forwardRef<HTMLDivElement, {{pascalCase name}}Props>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn("", className)} {...props} />
|
||||
),
|
||||
);
|
||||
{{pascalCase name}}.displayName = "{{pascalCase name}}";
|
||||
@@ -0,0 +1 @@
|
||||
export { {{pascalCase name}}, type {{pascalCase name}}Props } from "./{{kebabCase name}}";
|
||||
Reference in New Issue
Block a user