diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index acd505a..32dc53a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -3,8 +3,8 @@ name: Release on: push: tags: - - 'v*.*.*' # Matches v1.0.0, v1.2.3, etc. - - 'v*.*.*-beta.*' # Matches v1.0.0-beta.1, etc. + - 'v*.*.*' # Matches v1.0.0, v1.2.3, etc. + - 'v*.*.*-beta.*' # Matches v1.0.0-beta.1, etc. permissions: contents: write @@ -47,12 +47,29 @@ jobs: - name: Update package.json version run: npm version ${{ steps.get_version.outputs.VERSION }} --no-git-tag-version --allow-same-version - - name: Publish to NPM + - name: Pack release artifact + id: pack + run: | + ASSET="$(npm pack --silent | tail -n1)" + echo "asset=${ASSET}" >> "$GITHUB_OUTPUT" + + - name: Install Cosign + uses: sigstore/cosign-installer@v3 + + - name: Sign release artifact + run: | + cosign sign-blob --yes \ + --output-signature "${{ steps.pack.outputs.asset }}.sig" \ + --output-certificate "${{ steps.pack.outputs.asset }}.pem" \ + --bundle "${{ steps.pack.outputs.asset }}.sigstore.json" \ + "${{ steps.pack.outputs.asset }}" + + - name: Publish to NPM (with provenance) run: | if [[ "${{ steps.get_version.outputs.VERSION }}" == *"beta"* ]]; then - npm publish --tag beta + npm publish "${{ steps.pack.outputs.asset }}" --provenance --tag beta else - npm publish --tag latest + npm publish "${{ steps.pack.outputs.asset }}" --provenance --tag latest fi env: NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} @@ -62,5 +79,10 @@ jobs: with: generate_release_notes: true prerelease: ${{ contains(steps.get_version.outputs.VERSION, 'beta') }} + files: | + ${{ steps.pack.outputs.asset }} + ${{ steps.pack.outputs.asset }}.sig + ${{ steps.pack.outputs.asset }}.pem + ${{ steps.pack.outputs.asset }}.sigstore.json env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/package-lock.json b/package-lock.json index 53e2d37..018545f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18,6 +18,7 @@ "execa": "^9.5.2", "fs-extra": "^11.2.0", "ora": "^8.1.1", + "typescript": "^5.7.2", "zod": "^3.24.1" }, "bin": { @@ -36,7 +37,6 @@ "lint-staged": "^16.2.7", "prettier": "^3.4.2", "tsx": "^4.19.2", - "typescript": "^5.7.2", "typescript-eslint": "^8.54.0", "vitest": "^4.0.18" }, @@ -4425,7 +4425,6 @@ "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", - "dev": true, "license": "Apache-2.0", "peer": true, "bin": { diff --git a/package.json b/package.json index a800bf3..8e4aa47 100644 --- a/package.json +++ b/package.json @@ -64,6 +64,7 @@ "execa": "^9.5.2", "fs-extra": "^11.2.0", "ora": "^8.1.1", + "typescript": "^5.7.2", "zod": "^3.24.1" }, "devDependencies": { @@ -79,7 +80,6 @@ "lint-staged": "^16.2.7", "prettier": "^3.4.2", "tsx": "^4.19.2", - "typescript": "^5.7.2", "typescript-eslint": "^8.54.0", "vitest": "^4.0.18" }, diff --git a/src/__tests__/architecture-generator.test.ts b/src/__tests__/architecture-generator.test.ts new file mode 100644 index 0000000..0071ff4 --- /dev/null +++ b/src/__tests__/architecture-generator.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it } from 'vitest'; +import { generateArchitectureDoc } from '../docs/architecture-generator'; +import { DEFAULT_CONFIG, type ProjectConfig } from '../config/schema'; + +function createConfig(overrides: Partial = {}): ProjectConfig { + return { + ...DEFAULT_CONFIG, + name: 'test-app', + path: './test-app', + ...overrides, + }; +} + +describe('generateArchitectureDoc', () => { + it('should include TypeScript language and types folder for TypeScript projects', () => { + const doc = generateArchitectureDoc(createConfig({ language: 'typescript' })); + + expect(doc).toContain('- **Language**: TypeScript'); + expect(doc).toContain('├── types/ # TypeScript type definitions'); + }); + + it('should include JavaScript language and omit types folder for JavaScript projects', () => { + const doc = generateArchitectureDoc(createConfig({ language: 'javascript' })); + + expect(doc).toContain('- **Language**: JavaScript'); + expect(doc).not.toContain('├── types/ # TypeScript type definitions'); + }); + + it('should include state management folder when state management is enabled', () => { + const doc = generateArchitectureDoc(createConfig({ stateManagement: 'zustand' })); + + expect(doc).toContain('├── stores/ # State management stores'); + }); + + it('should include lib folder when data fetching is enabled', () => { + const doc = generateArchitectureDoc( + createConfig({ dataFetching: { enabled: true, library: 'tanstack-query' } }) + ); + + expect(doc).toContain('├── lib/ # Third-party library configs'); + }); +}); diff --git a/src/__tests__/cli-index.test.ts b/src/__tests__/cli-index.test.ts index 1f2b78c..c3ffeb9 100644 --- a/src/__tests__/cli-index.test.ts +++ b/src/__tests__/cli-index.test.ts @@ -68,6 +68,19 @@ describe('CLI Index', () => { expect(config.testing.e2e.runner).toBe('cypress'); }); + it('should disable E2E when unit-component testing is selected', () => { + const config = promptAnswersToConfig( + createAnswers({ + testing: 'unit-component', + e2eRunner: 'playwright', + }) + ); + + expect(config.testing.enabled).toBe(true); + expect(config.testing.e2e.enabled).toBe(false); + expect(config.testing.e2e.runner).toBe('none'); + }); + it('should validate project name', () => { expect(validateProjectName('')).toEqual({ valid: false, diff --git a/src/__tests__/cli-parser.test.ts b/src/__tests__/cli-parser.test.ts index e677ac2..d681b31 100644 --- a/src/__tests__/cli-parser.test.ts +++ b/src/__tests__/cli-parser.test.ts @@ -20,6 +20,7 @@ describe('CLI parser', () => { const stylingOption = createCmd?.options.find((o) => o.long === '--styling'); const stateOption = createCmd?.options.find((o) => o.long === '--state'); const runtimeOption = createCmd?.options.find((o) => o.long === '--runtime'); + const languageOption = createCmd?.options.find((o) => o.long === '--language'); const unitRunnerOption = createCmd?.options.find((o) => o.long === '--unit-runner'); expect(runtimeOption?.argChoices).toEqual(['vite', 'nextjs']); @@ -31,6 +32,7 @@ describe('CLI parser', () => { 'css', ]); expect(stateOption?.argChoices).toEqual(['none', 'redux', 'zustand', 'jotai']); + expect(languageOption?.argChoices).toEqual(['javascript', 'typescript']); expect(unitRunnerOption?.argChoices).toEqual(['vitest', 'jest']); }); diff --git a/src/__tests__/config-builder.test.ts b/src/__tests__/config-builder.test.ts index 1b1126a..ea57846 100644 --- a/src/__tests__/config-builder.test.ts +++ b/src/__tests__/config-builder.test.ts @@ -54,4 +54,20 @@ describe('ConfigBuilder', () => { expect(validation.success).toBe(true); }); + + it('should not mutate DEFAULT_CONFIG when toggling testing', () => { + const defaultTesting = structuredClone(DEFAULT_CONFIG.testing); + + new ConfigBuilder().setTestingEnabled(false).build(); + + expect(DEFAULT_CONFIG.testing).toEqual(defaultTesting); + }); + + it('should not leak state across builder instances', () => { + const disabled = new ConfigBuilder().setTestingEnabled(false).build(); + const fresh = new ConfigBuilder().build(); + + expect(disabled.testing.enabled).toBe(false); + expect(fresh.testing.enabled).toBe(true); + }); }); diff --git a/src/__tests__/integration/generator.test.ts b/src/__tests__/integration/generator.test.ts index 6a8f099..31c9532 100644 --- a/src/__tests__/integration/generator.test.ts +++ b/src/__tests__/integration/generator.test.ts @@ -93,6 +93,58 @@ describe('ProjectGenerator Integration', () => { expect(result.errors).toHaveLength(0); expect(existsSync(join(config.path, 'package.json'))).toBe(true); }); + + it('should write .gitignore before git initialization', async () => { + const config = createBaseConfig({ + name: 'gitignore-write', + git: { init: true, initialCommit: false }, + }); + projectPaths.push(config.path); + + const generator = new ProjectGenerator(config); + const result = await generator.generate(); + + expect(result.success).toBe(true); + expect(existsSync(join(config.path, '.gitignore'))).toBe(true); + }); + + it('should generate JavaScript projects without TypeScript-only files', async () => { + const config = createBaseConfig({ + name: 'javascript-supported', + language: 'javascript', + }); + projectPaths.push(config.path); + + const generator = new ProjectGenerator(config); + const result = await generator.generate(); + const pkg = readGeneratedPackageJson(config.path); + const devDeps = pkg.devDependencies as Record; + const scripts = pkg.scripts as Record; + + expect(result.success).toBe(true); + expect(result.errors).toHaveLength(0); + expect(existsSync(join(config.path, 'src/main.jsx'))).toBe(true); + expect(existsSync(join(config.path, 'tsconfig.json'))).toBe(false); + expect(devDeps).not.toHaveProperty('typescript'); + expect(scripts.build).not.toContain('tsc -b'); + }); + + it('should generate Next.js JavaScript projects with JavaScript entry files', async () => { + const config = createBaseConfig({ + name: 'javascript-nextjs', + runtime: 'nextjs', + language: 'javascript', + }); + projectPaths.push(config.path); + + const generator = new ProjectGenerator(config); + const result = await generator.generate(); + + expect(result.success).toBe(true); + expect(existsSync(join(config.path, 'src/app/page.jsx'))).toBe(true); + expect(existsSync(join(config.path, 'next-env.d.ts'))).toBe(false); + expect(existsSync(join(config.path, 'tsconfig.json'))).toBe(false); + }); }); describe('Package.json Validation', () => { @@ -380,4 +432,3 @@ describe('ProjectGenerator Integration', () => { }); }); }); - diff --git a/src/__tests__/integration/package-json.test.ts b/src/__tests__/integration/package-json.test.ts index 44e29a5..db4ec32 100644 --- a/src/__tests__/integration/package-json.test.ts +++ b/src/__tests__/integration/package-json.test.ts @@ -364,6 +364,29 @@ describe('Package.json Generation', () => { expect(devDeps).toHaveProperty('typescript'); }); + + it('should exclude TypeScript-only dependencies for javascript projects', async () => { + const config = createConfig('javascript-deps', { + language: 'javascript', + testing: { + enabled: true, + unit: { enabled: true, runner: 'jest' }, + component: { enabled: true, library: 'testing-library' }, + e2e: { enabled: false, runner: 'none' }, + }, + }); + projectPaths.push(config.path); + + const generator = new ProjectGenerator(config); + await generator.generate(); + + const pkg = readGeneratedPackageJson(config.path); + const devDeps = pkg.devDependencies as Record; + + expect(devDeps).not.toHaveProperty('typescript'); + expect(devDeps).not.toHaveProperty('ts-jest'); + expect(Object.keys(devDeps).some((dep) => dep.startsWith('@types/'))).toBe(false); + }); }); describe('Scripts', () => { @@ -381,6 +404,22 @@ describe('Package.json Generation', () => { expect(scripts.build).toContain('vite'); }); + it('should not include TypeScript build step for javascript Vite projects', async () => { + const config = createConfig('vite-js-scripts', { + runtime: 'vite', + language: 'javascript', + }); + projectPaths.push(config.path); + + const generator = new ProjectGenerator(config); + await generator.generate(); + + const pkg = readGeneratedPackageJson(config.path); + const scripts = pkg.scripts as Record; + + expect(scripts.build).toBe('vite build'); + }); + it('should have dev script for Next.js', async () => { const config = createConfig('nextjs-scripts', { runtime: 'nextjs' }); projectPaths.push(config.path); @@ -468,4 +507,3 @@ describe('Package.json Generation', () => { }); }); }); - diff --git a/src/__tests__/integration/scenarios.test.ts b/src/__tests__/integration/scenarios.test.ts index 935ea2a..ccce2eb 100644 --- a/src/__tests__/integration/scenarios.test.ts +++ b/src/__tests__/integration/scenarios.test.ts @@ -464,6 +464,38 @@ describe('Real-World Scenarios', () => { }); }); + describe('JavaScript Configuration', () => { + it('should not include tsconfig files for JavaScript projects', async () => { + const config = createConfig('js-config', { + language: 'javascript', + }); + projectPaths.push(config.path); + + const generator = new ProjectGenerator(config); + await generator.generate(); + + expect(existsSync(join(config.path, 'tsconfig.json'))).toBe(false); + expect(existsSync(join(config.path, 'tsconfig.node.json'))).toBe(false); + expect(existsSync(join(config.path, 'src/main.jsx'))).toBe(true); + }); + + it('should not include TypeScript as devDependency in JavaScript projects', async () => { + const config = createConfig('js-dep', { + language: 'javascript', + }); + projectPaths.push(config.path); + + const generator = new ProjectGenerator(config); + await generator.generate(); + + const pkg = readGeneratedPackageJson(config.path); + const devDeps = pkg.devDependencies as Record; + + expect(devDeps).not.toHaveProperty('typescript'); + expect(Object.keys(devDeps).some((dep) => dep.startsWith('@types/'))).toBe(false); + }); + }); + describe('No Conflicts Between Templates', () => { it('should not have conflicting scripts when multiple templates loaded', async () => { const config = createConfig('no-conflicts', { @@ -522,4 +554,3 @@ describe('Real-World Scenarios', () => { }); }); }); - diff --git a/src/__tests__/prompts.test.ts b/src/__tests__/prompts.test.ts index beeaae4..acd51ad 100644 --- a/src/__tests__/prompts.test.ts +++ b/src/__tests__/prompts.test.ts @@ -85,7 +85,7 @@ describe('CLI Prompts', () => { stateManagement: 'none', testing: 'none', unitRunner: 'vitest', - e2eRunner: 'playwright', + e2eRunner: 'none', dataFetching: false, packageManager: 'npm', git: true, diff --git a/src/__tests__/readme-generator.test.ts b/src/__tests__/readme-generator.test.ts index 9dfaee9..4c9518f 100644 --- a/src/__tests__/readme-generator.test.ts +++ b/src/__tests__/readme-generator.test.ts @@ -34,6 +34,12 @@ describe('generateReadme', () => { expect(readme).toContain('components/'); }); + it('should omit TypeScript-only structure details for JavaScript projects', () => { + const readme = generateReadme(createConfig({ language: 'javascript' })); + + expect(readme).not.toContain('TypeScript type definitions'); + }); + it('should include tech stack section', () => { const readme = generateReadme(createConfig()); @@ -70,6 +76,13 @@ describe('generateReadme', () => { expect(readme).toContain('TypeScript'); }); + it('should include JavaScript badge when using JavaScript', () => { + const config = createConfig({ language: 'javascript' }); + const readme = generateReadme(config); + + expect(readme).toContain('JavaScript'); + }); + it('should include styling badge', () => { const config = createConfig({ styling: { solution: 'tailwind' } }); const readme = generateReadme(config); @@ -234,4 +247,3 @@ describe('generateReadme', () => { }); }); }); - diff --git a/src/cli/index.ts b/src/cli/index.ts index 8199e9f..728e78c 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -25,13 +25,26 @@ export function promptAnswersToConfig(answers: PromptAnswers) { .setGitInit(answers.git) .setDataFetchingEnabled(answers.dataFetching); - if (answers.testing !== 'none') { + if (answers.testing === 'none') { + builder + .setTestingEnabled(false) + .setUnitTestingEnabled(false) + .setE2ETestingEnabled(false) + .setE2ETestRunner('none'); + } else if (answers.testing === 'unit-component') { builder .setTestingEnabled(true) + .setUnitTestingEnabled(true) .setUnitTestRunner(answers.unitRunner) - .setE2ETestRunner(answers.e2eRunner); + .setE2ETestingEnabled(false) + .setE2ETestRunner('none'); } else { - builder.setTestingEnabled(false); + builder + .setTestingEnabled(true) + .setUnitTestingEnabled(true) + .setUnitTestRunner(answers.unitRunner) + .setE2ETestingEnabled(true) + .setE2ETestRunner(answers.e2eRunner); } if (!answers.git) { diff --git a/src/cli/parser.ts b/src/cli/parser.ts index dc63572..258989e 100644 --- a/src/cli/parser.ts +++ b/src/cli/parser.ts @@ -47,7 +47,7 @@ export function createCommand(): Command { new Option('--state ', 'State management').choices(['none', 'redux', 'zustand', 'jotai']) ) .addOption( - new Option('--testing ', 'Testing setup').choices(['full', 'unit-component', 'custom', 'none']) + new Option('--testing ', 'Testing setup').choices(['full', 'unit-component', 'none']) ) .addOption( new Option('--unit-runner ', 'Unit test runner').choices(['vitest', 'jest']) diff --git a/src/cli/prompts.ts b/src/cli/prompts.ts index 197deb7..30e6904 100644 --- a/src/cli/prompts.ts +++ b/src/cli/prompts.ts @@ -99,7 +99,7 @@ export async function promptForProjectDetails( })) as 'full' | 'unit-component' | 'none'; let unitRunner: 'vitest' | 'jest' = 'vitest'; - let e2eRunner: 'playwright' | 'cypress' | 'none' = 'playwright'; + let e2eRunner: 'playwright' | 'cypress' | 'none' = 'none'; if (testing !== 'none') { unitRunner = (await select({ @@ -172,6 +172,3 @@ export async function confirmProceed(message: string = 'Proceed?'): Promise = {}; constructor(initialConfig?: Partial) { - this.config = initialConfig ? { ...initialConfig } : {}; + this.config = initialConfig ? structuredClone(initialConfig) : {}; + } + + private ensureTestingConfig(): ProjectConfig['testing'] { + if (!this.config.testing) { + this.config.testing = structuredClone(DEFAULT_CONFIG.testing); + } + return this.config.testing; + } + + private ensureDataFetchingConfig(): ProjectConfig['dataFetching'] { + if (!this.config.dataFetching) { + this.config.dataFetching = structuredClone(DEFAULT_CONFIG.dataFetching); + } + return this.config.dataFetching; + } + + private ensureGitConfig(): ProjectConfig['git'] { + if (!this.config.git) { + this.config.git = structuredClone(DEFAULT_CONFIG.git); + } + return this.config.git; } setName(name: string): this { @@ -42,34 +63,32 @@ export class ConfigBuilder { } setTestingEnabled(enabled: boolean): this { - if (!this.config.testing) { - this.config.testing = DEFAULT_CONFIG.testing; - } - this.config.testing.enabled = enabled; + this.ensureTestingConfig().enabled = enabled; + return this; + } + + setUnitTestingEnabled(enabled: boolean): this { + this.ensureTestingConfig().unit.enabled = enabled; + return this; + } + + setE2ETestingEnabled(enabled: boolean): this { + this.ensureTestingConfig().e2e.enabled = enabled; return this; } setUnitTestRunner(runner: 'vitest' | 'jest'): this { - if (!this.config.testing) { - this.config.testing = DEFAULT_CONFIG.testing; - } - this.config.testing.unit.runner = runner; + this.ensureTestingConfig().unit.runner = runner; return this; } setE2ETestRunner(runner: 'playwright' | 'cypress' | 'none'): this { - if (!this.config.testing) { - this.config.testing = DEFAULT_CONFIG.testing; - } - this.config.testing.e2e.runner = runner; + this.ensureTestingConfig().e2e.runner = runner; return this; } setDataFetchingEnabled(enabled: boolean): this { - if (!this.config.dataFetching) { - this.config.dataFetching = DEFAULT_CONFIG.dataFetching; - } - this.config.dataFetching.enabled = enabled; + this.ensureDataFetchingConfig().enabled = enabled; return this; } @@ -79,10 +98,7 @@ export class ConfigBuilder { } setGitInit(init: boolean): this { - if (!this.config.git) { - this.config.git = DEFAULT_CONFIG.git; - } - this.config.git.init = init; + this.ensureGitConfig().init = init; return this; } @@ -131,4 +147,3 @@ export function mergeConfigs(...configs: Partial[]): ProjectConfi - diff --git a/src/docs/architecture-generator.ts b/src/docs/architecture-generator.ts index 553cfa6..f5ca187 100644 --- a/src/docs/architecture-generator.ts +++ b/src/docs/architecture-generator.ts @@ -2,6 +2,10 @@ import type { ProjectConfig } from '../config/schema.js'; export function generateArchitectureDoc(config: ProjectConfig): string { const { runtime, language, styling, stateManagement, dataFetching, testing } = config; + const typesLine = + language === 'typescript' + ? '├── types/ # TypeScript type definitions\n' + : ''; return `# Project Architecture @@ -25,7 +29,7 @@ src/ ├── features/ # Feature-based modules ├── hooks/ # Custom React hooks ${dataFetching.enabled ? '├── lib/ # Third-party library configs\n' : ''}├── providers/ # React context providers -${stateManagement !== 'none' ? '├── stores/ # State management stores\n' : ''}├── types/ # TypeScript type definitions +${stateManagement !== 'none' ? '├── stores/ # State management stores\n' : ''}${typesLine} └── utils/ # Utility functions \`\`\` @@ -73,4 +77,3 @@ We use **${stateManagement}** for global client state. ` : 'Local state (`useState`) is preferred. Global state is managed via Context API if needed.'} `; } - diff --git a/src/docs/readme-generator.ts b/src/docs/readme-generator.ts index 79dc7b6..1d07c90 100644 --- a/src/docs/readme-generator.ts +++ b/src/docs/readme-generator.ts @@ -160,8 +160,12 @@ ${config.name}/ } structure += ` -│ ├── styles/ # Global styles +│ ├── styles/ # Global styles`; + + if (config.language === 'typescript') { + structure += ` │ └── types/ # TypeScript type definitions`; + } if (isNextjs) { structure += ` @@ -312,4 +316,3 @@ ${docLinks} MIT `; } - diff --git a/src/generator/index.ts b/src/generator/index.ts index 0a6c13a..045a0e2 100644 --- a/src/generator/index.ts +++ b/src/generator/index.ts @@ -2,6 +2,7 @@ import chalk from 'chalk'; import { execSync } from 'child_process'; import { existsSync } from 'fs'; import ora from 'ora'; +import ts from 'typescript'; import { ProjectAssembler } from '../assembler/index.js'; import type { ProjectConfig } from '../config/schema.js'; import { generateArchitectureDoc, generateReadme } from '../docs/index.js'; @@ -71,7 +72,8 @@ export class ProjectGenerator { // Step 3: Merge all template files spinner.start('Assembling project files...'); const mergedFiles = this.registry.getMergedFiles(); - this.assembler.addFiles(mergedFiles); + const languageAdjustedFiles = this.adjustTemplateFilesForLanguage(mergedFiles); + this.assembler.addFiles(languageAdjustedFiles); // Add Architecture Documentation const archDoc = generateArchitectureDoc(this.config); @@ -82,8 +84,8 @@ export class ProjectGenerator { this.assembler.addFile('README.md', readmeDoc); // Step 4: Merge dependencies - const { dependencies, devDependencies, scripts } = this.registry.getMergedDependencies(); - this.assembler.mergeTemplateDeps({ dependencies, devDependencies, scripts }); + const mergedDeps = this.registry.getMergedDependencies(); + this.assembler.mergeTemplateDeps(this.adjustTemplatePackageDataForLanguage(mergedDeps)); // Add TypeScript if configured if (this.config.language === 'typescript') { @@ -92,7 +94,12 @@ export class ProjectGenerator { }); } - spinner.succeed(`Assembled ${mergedFiles.size} files`); + // Add .gitignore before writing files, so it is persisted on disk. + if (this.config.git.init) { + this.assembler.addFile('.gitignore', this.getGitignoreContent()); + } + + spinner.succeed(`Assembled ${languageAdjustedFiles.size} files`); // Step 5: Write files to disk spinner.start('Writing files...'); @@ -138,6 +145,144 @@ export class ProjectGenerator { } } + private adjustTemplateFilesForLanguage(files: Map): Map { + if (this.config.language === 'typescript') { + return files; + } + + const adjusted = new Map(); + + for (const [filePath, content] of files.entries()) { + const adjustedPath = this.getLanguageAdjustedPath(filePath); + if (!adjustedPath) { + continue; + } + + const adjustedContent = this.getLanguageAdjustedContent(filePath, content); + if (adjustedContent === null) { + continue; + } + + adjusted.set(adjustedPath, adjustedContent); + } + + return adjusted; + } + + private getLanguageAdjustedPath(filePath: string): string | null { + if (this.config.language === 'typescript') { + return filePath; + } + + if (this.isTypeScriptOnlyFile(filePath)) { + return null; + } + + if (filePath.endsWith('.tsx')) { + return `${filePath.slice(0, -4)}.jsx`; + } + + if (filePath.endsWith('.ts')) { + return `${filePath.slice(0, -3)}.js`; + } + + return filePath; + } + + private getLanguageAdjustedContent(filePath: string, content: string): string | null { + if (content.startsWith('__BINARY__:') || this.config.language === 'typescript') { + return content; + } + + if (filePath.endsWith('.ts') || filePath.endsWith('.tsx')) { + const transpiled = this.transpileTemplateToJavaScript(filePath, content); + const normalized = this.rewriteTypeScriptFileReferences(transpiled); + + // Type-only template files compile to an empty module in JavaScript projects. + if (normalized.trim() === 'export {};') { + return null; + } + + return normalized; + } + + return this.rewriteTypeScriptFileReferences(content); + } + + private transpileTemplateToJavaScript(filePath: string, content: string): string { + const transpiled = ts.transpileModule(content, { + compilerOptions: { + target: ts.ScriptTarget.ES2021, + module: ts.ModuleKind.ESNext, + moduleResolution: ts.ModuleResolutionKind.Bundler, + jsx: ts.JsxEmit.Preserve, + allowSyntheticDefaultImports: true, + esModuleInterop: true, + }, + fileName: filePath, + reportDiagnostics: false, + }); + + return transpiled.outputText; + } + + private rewriteTypeScriptFileReferences(content: string): string { + return content + .replace(/\.tsx\b/g, '.jsx') + .replace(/(?; + devDependencies: Record; + scripts: Record; + }): { + dependencies: Record; + devDependencies: Record; + scripts: Record; + } { + if (this.config.language === 'typescript') { + return data; + } + + const dependencies = { ...data.dependencies }; + const devDependencies = { ...data.devDependencies }; + const scripts = { ...data.scripts }; + + for (const dep of Object.keys(dependencies)) { + if (this.isTypeScriptOnlyDependency(dep)) { + delete dependencies[dep]; + } + } + + for (const dep of Object.keys(devDependencies)) { + if (this.isTypeScriptOnlyDependency(dep)) { + delete devDependencies[dep]; + } + } + + for (const [name, command] of Object.entries(scripts)) { + scripts[name] = this.rewriteTypeScriptFileReferences(command).replace( + /^tsc\s+-b\s*&&\s*/, + '' + ); + } + + return { dependencies, devDependencies, scripts }; + } + + private isTypeScriptOnlyDependency(packageName: string): boolean { + return ( + packageName === 'typescript' || + packageName === 'ts-jest' || + packageName.startsWith('@types/') + ); + } + /** * Initialize git repository */ @@ -147,8 +292,24 @@ export class ProjectGenerator { stdio: 'ignore', }); - // Create .gitignore if it doesn't exist - const gitignoreContent = `# Dependencies + if (this.config.git.initialCommit) { + try { + execSync('git add -A', { + cwd: this.config.path, + stdio: 'ignore', + }); + execSync('git commit -m "Initial commit from create-react-forge"', { + cwd: this.config.path, + stdio: 'ignore', + }); + } catch { + // Git commit may fail if git user is not configured + } + } + } + + private getGitignoreContent(): string { + return `# Dependencies node_modules/ # Build output @@ -185,23 +346,6 @@ yarn-error.log* test-results/ playwright-report/ `; - - this.assembler.addFile('.gitignore', gitignoreContent); - - if (this.config.git.initialCommit) { - try { - execSync('git add -A', { - cwd: this.config.path, - stdio: 'ignore', - }); - execSync('git commit -m "Initial commit from create-react-forge"', { - cwd: this.config.path, - stdio: 'ignore', - }); - } catch { - // Git commit may fail if git user is not configured - } - } } /** @@ -236,4 +380,3 @@ export async function generateProject(config: ProjectConfig): Promise