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
text/typescript
/**
* 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()
}