|
| 1 | +--- |
| 2 | +title: "[Javascript] : plop 라이브러리로 디자인 시스템 컴포넌트 파일을 템플릿화 해보자!" |
| 3 | +excerpt_separator: <!--more--> |
| 4 | +categories: |
| 5 | + - Javascript |
| 6 | + - plop |
| 7 | +tags: |
| 8 | + - Javascript |
| 9 | + - plop |
| 10 | +# 이미지 url (썸네일 필요한 경우 추가) |
| 11 | +image: /assets/img/thumbnail/javascript-logo.png |
| 12 | +# 기본 비노출 상태, 노출하고 싶은 경우 아래 옵션 제거 |
| 13 | +# published: false |
| 14 | +--- |
| 15 | + |
| 16 | +> 이 글로 얻을 수 있는 정보 |
| 17 | +> 1. plop를 이용한 파일 템플릿화 |
| 18 | +> 2. plop 라이브러리 설치 및 사용법 |
| 19 | +> 2. plop 템플릿 |
| 20 | +> 3. handlebars 템플릿 |
| 21 | +{: .prompt-tip } |
| 22 | + |
| 23 | +## 0. 개요 |
| 24 | +디자인 시스템 컴포넌트를 구성하면서 스토리북 관련 구글링 중에 파일을 템플릿화 하여 쉽게 생성할 수 있는 plop 라이브러리를 알게되어 디자인 시스템 파일 템플릿 방법을 알아보고 템플릿을 공유해보려 합니다. |
| 25 | + |
| 26 | +## 1. plop 라이브러리란? |
| 27 | +**[plop](https://www.npmjs.com/package/plop)**라이브러리는 개요에서 말했듯이 <span class="highlighting-underline">파일을 템플릿화 하여 쉽게 생성</span>할 수 있게 도와주는 라이브러리 입니다. |
| 28 | + |
| 29 | +plop은 프롬프트 라이브러리인 **[inquirer](https://www.npmjs.com/package/inquirer)**와 텍스트 형식을 생성하는 템플릿 언어를 사용해 템플릿을 만드는 **[handlebars](https://www.npmjs.com/package/handlebars)** 라이브러리로 만들어졌습니다. 즉, 프롬프트를 통해 정보를 입력받고 그 정보로 템플릿 언어로 템플릿을 만들어 파일을 생성해주는 것입니다. |
| 30 | + |
| 31 | +## 2. plop 라이브러리 설치 및 사용법 |
| 32 | +자세한 내용은 **[plop 라이브러리 공식 홈페이지](https://www.npmjs.com/package/plop)**에서 보는 게 더 낫기 때문에 설치 방법과 사용법을 간략하게 핵심만 알아보겠습니다. |
| 33 | + |
| 34 | +### 2-1. plop 라이브러리 설치 |
| 35 | +plop 라이브러리를 파일 만들 때만 사용하니, production 환경까지 반영되지 않아도 되기 때문에 dev로 설치해줍니다. |
| 36 | + |
| 37 | +```typescript |
| 38 | +// yarn |
| 39 | +yarn add -dev plop |
| 40 | + |
| 41 | +// npm |
| 42 | +npm install --save-dev plop |
| 43 | +``` |
| 44 | + |
| 45 | +### 2-2. plop script 등록 및 type 설정 |
| 46 | +<span class="highlighting-underline">plop을 사용하기 위한 script와 ESM(ECMAScript Modules)를 사용하기 위해 type을 지정</span>해줍니다. (CJS(CommonJS)를 사용하려면 type을 지정해주지 않아도 괜찮습니다.) |
| 47 | + |
| 48 | +> CJS(CommonJS), ESM(ECMAScript Modules)란? |
| 49 | +> |
| 50 | +> 파일 모듈화를 진행하기 위한 모듈 시스템을 일컫습니다. 따로 package.json에 설정하지 않으면 CJS가 기본입니다.<br/> |
| 51 | +> `CJS는 require / module.exports`를 사용하고, `ESM은 import/export` 문을 사용해서 파일 모듈화를 진행할 수 있습니다.<br/> |
| 52 | +> CJS/EMS와 package.json의 type, exports 관심이 있으시다면 관련 다음 글을 읽어봐도 좋습니다.<br/> |
| 53 | +> [토스 - CommonJS와 ESM에 모두 대응하는 라이브러리 개발하기: exports field](https://toss.tech/article/commonjs-esm-exports-field) |
| 54 | +{: .prompt-info } |
| 55 | + |
| 56 | +```typescript |
| 57 | +// package.json |
| 58 | +{ |
| 59 | + ... |
| 60 | + "type": "module", |
| 61 | + "scripts": { |
| 62 | + ... |
| 63 | + "plop": "plop" |
| 64 | + } |
| 65 | +} |
| 66 | +``` |
| 67 | + |
| 68 | +### 2-3. plopfile.js 작성 |
| 69 | +폴더의 root 경로에 `plopfile.js`를 만들고 다음과 같이 <span class="highlighting-underline">setGenerator를 사용하여 프롬프트를 구성하고 템플릿 파일을 이용해 파일들을 템플릿화</span> 할 수 있습니다. |
| 70 | + |
| 71 | +setGenerator의 prompots 관련 옵션은 **[inquirer](https://www.npmjs.com/package/inquirer)**에서 actions의 templateFile 관련 옵션은 **[handlebars](https://www.npmjs.com/package/handlebars)**에서 확인할 수 있습니다. |
| 72 | + |
| 73 | +더 자세한 내용은 다음에서 이어집니다. |
| 74 | + |
| 75 | +```typescript |
| 76 | +export default function (plop) { |
| 77 | + // setGenerator로 프롬프트 구성과 파일 템플릿화를 진행할 수 있습니다. |
| 78 | + plop.setGenerator('generator name', { |
| 79 | + description: 'generator description', |
| 80 | + prompts: [], // inquirer를 이용해 prompts를 구성할 수 있습니다. |
| 81 | + actions: [] // actions 관련 옵션은 plop에서 actions내부에 templateFile 관련해서는 handlebars에서 확인할 수 있습니다. |
| 82 | + }); |
| 83 | +}; |
| 84 | +``` |
| 85 | + |
| 86 | +### 2-4. plop 실행 |
| 87 | +script를 지정한 plop 명령어를 통해 구성한 plop을 실행하여 설정한 프롬프트를 보여주고 파일을 생성할 수 있습니다. |
| 88 | + |
| 89 | +```typescript |
| 90 | +// yarn |
| 91 | +yarn plop |
| 92 | + |
| 93 | +// npm |
| 94 | +npm plop |
| 95 | +``` |
| 96 | + |
| 97 | + |
| 98 | + |
| 99 | +## 3. plop 템플릿 공유 및 꿀팁 |
| 100 | +팀에서는 Next.js14를 사용하고 있으며, 만들고 있는 디자인 시스템 컴포넌트의 기본적인 파일 구조는 다음과 같이 이루어져 있어 필요한 부분은 수정하여서 사용하면 좋을 것 같습니다. |
| 101 | + |
| 102 | +```typescript |
| 103 | +Component |
| 104 | +ㄴindex.tsx |
| 105 | +ㄴtype.d.ts |
| 106 | +ㄴindex.modules.scss |
| 107 | +ㄴindex.stories.tsx |
| 108 | +``` |
| 109 | + |
| 110 | +### 3-1. plop파일 |
| 111 | +plopfile.js에서 눈에 띄는 부분과 관련 링크를 적어놓겠습니다. |
| 112 | + |
| 113 | +- `plop.setHelper`: handlebars에서 사용하기 위한 메서드 여기에서는 propmts 폴더 선택을 도와주는 라이브러리를 사용 - <https://handlebarsjs.com/guide/#custom-helpers> |
| 114 | +- `plop.setPrompt`: 사용자들이 plop을 이용해 만든 다양한 라이브러리를 등록해서 사용할 수 있음 - <https://github.com/plopjs/awesome-plop> |
| 115 | + |
| 116 | +위에서 <span class="highlighting-underline">handlebars 라이브러리는 {{ }} 내부에 있는 값들을 치환하여 파일을 만들어줄 수 있습니다.</span> handlebars 내부에서 사용할 수 있는 함수 조건문 등을 사용할 수도 있는데, 공식 문서가 잘 되어 있어 공식 문서를 참고하시면 되겠습니다. |
| 117 | + |
| 118 | +```javascript |
| 119 | +// plopfile.js |
| 120 | + |
| 121 | +import inquirer from "inquirer"; |
| 122 | +import inquirerDirectory from "inquirer-directory"; |
| 123 | + |
| 124 | +export default function (plop) { |
| 125 | + // hbs 템플릿(handlebars)에서 사용할 포함 여부 함수 정의 |
| 126 | + plop.setHelper("includes", function (arr, values) { |
| 127 | + const valueList = values.split(","); |
| 128 | + |
| 129 | + return valueList.some((value) => arr.includes(value)); |
| 130 | + }); |
| 131 | + |
| 132 | + // 폴더 선택 라이브러리 적용 |
| 133 | + plop.setPrompt("directory", inquirerDirectory); |
| 134 | + // prompt 생성 |
| 135 | + plop.setGenerator("design-system-ui", { |
| 136 | + description: "Create design system ui", |
| 137 | + prompts: [ |
| 138 | + // 폴더 선택(setPrompt에서 적용한 폴더 선택 라이브러리 사용) |
| 139 | + { |
| 140 | + type: "directory", |
| 141 | + name: "path", |
| 142 | + message: `1. 컴포넌트를 생성할 폴더를 선택해주세요 (⬆️ 버튼을 누르면 빠르게 선택(choose this directory) 할 수 있어요)`, |
| 143 | + basePath: "ui", |
| 144 | + }, |
| 145 | + // 컴포넌트 이름 입력 |
| 146 | + { |
| 147 | + type: "input", |
| 148 | + name: "name", |
| 149 | + message: "2. 컴포넌트를 이름을 입력해주세요", |
| 150 | + }, |
| 151 | + // 스토리북 옵션 선택 |
| 152 | + { |
| 153 | + type: "checkbox", |
| 154 | + message: "3. 스토리북 Meta에 사용할 옵션을 선택해주세요", |
| 155 | + name: "options", |
| 156 | + loop: false, |
| 157 | + choices: [ |
| 158 | + new inquirer.Separator("====== 기본 옵션 ======"), |
| 159 | + { |
| 160 | + value: "args", |
| 161 | + name: "args ", |
| 162 | + disabled: "컴포넌트 props 값 설정", |
| 163 | + }, |
| 164 | + new inquirer.Separator("====== 선택 옵션 ======"), |
| 165 | + { |
| 166 | + value: "storyHeight", |
| 167 | + name: "story.height (Story가 보여질 영역 높이 조절)", |
| 168 | + }, |
| 169 | + { |
| 170 | + value: "sourceCode", |
| 171 | + name: "source.code (Story에 보여질 source code 관련 설정)", |
| 172 | + }, |
| 173 | + { |
| 174 | + value: "argTypes", |
| 175 | + name: "argTypes (컴포넌트 props의 type관련 내용 설정)", |
| 176 | + }, |
| 177 | + { |
| 178 | + value: "renderOrDecorators", |
| 179 | + name: "render or decorators (Story 렌더링 마크업,스타일링,동작 제어)", |
| 180 | + }, |
| 181 | + ], |
| 182 | + }, |
| 183 | + ], |
| 184 | + // templateFile에서 prompt로 입력한 정보를 받아 파일을 생성 |
| 185 | + actions: [ |
| 186 | + // index.tsx 생성 |
| 187 | + { |
| 188 | + type: "add", |
| 189 | + path: "ui/{{path}}/{{pascalCase name}}/index.tsx", |
| 190 | + templateFile: "plop-templates/design-system-ui/Component.tsx.hbs", |
| 191 | + }, |
| 192 | + // type.d.ts 생성 |
| 193 | + { |
| 194 | + type: "add", |
| 195 | + path: "ui/{{path}}/{{pascalCase name}}/type.d.ts", |
| 196 | + templateFile: "plop-templates/design-system-ui/Type.d.ts.hbs", |
| 197 | + }, |
| 198 | + // index.module.scss 생성 |
| 199 | + { |
| 200 | + type: "add", |
| 201 | + path: "ui/{{path}}/{{pascalCase name}}/index.module.scss", |
| 202 | + }, |
| 203 | + // index.stories.tsx 생성 |
| 204 | + { |
| 205 | + type: "add", |
| 206 | + path: "ui/{{path}}/{{pascalCase name}}/index.stories.tsx", |
| 207 | + templateFile: "plop-templates/design-system-ui/Story.tsx.hbs", |
| 208 | + }, |
| 209 | + ], |
| 210 | + }); |
| 211 | +} |
| 212 | +``` |
| 213 | + |
| 214 | +### 3-2. handlebars 템플릿 파일 |
| 215 | +plop파일에서 정의한 `includes helper`를 사용하는 것을 볼 수 있고 **if문을 통하여 템플릿을 조건부로 적용**하였습니다. |
| 216 | + |
| 217 | +> handlebars에서 helper 사용법 |
| 218 | +> |
| 219 | +> 기본 적으로 **\{\{ 헬퍼이름 메서드인자 \}\}** 형식으로 사용하며, 조건문 안에 넣을 때는 괄호로 감싸서( **\{\{#if (includes options "storyHeight,sourceCode")\}\}** ) 사용하게 됩니다.<br/> |
| 220 | +> e.g. **\{\{ pascalCase name \}\}** : pascalCase는 handlebars에서 기본으로 제공하는 메서드 인데, prompt에서 받은 name 값을 pascalCase 메서드를 통해 pascalCase로 만들어줍니다. |
| 221 | +{: .prompt-info } |
| 222 | + |
| 223 | +#### Story.tsx.hbs |
| 224 | + |
| 225 | +```javascript |
| 226 | +import {{pascalCase name}} from "@design-system/ui/{{path}}/{{pascalCase name}}"; |
| 227 | +import type { Meta, StoryObj } from "@storybook/react"; |
| 228 | + |
| 229 | +/** |
| 230 | + * 여기에 해당 컴포넌트에 대한 설명을 적어주세요. 미기재 시 {{pascalCase name}} 컴포넌트의 JSDoc이 노출됩니다. |
| 231 | + * (Storybook에서 parameters.docs.description.component 보다 JSDoc을 권장합니다.) |
| 232 | + * @see {https://storybook.js.org/docs/api/doc-blocks/doc-block-description#writing-descriptions} |
| 233 | + */ |
| 234 | +const meta: Meta<typeof {{pascalCase name}}> = { |
| 235 | + component: {{pascalCase name}}, |
| 236 | + {{#if (includes options "storyHeight,sourceCode")}} |
| 237 | + parameters: { |
| 238 | + docs: { |
| 239 | + {{#if (includes options "storyHeight")}} |
| 240 | + story: { |
| 241 | + // (Optional) story가 보여질 영역 높이를 조절할 수 있습니다. |
| 242 | + height: "200px", |
| 243 | + }, |
| 244 | + {{/if}} |
| 245 | + {{#if (includes options "sourceCode")}} |
| 246 | + source: { |
| 247 | + /** |
| 248 | + * (Optional) |
| 249 | + * storybook에 노출되는 source code를 직접 작성할 수 있습니다. (dedent를 사용해 깔끔하게 작성하는 게 좋습니다.) |
| 250 | + * |
| 251 | + * code와 아래의 transform 둘 다 작성하면 transform은 무시됩니다. |
| 252 | + */ |
| 253 | + code: dedent` |
| 254 | + const [state, setstate] = useState(); |
| 255 | +
|
| 256 | + return ( |
| 257 | + <Component> |
| 258 | + <SubComponent/> |
| 259 | + </Component> |
| 260 | + );`, |
| 261 | + /** |
| 262 | + * (Optional) |
| 263 | + * storybook에 노출되는 source code를 변환할 수 있습니다. |
| 264 | + * |
| 265 | + * 합성 컴포넌트인 SubComponent를 표현하려고 Meta.component에 Component.SubComponent로 작성해주어도 |
| 266 | + * source code에는 SubComponent로 노출되기 때문에 합성 컴포넌트를 표현할 때 사용할 수 있습니다. |
| 267 | + */ |
| 268 | + transform: (code: string) => |
| 269 | + code.replaceAll("SubComponent", "Component.SubComponent"), |
| 270 | + }, |
| 271 | + {{/if}} |
| 272 | + }, |
| 273 | + }, |
| 274 | + {{/if}} |
| 275 | + {{#if (includes options "argTypes")}} |
| 276 | + argTypes: { |
| 277 | + props1: { |
| 278 | + // (Optional) 미기재 시 해당 prop의 JSDoc이 노출됩니다. |
| 279 | + description: "여기에 props1의 설명을 적어주세요.", |
| 280 | + table: { |
| 281 | + // description 아래 위치하며, type을 나타내줍니다 |
| 282 | + type: { |
| 283 | + summary: "default 값을 표시해 줄 수 있습니다.", |
| 284 | + detail: "detail은 summary를 누르면 노출됩니다.", |
| 285 | + }, |
| 286 | + /** |
| 287 | + * (Optional) |
| 288 | + * args에 props1이 있고 defaultValue가 정의되어 있지 않으면 args에 정의된 값이 default 값으로 노출됩니다. |
| 289 | + */ |
| 290 | + defaultValue: { |
| 291 | + summary: "default 값을 표시해 줄 수 있습니다.", |
| 292 | + detail: "detail은 summary를 누르면 노출됩니다.", |
| 293 | + }, |
| 294 | + /** |
| 295 | + * (Optional) |
| 296 | + * props1에 category를 정의하면 props1이 기존 위치가 아닌 토글로 된 category에 노출됩니다. |
| 297 | + * subcategory는 정의한 category 아래에 표시됩니다. |
| 298 | + * 합성 컴포넌트나, 중첩된 props를 표시하고 싶을 때 사용할 수 있습니다. |
| 299 | + */ |
| 300 | + category: "category", |
| 301 | + subcategory: "subcategory", |
| 302 | + // (Optional) Args Table에서 props1을 제거합니다. |
| 303 | + disable: true, |
| 304 | + // (Optional) Args Table에서 props1이 읽기 전용임을 나타냅니다. |
| 305 | + readonly: true, |
| 306 | + }, |
| 307 | + /** |
| 308 | + * (Optional) |
| 309 | + * Args Table에서 사용자 조작에 관한 설정을 넣을 수 있습니다. |
| 310 | + * 특정 type(select, radio 등..)의 경우에 options 값이 필요합니다. |
| 311 | + * @see {https://storybook.js.org/docs/api/arg-types#control} |
| 312 | + */ |
| 313 | + control: "select", |
| 314 | + options: ["option1", "option2"], |
| 315 | + }, |
| 316 | + }, |
| 317 | + {{/if}} |
| 318 | + // (Optional) Meta에서 args에 입력한 값은 Args Table에 default 값으로 노출됩니다. |
| 319 | + args: { |
| 320 | + props1: "여기에 props1 타입에 맞는 값을 입력해주세요", |
| 321 | + }, |
| 322 | + {{#if (includes options "renderOrDecorators")}} |
| 323 | + /** |
| 324 | + * (Optional) render or decorators |
| 325 | + * Storybook에 렌더링 될 컴포넌트에 추가로 마크업/스타일링을 하거나 동작 제어가 필요할 때 사용합니다. |
| 326 | + * |
| 327 | + * storybook 내 이벤트 발생 시 args를 변경할 때 useArgs Addon과 함께 사용하면 좋습니다. |
| 328 | + * @see {https://storybook.js.org/docs/writing-stories/args#setting-args-from-within-a-story} |
| 329 | + */ |
| 330 | + // 하나의 컴포넌트를 렌더링할 때 주로 사용됩니다. |
| 331 | + decorators: [ |
| 332 | + (Story, context) => { |
| 333 | + return <Story {...context} args={context.args} />; |
| 334 | + }, |
| 335 | + ], |
| 336 | + // 여러 개의 컴포넌트나 합성컴포넌트를 렌더링할 때 주로 사용됩니다. |
| 337 | + render: (args) => { |
| 338 | + return ( |
| 339 | + <> |
| 340 | + <Component> |
| 341 | + <SubComponent /> |
| 342 | + </Component> |
| 343 | + <Component> |
| 344 | + <SubComponent /> |
| 345 | + </Component> |
| 346 | + </> |
| 347 | + ); |
| 348 | + }, |
| 349 | + {{/if}} |
| 350 | +}; |
| 351 | + |
| 352 | +export default meta; |
| 353 | + |
| 354 | +type Story = StoryObj<typeof {{pascalCase name}}>; |
| 355 | + |
| 356 | +/** |
| 357 | + * 여기에 해당 Story에 대한 설명을 적어주세요 |
| 358 | + * Storybook에서 parameters.docs.description.story 보다 JSDoc을 권장합니다. |
| 359 | + * @see {https://storybook.js.org/docs/api/doc-blocks/doc-block-description#writing-descriptions} |
| 360 | + */ |
| 361 | +export const Default: Story = { |
| 362 | + args: { props1: "option1" }, |
| 363 | +}; |
| 364 | +``` |
| 365 | + |
| 366 | +#### Component.tsx.hbs |
| 367 | + |
| 368 | +```javascript |
| 369 | +import classNames from "classnames/bind"; |
| 370 | +import type { {{pascalCase name}}Props } from "@design-system/ui/{{path}}/{{pascalCase name}}/type"; |
| 371 | +import style from "@design-system/ui/{{path}}/{{pascalCase name}}/index.module.scss"; |
| 372 | + |
| 373 | +const cx = classNames.bind(style); |
| 374 | + |
| 375 | +function {{pascalCase name}}(props: {{pascalCase name}}Props) { |
| 376 | + return <div />; |
| 377 | +} |
| 378 | + |
| 379 | +export default {{pascalCase name}}; |
| 380 | +``` |
| 381 | + |
| 382 | +#### Type.d.ts.hbs |
| 383 | + |
| 384 | +```javascript |
| 385 | +export interface {{pascalCase name}}Props {} |
| 386 | +``` |
| 387 | + |
| 388 | +## 마치며 |
| 389 | +동료들과 개발할 때, 어느 정도 규칙을 만들며 반복되는 업무를 자동화하고 특정 부분들을 템플릿화 하는 것이 생산성을 높여줄 수 있다고 생각합니다. 이 plop 라이브러리뿐만 아니라 다양한 라이브러리를 잘 활용하면 생산성을 많이 높여줄 수 있을 것 같습니다. |
| 390 | + |
| 391 | +더 좋은 plop 템플릿을 구성하시거나 다른 좋은 방법이 있으시다면 다양한 피드백으로 공유해주시면 감사하겠습니다 🙏 |
0 commit comments