diff --git a/README.md b/README.md index 57360ef..ff862ae 100644 --- a/README.md +++ b/README.md @@ -13,8 +13,8 @@ Build high-signal agent and instruction files from community-proven best practic 1. Launch the app and switch from the landing hero to the Instructions Wizard. 2. Pick the instruction file you want to assemble (from templates defined in `data/files.json`). 3. Choose your framework and automatically load its follow-up question set (dynamic imports from `data/questions/.json`). -4. Answer or skip topic prompts across general, architecture, performance, security, commits, and more. -5. Review a completion summary that highlights which best practices made it into your file and which were skipped for later. +4. Answer topic prompts across general, architecture, performance, security, commits, and more—or lean on the recommended defaults when you need a fast decision. +5. Review a completion summary that highlights what made it into your file and which areas still need decisions. ## Community knowledge base - Every topic originates from the developer community—playbooks, real-world retrospectives, and shared tooling habits. @@ -24,8 +24,8 @@ Build high-signal agent and instruction files from community-proven best practic ## Key interaction details - Tooltips open from the info icon, letting you explore examples, pros/cons, tags, and external docs without losing your place. -- Multi-select questions support skipping (recorded as `null`) so uncertain topics never block progress. -- Progress indicators keep a running count of answered versus skipped items, making gaps obvious before export. +- Multi-select questions let you apply the curated default choice with a single click so momentum never stalls. +- Progress indicators keep a running count of answered versus unanswered items, making gaps obvious before export. ## Run devcontext locally ```bash diff --git a/agents.md b/agents.md index 2af4cdf..3b24f4f 100644 --- a/agents.md +++ b/agents.md @@ -11,9 +11,9 @@ - Framework selection (`data/frameworks.json`) with branching into framework-specific question sets (e.g., `data/questions/react.json`). - Dynamic question sets loaded via `import()` based on the chosen framework. - User actions per question: - - Select single or multiple answers (with skip support that records `null`). + - Select single or multiple answers, or apply the recommended default when unsure. - Review hover tooltips with examples, pros/cons, tags, and documentation links. - - Complete flow with a summary of answered vs skipped items. + - Complete flow with a summary of answered selections and remaining gaps. ## Data Conventions - Every answer object may define: `value`, `label`, `icon`, `example`, `infoLines` (derived from `pros`/`cons`), `tags`, `isDefault`, `disabled`, `disabledLabel`, and `docs`. diff --git a/components/instructions-wizard.tsx b/components/instructions-wizard.tsx index f2eace4..464b516 100644 --- a/components/instructions-wizard.tsx +++ b/components/instructions-wizard.tsx @@ -22,7 +22,11 @@ import { buildStepFromQuestionSet, getFormatLabel, getMimeTypeForFormat, mapAnsw import type { GeneratedFileResult } from "@/types/output" const fileOptions = filesData as FileOutputConfig[] -const defaultFileOption = fileOptions.find((file) => file.enabled !== false) ?? fileOptions[0] ?? null +const defaultFileOption = + fileOptions.find((file) => file.isDefault) ?? + fileOptions.find((file) => file.enabled !== false) ?? + fileOptions[0] ?? + null const FRAMEWORK_STEP_ID = "frameworks" const FRAMEWORK_QUESTION_ID = "frameworkSelection" @@ -206,7 +210,6 @@ const frameworksStep: WizardStep = { { id: FRAMEWORK_QUESTION_ID, question: "Which framework are you working with?", - skippable: false, answers: (rawFrameworks as FrameworkConfig[]).map((framework) => ({ value: framework.id, label: framework.label, @@ -214,7 +217,7 @@ const frameworksStep: WizardStep = { disabled: framework.enabled === false, disabledLabel: framework.enabled === false ? "Soon" : undefined, docs: framework.docs, - skippable: framework.skippable, + isDefault: framework.isDefault, })), }, ], @@ -311,6 +314,34 @@ export function InstructionsWizard({ onClose, selectedFileId }: InstructionsWiza return currentAnswerValue === value } + const defaultAnswer = useMemo( + () => currentQuestion.answers.find((answer) => answer.isDefault), + [currentQuestion] + ) + + const isDefaultSelected = useMemo(() => { + if (!defaultAnswer) { + return false + } + + if (currentQuestion.allowMultiple) { + return Array.isArray(currentAnswerValue) && currentAnswerValue.includes(defaultAnswer.value) + } + + return currentAnswerValue === defaultAnswer.value + }, [currentAnswerValue, currentQuestion.allowMultiple, defaultAnswer]) + + const canUseDefault = Boolean( + !isComplete && + defaultAnswer && + !defaultAnswer.disabled && + (!isDefaultSelected || currentQuestion.allowMultiple) + ) + + const defaultButtonLabel = defaultAnswer + ? `Use default (${defaultAnswer.label})` + : "Use default" + const advanceToNextQuestion = () => { const isLastQuestionInStep = currentQuestionIndex === currentStep.questions.length - 1 const isLastStep = currentStepIndex === wizardSteps.length - 1 @@ -361,7 +392,7 @@ export function InstructionsWizard({ onClose, selectedFileId }: InstructionsWiza id: question.id, question: question.question, allowMultiple: question.allowMultiple, - skippable: question.skippable, + responseKey: question.responseKey, answers: question.answers.map(mapAnswerSourceToWizard), })) @@ -445,25 +476,33 @@ export function InstructionsWizard({ onClose, selectedFileId }: InstructionsWiza } } - const skipQuestion = () => { - if (currentQuestion.skippable === false) { + const applyDefaultAnswer = async () => { + if (!defaultAnswer || defaultAnswer.disabled) { return } setGeneratedFile(null) + const nextValue: Responses[keyof Responses] = currentQuestion.allowMultiple + ? [defaultAnswer.value] + : defaultAnswer.value + setResponses((prev) => ({ ...prev, - [currentQuestion.id]: null, + [currentQuestion.id]: nextValue, })) - if (currentQuestion.id === FRAMEWORK_QUESTION_ID) { - setDynamicSteps([]) + const isFrameworkQuestion = currentQuestion.id === FRAMEWORK_QUESTION_ID + + if (isFrameworkQuestion) { + await loadFrameworkQuestions(defaultAnswer.value, defaultAnswer.label) + return } - advanceToNextQuestion() + setTimeout(() => { + advanceToNextQuestion() + }, 0) } - const resetWizard = () => { setResponses({}) setDynamicSteps([]) @@ -559,20 +598,26 @@ export function InstructionsWizard({ onClose, selectedFileId }: InstructionsWiza wizardSteps.forEach((step) => { step.questions.forEach((question) => { - const key = question.id + const responseKey = question.responseKey ?? question.id + + if (!(responseKey in questionsAndAnswers)) { + return + } + const answer = responses[question.id] + const targetKey = responseKey as keyof WizardResponses if (answer !== null && answer !== undefined) { if (question.allowMultiple && Array.isArray(answer)) { // For all other multi-selects, keep as array - questionsAndAnswers[key as keyof WizardResponses] = Array.isArray(answer) ? answer.join(", ") : answer + questionsAndAnswers[targetKey] = answer.join(", ") } else if (!question.allowMultiple && typeof answer === 'string') { - questionsAndAnswers[key as keyof WizardResponses] = Array.isArray(answer) ? answer.join(", ") : answer + questionsAndAnswers[targetKey] = answer } else { - questionsAndAnswers[key as keyof WizardResponses] = Array.isArray(answer) ? answer.join(", ") : answer + questionsAndAnswers[targetKey] = Array.isArray(answer) ? answer.join(", ") : (answer as string) } } else { - questionsAndAnswers[key as keyof WizardResponses] = null + questionsAndAnswers[targetKey] = null } }) }) @@ -632,7 +677,7 @@ export function InstructionsWizard({ onClose, selectedFileId }: InstructionsWiza selectedFile ? { question: "Instructions file", - skipped: false, + hasSelection: true, answers: [ selectedFile.label, selectedFile.filename ? `Filename: ${selectedFile.filename}` : null, @@ -641,14 +686,14 @@ export function InstructionsWizard({ onClose, selectedFileId }: InstructionsWiza } : { question: "Instructions file", - skipped: true, + hasSelection: false, answers: [], }, ...wizardSteps.flatMap((step) => step.questions.map((question) => { const value = responses[question.id] const selectedAnswers = question.answers.filter((answer) => { - if (value === null) { + if (value === null || value === undefined) { return false } @@ -661,7 +706,7 @@ export function InstructionsWizard({ onClose, selectedFileId }: InstructionsWiza return { question: question.question, - skipped: value === null, + hasSelection: selectedAnswers.length > 0, answers: selectedAnswers.map((answer) => answer.label), } }) @@ -684,14 +729,14 @@ export function InstructionsWizard({ onClose, selectedFileId }: InstructionsWiza className="rounded-2xl border border-border/70 bg-background/90 p-5" >

{entry.question}

- {entry.skipped ? ( -

Skipped

- ) : ( + {entry.hasSelection ? ( + ) : ( +

No selection

)} ))} @@ -722,7 +767,17 @@ export function InstructionsWizard({ onClose, selectedFileId }: InstructionsWiza const isAtFirstQuestion = currentStepIndex === 0 && currentQuestionIndex === 0 const backDisabled = isAtFirstQuestion && !isComplete - const canSkipCurrentQuestion = !isComplete && currentQuestion.skippable !== false + const defaultButtonTitle = !canUseDefault + ? isComplete + ? "Questions complete" + : defaultAnswer?.disabled + ? "Default option unavailable" + : isDefaultSelected && !currentQuestion.allowMultiple + ? "Default already selected" + : defaultAnswer + ? undefined + : "No default available" + : undefined const showChangeFile = Boolean(onClose && selectedFile) const actionBar = ( @@ -738,17 +793,11 @@ export function InstructionsWizard({ onClose, selectedFileId }: InstructionsWiza
diff --git a/data/architecture.json b/data/architecture.json index 6065e31..75f9fb3 100644 --- a/data/architecture.json +++ b/data/architecture.json @@ -7,7 +7,8 @@ "value": "reactQuery", "label": "React Query", "example": "Use React Query for server state management", - "docs": "https://tanstack.com/query/latest/docs/react/overview" + "docs": "https://tanstack.com/query/latest/docs/react/overview", + "isDefault": true }, { "value": "reduxToolkit", @@ -30,7 +31,8 @@ "value": "hooks", "label": "Custom hooks", "example": "Encapsulate API calls in useFetchSomething()", - "docs": "https://react.dev/learn/reusing-logic-with-custom-hooks" + "docs": "https://react.dev/learn/reusing-logic-with-custom-hooks", + "isDefault": true } ] }, @@ -41,7 +43,8 @@ { "value": "featureFolders", "label": "Feature-based folders", - "example": "src/features/auth/LoginForm.tsx" + "example": "src/features/auth/LoginForm.tsx", + "isDefault": true }, { "value": "domainDriven", diff --git a/data/commits.json b/data/commits.json index 36f6d00..d32e93a 100644 --- a/data/commits.json +++ b/data/commits.json @@ -7,12 +7,13 @@ "value": "conventional", "label": "Conventional Commits", "example": "feat(auth): add login endpoint", - "docs": "https://www.conventionalcommits.org/en/v1.0.0/" + "docs": "https://www.conventionalcommits.org/en/v1.0.0/", + "isDefault": true }, { "value": "gitmoji", "label": "Gitmoji", - "example": "✨ add login endpoint", + "example": "\u2728 add login endpoint", "docs": "https://gitmoji.dev/" }, { @@ -34,7 +35,8 @@ { "value": "reviewRequired", "label": "Require at least one review", - "example": "PRs must be approved by another team member." + "example": "PRs must be approved by another team member.", + "isDefault": true }, { "value": "changelog", diff --git a/data/files.json b/data/files.json index b6e0d55..6c6c41c 100644 --- a/data/files.json +++ b/data/files.json @@ -7,7 +7,7 @@ "enabled": true, "icon": "markdown", "docs": "https://docs.github.com/en/copilot", - "skippable": false + "isDefault": true }, { "id": "agents-md", @@ -16,8 +16,7 @@ "format": "markdown", "enabled": true, "icon": "markdown", - "docs": "https://docs.github.com/en/copilot", - "skippable": false + "docs": "https://docs.github.com/en/copilot" }, { "id": "cursor-rules", @@ -25,7 +24,6 @@ "filename": ".cursor/rules", "format": "json", "enabled": true, - "docs": "https://docs.cursor.com/workflows/rules", - "skippable": false + "docs": "https://docs.cursor.com/workflows/rules" } ] diff --git a/data/frameworks.json b/data/frameworks.json index 17d7a0d..9352e42 100644 --- a/data/frameworks.json +++ b/data/frameworks.json @@ -5,22 +5,20 @@ "icon": "react", "enabled": true, "docs": "https://react.dev/learn", - "skippable": false + "isDefault": true }, { "id": "nextjs", "label": "Next.js", "icon": "nextdotjs", "enabled": true, - "docs": "https://nextjs.org/docs", - "skippable": false + "docs": "https://nextjs.org/docs" }, { "id": "angular", "label": "Angular", "icon": "angular", "enabled": true, - "docs": "https://angular.io/docs", - "skippable": false + "docs": "https://angular.io/docs" } ] diff --git a/data/general.json b/data/general.json index 0848685..ff7d46e 100644 --- a/data/general.json +++ b/data/general.json @@ -6,7 +6,8 @@ { "value": "maintainability", "label": "Maintainability", - "example": "Prefer clarity and modularity over speed." + "example": "Prefer clarity and modularity over speed.", + "isDefault": true }, { "value": "speed", @@ -34,7 +35,8 @@ "value": "airbnb", "label": "Airbnb JavaScript Style Guide", "example": "Follow https://github.com/airbnb/javascript", - "docs": "https://github.com/airbnb/javascript" + "docs": "https://github.com/airbnb/javascript", + "isDefault": true }, { "value": "standardjs", @@ -57,7 +59,8 @@ "value": "camelCase", "label": "camelCase", "example": "const userName = 'john'", - "docs": "https://en.wikipedia.org/wiki/Camel_case" + "docs": "https://en.wikipedia.org/wiki/Camel_case", + "isDefault": true }, { "value": "snake_case", @@ -75,7 +78,8 @@ "value": "kebab-case", "label": "kebab-case", "example": "user-profile.tsx", - "docs": "https://en.wikipedia.org/wiki/Letter_case#Kebab_case" + "docs": "https://en.wikipedia.org/wiki/Letter_case#Kebab_case", + "isDefault": true }, { "value": "camelCase", @@ -105,7 +109,8 @@ "value": "PascalCase", "label": "PascalCase", "example": "export function LoginForm() {}", - "docs": "https://en.wikipedia.org/wiki/PascalCase" + "docs": "https://en.wikipedia.org/wiki/PascalCase", + "isDefault": true }, { "value": "camelCase", @@ -123,7 +128,8 @@ "value": "named", "label": "Named exports", "example": "export const Button = () => {}", - "docs": "https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/export#named_exports" + "docs": "https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/export#named_exports", + "isDefault": true }, { "value": "default", @@ -140,7 +146,8 @@ { "value": "minimal", "label": "Minimal comments", - "example": "// Explain complex logic only" + "example": "// Explain complex logic only", + "isDefault": true }, { "value": "jsdoc", @@ -168,7 +175,8 @@ "value": "reviewBeforeMerge", "label": "Require reviews before merging", "example": "At least one approval is mandatory.", - "docs": "https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/reviewing-changes-in-pull-requests" + "docs": "https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/reviewing-changes-in-pull-requests", + "isDefault": true }, { "value": "docBeforeMerge", @@ -177,4 +185,4 @@ } ] } -] \ No newline at end of file +] diff --git a/data/performance.json b/data/performance.json index 51cce09..37c123d 100644 --- a/data/performance.json +++ b/data/performance.json @@ -7,13 +7,15 @@ "value": "paginate", "label": "Always paginate large queries", "example": "Use ?limit=50&offset=0", - "docs": "https://restfulapi.net/pagination/" + "docs": "https://restfulapi.net/pagination/", + "isDefault": true }, { "value": "infiniteScroll", "label": "Use infinite scroll for feeds", "example": "Load more on scroll", - "docs": "https://web.dev/infinite-scroll-without-chaos/" + "docs": "https://web.dev/infinite-scroll-without-chaos/", + "isDefault": false } ] }, @@ -25,7 +27,8 @@ "value": "memoHooks", "label": "Memoize heavy components", "example": "useMemo/useCallback for expensive calculations", - "docs": "https://react.dev/learn/keeping-components-pure#optimize-re-rendering-with-memoization" + "docs": "https://react.dev/learn/keeping-components-pure#optimize-re-rendering-with-memoization", + "isDefault": true }, { "value": "dynamicImport", @@ -35,4 +38,4 @@ } ] } -] +] \ No newline at end of file diff --git a/data/questions/angular.json b/data/questions/angular.json index 98634dd..c46f218 100644 --- a/data/questions/angular.json +++ b/data/questions/angular.json @@ -1,7 +1,8 @@ [ { - "id": "tooling", + "id": "angular-tooling", "question": "How do you scaffold or manage your Angular project?", + "responseKey": "tooling", "answers": [ { "value": "angular-cli", @@ -15,7 +16,8 @@ "cons": [ "Opinionated build pipeline" ], - "example": "ng new my-app --standalone" + "example": "ng new my-app --standalone", + "isDefault": true }, { "value": "nx", @@ -46,8 +48,9 @@ ] }, { - "id": "language", + "id": "angular-language", "question": "What language mode do you target?", + "responseKey": "language", "answers": [ { "value": "typescript-strict", @@ -61,7 +64,8 @@ "cons": [ "Requires extra typings for legacy packages" ], - "example": "tsconfig.json with \"strict\": true" + "example": "tsconfig.json with \"strict\": true", + "isDefault": true }, { "value": "typescript-default", @@ -92,8 +96,9 @@ ] }, { - "id": "fileStructure", + "id": "angular-fileStructure", "question": "How do you organize Angular features?", + "responseKey": "fileStructure", "answers": [ { "value": "standalone-first", @@ -107,7 +112,8 @@ "cons": [ "Requires Angular 17+ patterns" ], - "example": "src/app/(dashboard)/+page.component.ts" + "example": "src/app/(dashboard)/+page.component.ts", + "isDefault": true }, { "value": "ng-modules", @@ -140,8 +146,9 @@ ] }, { - "id": "styling", + "id": "angular-styling", "question": "Which styling approach do you prefer?", + "responseKey": "styling", "answers": [ { "value": "scss", @@ -154,7 +161,8 @@ "cons": [ "Requires Sass build step" ], - "example": "ng config schematics.@schematics/angular:component style=scss" + "example": "ng config schematics.@schematics/angular:component style=scss", + "isDefault": true }, { "value": "tailwind", @@ -186,8 +194,9 @@ ] }, { - "id": "stateManagement", + "id": "angular-stateManagement", "question": "How do you manage application state?", + "responseKey": "stateManagement", "answers": [ { "value": "ngrx-store", @@ -201,7 +210,8 @@ "cons": [ "Boilerplate for actions and reducers" ], - "example": "ng generate store State --module app.module.ts" + "example": "ng generate store State --module app.module.ts", + "isDefault": true }, { "value": "signals-services", @@ -233,8 +243,9 @@ ] }, { - "id": "apiLayer", + "id": "angular-apiLayer", "question": "How do you access backend data?", + "responseKey": "apiLayer", "answers": [ { "value": "httpclient", @@ -247,7 +258,8 @@ "cons": [ "Manual typing for REST endpoints" ], - "example": "this.http.get('/api/users')" + "example": "this.http.get('/api/users')", + "isDefault": true }, { "value": "apollo-angular", @@ -279,8 +291,9 @@ ] }, { - "id": "validation", + "id": "angular-validation", "question": "How do you validate user input?", + "responseKey": "validation", "answers": [ { "value": "reactive-forms", @@ -294,7 +307,8 @@ "cons": [ "Verbose form builders" ], - "example": "this.form = fb.group({ email: ['', [Validators.required]] })" + "example": "this.form = fb.group({ email: ['', [Validators.required]] })", + "isDefault": true }, { "value": "template-forms", @@ -324,8 +338,9 @@ ] }, { - "id": "logging", + "id": "angular-logging", "question": "How do you capture logs and telemetry?", + "responseKey": "logging", "answers": [ { "value": "sentry", @@ -338,7 +353,8 @@ "cons": [ "Hosted service pricing" ], - "example": "Sentry.init({ dsn: 'https://...' })" + "example": "Sentry.init({ dsn: 'https://...' })", + "isDefault": true }, { "value": "azure-app-insights", @@ -368,8 +384,9 @@ ] }, { - "id": "testingUT", + "id": "angular-testingUT", "question": "Which framework powers your unit tests?", + "responseKey": "testingUT", "answers": [ { "value": "jasmine-karma", @@ -383,7 +400,8 @@ "cons": [ "Browser runner adds overhead" ], - "example": "ng test" + "example": "ng test", + "isDefault": true }, { "value": "jest", @@ -415,8 +433,9 @@ ] }, { - "id": "testingE2E", + "id": "angular-testingE2E", "question": "How do you run end-to-end tests?", + "responseKey": "testingE2E", "answers": [ { "value": "cypress", @@ -429,7 +448,8 @@ "cons": [ "Higher resource usage" ], - "example": "ng e2e" + "example": "ng e2e", + "isDefault": true }, { "value": "playwright", diff --git a/data/questions/nextjs.json b/data/questions/nextjs.json index 09bfa29..4c1387d 100644 --- a/data/questions/nextjs.json +++ b/data/questions/nextjs.json @@ -1,7 +1,8 @@ [ { - "id": "tooling", + "id": "nextjs-tooling", "question": "How do you scaffold or manage your Next.js project?", + "responseKey": "tooling", "answers": [ { "value": "create-next-app", @@ -15,7 +16,8 @@ "cons": [ "Less flexibility for monorepo workflows" ], - "example": "npx create-next-app@latest my-app --ts" + "example": "npx create-next-app@latest my-app --ts", + "isDefault": true }, { "value": "turborepo", @@ -47,8 +49,9 @@ "explanation": "Choose the tooling you rely on for bootstrapping and evolving the project." }, { - "id": "fileStructure", + "id": "nextjs-fileStructure", "question": "Which routing paradigm are you using?", + "responseKey": "fileStructure", "answers": [ { "value": "app-directory", @@ -62,7 +65,8 @@ "cons": [ "Requires new mental models for data fetching" ], - "example": "app/dashboard/page.tsx" + "example": "app/dashboard/page.tsx", + "isDefault": true }, { "value": "pages-directory", @@ -94,8 +98,9 @@ "explanation": "Routing choice impacts data fetching patterns, layouts, and deployment expectations." }, { - "id": "language", + "id": "nextjs-language", "question": "What language do you use?", + "responseKey": "language", "answers": [ { "value": "typescript", @@ -109,7 +114,8 @@ "cons": [ "Slightly more boilerplate" ], - "example": "npx create-next-app@latest my-app --ts" + "example": "npx create-next-app@latest my-app --ts", + "isDefault": true }, { "value": "javascript", @@ -128,8 +134,9 @@ "explanation": "Language choice impacts type safety, linting, and Copilot suggestions." }, { - "id": "styling", + "id": "nextjs-styling", "question": "Which styling strategy do you rely on?", + "responseKey": "styling", "answers": [ { "value": "tailwind", @@ -143,7 +150,8 @@ "cons": [ "Class-heavy JSX can impact readability" ], - "example": "npx create-next-app@latest --example with-tailwindcss my-app" + "example": "npx create-next-app@latest --example with-tailwindcss my-app", + "isDefault": true }, { "value": "cssmodules", @@ -176,8 +184,9 @@ "explanation": "Styling stack influences rendering performance and component ergonomics." }, { - "id": "stateManagement", + "id": "nextjs-stateManagement", "question": "How do you handle client-side state?", + "responseKey": "stateManagement", "answers": [ { "value": "zustand", @@ -190,7 +199,8 @@ "cons": [ "Must wire your own middleware for persisting" ], - "example": "npm install zustand" + "example": "npm install zustand", + "isDefault": true }, { "value": "redux", @@ -224,8 +234,9 @@ "explanation": "State tooling guides component structure and cross-page coordination." }, { - "id": "dataFetching", + "id": "nextjs-dataFetching", "question": "What is your primary data fetching approach?", + "responseKey": "dataFetching", "answers": [ { "value": "server-components", @@ -239,7 +250,8 @@ "cons": [ "Requires careful server/client component boundaries" ], - "example": "export default async function Page(){ const data = await fetch(apiUrl).then(r => r.json()) }" + "example": "export default async function Page(){ const data = await fetch(apiUrl).then(r => r.json()) }", + "isDefault": true }, { "value": "swr", @@ -272,8 +284,9 @@ "explanation": "Data strategy shapes caching, hydration, and API integration patterns." }, { - "id": "auth", + "id": "nextjs-auth", "question": "How do you handle authentication?", + "responseKey": "auth", "answers": [ { "value": "next-auth", @@ -286,7 +299,8 @@ "cons": [ "Requires database adapter for persistence" ], - "example": "npm install next-auth" + "example": "npm install next-auth", + "isDefault": true }, { "value": "clerk", @@ -318,8 +332,9 @@ "explanation": "Auth strategy drives session handling, middleware, and deployment needs." }, { - "id": "validation", + "id": "nextjs-validation", "question": "What do you use for schema validation?", + "responseKey": "validation", "answers": [ { "value": "zod", @@ -333,7 +348,8 @@ "cons": [ "Adds runtime parsing cost" ], - "example": "const schema = z.object({ email: z.string().email() })" + "example": "const schema = z.object({ email: z.string().email() })", + "isDefault": true }, { "value": "yup", @@ -364,8 +380,9 @@ "explanation": "Validation approach affects reliability of Server Actions and API routes." }, { - "id": "logging", + "id": "nextjs-logging", "question": "How do you capture logs and runtime signals?", + "responseKey": "logging", "answers": [ { "value": "vercel-observability", @@ -393,7 +410,8 @@ "cons": [ "Hosted service pricing" ], - "example": "npx @sentry/wizard@latest -i nextjs" + "example": "npx @sentry/wizard@latest -i nextjs", + "isDefault": true }, { "value": "custom-logging", @@ -426,7 +444,8 @@ "cons": [ "Slower start-up time on large suites" ], - "example": "npm install --save-dev jest @testing-library/react next/jest" + "example": "npm install --save-dev jest @testing-library/react next/jest", + "isDefault": true }, { "value": "vitest", @@ -461,7 +480,8 @@ "cons": [ "Learning curve for advanced fixtures" ], - "example": "npm install --save-dev @playwright/test" + "example": "npm install --save-dev @playwright/test", + "isDefault": true }, { "value": "cypress", diff --git a/data/questions/react.json b/data/questions/react.json index 470ffb3..6bcc702 100644 --- a/data/questions/react.json +++ b/data/questions/react.json @@ -1,7 +1,8 @@ [ { - "id": "tooling", + "id": "react-tooling", "question": "What build tooling do you use?", + "responseKey": "tooling", "answers": [ { "value": "vite", @@ -15,7 +16,8 @@ "cons": [ "Less official docs compared to CRA" ], - "example": "npx create-vite@latest my-app --template react" + "example": "npx create-vite@latest my-app --template react", + "isDefault": true }, { "value": "cra", @@ -33,11 +35,12 @@ "example": "npx create-react-app my-app" } ], - "explanation": "Choose the build tool / project scaffolder you’re using." + "explanation": "Choose the build tool / project scaffolder you\u2019re using." }, { - "id": "language", + "id": "react-language", "question": "What language do you use?", + "responseKey": "language", "answers": [ { "value": "typescript", @@ -51,7 +54,8 @@ "cons": [ "Slightly more boilerplate" ], - "example": "npx create-vite@latest my-app --template react-ts" + "example": "npx create-vite@latest my-app --template react-ts", + "isDefault": true }, { "value": "javascript", @@ -71,8 +75,9 @@ "explanation": "Language choice impacts type safety and Copilot suggestions." }, { - "id": "fileStructure", + "id": "react-fileStructure", "question": "How do you prefer to organize your components?", + "responseKey": "fileStructure", "answers": [ { "value": "flat", @@ -97,7 +102,8 @@ "cons": [ "Overhead for small projects" ], - "example": "src/features/auth/components/LoginForm.tsx" + "example": "src/features/auth/components/LoginForm.tsx", + "isDefault": true }, { "value": "component-directories", @@ -116,8 +122,9 @@ "explanation": "Component organization affects maintainability as the project grows." }, { - "id": "styling", + "id": "react-styling", "question": "Which styling approach do you use?", + "responseKey": "styling", "answers": [ { "value": "tailwind", @@ -131,7 +138,8 @@ "cons": [ "HTML can get cluttered with classes" ], - "example": "" + "example": "", + "isDefault": true }, { "value": "cssmodules", @@ -151,8 +159,9 @@ "explanation": "Styling choice impacts developer experience and bundle size." }, { - "id": "testingUT", + "id": "react-testingUT", "question": "Which framework do you use for unit testing?", + "responseKey": "testingUT", "answers": [ { "value": "jest", @@ -166,7 +175,8 @@ "cons": [ "Slower for large projects" ], - "example": "npm install --save-dev jest @testing-library/react" + "example": "npm install --save-dev jest @testing-library/react", + "isDefault": true }, { "value": "vitest", @@ -186,8 +196,9 @@ "explanation": "Unit testing ensures components work as expected in isolation." }, { - "id": "testingE2E", + "id": "react-testingE2E", "question": "Which framework do you use for end-to-end (E2E) testing?", + "responseKey": "testingE2E", "answers": [ { "value": "cypress", @@ -201,7 +212,8 @@ "cons": [ "More resource-heavy" ], - "example": "npm install --save-dev cypress" + "example": "npm install --save-dev cypress", + "isDefault": true }, { "value": "playwright", diff --git a/data/security.json b/data/security.json index 23ac1ca..d89005e 100644 --- a/data/security.json +++ b/data/security.json @@ -7,7 +7,8 @@ "value": "env", "label": "Environment variables only", "example": "API keys must come from process.env", - "docs": "https://12factor.net/config" + "docs": "https://12factor.net/config", + "isDefault": true }, { "value": "vault", @@ -25,7 +26,8 @@ "value": "zod", "label": "Zod schema validation", "example": "Use Zod for all API input validation", - "docs": "https://zod.dev/?id=introduction" + "docs": "https://zod.dev/?id=introduction", + "isDefault": true }, { "value": "yup", @@ -43,7 +45,8 @@ "value": "noSecrets", "label": "Never log secrets", "example": "Do not log tokens or passwords", - "docs": "https://cheatsheetseries.owasp.org/cheatsheets/Logging_Cheat_Sheet.html" + "docs": "https://cheatsheetseries.owasp.org/cheatsheets/Logging_Cheat_Sheet.html", + "isDefault": true }, { "value": "structured", diff --git a/lib/__tests__/data-defaults.test.ts b/lib/__tests__/data-defaults.test.ts new file mode 100644 index 0000000..5028ddd --- /dev/null +++ b/lib/__tests__/data-defaults.test.ts @@ -0,0 +1,88 @@ +import { describe, it } from 'vitest' +import { readdirSync, readFileSync, statSync } from 'node:fs' +import { join, relative } from 'node:path' + +const DATA_ROOT = join(process.cwd(), 'data') + +type QuestionCandidate = { + node: Record + path: string +} + +const isRecord = (value: unknown): value is Record => { + return typeof value === 'object' && value !== null +} + +const collectJsonFiles = (dir: string): string[] => { + return readdirSync(dir).flatMap((entry) => { + const fullPath = join(dir, entry) + const stats = statSync(fullPath) + + if (stats.isDirectory()) { + return collectJsonFiles(fullPath) + } + + return entry.endsWith('.json') ? [fullPath] : [] + }) +} + +const collectQuestionCandidates = (node: unknown, currentPath: string): QuestionCandidate[] => { + if (Array.isArray(node)) { + return node.flatMap((value, index) => collectQuestionCandidates(value, `${currentPath}[${index}]`)) + } + + if (!isRecord(node)) { + return [] + } + + const candidates: QuestionCandidate[] = [] + const { answers } = node as { answers?: unknown } + + if (Array.isArray(answers)) { + candidates.push({ node, path: currentPath }) + } + + for (const [key, value] of Object.entries(node)) { + if (key === 'answers') { + continue + } + + candidates.push(...collectQuestionCandidates(value, `${currentPath}.${key}`)) + } + + return candidates +} + +describe('Instruction data defaults', () => { + const jsonFiles = collectJsonFiles(DATA_ROOT) + + jsonFiles.forEach((filePath) => { + it(`ensures ${relative(process.cwd(), filePath)} questions have exactly one default`, () => { + const raw = readFileSync(filePath, 'utf8') + const parsed = JSON.parse(raw) as unknown + const questions = collectQuestionCandidates(parsed, '$') + const relativePath = relative(process.cwd(), filePath) + + questions.forEach((candidate) => { + const question = candidate.node + const { answers } = question as { answers?: unknown } + + if (!Array.isArray(answers) || answers.length === 0) { + return + } + + const questionId = typeof question.id === 'string' ? question.id : candidate.path + const defaultCount = answers.reduce((count, answer) => { + const { isDefault } = (isRecord(answer) ? answer : {}) as { isDefault?: unknown } + return isDefault === true ? count + 1 : count + }, 0) + + if (defaultCount !== 1) { + throw new Error( + `Expected exactly one default answer in question '${questionId}' within ${relativePath}, but found ${defaultCount}.` + ) + } + }) + }) + }) +}) diff --git a/lib/wizard-utils.ts b/lib/wizard-utils.ts index d0abc8e..6ca0e7e 100644 --- a/lib/wizard-utils.ts +++ b/lib/wizard-utils.ts @@ -25,7 +25,6 @@ export const mapAnswerSourceToWizard = (answer: DataAnswerSource): WizardAnswer isDefault: answer.isDefault, disabled: answer.disabled, disabledLabel: answer.disabledLabel, - skippable: answer.skippable, } } @@ -37,15 +36,15 @@ export const buildStepFromQuestionSet = ( title: string, questions: DataQuestionSource[] ): WizardStep => ({ - id, - title, - questions: questions.map((question) => ({ - id: question.id, - question: question.question, - allowMultiple: question.allowMultiple, - answers: question.answers.map(mapAnswerSourceToWizard), - skippable: question.skippable, - })), + id, + title, + questions: questions.map((question) => ({ + id: question.id, + question: question.question, + allowMultiple: question.allowMultiple, + responseKey: question.responseKey, + answers: question.answers.map(mapAnswerSourceToWizard), + })), }) const formatLabelMap: Record = { diff --git a/types/wizard.ts b/types/wizard.ts index 2617997..d87b51c 100644 --- a/types/wizard.ts +++ b/types/wizard.ts @@ -4,7 +4,7 @@ export type FrameworkConfig = { icon?: string enabled?: boolean docs?: string - skippable?: boolean + isDefault?: boolean } export type DataAnswerSource = { @@ -19,15 +19,14 @@ export type DataAnswerSource = { isDefault?: boolean disabled?: boolean disabledLabel?: string - skippable?: boolean } export type DataQuestionSource = { id: string question: string allowMultiple?: boolean + responseKey?: string answers: DataAnswerSource[] - skippable?: boolean } export type FileOutputConfig = { @@ -38,7 +37,7 @@ export type FileOutputConfig = { enabled?: boolean icon?: string docs?: string - skippable?: boolean + isDefault?: boolean } export type WizardAnswer = { @@ -52,15 +51,14 @@ export type WizardAnswer = { disabled?: boolean disabledLabel?: string docs?: string - skippable?: boolean } export type WizardQuestion = { id: string question: string allowMultiple?: boolean + responseKey?: string answers: WizardAnswer[] - skippable?: boolean } export type WizardStep = {