#!/usr/bin/env node /** * Component Registry Generator * * src/components/ 하위 .tsx 파일을 스캔하여 * - src/generated/component-registry.json (페이지용 데이터) * - claudedocs/components/_registry.md (에디터/git 이력용) * 두 가지 출력을 생성합니다. * * 사용: node scripts/gen-component-registry.mjs */ import { readdir, readFile, writeFile, mkdir, stat } from 'node:fs/promises'; import { join, relative, basename, dirname, extname } from 'node:path'; import { fileURLToPath } from 'node:url'; const __dirname = dirname(fileURLToPath(import.meta.url)); const ROOT = join(__dirname, '..'); const SRC_COMPONENTS = join(ROOT, 'src', 'components'); const OUT_JSON = join(ROOT, 'src', 'generated', 'component-registry.json'); const OUT_MD = join(ROOT, 'claudedocs', 'components', '_registry.md'); // Tier mapping by directory name const TIER_MAP = { ui: 'ui', atoms: 'atoms', molecules: 'molecules', organisms: 'organisms', common: 'common', layout: 'layout', dev: 'dev', }; // Skip patterns const SKIP_FILES = new Set(['index.ts', 'index.tsx', 'actions.ts', 'actions.tsx', 'types.ts', 'types.tsx', 'utils.ts', 'utils.tsx', 'constants.ts', 'constants.tsx', 'schema.ts', 'schema.tsx']); const SKIP_PATTERNS = [ /\.test\./, /\.spec\./, /\.stories\./, /^use[A-Z].*\.ts$/, // hooks files (useXxx.ts only, not .tsx) ]; function shouldSkip(fileName) { if (SKIP_FILES.has(fileName)) return true; return SKIP_PATTERNS.some(p => p.test(fileName)); } function getTier(relPath) { const firstDir = relPath.split('/')[0]; return TIER_MAP[firstDir] || 'domain'; } function getCategory(relPath) { const parts = relPath.split('/'); // category = first directory under components/ return parts[0]; } function getSubcategory(relPath) { const parts = relPath.split('/'); // subcategory = second directory if exists return parts.length > 2 ? parts[1] : null; } function extractComponentInfo(content, fileName) { const isClient = /^['"]use client['"];?/m.test(content); // Extract component name from exports let name = null; let exportType = 'none'; // Check default export: export default function Foo / export default Foo const defaultFuncMatch = content.match(/export\s+default\s+function\s+([A-Z]\w*)/); const defaultConstMatch = content.match(/export\s+default\s+([A-Z]\w*)/); // Check named exports: export function Foo / export const Foo const namedFuncMatches = [...content.matchAll(/export\s+(?:async\s+)?function\s+([A-Z]\w*)/g)]; const namedConstMatches = [...content.matchAll(/export\s+(?:const|let)\s+([A-Z]\w*)/g)]; const hasDefault = !!(defaultFuncMatch || defaultConstMatch); const namedExports = [ ...namedFuncMatches.map(m => m[1]), ...namedConstMatches.map(m => m[1]), ].filter(n => n !== undefined); // Remove default export name from named list if it also appears if (defaultFuncMatch) { name = defaultFuncMatch[1]; } else if (defaultConstMatch) { name = defaultConstMatch[1]; } const hasNamed = namedExports.length > 0; if (hasDefault && hasNamed) { exportType = 'both'; } else if (hasDefault) { exportType = 'default'; } else if (hasNamed) { exportType = 'named'; name = namedExports[0]; // Use first named export as component name } // Fallback: derive name from file name if (!name) { const base = basename(fileName, extname(fileName)); name = base .split(/[-_]/) .map(s => s.charAt(0).toUpperCase() + s.slice(1)) .join(''); } // Props detection const hasProps = /(?:interface|type)\s+\w*Props/.test(content); const propsNameMatch = content.match(/(?:interface|type)\s+(\w*Props)/); const propsName = propsNameMatch ? propsNameMatch[1] : null; // Line count const lineCount = content.split('\n').length; return { name, exportType, hasProps, propsName, isClientComponent: isClient, lineCount }; } async function scanDirectory(dir) { const entries = await readdir(dir, { withFileTypes: true }); const results = []; for (const entry of entries) { const fullPath = join(dir, entry.name); if (entry.isDirectory()) { results.push(...await scanDirectory(fullPath)); } else if (entry.isFile() && entry.name.endsWith('.tsx')) { if (shouldSkip(entry.name)) continue; const relPath = relative(SRC_COMPONENTS, fullPath); const content = await readFile(fullPath, 'utf-8'); const info = extractComponentInfo(content, entry.name); results.push({ name: info.name, fileName: entry.name, filePath: `src/components/${relPath}`, tier: getTier(relPath), category: getCategory(relPath), subcategory: getSubcategory(relPath), exportType: info.exportType, hasProps: info.hasProps, propsName: info.propsName, isClientComponent: info.isClientComponent, lineCount: info.lineCount, }); } } return results; } function buildCategories(components) { const map = new Map(); for (const comp of components) { const key = `${comp.tier}::${comp.category}`; if (!map.has(key)) { map.set(key, { tier: comp.tier, category: comp.category, count: 0 }); } map.get(key).count++; } return [...map.values()].sort((a, b) => a.tier.localeCompare(b.tier) || a.category.localeCompare(b.category)); } function generateMarkdown(registry) { const now = registry.generatedAt; const lines = [ `# Component Registry`, ``, `> Auto-generated: ${now} `, `> Total: **${registry.totalCount}** components`, ``, ]; // Group by tier, then category const byTier = new Map(); for (const comp of registry.components) { if (!byTier.has(comp.tier)) byTier.set(comp.tier, new Map()); const tierMap = byTier.get(comp.tier); if (!tierMap.has(comp.category)) tierMap.set(comp.category, []); tierMap.get(comp.category).push(comp); } const tierOrder = ['ui', 'atoms', 'molecules', 'organisms', 'common', 'layout', 'dev', 'domain']; for (const tier of tierOrder) { const categories = byTier.get(tier); if (!categories) continue; const tierCount = [...categories.values()].reduce((s, arr) => s + arr.length, 0); lines.push(`## ${tier.toUpperCase()} (${tierCount})`); lines.push(``); for (const [category, comps] of [...categories.entries()].sort()) { lines.push(`### ${category} (${comps.length})`); lines.push(``); lines.push(`| Component | File | Export | Props | Client | Lines |`); lines.push(`|-----------|------|--------|-------|--------|-------|`); for (const c of comps.sort((a, b) => a.name.localeCompare(b.name))) { const client = c.isClientComponent ? 'Y' : ''; const props = c.hasProps ? (c.propsName || 'Y') : ''; lines.push(`| ${c.name} | ${c.fileName} | ${c.exportType} | ${props} | ${client} | ${c.lineCount} |`); } lines.push(``); } } return lines.join('\n'); } async function main() { console.log('Scanning src/components/...'); const components = await scanDirectory(SRC_COMPONENTS); components.sort((a, b) => a.tier.localeCompare(b.tier) || a.category.localeCompare(b.category) || a.name.localeCompare(b.name)); const registry = { generatedAt: new Date().toISOString(), totalCount: components.length, categories: buildCategories(components), components, }; // Ensure output directories await mkdir(dirname(OUT_JSON), { recursive: true }); await mkdir(dirname(OUT_MD), { recursive: true }); // Write JSON await writeFile(OUT_JSON, JSON.stringify(registry, null, 2), 'utf-8'); console.log(` JSON: ${relative(ROOT, OUT_JSON)} (${registry.totalCount} components)`); // Write Markdown const md = generateMarkdown(registry); await writeFile(OUT_MD, md, 'utf-8'); console.log(` MD: ${relative(ROOT, OUT_MD)}`); // Summary console.log(`\nSummary by tier:`); const tierCounts = {}; for (const c of components) { tierCounts[c.tier] = (tierCounts[c.tier] || 0) + 1; } for (const [tier, count] of Object.entries(tierCounts).sort()) { console.log(` ${tier}: ${count}`); } console.log(`\nDone!`); } main().catch(err => { console.error('Error:', err); process.exit(1); });