diff --git a/.github/workflows/test-presets.yml b/.github/workflows/test-presets.yml new file mode 100644 index 0000000..3448f32 --- /dev/null +++ b/.github/workflows/test-presets.yml @@ -0,0 +1,32 @@ +name: E2E Preset Test + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + test-presets: + runs-on: ubuntu-latest + strategy: + matrix: + preset: [landing, saas, ecommerce] + + steps: + - uses: actions/checkout@v4 + + - uses: pnpm/action-setup@v4 + with: + version: 10 + + - uses: actions/setup-node@v4 + with: + node-version: 20 + cache: pnpm + + - name: Install dependencies + run: pnpm install + + - name: Test ${{ matrix.preset }} preset + run: node scripts/test-presets.mjs ${{ matrix.preset }} diff --git a/package.json b/package.json index 5c382c3..4bf917b 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,12 @@ "scripts": { "dev": "pnpm --filter @coding-factory/base dev", "build": "pnpm --filter @coding-factory/base build", - "cli": "pnpm --filter @coding-factory/cli start" + "cli:build": "pnpm --filter @coding-factory/cli build", + "create": "node packages/cli/dist/index.js init", + "factory": "node packages/cli/dist/index.js", + "test:e2e": "node scripts/test-presets.mjs", + "demo:build": "node scripts/build-demo.mjs", + "demo:generate": "node scripts/build-demo.mjs --only-generate" }, "engines": { "node": ">=20", diff --git a/packages/cli/package.json b/packages/cli/package.json index a7bf0e4..726b240 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -12,6 +12,10 @@ "dev": "tsup src/index.ts --format esm --watch", "start": "node dist/index.js" }, + "files": [ + "dist", + "README.md" + ], "dependencies": { "@clack/prompts": "^0.9", "commander": "^13", diff --git a/scripts/build-demo.mjs b/scripts/build-demo.mjs new file mode 100644 index 0000000..fdcf48b --- /dev/null +++ b/scripts/build-demo.mjs @@ -0,0 +1,57 @@ +#!/usr/bin/env node + +/** + * Build Demo Site + * + * Generates a demo project from starters/base + starters/demo pages, + * ready for deployment to Vercel or any static host. + * + * Usage: + * node scripts/build-demo.mjs # generate + build + * node scripts/build-demo.mjs --only-generate # generate only (no build) + */ + +import { cpSync, mkdirSync, existsSync, rmSync } from 'node:fs' +import { resolve, dirname } from 'node:path' +import { execSync } from 'node:child_process' +import { fileURLToPath } from 'node:url' + +const __dirname = dirname(fileURLToPath(import.meta.url)) +const ROOT = resolve(__dirname, '..') +const DEMO_OUT = resolve(ROOT, '..', '.coding-factory-demo') + +const onlyGenerate = process.argv.includes('--only-generate') + +console.log('Building Coding Factory demo site...\n') + +// Clean +if (existsSync(DEMO_OUT)) rmSync(DEMO_OUT, { recursive: true, force: true }) + +// Copy base starter +console.log('1. Copying base starter...') +cpSync(resolve(ROOT, 'starters/base'), DEMO_OUT, { recursive: true }) + +// Overlay demo pages +console.log('2. Overlaying demo pages...') +cpSync(resolve(ROOT, 'starters/demo/src'), resolve(DEMO_OUT, 'src'), { recursive: true, force: true }) + +// Install +console.log('3. Installing dependencies...') +execSync('pnpm install --no-frozen-lockfile', { + cwd: DEMO_OUT, + stdio: 'inherit', + timeout: 120_000, +}) + +if (!onlyGenerate) { + // Build + console.log('\n4. Building...') + execSync('pnpm exec next build', { + cwd: DEMO_OUT, + stdio: 'inherit', + timeout: 180_000, + }) +} + +console.log(`\nDemo site ready at: ${DEMO_OUT}`) +console.log('Run: cd ' + DEMO_OUT + ' && pnpm dev') diff --git a/scripts/test-presets.mjs b/scripts/test-presets.mjs new file mode 100644 index 0000000..8210d9f --- /dev/null +++ b/scripts/test-presets.mjs @@ -0,0 +1,206 @@ +#!/usr/bin/env node + +/** + * E2E Preset Test Script + * + * Generates a project for each preset, installs deps, and runs `next build`. + * Exit code 0 = all passed, 1 = at least one failed. + * + * Usage: + * node scripts/test-presets.mjs # test all presets + * node scripts/test-presets.mjs landing # test single preset + * node scripts/test-presets.mjs --keep # don't clean up after test + */ + +import { cpSync, mkdirSync, existsSync, readFileSync, writeFileSync, rmSync } from 'node:fs' +import { resolve, dirname } from 'node:path' +import { execSync } from 'node:child_process' +import { fileURLToPath } from 'node:url' + +const __dirname = dirname(fileURLToPath(import.meta.url)) +const ROOT = resolve(__dirname, '..') +const TEST_DIR = resolve(ROOT, '..', '.coding-factory-test-output') + +// Parse args +const args = process.argv.slice(2) +const keepOutput = args.includes('--keep') +const targetPresets = args.filter((a) => !a.startsWith('--')) + +// Load registry +const registry = JSON.parse(readFileSync(resolve(ROOT, 'registry/registry.json'), 'utf-8')) +const presetNames = targetPresets.length > 0 ? targetPresets : Object.keys(registry.presets) + +// Resolve module dependencies +function resolveModuleDeps(moduleNames) { + const resolved = [] + const visited = new Set() + function walk(name) { + if (visited.has(name)) return + visited.add(name) + const mod = registry.modules[name] + if (!mod) return + for (const dep of mod.requiredModules || []) walk(dep) + resolved.push(name) + } + for (const name of moduleNames) walk(name) + return resolved +} + +// Generate a project from preset +function generateProject(presetName) { + const preset = registry.presets[presetName] + if (!preset) { + console.error(` Unknown preset: ${presetName}`) + return null + } + + const targetDir = resolve(TEST_DIR, `${presetName}-project`) + if (existsSync(targetDir)) rmSync(targetDir, { recursive: true, force: true }) + + // Copy base starter + cpSync(resolve(ROOT, 'starters/base'), targetDir, { recursive: true }) + + // Resolve and apply modules + const modules = resolveModuleDeps(preset.modules) + + for (const modName of modules) { + const manifestPath = resolve(ROOT, 'registry/modules', modName, 'module.json') + if (!existsSync(manifestPath)) continue + + const manifest = JSON.parse(readFileSync(manifestPath, 'utf-8')) + + // Copy files + for (const f of manifest.files) { + const src = resolve(ROOT, 'registry/modules', modName, f.source) + const dest = resolve(targetDir, f.target) + if (existsSync(src)) { + mkdirSync(dirname(dest), { recursive: true }) + cpSync(src, dest, { recursive: true, force: true }) + } + } + + // Merge dependencies + const deps = manifest.dependencies || {} + const devDeps = manifest.devDependencies || {} + if (Object.keys(deps).length > 0 || Object.keys(devDeps).length > 0) { + const pkgPath = resolve(targetDir, 'package.json') + const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8')) + pkg.dependencies = { ...(pkg.dependencies || {}), ...deps } + pkg.devDependencies = { ...(pkg.devDependencies || {}), ...devDeps } + writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + '\n') + } + } + + // Write factory config + writeFileSync( + resolve(targetDir, 'factory.config.json'), + JSON.stringify({ preset: presetName, theme: 'default', modules, layout: preset.layout }, null, 2) + '\n' + ) + + return { targetDir, modules } +} + +// Run build for a project +function buildProject(presetName, targetDir) { + try { + // Install deps (allow build scripts for prisma etc.) + execSync('pnpm install --no-frozen-lockfile', { + cwd: targetDir, + stdio: 'pipe', + timeout: 120_000, + env: { ...process.env, npm_config_ignore_scripts: '' }, + }) + + // Prisma generate if db module is present + if (existsSync(resolve(targetDir, 'prisma/schema.prisma'))) { + const prismaBin = resolve(targetDir, 'node_modules/.bin/prisma') + if (existsSync(prismaBin)) { + execSync(`${prismaBin} generate`, { + cwd: targetDir, + stdio: 'pipe', + timeout: 30_000, + }) + } + } + + // Next.js build + execSync('pnpm exec next build', { + cwd: targetDir, + stdio: 'pipe', + timeout: 180_000, + }) + + return { success: true } + } catch (error) { + const stderr = error.stderr?.toString() || '' + const stdout = error.stdout?.toString() || '' + return { success: false, error: stderr || stdout } + } +} + +// Main +console.log('╔══════════════════════════════════════╗') +console.log('║ Coding Factory — E2E Preset Test ║') +console.log('╚══════════════════════════════════════╝') +console.log() + +// Ensure CLI is built +console.log('Building CLI...') +execSync('pnpm --filter @coding-factory/cli build', { cwd: ROOT, stdio: 'pipe' }) + +// Clean test output +if (existsSync(TEST_DIR)) rmSync(TEST_DIR, { recursive: true, force: true }) +mkdirSync(TEST_DIR, { recursive: true }) + +const results = [] + +for (const presetName of presetNames) { + console.log(`\n━━━ ${presetName.toUpperCase()} ━━━`) + + // Generate + process.stdout.write(' Generating... ') + const project = generateProject(presetName) + if (!project) { + results.push({ preset: presetName, success: false, error: 'Generation failed' }) + console.log('FAIL') + continue + } + console.log(`OK (${project.modules.length} modules)`) + + // Build + process.stdout.write(' Installing + Building... ') + const buildResult = buildProject(presetName, project.targetDir) + + if (buildResult.success) { + console.log('OK ✓') + results.push({ preset: presetName, success: true }) + } else { + console.log('FAIL ✗') + // Show last 10 lines of error + const errorLines = buildResult.error.split('\n').filter(Boolean).slice(-10) + for (const line of errorLines) { + console.log(` ${line}`) + } + results.push({ preset: presetName, success: false, error: buildResult.error }) + } +} + +// Summary +console.log('\n━━━ RESULTS ━━━') +const passed = results.filter((r) => r.success).length +const failed = results.filter((r) => !r.success).length + +for (const r of results) { + console.log(` ${r.success ? '✓' : '✗'} ${r.preset}`) +} +console.log(`\n ${passed} passed, ${failed} failed`) + +// Cleanup +if (!keepOutput && failed === 0) { + rmSync(TEST_DIR, { recursive: true, force: true }) + console.log(' (test output cleaned)') +} else if (failed > 0) { + console.log(` (test output kept at ${TEST_DIR})`) +} + +process.exit(failed > 0 ? 1 : 0) diff --git a/starters/demo/src/app/demo/ecommerce/page.tsx b/starters/demo/src/app/demo/ecommerce/page.tsx new file mode 100644 index 0000000..bf9e114 --- /dev/null +++ b/starters/demo/src/app/demo/ecommerce/page.tsx @@ -0,0 +1,109 @@ +import Link from 'next/link' + +const products = [ + { name: 'Basic Plan', price: '₩29,000/mo', desc: 'For individuals', features: ['5 projects', 'All modules', 'Email support'] }, + { name: 'Pro Plan', price: '₩79,000/mo', desc: 'For teams', features: ['Unlimited projects', 'Priority support', 'Custom themes'] }, + { name: 'Enterprise', price: 'Contact us', desc: 'For organizations', features: ['White-label', 'Dedicated support', 'Custom modules'] }, +] + +export default function EcommerceDemo() { + return ( +
+ + + {/* Hero */} +
+

E-commerce with Payments

+

+ Full-stack e-commerce ready: authentication, database, security, SEO, analytics, ads, + and payment gateway integration out of the box. +

+
+ + {/* Pricing Cards (demonstrate payment flow) */} +
+

Pricing Example

+
+ {products.map((product, i) => ( +
+ {i === 1 && ( +
+ Popular +
+ )} +

{product.name}

+

{product.desc}

+

{product.price}

+
    + {product.features.map((f) => ( +
  • + {f} +
  • + ))} +
+ +
+ ))} +
+
+ + {/* Payment Flow */} +
+

Payment Integration

+
+

{'// Use PaymentButton anywhere in your app'}

+

{'import { PaymentButton } from'} {'"@/components/payments/payment-button"'}

+

{' +

{' orderId="order-123"'}

+

{' amount={29000}'}

+

{' orderName="Pro Plan"'}

+

{'>'}

+

{' Subscribe Now'}

+

{''}

+

{'// Supports Toss Payments (KR) and Stripe (Global)'}

+

{'// Switch with PAYMENT_PROVIDER=toss|stripe'}

+
+
+ + {/* All 7 Modules */} +
+

All 7 Modules Included

+
+ {[ + { name: 'Auth', desc: 'Social + credentials login with NextAuth.js v5' }, + { name: 'Database', desc: 'Prisma ORM with PostgreSQL' }, + { name: 'Security', desc: 'CSP, CSRF, rate limiting, Zod validation' }, + { name: 'SEO', desc: 'Meta tags, sitemap, structured data' }, + { name: 'Analytics', desc: 'GA4, GTM, event tracking' }, + { name: 'Ads', desc: 'AdSense, AdPost ad placement' }, + { name: 'Payments', desc: 'Toss / Stripe unified adapter' }, + ].map((mod) => ( +
+ +
+

{mod.name}

+

{mod.desc}

+
+
+ ))} +
+
+
+ ) +} diff --git a/starters/demo/src/app/demo/landing/page.tsx b/starters/demo/src/app/demo/landing/page.tsx new file mode 100644 index 0000000..496be7b --- /dev/null +++ b/starters/demo/src/app/demo/landing/page.tsx @@ -0,0 +1,80 @@ +import Link from 'next/link' + +export default function LandingDemo() { + return ( +
+ + + {/* Hero Section */} +
+

+ Your Product, Supercharged +

+

+ A beautiful landing page with built-in SEO optimization, analytics tracking, + and ad placement — all configured automatically. +

+
+ + +
+
+ + {/* Included Modules */} +
+

What's Included

+
+ + + component', 'Ad-block detection']} + /> +
+
+ + {/* Code Example */} +
+

One Command

+
+

# Generate a landing page project

+

pnpm create my-landing

+

# Modules are already configured:

+

✓ SEO — meta tags, sitemap, structured data

+

✓ Analytics — GA4, GTM, event tracking

+

✓ Ads — AdSense, AdPost slots

+
+
+
+ ) +} + +function ModuleCard({ title, features }: { title: string; features: string[] }) { + return ( +
+

{title}

+ +
+ ) +} diff --git a/starters/demo/src/app/demo/saas/page.tsx b/starters/demo/src/app/demo/saas/page.tsx new file mode 100644 index 0000000..1def9a7 --- /dev/null +++ b/starters/demo/src/app/demo/saas/page.tsx @@ -0,0 +1,71 @@ +import Link from 'next/link' + +export default function SaasDemo() { + return ( +
+ + + {/* Dashboard Preview */} +
+ {/* Sidebar */} + + + {/* Main Content */} +
+

SaaS Dashboard

+ + {/* Stats */} +
+ {[ + { label: 'Total Users', value: '12,847' }, + { label: 'Active Now', value: '342' }, + { label: 'Revenue', value: '$48,290' }, + { label: 'Growth', value: '+12.5%' }, + ].map((stat) => ( +
+

{stat.label}

+

{stat.value}

+
+ ))} +
+ + {/* Included Modules */} +

Included Modules

+
+ {[ + { name: 'Auth', desc: 'NextAuth.js v5 — Google, Kakao, Naver + credentials login' }, + { name: 'Database', desc: 'Prisma ORM — PostgreSQL with adapter pattern' }, + { name: 'Security', desc: 'CSP headers, CSRF protection, rate limiting, Zod validation' }, + { name: 'Analytics', desc: 'GA4 + GTM event tracking with cookie consent' }, + { name: 'SEO', desc: 'Meta tags, sitemap, robots.txt, structured data' }, + ].map((mod) => ( +
+

{mod.name}

+

{mod.desc}

+
+ ))} +
+
+
+
+ ) +} diff --git a/starters/demo/src/app/layout.tsx b/starters/demo/src/app/layout.tsx new file mode 100644 index 0000000..49e41e1 --- /dev/null +++ b/starters/demo/src/app/layout.tsx @@ -0,0 +1,15 @@ +import type { Metadata } from 'next' +import './globals.css' + +export const metadata: Metadata = { + title: 'Coding Factory — Demo', + description: '모듈 조합으로 웹 프로젝트를 빠르게 생성하는 보일러플레이트 시스템', +} + +export default function RootLayout({ children }: { children: React.ReactNode }) { + return ( + + {children} + + ) +} diff --git a/starters/demo/src/app/page.tsx b/starters/demo/src/app/page.tsx new file mode 100644 index 0000000..1a94a80 --- /dev/null +++ b/starters/demo/src/app/page.tsx @@ -0,0 +1,154 @@ +import Link from 'next/link' + +const presets = [ + { + name: 'Landing', + description: 'SEO + Analytics + Ads', + modules: ['seo', 'analytics', 'ads'], + href: '/demo/landing', + color: 'bg-blue-500', + }, + { + name: 'SaaS', + description: 'Auth + DB + Security + Analytics + SEO', + modules: ['auth', 'db', 'security', 'analytics', 'seo'], + href: '/demo/saas', + color: 'bg-purple-500', + }, + { + name: 'E-commerce', + description: 'Auth + DB + Security + SEO + Analytics + Ads + Payments', + modules: ['auth', 'db', 'security', 'seo', 'analytics', 'ads', 'payments'], + href: '/demo/ecommerce', + color: 'bg-green-500', + }, +] + +const modules = [ + { name: 'seo', description: 'Meta tags, sitemap, robots.txt, JSON-LD', icon: '🔍' }, + { name: 'analytics', description: 'GA4, GTM, event tracking, cookie consent', icon: '📊' }, + { name: 'ads', description: 'Google AdSense, Naver AdPost', icon: '📢' }, + { name: 'security', description: 'CSP, CSRF, rate limiting, input validation', icon: '🛡️' }, + { name: 'auth', description: 'NextAuth.js v5, social + credentials login', icon: '🔐' }, + { name: 'db', description: 'Prisma adapter (PostgreSQL, MySQL, SQLite)', icon: '🗄️' }, + { name: 'payments', description: 'Toss Payments / Stripe with adapter pattern', icon: '💳' }, +] + +export default function DemoHome() { + return ( +
+ {/* Hero */} +
+
+ v0.1.0 +
+

+ Coding Factory +

+

+ 모듈 조합으로 웹 프로젝트를 빠르게 생성하는 보일러플레이트 시스템. + CLI 한 줄로 프로젝트 생성, 필요한 모듈만 추가/제거. +

+ + {/* CLI example */} +
+
+ + + + terminal +
+
+

$ pnpm create my-project

+

? Select a preset

+

● landing

+

○ saas

+

○ ecommerce

+

○ custom

+
+
+
+ + {/* Presets */} +
+

Presets

+

+ 프로젝트 유��에 맞는 프리셋을 선택하세요 +

+
+ {presets.map((preset) => ( + +
+

+ {preset.name} +

+

{preset.description}

+
+ {preset.modules.map((mod) => ( + + {mod} + + ))} +
+ + ))} +
+
+ + {/* Modules */} +
+

Modules

+

+ 7개 모듈을 자유롭게 조합하세요 +

+
+ {modules.map((mod) => ( +
+
{mod.icon}
+

{mod.name}

+

{mod.description}

+
+ ))} +
+
+ + {/* How it works */} +
+

How it works

+
+ {[ + { step: '1', title: 'Choose a preset', desc: 'landing, saas, ecommerce, or custom' }, + { step: '2', title: 'Pick a theme', desc: 'default, corporate, or playful' }, + { step: '3', title: 'Generate', desc: 'CLI copies code into a clean Next.js project' }, + { step: '4', title: 'Customize', desc: 'Add/remove modules anytime with factory add/remove' }, + ].map((item) => ( +
+
+ {item.step} +
+
+

{item.title}

+

{item.desc}

+
+
+ ))} +
+
+ + {/* Footer */} + +
+ ) +}