Files
sam-react-prod/scripts/gen-component-registry.mjs
유병철 020d74f36c feat(WEB): DynamicItemForm 필드 타입 확장 및 컴포넌트 레지스트리 추가
- DynamicFieldRenderer에 신규 필드 타입 추가 (Currency, File, MultiSelect, Radio, Reference, Toggle, UnitValue, Computed)
- DynamicTableSection 및 TableCellRenderer 추가
- 필드 프리셋 및 설정 구조 분리
- 컴포넌트 레지스트리 개발 도구 페이지 추가
- UniversalListPage 개선
- 근태관리 코드 정리
- 즐겨찾기 기능 및 동적 필드 타입 백엔드 스펙 문서 추가

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-12 11:17:57 +09:00

259 lines
8.1 KiB
JavaScript

#!/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);
});