diff --git a/docs/COMPONENTS.md b/docs/COMPONENTS.md index a747992b..0b3c1468 100644 --- a/docs/COMPONENTS.md +++ b/docs/COMPONENTS.md @@ -35,8 +35,9 @@ Public subpath `index.ts` files are export-only. A component's implementation li sibling file. Support modules and multi-part primitives each keep their own file. The component creation rules live in `packages/turbo-generators/src/component-creation-plan.ts`. -Turbo and Plop are adapters that apply the plan. Keep new creation rules in the plan module so -dry-run tests can prove which files, exports, stories, docs, and checks a component needs. +That module owns component-name validation, documentation groups, conformance tiers, and defaults. +Turbo and Plop collect answers and invoke that flow. Keep new creation rules in the plan module so +tests can prove the files, exports, stories, docs, and checks a component needs. The generator creates the component guide's primary `apps/docs/src/examples//basic.tsx` example and references it with `ExampleBlock`. Replace the placeholder content with one focused, diff --git a/packages/turbo-generators/README.md b/packages/turbo-generators/README.md index a9549b7a..f3d6898a 100644 --- a/packages/turbo-generators/README.md +++ b/packages/turbo-generators/README.md @@ -19,6 +19,6 @@ pnpm generate:component ## Structure -- `config.ts`: Turbo/Plop adapter. -- `src/component-creation-plan.ts`: Component creation rules and expected outcomes. -- `src/apply-component-creation-plan.ts`: File and JSON edit adapter. +- `config.ts`: Turbo/Plop adapter that collects answers and invokes the creation flow. +- `src/component-creation-plan.ts`: Generator rules, answer parsing, and planned files. +- `src/apply-component-creation-plan.ts`: Applies a parsed scaffold to the repository. diff --git a/packages/turbo-generators/config.ts b/packages/turbo-generators/config.ts index 34fb7398..f3108f61 100644 --- a/packages/turbo-generators/config.ts +++ b/packages/turbo-generators/config.ts @@ -1,29 +1,29 @@ import type { PlopTypes } from '@turbo/gen'; -import * as z from 'zod'; -import { applyComponentCreationPlan } from './src/apply-component-creation-plan.js'; -import type { CreateComponentInput } from './src/component-creation-plan.js'; -import { createComponentPlan } from './src/component-creation-plan.js'; +import { createComponent } from './src/apply-component-creation-plan.js'; +import type { ConformanceTier } from './src/component-creation-plan.js'; +import { + COMPONENT_DEFAULTS, + CONFORMANCE_TIERS, + DOC_GROUPS, + validateComponentName, +} from './src/component-creation-plan.js'; -const COMPONENT_NAME_RE = /^[A-Za-z][A-Za-z0-9-]*$/; -const CONFORMANCE_TIERS = ['universal', 'field-shaped', 'none'] as const; -// Mirrors apps/docs/content/docs/components/*/meta.json — the pages listed there. -const DOC_GROUPS = ['actions', 'feedback', 'forms', 'typography', 'visuals'] as const; +const CONFORMANCE_TIER_LABELS: Record = { + 'field-shaped': 'Field-shaped', + none: 'None', + universal: 'Universal', +}; -const componentAnswersSchema = z.object({ - conformanceTier: z.enum(CONFORMANCE_TIERS).default('universal'), - docsGroup: z.enum(DOC_GROUPS), - integrationTripwire: z.boolean().default(false), - name: z.string().min(1), - visualCoverage: z.boolean().default(true), -}); +const YES_NO = [ + { name: 'Yes', value: true }, + { name: 'No', value: false }, +]; export default function generator(plop: PlopTypes.NodePlopAPI): void { plop.setGenerator('component', { actions: [ async (answers) => { - const input = parseComponentAnswers(answers); - const plan = createComponentPlan(input); - await applyComponentCreationPlan(process.cwd(), plan); + const plan = await createComponent(process.cwd(), answers); return `Created ${plan.expected.packageExportPath}`; }, ], @@ -42,29 +42,25 @@ export default function generator(plop: PlopTypes.NodePlopAPI): void { type: 'list', }, { - choices: [ - { name: 'Yes', value: true }, - { name: 'No', value: false }, - ], + choices: YES_NO, + default: COMPONENT_DEFAULTS.visualCoverage, message: 'Add visual coverage?', name: 'visualCoverage', type: 'list', }, { - choices: [ - { name: 'Universal', value: 'universal' }, - { name: 'Field-shaped', value: 'field-shaped' }, - { name: 'None', value: 'none' }, - ], + choices: CONFORMANCE_TIERS.map((tier) => ({ + name: CONFORMANCE_TIER_LABELS[tier], + value: tier, + })), + default: COMPONENT_DEFAULTS.conformanceTier, message: 'Conformance tier:', name: 'conformanceTier', type: 'list', }, { - choices: [ - { name: 'Yes', value: true }, - { name: 'No', value: false }, - ], + choices: YES_NO, + default: COMPONENT_DEFAULTS.integrationTripwire, message: 'Add an integration tripwire?', name: 'integrationTripwire', type: 'list', @@ -72,21 +68,3 @@ export default function generator(plop: PlopTypes.NodePlopAPI): void { ], }); } - -export function parseComponentAnswers(answers: unknown): CreateComponentInput { - return componentAnswersSchema.parse(answers); -} - -function validateComponentName(value: unknown): true | string { - if (typeof value !== 'string') { - return 'Component name required.'; - } - const trimmed = value.trim(); - if (!trimmed) { - return 'Component name required.'; - } - if (!COMPONENT_NAME_RE.test(trimmed)) { - return 'Use letters/numbers/hyphens. Start with a letter.'; - } - return true; -} diff --git a/packages/turbo-generators/src/apply-component-creation-plan.test.ts b/packages/turbo-generators/src/apply-component-creation-plan.test.ts index 71852b2d..0c6b2cd4 100644 --- a/packages/turbo-generators/src/apply-component-creation-plan.test.ts +++ b/packages/turbo-generators/src/apply-component-creation-plan.test.ts @@ -3,60 +3,39 @@ import { join } from 'node:path'; import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; import { afterEach, describe, expect, it } from 'vite-plus/test'; import * as z from 'zod'; -import { applyComponentCreationPlan } from './apply-component-creation-plan.js'; -import type { ComponentCreationPlan } from './component-creation-plan.js'; +import { createComponent } from './apply-component-creation-plan.js'; const roots: Array = []; +const MANIFEST_MARKER = + '].map(([name, path, conformanceTier, integrationTripwire, visualApplicability]) => ({'; afterEach(async () => { await Promise.all(roots.map((root) => rm(root, { force: true, recursive: true }))); roots.length = 0; }); -describe('applyComponentCreationPlan', () => { - it('writes files and applies sorted docs navigation edits idempotently', async () => { - const root = await mkdtemp(join(tmpdir(), 'component-plan-')); - roots.push(root); - - const plan: ComponentCreationPlan = { - expected: { - exampleSlug: 'status-badge/basic', - hostedDocsPath: 'components/feedback/status-badge', - packageDocsSlug: 'status-badge', - packageExportPath: './status-badge', - }, - files: [ - { - contents: 'export const StatusBadge = 1;\n', - path: 'packages/@luke-ui/react/src/status-badge/status-badge.tsx', - }, - ], - jsonEdits: [ - { - key: 'pages', - kind: 'array-add-sorted', - path: 'apps/docs/content/docs/components/meta.json', - title: 'Components', - value: 'feedback', - }, - { - key: 'pages', - kind: 'array-add-sorted', - path: 'apps/docs/content/docs/components/feedback/meta.json', - title: 'Feedback', - value: 'status-badge', - }, - ], - }; - - await applyComponentCreationPlan(root, plan); - await applyComponentCreationPlan(root, plan); +describe('createComponent', () => { + it('applies parsed answers to a repository fixture and is safe to rerun', async () => { + const root = await createRepositoryFixture(); + const answers = { docsGroup: 'feedback', name: 'StatusBadge' }; + await createComponent(root, answers); + await createComponent(root, answers); + + await expect( + readFile(join(root, 'packages/@luke-ui/react/src/status-badge/index.ts'), 'utf8'), + ).resolves.toBe( + [ + "export { StatusBadge, type StatusBadgeProps } from './status-badge.js';", + "export { statusBadgeRecipe, type StatusBadgeRecipeVariants } from './recipe.css.js';", + '', + ].join('\n'), + ); await expect( readFile(join(root, 'packages/@luke-ui/react/src/status-badge/status-badge.tsx'), 'utf8'), - ).resolves.toBe('export const StatusBadge = 1;\n'); + ).resolves.toContain('export function StatusBadge'); await expect(readJson(root, 'apps/docs/content/docs/components/meta.json')).resolves.toEqual({ - pages: ['feedback'], + pages: ['actions', 'feedback'], title: 'Components', }); await expect( @@ -65,48 +44,50 @@ describe('applyComponentCreationPlan', () => { pages: ['status-badge'], title: 'Feedback', }); + expect(await readFile(join(root, modulesRegistryPath), 'utf8')).toBe( + [ + '// Style-producing modules in the shipped stylesheet.', + "import '../button/recipe.css.js';", + "import '../status-badge/recipe.css.js';", + "import '../text/recipe.css.js';", + '', + ].join('\n'), + ); + expect(await readFile(join(root, manifestPath), 'utf8')).toBe( + [ + 'const entries = [', + "\t['Button', 'button', 'universal', 'required', 'applicable'],", + "\t['StatusBadge', 'status-badge', 'universal', 'none', 'applicable'],", + MANIFEST_MARKER, + '\tname,', + '}));', + '', + ].join('\n'), + ); + await expect( + readFile( + join(root, 'packages/@luke-ui/react/src/status-badge/component-test-registration.ts'), + 'utf8', + ), + ).rejects.toMatchObject({ code: 'ENOENT' }); }); it('inserts a generated recipe import in code-point order', async () => { - const root = await mkdtemp(join(tmpdir(), 'component-plan-')); - roots.push(root); - - const registryPath = 'packages/@luke-ui/react/src/styles/modules.css.ts'; - const initialContent = [ - '// Style-producing modules in the shipped stylesheet.', - "import '../Icon/recipe.css.js';", - "import '../button/recipe.css.js';", - "import '../icon/recipe.css.js';", - "import '../text/recipe.css.js';", - '', - ].join('\n'); - - await mkdir(join(root, 'packages/@luke-ui/react/src/styles'), { recursive: true }); - await writeFile(join(root, registryPath), initialContent, 'utf8'); - - const plan: ComponentCreationPlan = { - expected: { - exampleSlug: 'status-badge/basic', - hostedDocsPath: 'components/actions/status-badge', - packageDocsSlug: 'status-badge', - packageExportPath: './status-badge', - }, - files: [], - jsonEdits: [], - sortedImportEdits: [ - { - kind: 'sorted-import', - line: "import '../icon-button/recipe.css.js';", - path: registryPath, - }, - ], - }; - - await applyComponentCreationPlan(root, plan); - await applyComponentCreationPlan(root, plan); - - const result = await readFile(join(root, registryPath), 'utf8'); - expect(result).toBe( + const root = await createRepositoryFixture({ + modulesRegistry: [ + '// Style-producing modules in the shipped stylesheet.', + "import '../Icon/recipe.css.js';", + "import '../button/recipe.css.js';", + "import '../icon/recipe.css.js';", + "import '../text/recipe.css.js';", + '', + ].join('\n'), + }); + + await createComponent(root, { docsGroup: 'actions', name: 'IconButton' }); + await createComponent(root, { docsGroup: 'actions', name: 'IconButton' }); + + expect(await readFile(join(root, modulesRegistryPath), 'utf8')).toBe( [ '// Style-producing modules in the shipped stylesheet.', "import '../Icon/recipe.css.js';", @@ -119,75 +100,121 @@ describe('applyComponentCreationPlan', () => { ); }); - it('inserts manifest entries idempotently', async () => { - const root = await mkdtemp(join(tmpdir(), 'component-plan-')); - roots.push(root); - - const manifestPath = 'packages/@luke-ui/react/src/conformance/manifest.ts'; - const marker = - '].map(([name, path, conformanceTier, integrationTripwire, visualApplicability]) => ({'; - const initialContent = `const entries = [\n\t['Button', 'button', 'universal', 'required', 'applicable'],\n${marker}\n\tname,\n}));\n`; - await mkdir(join(root, 'packages/@luke-ui/react/src/conformance'), { recursive: true }); - await writeFile(join(root, manifestPath), initialContent, 'utf8'); - - const plan: ComponentCreationPlan = { - expected: { - exampleSlug: 'status-badge/basic', - hostedDocsPath: 'components/feedback/status-badge', - packageDocsSlug: 'status-badge', - packageExportPath: './status-badge', - }, - files: [], - jsonEdits: [], - textFileInserts: [ - { - kind: 'text-insert', - lines: ["\t['StatusBadge', 'status-badge', 'universal', 'none', 'applicable'],"], - marker, - path: manifestPath, - }, - ], - }; - - await applyComponentCreationPlan(root, plan); - await applyComponentCreationPlan(root, plan); + it('scaffolds field-shaped conformance on disk', async () => { + const root = await createRepositoryFixture(); - expect(await readFile(join(root, manifestPath), 'utf8')).toBe( - `const entries = [\n\t['Button', 'button', 'universal', 'required', 'applicable'],\n\t['StatusBadge', 'status-badge', 'universal', 'none', 'applicable'],\n${marker}\n\tname,\n}));\n`, + await createComponent(root, { + conformanceTier: 'field-shaped', + docsGroup: 'forms', + name: 'DateField', + }); + + const browserTest = await readFile( + join(root, 'packages/@luke-ui/react/src/date-field/date-field.browser.test.tsx'), + 'utf8', + ); + expect(browserTest).toContain('testFieldShapedConformance'); + expect(browserTest).toContain("path: 'date-field'"); + expect(browserTest).not.toContain('getTarget'); + expect(browserTest).not.toContain("name: 'DateField'"); + expect(await readFile(join(root, manifestPath), 'utf8')).toContain( + "['DateField', 'date-field', 'field-shaped', 'none', 'applicable']", + ); + }); + + it('scaffolds integration tripwire coverage when requested', async () => { + const root = await createRepositoryFixture(); + + await createComponent(root, { + docsGroup: 'actions', + integrationTripwire: true, + name: 'ActionChip', + }); + + expect( + await readFile( + join(root, 'packages/@luke-ui/react/src/action-chip/action-chip.browser.test.tsx'), + 'utf8', + ), + ).toContain("testIntegration('action-chip', async"); + expect(await readFile(join(root, manifestPath), 'utf8')).toContain( + "['ActionChip', 'action-chip', 'universal', 'required', 'applicable']", + ); + }); + + it('omits visual coverage when it does not apply', async () => { + const root = await createRepositoryFixture(); + + await createComponent(root, { + docsGroup: 'forms', + name: 'DateField', + visualCoverage: false, + }); + + await expect( + readFile( + join(root, 'packages/@luke-ui/react/src/date-field/date-field.visual.test.tsx'), + 'utf8', + ), + ).rejects.toMatchObject({ code: 'ENOENT' }); + expect(await readFile(join(root, manifestPath), 'utf8')).toContain( + "['DateField', 'date-field', 'universal', 'none', 'none']", ); }); it('rejects docs navigation JSON that is not an object', async () => { - const root = await mkdtemp(join(tmpdir(), 'component-plan-')); - roots.push(root); - - const metaPath = 'apps/docs/content/docs/components/meta.json'; - await mkdir(join(root, 'apps/docs/content/docs/components'), { recursive: true }); - await writeFile(join(root, metaPath), '[]\n', 'utf8'); - - const plan: ComponentCreationPlan = { - expected: { - exampleSlug: 'status-badge/basic', - hostedDocsPath: 'components/feedback/status-badge', - packageDocsSlug: 'status-badge', - packageExportPath: './status-badge', - }, - files: [], - jsonEdits: [ - { - key: 'pages', - kind: 'array-add-sorted', - path: metaPath, - title: 'Components', - value: 'feedback', - }, - ], - }; - - await expect(applyComponentCreationPlan(root, plan)).rejects.toBeInstanceOf(z.ZodError); + const root = await createRepositoryFixture(); + await writeFile(join(root, 'apps/docs/content/docs/components/meta.json'), '[]\n', 'utf8'); + + await expect( + createComponent(root, { docsGroup: 'feedback', name: 'StatusBadge' }), + ).rejects.toBeInstanceOf(z.ZodError); }); }); +const modulesRegistryPath = 'packages/@luke-ui/react/src/styles/modules.css.ts'; +const manifestPath = 'packages/@luke-ui/react/src/conformance/manifest.ts'; + +async function createRepositoryFixture(options?: { modulesRegistry?: string }): Promise { + const root = await mkdtemp(join(tmpdir(), 'component-plan-')); + roots.push(root); + + await mkdir(join(root, 'apps/docs/content/docs/components'), { recursive: true }); + await mkdir(join(root, 'packages/@luke-ui/react/src/styles'), { recursive: true }); + await mkdir(join(root, 'packages/@luke-ui/react/src/conformance'), { recursive: true }); + + await writeFile( + join(root, 'apps/docs/content/docs/components/meta.json'), + `${JSON.stringify({ pages: ['actions'], title: 'Components' }, null, '\t')}\n`, + 'utf8', + ); + await writeFile( + join(root, modulesRegistryPath), + options?.modulesRegistry ?? + [ + '// Style-producing modules in the shipped stylesheet.', + "import '../button/recipe.css.js';", + "import '../text/recipe.css.js';", + '', + ].join('\n'), + 'utf8', + ); + await writeFile( + join(root, manifestPath), + [ + 'const entries = [', + "\t['Button', 'button', 'universal', 'required', 'applicable'],", + MANIFEST_MARKER, + '\tname,', + '}));', + '', + ].join('\n'), + 'utf8', + ); + + return root; +} + async function readJson(root: string, path: string): Promise { return z.unknown().parse(JSON.parse(await readFile(join(root, path), 'utf8'))); } diff --git a/packages/turbo-generators/src/apply-component-creation-plan.ts b/packages/turbo-generators/src/apply-component-creation-plan.ts index d348f194..29066df0 100644 --- a/packages/turbo-generators/src/apply-component-creation-plan.ts +++ b/packages/turbo-generators/src/apply-component-creation-plan.ts @@ -1,26 +1,29 @@ import { dirname, join } from 'node:path'; import { mkdir, readFile, writeFile } from 'node:fs/promises'; import * as z from 'zod'; -import type { - ComponentCreationPlan, - JsonArrayAddSortedEdit, - PlanFile, - SortedImportEdit, - TextFileInsertEdit, -} from './component-creation-plan.js'; +import type { ComponentCreationPlan, PlanFile } from './component-creation-plan.js'; +import { createComponentWork, parseComponentAnswers } from './component-creation-plan.js'; const docsMetaSchema = z.record(z.string(), z.unknown()); +type ComponentCreationWork = ReturnType; -export async function applyComponentCreationPlan( +export async function createComponent( root: string, - plan: ComponentCreationPlan, + answers: unknown, +): Promise { + const work = createComponentWork(parseComponentAnswers(answers)); + await applyComponentCreationPlan(root, work); + return { expected: work.expected, files: work.files }; +} + +async function applyComponentCreationPlan( + root: string, + plan: ComponentCreationWork, ): Promise { await Promise.all(plan.files.map((file) => writePlanFile(root, file))); await Promise.all(plan.jsonEdits.map((edit) => applyJsonEdit(root, edit))); - await Promise.all((plan.textFileInserts ?? []).map((edit) => applyTextInsertEdit(root, edit))); - await Promise.all( - (plan.sortedImportEdits ?? []).map((edit) => applySortedImportEdit(root, edit)), - ); + await Promise.all(plan.textFileInserts.map((edit) => applyTextInsertEdit(root, edit))); + await Promise.all(plan.sortedImportEdits.map((edit) => applySortedImportEdit(root, edit))); } async function writePlanFile(root: string, file: PlanFile): Promise { @@ -29,7 +32,10 @@ async function writePlanFile(root: string, file: PlanFile): Promise { await writeFile(target, file.contents, 'utf8'); } -async function applyJsonEdit(root: string, edit: JsonArrayAddSortedEdit): Promise { +async function applyJsonEdit( + root: string, + edit: ComponentCreationWork['jsonEdits'][number], +): Promise { const target = join(root, edit.path); await mkdir(dirname(target), { recursive: true }); const data = await readJson(target, edit.title); @@ -52,7 +58,10 @@ async function readJson(path: string, title: string): Promise { +async function applySortedImportEdit( + root: string, + edit: ComponentCreationWork['sortedImportEdits'][number], +): Promise { const target = join(root, edit.path); await mkdir(dirname(target), { recursive: true }); const content = await readFile(target, 'utf8').catch(() => ''); @@ -90,7 +99,10 @@ function insertSortedImport(content: string, line: string): string { return `${[...header, ...imports, ...footer].join('\n')}\n`; } -async function applyTextInsertEdit(root: string, edit: TextFileInsertEdit): Promise { +async function applyTextInsertEdit( + root: string, + edit: ComponentCreationWork['textFileInserts'][number], +): Promise { const target = join(root, edit.path); const content = await readFile(target, 'utf8'); const insertion = edit.lines.join('\n'); diff --git a/packages/turbo-generators/src/component-creation-plan.test.ts b/packages/turbo-generators/src/component-creation-plan.test.ts index 948ae701..93877297 100644 --- a/packages/turbo-generators/src/component-creation-plan.test.ts +++ b/packages/turbo-generators/src/component-creation-plan.test.ts @@ -1,16 +1,37 @@ import { describe, expect, it } from 'vite-plus/test'; +import { ZodError } from 'zod'; import { parseComponentFrontmatter, renderPropsPage, } from '../../../apps/docs/scripts/generate-props-pages.js'; -import { createComponentPlan } from './component-creation-plan.js'; +import { + COMPONENT_DEFAULTS, + createComponentPlan, + parseComponentAnswers, +} from './component-creation-plan.js'; -describe('createComponentPlan', () => { - it('plans a component with a colocated recipe across package and hosted docs surfaces', () => { - const plan = createComponentPlan({ +const validAnswers = { + docsGroup: 'feedback', + name: 'StatusBadge', +} as const; + +describe('parseComponentAnswers', () => { + it('rejects invalid docs group answers', () => { + expect(() => parseComponentAnswers({ ...validAnswers, docsGroup: 'layout' })).toThrow(ZodError); + }); + + it('defaults test applicability for omitted answers', () => { + expect(parseComponentAnswers(validAnswers)).toEqual({ + ...COMPONENT_DEFAULTS, docsGroup: 'feedback', name: 'StatusBadge', }); + }); +}); + +describe('createComponentPlan', () => { + it('plans a component with a colocated recipe across package and hosted docs surfaces', () => { + const plan = createComponentPlan(validAnswers); expect(plan.expected).toEqual({ exampleSlug: 'status-badge/basic', @@ -28,16 +49,9 @@ describe('createComponentPlan', () => { 'packages/@luke-ui/react/src/status-badge/status-badge.tsx', 'packages/@luke-ui/react/src/status-badge/status-badge.visual.test.tsx', ]); - expect(plan.sortedImportEdits).toEqual([ - { - kind: 'sorted-import', - line: "import '../status-badge/recipe.css.js';", - path: 'packages/@luke-ui/react/src/styles/modules.css.ts', - }, - ]); - expect(plan.textFileInserts?.[0]?.lines).toEqual([ - "\t['StatusBadge', 'status-badge', 'universal', 'none', 'applicable'],", - ]); + expect(plan).not.toHaveProperty('jsonEdits'); + expect(plan).not.toHaveProperty('sortedImportEdits'); + expect(plan).not.toHaveProperty('textFileInserts'); const recipeSource = plan.files.find((file) => file.path.endsWith('/status-badge/recipe.css.ts'), @@ -62,55 +76,6 @@ describe('createComponentPlan', () => { ); }); - it('scaffolds field-shaped conformance coverage without a universal target', () => { - const plan = createComponentPlan({ - conformanceTier: 'field-shaped', - docsGroup: 'forms', - name: 'DateField', - }); - - expect(plan.textFileInserts?.[0]?.lines).toEqual([ - "\t['DateField', 'date-field', 'field-shaped', 'none', 'applicable'],", - ]); - const browserTest = plan.files.find((file) => - file.path.endsWith('/date-field.browser.test.tsx'), - )?.contents; - expect(browserTest).toContain('testFieldShapedConformance'); - expect(browserTest).toContain("path: 'date-field'"); - expect(browserTest).not.toContain('getTarget'); - expect(browserTest).not.toContain("name: 'DateField'"); - }); - - it('scaffolds integration tripwire coverage when requested', () => { - const plan = createComponentPlan({ - docsGroup: 'actions', - integrationTripwire: true, - name: 'ActionChip', - }); - - expect(plan.textFileInserts?.[0]?.lines).toEqual([ - "\t['ActionChip', 'action-chip', 'universal', 'required', 'applicable'],", - ]); - expect( - plan.files.find((file) => file.path.endsWith('/action-chip.browser.test.tsx'))?.contents, - ).toContain("testIntegration('action-chip', async"); - }); - - it('omits visual coverage when it does not apply', () => { - const plan = createComponentPlan({ - docsGroup: 'forms', - name: 'DateField', - visualCoverage: false, - }); - - expect(plan.files.map((file) => file.path)).not.toContain( - 'packages/@luke-ui/react/src/date-field/date-field.visual.test.tsx', - ); - expect(plan.textFileInserts?.[0]?.lines).toEqual([ - "\t['DateField', 'date-field', 'universal', 'none', 'none'],", - ]); - }); - it('rejects invalid component names before file writes', () => { expect(() => { return createComponentPlan({ @@ -121,10 +86,7 @@ describe('createComponentPlan', () => { }); it('scaffolds a /.mdx guide that generate:props can turn into a props.mdx', () => { - const plan = createComponentPlan({ - docsGroup: 'feedback', - name: 'StatusBadge', - }); + const plan = createComponentPlan(validAnswers); const guide = plan.files.find((file) => { return file.path.endsWith('feedback/status-badge.mdx'); diff --git a/packages/turbo-generators/src/component-creation-plan.ts b/packages/turbo-generators/src/component-creation-plan.ts index 3c68728b..ee93b241 100644 --- a/packages/turbo-generators/src/component-creation-plan.ts +++ b/packages/turbo-generators/src/component-creation-plan.ts @@ -1,19 +1,32 @@ -type ConformanceTier = 'universal' | 'field-shaped' | 'none'; +import * as z from 'zod'; -export interface CreateComponentInput { - conformanceTier?: ConformanceTier; - docsGroup: string; - integrationTripwire?: boolean; - name: string; - visualCoverage?: boolean; -} +export const CONFORMANCE_TIERS = ['universal', 'field-shaped', 'none'] as const; +export const DOC_GROUPS = ['actions', 'feedback', 'forms', 'typography', 'visuals'] as const; + +export const COMPONENT_DEFAULTS = { + conformanceTier: 'universal', + integrationTripwire: false, + visualCoverage: true, +} as const; + +export type ConformanceTier = (typeof CONFORMANCE_TIERS)[number]; export interface PlanFile { contents: string; path: string; } -export interface JsonArrayAddSortedEdit { +export interface ComponentCreationPlan { + expected: { + hostedDocsPath: string; + packageDocsSlug: string; + packageExportPath: string; + exampleSlug: string; + }; + files: Array; +} + +interface JsonArrayAddSortedEdit { key: 'pages'; kind: 'array-add-sorted'; path: string; @@ -21,48 +34,80 @@ export interface JsonArrayAddSortedEdit { value: string; } -export interface TextFileInsertEdit { +interface TextFileInsertEdit { kind: 'text-insert'; lines: Array; marker: string; path: string; } -export interface SortedImportEdit { +interface SortedImportEdit { kind: 'sorted-import'; line: string; path: string; } -export interface ComponentCreationPlan { - expected: { - hostedDocsPath: string; - packageDocsSlug: string; - packageExportPath: string; - exampleSlug: string; - }; - files: Array; +interface ComponentCreationWork extends ComponentCreationPlan { jsonEdits: Array; - textFileInserts?: Array; - sortedImportEdits?: Array; + sortedImportEdits: Array; + textFileInserts: Array; } const COMPONENT_NAME_RE = /^[A-Za-z][A-Za-z0-9-]*$/; const CAMEL_BOUNDARY_RE = /([a-z0-9])([A-Z])/g; const NON_ALPHANUM_RE = /[^A-Za-z0-9-]/g; -export function createComponentPlan(input: CreateComponentInput): ComponentCreationPlan { - const name = parseName(input.name); - const docsGroup = parseDocsGroup(input.docsGroup); +const componentAnswersSchema = z.object({ + conformanceTier: z.enum(CONFORMANCE_TIERS).default(COMPONENT_DEFAULTS.conformanceTier), + docsGroup: z.enum(DOC_GROUPS), + integrationTripwire: z.boolean().default(COMPONENT_DEFAULTS.integrationTripwire), + name: z.string(), + visualCoverage: z.boolean().default(COMPONENT_DEFAULTS.visualCoverage), +}); + +export type CreateComponentInput = z.input; +type ParsedComponentAnswers = z.output; + +export function validateComponentName(value: unknown): true | string { + if (typeof value !== 'string') { + return 'Component name required.'; + } + const trimmed = value.trim(); + if (!trimmed) { + return 'Component name required.'; + } + if (!COMPONENT_NAME_RE.test(trimmed)) { + return 'Use letters/numbers/hyphens. Start with a letter.'; + } + return true; +} + +export function parseComponentAnswers(answers: unknown): ParsedComponentAnswers { + const parsed = componentAnswersSchema.parse(answers); + const nameCheck = validateComponentName(parsed.name); + if (nameCheck !== true) { + throw new Error(nameCheck); + } + return parsed; +} + +export function createComponentPlan(answers: CreateComponentInput): ComponentCreationPlan { + const { expected, files } = createComponentWork(parseComponentAnswers(answers)); + return { expected, files }; +} + +export function createComponentWork(input: ParsedComponentAnswers): ComponentCreationWork { + const name = toKebabCase(input.name); + const docsGroup = input.docsGroup; const displayName = toDisplayName(name); const pascalName = displayName.replaceAll(' ', ''); const camelName = toCamelCase(name); const recipeName = `${camelName}Recipe`; const variantsType = `${pascalName}RecipeVariants`; const packagePath = `@luke-ui/react/${name}`; - const conformanceTier = input.conformanceTier ?? 'universal'; - const integrationTripwire = input.integrationTripwire === true ? 'required' : 'none'; - const visualApplicability = input.visualCoverage === false ? 'none' : 'applicable'; + const conformanceTier = input.conformanceTier; + const integrationTripwire = input.integrationTripwire ? 'required' : 'none'; + const visualApplicability = input.visualCoverage ? 'applicable' : 'none'; const files: Array = [ { @@ -107,7 +152,7 @@ export function createComponentPlan(input: CreateComponentInput): ComponentCreat }, ]; - if (input.visualCoverage !== false) { + if (input.visualCoverage) { files.push({ contents: renderVisualTest({ name, pascalName }), path: `packages/@luke-ui/react/src/${name}/${name}.visual.test.tsx`, @@ -159,25 +204,6 @@ export function createComponentPlan(input: CreateComponentInput): ComponentCreat }; } -function parseName(value: string): string { - const trimmed = value.trim(); - if (!trimmed) { - throw new Error('Component name required.'); - } - if (!COMPONENT_NAME_RE.test(trimmed)) { - throw new Error('Use letters/numbers/hyphens. Start with a letter.'); - } - return toKebabCase(trimmed); -} - -function parseDocsGroup(value: string): string { - const trimmed = value.trim(); - if (!trimmed) { - throw new Error('Docs group required.'); - } - return toKebabCase(trimmed); -} - function toKebabCase(value: string): string { return value .trim() @@ -216,8 +242,8 @@ export interface ${input.pascalName}Props extends ComponentProps<'div'> {} /** ${input.pascalName} component. */ export function ${input.pascalName}(props: ${input.pascalName}Props): JSX.Element { -\tconst { className, ...divProps } = props; -\treturn
; + const { className, ...divProps } = props; + return
; } `; } @@ -242,15 +268,15 @@ function renderPackageStory(input: { import preview from '../../.storybook/preview.js'; const meta = preview.meta({ -\tcomponent: ${input.pascalName}, -\ttags: ['${input.docsGroup}'], -\ttitle: '${toDisplayName(input.docsGroup)}/${input.pascalName}', + component: ${input.pascalName}, + tags: ['${input.docsGroup}'], + title: '${toDisplayName(input.docsGroup)}/${input.pascalName}', }); export const Default = meta.story({ -\targs: { -\t\tchildren: '${input.pascalName}', -\t}, + args: { + children: '${input.pascalName}', + }, }); `; } @@ -374,8 +400,8 @@ props: --- `; } @@ -385,9 +411,9 @@ function renderRecipe(input: { recipeName: string; variantsType: string }): stri import { recipe } from '../styles/recipe.js'; export const ${input.recipeName} = recipe({ -\tbase: { -\t\tdisplay: 'inline-flex', -\t}, + base: { + display: 'inline-flex', + }, }); export type ${input.variantsType} = RecipeSelection; diff --git a/packages/turbo-generators/src/config.test.ts b/packages/turbo-generators/src/config.test.ts index 82546b5a..cdff2d78 100644 --- a/packages/turbo-generators/src/config.test.ts +++ b/packages/turbo-generators/src/config.test.ts @@ -1,22 +1,26 @@ import { describe, expect, it } from 'vite-plus/test'; -import { ZodError } from 'zod'; -import { parseComponentAnswers } from '../config.js'; +import generator from '../config.js'; +import { COMPONENT_DEFAULTS } from './component-creation-plan.js'; -const validAnswers = { - docsGroup: 'feedback', - name: 'StatusBadge', -}; +describe('component generator prompts', () => { + it('uses the plan-owned defaults instead of choice order', () => { + let prompts: Array<{ default?: unknown; name?: string }> | undefined; + generator({ + setGenerator(_name, config) { + if (!Array.isArray(config.prompts)) { + throw new Error('Expected static component generator prompts.'); + } + prompts = config.prompts; + }, + } as Parameters[0]); + if (prompts === undefined) { + throw new Error('Expected the component generator to register prompts.'); + } -describe('parseComponentAnswers', () => { - it('rejects invalid docs group answers', () => { - expect(() => parseComponentAnswers({ ...validAnswers, docsGroup: 'layout' })).toThrow(ZodError); - }); - - it('defaults test applicability for existing callers', () => { - expect(parseComponentAnswers(validAnswers)).toMatchObject({ - conformanceTier: 'universal', - integrationTripwire: false, - visualCoverage: true, - }); + expect({ + conformanceTier: prompts.find((prompt) => prompt.name === 'conformanceTier')?.default, + integrationTripwire: prompts.find((prompt) => prompt.name === 'integrationTripwire')?.default, + visualCoverage: prompts.find((prompt) => prompt.name === 'visualCoverage')?.default, + }).toEqual(COMPONENT_DEFAULTS); }); });