- DynamicFieldRenderer에 신규 필드 타입 추가 (Currency, File, MultiSelect, Radio, Reference, Toggle, UnitValue, Computed) - DynamicTableSection 및 TableCellRenderer 추가 - 필드 프리셋 및 설정 구조 분리 - 컴포넌트 레지스트리 개발 도구 페이지 추가 - UniversalListPage 개선 - 근태관리 코드 정리 - 즐겨찾기 기능 및 동적 필드 타입 백엔드 스펙 문서 추가 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
259 lines
8.1 KiB
JavaScript
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);
|
|
});
|