UNPKG

roadkit

Version:

Beautiful Next.js roadmap website generator with full-screen kanban boards, dark/light mode, and static export

386 lines (336 loc) 12.9 kB
/** * Template Generator for RoadKit * Generates Next.js roadmap projects with modern UI and themes */ import path from 'path' import { fileURLToPath } from 'url' import { existsSync, mkdirSync, writeFileSync, readFileSync, copyFileSync, statSync } from 'fs' /** * Project configuration for template generation */ export interface ProjectConfig { name: string outputPath: string theme: 'modern' | 'classic' | 'minimal' | 'corporate' typescript: boolean features: { analytics: boolean auth: boolean darkMode: boolean exportFeature: boolean } } /** * Template file information */ interface TemplateFile { source: string destination: string process: boolean } /** * Get the package directory (where templates are located) */ function getPackageDir(): string { // In CommonJS module system or when running from source if (typeof __dirname !== 'undefined') { return path.resolve(__dirname, '../..') } // In ES modules, use import.meta.url if (typeof import.meta !== 'undefined' && import.meta.url) { const currentFile = fileURLToPath(import.meta.url) return path.resolve(path.dirname(currentFile), '../..') } // Fallback: try to find package.json by walking up the directory tree let dir = process.cwd() while (dir !== path.dirname(dir)) { if (existsSync(path.join(dir, 'package.json'))) { try { const pkg = JSON.parse(readFileSync(path.join(dir, 'package.json'), 'utf-8')) if (pkg.name === 'roadkit') { return dir } } catch (e) { // Continue searching } } dir = path.dirname(dir) } // Last resort: assume current working directory return process.cwd() } /** * Main template generator class */ export class TemplateGenerator { private packageDir: string constructor(private config: ProjectConfig) { this.packageDir = getPackageDir() } /** * Generate the complete project */ async generateProject(): Promise<void> { const { name, outputPath } = this.config const projectPath = path.join(outputPath, name) console.log(`🚀 Generating Next.js roadmap project: ${name}`) // Create project structure await this.createProjectStructure(projectPath) // Copy template files await this.copyTemplateFiles(projectPath) // Generate configuration files await this.generateConfigFiles(projectPath) // Install dependencies await this.installDependencies(projectPath) console.log(`✅ Successfully generated project at: ${projectPath}`) } /** * Create the basic project directory structure */ private async createProjectStructure(projectPath: string): Promise<void> { const directories = [ 'app', 'components', 'components/ui', 'components/kanban', 'lib', 'src', 'src/data', 'src/types', 'src/utils', 'public' ] // Create main directory if (!existsSync(projectPath)) { mkdirSync(projectPath, { recursive: true }) } // Create subdirectories for (const dir of directories) { const fullPath = path.join(projectPath, dir) if (!existsSync(fullPath)) { mkdirSync(fullPath, { recursive: true }) } } console.log('📁 Created project structure') } /** * Copy all template files to the project */ private async copyTemplateFiles(projectPath: string): Promise<void> { const templateFiles = this.getTemplateFiles() let fileCount = 0 for (const file of templateFiles) { const sourcePath = path.join(this.packageDir, file.source) const destPath = path.join(projectPath, file.destination) if (existsSync(sourcePath)) { // Ensure destination directory exists const destDir = path.dirname(destPath) if (!existsSync(destDir)) { mkdirSync(destDir, { recursive: true }) } if (file.process) { // Process template variables const content = readFileSync(sourcePath, 'utf-8') const processedContent = this.processTemplate(content) writeFileSync(destPath, processedContent) } else { // Direct copy copyFileSync(sourcePath, destPath) } fileCount++ } } console.log(`📄 Generated ${fileCount} template files`) } /** * Get list of template files to copy */ private getTemplateFiles(): TemplateFile[] { const baseTemplates = 'templates/base' return [ // App files { source: `${baseTemplates}/app/page.tsx`, destination: 'app/page.tsx', process: true }, { source: `${baseTemplates}/app/layout.tsx`, destination: 'app/layout.tsx', process: true }, { source: `templates/themes/globals.css.template`, destination: 'app/globals.css', process: true }, // Components { source: `${baseTemplates}/components/kanban/KanbanBoard.tsx`, destination: 'components/kanban/KanbanBoard.tsx', process: true }, { source: `${baseTemplates}/components/kanban/KanbanCard.tsx`, destination: 'components/kanban/KanbanCard.tsx', process: true }, { source: `${baseTemplates}/components/kanban/KanbanColumn.tsx`, destination: 'components/kanban/KanbanColumn.tsx', process: true }, { source: `${baseTemplates}/components/kanban/CardDetailModal.tsx`, destination: 'components/kanban/CardDetailModal.tsx', process: true }, // UI Components { source: `${baseTemplates}/components/ui/button.tsx`, destination: 'components/ui/button.tsx', process: false }, { source: `${baseTemplates}/components/ui/card.tsx`, destination: 'components/ui/card.tsx', process: false }, { source: `${baseTemplates}/components/ui/badge.tsx`, destination: 'components/ui/badge.tsx', process: false }, { source: `${baseTemplates}/components/ui/dialog.tsx`, destination: 'components/ui/dialog.tsx', process: false }, { source: `${baseTemplates}/components/ui/progress.tsx`, destination: 'components/ui/progress.tsx', process: false }, { source: `${baseTemplates}/components/ui/input.tsx`, destination: 'components/ui/input.tsx', process: false }, { source: `${baseTemplates}/components/ui/scroll-area.tsx`, destination: 'components/ui/scroll-area.tsx', process: false }, { source: `${baseTemplates}/components/ui/tooltip.tsx`, destination: 'components/ui/tooltip.tsx', process: false }, { source: `${baseTemplates}/components/ui/separator.tsx`, destination: 'components/ui/separator.tsx', process: false }, { source: `${baseTemplates}/components/ui/dropdown-menu.tsx`, destination: 'components/ui/dropdown-menu.tsx', process: false }, // Theme files { source: `templates/themes/theme-provider.tsx.template`, destination: 'components/theme-provider.tsx', process: true }, // Configuration and utilities { source: `${baseTemplates}/lib/utils.ts`, destination: 'lib/utils.ts', process: false }, { source: `${baseTemplates}/src/types/roadmap.ts`, destination: 'src/types/roadmap.ts', process: false }, { source: `${baseTemplates}/src/data/roadmap-data.ts`, destination: 'src/data/roadmap-data.ts', process: true }, // Config files { source: `${baseTemplates}/next.config.js`, destination: 'next.config.js', process: false }, { source: `${baseTemplates}/components.json`, destination: 'components.json', process: false }, { source: `${baseTemplates}/tsconfig.json`, destination: 'tsconfig.json', process: false }, { source: `${baseTemplates}/postcss.config.js`, destination: 'postcss.config.js', process: false }, ] } /** * Process template content by replacing variables */ private processTemplate(content: string): string { const replacements: Record<string, string> = { '{{PROJECT_NAME}}': this.config.name, '{{PROJECT_TITLE}}': this.config.name.charAt(0).toUpperCase() + this.config.name.slice(1), '{{THEME_NAME}}': this.config.theme, '{{THEME_ID}}': this.config.name.toLowerCase().replace(/[^a-z0-9]/g, '-'), '{{DEFAULT_THEME_MODE}}': 'system', '{{TYPESCRIPT}}': this.config.typescript ? 'true' : 'false', '{{DESCRIPTION}}': `Interactive roadmap for ${this.config.name}`, } let processedContent = content for (const [placeholder, replacement] of Object.entries(replacements)) { processedContent = processedContent.replace(new RegExp(placeholder, 'g'), replacement) } return processedContent } /** * Generate configuration files */ private async generateConfigFiles(projectPath: string): Promise<void> { // Generate package.json const packageJson = { name: this.config.name, version: '0.1.0', private: true, scripts: { dev: 'next dev', build: 'next build', start: 'next start', export: 'NODE_ENV=production next build', lint: 'next lint', 'type-check': 'tsc --noEmit' }, dependencies: { 'next': '^15.5.0', 'react': '^19.1.1', 'react-dom': '^19.1.1', '@radix-ui/react-slot': '^1.2.3', '@radix-ui/react-separator': '^1.1.7', '@radix-ui/react-tooltip': '^1.2.8', '@radix-ui/react-dialog': '^1.1.15', '@radix-ui/react-progress': '^1.1.7', '@radix-ui/react-scroll-area': '^1.2.10', '@radix-ui/react-dropdown-menu': '^2.1.8', 'class-variance-authority': '^0.7.1', 'clsx': '^2.1.1', 'tailwind-merge': '^3.3.1', 'lucide-react': '^0.541.0', 'yaml': '^2.8.1', 'next-themes': '^0.4.6' }, devDependencies: { 'typescript': '^5.9.2', '@types/node': '^24.3.0', '@types/react': '^19.1.11', '@types/react-dom': '^19.1.7', 'tailwindcss': '^4.1.12', '@tailwindcss/postcss': '^4.1.12', 'postcss': '^8.5.6', 'autoprefixer': '^10.4.21', 'eslint': '^9.34.0', 'eslint-config-next': '^15.5.0' } } writeFileSync( path.join(projectPath, 'package.json'), JSON.stringify(packageJson, null, 2) ) // Generate .npmrc to prevent workspace conflicts const npmrcContent = `# Disable workspace detection to prevent conflicts workspaces-experimental=false ` writeFileSync( path.join(projectPath, '.npmrc'), npmrcContent ) } /** * Install dependencies */ private async installDependencies(projectPath: string): Promise<void> { console.log('📦 Installing dependencies...') const originalCwd = process.cwd() process.chdir(projectPath) try { const { spawn } = await import('child_process') const { promisify } = await import('util') // Try bun first, fallback to npm const commands = [ ['bun', 'install'], ['npm', 'install', '--no-workspaces', '--legacy-peer-deps'], ['yarn', 'install'], ['pnpm', 'install'] ] let success = false for (const [cmd, ...args] of commands) { try { const child = spawn(cmd, args, { stdio: ['inherit', 'inherit', 'inherit'], cwd: projectPath }) await new Promise((resolve, reject) => { child.on('close', (code) => { if (code === 0) resolve(code) else reject(new Error(`${cmd} failed with code ${code}`)) }) child.on('error', reject) }) success = true console.log('✅ Dependencies installed successfully') break } catch (error) { // Try next package manager continue } } if (!success) { throw new Error('No package manager found (tried: bun, npm, yarn, pnpm)') } // Post-install: Ensure Next.js SWC is properly installed console.log('🔧 Ensuring Next.js SWC dependencies...') try { const swcChild = spawn('npx', ['next', 'telemetry', 'disable'], { stdio: ['inherit', 'pipe', 'pipe'], cwd: projectPath }) await new Promise((resolve) => { swcChild.on('close', resolve) swcChild.on('error', resolve) // Don't wait too long for telemetry command setTimeout(resolve, 3000) }) } catch (e) { // Ignore telemetry errors, it's just to trigger SWC download } } catch (error) { console.error('❌ Failed to install dependencies:', error) throw error } finally { process.chdir(originalCwd) } } } /** * Generate a new roadmap project */ export async function generateRoadmapProject(config: ProjectConfig): Promise<void> { const generator = new TemplateGenerator(config) await generator.generateProject() }