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>
This commit is contained in:
258
scripts/gen-component-registry.mjs
Normal file
258
scripts/gen-component-registry.mjs
Normal file
@@ -0,0 +1,258 @@
|
||||
#!/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);
|
||||
});
|
||||
Reference in New Issue
Block a user