diff --git a/app/existing/[repoUrl]/repo-scan-client.tsx b/app/existing/[repoUrl]/repo-scan-client.tsx index 5a22947..3e95b14 100644 --- a/app/existing/[repoUrl]/repo-scan-client.tsx +++ b/app/existing/[repoUrl]/repo-scan-client.tsx @@ -112,26 +112,26 @@ export default function RepoScanClient({ initialRepoUrl }: RepoScanClientProps) })) }, [scanResult]) - const handleStartScan = () => { - if (!repoUrlForScan) { - return - } - - setHasConfirmed(true) - setScanToken((token) => token + 1) + const handleStartScan = () => { + if (!repoUrlForScan) { + return + } - track(ANALYTICS_EVENTS.REPO_SCAN_START, { repo: repoUrlForScan }) - } + setHasConfirmed(true) + setScanToken((token) => token + 1) - const handleRetryScan = () => { - if (!repoUrlForScan) { - return + track(ANALYTICS_EVENTS.REPO_SCAN_START, { repo: repoUrlForScan }) } - setScanToken((token) => token + 1) + const handleRetryScan = () => { + if (!repoUrlForScan) { + return + } - track(ANALYTICS_EVENTS.REPO_SCAN_RETRY, { repo: repoUrlForScan }) - } + setScanToken((token) => token + 1) + + track(ANALYTICS_EVENTS.REPO_SCAN_RETRY, { repo: repoUrlForScan }) + } const warnings = scanResult?.warnings ?? [] const stackMeta = scanResult?.conventions ?? null @@ -372,7 +372,16 @@ export default function RepoScanClient({ initialRepoUrl }: RepoScanClientProps) disabled={busy} className="flex h-[36px] items-center rounded-full px-4 py-0 text-sm" > - {busy ? `Generating ${file.filename}…` : `Generate ${file.filename}`} +
+ + {busy ? `Generating ${file.filename}…` : `Generate ${file.filename}`} + + {file.isLegacy && ( + + Legacy + + )} +
) })} diff --git a/app/new/stack/stack-summary-page.tsx b/app/new/stack/stack-summary-page.tsx index f5eb0f1..32852d5 100644 --- a/app/new/stack/stack-summary-page.tsx +++ b/app/new/stack/stack-summary-page.tsx @@ -388,9 +388,16 @@ export function StackSummaryPage({ stackId, mode }: StackSummaryPageProps) { disabled={isGenerating} className="flex h-[38px] min-w-[190px] items-center justify-center rounded-full px-6 py-0 text-base leading-none shadow-lg shadow-primary/20" > - - {isGenerating ? `Generating ${file.filename}…` : `Generate ${file.filename}`} - +
+ + {isGenerating ? `Generating ${file.filename}…` : `Generate ${file.filename}`} + + {file.isLegacy && ( + + Legacy + + )} +
) })} diff --git a/components/final-output-view.tsx b/components/final-output-view.tsx index 32e3d10..8d1c594 100644 --- a/components/final-output-view.tsx +++ b/components/final-output-view.tsx @@ -98,11 +98,51 @@ export default function FinalOutputView({ fileName, fileContent, mimeType, onClo }, COPY_RESET_DELAY) }, [currentContent]) - const handleDownloadClick = useCallback(() => { + const handleDownloadClick = useCallback(async () => { if (!currentContent) { return } + if (normalizedFileName.endsWith('.zip')) { + try { + const { default: JSZip } = await import('jszip') + const zip = new JSZip() + + // Split combined content by the FILE delimiter + const parts = currentContent.split('--- FILE: ') + let foundFiles = false + + for (const part of parts) { + if (!part.trim()) continue + + const lines = part.split('\n') + const firstLine = lines[0].trim() + if (firstLine.endsWith(' ---')) { + const fileName = firstLine.replace(' ---', '').trim() + const content = lines.slice(1).join('\n').trim() + zip.file(fileName, content) + foundFiles = true + } + } + + if (foundFiles) { + const blob = await zip.generateAsync({ type: 'blob' }) + const url = URL.createObjectURL(blob) + const link = document.createElement("a") + link.href = url + link.download = normalizedFileName + link.style.display = "none" + document.body.appendChild(link) + link.click() + document.body.removeChild(link) + URL.revokeObjectURL(url) + return + } + } catch (error) { + console.error('Failed to generate ZIP:', error) + } + } + const downloadMimeType = mimeType ?? "text/plain;charset=utf-8" const blob = new Blob([currentContent], { type: downloadMimeType }) const url = URL.createObjectURL(blob) diff --git a/data/files.json b/data/files.json index 70a852a..e54ed63 100644 --- a/data/files.json +++ b/data/files.json @@ -24,13 +24,22 @@ "enabled": true }, { - "value": "cursor-rules", - "label": "Cursor Rules", + "value": "cursor-rules-legacy", + "label": "Cursor Rules (JSON)", "filename": ".cursor/rules", "format": "json", - "docs": "https://docs.cursor.com/workflows/rules", + "docs": "https://docs.cursor.com/context/rules", + "enabled": true, + "isLegacy": true + }, + { + "value": "cursor-rules", + "label": "Cursor Rules (.mdc + ZIP)", + "filename": "cursor-rules.zip", + "format": "zip", + "docs": "https://cursor.com/docs/context/rules", "enabled": true } ] } -] +] \ No newline at end of file diff --git a/file-templates/cursor-rules-template-mdc.md b/file-templates/cursor-rules-template-mdc.md new file mode 100644 index 0000000..f419903 --- /dev/null +++ b/file-templates/cursor-rules-template-mdc.md @@ -0,0 +1,79 @@ +--- FILE: .cursor/rules/tech-stack.mdc --- +--- +description: Application core tech stack and framework guidance +globs: ["**/*"] +alwaysApply: true +--- + +# Tech Stack: {{stackSelection}} + +- **Language**: {{language}} +- **Framework**: {{stackSelection}} +- **Tooling**: {{tooling}} + +## Core Guidance +{{stackGuidance}} + +--- FILE: .cursor/rules/naming.mdc --- +--- +description: Naming conventions for variables, files, and components +globs: ["**/*.{ts,tsx,js,jsx,py,go,rs}"] +alwaysApply: false +--- + +# Naming Conventions + +- **Variables & Functions**: {{variableNaming}} +- **Files & Modules**: {{fileNaming}} +- **Components & Types**: {{componentNaming}} +- **Exports**: {{exports}} + +--- FILE: .cursor/rules/testing.mdc --- +--- +description: Testing strategy and quality assurance +globs: ["**/*.test.{ts,tsx,js,jsx}", "**/*_test.go", "**/test_*.py", "**/__tests__/**/*"] +alwaysApply: false +--- + +# Testing Strategy + +- **Unit Testing**: {{testingUT}} +- **E2E Testing**: {{testingE2E}} + +## Requirements +- Use descriptive test names. +- Cover both success and failure cases. +- Place tests in appropriate directories based on project structure. + +--- FILE: .cursor/rules/security.mdc --- +--- +description: Security, validation, and logging rules +globs: ["**/*"] +alwaysApply: false +--- + +# Security & Quality + +- **Auth & Secrets**: {{auth}} +- **Validation**: {{validation}} +- **Logging**: {{logging}} + +## Rules +- Never commit secrets to version control. +- Validate all external data inputs. +- Use structured logging; avoid logging sensitive information. + +--- FILE: .cursor/rules/collaboration.mdc --- +--- +description: Commit messages and PR conventions +globs: [".git/**/*", "**/*"] +alwaysApply: false +--- + +# Collaboration + +- **Commit Style**: {{commitStyle}} +- **PR Rules**: {{prRules}} +- **Collaboration Style**: {{collaboration}} + +Follow project-specific conventions for small, focused changes. diff --git a/lib/__tests__/template-config.test.ts b/lib/__tests__/template-config.test.ts index 93620a7..75ad5d5 100644 --- a/lib/__tests__/template-config.test.ts +++ b/lib/__tests__/template-config.test.ts @@ -22,6 +22,14 @@ describe('template-config', () => { it('should return correct config for cursor rules', () => { const result = getTemplateConfig('cursor-rules') + expect(result).toEqual({ + template: 'cursor-rules-template-mdc.md', + outputFileName: 'cursor-rules.zip' + }) + }) + + it('should return correct config for legacy cursor rules', () => { + const result = getTemplateConfig('cursor-rules-legacy') expect(result).toEqual({ template: 'cursor-rules-template.json', outputFileName: '.cursor/rules' @@ -109,8 +117,8 @@ describe('template-config', () => { } const result = getTemplateConfig(key) expect(result).toEqual({ - template: 'cursor-rules-template.json', - outputFileName: '.cursor/rules', + template: 'cursor-rules-template-mdc.md', + outputFileName: 'cursor-rules.zip', }) }) }) @@ -138,6 +146,7 @@ describe('template-config', () => { 'agents-astro', 'agents-remix', 'cursor-rules', + 'cursor-rules-legacy', 'json-rules', 'instructions-md' ] diff --git a/lib/scan-generate.ts b/lib/scan-generate.ts index 1750ffc..73ad72a 100644 --- a/lib/scan-generate.ts +++ b/lib/scan-generate.ts @@ -9,7 +9,7 @@ import { ANALYTICS_EVENTS } from "@/lib/analytics-events" const fileOptions = getFileOptions() -export type OutputFileId = "instructions-md" | "agents-md" | "cursor-rules" +export type OutputFileId = "instructions-md" | "agents-md" | "cursor-rules" | "cursor-rules-legacy" export async function generateFromRepoScan( scan: RepoScanSummary, diff --git a/lib/template-config.ts b/lib/template-config.ts index bfe87ff..db61e52 100644 --- a/lib/template-config.ts +++ b/lib/template-config.ts @@ -105,11 +105,16 @@ export const templateCombinations: Record = { template: 'agents-template.md', outputFileName: 'agents.md', }, - // Cursor rules - 'cursor-rules': { + // Cursor rules (Legacy) + 'cursor-rules-legacy': { template: 'cursor-rules-template.json', outputFileName: '.cursor/rules', }, + // New Cursor rules (MDC + ZIP) + 'cursor-rules': { + template: 'cursor-rules-template-mdc.md', + outputFileName: 'cursor-rules.zip', + }, // Generic JSON rules (placeholder) 'json-rules': { template: 'copilot-instructions-template.md', diff --git a/lib/template-render.ts b/lib/template-render.ts index f5ef296..c6113af 100644 --- a/lib/template-render.ts +++ b/lib/template-render.ts @@ -97,14 +97,13 @@ export async function renderTemplate({ } const replaceVariable = (key: keyof WizardResponses, fallback = 'Not specified') => { - const placeholder = `{{${String(key)}}}` + const placeholder = new RegExp(`{{${String(key)}}}`, 'g') - if (!generatedContent.includes(placeholder)) { + if (!template.includes(`{{${String(key)}}}`)) { return } const value = responses[key] - const defaultMeta = defaultedResponses?.[key] if (value === null || value === undefined || value === '') { @@ -149,8 +148,8 @@ export async function renderTemplate({ replaceVariable('outputFile') const replaceStaticPlaceholder = (placeholderKey: string, value: string) => { - const placeholder = `{{${placeholderKey}}}` - if (!generatedContent.includes(placeholder)) { + const placeholder = new RegExp(`{{${placeholderKey}}}`, 'g') + if (!template.includes(`{{${placeholderKey}}}`)) { return } const replacement = isJsonTemplate ? escapeForJson(value) : value diff --git a/lib/wizard-utils.ts b/lib/wizard-utils.ts index afa0d1c..165c14c 100644 --- a/lib/wizard-utils.ts +++ b/lib/wizard-utils.ts @@ -65,18 +65,18 @@ export const buildStepFromQuestionSet = ( title: string, questions: DataQuestionSource[] ): WizardStep => ({ - id, - title, - questions: questions.map((question) => ({ - id: question.id, - question: question.question, - allowMultiple: question.allowMultiple, - responseKey: question.responseKey, - isReadOnlyOnSummary: question.isReadOnlyOnSummary, - enableFilter: question.enableFilter, - answers: question.answers.map(mapAnswerSourceToWizard), - freeText: question.freeText, - })), + id, + title, + questions: questions.map((question) => ({ + id: question.id, + question: question.question, + allowMultiple: question.allowMultiple, + responseKey: question.responseKey, + isReadOnlyOnSummary: question.isReadOnlyOnSummary, + enableFilter: question.enableFilter, + answers: question.answers.map(mapAnswerSourceToWizard), + freeText: question.freeText, + })), }) export const buildFileOptionsFromQuestion = ( @@ -98,6 +98,7 @@ export const buildFileOptionsFromQuestion = ( icon: answer.icon, docs: answer.docs, isDefault: answer.isDefault, + isLegacy: answer.isLegacy, })) } @@ -105,12 +106,14 @@ const formatLabelMap: Record = { markdown: "Markdown", json: "JSON", "cursor-rules-json": "JSON", + zip: "ZIP Archive", } const formatMimeTypeMap: Record = { markdown: "text/markdown", json: "application/json", "cursor-rules-json": "application/json", + zip: "application/zip", } /** diff --git a/package-lock.json b/package-lock.json index 96ba171..f415a3f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,6 +12,7 @@ "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "framer-motion": "^11.18.2", + "jszip": "^3.10.1", "lucide-react": "^0.544.0", "mixpanel-browser": "^2.70.0", "next": "15.5.7", @@ -4237,6 +4238,12 @@ "dev": true, "license": "MIT" }, + "node_modules/core-util-is": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", + "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", + "license": "MIT" + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -5828,6 +5835,12 @@ "node": ">= 4" } }, + "node_modules/immediate": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz", + "integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==", + "license": "MIT" + }, "node_modules/import-fresh": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", @@ -5865,6 +5878,12 @@ "node": ">=8" } }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, "node_modules/internal-slot": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz", @@ -6461,6 +6480,18 @@ "node": ">=4.0" } }, + "node_modules/jszip": { + "version": "3.10.1", + "resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.1.tgz", + "integrity": "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==", + "license": "(MIT OR GPL-3.0-or-later)", + "dependencies": { + "lie": "~3.3.0", + "pako": "~1.0.2", + "readable-stream": "~2.3.6", + "setimmediate": "^1.0.5" + } + }, "node_modules/keyv": { "version": "4.5.4", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", @@ -6505,6 +6536,15 @@ "node": ">= 0.8.0" } }, + "node_modules/lie": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz", + "integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==", + "license": "MIT", + "dependencies": { + "immediate": "~3.0.5" + } + }, "node_modules/lightningcss": { "version": "1.30.1", "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.30.1.tgz", @@ -7312,6 +7352,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/pako": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz", + "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==", + "license": "(MIT AND Zlib)" + }, "node_modules/parent-module": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", @@ -7562,6 +7608,12 @@ "dev": true, "license": "MIT" }, + "node_modules/process-nextick-args": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", + "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", + "license": "MIT" + }, "node_modules/prop-types": { "version": "15.8.1", "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", @@ -7645,6 +7697,27 @@ "node": ">=0.10.0" } }, + "node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/readable-stream/node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "license": "MIT" + }, "node_modules/redent": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", @@ -7858,6 +7931,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, "node_modules/safe-push-apply": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/safe-push-apply/-/safe-push-apply-1.0.0.tgz", @@ -7981,6 +8060,12 @@ "node": ">= 0.4" } }, + "node_modules/setimmediate": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz", + "integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==", + "license": "MIT" + }, "node_modules/sharp": { "version": "0.34.3", "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.3.tgz", @@ -8203,6 +8288,15 @@ "node": ">= 0.4" } }, + "node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, "node_modules/string.prototype.includes": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/string.prototype.includes/-/string.prototype.includes-2.0.1.tgz", @@ -8916,6 +9010,12 @@ "punycode": "^2.1.0" } }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, "node_modules/vite": { "version": "7.1.7", "resolved": "https://registry.npmjs.org/vite/-/vite-7.1.7.tgz", diff --git a/package.json b/package.json index 0bdd771..fbcc411 100644 --- a/package.json +++ b/package.json @@ -18,6 +18,7 @@ "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "framer-motion": "^11.18.2", + "jszip": "^3.10.1", "lucide-react": "^0.544.0", "mixpanel-browser": "^2.70.0", "next": "15.5.7", @@ -29,10 +30,10 @@ }, "devDependencies": { "@eslint/eslintrc": "^3", + "@playwright/test": "^1.50.1", "@tailwindcss/postcss": "^4", "@testing-library/jest-dom": "^6.8.0", "@testing-library/react": "^16.3.0", - "@playwright/test": "^1.50.1", "@types/mixpanel-browser": "^2.60.0", "@types/node": "^20", "@types/react": "^19", diff --git a/types/wizard.ts b/types/wizard.ts index 35ab5ab..a9240f8 100644 --- a/types/wizard.ts +++ b/types/wizard.ts @@ -18,6 +18,7 @@ export type DataAnswerSource = { filename?: string format?: string detection?: StackDetectionConfig + isLegacy?: boolean } export type QuestionFreeTextConfig = { @@ -46,6 +47,7 @@ export type FileOutputConfig = { icon?: string docs?: string isDefault?: boolean + isLegacy?: boolean } export type WizardAnswer = { @@ -59,6 +61,7 @@ export type WizardAnswer = { disabled?: boolean disabledLabel?: string docs?: string + isLegacy?: boolean filename?: string format?: string enabled?: boolean