import type { API, ASTPath, Collection, FileInfo, ImportDeclaration, ImportSpecifier, JSCodeshift, JSXAttribute, JSXIdentifier, JSXOpeningElement, } from 'jscodeshift'; const DESIGN_SYSTEM_PACKAGE = '@wishket/design-system'; const SOURCE_NAME = 'Menu'; const LINK_NAME = 'MenuLink'; const BUTTON_NAME = 'MenuButton'; /** * v3 — `Menu` 컴포넌트를 `MenuLink` / `MenuButton`으로 분리하는 codemod. * * 변환 규칙: * * 1. `@wishket/design-system`에서 import된 `Menu`만 처리 (alias 미지원). * 2. JSX 호출 분석: * - `href` prop 있음 → ``로 rename + import 정리 * - `href` 없고 `onClick` 있음 → ``로 rename + import 정리 * - 둘 다 있음 (모순) → 변환 안 함 + TODO 코멘트 * - 둘 다 없음 → 변환 안 함 + TODO 코멘트 * - spread props (``) → 변환 안 함 + TODO 코멘트 * 3. import 정리: * - `Menu`를 사용된 변형(`MenuLink` / `MenuButton` / 둘 다)으로 교체 * - 모든 호출을 변환하지 못해 `Menu`가 여전히 필요한 경우 `Menu`도 유지 */ export default function transformer(file: FileInfo, api: API): string { const j: JSCodeshift = api.jscodeshift; const root = j(file.source); const dsImports: Collection = root.find( j.ImportDeclaration, { source: { value: DESIGN_SYSTEM_PACKAGE }, }, ); if (dsImports.size() === 0) return file.source; // `Menu`가 alias 없이 default-named로 import 되었는지 확인. let hasMenuImport = false; let hasAliasedMenuImport = false; dsImports.forEach(path => { const specifiers = path.node.specifiers ?? []; specifiers.forEach(spec => { if (spec.type !== 'ImportSpecifier') return; const importedName = spec.imported.name; if (importedName !== SOURCE_NAME) return; const localName = spec.local?.name ?? importedName; if (localName !== SOURCE_NAME) { hasAliasedMenuImport = true; } else { hasMenuImport = true; } }); }); if (!hasMenuImport && !hasAliasedMenuImport) { return file.source; } let didChange = false; let usedMenuLink = false; let usedMenuButton = false; let hasUnconvertedMenu = false; if (hasAliasedMenuImport) { // alias가 있는 경우 안전하게 변환하기 어려움 — 파일 상단에 안내 코멘트 추가. addLeadingComment( j, root, ` TODO: codemod could not safely resolve aliased Menu import — manually split into MenuLink / MenuButton.`, ); return root.toSource({ quote: 'single' }); } root .find(j.JSXOpeningElement) .forEach((path: ASTPath) => { const name = getJSXName(path.node); if (name !== SOURCE_NAME) return; const attrs = path.node.attributes ?? []; const hasSpread = attrs.some(attr => attr.type === 'JSXSpreadAttribute'); if (hasSpread) { addCommentBefore( j, path, ` TODO: codemod could not safely resolve spread props — manually choose MenuLink or MenuButton.`, ); hasUnconvertedMenu = true; return; } const hasHref = attrs.some( (attr): attr is JSXAttribute => attr.type === 'JSXAttribute' && attr.name.type === 'JSXIdentifier' && attr.name.name === 'href', ); const hasOnClick = attrs.some( (attr): attr is JSXAttribute => attr.type === 'JSXAttribute' && attr.name.type === 'JSXIdentifier' && attr.name.name === 'onClick', ); if (hasHref && hasOnClick) { addCommentBefore( j, path, ` TODO: codemod could not safely resolve — manually choose MenuLink or MenuButton.`, ); hasUnconvertedMenu = true; return; } if (!hasHref && !hasOnClick) { addCommentBefore( j, path, ` TODO: codemod could not safely resolve — Menu without href or onClick. Choose MenuLink (add href) or MenuButton (add onClick).`, ); hasUnconvertedMenu = true; return; } const target = hasHref ? LINK_NAME : BUTTON_NAME; renameJSXElement(j, path, target); if (target === LINK_NAME) usedMenuLink = true; else usedMenuButton = true; didChange = true; }); // Import 정리. dsImports.forEach(path => { const specifiers = path.node.specifiers ?? []; const next: ImportSpecifier[] = []; let touched = false; specifiers.forEach(spec => { if (spec.type !== 'ImportSpecifier') { next.push(spec as ImportSpecifier); return; } if (spec.imported.name !== SOURCE_NAME) { next.push(spec); return; } // `Menu`를 제거하고 사용된 변형으로 교체. touched = true; if (hasUnconvertedMenu) { // 일부 변환 못한 호출이 남아있어 Menu도 유지가 필요할 수 있음. next.push(spec); } if (usedMenuLink) { next.push( j.importSpecifier(j.identifier(LINK_NAME)) as ImportSpecifier, ); } if (usedMenuButton) { next.push( j.importSpecifier(j.identifier(BUTTON_NAME)) as ImportSpecifier, ); } }); if (touched) { // 중복 제거 (같은 이름이 이미 import된 경우 대비). const seen = new Set(); path.node.specifiers = next.filter(spec => { if (spec.type !== 'ImportSpecifier') return true; const importedName = spec.imported.name; const localName = spec.local?.name ?? importedName; const key = `${importedName}::${localName}`; if (seen.has(key)) return false; seen.add(key); return true; }); didChange = true; } }); if (!didChange) return file.source; return root.toSource({ quote: 'single' }); } function getJSXName(node: JSXOpeningElement): string | null { const { name } = node; if (name.type === 'JSXIdentifier') return name.name; return null; } function renameJSXElement( j: JSCodeshift, path: ASTPath, newName: string, ) { const opening = path.node; (opening.name as JSXIdentifier).name = newName; // self-closing이 아니면 닫는 태그도 함께 갱신. const parent = path.parent.node; if (parent && parent.type === 'JSXElement' && parent.closingElement) { const closingName = parent.closingElement.name; if (closingName.type === 'JSXIdentifier') { (closingName as JSXIdentifier).name = newName; } } } function addCommentBefore( j: JSCodeshift, path: ASTPath, message: string, ) { // JSX element 자체의 부모(보통 JSXElement)를 찾아 그 앞에 코멘트를 삽입. let current: ASTPath | null = path; while (current && current.node && current.node.type !== 'JSXElement') { current = current.parent; } if (!current) return; const node = current.node; node.comments = node.comments ?? []; node.comments.push(j.commentLine(message, true, false)); } function addLeadingComment( j: JSCodeshift, root: Collection, message: string, ) { const program = root.get().node.program; const body = program.body; if (!body || body.length === 0) return; const first = body[0]; first.comments = first.comments ?? []; first.comments.push(j.commentLine(message, true, false)); }