지난 4주 차 회고 글에서 shadcn의 cli 부분에 대해 조사해 보았다. 조사하면서 공부했던 것을 토대로 shadcn add를 클론코딩(?) 해보았다.
※ shadcn add를 따라해본 것이지만, 전체적인 플로우만 따라하는 느낌으로 진행했습니다.
import { existsSync, promises as fs } from "fs";
import path from "path";
import { program, Command } from "commander";
import { getRegistry } from "./utils/registry.js";
import { execa } from "execa";
const add = new Command()
.name("add")
.description("add new component")
.argument("<component>")
.action(
...
먼저 add
명령어를 추가하기 위해 new Command()
를 이용하여 command를 생성하고 name, description을 설정해 준다. argument를 이용하여 CLI로 입력받은 값을 가져올 수 있다.
argument 변수에 있는 괄호가 대괄호([ ])라면 optional 하게 인자를 받을 수 있고, 꺾쇠(< >)라면 필수로 인자를 입력해야 한다.
.action(async (component) => {
const cwd = process.cwd();
// 존재하는 컴포넌트라면 json 형식의 데이터를 반환
// 존재하지 않는 컴포넌트라면 false를 반환
const newComponent = await getRegistry(component);
if (!newComponent) throw new Error(`There is no '${component}' component. Please check and retry.`);
action
을 이용하여 add command
로 실행될 action을 정의할 수 있다.
인자로는 argument로 입력받은 component에 대한 값을 가져올 수 있다. getRegistry
를 이용하여 shadcn에 component 이름과 일치하는 파일에 대해 get 요청을 한다. 만약 없는 파일에 대한 요청을 했다면 false를 반환하여 error를 던지게 했고, 있는 파일이면 해당 파일에 대한 json 파일을 가져오도록했다.
const { name, dependencies, devDependencies, files, type } = newComponent;
const [parentFolder, childFolder] = type.split(":");
const targetDir = path.resolve(cwd, parentFolder, childFolder);
if (!existsSync(targetDir)) {
// targetDir이 존재하지 않는다면
// 상위 디렉토리와 함께 폴더 생성
// { recursive: true }가 상위 디렉토리를 생성 해줌
await fs.mkdir(targetDir, { recursive: true });
}
newComponent
에 있는 값들을 구조 분해 할당으로 가져오고, 해당 컴포넌트를 가져다 놓을 폴더를 생성해 준다.
이때, existsSync
의 인자로targetDir
을 넘겨주어 해당 path가 존재하는지 확인을 한다. type이 components:ui라는 가정하에 targetDir
의 값은 다음과 같다.
/Users/유저이름/Desktop/작업디렉토리/components/ui
만약 targetDir
이 존재하지 않는다면 node js의 file system을 이용하여 해당 경로에 폴더를 생성해 준다. recursive를 true로 설정해 주어야 ui의 상위 폴더에 대해서도 폴더를 생성해 준다.
for (const file of files) {
const filePath = path.resolve(targetDir, file.name);
if (existsSync(filePath)) {
console.log(`${name} is component already exist.`);
continue;
}
await fs.writeFile(filePath, file.content);
}
가져온 component에 있는 files에 대해 반복문을 돌며 파일을 생성해 준다.
폴더 생성 때와 마찬가지로 filePath
를 생성하고 해당 경로의 존재 유무를 판단한다. 만약 있다면 console로 중복되는 파일이 존재함을 알려주고, 없다면 해당 위치에 file의 content를 넣는 작업을 한다. 이때 content는 컴포넌트의 내용물이다.
아래는 내용물의 일부이다.
'use client'
...
const Toggle = React.forwardRef<
React.ElementRef<typeof TogglePrimitive.Root>,
React.ComponentPropsWithoutRef<typeof TogglePrimitive.Root> &
VariantProps<typeof toggleVariants>
>(({ className, variant, size, ...props }, ref) => (
...
shadcn의 toggle을 확인해 보면 위와 같은 코드가 있는 것을 볼 수 있다.
if (dependencies?.length) {
// execa 라이브러리를 이용하여
// 해당 컴포넌트의 의존성 설치
await execa("pnpm", ["add", ...dependencies], cwd);
}
if (devDependencies?.length) {
// execa 라이브러리를 이용하여
// 해당 컴포넌트의 의존성 설치
await execa("pnpm", ["add", "-D", ...devDependencies], cwd);
}
마지막으로 가져온 component에 있는 dependencies와 devDependencies를 execa
를 통해 package.json
에 설치한다.
execa
에 대해 간단히 알아보자면, 첫 번째 인자는 패키지 매니저를 지정하고, 두 번째 인자로는 입력할 명령어들을 string[]의 형태로 추가한다. 마지막 세 번째 인자는 어떤 경로에 설치할지 지정하는 옵션이다.
program.addCommand(add).parse();
마지막으로 addCommand
와 parse
를 이용하여 add 커맨드를 인식하게 하고, 파싱 할 수 있게 해준다.
정말 간단하게만 구현해 보았는데, 예외 처리를 생각해서 만들려면 시간이 꽤 걸릴 것 같다..! 그래도 직접 만들며 많이 배워가는 유익한 시간이었다.
레포지토리
commander-practice