diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 00000000..3f95f919 --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1 @@ +* @1ilsang \ No newline at end of file diff --git a/.github/actions/netlify-preview/action.yml b/.github/actions/netlify-preview/action.yml new file mode 100644 index 00000000..d1a558a9 --- /dev/null +++ b/.github/actions/netlify-preview/action.yml @@ -0,0 +1,93 @@ +name: netlify-preview + +inputs: + NETLIFY_SITE_ID: + required: true + NETLIFY_API_TOKEN: + required: true + HEAD_COMMIT: + required: true + +runs: + using: composite + + steps: + # https://cli.netlify.com/ + - name: Netlify deploy + id: netlify-deploy + shell: bash + run: | + pnpm nf deploy \ + --dir out \ + --site ${{ inputs.NETLIFY_SITE_ID }} \ + --auth ${{ inputs.NETLIFY_API_TOKEN }} \ + --json \ + > deploy_output.json + + - name: Generate URL Preview + id: url-preview + shell: bash + # pnpm 샌드박스 로그가 json 파일에 상단에 추가되는 버그가 있음. + # 로그 삭제후 5번째 라인부터 JSON 파일 재생성 + run: | + tail -n +5 deploy_output.json > parsed_output.json + echo $(cat parsed_output.json) + NETLIFY_PREVIEW_URL=$(jq -r '.deploy_url' parsed_output.json) + echo "NETLIFY_PREVIEW_URL=$NETLIFY_PREVIEW_URL" >> "$GITHUB_OUTPUT" + + - name: Comment URL Preview on PR + # https://octokit.github.io/rest.js/v20 + uses: actions/github-script@v7 + env: + NETLIFY_PREVIEW_URL: ${{ steps.url-preview.outputs.NETLIFY_PREVIEW_URL }} + HEAD_COMMIT: ${{ inputs.HEAD_COMMIT }} + with: + script: | + const owner = context.repo.owner; + const repo = context.repo.repo; + + async function getIssueNumber() { + const result = await github.rest.repos.listPullRequestsAssociatedWithCommit({ + owner, + repo, + commit_sha: process.env.HEAD_COMMIT, + }); + const issueNumber = result.data[0]?.number; + return issueNumber; + } + + async function getExistPrevActionBot(issueNumber) { + const result = await github.rest.issues.listComments({ + owner, + repo, + issue_number: issueNumber + }); + const existPrevAction = result.data.find(comment => comment.user.login === 'github-actions[bot]'); + return existPrevAction; + } + + async function comment(){ + const issueNumber = await getIssueNumber(); + if (!issueNumber) { + console.log('No PR found for commit ' + process.env.HEAD_COMMIT); + return; + } + const prevActionBot = await getExistPrevActionBot(issueNumber); + const body = `Preview URL: ${process.env.NETLIFY_PREVIEW_URL}`; + if (prevActionBot?.id) { + await github.rest.issues.updateComment({ + owner, + repo, + comment_id: prevActionBot.id, + body + }); + } else { + await github.rest.issues.createComment({ + owner, + repo, + issue_number: issueNumber, + body + }); + } + } + comment(); diff --git a/.github/actions/nextjs-build-export/action.yml b/.github/actions/nextjs-build-export/action.yml new file mode 100644 index 00000000..9c0458a1 --- /dev/null +++ b/.github/actions/nextjs-build-export/action.yml @@ -0,0 +1,32 @@ +# https://docs.github.com/en/actions/creating-actions/creating-a-composite-action?platform=mac#creating-an-action-metadata-file +name: nextjs-build-export + +inputs: + e2e: + required: false + default: 'false' + +runs: + using: composite + + steps: + - name: Restore Next.js related caches + uses: actions/cache@v4 + with: + path: | + ${{ github.workspace }}/.next + ${{ github.workspace }}/out + key: ${{ runner.os }}-nextjs-store-${{ hashFiles('**/pnpm-lock.yaml') }}-${{ hashFiles('**.[jt]s', '**.[jt]sx', '**.md', '**.html', '**.png') }}-${{ inputs.e2e == 'true' && 'e2e' || 'default' }} + restore-keys: | + ${{ runner.os }}-nextjs-store-${{ hashFiles('**/pnpm-lock.yaml') }}- + id: cache-nextjs-build + + - name: Build and Export [default] + shell: bash + if: steps.cache-nextjs-build.outputs.cache-hit != 'true' && inputs.e2e == 'false' + run: pnpm build + + - name: Build and Export [e2e] + shell: bash + if: steps.cache-nextjs-build.outputs.cache-hit != 'true' && inputs.e2e == 'true' + run: pnpm e2e:build diff --git a/.github/actions/playwright-install/action.yml b/.github/actions/playwright-install/action.yml new file mode 100644 index 00000000..2f438597 --- /dev/null +++ b/.github/actions/playwright-install/action.yml @@ -0,0 +1,25 @@ +name: playwright-install + +runs: + using: composite + + steps: + # https://github.com/microsoft/playwright/issues/7249#issuecomment-1373375487 + - name: Get playwright version + shell: bash + run: | + echo "PLAYWRIGHT_VERSION=$(node -e "process.stdout.write(require('@playwright/test/package.json').version)")" >> $GITHUB_OUTPUT + id: playwright-version + + - name: Cache Playwright Browsers for Playwright's Version + uses: actions/cache@v4 + with: + # https://playwright.dev/docs/browsers#managing-browser-binaries + path: ~/Library/Caches/ms-playwright + key: ${{ runner.os }}-playwright-${{ steps.playwright-version.outputs.PLAYWRIGHT_VERSION }} + id: cache-playwright-browsers + + - name: Setup Playwright + shell: bash + if: steps.cache-playwright-browsers.outputs.cache-hit != 'true' + run: pnpm e2e:install diff --git a/.github/actions/pnpm-install/action.yml b/.github/actions/pnpm-install/action.yml new file mode 100644 index 00000000..c61a5064 --- /dev/null +++ b/.github/actions/pnpm-install/action.yml @@ -0,0 +1,32 @@ +name: pnpm-install + +runs: + using: composite + + steps: + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version-file: .nvmrc + + - name: Install pnpm + uses: pnpm/action-setup@v3 + with: + run_install: false + + - name: Get pnpm store directory + shell: bash + run: | + echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV + + - name: Setup pnpm cache + uses: actions/cache@v4 + with: + path: ${{ env.STORE_PATH }} + key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }} + restore-keys: | + ${{ runner.os }}-pnpm-store- + + - name: Install dependencies + shell: bash + run: pnpm install diff --git a/.github/workflows/code.yml b/.github/workflows/code.yml new file mode 100644 index 00000000..7109232d --- /dev/null +++ b/.github/workflows/code.yml @@ -0,0 +1,51 @@ +name: code + +on: + push: + branches: [main] + pull_request: + branches: [main] +permissions: + pull-requests: write + +jobs: + lint: + runs-on: macos-latest + steps: + - uses: actions/checkout@v4 + + - name: 🌱 Install pnpm + uses: ./.github/actions/pnpm-install + + - name: 🏁 Lint + run: pnpm lint + + build-export: + runs-on: macos-latest + steps: + - uses: actions/checkout@v4 + + - name: 🌱 Install pnpm + uses: ./.github/actions/pnpm-install + + - name: 🏗 Build and Export + uses: ./.github/actions/nextjs-build-export + + preview: + runs-on: macos-latest + needs: build-export + steps: + - uses: actions/checkout@v4 + + # 캐싱 된 값을 사용 + - name: 🌱 Install pnpm + uses: ./.github/actions/pnpm-install + - name: 🏗 Build and Export + uses: ./.github/actions/nextjs-build-export + + - name: 🌈 Netlify preview + uses: ./.github/actions/netlify-preview + with: + NETLIFY_SITE_ID: ${{ secrets.NETLIFY_SITE_ID }} + NETLIFY_API_TOKEN: ${{ secrets.NETLIFY_API_TOKEN }} + HEAD_COMMIT: ${{ github.event.pull_request.head.sha }} diff --git a/.github/workflows/e2e-reusable.yml b/.github/workflows/e2e-reusable.yml new file mode 100644 index 00000000..b5bfdaae --- /dev/null +++ b/.github/workflows/e2e-reusable.yml @@ -0,0 +1,55 @@ +# https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions +name: e2e-reusable + +on: + workflow_call: + inputs: + others: + type: boolean + default: false + dom-snapshot: + type: boolean + default: false + screen-snapshot: + type: boolean + default: false + +jobs: + i: + timeout-minutes: 60 + runs-on: macos-latest + env: + TZ: Asia/Seoul + + steps: + - uses: actions/checkout@v4 + + - name: 🌱 Install pnpm + uses: ./.github/actions/pnpm-install + + - name: 🥦 Install playwright + uses: ./.github/actions/playwright-install + + - name: 🏗 Build and Export + uses: ./.github/actions/nextjs-build-export + with: + e2e: 'true' + + - name: 🍄 Run Playwright [others] + if: inputs.others + run: pnpm e2e:others + + - name: 🧊 Run Playwright [dom-snapshot] + if: inputs.dom-snapshot + run: pnpm e2e:dom + + - name: 🩸 Run Playwright [screen-snapshot] + if: inputs.screen-snapshot + run: pnpm e2e:screen + + - uses: actions/upload-artifact@v4 + if: failure() + with: + name: playwright-report-$${{ inputs.others && 'others' || inputs.dom-snapshot && 'dom-snapshot' || inputs.screen-snapshot && 'screen-snapshot' || 'no-input-name' }} + path: playwright-report/ + retention-days: 10 diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml new file mode 100644 index 00000000..10b045df --- /dev/null +++ b/.github/workflows/e2e.yml @@ -0,0 +1,23 @@ +name: e2e + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + others: + uses: './.github/workflows/e2e-reusable.yml' + with: + others: true + + dom-snapshot: + uses: './.github/workflows/e2e-reusable.yml' + with: + dom-snapshot: true + + screen-snapshot: + uses: './.github/workflows/e2e-reusable.yml' + with: + screen-snapshot: true diff --git a/.github/workflows/jest.yml b/.github/workflows/jest.yml new file mode 100644 index 00000000..cd4f41e6 --- /dev/null +++ b/.github/workflows/jest.yml @@ -0,0 +1,19 @@ +name: jest + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + jest: + runs-on: macos-latest + steps: + - uses: actions/checkout@v4 + + - name: 🌱 Install pnpm + uses: ./.github/actions/pnpm-install + + - name: 🪤 Jest + run: pnpm jest diff --git a/.github/workflows/label-mailto.yml b/.github/workflows/label-mailto.yml new file mode 100644 index 00000000..6a5da799 --- /dev/null +++ b/.github/workflows/label-mailto.yml @@ -0,0 +1,36 @@ +name: label-mailto + +on: + issues: + types: [labeled] + +jobs: + send_notification: + runs-on: ubuntu-latest + + steps: + - name: Trim issue body to 3 lines + run: | + echo "${{ github.event.issue.body }}" | head -n 3 > trimmed_body.txt + echo "TRIMMED_BODY<> $GITHUB_ENV + while IFS= read -r line; do + echo "$line" >> $GITHUB_ENV + done < trimmed_body.txt + echo "EOF" >> $GITHUB_ENV + + - name: Send Email Notification + uses: dawidd6/action-send-mail@v3 + env: + TRIMMED_BODY: ${{ env.TRIMMED_BODY }} + with: + secure: true + server_address: smtp.gmail.com + server_port: 465 + # user credentials + username: ${{ secrets.EMAIL_USERNAME }} + password: ${{ secrets.EMAIL_PASSWORD }} + # Mail + subject: '[Issue] ${{ github.event.issue.title }}: ${{github.event.label.name}}' + body: "Issue URL: ${{ github.event.issue.html_url }}\n\nBody:\n${{env.TRIMMED_BODY}}" + to: 1ilsangc@gmail.com + from: 1ilsang.dev diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..2aa1b17f --- /dev/null +++ b/.gitignore @@ -0,0 +1,19 @@ +node_modules +build +dist +out +.next/ + +.DS_Store + +# TypeScript cache +*.tsbuildinfo + +# Playwright +/test-results/ +/playwright-report/ +/blob-report/ +/playwright/.cache/ + +# Jest +/coverage/ \ No newline at end of file diff --git a/.husky/pre-commit b/.husky/pre-commit new file mode 100755 index 00000000..c89cfb10 --- /dev/null +++ b/.husky/pre-commit @@ -0,0 +1 @@ +corepack pnpm lint diff --git a/.markdownlint.cjs b/.markdownlint.cjs new file mode 100644 index 00000000..1c974590 --- /dev/null +++ b/.markdownlint.cjs @@ -0,0 +1,18 @@ +module.exports = { + default: true, + MD001: false, + MD002: false, + MD004: { style: 'dash' }, + MD007: { indent: 2 }, + MD013: { line_length: 1300 }, + MD014: false, + MD024: false, + MD026: false, + MD029: false, + MD033: false, + MD034: false, + MD036: false, + MD041: false, + 'no-hard-tabs': false, + whitespace: false, +}; diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 00000000..593cb75b --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +20.16.0 \ No newline at end of file diff --git a/.prettierrc.cjs b/.prettierrc.cjs new file mode 100644 index 00000000..28da0602 --- /dev/null +++ b/.prettierrc.cjs @@ -0,0 +1,7 @@ +module.exports = { + semi: true, + singleQuote: true, + trailingComma: 'all', + arrowParens: 'always', + htmlWhitespaceSensitivity: 'strict', +}; diff --git a/.stylelintrc.cjs b/.stylelintrc.cjs new file mode 100644 index 00000000..46bdb13d --- /dev/null +++ b/.stylelintrc.cjs @@ -0,0 +1,13 @@ +module.exports = { + extends: 'stylelint-config-standard-scss', + rules: { + 'at-rule-no-unknown': null, + 'color-function-notation': null, + 'scss/at-rule-no-unknown': [ + true, + { + ignoreAtRules: ['tailwind'], + }, + ], + }, +}; diff --git a/LICENSE b/LICENSE new file mode 100644 index 00000000..f2a47cc7 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2022 1ilsang + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 00000000..9e71f8d9 --- /dev/null +++ b/README.md @@ -0,0 +1,5 @@ +# 1ilsang.dev + +[![Netlify Status](https://api.netlify.com/api/v1/badges/8d7af773-8d25-4dc6-b1a2-94eddd9fa667/deploy-status)](https://app.netlify.com/sites/1ilsang/deploys) + +안녕하세요. 개인 블로그 입니다. diff --git a/_posts/activity/geultto8-seminar.md b/_posts/activity/geultto8-seminar.md new file mode 100644 index 00000000..a5af80fa --- /dev/null +++ b/_posts/activity/geultto8-seminar.md @@ -0,0 +1,117 @@ +--- +title: '[글또 세미나] 모여봐요 오픈소스의 숲 발표 후기' +description: '번역 기여 방법과 코어 코드에 접근하는 방법' +url: 'geultto8-open-source-seminar' +tags: ['geultto', 'geultto8', 'seminar', 'open-source', 'eslint', 'translate'] +coverImage: 'https://github.com/1ilsang/dev/assets/23524849/a6b9e96a-d651-4854-a1a4-a05370dba890' +date: '2023-09-01T11:30:00.000Z' +ogImage: + url: 'https://github.com/1ilsang/dev/assets/23524849/a6b9e96a-d651-4854-a1a4-a05370dba890' +--- + +![cover](https://github.com/1ilsang/dev/assets/23524849/7f688c0e-1e5b-4c97-bffc-312aa35e9d35 'cover') + +> [발표 자료 PDF 다운로드](https://github.com/1ilsang/dev/files/12504345/_1ilsang.pdf) + +[글또](https://www.notion.so/ac5b18a482fb4df497d4e8257ad4d516)에서 프런트엔드 반상회를 하게 되었고 발표자를 구하여서 지원하게 되었다. + +작년 넥스터즈 활동을 마지막으로 외부 발표를 하지 않았는데 이번 기회에 다시 한번 공개적으로 하고 싶다고 생각해 지원하게 되었다. + +## 주제 선정 + +프런트엔드 반상회인 만큼 *프런트엔드 개발자에게 도움이 되는 내용*을 발표하고 싶었다. + +그래서 주제를 선정하는 도중 "내가 신입일 때 가장 듣고 싶었던 세션이 무엇일까?" 고민하게 되었고 결론은 "오픈소스 분석 방법"이였다. + +라인에서 처음으로 프런트엔드 커리어를 쌓을 때 받은 미션은 사내 동영상 라이브러리의 유지보수였다. `package.json`이 뭐 하는 건지도 잘 몰랐기 때문에 `UMD`, `CJS` 등은 너무나 생소했고 트리쉐이킹과 다양한 디바이스의 지원은 당시 나에겐 상당히 어려웠다. + +어쨋건 라이브러리였기 때문에 다른 오픈소스 라이브러리들은 어떻게 개발하고 있는지 틈틈히 분석하면서 늘 이런 오픈소스에 나도 기여하고 싶다고 생각했던것 같다. + +따라서 나는 초보자를 위한 오픈소스 기여 가이드 및 핵심 코드 진입 방법을 목표로 발표하기로 했다. + +## 준비 과정 + +내 세션은 두 가지 내용이 혼합되어 있다. + +1. 오픈소스 기여 가이드 +2. 핵심 코드 진입 방법 + +위의 내용을 통해 오픈소스 코드의 첫 진입점을 찾고 기여할 수 있도록 가이드를 주고자 했다. + +기여 방식을 설명하는 자리인 만큼 초보 개발자를 위해 익숙하면서도 흥미를 불러올 만한 내용으로 AtZ 친절하게 설명하고자 했다. + +![open source list](https://github.com/1ilsang/dev/assets/23524849/cc1d815d-f8a0-44e2-ac8c-cdad3406b71d 'l') + +수많은 오픈소스에서 어떤것을 목표로 할까 고민을했고 역시 내가 기여해 봤고 자주 사용하는 오픈소스 중에서 고르게 되었다. + +전체적인 구성은 기여 방법을 기준으로 3단계로 나눠 생각했다. + +1. [Easy] React.dev 번역 기여와 같이 코드부와 관련 없지만 라이브러리에 기여할수 있는 문서 작업. +2. [Medium] ESLint와 같은 도구의 CLI 진입 코드와 플러그인 구성 방법. +3. [Hard] Jotai와 같이 코드에서 사용되는 코어 라이브러리의 엔트리 진입 방법. + +세 단계를 구분한 이유는 Hard로 갈수록 핵심 코드의 동작 방식을 잘 이해해야 하기 때문이다. + +- 번역/문서화 기여는 라이브러리 코드에 진입하지 않기 때문에 비교적 쉽게 기여할 수 있다고 생각한다. 이 단계에서 포크나 PR 방법, 오픈소스 생태계 등을 설명할 예정이다. +- ESLint와 같은 개발 도구는 ESLint 자체보단 플러그인에 대해 분석하고자 한다. 코어 로직 주변에서 전반적인 코드 구조/방식을 이해할 수 있어 핵심 코어 기여보단 쉽다고 생각한다. 라이브러리의 확장과 플러그인 구조, CLI 등을 설명하고자 한다. +- Jotai와 같은 코어 라이브러리는 라이브러리도 잘 알아야 하고 같이 사용되는(React, Next 등) 코드와의 관계도 이해하고 있어야 하므로 어렵다고 생각한다. 여기는 가볍게 코드 진입과 빌드, 배포에 관해 설명하려고 한다. + +어느 정도 틀이 갖춰진 다음에는 빠르게 PPT 작업을 할 수 있었다. + +20분 발표이고 청중이 초보 개발자인만큼 가볍게 다양한 기여 예제를 보여주고자 했다. + +장표의 마지막에는 오픈소스에 지속해서 노출되는 방법을 추가하며 마무리했다. + +## 발표 당일 + +![timetable](https://github.com/1ilsang/dev/assets/23524849/50959cd4-cffb-4f46-ae2e-8ab76362202b 'l') + +![booth](https://github.com/1ilsang/dev/assets/23524849/fde40b79-9c4a-43ba-8c73-828023e3be15 'l') + +타임테이블이나 스티커의 디자인이 깔끔하게 잘 뽑혔다. 운영진분들의 노고가 느껴졌다. + +> 압도적 감사..! + +세션은 팀 스파르타에서 진행되었는데 내부가 탁 트이고 깔끔해서 세션하기에 좋은 장소였다. + +
+ +![start](https://github.com/1ilsang/dev/assets/23524849/0d163917-eb6d-4d9b-919b-30aa8e7cff5e) + +발표는 크게 떨리진 않았던 것 같다. + +스무스한 행사 진행에 힘입어 청중들도 잘 호응해 주셨다. + +혼자 떠드는 발표를 하고 싶지 않아서 청중과 눈을 마주친다거나 질문을 한다거나 여유 있게 행동하고 싶었는데 매우 쉽지 않았다.ㅋ + +장표를 넘기기 전에 다들 어떻게 듣고 계시는지 궁금해서 종종 청중들을 바라봤는데 다들 엄청나게 집중해 주셔서 압도적 감사함을 느꼈다. 그래서인지 자신감을 가지고 더 여유롭게 떨지 않으면서 발표할 수 있었다. + +확실히 열의 있는 분들과 함께 현장에서 발표하 는게 훨씬 좋고 인상적이라 느꼈다. [영상으로 발표](https://engineering.linecorp.com/ko/blog/ui-component-library-for-developers-with-typescript-storybook)할때는 오히려 힘들었다. + +
+ +![end](https://github.com/1ilsang/dev/assets/23524849/c4458081-6933-4d58-8345-2e940ad64425) + +20분 정말 짧았다. 마이크 들자마자 끝난 느낌이었다. + +10분간 QnA도 진행되었는데 생각보다 질문을 많이 해주셔서 조금 기뻤다. + +특히 인상 깊었던 질문 중 하나는 기여하고 싶은 라이브러리의 조건에 대한 질문이었는데 내가 회사에서 라이브러리를 개발하면서 중요하게 생각했던 부분을 말할 수 있었다. + +아무리 좋은 라이브러리라도 결국은 다른 개발자가 사용하기에 "편해야"한다. 나는 라이브러리는 DX가 무엇보다 중요하다고 생각한다. 따라서 문서화를 비롯해 IDE 단계에서의 편리함(코드의 간결함이나 타입 추론과 주석 등)이 기여하고 싶은 라이브러리의 조건이 되지 않을까 생각한다. + +소신것 준비한 만큼 후회 없이 발표했다. + +## 마치며 + +![flower](https://github.com/1ilsang/dev/assets/23524849/cb11a457-109a-443b-ace8-359b92a8fa97) + +연사자들에게 꽃을 나눠주셨는데 매우 민망(ㅋㅋ) 감사합니다. + +발표를 준비하면서 스스로 공부가 많이 되었다. + +앞으로도 꾸준히 공부해서 좋은 내용으로 다른 분들에게 공유할 수 있도록 노력하고자 한다. + +열정이 솟아난 8월이었다. + +> [발표 자료 PDF 다운로드](https://github.com/1ilsang/dev/files/12504345/_1ilsang.pdf) diff --git a/_posts/activity/inflearn/pangyo-03-dev-career.md b/_posts/activity/inflearn/pangyo-03-dev-career.md new file mode 100644 index 00000000..4e1b3aa5 --- /dev/null +++ b/_posts/activity/inflearn/pangyo-03-dev-career.md @@ -0,0 +1,135 @@ +--- +title: '인프런 판교 퇴근길 밋업 - 개발자 커리어 후기' +description: '한기용님의 실리콘밸리에서 인정받는 개발자의 특징 10가지 소개' +url: 'inflearn-meetup-03-dev-career' +tags: ['inflearn', 'pangyo', 'meet-up', 'seminar'] +coverImage: 'https://cdn.inflearn.com/public/files/courses/333675/5fb60a7a-1b19-4b41-80e6-c49851282dec/5_meetup_p.png' +date: '2024-06-08T09:30:00.000Z' +ogImage: + url: 'https://cdn.inflearn.com/public/files/courses/333675/5fb60a7a-1b19-4b41-80e6-c49851282dec/5_meetup_p.png' +--- + +![cover](https://cdn.inflearn.com/public/files/courses/333675/5fb60a7a-1b19-4b41-80e6-c49851282dec/5_meetup_p.png 'cover') + +학생 때는 개발자 모임에 많이 참여했었는데 요즘은 거의 나간 적이 없었다. + +마침 [인프런](https://inflearn.com/)에서 재밌어 보이는 모임을 개최하고 있었기에 신청하게 되었다. + +## 소개 + +![intro](https://github.com/1ilsang/dev/assets/23524849/bc2b8220-c393-4e71-b704-7301296f8397 'l') + +> [인프런 공식 행사 소개](https://www.inflearn.com/course/%ED%8C%90%EA%B5%90-%ED%87%B4%EA%B7%BC%EA%B8%B8%EB%B0%8B%EC%97%85-%EC%9D%B8%ED%94%84%EB%9F%B0-%EA%B0%9C%EB%B0%9C%EC%9E%90%EC%BB%A4%EB%A6%AC%EC%96%B4) + +행사는 판교 퇴근길 밋업의 세 번째 주제로 개발자 커리어를 다뤘다. + +연사는 [27년차 실리콘밸리 개발자의 인생 이야기](https://www.youtube.com/watch?v=nLL409se8sM)로 유명한 한기용님이다. + +영상을 보면서 흥미롭다고 느꼈는데 직강을 볼 수 있다는 소식에 설레는 마음으로 신청했던 기억이 난다. + +"실리콘밸리에서 인정받는 개발자의 특징 10가지"라니 듣지 않을 수가 없었다. + +## 행사 + +![goods](https://github.com/1ilsang/dev/assets/23524849/9e946eb0-5e99-4486-9861-e7865b7e9fb1) + +> 개발자 행사의 꽃은 굿즈가 아닐까? + +조금 일찍 갔기 때문에 샌드위치 먹으면서 행사 시작까지 사람들 구경했다. + +
+ +![main-speech](https://github.com/1ilsang/dev/assets/23524849/f6edab52-c2b2-4c7c-ac0c-f2e552a4ac03 'l') + +본격적으로 세션이 시작되었고 기용님 본인 소개가 시작되었다. + +지금까지 쌓아오신 커리어가 다채롭다고 느껴졌다. 그렇기에 이렇게 연사로 계신 걸까? 나도 다양한 환경을 경험해 보고 싶다고 생각했다. + +세션은 전반부와 후반부로 나뉘어서 진행되었다. + +### 커리어를 바라보는 관점 + +전반부는 커리어 어떻게 이어 나갈지에 대한 내용이었다. 내가 공감이 많이 갔던 부분은 아래와 같다. + +
+ +1. `많은 회사를 다녀봐라` + - 어떤 매니저가 나랑 잘 맞는지 찾아야 한다 +2. `현재에 충실하기` + - 불안하니까 선행학습을 한다. 필요하지 않은 학습을 하지 마라 +3. `서포터를 잘 만나기` + - 무언가 추진할 때 지지해 줄 사람이 있어야 한다 + +
+ +어떤 매니저/동료를 만나느냐에 따라 성장의 곡선과 마음의 상처가 달라지는 것을 겪기도 하고 보기도 했다. + +본인의 잘못이라고 책망하는 경우가 많은데 시스템적인 문제 혹은 그냥 문화가 안 맞는 것일 수도 있다. + +나의 경우는 주변에서 칭찬하면 더 열심히 하는 경향이 있는데, 최근 특히나 서포터들의 힘을 많이 느낀다(😭😭😭👏👏). + +### 개발자로서 생각해 보면 좋을 10가지 + +후반부는 개발자로서 생각해 보면 좋을 10가지를 소개해 주셨다. + +
+ +1. 기본기 +2. 학습 능력 +3. 의사소통 +4. 문제정의 +5. 시간 추정 +6. 운영 고려 코드 작성 +7. 서비스 사고 대처 +8. 결과 지향 +9. 영향력 +10. 리더 vs 전문가 + +임팩트(결과)를 어떻게 낼 것인지에 대한 고민이 핵심이고 그것을 위한 스킬들을 설명해 주셨다고 생각된다. + +특히 처음부터 끝까지 강조하신 "결과 지향적 개발자"는 내가 가져야 할 핵심 포인트라 느꼈다. + +### Q&A + +![intro](https://github.com/1ilsang/dev/assets/23524849/1f1aeed6-4df4-4d33-8e14-bed3409b492f 'l') + +세미나가 끝나고 Q&A 시간이 주어졌다. 흥미로웠던 질문 두 가지를 가져와 봤다. + +`"해고에 대한 걱정은 없으신지?"` + +- 한국에서는 해고가 불명예의 느낌이지만 밸리에서는 누구나 해고당한다. +- 해고 패키지를 잘 받도록 노력하자. 첫 번째 레이오프는 많이 챙겨주므로 오히려 먼저 나가는 게 좋을 수 있다. + +`"나에게 맞는 것, 맞지 않는 것을 어떻게 구분하셨고 받아들이셨나요?"` + +- 내 매니저가 어떤 사람인가를 많이 봤다. +- 매니저를 잘 만나야 이후의 가치 판단이 된다. + +### 네트워킹 + +모든 세션이 끝나고 네트워킹이 진행되었다. + +네트워킹 시간은 사전 설문조사의 내용을 기반으로 관심도가 비슷한 사람들과 묶어주셨다. + +잡담을 많이 했는데 시간 가는 줄 몰랐다. 시간이 부족할 정도. + +## 마무리 + +
+ +book +letter + +
+ +기용님이 최근 출판하신 [실패는 나침반이다](https://product.kyobobook.co.kr/detail/S000212569197) 도서를 지참해서 오면 친필 사인을 해주는 이벤트가 있었다. + +기용님에 대한 개인적인 관심이 컸기 때문에 얼른 사인받으러 갔다. + +커리어를 긴 호흡으로 바라보라는 점에 동의하고 있다. 나는 너무 조급한 게 아닐까? + +조금 더 적극적으로 삶을 가꾸고 나를 사랑해야겠다. + +오랜만의 개발자 모임에서 에너지를 많이 받았다. 앞으로도 종종 찾아다녀야겠다. + +Love yourself. diff --git a/_posts/activity/junction2023.md b/_posts/activity/junction2023.md new file mode 100644 index 00000000..4978c838 --- /dev/null +++ b/_posts/activity/junction2023.md @@ -0,0 +1,114 @@ +--- +title: 'Junction Asia 2023 참여 후기' +description: '부산 벡스코에서 2박 3일간 정션 해커톤에 참여했다' +url: 'junction2023' +tags: ['activity', 'hackathon', 'junction2023', 'busan'] +coverImage: 'https://github.com/1ilsang/dev/assets/23524849/4797d67e-2599-42ec-b653-329a40cf81e6' +date: '2023-08-23T10:58:06.952Z' +ogImage: + url: 'https://github.com/1ilsang/dev/assets/23524849/4797d67e-2599-42ec-b653-329a40cf81e6' +--- + +![cover](https://github.com/1ilsang/dev/assets/23524849/44f4e6a0-d7d7-4ed9-9e0a-110caba63c9b 'cover') + +작년 2022 정션에 참여했던 친구들의 피드백이 상당히 좋았기 때문에 올해는 꼭 참여해 보고 싶었던 해커톤이다. + +모집 포스터가 올라오자마자 일정 확인 후 바로 휴가 썼다ㅋ. 가보자고! + +## 지원 과정 + +나는 사전에 팀을 구성해서 지원했는데 개인으로도 지원할 수 있다. + +팀으로 지원할 경우 디자이너, 개발자, 기획자가 각각 최소 한 명 이상 5인으로 구성되어야 한다. + +선발 과정은 서류전형 한 번으로 이루어져 있다. + +간단한 자소서와 티셔츠 사이즈 등의 설문을 완료하면 지원이 끝난다. + +![pass](https://github.com/1ilsang/dev/assets/23524849/a49950da-894c-46b3-8b53-8505e0d5dc49) + +다행히도 합격 발표가 났다! + +## 부산 도착 + +![entry](https://github.com/1ilsang/dev/assets/23524849/e5d1cf04-7c2d-4729-a81d-969b5a2d95d7 'l') + +작년에는 숙소 제공도 있었는데 이번엔 없었다. 파트너사도 작년과는 많이 달라져서 조금 아쉬웠다. + +그래도 해커톤 자체가 거의 3년 만에 참여하는 거라 상당히 들뜬 마음으로 부산에 갈 수 있었다. + +해커톤은 금~일 2박 3일간 진행되었고 접수는 오후 6시부터였다. 접수 전에 스폰서와의 사전 미팅 시간도 있는데 간단한 네트워킹 자리다. + +공식 언어가 영어이기 때문에 겁을 많이 먹었는데 대부분이 한국인이라 큰 어려움은 없었다. + +## 첫날: 아이데이션 + +![start](https://github.com/1ilsang/dev/assets/23524849/8a9fa7e1-e5af-4523-b45c-588ab03f0a38 'l') + +첫날에는 트랙 공개 및 첫 번째 미션이 주어졌다. + +트랙은 아래의 5가지였는데 잘 기억이 안 나서 핵심 주제는 다를 수 있다. 뉘앙스만 봐주시길 + +1. 공공/빅데이터를 활용한 사회문제 해결 +2. 로봇이 만드는 음식의 인식 개선 +3. 전자 라벨 솔루션 +4. 관광 이동 수단 개선? 관광의 재미 솔루션 +5. 배달 음식 생태계? 구조 개선 + +미션은 트랙을 선정하고 어떤 것을 개발할지 기획해서 제출하면 됐다. + +우리는 3번 전자 라벨을 선택했고 상품에만 사용되는 전자 라벨을 사원증에도 사용할 수 있도록 하여 시장을 넓히는 것을 목표로 기획했다. + +
+ +![event](https://github.com/1ilsang/dev/assets/23524849/c2e87507-3ec1-4fdf-99e0-5c55a2dd2153 'l') + +해커톤의 또 다른 재미는 사이드 부스인데 정션은 부스 컨셉들이 재밌었다. 빙고 채우는 재미가 있었음. + +구석 한쪽에 빈백을 쭉 설치해 놓아서 피곤할 때 리차지하고 올 수 있었다. + +다들 여기서 떨면서 잠들었는데 매우 슬픔이었다. + +## 둘째 날: 개발 + +![web](https://github.com/1ilsang/dev/assets/23524849/593eddcf-6435-4504-814c-290b1efbe95b 'l') + +우리는 전자 라벨을 사원증으로 확장하는 것을 목표로 기획을 잡았기 때문에 전자 라벨에서 회사의 다양한 정보를 얻을 수 있도록 하고자 하였다. + +따라서 전자 라벨과 연동된 CMS 페이지를 개발하고 페이지에서 라벨의 색, 이미지, 문구 등을 수정할 수 있게 하고 회의 스케줄을 받을 수 있도록 하였다. + +> + +스폰서 API를 활용해 라벨과 통신했고 CMS 페이지는 Vite, React, Jotai, React-Query로 구성했다. + +생각보다 페이지 찍는 게 시간이 좀 걸렸다. 역시 CSS는 너무 어려운 것임 + +![spa](https://github.com/1ilsang/dev/assets/23524849/4dbfdaf6-5780-4227-95e0-93e6d91492bd) + +밤에는 정션에서 제공해 준 센텀 스파권으로 찜질방에 갔는데 진짜 너무 좋아서 그대로 쭉 있고 싶었다. + +## 셋째 날: 발표 + +![production](https://github.com/1ilsang/dev/assets/23524849/72613a5d-883b-4438-836c-c1d65faf644f 'l') + +위의 CMS 페이지에서 문구나 이미지 등을 설정해 저장하면 전자 라벨에 업데이트가 되게 개발했다. 찌그러지지 않고 잘 나와서 참 다행이었다. + +역시 갓자이너 + +
+ +![presentation](https://github.com/1ilsang/dev/assets/23524849/2041f4cb-4c1b-43ec-bbc0-8f1bccc28e74) + +해당 주제의 스폰서분들 앞에서 구현된 걸 기준으로 시현하는데 영어로 말해야 해서 진짜 지옥이었다. 가장 힘든 순간이었다(-\_-). + +발표 이후 여유 시간 동안 옆 팀 외국인들이랑 친해졌는데 이 부분이 좀 인상적이었다. 카이스트 재학 중인 외국인 5명이었는데 엄청 긱한 느낌이었다. 대화에 왜?라고 의문을 많이 가지는데 나도 다시 한번 생각해 볼 수 있어서 흥미롭게 대화할 수 있었다. + +## 마무리 + +오랜만에 밤새면서 빠르게 작업하니까 재밌었다. 긴 재택근무 간에 떨어진 열정을 다시 채운 느낌이었다. + +기회가 된다면 계속 꾸준히 해커톤에 참여하고 싶다. + +아참 침낭이랑 후드 챙겨갔는데 이거 없었으면 진짜 고통스러울 뻔했다. 진짜 에어컨이 계속 나오기 때문에 너무 추웠다. + +좋은 경험이었다. 그럼, 이만! diff --git a/_posts/activity/mdn/yari-content-ko.md b/_posts/activity/mdn/yari-content-ko.md new file mode 100644 index 00000000..19d93d56 --- /dev/null +++ b/_posts/activity/mdn/yari-content-ko.md @@ -0,0 +1,147 @@ +--- +title: '@mdn/yari-content-ko Organizer 합류 여정' +description: 'Mozilla Developer Network는 어떤 곳일까?' +url: 'mdn-ko-organizer' +tags: ['mdn', 'mozilla', 'open-source'] +coverImage: 'https://github.com/1ilsang/dev/assets/23524849/13851fba-2198-4113-aae2-8e6aedc05e2e' +date: '2024-04-13T08:21:06.718Z' +ogImage: + url: 'https://github.com/1ilsang/dev/assets/23524849/13851fba-2198-4113-aae2-8e6aedc05e2e' +--- + +![cover](https://github.com/1ilsang/dev/assets/23524849/8dba134c-4d8a-431e-b08b-ef37257b2ff9 'cover') + +![mdn local](https://github.com/1ilsang/dev/assets/23524849/d81fe59a-9c77-4c96-a235-c95ae48d23a0 'l') + +최근 [@mdn/yari-content-ko](https://developer.mozilla.org/en-US/docs/MDN/Community/Contributing/Translated_content#korean_ko) 팀에 합류하게 되었다. + +오픈소스 프로젝트의 방향성을 계획하고, 리뷰어로 활동하는 것은 처음이었기에 들뜬 마음으로 임할 수 있었다. + +이 글을 통해 MDN 및 나의 합류 과정을 정리해 보려고 한다. + +## Index + +- [MDN?](#mdn) +- [합류 여정](#합류-여정) +- [합류 후](#합류-후) +- [올해 목표](#올해-목표) +- [마무리](#마무리) + +## MDN? + +![mdn readme](https://github.com/1ilsang/dev/assets/23524849/ddd6058a-4e20-4033-abdd-22aac50a5e55 'l') + +> https://github.com/mdn + +MDN은 Readme에 그 목적이 잘 나타나 있다. + +MDN 웹 문서는 CSS, HTML, JavaScript, Web API를 비롯한 웹 플랫폼 기술을 문서화하는 오픈소스 프로젝트이다. + +
+ +![image](https://github.com/1ilsang/dev/assets/23524849/46cddd6a-8dd8-40a4-a376-f936e4f236eb 'l') + +[MDN 사이트](https://developer.mozilla.org/)에 들어가면 방대한 기술 문서를 확인할 수 있다. + +### 임무 + +MDN의 임무는 "더 나은 인터넷을 위한 청사진을 제공하고 새로운 세대의 개발자와 콘텐츠 제작자가 이를 구축할 수 있도록 지원하는 것"이라고 되어 있다. + +해당 임무에 걸맞게 MDN 문서들은 다양한 웹 플랫폼 기술의 올바른 사용법과 해석을 하기 위해 노력하고 있다. + +### 역사 + +MDN은 2005년에 시작되어 문서 전체가 오픈소스로 운영되어 누구나 참여할 수 있는 프로젝트이다. + +초창기에는 모든 문서가 SQL 데이터베이스에 존재하고 [WYSIWYG](https://ko.wikipedia.org/wiki/%EC%9C%84%EC%A7%80%EC%9C%84%EA%B7%B8) 편집기로 변경했다. 이때는 한국 로케이션이 활성화 안되어 있었기 때문에 번역 문서에 "역자주" 등 주관적 의견이 많이 추가되어 있었다. 이는 긍정적인 면도 있지만 전체적으론 통일성이 부족해지고 각 문서의 품질이 떨어지기도 했다. + +2020년 기존 문서화 툴을 [yari](https://github.com/mdn/yari)로 변경하면서 git을 통한 체계적인 기여와 통일성 있는 문서로 발전하게 되었다. + +> 관련 내용: [Welcome Yari: MDN Web Docs has a new platform](https://hacks.mozilla.org/2020/12/welcome-yari-mdn-web-docs-has-a-new-platform/) + +이후 2021년 4월 `yari-content-ko`팀이 창설되면서 [한국 로케일도 활성화 되었다](https://egas.tistory.com/16). 이때부터 한국 MDN 문서도 체계적인 리뷰 시스템이 존재하게 되었다. + +## 합류 여정 + +이제 나의 썰을 조금 풀어보려고 한다. + +### 문서화에 대한 관심 + +![webpack blog post](https://github.com/1ilsang/dev/assets/23524849/befec4ad-700e-4662-be10-9074fb44e8c4 'l') + +2021년에 [번역 오픈소스 기여 가이드](https://blog.naver.com/1ilsang/222517766844) 글을 작성한 적이 있다. + +본문에서 언급되어 있듯 나는 오픈소스에 기여하고 싶었지만 기술 이전에 영어에 대한 부족함을 많이 느꼈다. 어쩌면 이것이 문서화에 대한 열망으로 표출되었다고 생각한다. + +당시 내가 재직 중이던 [LINE+에서 Webpack 한글화 작업이 진행](https://github.com/line/webpack.kr)되고 있었다. + +운이 좋았다고 생각된다. 팀원들의 기여 과정을 어깨너머로 보면서 하고 싶다고 느꼈고 실제로 조금씩 기여하기 시작했다. + +이때의 경험이 상당히 좋았기 때문에 이후 [React.dev](https://github.com/reactjs/ko.react.dev) 및 [MDN](https://github.com/mdn/translated-content)에도 조금씩 기여하게 되었다. + +### 사내 오픈소스 스프린트 참여 + +사내 DevRel 팀에서 오픈소스 기여 행사를 열었다. 이때 MDN 문서 번역 프로젝트가 있어 참여해 본격적으로 번역 기여를 하기 시작했다. + +이때 PR을 꽤 열심히 날려서 기여 1등으로 행사를 마무리했다. + +### 온보딩 과정 진행 + +행사 이후에도 MDN에 꾸준히 기여하던 중 운이 좋게도 `yari-content-ko` 팀원 제안 메일을 받게 되었다. + +내 대답은 당연히 YES였기 때문에 바로 온라인 티타임을 가졌다. 상당히 친절하게 맞아주셔서 감동이었다. + +![onboarding](https://github.com/1ilsang/dev/assets/23524849/ba4afedd-de90-421f-86f5-2fdb4e585a16) + +이후 [본격적인 리뷰어 온보딩 과정이 시작](https://github.com/mdn/translated-content/issues/18056)되었고 오픈소스답게 공개적으로 이슈를 생성해 과정을 전체 공유했다. + +![pr](https://github.com/1ilsang/dev/assets/23524849/36d21f62-9393-4944-aa58-6334d92c5ab8) + +다행히 무사히 과제를 끝낼 수 있었고 본격적으로 리뷰어로 활동하게 되었다. + +## 합류 후 + +`@mdn/yari-content-ko` 팀은 MDN 한국 문서에 대한 전체 권한을 가지고 있다. 기여 PR 리뷰와 유지보수 및 한국 지역 활성화에 대한 고민을 함께 하고 있다. + +팀에 합류하면서 크게 3가지 달라진 점이 있었다. + +### 정기 회의 + +정기 회의를 통해 전체적인 방향성에 대한 싱크를 맞추고 PR 리뷰에 이상이 없는지 등 검증하는 시간을 가졌다. + +### 공개 논의 + +![public discussion](https://github.com/1ilsang/dev/assets/23524849/be2b4218-3354-4f53-b8d3-602bffb0a3ee) + +> https://github.com/orgs/mdn/discussions/655 + +문서 번역 리뷰나 프로젝트에 대한 의견 제시 등 공개적인 논의를 함께 이야기하게 되었다. + +### 리뷰어 활동 + +![reviewer action](https://github.com/1ilsang/dev/assets/23524849/179ee6ef-7a65-47f0-be65-f0706627250b) + +아마도 가장 크게 달라진 부분이라 생각한다. 컨트리뷰터에서 리뷰어가 되면서 기여해 주신 PR을 검토하고 있다. + +이 부분이 꽤 까다롭지만 보람을 느끼고 있다. 기여자의 열정이 식지 않도록 빠르고 친절하게 응답하려고 노력하고 있다. + +이모지의 힘이 크다고 느끼고 있다. 하트 감사합니다. + +## 올해 목표 + +![CSS Goal](https://github.com/1ilsang/dev/assets/23524849/91d2c86d-31a9-4915-96fb-1f4cd47d1a10 'l') + +팀원이 기여 목표를 세우는 것을 보고 감명받아 나도 세웠다. + +1. CSS 한국어 번역 50%까지 올리기 +2. 번역 자동화 스크립트 추가 + +현재는 번역 리뷰 시 [Glossary](https://github.com/mdn/translated-content/blob/main/docs/ko/guides/glossary-guide.md)를 수동으로 확인하고 있다. 이 부분을 자동화하고 CSS 번역을 꾸준히 해보려고 한다. + +## 마무리 + +번역은 오픈소스 입문의 좋은 시작점이라 생각한다. + +그렇기 때문에 리뷰어로서 사명감을 느끼고 있다. 오픈소스를 시작하려는 분들이 꾸준히 기여하고 생태계를 끌어 나갈 인재로 성장할 수 있도록 좋은 경험을 주고 싶다. + +MDN 문서 번역에 관심이 생겼다면 [첫 기여자들을 위한 안내서](https://github.com/mdn/translated-content/blob/main/docs/ko/README.md)를 참고해 주시길 바란다. diff --git a/_posts/activity/remote-work/bali-remote.md b/_posts/activity/remote-work/bali-remote.md new file mode 100644 index 00000000..18c6b787 --- /dev/null +++ b/_posts/activity/remote-work/bali-remote.md @@ -0,0 +1,267 @@ +--- +title: '발리 한 달 리모트 워크 후기' +description: '발리 한 달 살기 얼마나 낭만 있을까?' +url: 'bali-remote-work' +tags: ['activity', 'bali', 'remote-work'] +coverImage: 'https://github.com/1ilsang/dev/assets/23524849/b9834b4f-a495-43df-ace7-bc8784a6d6f9' +date: '2023-12-30T08:21:16.971Z' +ogImage: + url: 'https://github.com/1ilsang/dev/assets/23524849/b9834b4f-a495-43df-ace7-bc8784a6d6f9' +--- + +![cover](https://github.com/1ilsang/dev/assets/23524849/b9834b4f-a495-43df-ace7-bc8784a6d6f9 'cover') + +## 시작 계기 + +작년 [제주 한 달 리모트 워크 후기](/posts/jeju-remote-work)가 좋았어서 해외에서 한 달 리모트를 해보자고 결심했다. + +마침 회사에서 해외 리모트를 허용했기 때문에 또 언제 경험해 볼까 싶어 신청하게 되었다. + +다양한 선택지가 있었는데, 나는 발리를 선택하게 되었다. + +사실 그냥 서핑이 하고 싶었다. + +## 알아보기 + +### 면적 + +![map](https://github.com/1ilsang/dev/assets/23524849/e2bffe3f-5ed6-4a0c-9703-a2602cd50258) + +발리는 인도네시아에 있는 섬으로, 제주도의 약 3배에 해당한다. + +또한 [세계 1위의 허니문 여행지](https://www.chosun.com/culture-life/culture_general/2023/02/01/VPMDDNIARRGQ3DLNFD7QLDW6HI/)이기도 하다. + +### 시차 + +발리는 서울보다 한 시간 느리다. 서울에서 오후 3시라면 발리에서는 오후 2시이다. + +### 날씨 + +![강수량](https://github.com/1ilsang/dev/assets/23524849/a0dcf98f-d504-478a-840c-21ede6bca068) + +4월에서 10월이 비가 적게 오는 건기로 보고있다. 대부분의 여행자 사이트에서는 6월에서 9월을 여행 일자로 추천하고 있다. + +따라서 나는 2023.07.03 ~ 2023.07.30 7월 한 달간 다녀오기로 했다. + +서울과 비교해 보면 온도 자체는 더 더운 편이었지만 습하지 않아 상당히 쾌적했다. + +### 종교 + +인도네시아 인구의 90%는 이슬람을 믿지만 발리는 90%가 힌두교를 믿는다. + +발리는 신들의 섬이라 불릴 만큼 다양한 사원이 마을 곳곳에 있어 거리 어디에나 [차낭 사리(Canang Sari)](https://brunch.co.kr/@clearncool/137)라는 제물을 바친다. + +관광객임을 감안해도 한 달간 종교 문제로 갈등이 있지는 않았다. + +### 물가 + +![example](https://github.com/1ilsang/dev/assets/23524849/9af593bd-4754-4743-b39d-0930b4154f19) + +18,000루피아가 한화로 1,500원 정도인데, 이 돈이면 나시고랭 하나를 먹을 수 있다(ㄷㄷ). + +### 항공편 + +인천 -> 방콕 -> 발리 -> 코타키나발루 -> 인천으로 총 `54만원`이었다. + +### 숙소 + +짐바란에서 한 달 1층 독채 에어비엔비 `60만원`에 지냈다. + +### 전기 및 통신 + +카페뿐만 아니라 관광지의 와이파이는 한국과 비교해도 잘되는 편이다. 다만 전기가 잘나간다(인도네시아는 전력 부족 국가다). 이와 관련된 에피소드는 아래에서 풀겠다. + +LTE가 느리다고 느꼈다. 나는 4G 유심을 샀는데 핫스팟 무지하게 느렸다. + +### 교통 + +![traffic](https://github.com/1ilsang/dev/assets/23524849/03253b0b-736e-4080-94ad-9a8601da7e9a) + +발리에서 자동차 렌트는 도심 외곽이 아니면 비추다. 오토바이가 훨씬 빠르고 잘 되어있다. + +고잭/그랩 굿 + +## 한 달간의 여정 + +
+ surf-interior + delivery-food + bibycle +
+ +도착 당일 + +- 늦은 저녁, 공항에 도착했다. +- 공항 입구에서부터 서핑보드가 서 있는데(사진 1) 엄청나게 기대됐다. +- 입국 심사가 정말 느리다. 너무 배고팠다. +- 그랩 정말 저렴했다. 기사님이 친절하셔서 첫인상이 좋았다. +- 숙소에 도착하니 Airbnb 호스트가 마중 나오셨다. +- 호스트는 일본인 부부셨는데 진짜 말도 안 되게 친절하셨다. 완전 호감이었다. +- 따라서 호감작 하기로 마음먹었다. 이 내용은 이후에 나온다. +- 너무 배고파서 숙소 도착하자마자 그랩 푸드로 배달했다(사진 2). +- 물고기가 상당히 맛있었다. + +2일 차 + +- 여기는 오토바이가 불법이기 때문에 전기 자전거를 한 달간 타고 다니기로 했다(사진 3). +- 이 돈이면 오토바이 타는 게 훨씬 저렴한데 아쉬운 감이 없지 않았다. +- 전기 자전거 힘이 엄청 좋다. 최고 속력 40까지 봤다(oh...). +- ATM에서 백만 루피아씩밖에 안 뽑혀서 킹받았다. +- 배달이 싸다. 생선요리가 그나마 비싼 축인 데 저렴하고 맛있음. +- 길에 개가 풀려있다. 처음에 당황. +- 숙소는 전기를 충전해서 사용해야 한다. 에어컨에 맥북 풀세팅하니 하루에 8씩 나가는 듯하다. + +1주일 차 + +- 나는 관광지보다는 로컬이 많은 곳에 있었다(짐바란). +- 그래서인지? 사람들이 다들 친절했다. 많이 웃는다. +- 도마뱀이 엄청 많다. +- 비가 생각보다 많이 왔다(이때가 우기의 끝자락이었다. 2주 차부터는 한 방울도 안 왔다). +- 회사 일하기 바빴다. 아쉬운 부분. 로컬 지역이다 보니 저녁에는 할 게 없었다. +- 길의 고저가 크다. 낭떠러지 같은데 도로인 곳이 꽤 있다. +- 자전거 타이어 터져서 언덕 밀고 왔는데 진짜 레전드. + +![busy](https://github.com/1ilsang/dev/assets/23524849/90aa4718-e9f7-463d-bc4c-28b0d0c5cc23) + +> 내 거친 코드와 그걸 지켜보는 불안한 눈빛 + +
+ bird + lizard + monkey + kecak-fire-dance + surf + walk + turtle + medicine + climbing +
+ +10일 차 + +- 자전거 타다가 황천길 갈뻔했다. 낙엽 밟고 미끄러졌는데 바로 옆에서 차가 지나다녔다. +- [울루와투 사원](https://maps.app.goo.gl/ugQ2tnBDRyESwMuR7) 갔는데 [케착 파이어 댄스](https://www.kkday.com/ko/product/129108-uluwatu-kecak-fire-dance-ticket-bali-indonesia)(사진 3) 볼만했다. +- 줄 서는데 누가 순수한 선의로 사진 찍고 오라고 줄을 대신 기다려줬다. 인류애는 있었다..! +- 원숭이가 아무렇지 않게 물건을 훔쳐 간다. +- 저녁을 실외에서 먹는 건 비추다. 벌레가 많다. +- 카페에 코딩하는 외국인이 꽤 있다. 말 걸고 싶은데 오지랖인 것 같아 극한으로 참았다. + +2주 차 + +- 갑자기 자주 전기가 끊어졌다. 회의가 많았는데 정말 곤란했다. +- 믿었던 LTE 유심마저 잘 안 터졌다. 진짜 곤란했다. 숙소에 발전기 있는지 꼭 확인하자. +- 모든 음료에 종이 빨대가 기본인데 너무 빨리 흐물해져서 열받는다. +- 현지에 조금 익숙해져서 바로 호감작 시작했다. +- 호스트 일본인 부부랑 저녁을 먹었다. 두 분은 발리에서 만났다고 하셨는데 전체 스토리가 로맨틱 그 자체였다. 즐거운 자리여서 또 저녁 먹기로 했다. +- 집 앞 카페 알바생과 꽤 친해졌다. 블랙핑크 후광 엄청났다. +- 서핑을 시작했다(사진 4). 물 많이 먹긴 했는데 클라이밍 다음으로 재밌다고 느낀 스포츠였다. 바로 다음 스케줄 잡음. + +3주 차 + +- 동네에서 걷기대회?를 주최했다(사진 5). 참여했는데 골목골목마다 사원이 참 많다고 느꼈다. +- [거북이 방생하는 캠페인](https://maps.app.goo.gl/mW6SAG3UJJqtunj26)(사진 6)에 참여했다. 인류애 +1 했다. +- 배가 너무 아파서 정신 잃을뻔했다. [발리벨리](https://news.nate.com/view/20231116n29387)에 걸린 것 같았다. GOAT 약(사진 7) 덕분에 겨우 버텼다. +- 발리에서도 클라이밍은 하고 싶었기 때문에 굳이 돈내고 관광지까지 올라가서 탔다(사진 8). +- 길을 걷는데 어떤 외국인이랑 우연히 잡담하게 됐다. +- 한국 돈을 보여달라고 했는데 현금이 없어서(의심도 됐고) 못 보여줬는데 자신이 사우디에서 왔고 부자라고 어필했다. +- ??? 뭐지 하는 데 맥도날드 어디냐고 물어보더라. 희한한 친구였다. + +## 벌써 마지막 주 + +
+ canang-sari + la-brisa + la-brisa-pad + motocycle + jungle-water + scuba-diving + merry-go-round + cliff + duck + shop +
+ +> 마지막 한 주를 무사히 보내길 염원하며 나도 차낭사리를 켰다. + +일만 하다 돌아가기는 아쉬워서 마지막 주에는 휴가를 냈다. 서핑도 하고 스쿠버다이빙에 클라이밍도 하러 가고 요가도 했다. + +클라이밍은 가족 단위가 많았는데 꼬맹이들 정신없이 올라가더라... + +요가가 좀 힙했다. 향 피우고 몽환적인 노래에 축 늘어져 있는데 감성적이었다. + +워케이션으로 지내던 발리와는 느낌이 너무 달랐다. 휴양지의 발리는 생각보다 비싸고 화려했다. + +공항에 가기전 친해진 카페 알바나 일본인 호스트랑 이런저런 이야기를 많이 했는데 타지에서 이어진 인연이 신기하고 아쉬웠다. + +내가 언제 다시 발리를 올지는 모르겠지만 그들이 한국에 온다면 극진히 대접하고 싶다. + +
+ stake + banana-soup + russian + fan-cake + fish + scrumble + duck +
+ +음식 이야기가 없으면 안된다고해서;; 음식 사진을 끝으로 마무리 하겠다. + +## 마무리 + +![good-bye-airport](https://github.com/1ilsang/dev/assets/23524849/ffdaffea-4632-4270-a7ed-77bc2882b3a6 'l') + +한 달간 해외 리모트 워크를 하면서 "영어"와 "기술"에 대한 갈망이 커졌다. + +- 워케이션온 다른 사람들과 가볍게 대화하다 보면 의외의 기회를 잡을 수 있다. +- 영어와 기술을 조금 더 준비한다면 내 무대의 제한이 없겠구나 확신했다. +- 모든 순간에서 언어가 아쉬웠다. 내 생각을 더 잘 전달하고 싶었다. +- 기술을 더 연마해야겠다고 생각했다. 더 빠르게 작업하고 여가를 즐긴 순 없을까? +- 기술을 더욱 연마해야겠다고 생각했다. 작은 기술이라도 있었으니 이런 기회가 있지 않았을까? +- 기술을 더더욱 연마해야겠다고 생각했다. 팀원들에게 임팩트 있는 사람이 되고 싶다. + +나는 무던한 사람인 듯하다. + +- 문화 충돌에 큰 거부감이 없었다. 다른 문화에 대한 흥미가 더 컸다. +- 다행히도 김치가 그립진 않았다. 현지 음식에 그대로 적응했다. +- 타인과 말하는 게 즐거웠다. 상대방에 대한 호기심이 많았다. + +내년에는 어디에서 또 어떤 인연을 만날까 두근두근하다. + +즐거운 한 달이었다. + +## 추천하는 장소 + +짐바란 + +- [HoCiak Restoran (청경채, 마라두부 +1)](https://maps.app.goo.gl/UNX3z29YeU1sFoD48) +- [JinjeRoot Coffee](https://maps.app.goo.gl/mY6me9gwRRJ8nHdt8) +- [Snowcat Bali (Russian Food)](https://maps.app.goo.gl/isJd2xyHrgSPEZBA9) +- [Noka gril & steak](https://maps.app.goo.gl/FPcwQkvbY9PPW9Q37) +- [BGS Surf Shop & Coffee Bar](https://maps.app.goo.gl/nc3ZfTvj41scEW6K6) +- [Sandara Gelato](https://maps.app.goo.gl/1xqWHas8FKaVU1Aw6) +- [Tori Boy (이자카야)](https://maps.app.goo.gl/kQ2CZvaWxwJcmSm6A) +- [Mr. Wok Waroeng Chinese](https://maps.app.goo.gl/eKbeU4AcR77Y8LcN8) + - 여기 사장님이 중국계 분이신데 부산 자주 가신듯 하다. 엄청 재밌고 친절하시다. +- [ANDE Cafe & More](https://maps.app.goo.gl/8p1Anj22F5Vi4MQu8) +- [Padang Padang Beach](https://maps.app.goo.gl/5Pu5nFhmLn9TkjgHA) +- [Uluwatu Temple](https://maps.app.goo.gl/C5UciebnrBQaE11s7) +- [Pantai Batu Barak](https://maps.app.goo.gl/gmmiPtc6XpXLHCYx7) + +꾸따 + +- [La Brisa Bali](https://maps.app.goo.gl/6LdzPfsHSBXsD5CR9) +- [서핑 입문지는 역시 꾸따비치](https://maps.app.goo.gl/FwDBjxW5c6ujkGpGA) +- [Sea Turtle Society](https://maps.app.goo.gl/TqQoMfRkVRfaeuGE7) +- [Discovery Mall](https://maps.app.goo.gl/pHV3kvv53A6FvT356) + +우붓 + +- [Cretya Ubud](https://maps.app.goo.gl/ZfMM1JXeexPu8b8q8) +- [BALI TEAKY 3](https://maps.app.goo.gl/71vMNVGMTwn6oobg7) +- [Laughing Buddha Bar](https://maps.app.goo.gl/vtisJiQYeuCHcq3D8) +- [Starbucks Ubud](https://maps.app.goo.gl/4SS8od43WKkpcHudA) + +ETC + +- [USAT Liberty Shipwreck](https://maps.app.goo.gl/nKpy2WibQLfbKvgU8) diff --git a/_posts/activity/remote-work/jeju-remote.md b/_posts/activity/remote-work/jeju-remote.md new file mode 100644 index 00000000..c3e7dcfb --- /dev/null +++ b/_posts/activity/remote-work/jeju-remote.md @@ -0,0 +1,107 @@ +--- +title: '제주 한 달 리모트 워크 후기' +description: '제주 한 달 살기 얼마나 낭만 있을까?' +url: 'jeju-remote-work' +tags: ['activity', 'jeju', 'remote-work'] +coverImage: 'https://github.com/1ilsang/dev/assets/23524849/733573ae-f90e-4573-a7a2-41940c787da9' +date: '2023-09-16T03:19:48.222Z' +ogImage: + url: 'https://github.com/1ilsang/dev/assets/23524849/733573ae-f90e-4573-a7a2-41940c787da9' +--- + +![cover](https://github.com/1ilsang/dev/assets/23524849/bd5efca8-90f6-4dba-bdd9-914d4d7dc459 'cover') + +> 제주 섭지코지 + +최근에 해외 리모트로 한 달간 발리를 갔다 왔다. + +해외 리모트 소감을 쓰려던 중 작년 중순(2022.05) 제주도에서 보낸 한 달간의 리모트가 떠올랐다. 시간 순서대로 적어보고자 하여 기억을 더듬어 그때의 기록을 남기고자 한다. + +## 시작 계기 + +재택근무가 지속되던 어느날 회사에서 국내 리모트도 거주지의 확보만 된다면 가능하다고 공지가 나왔다. + +평소 해외에서 근무해 보고 싶다고 생각을 자주 했었다. 마침 리모트도 되니 이번 기회에 연고지가 없는 곳으로 이동해 워케이션을 해보면서 나는 워케이션이 잘 맞는 사람인지 알고 싶었다. + +바로 제주도가 떠올랐고 한 달 제주살이를 해보자 마음먹었다. + +## 첫 일주일 + +![start](https://github.com/1ilsang/dev/assets/23524849/cc26351c-f65e-4d2d-a294-5599410e1e84 'l') + +나는 사람이 많은 곳을 피하고 싶었다. 고민하던 도중 [성산일출봉](https://naver.me/GEAuu260) 근처에 관광할 곳이 하나도 없는 지역 근처의 숙소를 구했다. + +오죽했으면 근처에 카페도 거의 없어서 걸어서 20분 가야 했다. 9\_- + +성산일출봉이 어느 위치에서도 보였기 때문에 나침반 역할을 했다. 평소 서울에서 거의 벗어나질 않았기 때문에 어딜가도 바다와 대자연이 펼쳐진 제주도는 설레기에 충분한 장소였다. + +주변을 더 자세히 보고자 많이 걸어 다녔고 혼자 사색하는 시간도 많이 가졌었다. + +
+ +![work](https://github.com/1ilsang/dev/assets/23524849/7681d32c-6537-4e9f-871b-993cb19090fe) + +> 처참했던 근무환경 + +집을 벗어나 일하는게 익숙하지 않았던 나는 큰 실수를 하나 저지르는데 바로 "책상"을 제대로 알아보지 않았다는 점이었다. + +숙소의 가격이나 주변 환경이나 인터넷 등은 확인했는데 책상은 여부만 확인하고 제대로 알아보지 않았던 점이 큰 실수였다. + +의자가 제대로 되어있지 않은 책상에서 장시간 코딩은 허리에 무리가 많이 갔다. 퇴근하고 나서는 아예 누워서 했다. + +평소 모션데스크에 절여져 있어서 서서 코딩하는게 편했는데 집에 있는 책상이 너무 그리웠다. + +
+ +![landscape](https://github.com/1ilsang/dev/assets/23524849/610f23eb-9380-4a90-877f-8b402d0691e5 'l') + +![green-tea-cave](https://github.com/1ilsang/dev/assets/23524849/5fe84b29-18bf-4852-8f16-2a256a2d5d5c 'l') + +하지만 퇴근 후 혹은 주말에 제주 여러 지역을 돌아다니며 주위를 환기하는 과정은 워케이션을 후회하지 않게 해주기에 충분했다. + +특히 카페에서 공부하는 걸 좋아하던 나는, 제주의 이색적인 카페에서 아름다운 풍경을 뒤로하고 여유롭게 책을 읽는게 상당히 좋았다. + +제주에서 몇 가지 목표가 있었는데, 그중 하나가 [완성된 웹사이트로 배우는 HTML&CSS 웹 디자인 책 리뷰](https://blog.naver.com/1ilsang/222771871391)였다. + +지금도 CSS가 어렵지만 이때는 정말 `flex`의 존재도 모를 때였기 때문에 CSS를 꼭 공부해 보고자 생각했었는데 흥미롭게 읽을 수 있었다. + +그 외에도 지금의 블로그의 토대를 만들고 두루뭉실했던 계획을 세분화하는 등 바쁘다며 미뤄뒀던 여러 작업들을 마칠 수 있었다. + +## 벌써 마지막 주 + +
+ fire + mountain +
+ +당시에 시간이 정말 빠르게 간다고 느꼈다. 하루하루 많은 일이 있었는데 어느덧 마지막 주였던 기억이 난다. + +중간에 배포가 있어서 야근을 엄청 하기도 했는데 당시 이런 생각이 들었다. 내가 개발을 조금 더 잘했으면 더 빨리 끝내고 편하게 쉬었을까? 물론 그랬겠지만, 여유가 있었다면 그 여유만큼 뭔가 더 일을 만들었을 것 같다. 서울에서는 스터디라던가 커피챗이라던가 다양한 활동/모임에 참여하느라 저녁의 여유를 잘 못 느꼈는데 여기에서 거의 반강제로 집에 있으면서 삶을 바라보는 방향이 많이 바뀔 수 있었다. + +나는 왜 공부하는가? + +다양한 답변이 속에서 나왔지만 결국 나는 누군가에게 도움이 되고 싶어서 공부한다는 결론이 나왔었다. 정보 공유를 한다거나 가르쳐줄 때 큰 재미를 느꼈고 그 재미가 내 행동 기반이라는 것을 깨닫게 되었다. 회사에서만 보더라도 다양한 정보 혹은 개발기를 공유하고 싶어서 열심히 일을 하게 되었던 것 같다. + +이처럼 제주에서 나는 중간중간 스스로에게 질문을 많이 던지면서 천천히 생각해 보는 시간을 많이 가졌다. 이것이 제주에서 느꼈던 가장 좋았던 점이었다. + +맨날 퇴근 후 다음 작업을 하느라 스스로에게 질문을 하지 못했는데 이번 기회에 삶의 방향을 한번 돌아보게 되었다. + +![pony](https://github.com/1ilsang/dev/assets/23524849/da21b5d7-dc08-48d1-915c-f7738f7341cd) + +## 마무리 + +
+ sunset-mount + sunset-sea +
+ +워케이션 기간동안 많은 것을 느꼈다. + +- 새로운 환경에서 작업하면서 주위를 환기하는 과정은 상당히 즐거웠다. +- 나 자신과 스스로 마주할 수 있었기에 조금 더 자신을 알게 되었다. +- 나는 "서울에서의 바쁜 일상을 즐겼다"는 결론을 가지게 되었다. 제주 생활도 좋았지만 "서울에 올라가면 꼭 ~ 해야지"라고 서울에서의 일정이 마구 생기는 모습을 보면서 나에게 워케이션은 한번씩의 환기 이벤트라고 생각하게 되었다. +- 워케이션을 통해 평범했던 일상을 더욱 좋아하게 되었고 긍정적으로 삶을 바라볼 수 있게 되었다. + +이제 다시 평범한 일상으로 돌아가게 되겠지만 새로운 환경이 필요하다고 생각하면 주저 없이 워케이션을 선택할 것 같다. + +좋은 경험이었다. diff --git a/_posts/algorithm/goorm/195687.md b/_posts/algorithm/goorm/195687.md new file mode 100644 index 00000000..9ec64b30 --- /dev/null +++ b/_posts/algorithm/goorm/195687.md @@ -0,0 +1,92 @@ +--- +title: '[구름톤 챌린지] 이진수 정렬' +description: '메모이제이션 활용하기' +url: 'goorm-195687' +tags: ['algorithm', 'goorm', 'binary', 'memoization'] +coverImage: 'https://github.com/1ilsang/dev/assets/23524849/904bc3d3-7662-4a68-add4-e4fb6349ff08' +date: '2023-08-21T12:56:23.115Z' +ogImage: + url: 'https://github.com/1ilsang/dev/assets/23524849/904bc3d3-7662-4a68-add4-e4fb6349ff08' +--- + +![cover](https://github.com/1ilsang/dev/assets/23524849/0ca5d93d-d603-4bd7-91e7-1bfae12f4e5e 'cover') + +> [문제 링크](https://level.goorm.io/exam/195687/%EC%9D%B4%EC%A7%84%EC%88%98-%EC%A0%95%EB%A0%AC) + +10진수 숫자를 2진법으로 변환후 1의 개수가 가장 많은 순부터 정렬해 K번째 위치 값을 출력하면 되는 문제다. + +## 접근법 + +N을 순회하면서 각각 2진수로 변환하고 그 값을 저장해 나간다면(`O(N^2)`) 너무 재미없다. + +따라서 우리는 메모를 사용해 이전 값을 활용해 다음 값을 구해 나갈 것이다. 이 방법을 사용하면 `O(N)`으로 처리가 가능하다. + +```js +// 숫자별 1의 개수를 정리해 본다. +// Index: [0,1,2,3,4,5,6,7,8,9]; +// Count: [0,1,1,2,1,2,2,3,1,2]; +``` + +9까지의 수를 2진수의 개수로 표현하면 위의 표와 같아진다. 여기서 우리는 두 가지 패턴을 찾을 수 있다. + +1. 2의 지수승(2^n)은 무조건 1이다(1, 2, 4, 8은 2진수에서 무조건 1이다). +2. **현재 값에서 2를 나눈 값의 1의 개수와 현재 값을 2로 나눈 나머지를 더하면 현재 값의 1의 개수가 된다.** + +_7을 기준으로 해보자._ + +```md +7/2 = 3 => 2 +7%2 = 1 +=> 7 = 3 +``` + +- 7을 2로 나누면 3이 된다. 위의 표에서 3의 1 개수는 2이다. +- 7을 2로 나눈 나머지는 1이다. +- 2 + 1 = 3이므로 7은 3이 된다. + +이전 값을 알면 현재 값을 손쉽게 구할 수 있게 되었다. + +그러므로 2부터 포문을 돌리면서 메모 배열을 만들고 최댓값을 찾아나가면 된다. + +## 정리 + +1. 메모이제이션을 활용해 들어올 수 있는 숫자의 최댓값 `2^20`(1048576)까지 이진수의 개수를 구한다. +2. N을 메모이제이션 배열로 정렬한다. +3. K 번 인덱스의 값을 출력한다. + +## 최종 코드 + +```js +let N, K; +rl.on('line', (line) => { + if (typeof N === 'undefined') { + const [n, k] = line.split(' ').map((num) => Number(num)); + N = n; + K = k; + return; + } + const nums = line.split(' ').map((num) => Number(num)); + const memo = [0, 1]; + + // 메모 처리 + for (let i = 2; i <= 1048576; i++) { + const before = memo[Math.floor(i / 2)]; + const remain = i % 2; + memo[i] = before + remain; + } + + // input을 순회하면서 메모 값을 기준으로 정렬한다. + const sortedList = nums.sort((a, b) => { + const am = memo[a]; + const bm = memo[b]; + // 만약 메모 값이 같다면(1의 개수가 동일하다면) 10진수를 기준으로 정렬 + if (am === bm) { + return b - a; + } + return bm - am; + }); + + console.log(sortedList[K - 1]); + rl.close(); +}); +``` diff --git a/_posts/algorithm/goorm/195692.md b/_posts/algorithm/goorm/195692.md new file mode 100644 index 00000000..68b7c3c0 --- /dev/null +++ b/_posts/algorithm/goorm/195692.md @@ -0,0 +1,165 @@ +--- +title: '[구름톤 챌린지] GameJam' +description: '시뮬레이션 단계화하기' +url: 'goorm-195692' +tags: ['algorithm', 'goorm', 'simulation', 'brute-force'] +coverImage: 'https://github.com/1ilsang/dev/assets/23524849/904bc3d3-7662-4a68-add4-e4fb6349ff08' +date: '2023-08-27T14:35:41.009Z' +ogImage: + url: 'https://github.com/1ilsang/dev/assets/23524849/904bc3d3-7662-4a68-add4-e4fb6349ff08' +--- + +![cover](https://github.com/1ilsang/dev/assets/23524849/7c30fa46-50a2-433a-a844-84ae2bcb2bb2 'cover') + +> [문제 링크](https://level.goorm.io/exam/195692/gamejam/quiz/1) + +끔찍한 시뮬레이션 문제이다. + +별다른 특이 사항 없이 문제에서 제시한 대로 구현하면 된다. + +## 접근법 + +구름과 플레이어 두 명이 각각 게임을 진행한다. 따라서 우리는 동일한 게임을 2번 실행해야 한다는 것을 알 수 있다. + +즉 **초기화를 잘하던가, 똑같은 코드를 두 번 실행해야 한다**. + +게임 보드 칸에는 이동 횟수와 방향이 적혀있으며 게이머가 놓은 위치에서부터 칸의 명령을 따라 쭉 실행하면 된다. + +만약 **보드를 이탈하는 경우(처음/끝) 반대쪽 첫 칸으로 이동하게 된다**(`-1 -> length -1`, `length -> 0`), + +이때 이전에 방문했던 곳에 다시 온다면 게임이 종료된다. 우리는 **두 번의 메모 맵**이 필요하다. + +두 플레이어가 게임을 종료했을 때 **점수를 비교해 출력**한다. + +## 정리 + +1. 유저의 위치를 받아 점수를 반환하는 함수를 만든다(내부 변수 사용으로 초기화 용이). +2. 이동 거리만큼 이동한다. + 1. 보드 이탈시 좌표를 반대쪽 첫 칸으로 재설정해 준다. + 2. 이동할 때마다 메모를 한다. +3. 점수 값을 비교 출력한다. + +## 최종 코드 + +```js +const readline = require('readline'); +let rl = readline.createInterface({ + input: process.stdin, + output: process.stdout, +}); +let n; +const goorm = []; +const player = []; +const map = []; +const d = { + U: [-1, 0], + R: [0, 1], + D: [1, 0], + L: [0, -1], +}; +const getBoardInfo = ({ r, c }) => { + const cmd = map[r][c]; + const count = parseInt(cmd); + const direction = cmd.slice(-1); + return { + cmd, + count, + direction, + }; +}; +const setNextMove = ({ r, c, direction }) => { + let nr = r + d[direction][0]; + let nc = c + d[direction][1]; + + if (nr < 0) { + nr = map.length - 1; + } else if (nc < 0) { + nc = map[0].length - 1; + } else if (nr >= map.length) { + nr = 0; + } else if (nc >= map[0].length) { + nc = 0; + } + return { + nr, + nc, + }; +}; +const buildMemo = () => { + const memo = map.map((row) => { + return Array(row.length).fill(0); + }); + return memo; +}; +const playGame = ({ r, c }) => { + // 최초 세팅. 현재 칸의 명령을 파싱한다. + let { cmd, count, direction } = getBoardInfo({ r, c }); + let nr = r; + let nc = c; + let score = 1; + + const memo = buildMemo(); + memo[r][c] = 1; + + while (true) { + // 이동 횟수가 0이라면 현재 위치 칸의 명령으로 초기화 한다. + if (count === 0) { + const curValues = getBoardInfo({ r: nr, c: nc }); + cmd = curValues.cmd; + count = curValues.count; + direction = curValues.direction; + } + // 다음 이동을 위해 nextRow, nextCol 값을 세팅한다. + const nextPosition = setNextMove({ r: nr, c: nc, direction }); + nr = nextPosition.nr; + nc = nextPosition.nc; + // 만약 다음 좌표가 방문한적이 있다면 루프를 종료한다. + if (memo[nr][nc]) { + break; + } + // 이동 거리를 감소시키고 스코어를 추가한뒤 좌표를 메모한다. + count--; + score++; + memo[nr][nc] = 1; + } + return score; +}; +const getParsedLineNumbers = (line) => + line.split(' ').map((num) => Number(num) - 1); +const setFields = (line) => { + const parsedLine = line.split(' '); + map.push(parsedLine); +}; + +rl.on('line', (line) => { + if (n === undefined) { + n = Number(line); + return; + } + if (goorm.length === 0) { + goorm.push(...getParsedLineNumbers(line)); + return; + } + if (player.length === 0) { + player.push(...getParsedLineNumbers(line)); + return; + } + if (map.length < n) { + setFields(line); + if (map.length < n) { + return; + } + } + const gScore = playGame({ + r: goorm[0], + c: goorm[1], + }); + const pScore = playGame({ + r: player[0], + c: player[1], + }); + const answer = gScore > pScore ? `goorm ${gScore}` : `player ${pScore}`; + console.log(answer); + rl.close(); +}); +``` diff --git a/_posts/algorithm/goorm/195693.md b/_posts/algorithm/goorm/195693.md new file mode 100644 index 00000000..0bcefdd2 --- /dev/null +++ b/_posts/algorithm/goorm/195693.md @@ -0,0 +1,112 @@ +--- +title: '[구름톤 챌린지] 통증2' +description: '완전 탐색에서 DP까지' +url: 'goorm-195693' +tags: ['algorithm', 'goorm', 'brute-force', 'dynamic-programming'] +coverImage: 'https://github.com/1ilsang/dev/assets/23524849/904bc3d3-7662-4a68-add4-e4fb6349ff08' +date: '2023-08-29T07:27:42.821Z' +ogImage: + url: 'https://github.com/1ilsang/dev/assets/23524849/904bc3d3-7662-4a68-add4-e4fb6349ff08' +--- + +![cover](https://github.com/1ilsang/dev/assets/23524849/e81cf0d5-7344-42da-9989-15bd4a83a533 'cover') + +> [문제 링크](https://level.goorm.io/exam/195693/%ED%86%B5%EC%A6%9D-2/quiz/1) + +아이템 A, B를 최소한으로 사용하여 통증을 0으로 맞출 수 있는지 확인하는 문제이다. 불가능하다면 -1을 출력한다. + +## 접근법 + +### 1. 완전 탐색 + +A와 B의 합이 N이 될 때까지 전체 조합을 탐색한다. + +A가 0...I개 일때 B가 0...J개로 가능한지 확인할 수 있다. 이때 개수로 확인하게 되면 A가 I번 만큼 순회할 때마다 B가 J번 만큼 순회하게 되므로 **시간초과**가 발생한다. + +A가 0...I개 일때 B는 0부터 하나씩 올려가며 찾지 않고 N-A가 B로 나누어지는지를 확인하면 `O(N)`만에 해결이 가능해진다. + +```js +let answer = Infinity; +for (let aCost = 0; aCost <= n; aCost += a) { + // N에 A를 뺀 값이 B로 나누어떨어지지 않는다면 패스한다. + if ((n - aCost) % b !== 0) continue; + const aCount = Math.floor(aCost / a); + const bCount = Math.floor((n - aCost) / b); + const count = aCount + bCount; + // 최소 개수를 출력해야 하므로 현재 값이 answer보다 작다면 갱신한다. + if (count < answer) { + answer = count; + } +} +// answer가 무한이라면 가능한 조합이 없다는 의미이므로 -1로 변경한다. +if (answer === Infinity) { + answer = -1; +} +console.log(answer); +``` + +### 2. DP + +개수가 기준이 아닌 통증의 값 N을 기준으로 생각해 보자. + +통증 N은 `[N - A] + 1` 혹은 `[N - B] + 1`이 될 수 있다. 현재 N값이 되기 위해선 A혹은 B를 더했으므로 역산으로 A 혹은 B를 뺀 개수에 현재 카운트 1을 추가하면 된다. + +따라서 통증 0부터 N까지 순회하며 dp 테이블을 채워나가면 `O(N)`으로 처리가 가능해진다. + +> 관련 문제로 [이진수 정렬의 memo 배열이 채워지는 방식](/posts/goorm-195687)과 같다. + +```js +const dp = Array(n + 1).fill(Infinity); +dp[0] = 0; + +for (let i = 1; i <= n; i++) { + if (i - a >= 0) { + dp[i] = Math.min(dp[i - a] + 1, dp[i]); + } + if (i - b >= 0) { + dp[i] = Math.min(dp[i - b] + 1, dp[i]); + } +} +console.log(dp[n] === Infinity ? -1 : dp[n]); +``` + +## 정리 + +1. 통증 N의 아이템 사용 개수는 `N - A` 혹은 `N - B` 값의 +1이다. +2. DP 테이블을 0부터 N까지 채운다. +3. DP[n] 값을 출력한다. + +## 최종 코드 + +```js +const readline = require('readline'); +let rl = readline.createInterface({ + input: process.stdin, + output: process.stdout, +}); +let n; +rl.on('line', (line) => { + if (n === undefined) { + n = Number(line); + return; + } + const [a, b] = line.split(' ').map((item) => Number(item)); + const dp = Array(n + 1).fill(Infinity); + dp[0] = 0; + + for (let i = 1; i <= n; i++) { + if (i - a >= 0) { + dp[i] = Math.min(dp[i - a] + 1, dp[i]); + } + if (i - b >= 0) { + dp[i] = Math.min(dp[i - b] + 1, dp[i]); + } + } + console.log(dp[n] === Infinity ? -1 : dp[n]); + rl.close(); +}); + +rl.on('close', () => { + // console.log("Hello Goorm! Your input is " + input); +}); +``` diff --git a/_posts/algorithm/goorm/195696.md b/_posts/algorithm/goorm/195696.md new file mode 100644 index 00000000..c7e05ea5 --- /dev/null +++ b/_posts/algorithm/goorm/195696.md @@ -0,0 +1,111 @@ +--- +title: '[구름톤 챌린지] 작은 노드' +description: '양방향 그래프 가볍게 복습하기' +url: 'goorm-195696' +tags: ['algorithm', 'goorm', 'graph', 'sort'] +coverImage: 'https://github.com/1ilsang/dev/assets/23524849/904bc3d3-7662-4a68-add4-e4fb6349ff08' +date: '2023-09-03T12:14:23.054Z' +ogImage: + url: 'https://github.com/1ilsang/dev/assets/23524849/904bc3d3-7662-4a68-add4-e4fb6349ff08' +--- + +![cover](https://github.com/1ilsang/dev/assets/23524849/14e4940e-d64c-42ce-9ae9-b3db4ace0834 'cover') + +> [문제 링크](https://level.goorm.io/exam/195696/%EC%9E%91%EC%9D%80-%EB%85%B8%EB%93%9C/quiz/1) + +양방향 그래프에서 시작 점으로부터 더 이상 갈 수 없을 때까지 이동한 뒤 출력하면 되는 문제다. + +## 접근법 + +```js +// 양방향 그래프 +map[i].push(j); // i 노드가 j 노드로 방향성을 가지고 있다. +map[j].push(i); // j 노드가 i 노드로 방향성을 가지고 있다. +console.log(map); +// { i: [j, ...], j: [i, ...] } +``` + +양방향 그래프이기 때문에 그래프를 만들 때 좌우 둘 다 추가해 준다. + +각 정점에서 방문하지 않았던 번호가 낮은 노드로 이동하기 때문에 각 **정점의 값은 정렬**되어 있어야 한다. + +```js +[100, 30, 200].sort(); +// [100, 200, 30] +``` + +유의. 자바스크립트에서 `sort`함수에 비교함수 콜백을 작성하지 않으면 [ASCII](https://wikipedia.org/wiki/ASCII)값을 기준으로 정렬한다. + +따라서 "문자"로 비교하기 때문에 `30`, `100`, `200`이 아닌 첫 번째 문자의 아스키 값의 우선순위에 따라 `100`, `200`, `30`이 출력된다. + +```js +// a, b중 큰 값은 뒤로(+) 작은 값은 앞으로(-) 가게 된다. +[100, 30, 200].sort((a, b) => a - b); +// [30, 100, 200] + +// 0이므로 a, b는 서로 변경되지 않는다. +[100, 30, 200].sort((a, b) => 0); +// [100, 30, 200] + +// a, b중 큰 값은 앞으로(-) 작은 값은 뒤로(+)가게 된다. +[100, 30, 200].sort((a, b) => b - a); +// [200, 100, 30] +``` + +따라서 비교 함수 콜백을 통해 정렬의 우선순위를 먼저 지정해야 한다. + +- 콜백 함수의 리턴값이 0보다 작은 경우(음수) a를 b보다 낮은 인덱스로 정렬한다(a가 먼저 오므로 오름차순이 됨). +- 콜백 함수의 리턴값이 0인 경우 a와 b를 서로 변경하지 않고 다른 요소에 대해 정렬한다(현재 두 값으로는 비교 X). +- 콜백 함수의 리턴값이 0보다 큰 경우(양수) a를 b보다 높은 인덱스로 정렬한다(b가 먼저 오므로 내림차순이 됨). + +방문한 횟수는 방문(메모) 배열에 값을 추가해 나가다 마지막에 배열 길이를 출력하면 된다. + +## 정리 + +1. 양방향 그래프를 그린다. +2. 메모 배열을 활용해 방문 시 체크해 준다. +3. 마지막 노드와 메모 배열의 길이를 출력한다. +4. `sort` 함수는 문자([ASCII](https://wikipedia.org/wiki/ASCII))를 기준으로 정렬하기 때문에 유의해야 한다. + +## 최종 코드 + +```js +const readline = require('readline'); +let rl = readline.createInterface({ + input: process.stdin, + output: process.stdout, +}); +const input = []; +rl.on('line', (line) => { + input.push(line); +}); + +rl.on('close', () => { + const map = {}; + + const [n, m, k] = input[0].split(' ').map(Number); + for (const line of input.slice(1)) { + const [i, j] = line.split(' ').map(Number); + if (!map[i]) map[i] = []; + if (!map[j]) map[j] = []; + // 양방향 그래프 생성 + map[i].push(j); + map[j].push(i); + } + + // k 노드부터 출발할 예정이므로 메모와 last 값을 초기화한다. + const memo = [k]; + let last = k; + while (true) { + // 현재 노드의 값이 없다면 이어진 정점이 없으므로 탈출한다. + if (!map[last]) break; + const next = map[last] + .filter((node) => !memo.includes(node)) // 방문한 적이 없는 값들만 필터링한다. + .sort((a, b) => a - b)[0]; // 방문하지 않은 정점들을 오름차순 정렬해 첫 번째 값을 꺼낸다. + if (!next) break; // next가 없다면 해당 노드에서 갈 수 있는 모든 정점을 방문한 상태이므로 종료한다. + last = next; // 마지막 노드를 갱신한다. + memo.push(last); // 메모에 마지막 노드를 추가한다. + } + console.log(`${memo.length} ${last}`); +}); +``` diff --git a/_posts/algorithm/goorm/195698.md b/_posts/algorithm/goorm/195698.md new file mode 100644 index 00000000..80b51dc0 --- /dev/null +++ b/_posts/algorithm/goorm/195698.md @@ -0,0 +1,190 @@ +--- +title: '[구름톤 챌린지] 연합' +description: '단방향 그래프의 집합을 BFS 및 Union-Find로 구해보자' +url: 'goorm-195698' +tags: ['algorithm', 'goorm', 'graph', 'bfs', 'union-find'] +coverImage: 'https://github.com/1ilsang/dev/assets/23524849/904bc3d3-7662-4a68-add4-e4fb6349ff08' +date: '2023-09-06T10:12:00.000Z' +ogImage: + url: 'https://github.com/1ilsang/dev/assets/23524849/904bc3d3-7662-4a68-add4-e4fb6349ff08' +--- + +![cover](https://github.com/1ilsang/dev/assets/23524849/ab47c9e9-2a78-4874-b8f4-51fca8a6eaf8 'cover') + +> [문제 링크](https://level.goorm.io/exam/195698/%EC%97%B0%ED%95%A9/quiz/1) + +노드 사이에 사이클이 발생하면 "연합"이 된다. 각 사이클의 노드가 인접하면 인접한 사이클끼리도 연합이 된다. 이 연합 집합의 개수를 출력하는 문제이다. + +이 문제는 그래프에서 사이클을 찾고 집합의 구성을 분류하는 기초적인 문제라 개념 잡기에 좋은 문제인듯 하다. + +## 접근법 + +사이클을 찾고 해당 사이클이 집합을 이루는지 찾아야 한다. 이 문제는 BFS 또는 Union-Find로 풀릴 수 있다. + +먼저 각 노드가 사이클을 가지고 있는지는 어떻게 확인할까? 무지성 `includes`를 해도 되지만 `O(1)`로 찾지 않으면 시간초과가 발생한다. + +```js +// 그래프의 방향성을 저장하기 위한 2차원 배열을 생성한다. +const check = Array.from(Array(n + 1), () => Array(n + 1).fill(0)); +// cur 노드에서 next 노드로 방향성을 가지고 있다면 체크해 준다. +check[cur][next] = 1; + +// 현재 노드와 다음 노드의 방향성이 둘 다 1이라면 사이클이다. +check[cur][next] === 1 && check[next][cur] === 1; +``` + +문제가 친절하게도 각 정점 사이의 직접 사이클만 요구하므로 사이클을 더 쉽게 찾을 수 있다. + +각 노드의 사이클 여부를 확인할 수 있게 되었으니 집합을 구성해 주어야 한다. + +### BFS로 집합 구성하기 + +인접한 노드끼리 사이클이면서 사이클끼리 인접하면 연합이 된다. + +따라서 우리는 각 노드를 순회하면서 사이클을 찾고 사이클이 되는 노드에서 다시 사이클을 찾는 식으로 BFS 탐색을 하면 된다. + +```js +const bfs = (i) => { + const q = [i]; + memo[i] = 1; + while (q.length) { + // 현재 노드에서 사이클을 찾아간다. + const cur = q.shift(); + if (!map[cur]) break; // 현재 노드에서 이어진 노드가 없다면 루프를 탈출한다. + const nextList = map[cur]; + for (const next of nextList) { + // 다음 노드가 현재 노드로 사이클이 없거나 방문한 적이 있으면 패스한다. + if (!check[next][cur] || memo[next]) continue; + // 다음 노드와 사이클이면서 방문한 적이 없기 때문에 큐에 넣어준다. + memo[next] = 1; + q.push(next); + } + } +}; +let answer = 0; +for (let i = 1; i <= n; i++) { + if (memo[i]) continue; + bfs(i); + // BFS에서 탈출했다면 연합 한 개가 구성된 것이므로 카운트를 추가한다. + answer++; +} +console.log(answer); +``` + +따라서 각 노드에서 사이클이 되는 노드를 찾고 해당 노드를 큐에 넣어줌으로써 연속된 사이클을 계속해서 찾을 수 있다. + +### Union-Find로 집합 구성하기 + +그래프의 집합을 나타내는 방법으로 Union-Find를 사용할 수 있다. + +```js +// index 0 1 2 3 4 5 6 7 +const parent = [0, 1, 1, 1, 4, 5, 4, 4]; +// 1,2,3 인덱스 노드는 부모 노드가 1인 집합이 된다. +// 4,6,7 인덱스 노드는 부모 노드가 4인 집합이 된다. +// 0,5 인덱스 노드는 자기 자신만 집합인 노드가 된다. + +for (let cur = 1; cur <= n; cur++) { + const curList = map[cur]; + for (const next of curList || []) { + // next -> cur로 이어져 있지 않다는 것은 사이클이 아니므로 무시한다. + if (!check[next][cur]) continue; + // 현재 노드와 다음 노드가 사이클이라면 집합을 구한다. + union(parent, cur, next); + } +} +``` + +노드를 순회하면서 Union 조건이 성립(사이클)한다면 각 노드를 합쳐준다. + +이후 각 노드의 부모 노드를 find 한다. 노드의 부모가 같다면 같은 집합이라는 뜻이 된다. + +Union-Find에서는 memo 배열을 통한 방문 여부 확인 코드가 없는 것을 알 수 있다. + +- BFS의 경우 인접한 모든 노드를 방문하면서 방문 여부로 집합을 구성하지만, Union-Find는 각 노드의 부모로 집합 여부를 확인하기 때문에 사이클이 된다면 각 노드의 부모를 계속해서 갱신해 줘야 한다. + +```js +// 오답 +const answer = new Set(parent); +console.log(answer.size); + +// 정답 +const answer = new Set(); +for (let i = 1; i <= n; i++) { + answer.add(find(parent, i)); +} +console.log(answer.size); +``` + +이때 부모 배열의 값으로만 비교해 출력하면 오답이 된다. + +모든 노드를 순회하면서 부모를 갱신하지 않았기 때문에 마지막에 각 노드를 다시 find 해서 부모를 갱신해야 올바른 값이 된다. + +## 정리 + +1. 양방향 그래프를 그린다. +2. 노드를 순회하면서 사이클 여부를 확인한다. +3. 사이클인 노드들을 집합으로 구분한다. +4. 집합의 개수를 출력한다. + +## 최종 코드 + +```js +const readline = require('readline'); +let rl = readline.createInterface({ + input: process.stdin, + output: process.stdout, +}); +const input = []; +rl.on('line', (line) => { + input.push(line); +}); +const l = console.log; + +const union = (arr, a, b) => { + const aRoot = find(arr, a); + const bRoot = find(arr, b); + if (aRoot === bRoot) return; + const root = Math.min(aRoot, bRoot); + arr[Math.max(aRoot, bRoot)] = root; +}; + +const find = (arr, cur) => { + if (arr[cur] === cur) return cur; + // find 하면서 path 단축도 같이한다. + return (arr[cur] = find(arr, arr[cur])); +}; + +rl.on('close', () => { + const [n, m] = input[0].split(' ').map(Number); + // 부모 노드를 체크할 배열을 index의 값을 가지도록 세팅한다. + const ufArr = Array(n + 1) + .fill(0) + .map((_, idx) => idx); + const check = Array.from(Array(n + 1), () => Array(n + 1).fill(0)); + const map = {}; + input.slice(1).forEach((line) => { + const [s, e] = line.split(' ').map(Number); + map[s] = [...(map[s] || []), e]; + check[s][e] = 1; // 해당 노드의 방향성을 체크한다 + }); + for (let cur = 1; cur <= n; cur++) { + const curList = map[cur]; + for (const next of curList || []) { + // next -> cur로 이어져 있지 않다는 것은 사이클이 아니므로 무시한다. + if (!check[next][cur]) continue; + // 현재 노드와 다음 노드가 사이클이라면 집합을 구한다. + union(ufArr, cur, next); + } + } + const answer = new Set(); + for (let i = 1; i <= n; i++) { + // 각 정점의 부모를 find로 순회하며 찾는다. + // Set이므로 중첩된 부모는 제외하고 추가된다. + answer.add(find(ufArr, i)); + } + // Set의 size는 중복되지 않는 각 노드의 부모(집합)이므로 + // 연합(집합)의 개수가 된다. + console.log(answer.size); +}); +``` diff --git a/_posts/algorithm/leetcode/easy/2727.md b/_posts/algorithm/leetcode/easy/2727.md new file mode 100644 index 00000000..425d7e18 --- /dev/null +++ b/_posts/algorithm/leetcode/easy/2727.md @@ -0,0 +1,99 @@ +--- +title: '[LeetCode] 2727. Is Object Empty' +description: 'for...in vs for...of' +url: 'leetcode-easy-2727' +tags: ['algorithm', 'leetcode', 'easy', 'object', 'for-in', 'for-of', 'keys'] +coverImage: 'https://github.com/1ilsang/dev/assets/23524849/e0fcd614-46f9-4221-a284-570b9591a1b8' +date: '2023-08-01T01:02:45.202Z' +ogImage: + url: 'https://github.com/1ilsang/dev/assets/23524849/e0fcd614-46f9-4221-a284-570b9591a1b8' +--- + +![cover](https://github.com/1ilsang/dev/assets/23524849/14a719b6-8d62-4a60-ae5f-253edae47184 'cover') + +> [문제 링크](https://leetcode.com/problems/is-object-empty/) + +객체 혹은 배열이 비어있는지 확인하는 문제이다. + +```js +/** + * @param {Object | Array} obj + * @return {boolean} + */ +const isEmpty = (obj) => { + return Object.keys(obj).length === 0; +}; +``` + +배열 혹은 객체의 키가 있는지 확인하면 되므로 Object.keys의 길이가 0인지 확인하면 된다. + +만약 `keys`로 추출 후 비교하는 것이 싫다면 아래와 같은 방법도 있다. + +```js +const isEmpty = (obj) => { + for (i in obj) { + return false; + } + return true; +}; +``` + +배열 또한 객체이므로 `for...in`을 통해 속성 키값을 순회할 수 있는지 확인하면 된다. + +`keys`로 모든 키값을 가져와 비교하는 것보다 순회와 동시에 객체가 비어있는지 판단이 가능하므로 훨씬 빠르다. + +**참고로 이 문제는 "상속된" 프로퍼티를 비교해야 하는지에 대한 명시가 없어 모호한 부분이 있다.** + +만약 상속된 프로퍼티까지 비교한다면 `keys`와 `for-in`의 정답 여부가 달라졌을 것이다. 이 부분은 아래에서 다루겠다. + +## ++ `for-in`과 `for-of`의 차이가 무엇일까? + +### for...in + +> for...in문은 **상속된** 열거 가능한 속성들을 포함하여 객체에서 문자열로 키가 지정된 모든 열거 가능한 **속성**에 대해 반복합니다(Symbol로 키가 지정된 속성은 무시합니다.). + +[MDN for...in 설명](https://developer.mozilla.org/ko/docs/Web/JavaScript/Reference/Statements/for...in)에 따르면 `for...in`은 상속된 모든 속성(Property)을 포함한 속성 키 값을 반복한다. + +### for..of + +> for...of 명령문은 반복가능한 객체 (Array, Map, Set, String, TypedArray, arguments 객체 등을 포함)에 대해서 반복하고 각 개별 **속성값**에 대해 실행되는 문이 있는 사용자 정의 반복 후크를 호출하는 루프를 생성합니다. + +[MDN for...of 설명](https://developer.mozilla.org/ko/docs/Web/JavaScript/Reference/Statements/for...of)에 따르면 `for...of`는 반복 가능한 객체의 속성 값에 대한 순회를 한다. + +### 결론 + +```jsx +Object.prototype.objCustom = function () {}; +Array.prototype.arrCustom = function () {}; + +let iterable = [3, 5, 7]; +iterable.foo = 'hello'; + +for (let i in iterable) { + console.log(i); // "0", "1", "2", "foo", "arrCustom", "objCustom" +} +for (let i of iterable) { + console.log(i); // 3, 5, 7 +} +console.log(Object.keys(iterable)); // [ "0", "1", "2", "foo" ] + +// Map 객체 +const m = new Map([ + ['a', 1], + ['b', 2], +]); +console.log(m); // Map(2) {'a' => 1, 'b' => 2} +for (let i in m) { + console.log(i); // 순회 되지 않음. undefined +} +for (let i of m) { + console.log(i); // ['a', 1], ['b', 2] +} +console.log(Object.keys(m)); // [] +``` + +`for...in` 루프는 객체의 모든 열거 가능한 속성에 대해 반복하며 문자열 키 값을 반환한다. 추가로 인덱스의 순서를 보장하지 않는다. + +`for...of` 구문은 컬렉션 전용이다. 모든 객체보다는, [Symbol.iterator] 속성이 있는 모든 컬렉션 요소에 대해 반복하며 컬렉션을 반환한다. + +`keys`는 해당 키를 가져오지만, 상속된 값은 가져오지 않는다. diff --git a/_posts/algorithm/leetcode/hard/42.md b/_posts/algorithm/leetcode/hard/42.md new file mode 100644 index 00000000..6c7aa1f9 --- /dev/null +++ b/_posts/algorithm/leetcode/hard/42.md @@ -0,0 +1,118 @@ +--- +title: '[LeetCode] 42. Trapping Rain Water' +description: '2차원 좌표의 영역을 빠르게 구해보자' +url: 'leetcode-hard-42' +tags: ['algorithm', 'leetcode', 'hard', 'stack', 'two-pointers'] +coverImage: 'https://github.com/1ilsang/dev/assets/23524849/e0fcd614-46f9-4221-a284-570b9591a1b8' +date: '2023-10-14T08:10:32.304Z' +ogImage: + url: 'https://github.com/1ilsang/dev/assets/23524849/e0fcd614-46f9-4221-a284-570b9591a1b8' +--- + +![cover](https://github.com/1ilsang/dev/assets/23524849/59fc8404-8da9-451c-bf9b-36c33e60e97a 'cover') + +> [문제 링크](https://leetcode.com/problems/trapping-rain-water) + +빗물이 고일 수 있는 모든 영역을 구하면 되는 문제다. + +기본적으로 물은 '높은 곳에서 낮은 곳'으로 흐르기 때문에 우리는 높이를 비교하면서 빗물이 고일 수 있는지 판단해야 한다. + +이 문제는 두 가지 스택과 투포인터 두 가지로 풀 수 있다. 두 방법 모두 알아두면 좋기 때문에 두 가지 해석을 모두 하려고 한다. + +## 접근법 1. Stack O(n) T(n) + +스택 접근은 "가로로 면적을 합해가는" 방식이다. + +빗물이 고이기 위해서는 양쪽으로 벽이 있어야 한다. + +스택을 활용해 양 벽을 계산하는 방법은, 높이가 감소할 때는 스택에 푸쉬하고 높이가 이전 탑보다 높아질 때는 팝을 하면서 얼마만큼의 빗물을 저장할 수 있는지 계산하면 된다. + +![example](https://github.com/1ilsang/dev/assets/23524849/fbb24919-41e2-4ff7-9a66-876b590d0c79) + +0번째 높이부터 순회해 보자. + +1. 모든 원소를 순회할 때마다 스택에 푸쉬한다. 왼쪽의 그림과 같이 높이가 감소해 나갈 때에는 특별한 작업 없이 계속 진행한다. +2. 중앙의 그림(`i = 3`)의 상황일 경우 현재 벽이 스택의 Top 값보다 더 높기 때문에 빗물이 고일 수 있다. 현재 높이와 같거나 클 때까지 스택에 쌓여있는 벽들을 pop 하며 "가로로" 누적값을 더한다. 여기서는 높이 1이 최대이므로 스택에 추가된 높이 2(`i = 0`)까지 계산하지 않고 끝난다. +3. 우측의 그림(`i = 4`)에서도 동일하다. 현재 높이보다 큰 높이가 나올 때까지 팝을 하며 계산한다. 2번에서 이미 높이 1일 때의 경우를 계산했으므로 높이 2일 때의 가로 값만 계산하면 된다. + +마지막 노드까지 위의 방식을 계속해서 하면 모든 면적을 구할 수 있다. 코드와 라인별 해석은 제일 아래에 작성해 두었다. + +## 접근법 2. Two Pointers O(n) T(1) + +투포인터 접근은 "세로로 면적을 합해가는" 방식이다. + +빗물이 고이기 위해서는 양쪽으로 벽이 있어야 한다. + +투포인터는 양끝에 포인터를 설정하고 좌우로 움직이며 높이가 더 높은 쪽을 향해 간다. 높이가 높은 쪽을 향해 양 포인터를 옮기면 반대로 낮은 쪽은 빗물이 고이는 곳이기 때문에 세로로 더해나가면 된다. + +![example](https://github.com/1ilsang/dev/assets/23524849/57196d71-ccc4-480a-bef3-b9d34df762e2) + +L, R을 양 끝에 두고 순회하면서 각각 MAX 값을 구한다. + +1. 최초의 상태. 최대값(가로선)을 설정한다. +2. `L < R`이라면 L을 옮기고 아니면 R을 옮긴다. 여기는 L를 옮겼다. `i = 1`의 높이가 L의 최대높이보다 낮으므로 그 차이를 더한다. +3. `L < R`일 때까지 L을 옮긴 모습이다. +4. R을 옮겼다. `i = 8`의 높이가 R의 최대높이보다 낮으므로 그 차이를 더한다. +5. 계속해서 반복하면 결국 LR은 한곳으로 모이고 그 사이의 모든 세로 값이 더해져 빗물의 면적을 구할 수 있다. + +## 최종 코드 + +```js +// Stack +const trap = (height) => { + let res = 0; + let i = 0; + const stack = []; + + while (i < height.length) { + const curHeight = height[i]; + // 현재 높이가 스택의 마지막 높이보다 높다면 + while (stack.length > 0 && height[stack[stack.length - 1]] < curHeight) { + const lastI = stack.pop(); + // 스택이 비었다는 의미는 자신뿐이므로 빗물이 고일 수 없기 때문에 탈출한다. + if (stack.length === 0) break; + const peekI = stack[stack.length - 1]; + // 현재 위치(i)와 스택의 다음 위치(peekI)의 거리를 구한다. + const dist = i - peekI - 1; + // 현재 위치의 높이와 스택의 다음 위치 높이중 낮은 값을 기준으로 스택의 마지막 높이를 뺀다. + // 마지막 높이만큼은 빗물이 고일 수 없기 때문 + const h = Math.min(curHeight, height[peekI]) - height[lastI]; + res += dist * h; + } + stack.push(i++); + } + + return res; +}; +``` + +```js +// Two-pointers +const trap = (height) => { + let res = 0; + let l = 0; + let r = height.length - 1; + let lMax = 0; + let rMax = 0; + + while (l < r) { + const curLeft = height[l]; + const curRight = height[r]; + // 매번 max 값을 갱신한다. 그래야 자신이 줄어든 값인지 비교할 수 있다. + lMax = Math.max(curLeft, lMax); + rMax = Math.max(curRight, rMax); + + // 현재 높이가 최대값 보다 적다면 고일 수 있으므로 추가한다. + if (curLeft < lMax) { + res += lMax - curLeft; + } + if (curRight < rMax) { + res += rMax - curRight; + } + // 양쪽의 포인터를 비교해서 높이가 더 큰 방향으로 이동한다. + curLeft < curRight ? l++ : r--; + } + + return res; +}; +``` diff --git a/_posts/algorithm/leetcode/medium/238.md b/_posts/algorithm/leetcode/medium/238.md new file mode 100644 index 00000000..920ff904 --- /dev/null +++ b/_posts/algorithm/leetcode/medium/238.md @@ -0,0 +1,136 @@ +--- +title: '[LeetCode] 238. Product of Array Except Self' +description: '연속되는 곱셈을 어떻게 처리할까?' +url: 'leetcode-medium-238' +tags: ['algorithm', 'leetcode', 'medium', 'product'] +coverImage: 'https://github.com/1ilsang/dev/assets/23524849/e0fcd614-46f9-4221-a284-570b9591a1b8' +date: '2023-08-13T13:31:34.976Z' +ogImage: + url: 'https://github.com/1ilsang/dev/assets/23524849/e0fcd614-46f9-4221-a284-570b9591a1b8' +--- + +![cover](https://github.com/1ilsang/dev/assets/23524849/29e0b7b7-8be2-4ad6-8289-d94681c21c1c 'cover') + +> [문제 링크](https://leetcode.com/problems/product-of-array-except-self/) + +`nums` 배열 원소의 모두 곱한 값에서 해당 원소를 나눈 값을 출력하는 문제이다. 문제에서는 나눗셈하면 안 된다고 명시하고 있으므로 나눗셈하지 않고 풀어야 하는 게 포인트이다. + +1. 나눗셈을 하지 않아야 함. +2. T=O(n), S=O(1)으로 풀어본다. + +따라서 위의 두 가지를 목표로 이 문제를 해결해 보려고 한다. + +## Ideation + +`nums = [1, 2, 3, 4]`의 경우를 살펴보자. + +기대하는 정답은 `[24, 12, 8, 6]`이다. 해당 배열이 나오기 위해서는 전체 값의 곱(`1*2*3*4 = 24`)에 각자의 원소를 나누면 된다. + +전체 곱에서 **자신을 나누어 나오는 값**은 자신을 제외한 모든 값의 곱과 동일하다. + +> e.g. 원소 3을 기준으로 한다면 `1*2*3*4 / 3` === `1*2*4`이다. + +따라서 우리는 **자신을 제외한 곱들의 왼쪽과 오른쪽을 곱해주면 정답이 된다**는 것을 알 수 있다. + +> e.g. 원소 3을 기준으로 3의 좌/우의 곱은 `1*2` \* `4` = `8`이다. + +그러므로 왼쪽의 곱들과 오른쪽의 곱들을 기억해 두고 각 원소별 값을 출력해 주면 해결할 수 있다. + +해당 원소까지의 왼쪽, 오른쪽 곱셈 값은 누적 배열을 활용하면 된다. + +> e.g. 원소 n 기준 왼쪽 `[n-2, n-2 * n-1]` \* `[n+1, n+1 * n+2]` 오른쪽 + +```js +const nums = [1, 2, 3, 4]; + +// @NOTE: 요소의 왼쪽까지의 곱을 기준으로 하기 때문에 첫 번째는 1으로하고 마지막 원소까지 곱할 필요는 없다. +const leftArr = [ + 1, + 1 * nums[0], + 1 * nums[0] * nums[1], + 1 * nums[0] * nums[1] * nums[2], +]; // [1, 1, 2, 6]; + +// @NOTE: 요소의 오른쪽까지의 곱을 기준으로 하기 때문에 직관적으로 역순으로 한다. +const rightArr = [ + 1 * nums[2] * nums[1] * nums[0], + 1 * nums[2] * nums[1], + 1 * nums[2], + 1, +]; // [24, 12, 4, 1]; +``` + +## Implementation(T=O(n), S=O(n)) + +위의 정리를 토대로 구현을 해보자. + +1. n 번째 원소의 왼쪽 곱의 값들을 저장한다. +2. n 번째 원소의 오른쪽 곱의 값들을 저장한다. +3. 전체 원소를 순회하며 n 번째 원소의 좌우 값의 곱을 저장한다. +4. 리턴한다. + +```js +// Left Arr +const l = nums.reduce( + (acc, cur) => { + const last = acc[acc.length - 1]; // 누적값 + acc.push(cur * last); // 이전의 누적값 * 현재 원소를 통해 누적값을 채운다. + return acc; + }, + [1], +); +l.pop(); // 마지막 원소는 필요 없으므로 빼준다. + +// Right Arr +const r = nums.reduce( + (acc, _, index) => { + const last = acc[0]; + const cur = nums[nums.length - index - 1]; + acc.unshift(cur * last); + return acc; + }, + [1], +); +r.shift(); + +// Left * Right Arr +const answers = nums.map((_, index) => l[index] * r[index]); +return answers; +``` + +위의 구현으로 문제를 해결할 수 있지만 공간 복잡도가 `O(n)`이기 때문에 더 최적화가 가능하다. + +## Implementation(T=O(n), S=O(1)) + +**좌우 배열은 결국 마지막에 곱하기 위해서만 존재**한다. + +따라서 굳이 저장하지 않고 바로바로 곱해나가면 공간복잡도를 `O(1)`으로 줄일 수 있다. + +```js +const answers = []; + +let last = 1; +// @NOTE: Step1. Answers 배열에 미리 왼쪽 곱 배열을 세팅 +for (let i = 0; i < nums.length; i++) { + const cur = nums[i]; + answers[i] = last; + last *= cur; // 누적값 갱신 +} + +last = 1; +// @NOTE: Step2. Answers가 이미 좌측 곱 배열이므로 우측 값을 그대로 곱해주면 정답이 된다. +for (let i = nums.length - 1; i >= 0; i--) { + const cur = nums[i]; + answers[i] *= last; + last *= cur; +} +return answers; +``` + +> NOTE: 어쨌든 answers 배열을 사용하므로 정확히는 O(n)이지만 +> +> LeetCode에서 return 배열(answers)이 아닌 추가 배열을 사용하지 않는다는 것으로 O(1)으로 취급하고 있다. + +연속되는 곱셈의 합을 이용한 재밌는 문제였다. + +그럼 이만~ diff --git a/_posts/book/micro-state-management.md b/_posts/book/micro-state-management.md new file mode 100644 index 00000000..731c3733 --- /dev/null +++ b/_posts/book/micro-state-management.md @@ -0,0 +1,183 @@ +--- +title: 'Micro State Management with React Hooks 리뷰' +description: '상태 관리의 종류와 기술들에 대한 이해' +url: 'micro-state-management-review' +tags: + ['book', 'review', 'react', 'hooks', 'context', 'zustand', 'jotai', 'valtio'] +coverImage: 'https://github.com/1ilsang/dev/assets/23524849/3a57dcf8-43b1-4e95-ae8d-2b5194c83122' +date: '2024-03-01T00:00:00.000Z' +ogImage: + url: 'https://github.com/1ilsang/dev/assets/23524849/3a57dcf8-43b1-4e95-ae8d-2b5194c83122' +--- + +![cover](https://github.com/1ilsang/dev/assets/23524849/4e361695-129c-4d6b-885e-ad8805549b4a 'cover') + +[Micro State Management with React Hooks](https://product.kyobobook.co.kr/detail/S000061587593) 리뷰를 해보려고 한다. + +## 선택하게 된 계기 + +이 책은 작년에 읽었었는데, 당시에는 이해가 많이 안 돼서 깊이 있게 생각하지 못했었다. + +실무에서 [Jotai](https://github.com/pmndrs/jotai)를 사용하기 시작하면서 사용하는 라이브러리에 대한 이해를 높이고자 다시금 이 책을 읽게 되었다. + +이 책은 세 가지 흥미로운 부분이 있다. + +1. 원서다. 영어 기술 서적은 처음 읽어서 꽤 도전적이었다. +2. Zustand, Jotai 등을 만든 상태관리에 진심인 메인테이너 [Daishi Kato](https://github.com/dai-shi)가 직접 펴낸 책이다. +3. React에서의 상태 관리 전략을 다양하게 보여주기 때문에 시야가 넓어진다. + +다 읽고 나서 알게 되었는데, [이 책은 최근에 한국어로 번역되었다](https://product.kyobobook.co.kr/detail/S000212233308)(-\_-). 이때 아니면 언제 원서 읽었겠나 싶어 만족하려고 한다. + +## 간단한 요약 + +- 상태란 무엇일까? +- 상태는 어떻게 존재할 수 있을까? +- 상태를 변화시키는 방법은 어떤 것들이 있을까? +- React hooks은 상태 관리 라이브러리에 어떤 영향을 주었을까? +- 우리는 리렌더링을 어떻게 회피할 수 있을까? + +이 책을 읽으면서 얻을 수 있었던 인사이트들이다. + +너무 어렵게 들어가지 않으면서 상태 관리의 다양한 기법들을 제시하기 때문에 주니어부터 시니어까지 충분히 배울게 많은 책이라는 생각이 든다. + +## 인상 깊었던 부분 + +책을 읽으면서 좋았던 예제나 포인트들을 가볍게 소개하고자 한다. + +### 1. React State에 대한 이해 + +React에서 상태(state)는 UI를 나타내는 모든 데이터다. React는 상태와 함께 렌더링 할 컴포넌트를 처리한다. + +책의 서두에서 기존 상태 관리의 문제점에 대해 이야기한다. + +```md +React Hooks 이전에는 모놀리식 상태 라이브러리들이 유행했다. +이는 DX 향상에 큰 도움을 주었지만 사용되지 않는 기능들도 포함된다는 문제가 있었다. + +1. Form 상태는 글로벌 상태와 별도로 다루어져야 하지만, 단일 상태 솔루션에서는 불가능하다. +2. 서버 캐시 상태는 refetching과 같은 다른 상태들과는 다른 독특한 특징을 가지고 있으나 분리가 불가능하다. +3. 브라우저 네비게이션 상태는 원본값이 브라우저 측에 기반한다는 성질이 있어 단일 상태 솔루션에 적합하지 않다. + +이런 문제들을 해결하는 것이 React Hooks의 목표 중 하나이다. +``` + +위 내용을 요약하면 Hooks 이전의 상태는 상태의 순수함이 결여되어 있었다는 점이 문제라고 꼬집는다. + +상태는 전역 상태와 지역 상태가 있다. 지역으로 존재해야 할 데이터들이 전역 스토어에 혼재되어 있고 서버/브라우저 상태가 무분별하게 스토어에 들어 있었다. + +React Hooks로 위의 내용들을 해결하고자 한다는 내용이 인상적이었다. + +### 2. useState vs useReducer + +React 컴포넌트가 상태를 가지고 있으려면 어떻게 해야 할까? + +일반 변수나 전역 변수로 선언하고 사용할 수도 있다. 하지만 해당 값이 변경되었다고 한들 컴포넌트는 리렌더링 되지 않는다. + +컴포넌트가 지역 상태의 변경을 감지하고 리렌더링 하려면 `useState` 혹은 `useReducer`를 사용해야 한다. + +> +1. React는 전역 상태를 제공하지 않는다. + +```jsx +// CASE 1. +const [count, setCount] = useState(0); +setCount(1); +setCount(1); // Not render. 동일한 값이므로 렌더링 하지 않는다. + +// CASE 2. +const [state, setState] = useState({ count: 0 }); +setState({ count: 1 }); +setState({ count: 1 }); // Re-render! 주소 참조는 항상 다른 값이 된다. + +// CASE 3. +state.count = 1; +setState(state); // Not render. state 주소값은 변하지 않았기 때문에 렌더링 하지 않는다. + +// CASE 4. +setCount(count + 1); +setCount(count + 1); // 동일하게 두 번 호출되면 +2가 아닌 +1만 될 수 있다. 이를 해결하기 위해선 함수 업데이트가 필요하다. + +// CASE 5. +setCount((prev) => prev + 1); // 함수로 작성하게 될 경우 아무리 빠르게 눌러도 횟수만큼의 업데이트가 될 것을 보장한다. 이는 내부적으로 함수를 연속적으로 호출하기 때문이다. + +// CASE 6. +setCount((prev) => prev); // 결과값이 직전과 동일하기 때문에 리렌더링이 일어나지 않는다. + +/** + * init 함수는 useState를 호출하기 전에 실행되지 않는다(lazy initialize). + * 이는 컴포넌트가 마운트 될 때 한 번만 호출함을 뜻한다. + **/ +const init = () => 0; +const [count, setCount] = useState(init); +``` + +useState의 다양한 사용 사례를 들어 리렌더링이 되는 경우를 설명한다. 또한 지연 초기화 경우를 들어 상태 관리의 최적화에 관해 설명한다. + +```jsx +const init = (count) => ({ count, text: 'hi' }); +const reducer = (state, action) => { + switch(action.type) { + case 'INCREMENT': + return { ...state, count: state.count + 1 }; + case 'SET_TEXT': + if(!action.text) { + return state; // THIS IS Bailout! 리렌더링 되지 않음. + return { ... state, text: action.text }; + default: + throw new Error(`unkown action type`); + } + } +} + +const Component = () => { + const [state, dispatch] = useReducer(reducer, 10, init); + return ( +
{state.count} + + dispatch({ type: 'SET_TEXT', text: e.target.value })} /> +``` + +`useReducer` 부분에는 `useState`에서는 불가능한, reducer만의 특별한 기능들을 언급하며 왜 useState는 useReducer로 대체 가능하지만, 역은 안되는지에 대해 설명한다. + +useState와 다르게 useReducer에서 인라인으로 함수를 선언하면 사이드 이펙트가 발생한다거나(useReducer는 렌더링 단계에서 reducer를 호출하므로) 복잡한 상태 관리를 처리하기 위한 reducer만의 기법들은 좋은 인사이트를 주었다. + +### 3. ContextAPI vs Import + +Context를 활용한 상태와 모듈(import) 상태에 대한 비교는 내가 가지고 있던 전역/지역 상태에 대한 시야를 넓혀주었다. + +> 모듈 상태는 ESM 스코프에 특정 상수 혹은 변수를 정의하는 것을 의미. +> +> `export const store = {}` 와 같다. + +우리는 전역 상태가 앱 전반에서 접근할 수 있는 상태라고 알고 있다. 그런데 이 "앱 전반"은 "React 외부에서 접근"한다는 것까지 포함해야 한다. + +ContextAPI로 작성된 전역 상태(root provider)는 React 외부에서 접근이 불가능하다. 하지만 모듈로 작성된 코드는 외부에서 접근이 가능하다. + +또한 Context는 기본적으로 [싱글턴 패턴](https://ko.wikipedia.org/wiki/%EC%8B%B1%EA%B8%80%ED%84%B4_%ED%8C%A8%ED%84%B4)을 위해 디자인되지 않았다. 여러 프로바이더에서 사용될 수 있으며 여러 서브 트리에서 다양한 상태로 존재할 수 있다. 하지만 모듈 상태는 싱글턴으로 존재한다. 따라서 단일 전역 상태를 위해서는 모듈 상태를 사용해야 한다. 이는 인메모리에 올라간 단일 변수로 취급되기 때문이다. + +### 4. Zustand vs Jotai vs Valtio + +| | [Zustand](https://github.com/pmndrs/zustand) | [Jotai](https://github.com/pmndrs/jotai) | [Valtio](https://github.com/pmndrs/valtio) | +| :-------------: | :--------------------------------------------------------------------------------------: | :-----------------------------------------------------------------------------------------------------------------------------: | :-------------------------------------------------------------------------------------------------------------------------------------: | +| 상태의 위치 | Module | React Component | Module | +| 상태의 형태 | Immutable | Immutable | Mutable | +| 상태 변경 전략 | Selector | ContextAPI | Proxy | +| 재사용성 | 모듈 상태는 재사용이 까다롭다(싱글턴). 재사용을 위해 결국 Context를 쓰게 된다 | Provider를 통해 쉽게 재사용이 가능하다 | Zustand와 동일 | +| 코드 학습량 | 셀렉터에 대한 이해가 있어야 한다 | Context 및 Atoms에 대한 이해가 있어야 한다 | 순수 자바스크립트로 이루어져 있어 학습량이 거의 없다 | +| 리렌더링 최적화 | 개발자가 셀렉터를 잘 써야 한다 한다 | 개발자가 Atom 단위를 적절하게 활용해야 한다 | Proxy가 자동으로 해준다 | +| 비고 | 셀렉터 최적화, 객체 참조와 메모이제이션에 익숙하다면 편하게 단일 스토어를 사용할 수 있다 | Context 기반이므로 Jotai에서 가능한 것들은 Context에서도 가능하다. React LifeCycle과 공존하므로 예측 가능하다(Suspense 지원 등) | 자동 리렌더링 최적화 및 순수 JS 기반이라 편하게 사용 가능하다. 불변성을 위해 코드가 복잡해지지 않아도 된다. 하지만 디버깅 과정이 어렵다 | + +저자가 직접 만든 상태 관리 라이브러리들을 비교 하는 부분은 이 책의 하이라이트라고 생각한다. + +왜 여러 상태 라이브러리를 만들수 밖에 없었는지, 각 라이브러리의 리렌더링 회피 전략과 차이를 설명한다. 또한 공통점도 설명하는데 세 라이브러리 모두 "코드량이 적다". + +저자가 가장 중요하게 생각하는 포인트라고 생각한다. + +## 맺으며 + +기본적으로 React를 어느정도 이해하고 있는 개발자를 대상으로 작성된 책이지만 코드 자체가 어렵진 않아서 초심자도 읽어볼 만하다고 생각한다. + +책을 읽으면서 상태 관리에 대해 시야가 넓어질 수 있었다. + +다음 월간 다이브에는 "상태"를 주제로 해보려고 한다. 책을 통해 배운 것들을 잘 풀어보고 싶다. + +상태 관리의 종류와 기법들에 대해 이해하고 싶다면 추천하고 싶은 책이다. diff --git a/_posts/book/proving-ground.md b/_posts/book/proving-ground.md new file mode 100644 index 00000000..90f314dc --- /dev/null +++ b/_posts/book/proving-ground.md @@ -0,0 +1,58 @@ +--- +title: '"사라진 개발자들" 리뷰' +description: '악삭박박과 최초의 개발자들' +url: 'proving-ground-review' +tags: ['book', 'review', '사라진개발자들', 'eniac'] +coverImage: 'https://github.com/1ilsang/dev/assets/23524849/7da7662a-9991-467b-b7bc-cfd8819da792' +date: '2023-09-24T04:24:40.856Z' +ogImage: + url: 'https://github.com/1ilsang/dev/assets/23524849/7da7662a-9991-467b-b7bc-cfd8819da792' +--- + +![cover](https://github.com/1ilsang/dev/assets/23524849/38f64546-9be7-4908-b95d-0d4b963e4160 'cover') + +여성 에니악 개발자 6인의 이야기를 중심으로 최초의 컴퓨터 탄생과 프로그래머의 역사를 풀고 있는 "사라진 개발자들"을 리뷰해 보려고 한다. 스포가 어느 정도 있다. + +## 선택하게 된 계기 + +기술 서적을 읽다 보면 가끔 전혀 이해할 수 없는 혹은 접해보지 못한 키워드를 만날때가 있다(e.g. 천공 카드). 그때마다 그 단어의 기원을 알아가는 것이 재밌었다. + +이 책 또한 위의 흥미에서 시작되었다. "전자식 숫자 적분 및 계산기(Electronic Numerical Integrator And Computer; ENIAC, 에니악)"이라니 정보처리기사 공부할 때 말고는 들어본 적도 없다. 거기에 최초의 개발자라니! 선택을 안 할 수가 없었다! + +## 간단한 요약 + +미국은 2차 세계대전에서 우위를 점하기 위해 대포의 정확도를 높이고자 무던히 노력했다. 땅의 상태나 온도, 바람의 세기 등 다양한 변수를 고려해 최대한 정확한 사표(射表)를 만들고자 했다. 전시의 상황은 시시각각 변했기 때문에 빠르고 정확한 사표가 필요했고 이를 위해 필라델피아의 탄도 연구소에서는 계산을 위한 컴퓨터(compute-er)들을 고용하기 시작했다. 많은 남성들이 전장으로 갔기 때문에 자연스럽게 컴퓨터들은 수학과를 졸업한 여성들로 채워지게 되었다. 이후 에니악 6인이 될 여성들은 여기서 컴퓨터로써 만나게 된다. + +한편 진공관의 가능성을 믿은 존과 프레스(에커트)는 에니악을 개발한다. 에니악의 실행을 위해선 배선을 옮기고 각각의 논리를 이어 나가야 했는데 그 과정을 에니악 6인이 진행해 나가게 된다. 여기의 논리들에는 이후 모든 프로그래밍에서 사용되는 `if`, `loop` 등이 있었다. 그녀들은 최초의 프로그래머로써 최초의 컴퓨터(당시)와 작업을 진행하게 된다. + +## 첫 인상 + +맨 처음 이 책을 선택했을 때 나의 기대는 "에니악"을 중심으로 여성 개발자들의 활약상을 보는 것이었다. 하지만 프롤로그를 읽으면서 책의 흐름은 나의 기대와 다르게 흘러갈 것을 짐작할 수 있었다. 저자는 어떤 여성이 컴퓨팅 분야에 어떤 업적을 남겼는지 관심을 가졌고 그 역사를 찾다 흑백 에니악 사진에서 이름을 알 수 없는 여성들을 알게 되어 그들의 이야기를 중심으로 책을 만들었다. + +이는 조금 낭패였다. 나는 그들의 삶에는 관심이 없었다. 기술의 기원과 발전에만 흥미가 있었다. 그렇기 때문에 책의 초반 그녀들의 학창 시절과 가족들에 대한 이야기는 꽤 곤혹스러웠다. + +그런데 그녀들의 이야기 속에서 당시 미국의 분위기를 생생하게 접할 수 있었는데 이는 또 다른 매력이었다. 책의 마지막 부에 이르러서 나는 그녀들의 행방이 궁금해 검색하지 않을 수 없었다. + +## 인상 깊었던 부분 + +책의 중간중간에는 당시 분위기를 엿볼 수 있는 문장들이 있다. + +- 대공황 시대에 남성에게만 허용한 일자리가 2차 세계대전 동안 여성으로 채워지게 되었다 +- 전자식 컴퓨터는 불가능하고 불필요하다고 당시 주류 학계는 생각했다 +- 루즈벨트 대통령은 정기적으로 라디오를 이용해 각 가정에 개인적인 메시지를 전했다 + +이외에도 인물들의 이야기 속에서 진주만 습격부터 히로시마 원폭, 로스앨러모스 과학자와 폰 노이만 등 올스타들이 등장한다. 이는 상당히 흥미로웠다. 영화 오펜하이머를 최근에 보았기 때문에 에니악이 수소 폭탄 폭파 장치의 계산을 도왔다는 이야기는 텔러를 떠올리기에 충분했다. 임팩트 있는 역사적인 사건들이 중간중간 나올 때마다 에니악이 얼마나 중요한 위치에 있었는지 이해할 수 있었다. + +에니악 6인의 프로그래밍 여정 또한 흥미롭게 읽을 수 있었다. `LOOP`와 `IF-THEN` 구문을 활용하는 대목이나 `ROM`(read-only-memory)의 기원, 벤치 테스트 과정, 에니악 병렬 프로그래밍과 분할-정복법, `breakpoint` 등이 소개되었다. 그녀들이 성공적으로 에니악 로직을 작성하기 위해 얼마나 노력했는지 알 수 있었다. + +에니악에 대한 이야기도 있었는데, 탄도 연구소에서 90억의 투자를 했다는 점이나 이후 악삭박박의 탄생 여정 및 폰 노이만 형님의 설계까지 흥미롭게 읽을 수 있었다. 직접 프로그래밍에서 프로그래밍 내장식 컴퓨터가 되기까지 대학교에서 잠깐 들었던 내용들이 나오니 상당히 반가웠다. + +## 맺으며 + +책의 한 문장을 뽑으라면 역시 나는 최초의 프로그래머를 선언하는 부분을 가져오고 싶다. + +이 과정에서 프로그래머라는 직업이 탄생했다. 문제를 가진 사람과 컴퓨터를 연결해 문제를 해결하도록 돕는 역할을 하는 사람이 등장한 것이다. 여섯 여성은 현대 컴퓨터 분야 최초의 직업 프로그래머였다. -289p + +우리는 영웅의 그림자에 가려진 또 다른 영웅들을 발굴하고 기억해야 한다고 느꼈다. + +흥미롭게 읽은 책이다. diff --git a/_posts/book/quality-of-job.md b/_posts/book/quality-of-job.md new file mode 100644 index 00000000..9c8c28da --- /dev/null +++ b/_posts/book/quality-of-job.md @@ -0,0 +1,175 @@ +--- +title: '"일의 격"을 읽고' +description: "신수정님의 '일의 격' 책을 읽고 생각을 정리하게 되었다" +url: 'quality-of-job-review' +tags: ['일의격', 'book', 'review'] +coverImage: 'https://github.com/1ilsang/dev/assets/23524849/828d3793-5021-498a-9f8a-c1990d361ae5' +date: '2023-06-22T11:36:39.984Z' +ogImage: + url: 'https://github.com/1ilsang/dev/assets/23524849/828d3793-5021-498a-9f8a-c1990d361ae5' +--- + +![cover](https://github.com/1ilsang/dev/assets/23524849/c72379b1-dde7-42ea-9168-863270b6a51f 'cover') + +최근 일을 어떻게 마주해야 할지, 지속 가능한 회사 생활이 뭘까 고민하던 도중 이 책을 접하게 되었다. + +나는 **'회사에서의 나'를 굳이 일상에서 분리해야 할까?** 라는 생각을 평소에 많이 했기 때문에 '즐거운 회사 생활'을 원했고, 회사 생활을 즐기려고 노력했다. + +앞으로도 이렇게 살아가면 될지? 회사에서 나는 어떤 포지션/페르소나를 가졌는지 의문이 있던 차, 이 책의 다양한 예제와 격언을 통해 나는 어떤 사람인지 조금 더 가시화되었다. + +하여, 읽으면서 나에게 인상 깊었던 구절을 정리해 보려고 한다. + +## 성장하는 나 + +### 안타를 맞는다는 것은 스트라이크를 던질 수 있다는 의미이다(97p) + +예제가 너무 인상적이어서 적어두려고 한다. + +> 심적 불안을 가진 투수가 공을 가운데로 던지지 못한다. 감독은 "스트라이크로 삼진 시키든지 아니면 홈런을 맞아라."고 지시한다. 결국 투수는 홈런을 맞게 되지만 다들 미소짓는다. 그가 홈런을 맞았다는 의미는 이제 그가 볼을 중앙에 던질 수 있음을 의미하기 때문이다. + +난 이 일화가 너무 와닿았고 좋았다. 투수를 훈련시키기 위한 적절한 격언을 할 줄 알며 홈런을 맞아도 여유 있는 감독이 내 주변에는 몇 명이나 있을까? 나는 그러한 감독인가? + +### '즐긴다'는 말의 허상(128p) + +> 즐겨서는 최고의 결과를 얻을 수 없다. 최고가 되려고 하면 그 과정을 즐길 수 없다. + +즐거움이 삶의 모토인 나에게는 정말 슬픈 문장이었다. 이 문장을 반박하고 싶지만, 적절한 예가 떠오르지 않았다. + +즐기면서 한다는 것은 정말 허상인가? + +### 자신이 전문가라면 더 말해야 한다(147p) + +나는 이 부분이 상당히 공감되었다. + +최근 개발자 부트 캠프나 인터넷 강의가 우후죽순 생기면서 회사에서는 엉망인 사람들이 외부 이미지로 강의를 팔아먹는 형태를 규탄하는 글을 봤다. + +그분들이 잘못한 부분도 어느 정도 있겠지만 정말 전문가라면 자신의 지식을 공유하고 더 대중들 앞으로 나와 그런 분들이 자연스럽게 사라질 수 있도록 해야 한다고 생각한다. 그것이 전문가가 개발자 생태계에 기여하는 방법이라 나는 믿고 있다. + +뒤에서 상대방을 비난하기만 한다면 무슨 의미가 있을까? + +### 당신의 재능이 최고의 재산이다(153p) + +> 당신의 직업은 당신의 목적이 아니다. 세상에 보탬이 되는 당신의 재능을 찾아라. + +1. 당신의 재능은 무엇인가? 어떻게 하면 그 재능으로 남을 도울 수 있는가? +2. 무슨 일을 할 때 제일 살아있다는 느낌이 드는가? 그 열정을 누구와 나누고 싶은가? +3. 당신의 가이드와 멘토는 누구인가? 누가 자신이 올바른 길을 가는 데 도움을 주고 지지해 주는가? +4. 당신은 주위 사람이 재능을 발견하고 원하는 것을 성취하도록 어떤 도움을 줄 수 있는가? + +책에 나오는 4가지 질문이다. 개발자들은 비교적 쉽게 1~4번을 채울 수 있을 것 같다. + +## 성공하는 조직 + +### 리더는 체스 플레이어가 아니라 정원사다(164p) + +동료를 장기 말처럼 움직이는 것(마이크로 매니징)이 아니라 스스로 행동하게 하되 그 방향성을 키워주는 것이 좋은 리더라고 생각한다. + +### 상사에게 직언을 어떻게 해야 하나?(187p) + +> 직언은 상대의 이익을 섞어서 해야 한다. + +위의 문장이 좋아서 추가했다. 아무리 좋은 말이라 하여도 날것으로 전달하면 분명 불편해지는 면이 있다고 생각한다. + +상대방의 이익을 섞어서 말한다면 훨씬 더 대화가 매끄럽게 진행된다. 말 잘하고 싶다! + +### 비효율의 숙달화(224p) + +분명 비효율적인데 익숙해져서 그대로 하는 관행들이 있다. 운이 좋게도 나는 관행을 타파하는 회사들에 다녔고 비효율을 바로잡고자 하는 사람들의 옆에서 좋은 인사이트를 많이 얻을 수 있었다. + +그럼에도 나 개인의 프로젝트에서는 비효율의 숙달화가 심한 면들이 있기에 반성하고자 언급한다. + +### 좋은 회사란 무엇인가?(225p) + +> 회사의 가치와 자신이 맞는가? + +이 부분이 정말 중요하다고 생각한다. 나는 취준생일 때 반드시 IT 기업에 가겠다는 강한 신념이 있었다. 개발이 재밌었고 파괴적인 부분을 좋아했다. + +서로 핏이 맞지 않는다면 그 기간 내내 서로 힘들 뿐이라 생각한다. 따라서 맞지않는 회사에 갈 필요도 있을 이유도 없다고 생각한다. + +이는 개인의 에너지와도 직결된다고 생각한다. 직원의 무능은 개인의 부족한 면도 있겠지만 회사가 자신과 안 맞을 확률이 크다고 생각한다. + +### 유능한 직원을 무능하게 만드는 간단한 방법(233p) + +> 부하직원을 의심한다. 부하직원은 자존심이 상하고 업무 의욕이 점점 감퇴한다. 그리고 상사를 조금씩 불편하게 대하게 된다. 상사는 이 모습을 보고 더 의심하게 된다. 악순환이 반복된다. + +필패 신드롬의 내용이다. "칭찬은 고래도 춤추게 한다"는 문장을 좋아하는 나에겐 특히 공감되는 문장이다. + +### 내가 말하지 않으면 리더도 나를 잘 모른다(235p) + +이제까지는 리더가 자각해야 할 부분이라면 이 문장은 팀원으로서 자각해야 하는 부분이라 생각해 언급한다. + +책에서도 나오지만 사실 그 누가 나는 악당이 되겠다 하며 회사 생활을 하겠는가? 커뮤니케이션의 실수가 가장 크다. + +리더의 덕목~ 뺄셈의 리더쉽 등등 리더를 위한 격언은 많지만, 팀원을 위한 내용은 상대적으로 부족한듯 하다. + +리더도 분명 사람이기에 그들에게 자신의 상황/성과를 잘 전파하고 명확하게 의견을 이야기하는 것이 중요하다. + +### 리더는 직원과 어느 정도 개인적 유대를 맺어야 할까?(246p) + +여기 예시가 인상적이었는데 길기 때문에 적진 않았다. + +분명한 건 서로가 서로에게 관심이 있다는 것을 인지할 수 있는 유대는 있어야 한다고 생각한다. + +### 상대가 진짜 똑똑한지 허풍인지 구별하는 방법(259p) + +> 가장 똑똑한 사람들은 끊임없이 자신의 이해를 수정한다. 그들은 이미 해결했던 문제들에 대해서도 다시 고려해본다. 그들은 기존 사고에 대항하는 새로운 관점, 정보, 생각, 모순, 도전 등에 대해 열려있다. 자신의 예전 생각이 잘못되었다면 언제든 바꾼다. + +지속적인 만남을 하다 보면 정말 진국이라고 생각되는 사람이 있다. 그러한 사람들은 위와 같은 부류인 경우가 많았다. + +그들은 자신의 주관은 분명하게 있지만 늘 새로운 도전에 열려있었고 옳다고 느끼면 주관을 바꿀 줄 아는 사람들이었다. 주관을 바꾸면서 자신만의 철학을 리빌딩 하기 때문에 말을 함에 있어 똑똑하다고 느껴지는 경우가 많았다. + +말 잘하는 것이 정말 중요하다고 느끼기 때문에 본받고 싶은 스킬이라 생각한다. + +## 성숙한 삶 + +### 과제의 분리(275p) + +> 그들이 나를 좋아하고 싫어하는 것은 그들의 과제이니 자신과 분리시켜야 한다. + +나는 얼마나 나를 사랑하고 있는가? 나는 남을 얼마나 신경 쓰고 있는가? + +친구가 과제를 대신해달라고 하면 그렇게 화내면서 왜 남의 과제를 하고 있는 것인가? + +### 더 많이 행동하면 더 행복해진다(279p) + +최근 아침에는 수영을 저녁에는 클라이밍을 하고 있는데 정말 행복하다. 운동뿐만 아니라 무엇인가를 한다는 것은 참 의미 있다고 생각한다. + +### 그게 다다(285p) + +> 실수를 했으면 고치면 되고, 잘못을 하면 꾸중을 듣고, 성과가 안 나오면 교훈 삼아 다음에 잘 하면 되고, 차였으면 다른 사람을 찾으면 된다. 그게 다다. + +정말로. 그게 다다. + +### 내가 나를 좌절시키는 것이다(291p) + +과제의 분리와 이어진다고 생각한다. 높은 자존감은 자기애에서 시작한다. + +### 자랑할 것, 자부심을 가질 것이 무엇인가?(307p) + +> 당신은 무엇에 자부심을 가지고 있는가? + +아 + +### 억누르지 말고 관점을 재해석 하라(324p) + +> 관점의 변화, 즉 재해석이 우리의 행동을 바꾼다. + +여기에 문구들이 주옥같다. 누군가 나의 발을 밟는다면 화가나 뒤돌아볼 것이다. 하지만 그 상대가 맹인이라면? 눈 녹듯 분노가 사라지고 부끄러움이 밀려온다. + +부정적 감정은 관점의 재해석으로 해결된다. + +편협한 꼰대는 관점의 고정화로 나타나게 되는 것 아닐까? + +### 인간관계와 우연이 삶에 미치는 영향(328p) + +공정하다는 착각의 내용이 떠오르는 부분이었다. + +취업할 때도 운7기3이라는 말이 있으니 그만큼 운이 삶에 미치는 영향은 지대하다고 생각한다. + +그렇지만 운도 준비된 자가 잡을 수 있다는 말이 있듯 행운이 다가올 때 잡을 수 있도록 늘 준비해야 한다고 생각한다. + +## 맺으며 + +위 격언들을 한 번씩 되돌아보며 더욱 성장한 나. 성공한 삶을 살아갈 수 있도록 노력해야겠다. + +그럼 이만 diff --git a/_posts/book/woowa-type.md b/_posts/book/woowa-type.md new file mode 100644 index 00000000..65ee3a2a --- /dev/null +++ b/_posts/book/woowa-type.md @@ -0,0 +1,172 @@ +--- +title: '우아한 타입스크립트 with 리액트 리뷰' +description: '주니어 FE를 위한 온보딩 책' +url: 'woowa-type-review' +tags: ['book', 'review', 'typescript', 'react'] +coverImage: 'https://github.com/1ilsang/dev/assets/23524849/3e0ae612-003f-43e9-a6b0-d481697fc280' +date: '2023-11-24T12:37:42.554Z' +ogImage: + url: 'https://github.com/1ilsang/dev/assets/23524849/3e0ae612-003f-43e9-a6b0-d481697fc280' +--- + +![cover](https://github.com/1ilsang/dev/assets/23524849/3e0ae612-003f-43e9-a6b0-d481697fc280 'cover') + +"우아한 타입스크립트 with 리액트"의 리뷰를 해보려고 한다. + +## 선택하게 된 계기 + +두 가지 캐치프라이즈가 나를 이끌었다고 생각한다. + +1. 배달의민족 개발 사례로 살펴보는 +2. 주니어 개발자를 위한 온보딩 가이드 + +기술 스택이 동일하다 하여도 회사별로 사용 방식이 상당히 상이하다고 느낄 때가 많았기 때문에 이렇게 간접적으로나마 문화나 기술 적용 방식을 체험해 볼 수 있는 서적을 선호한다. + +또한 주니어 개발자를 위한 온보딩 가이드를 내걸었으므로 어떻게 신입이 회사의 일원으로 빠르게 흡수될 수 있을지 고민한 흔적이 있을 것이라 기대해 선택하게 되었다. + +## 간단한 요약 + +이 책은 앞에서 타입스크립트를 다루고 뒷부분은 리액트를 활용한 여러 기법이나 패턴에 관해 설명한다. + +앞쪽 타입은 신입이 보기에 조금 어려운 타입 좁히기까지 잘 다루고 있다. `extends`와 `infer`를 통한 타입 추론이 익숙하지 않다면 읽어보기를 추천하고 싶다. + +물론 타입스크립트나 리액트를 깊이 있게 공부하려고 이 책을 선택한다면 조금 부족할 수 있다고 생각한다(애초에 주니어 온보딩 책이다). + +온보딩 책인 만큼 API, 리렌더링, 훅스, State 등을 다양한 예제와 패턴을 통해 소개하는데 내용이 좋으므로 사수가 없는 환경에서 개발하는 분들이라면 읽기를 추천하고 싶다. + +## 인상 깊었던 부분 + +책을 읽으면서 좋았던 예제나 포인트들을 가볍게 소개하고자 한다. + +### 타 언어의 타입 시스템과 비교 + +2장에서 타 언어의 타입 시스템을 거론하며 타입스크립트의 타입 철학을 엿볼 수 있게 해준다. 나는 이 부분이 좋았다. + +- 타입스크립트는 다른 명목적으로 구체화한 타입 시스템(Java, C++)과 다르게 구조로 타입을 구분한다. +- 타입스크립트는 타입 시스템을 집합으로 이해하면 된다. 타입은 값의 집합으로 생각할 수 있다. + +```tsx +class Person { + name: string; + constructor(name: string) { + this.name = name; + } +} +class Developer { + name: string; + age: number; + constructor(name: string, age: number) { + this.name = name; + this.age = age; + } +} +function greet(p: Person) { + console.log(p.name); +} +const developer = new Developer('zig', 20); + +// Person 타입을 받지만 Developer 타입 값을 넣어도 무관하다. +// 구조적 서브 타입이기 때문에 에러를 발생시키지 않는다. +// 서로 다른 두 타입 간의 호환성은 오직 타입 내부의 구조에 의해 결정된다. +greet(developer); // OK +``` + +- 타입스크립트가 위와 같은 구조적 타이핑을 채택한 이유는 자바스크립트를 모델링한 언어이기 때문이다. +- 자바스크립트는 덕 타이핑(duck typing)을 기반으로 만들어졌다. 덕타입은 매개변수가 올바르게 주어진다면 그 값이 어떻게 만들어졌는지 신경 쓰지 않고 허용한다. + +### 타입에 대한 고찰 + +```ts +type IdType = string | number; +type Numeric = number | boolean; +// 교차 타입은 두 타입을 모두 만족하는 경우에만 유지된다. +type Universal = IdType & Numeric; // number + +// 따라서 두 타입을 만족하지 못하는 경우 never가 된다. +type DeliveryTip = { + tip: number; +} +type Filter = { + tip: string; +} & DeliveryTip; +const filter: Filter = { tip: ... } // Type '...' is not assignable to type 'never'. +``` + +타입스크립트의 특징(집합적 특징과 구조적 타이핑 등)은 교차 타입에서 혼란을 야기할 수 있다. 이 부분에 대한 내용을 명확하게 설명하고 있다. + +### 개발팀과의 인터뷰 + +![woowa-story](https://github.com/1ilsang/dev/assets/23524849/7e43ccaf-306d-48f0-b0aa-48151564e8ee) + +중간중간 배민 개발팀들이 참조 출연해 해당 타입/기술을 쓰는지, 어떻게 생각하는지 인터뷰하는 것들이 있는데 실무에서 어떻게 생각하는지 생생하게 볼 수 있어서 흥미로웠다. + +자연스럽게 나 또한 질문에 대한 답을 해보곤 하면서 더 몰입할 수 있었다. + +### 친절한 설명 + +```ts +type CreateMutable = { + -readonly [Property in keyof Type]-?: Type[Property]; // - 는 오타가 아니다. +}; + +// 우아한 타입스크립트 https://www.youtube.com/whatch?v=ViS8DLd6o-E +// 제너릭 T 타입이 K로 추론되는 Promise라면 K를 반환하고 아니라면 any를 반환한다. +// 이를 통해 Promise 반환 타입을 좁혀서 추론할 수 있다. +type UnpackPromise = T extends Promise[] ? K : any; +``` + +타입스크립트의 문법 중에는 처음에 이해하기 어려운 것들이 있는데, 하나하나 과정을 풀어서 설명해 준다. + +### 실용적인 예제 + +특정 개념을 설명하고 활용하는 방법을 실무 코드를 기준으로 알려주기 때문에 상당히 실용적인 예제들로 채워져있다. + +```ts +const BottomSheetMap = { + RECENT_CONTACTS: RecentContactsBottomSheet, + CARD_SELECT: CardSelectBottomSheet, +}; +type BOTTOM_SHEET_ID = keyof typeof BottomSheetMap; +type BottomSheetStore = { + // BOTTOM_SHEET_ID(BottomSheetMap 객체의 key 값)의 property 값을 변환한다. + [index in BOTTOM_SHEET_ID as `${index}_BOTTOM_SHEET_ID`]: { + // ... + }; +}; +const store: BottomSheetStore = { + RECENT_CONTACTS_BOTTOM_SHEET_ID: { ... }, // key 값이 index로 가져온 값으로 변환된다. + CARD_SELECT_BOTTOM_SHEET_ID: { ... }, +}; +``` + +바텀 시트별 스토어를 선언하면서 키값을 특정하게 강제하고 있다. + +```ts +type ProductPrice = 100 | 200 | 300; +const getProductName = (productPrice: ProductPrice): string => { + if (productPrice === 100) return '배민 상품권 100원'; + if (productPrice === 200) return '배민 상품권 200원'; + else { + // exhaustiveCheck를 안 해주면 300원에 대한 코드가 없어도 에러가 발생하지 않음. + // 조건별 완벽한 타입 검증을 위해 사용하는 패턴 + exhaustiveCheck(productPrice); // Argument of type 'number' is not assignable to parameter of type 'never'. + return '배민 상품권'; + } +}; +// param 값이 never 타입이므로 해당 함수가 호출되기 전에 타입 가드가 다 되어 있어야 한다. +const exhaustiveCheck = (param: never) => { + throw new Error(`type error!`); +}; +``` + +Exhaustiveness Checking 패턴을 통해 이후 타입이 추가되어도 무결성을 지킬 수 있게 된다. + +이 외에도 컴파일 과정에 대한 설명이나 Axios 가이드 및 훅에서 유의할 점 등 여러 꿀팁들이 있다. + +## 맺으며 + +배민의 공유 문화는 본받을만하다고 생각한다. + +기술업계 특성상 비판적인 시선이 기본적으로 있기 때문에 외부 공개를 꺼릴 수도 있었겠지만, 기술에 대한 공유를 두려워하지 않고 책으로 펴낸 것에 리스펙하게 된다. + +여러 예제가 실제 코딩에 도움이 되기 때문에 추천하고 싶은 책이다. diff --git a/_posts/js/eslint-plugin.md b/_posts/js/eslint-plugin.md new file mode 100644 index 00000000..fcd992d0 --- /dev/null +++ b/_posts/js/eslint-plugin.md @@ -0,0 +1,227 @@ +--- +title: 'ESLint 플러그인 배포하기' +description: 'ESLint 플러그인 배포 방법 알아보기' +url: 'deploy-eslint-plugin' +tags: ['eslint', 'plugin', 'ast'] +coverImage: 'https://github.com/1ilsang/dev/assets/23524849/d3c160f4-daef-49e0-ab36-39009eb277bc' +date: '2023-09-01T06:48:29.956Z' +ogImage: + url: 'https://github.com/1ilsang/dev/assets/23524849/d3c160f4-daef-49e0-ab36-39009eb277bc' +--- + +![cover](https://github.com/1ilsang/dev/assets/23524849/485f661f-95f1-4ffe-81c9-651ea945f92e 'cover') + +ESLint 플러그인 구조를 간단하게 분석하고 커스텀 플러그인을 만들어 배포해 보자. + +## TL;DR! + +1. ESLint에서 제공해 주는 generator를 사용해 프로젝트를 만든다. +2. 규칙을 만든다. +3. 배포한다. + +## 기본 세팅 + +### 플러그인 구조 만들기 + +```sh +# yo는 yeoman의 줄임으로, 스케폴딩 지원 도구다. +npm install -g yo +# ESLint 특화 스케폴딩 인터페이스 CLI를 설치한다. +npm install -g generator-eslint +``` + +ESLint는 플러그인 구조의 통일을 위해 제너레이터를 제공해 주고 있다. + +`yo`는 [yeoman](https://yeoman.io)의 줄임으로, 스케폴딩 지원 도구다. 프로젝트에 필요한 디렉토리 및 파일을 커맨드라인으로 생성해 준다. + +[generator-eslint](https://www.npmjs.com/package/generator-eslint)는 yeoman을 활용해 프로젝트를 구조화할 때 ESLint를 기준으로 설치되도록 래핑 된 패키지이다. ESLint에서 관리/지원하고 있으므로 직접 yo로 구조를 설정하지 않아도 어려움 없이 one-line으로 ESLint 구조를 생성할 수 있게 된다. + +매우 간편하므로 설치한다. + +```sh +# 플러그인 디렉토리 생성 +mkdir eslint-plugin-NAME +cd eslint-plugin-NAME # 디렉토리로 이동 + +# 전역으로 설치한 yo에서 스케폴딩된 generator-eslint를 실행한다. +yo eslint:plugin +? What is your name? GITHUB_NAME # package.json의 author에 추가된다. +? What is the plugin ID? NAME # 해당 Plugin의 실제 이름(배포 명)이 되므로 적절하게 작성한다. +? Type a short description of this plugin: PLUGIN_DESCRIPTION # package.json의 description에 나타난다. +? Does this plugin contain custom ESLint rules? Yes # 커스텀 룰을 추가할 것이므로 Yes. +? Does this plugin contain one or more processors? No # 우리는 eslint 기본 프로세서를 사용할 것이므로 No를 설정한다. + +# 초기 세팅이 되었으므로 dependencies를 설치한다. +npm install +``` + +![default-architecture](https://github.com/1ilsang/dev/assets/23524849/2240acfd-bd8f-4378-9914-09f294f8b69f) + +초기 구조 설정이 완료되면 위와 같이 폴더가 생성된다. + +ESLint의 다양한 규칙은 `lib/rules`에 추가되어야 한다. 우리는 커스텀 규칙을 만들 것이므로 해당 디렉토리에 추가해 나가야 한다. + +### 플러그인 Rule 구조 만들기 + +친절하게도 ESLint에서 커스텀 룰의 구조도 패키징 해주었기 때문에 `yo`를 통해 한 번 더 rule 구조를 만들어 준다. + +```js +var myData = getData123(); // 함수에 숫자가 있으므로 우리의 ESLint 플러그인에서 에러를 발생시킬 것이다! +``` + +우리는 "**함수에 숫자가 있으면 안 된다**"는 룰을 만들어 보자. + +```sh +# generator-eslint에 설정되어 있는 rule 옵션으로 yo를 통해 구조를 만든다. +yo eslint:rule +? What is your name? 1ilsang # rule 파일에 주석으로 author로 추가된다. +? Where will this rule be published? ESLint Plugin # core가 아닌 plugin 추가이므로 plugin으로 설정한다. +? What is the rule ID? no-function-name-number # rule 아이디에 해당한다. rule 파일명이 된다. +? Type a short description of this rule: The function name must not contain numbers. # rule 설명 추가. 주석으로 파일에 추가된다. +? Type a short example of the code that will fail: var myData = getData123(); # 에러 케이스를 설정한다. 함수에 숫자가 있으면 안되므로 에러상황이다. + create docs/rules/no-function-name-number.md + create lib/rules/no-function-name-number.js + create tests/lib/rules/no-function-name-number.js + +No change to package.json was detected. No package manager install will be executed. +``` + +![rule-architecture](https://github.com/1ilsang/dev/assets/23524849/0e38253f-1278-4aba-80ed-ff44b631b27f 's') + +CLI를 다 작성하면 위와 같이 rules 폴더 밑에 추가된 것을 확인할 수 있다. 이제 우리는 해당 파일에서 규칙을 추가하면 된다. + +## ESLint의 소스코드 파싱과 AST + +규칙 추가에 앞서 ESLint의 동작 방식을 가볍게 살펴보자. + +기본적으로 ESLint는 [Espree 파서](https://github.com/eslint/espree)를 사용해 소스코드를 정적 분석한 뒤 AST(Abstract Syntax Tree)를 생성한다. + +우리는 파싱된 트리에서 구문을 분석해 커스텀 규칙에 위반하는지 확인하면 된다. + +![ast-tree](https://github.com/1ilsang/dev/assets/23524849/2b0673b3-e325-4e39-96f4-6c8c96ea1632) + +[astexplorer](https://astexplorer.net/) 사이트를 활용하면 파싱된 AST 트리를 눈으로 쉽게 볼 수 있다. + +우리는 함수명을 분석해야 하므로 `getData123`에 집중한다. 해당 값은 `CallExpression > callee > name`에 존재한다는 것을 확인할 수 있다. + +이제 우리의 커스텀 룰에 추가하면 된다! + +## 규칙 추가 + +```js +// lib > rules > no-function-name-number.js +module.exports = { + meta: { + type: 'problem', // 이 규칙에 위반되는 값은 코드에 없어야 하므로 problem으로 설정한다. + docs: { + // 해당 규칙에 어긋날 경우 빨간줄 위에 뜨는 문구를 설정할 수 있다. + description: 'The function name must not contain numbers.', + recommended: true, + url: 'https://1ilsang.dev/posts/deploy-eslint-plugin', + }, + fixable: true, // 자동 수정을 추가할 예정으로 true로 한다. + schema: [], // 규칙이 여러 옵션을 가지고 있다면 스키마로 분리해 표현할 수 있다. + }, + + create(context) { + // 우리의 규칙을 위해 CallExpression > callee > name의 문자열이 숫자가 있는지 확인하면 된다. + return { + CallExpression(node) { + const { callee } = node; + + // callee.name에 숫자가 포함되면 + if (/[0-9]/.test(callee.name)) { + context.report({ + node, + data: { wrongFunc: callee.name }, + // 에러 메시지를 띄운다. wrongFunc는 현재 함수의 토큰 값이다. + message: `[{{wrongFunc}}()] 함수에 숫자..?`, + // --fix 옵션으로 수정되게 할 수 있다. 숫자를 ''로 치환한다. + fix: (fixer) => + fixer.replaceText(callee, callee.name.replaceAll(/[0-9]/g, '')), + }); + } + }, + }; + }, +}; +``` + +> 각 옵션에 대한 상세 정보는 [공식 문서](https://eslint.org/docs/latest/extend/custom-rules)를 읽어보길 추천한다. + +## 테스트 추가 + +메타테그 및 규칙을 완성하면 테스트를 해보자. + +```js +// tests > lib > rules > RULE_NAME.js +ruleTester.run('RULE_NAME', rule, { + // 테스트를 통과하는 함수. + valid: ['var data = getData();'], + + // 테스트를 통과하지 못하는 함수. + invalid: [ + { + code: 'var data = getData123();', + errors: [ + { + message: '[getData123()] 함수에 숫자..?', + type: 'CallExpression', + }, + ], + }, + ], +}); +``` + +## 배포하기 + +이제 마지막 단계인 배포를 해보자. npm 로그인이 되어있으면 큰 문제 없이 가능하다. + +```json +// package.json +{ + "name": "eslint-plugin-ID", + "version": "0.0.1" +} +``` + +ESLint 플러그인은 `eslint-plugin` prefix가 존재하므로 이름을 지켜준다. + +배포는 버전을 기준으로 진행하게 되므로 코드 수정내역이 발생해 다시 배포한다면 `version`을 업데이트해 주어야 한다. + +```sh +npm publish +``` + +해당 명령어로 배포하면 완료! 만약 `ENEEDAUTH` 에러가 발생한다면 `npm adduser`를 통해 로그인을 해주자. + +## 사용하기 + +배포된 플러그인을 실제로 사용해 보자. `npm i -D eslint-plugin-PLUGIN_ID`으로 설치한다. + +```json +// .eslintrc +{ + "plugins": ["PLUGIN_ID"], + "rules": { + "PLUGIN_ID/RULE": "error" + } +} +``` + +`.eslintrc` 파일에 배포된 플러그인 아이디를 설정하고 rule을 지정한다. + +![result](https://github.com/1ilsang/dev/assets/23524849/adfc66f0-0516-4a77-90dd-6c9a523d8192) + +이제 IDE에서 함수에 숫자를 사용하면 에러가 노출되는 것을 확인할 수 있다. + +`eslint --fix`를 실행하게 되면 `isNumber`로 함수명이 변경된다. + +또한 에러 문구의 `eslint(plugin/rule)`의 링크를 클릭하면 `meta > docs > url` 값으로 리다이렉트 된다. + +그럼 이만! + +## Reference + +- diff --git a/_posts/js/google-adsense.md b/_posts/js/google-adsense.md new file mode 100644 index 00000000..b07dbaef --- /dev/null +++ b/_posts/js/google-adsense.md @@ -0,0 +1,218 @@ +--- +title: '[Next.js] Google AdSense 광고 적용 및 이해하기' +description: '구글 광고 적용기' +url: 'google-adsense' +tags: ['google', 'ad', 'ads', 'adsense', 'nextjs'] +coverImage: '/images/posts/google-adsense/cover.webp' +date: '2024-08-22T07:26:38.617Z' +ogImage: + url: '/images/posts/google-adsense/cover.webp' +--- + +![cover](/images/posts/google-adsense/cover.webp 'cover') + +[Google AdSense](https://adsense.google.com/intl/ko_kr/start/)를 적용한 내용을 정리해 보려고 한다. + +## Index + +- [Index](#index) +- [AdSense 적용 결과](#adsense-적용-결과) +- [Google Ads, AdSense 차이](#google-ads-adsense-차이) +- [AdSense 설정하기](#adsense-설정하기) + - [사이트 등록](#사이트-등록) + - [광고 컴포넌트 적용](#광고-컴포넌트-적용) + - [사이트 소유권 확인](#사이트-소유권-확인) + - [광고 컴포넌트 영역 설정](#광고-컴포넌트-영역-설정) + - [사이트 기준](#사이트-기준) + - [광고 단위 기준](#광고-단위-기준) + - [지급 정보 설정](#지급-정보-설정) +- [수익 구조](#수익-구조) +- [마무리](#마무리) + +## AdSense 적용 결과 + +
+ +ad-bottom-mobile +ad-left-rail-desktop + +
+ +모바일 화면에서는 바텀 앵커 영역, 데스크탑 화면에서는 좌측 레일 부분에 광고를 실었다(현재는 제거). + +광고 컴포넌트 영역은 구글이 자동으로 설정하게 할 수도 있고 직접 특정 영역만 설정할 수 있다. + +이 부분들은 아래에서 자세히 다루겠다. + +## Google Ads, AdSense 차이 + +맨 처음 광고를 달고자 마음먹으면 곧바로 광고 도메인과 관련된 다양한 키워드에 혼란을 느끼게 된다. + +구글 공식 문서에 따른 [Google AdSense와 Ads의 차이점](https://support.google.com/adsense/answer/76231)은 광고 게시자인지 광고주인지에 따라 다르게 선택할 수 있는 플랫폼임을 알려주고 있다. + +우리는 광고 게시자에 해당하기 때문에 AdSense를 적용해야 한다. + +## AdSense 설정하기 + +### 사이트 등록 + +![site register](/images/posts/google-adsense/site-register.webp 'l') + +맨 처음 해야 할 일은 [AdSense에 광고를 실을 사이트를 등록](https://www.google.com/adsense)하는 것이다. + +사이트 등록 후 광고 스크립트 한줄만 사이트에 추가하면 사실상의 모든 과정이 끝난다(!). + +하지만 구글 애드센스를 적용하기 위해선 사이트 심사를 통과해야 한다. 심사 기간은 최대 2주 정도가 소요되며 사이트의 컨텐츠가 갖추어지지 않으면 거절될 수 있다. + +관련 내용을 살펴보니 [설정된 페이지가 주기적으로 조회되지 않으면 심사가 최대 6주까지도 소요](https://support.google.com/adsense/thread/206913501/%EC%82%AC%EC%9D%B4%ED%8A%B8%EC%9D%98-%EA%B4%91%EA%B3%A0%EA%B2%8C%EC%9E%AC-%EC%8B%AC%EC%82%AC-%EA%B8%B0%EA%B0%84?hl=ko)된다고 한다. + +하지만 큰 문제가 없다면 하루 내로 심사가 통과되는 듯하며 이 블로그 또한 하루 내에 심사가 통과되었다. + +사이트 등록을 했다면 광고 스크립트를 적용해 보자. + +### 광고 컴포넌트 적용 + +![ad script register](/images/posts/google-adsense/ad-script-register.webp) + +사이트 심사 및 소유주 확인을 위해 원하는 확인 방법을 선택해 적용하면 된다. + +여기 UI가 이상한데, 3개 중 하나를 적용하는 게 아니다. + +`애드센스 코드 스니펫` 혹은 `메타 태그`로 사이트 심사를 받고 `Ads.txt`로 소유주 확인을 해야 한다. + +죽, 애드센스 코드 스니펫 혹은 메타 태그 + `Ads.txt` 2가지를 적용해야 한다. + +`Ads.txt`는 아래 [사이트 소유권 확인](#사이트-소유권-확인)에서 다루겠다. 지금은 넘어가도 된다. + +나는 애드센스 코드 스니펫을 적용했다. + +```tsx {10} +import Script from 'next/script'; + +export const GoogleAdSense: FunctionComponent = () => { + if (process.env.NODE_ENV !== 'production') { + return null; + } + return ( + + + + + + + + + + + + +``` + +## Reference + +- [공식 블로그 글 보기](https://prettier.io/blog/2023/07/05/3.0.0.html) diff --git a/_posts/js/renovate.md b/_posts/js/renovate.md new file mode 100644 index 00000000..199ac5b8 --- /dev/null +++ b/_posts/js/renovate.md @@ -0,0 +1,143 @@ +--- +title: 'Renovate 간단하게 살펴보기' +description: '패키지 매니징을 자동화 해보자' +url: 'renovate' +tags: ['renovate', 'packageManager', 'bot', 'dependency'] +coverImage: 'https://github.com/1ilsang/dev/assets/23524849/e3637b13-b6b4-4bda-b3bb-7cd9f15928e3' +date: '2022-07-02T05:01:36.129Z' +ogImage: + url: 'https://github.com/1ilsang/dev/assets/23524849/e3637b13-b6b4-4bda-b3bb-7cd9f15928e3' +--- + +![cover](https://github.com/1ilsang/dev/assets/23524849/e3637b13-b6b4-4bda-b3bb-7cd9f15928e3 'cover') + +이번에는 디펜던시를 자동으로 최신화 해주는 [Renovate](https://www.mend.io/free-developer-tools/renovate/)를 소개해보고자 한다. + +## Index + +- INTRO +- Renovate란? +- Renovate의 장점 +- 적용 방법 +- 마무리 + +## INTRO + +![repo-alert](https://github.com/1ilsang/dev/assets/23524849/45e6a922-03f6-42a2-af73-4f9439e9d88c 'l') + +리포지터리에서 위와 같은 노티를 봤을수도 있다. 혹여나 **_critical severity_**가 존재한다면 마음 한켠이 굉장히 불안해지기 시작한다. "그날이 왔구나" 생각하며 일정을 산정해 버전업 계획을 세우게 된다. + +오래된 버전을 올리는것은 굉장히 고통스러운 작업을 동반한다. + +노티로 알려주는 패키지에는 "종속성"에 포함되는 패키지도 있기 때문에 복잡하게 얽힌 의존 관계를 한땀한땀 쫓아가며 올려야 하는 패키지들을 수색하는 과정이 필요하다. + +![file-hierarchy](https://github.com/1ilsang/dev/assets/23524849/0b908c8e-276f-4a62-9b68-a278822b6635) + +만약 `minimist` 라는 라이브러리의 버전을 올려야 한다고 할 경우, 이 라이브러리를 종속성으로 가지고 있는 "실제로 설치된" 라이브러리를 `yarn.lock`과 같은 락파일에서 디펜던시 그래프를 찾아 올라가야 한다. + +위의 예에서는 `detective` 패키지가 종속성을 가지고 있다. 하지만 `detective는` 설치한 적이 없기 때문에 `package.json`에 없다. 따라서 `detective` 라이브러리를 종속하고 있는 또 다른 라이브러리를 찾아 올라가야 한다. + +이는 굉장히 고통스러운 과정이다. + +의존성이 커지기 전에 조금씩 버전을 올렸다면 이런 문제는 없지 않았을까 생각하게 된다. Renovate를 통해 이 문제를 해결해 보자. + +> 깃헙의 공식 툴인 [dependabot 과의 차이점](https://www.libhunt.com/compare-renovate-vs-dependabot-core)도 향후 작성해 볼 예정이다. [레딧에서 다양한 의견](https://www.reddit.com/r/reactjs/comments/us666i/how_do_you_keep_up_with_npm_package_updates/)을 볼 수 있다. + +## Renovate란? + +![renovate-logo](https://github.com/1ilsang/dev/assets/23524849/7931049f-5714-4944-9348-fe12d56c1521 'l') + +[Renovate](https://www.mend.io/free-developer-tools/renovate/)는 자동으로 디펜던시를 업데이트 해주는 봇이다. + +리포지터리의 패키지 관리자 파일을 확인하고 업데이트가 필요한 종속성을 발견하면 Pull Request 를 자동으로 해준다. + +## Renovate의 장점 + +- [MIT license](https://github.com/renovatebot/renovate/blob/main/license) / [오픈소스](https://github.com/renovatebot) +- [간단한 봇 설치](https://github.com/apps/renovate) 및 유지보수가 필요하지 않다. + - 리포지터리의 디펜던시에 renovate가 추가되지 않는다는 큰 장점이 있다(레포와 완전히 별개로 동작). +- 풍부한 json 봇 동작 설정. +- PR 자동 생성 + 릴리즈 노트. +- monorepo 지원. + +![pr-example](https://github.com/1ilsang/dev/assets/23524849/75ce7f26-d82c-4b7b-a589-cfe684307e0f) + +Renovate를 적용하면 [PR](https://github.com/1ilsang/dev/pull/5)이 생성된다. + +PR을 확인해보면 아래와 같은 특징을 찾아볼 수 있다. + +1. 캐럿 -> PIN 버전으로 변경되었다. + - 버전을 특정하고 이후의 버전은 새로운 PR로 생성된다. +2. 릴리즈 노트를 제공한다. + - 현재 버전과 타겟 버전 사이의 추가 내역을 제공해주기 때문에 로그를 명시적으로 확인할 수 있다. +3. Compare Source를 제공합니다. + - 라이브러리 코드의 어디가 바뀌었는지 정확히 알 수 있다. + - 덤으로 메인테이너의 코드 리뷰나 discussion도 눈팅할수 있다. +4. PR이므로 DroneCI 및 actions와 조합해 2중 검증을 할 수 있다. + +그 외에도 main 브랜치에 다른 브랜치가 merge되어 rebase가 필요할 경우 토글 버튼만으로 처리할수 있다. + +```json +{ + "extends": ["config:base"], + // PR의 기본 라벨 설정 + "labels": ["renovate", "translate"], + "packageRules": [ + { + // 타입패키지들은 major업데이트가 아닌 이상 자동 merge + "packagePatterns": ["^@types/"], + "automerge": true, + // automerge시 comment를 설정할 수 있다. + "automergeType": "pr-comment", + "automergeComment": "types: auto merge", + "major": { + "automerge": false + } + }, + { + // lint 관련 패키지들은 하나의 PR로 생성하도록 설정 + "groupName": "lints", + "matchPackagePatterns": ["^eslint", "^prettier", "^markdownlint"], + "labels": ["lint"] + } + ] +} +``` + +또한 JSON 설정을 통해 auto merge, label, semver 범위 지정, [PR 스케줄 설정](https://docs.renovatebot.com/configuration-options/#schedule), **최대 PR 개수 설정(기본 10개)** 등의 옵션을 설정할수 있다. + +- [공식문서 보기](https://docs.renovatebot.com/configuration-options/) + +## 적용 방법 + +적용 방법은 상당히 간단하다. 봇을 설치해 주면 사실상 끝이다. + +1. Renovate app을 설치한다. + +[Renovate app](https://github.com/apps/renovate) 봇을 설치한다. + +![repo-bot](https://github.com/1ilsang/dev/assets/23524849/d47b19d3-694c-4a10-aa7f-abfef7e21f77) + +그후 설치 페이지로 진입해서 봇을 추가할 리포지터리를 선택한다. + +1. Renovate PR을 merge 한다. + +앱을 레포에 등록하면 [자동으로 PR이 생성](https://github.com/1ilsang/dev/pull/2)된다. 해당 PR을 머지한다. + +3. PR 확인 및 renovate.json 설정 + +이제 앞에서 본것과 같이 업데이트가 필요한 라이브러리의 PR이 자동으로 생성된다. 기본값은 10개이기 때문에 renovate.json 값을 수정해 원하는 방식으로 조정할 수 있다. + +## 마무리 + +![finish](https://github.com/1ilsang/dev/assets/23524849/62000c48-245d-4913-8678-dd590eba170a 'l') + +그동안 디펜던시는 "기간 잡아서 한방에 처리하자"로 남겨두고 있었다. + +그렇기 때문에 정말 특정한 이슈가 없는 이상 버전 올릴 생각을 잘 하지 않게 되었고, 그 결과 수많은 Breaking change를 만나며 고생했던 기억이 있다. + +무엇보다 "**사용하고 있는 라이브러리의 최신 근황**"에 대해 궁금해 하지 않았던 점도 한몫 했다. + +이제, Renovate가 제공해주는 지속되는 PR을 통해 놓치지 않고 새로운 버전을 쫓아갈 수 있을거라 생각하고 있다. 또한 changeLog 및 sourceCompare를 통해 각 라이브러리의 근황도 자연스럽게 알게 될거라 기대하고 있다. + +그럼 이만! diff --git a/_posts/js/sort.md b/_posts/js/sort.md new file mode 100644 index 00000000..90d713f1 --- /dev/null +++ b/_posts/js/sort.md @@ -0,0 +1,364 @@ +--- +title: 'Array.prototype.sort() 이해하기' +description: '정열적으로 정렬해 보기' +url: 'array-prototype-sort' +tags: ['ECMAScript', 'array', 'sort'] +coverImage: 'https://github.com/1ilsang/dev/assets/23524849/47823496-4644-4e7b-9801-9d54273d200e' +date: '2024-02-27T20:56:05.629Z' +ogImage: + url: 'https://github.com/1ilsang/dev/assets/23524849/47823496-4644-4e7b-9801-9d54273d200e' +--- + +![cover](https://github.com/1ilsang/dev/assets/23524849/26fb163a-2663-4e47-ba32-583d5e2a3c73 'cover') + +```js +[11, 8, 1, 2, 33, 3].sort(); // [1, 11, 2, 3, 33, 8] + +const arrayLike = { 0: 'c', 2: 'b', 123: '1ilsang', '1ilsang': 123, length: 3 }; +Array.prototype.sort.call(arrayLike); // { 0: 'b', 1: 'c', 123: '1ilsang', '1ilsang': 123, length: 3 }; +// ????? +``` + +JavaScript에서 sort는 어떻게 구현되어 있을까? stable 한가? 브라우저별 차이는 없을까? ECMA의 명세는 어떻게 되어있을까? + +## Index + +- [TL;DR!](#tldr) +- [의문의 시작](#의문의-시작) +- [Array.prototype.sort() 공식 명세](#arrayprototypesort-공식-명세) +- [브라우저별 sort 구현체](#브라우저별-sort-구현체) +- [마무리](#마무리) +- [참고](#참고) + +## TL;DR! + +| Engine | Browser | Algorithm | Stable | In-place | ECMA Spec | +| :----------: | :-----: | :---------------: | :----: | :------: | :-------: | +| V8 | Chrome | Tim sort | O | X | O | +| Webkit | Safari | Bucket / Merge | O | X | O | +| SpiderMonkey | Firefox | Merge + Insertion | O | X | O | + +1. sort 함수는 ECMA 2019부터 stable sort가 되었지만 in-place 하지 않을 수 있다. +2. 유사 배열 객체를 정렬할 때는 length를 기준으로 프로퍼티 값을 비교한다. +3. 브라우저별 sort 함수의 구현체가 다르지만 ECMAScript 명세를 지킨다. + +## 의문의 시작 + +JavaScript의 `sort`는 [Tim sort](https://d2.naver.com/helloworld/0315536)로 구현되어 있다고 알고 있었다. 그런데 그 이상의 [감동](https://www.donga.com/news/Culture/article/all/20230203/117728020/1)이 나에게 있는지 의문이 들었고 스스로에게 아래와 같이 질문해 보았다. + +1. `Array.prototype.sort`의 명세는 어떻게 되어 있는가? +2. 브라우저는 `Array.prototype.sort`의 명세대로 sort를 구현했는가? +3. 모든 브라우저가 Tim sort로 구현되어 있는가? +4. `compareFn`의 유무에 따른 sort 함수의 동작은 무엇이 달라지는가? +5. sort 함수는 [in-place](https://en.wikipedia.org/wiki/In-place_algorithm)하고 [stable](https://en.wikipedia.org/wiki/Sorting_algorithm#Stability) 한가? +6. 유사 배열 객체 또한 sort 함수로 정렬된다. 어떻게 동작하는가? + +![sad](https://github.com/1ilsang/dev/assets/23524849/35862db0-f699-4378-9ecc-ed9ff21c4699) + +자 이제 감동을 채워나가자. + +## Array.prototype.sort() 공식 명세 + +[ECMAScript](https://tc39.es/ecma262)의 내용을 기준으로 설명하겠다. + +![ECMA2019 stable](https://github.com/1ilsang/dev/assets/23524849/814b4f37-a976-4d8c-8121-bb5aec2e143e 'l') + +ECMA2019의 업데이트로 `Array.prototype.sort` 함수는 stable 하도록 명시되었다. + +해당 명세는 [[Normative] Make Array.prototype.sort stable #1340 PR](https://github.com/tc39/ecma262/pull/1340)에서 최초 정의되었다. + +문서의 23.1.3.30에 `Array.prototype.sort`의 동작이 정의되어 있다. 공식 명세를 따라가며 동작을 확인해 보자. + +### 23.1.3.30 Array.prototype.sort (compareFn) + +![ecma official](https://github.com/1ilsang/dev/assets/23524849/f6e1df19-763e-4750-8909-01a2c5dcb102 'l') + +> [Array.prototype.sort](https://tc39.es/ecma262/#sec-array.prototype.sort)의 명세 내용 + +한 줄씩 내용을 해석해 보자. + +#### 1. compareFn의 유효성을 검사한다. + +```js +const list = [3, 4, 6, 1, 5, 3]; +list.sort(123); // TypeError! +Array.prototype.sort.call(list, 123); // TypeError! +``` + +- `compareFn`은 비교 콜백 함수를 뜻한다. +- compareFn이 `undefined`가 아니고 [IsCallable](https://tc39.es/ecma262/#sec-iscallable)(호출 가능)하지 않다면, TypeError를 발생시킨다. + +#### 2. 배열 객체로 변환한다. + +```js +Object('123'); // String {'123'} +Object([1, 2, 3]); // [1,2,3] +Object({ 1: 'a', 2: 'b' }); // {1: 'a', 2: 'b'} +``` + +> 처음 공식 문서를 읽으면 여기서 막힌다. `?`와 같은 표현을 [ReturnIfAbrupt Shorthands](https://tc39.es/ecma262/#sec-returnifabrupt-shorthands)라고 한다. 에러가 발생하면 즉시 리턴하고 아니면 결과를 진행한다는 뜻이다. 자세한 내용은 [Completion Records](https://timothygu.me/es-howto/#completion-records-and-shorthands) 참고. + +- [ToObject](https://tc39.es/ecma262/#sec-toobject)를 호출하여 현재 배열(`this` 값)을 객체로 변환한다. + - 위 코드의 첫 번째 예시와 같이 원시 타입 문자열을 문자열 객체로 변환한다. + - 암묵적 형변환을 이해하고 싶다면 [이 포스트](/posts/implicit-coercion)를 읽어보길 추천한다. +- 객체로 변환해 처리하므로, 이는 sort 메서드가 배열이 아닌 객체에도 적용될 수 있음을 뜻한다. +- 설정된 객체를 `obj`라 명한다. + +#### 3. 객체의 길이를 계산한다. + +```js +const arrayLike = { 0: 'c', 1: 'a', 2: 'b', length: 3 }; +console.log(arrayLike[1], arrayLike.length); // 'a', 3 +Array.isArray(arrayLike); // false +arrayLike instanceof Array; // false +``` + +- [LengthOfArrayLike](https://tc39.es/ecma262/#sec-lengthofarraylike)를 호출하여 변환된 객체(obj)의 길이를 가져온다. + - LengthOfArrayLike 추상 연산은 유사 배열 객체의 `length` 프로퍼티를 반환한다. + - 해당 추상 연산에서 "유사 배열 객체"는 해당 연산이 정상으로 완료되는 객체를 뜻한다. 즉 `length` 프로퍼티(속성)가 있어야 유사 배열 객체로 성립한다. +- 가져온 길이를 `len`이라 명한다. + +#### 4. 정렬 비교를 위한 추상 클로저를 생성한다. + +```js +[11, 8, 1, 2, 33, 3].sort(); // [1, 11, 2, 3, 33, 8] +``` + +- 매개변수로 `x`, `y`가 있는 추상 클로저를 생성한다. 이 클로저는 compareFn을 캡쳐하고 다음 단계를 호출한다. +- 클로저가 실행되면 [CompareArrayElements](https://tc39.es/ecma262/#sec-comparearrayelements)를 호출하여 `x`와 `y`를 비교(compareFn)하고 결과를 반환한다. + - 결과 값은 `-1,0,1` 혹은 에러를 반환한다. + - compareFn이 제공되지 않으면 각 인자를 [ToString](https://tc39.es/ecma262/multipage/abstract-operations.html#sec-tostring)으로 변환 후 문자열 비교(유니코드 포인트 순서) 한다. 이 때문에 위와 같이 기본 `sort` 함수의 동작이 처음에는 당혹스럽게 느껴진다. +- 생성된 추상 클로저를 `SortCompare`이라 명한다. + +#### 5. 새로운 배열에 프로퍼티를 정렬한다. + +- [SortIndexedProperties](https://tc39.es/ecma262/multipage/indexed-collections.html#sec-sortindexedproperties)를 위에서 생성된 값들과 함께 호출한다. + - SortIndexedProperties는 객체(obj)의 인덱싱된 속성들을 SortCompare를 사용해 len 만큼 정렬하는 함수다. + - 여기서 `SKIP-HOLES`는 배열의 빈 요소를 정렬에서 제외하라는 뜻이다. +- SortIndexedProperties의 동작은 대략 아래와 같다. + - 빈 리스트 items를 생성한다 + - 메모리를 추가 사용한다. 명세는 in-place 하지 않다. + - 숫자 0인 k를 정의한다. + - (반복) k < len 이라면 k를 문자열로 변환한 Pk를 생성한다. + - obj에 Pk 속성이 있는지 확인하고 있다면(SKIP-HOLES이므로) 가져온다(kValue). + - 가져온 값(kValue)을 items에 추가한다. + - k의 값을 1 증가시킨다. + - 값이 추가된 items에 SortCompare를 호출해 항목을 정렬한다. +- 정렬된 리스트를 `sortedList`라 명한다. + +#### 6. 정렬된 요소의 개수를 계산한다. + +- sortedList에 있는 요소의 개수를 `itemCount`라 명한다. + +#### 7. j를 0으로 설정한다. + +#### 8. 정렬된 요소를 객체에 설정한다. + +- j가 itemCount보다 작을 동안 반복한다. +- 객체(obj)의 j 번째 속성을 sortedList[j]로 설정한다. + - 원본 객체를 변경하고 있다. [mutable](https://developer.mozilla.org/en-US/docs/Glossary/Mutable) 하다. +- j를 1 증가시킨다. + +#### 9 ~ 10. 빈 요소를 처리한다. + +```js +[1, , 2].sort(); // [1, 2, empty] + +// 인덱스 1이 존재하지 않음. +const arrayLike = { 0: 'c', 2: 'b', 123: '1ilsang', '1ilsang': 123, length: 3 }; + +// 인덱스 2 가 삭제되고 1이 추가되었다. +// 또한 length를 벗어나는(혹은 성립하지 않는) 인덱스는 무시(정렬 X)된다. +Array.prototype.sort.call(arrayLike); // { 0: 'b', 1: 'c', 123: '1ilsang', '1ilsang': 123, length: 3 }; +``` + +- SortedIndexedProperties 호출 때 SKIP-HOLES를 지정했으므로 빈 요소의 수를 유지하기 위해 [DeletePropertyOrThrow](https://tc39.es/ecma262/multipage/abstract-operations.html#sec-deletepropertyorthrow)를 호출하여 나머지 인덱스를 삭제한다. + +#### 11. 객체를 반환한다. + +![ecma official](https://github.com/1ilsang/dev/assets/23524849/f6e1df19-763e-4750-8909-01a2c5dcb102 'l') + +![easy-right?](https://github.com/1ilsang/dev/assets/23524849/a636baec-8023-4017-bbc5-f41deab21f65 's') + +`Array.prototype.sort`의 명세를 보면서 기존의 의문점이 상당히 많이 풀리게 되었다. + +- Array.prototype.sort의 명세는 어떻게 되어 있는가? + - 위에서 다루었다. +- compareFn의 유무에 따른 sort 함수의 동작은 무엇이 달라지는가? + - 비교 함수가 없으면 [[4. 정렬 비교를 위한 추상 클로저를 생성한다.]](#4-정렬-비교를-위한-추상-클로저를-생성한다)의 내용에서 각 인자를 [ToString](https://tc39.es/ecma262/multipage/abstract-operations.html#sec-tostring)으로 변환 후 문자열 비교(유니코드 포인트 순서)한다고 했다. +- sort 함수는 in-place 하고 stable 한가? + - 공식 문서에 따르면 stable 해야 한다. + - [SortIndexedProperties](/posts/array-prototype-sort#5-새로운-배열에-프로퍼티를-정렬한다)의 동작을 보면 빈 리스트 items를 생성 후 하나씩 원소를 추가하고 있으므로 in-place 하지 않을 수 있다. +- 유사 배열 객체 또한 sort 함수로 정렬된다. 어떻게 동작하는가? + - 공식 스펙 자체가 ToObject로 객체화한 후 처리하고 있으므로 객체 비교를 전제로 동작한다. + +이로써 스펙상의 이야기는 되었다. 하지만, 실제로 브라우저에서 어떻게 구현되어 있는지에 따라 동작이 달라질 수 있으므로 남은 의문의 해결과 실제 `sort` 함수의 동작을 확인하기 위해 브라우저별 어떻게 구현해 놓았는지 확인해 보자. + +## 브라우저별 Sort 구현체 + +- 브라우저는 `Array.prototype.sort`의 명세대로 sort를 구현했는가? +- 모든 브라우저가 Tim sort로 구현되어 있는가? + +이제 위의 질문에 답을 해보자. + +### V8 + +```js title="v8/third_party/v8/builtins/array-sort.tq" showLineNumbers{1418} {20} +// https://github.com/v8/v8/blob/12.3.206.1/third_party/v8/builtins/array-sort.tq#L1419 +transitioning javascript builtin ArrayPrototypeSort( + js-implicit context: NativeContext, receiver: JSAny)(...arguments): JSAny { + // 1. If comparefn is not undefined and IsCallable(comparefn) is false, + // throw a TypeError exception. + const comparefnObj: JSAny = arguments[0]; + const comparefn = Cast<(Undefined | Callable)>(comparefnObj) otherwise + ThrowTypeError(MessageTemplate::kBadSortComparisonFunction, comparefnObj); + + // 2. Let obj be ? ToObject(this value). + const obj: JSReceiver = ToObject(context, receiver); + + // 3. Let len be ? ToLength(? Get(obj, "length")). + const len: Number = GetLengthProperty(obj); + + if (len < 2) return obj; + + const isToSorted: constexpr bool = false; + const sortState: SortState = NewSortState(obj, comparefn, len, isToSorted); + ArrayTimSort(context, sortState); + + return obj; +} +``` + +V8의 builtin sort 함수인 [ArrayPrototypeSort](https://github.com/v8/v8/blob/12.3.206.1/third_party/v8/builtins/array-sort.tq#L1419)에는 Tim sort가 적용되어 있다. 또한 주석에서도 알 수 있듯 명세의 순서를 따르고 있다. + +[Tim sort](https://d2.naver.com/helloworld/0315536)는 stable 하지만 in-place하지는 않다([merge sort 보다는 적게 메모리를 사용한다](https://youtu.be/HHN1axRRKx8?si=9KLghQJ1OAYtATK2&t=892)). + +> [V8 블로그의 글](https://v8.dev/blog/array-sort)에 따르면 Chrome 70 전에는 퀵 정렬과 삽입 정렬을 혼합해서 사용하고 있었다. + +### Webkit + +```js title="Webkit/Source/JavaScriptCore/builtins/ArrayPrototype.js" showLineNumbers{508} {25,27} +// https://github.com/WebKit/WebKit/blob/wpewebkit-2.43.1/Source/JavaScriptCore/builtins/ArrayPrototype.js#L509sadfaefafasdf +function sort(comparator) { + "use strict"; + + var isStringSort = false; + if (comparator === @undefined) + isStringSort = true; + else if (!@isCallable(comparator)) + @throwTypeError("Array.prototype.sort requires the comparator argument to be a function or undefined"); + + var receiver = @toObject(this, "Array.prototype.sort requires that |this| not be null or undefined"); + var receiverLength = @toLength(receiver.length); + + // For compatibility with Firefox and Chrome, do nothing observable + // to the target array if it has 0 or 1 sortable properties. + if (receiverLength < 2) + return receiver; + + var compacted = [ ]; + var sorted = null; + var undefinedCount = @sortCompact(receiver, receiverLength, compacted, isStringSort); + + if (isStringSort) { + sorted = @newArrayWithSize(compacted.length); + @sortBucketSort(sorted, 0, compacted, 0); + } else + sorted = @sortMergeSort(compacted, comparator); + + @sortCommit(receiver, receiverLength, sorted, undefinedCount); + return receiver; +} +``` + +Webkit(Safari)의 [sort 함수](https://github.com/WebKit/WebKit/blob/wpewebkit-2.43.1/Source/JavaScriptCore/builtins/ArrayPrototype.js#L509)는 스트링일 경우 [버킷 정렬](https://ko.wikipedia.org/wiki/%EB%B2%84%ED%82%B7_%EC%A0%95%EB%A0%AC)을 사용하고 아니라면 [합병 정렬(merge sort)](https://en.wikipedia.org/wiki/Merge_sort)을 이용한다. 또한 명세의 순서를 따르고 있다. + +두 정렬 모두 stable 하다. 하지만 둘 다 in-place 하지 않다. + +### SpiderMonkey + +```js title="gecko-dev/js/src/builtin/Array.js" showLineNumbers{103} {36} +// https://github.com/mozilla/gecko-dev/blob/661a7d013f6b841e9fbbe56d307cb206f62963c3/js/src/builtin/Array.js#L104 +function ArraySort(comparefn) { + // Step 1. + if (comparefn !== undefined) { + if (!IsCallable(comparefn)) { + ThrowTypeError(JSMSG_BAD_SORT_ARG); + } + } + // Step 2. + var O = ToObject(this); + // First try to sort the array in native code, if that fails, indicated by + // returning |false| from ArrayNativeSort, sort it in self-hosted code. + if (callFunction(ArrayNativeSort, O, comparefn)) { + return O; + } + // Step 3. + var len = ToLength(O.length); + // Arrays with less than two elements remain unchanged when sorted. + if (len <= 1) { + return O; + } + // Step 4. + var wrappedCompareFn = ArraySortCompare(comparefn); + // Step 5. + // To save effort we will do all of our work on a dense list, then create holes at the end. + var denseList = []; + var denseLen = 0; + for (var i = 0; i < len; i++) { + if (i in O) { + DefineDataProperty(denseList, denseLen++, O[i]); + } + } + if (denseLen < 1) { + return O; + } + var sorted = MergeSort(denseList, denseLen, wrappedCompareFn); + MoveHoles(O, len, sorted, denseLen); + return O; +} +``` + +SpiderMonkey(Firefox)는 [Gecko](https://github.com/mozilla/gecko-dev)에 속한 엔진으로, JavaScript 실행에 특화되어 있다. Gecko는 Firefox의 전반적인 렌더링 엔진이다. + +> Gecko 깃헙은 [mozilla-central](https://searchfox.org/mozilla-central/source/js/src/builtin/Sorting.js#78) 미러링 리포지터리로 Read-only다. + +SpiderMonkey는 합병 정렬을 사용하는데, [합병 정렬의 내부에 최적화 작업을 위해 삽입 정렬을 사용](https://github.com/mozilla/gecko-dev/blob/661a7d013f6b841e9fbbe56d307cb206f62963c3/js/src/builtin/Sorting.js#L86)하고 있으며 명세의 순서를 따르고 있다. Tim 정렬과 유사한 부분이 있다. + +합병 정렬과 삽입 정렬은 모두 stable 하지만 삽입 정렬만 in-place 하다. + +### 정리 + +| Engine | Browser | Algorithm | Stable | In-place | ECMA Spec | +| :----------: | :-----: | :---------------: | :----: | :------: | :-------: | +| V8 | Chrome | Tim sort | O | X | O | +| Webkit | Safari | Bucket / Merge | O | X | O | +| SpiderMonkey | Firefox | Merge + Insertion | O | X | O | + +- 브라우저는 `Array.prototype.sort`의 명세대로 sort를 구현했는가? + - YES +- 모든 브라우저가 Tim sort로 구현되어 있는가? + - No + +## 마무리 + +`sort` 함수를 이해하면서 공식 문서 및 브라우저들의 소스 코드를 살펴보게 되었다. + +가볍게 보았음에도 브라우저 코드 형태를 본다거나 ECMAScript를 읽을 수 있게 된 것은 뜻밖의 수확이었다. 가장 큰 수확은 sort뿐만 아니라 다른 명세(map, reduce,...)들에 대한 접근도 두려워하지 않게 되었다는 점이다. + +처음엔 막막하던 공식 문서도 차근차근 따라가다 보니 읽어 나갈 수 있었다. JavaScript의 [암묵적 형 변환](/posts/implicit-coercion)과 같은 유연함은 오히려 동작을 이해하기 어렵게 하는 요소가 된다. 공식 문서에서 이러한 동작을 간결하게 표현하기 위한 많은 노력들을 볼 수 있었기에 감동 할 수 있었다. + +메서드 동작을 명확하게 설명하지 못하는 부분이 늘 존재했었기에 아쉬움이 있었는데 이번 기회로 자신감도 얻고 JavaScript 자체에 더 가까워진 느낌이 든다. + +재밌었다. + +## 참고 + +- [https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/sort](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/sort) +- [https://tc39.es/ecma262/#sec-array.prototype.sort](https://tc39.es/ecma262/#sec-array.prototype.sort) +- [https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/toSorted](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/toSorted) +- [https://github.com/v8/v8/blob/12.3.206.1/third_party/v8/builtins/array-sort.tq#L1419](https://github.com/v8/v8/blob/12.3.206.1/third_party/v8/builtins/array-sort.tq#L1419) +- [https://d2.naver.com/helloworld/0315536](https://d2.naver.com/helloworld/0315536) +- [https://timothygu.me/es-howto](https://timothygu.me/es-howto) +- [https://v8.dev/blog/array-sort](https://v8.dev/blog/array-sort) +- [자료구조 Tim 정렬 알고리즘](https://www.youtube.com/watch?v=HHN1axRRKx8) diff --git a/_posts/js/storybook7.md b/_posts/js/storybook7.md new file mode 100644 index 00000000..b9ec038c --- /dev/null +++ b/_posts/js/storybook7.md @@ -0,0 +1,192 @@ +--- +title: 'Storybook 7.0 살펴보기' +description: '7버전은 무엇이 달라졌을까?' +url: 'storybook7' +tags: ['storybook', 'decorator', 'const', 'extends'] +coverImage: 'https://github.com/1ilsang/dev/assets/23524849/c251c31e-1775-4cf9-9131-7cab72cde00e' +date: '2023-08-13T10:17:57.922Z' +ogImage: + url: 'https://github.com/1ilsang/dev/assets/23524849/c251c31e-1775-4cf9-9131-7cab72cde00e' +--- + +![cover](https://github.com/1ilsang/dev/assets/23524849/c251c31e-1775-4cf9-9131-7cab72cde00e 'cover') + +4월 초 Storybook v7이 공식 릴리즈 되었다. 이 포스트에서는 [스토리북 블로그에 작성된](https://storybook.js.org/blog/storybook-7-0/) 7버전의 기능들을 확인해보고 정리해 보고자 한다. + +## TL;DR! + +- 사전 번들 제공으로 DX 향상 +- Webpack4 -> Webpack5 +- CSF(Component Story Format) v3 업데이트로 인한 스토리 Props 직관성 향상 +- MDX v2 지원 +- Vite, NextJS, SvelteKit 지원 +- 컴포넌트 테스트 지원 향상 + +## 사전 번들 제공 + +Storybook v7의 주요 기능중 가장 마음에 드는 부분은 사전 번들 제공이다. + +기존에 v6을 사용할 때에는 Storybook도 번들링되기 때문에 번들 시간이 상당히 길었다. 하지만 이번 업데이트로 번들링된 파일이 제공되므로 Storybook의 번들 시간이 없어졌고 연계된 에드온 또한 런타임 부담 없이 더 안정적으로 사용할수 있게 되었다. + +또한 Webpack또한 v4에서 v5로 업데이트 되었기 때문에 번들 속도는 더욱 빨라졌다. + +![compare-speed](https://github.com/1ilsang/dev/assets/23524849/260e2d5a-9a95-4334-a5fe-68885fc35df0) + +> 20초 걸리던 매니저 빌드 타임이 사전 번들 덕분에 1초대로 줄었으며 프리뷰 영역도 2초 정도 단축되었다. wow + +## CSF v3 + +Component Story Format(CSF)도 상당 부분 변경되었다. 컴포넌트 형식에 맞춰 통일화된 규격을 제공한다. + +1. stories 파일의 `default export`가 변경되었다. 이제 스토리 메타데이터를 정의하는 객체를 리턴한다. +2. stories 정의 방식이 변경되었다. 스토리는 스토리 메타데이터 객체 내부에 정의되어야 한다. +3. stories 템플릿을 제공한다. 템플릿으로 스토리를 정의할수 있기 때문에 재사용성이 향상되었다. +4. stories 이름을 정의하는 방식이 변경되었다. id, title 값이 메타데이터 객체로 들어오게 되었다. + +```js +// v6 {id}.stories.tsx +export const Pair = Template.bind({}); +Pair.argTypes = { + type: { + options: ['mobile', 'pc'], + control: { type: 'radio' }, + defaultValue: 'mobile', + }, + slot: { + options: ['header', 'toolbar left', 'toolbar right', 'more'], + control: { type: 'radio' }, + defaultValue: 'header', + }, +}; +Pair.args = { + /* ... */ +}; +Pair.parameter = { + /* ... */ +}; +Pair.action = clickPair('toolbar'); + +// v7 {id}.stories.tsx +export default { + title: 'Buttons/color', + argTypes: { + type: { + options: ['mobile', 'pc'], + control: { type: 'radio' }, + }, + slot: { + options: ['header', 'toolbar left', 'toolbar right', 'more'], + control: { type: 'radio' }, + }, + }, +}; +export const Pair = { + name: 'Pair', + action: clickPair('toolbar'), + render: Template, + args: { + type: 'mobile', + slot: 'header', + }, + parameter: { + /* ... */ + }, +}; +``` + +## MDX v2 + +![MDX](https://github.com/1ilsang/dev/assets/23524849/cce48ea1-146f-47fb-a6a9-52d5eea4dc1b 'l') + +```jsx +// v6 guide.stories.mdx + + + +// v7 guide.mdx +import TitleGuide, {RedTitle} from "./Component/TitleGuide"; + + + +// ./Component/TitleGuide.stories.mdx +export const RedTitle = { /* ... */ }; +export default { + title:'Component/Title/Guide', +}; +``` + +v7이 되면서 MDX1에서 MDX2로 업데이트 되었다. + +기존에는 mdx 파일과 스토리 파일을 ID 스트링으로 연결했었다. v7 부터는 조금 더 코드 친화적으로 컴포넌트와 문서를 이어줄 수 있게 되었다. + +MDX2는 내장 jsx 및 플러그인을 지원하기 때문에 동적인 문서를 만들기에 더욱 좋아졌다. + +확장자에 변화도 생겼다. `{name}.stories.mdx`와 같이 닷(.)으로 이어진 확장자는 인식하지 못한다. `{name}.mdx`로 파일명 수정이 필요하다. + +```jsx +| name | type | description | +| :------: | :--------------: | :----------------: | +| videoRef | HTMLVideoElement | video element | +| event | MouseEvent | click event object | +``` + +기본적으로 MDX는 [GitHub-flavored markdown(GFM)](https://github.github.com/gfm/)이 꺼져있으므로 위와 같은 테이블 마크다운이 깨질 수 있다. + +이는 [remarkGfm을 설치하여 수정](https://storybook.js.org/docs/react/writing-docs/mdx#lack-of-github-flavored-markdown-gfm)하여야 한다. + +## 그 외 + +![support](https://github.com/1ilsang/dev/assets/23524849/6c81a754-b986-439f-8e72-514c723c853d 'l') + +설정 수정 없이 Vite, NextJS, SvelteKit을 지원한다. + +본인은 Webpack에서 Vite로 마이그레이션을 고려하고 있었는데 이번 버전이 좋은 기회가 될꺼라 기대하고 있다. + +![test-coverage](https://github.com/1ilsang/dev/assets/23524849/72e2d169-6801-4270-9447-2984b292ec57 'l') + +스토리북은 이전부터 테스팅 도구로써의 포지션을 견고히 하고자 하는데, 이번 버전에서도 상당부분 업데이트가 되어 있다. + +v7에는 코드 커버리지 기능이 추가되었다. 테스트 코드의 누락을 조금 더 쉽게 찾을수 있게 되었다. + +![test](https://github.com/1ilsang/dev/assets/23524849/74ab1c8c-e5f9-4926-bbf7-56c4e676323a 'l') + +```tsx +const meta: Meta = { + title: 'SignupForm', + component: SignupForm, +}; +export default meta; +type Story = StoryObj; + +export const Submitted: Story = { + play: async ({ args, canvasElement, step }) => { + const canvas = within(canvasElement); + + await step('Enter email and password', async () => { + await userEvent.type(canvas.getByTestId('email'), 'hi@example.com'); + await userEvent.type(canvas.getByTestId('password'), 'supersecret'); + }); + + await step('Submit form', async () => { + await userEvent.click(canvas.getByRole('button')); + }); + }, +}; +``` + +기존의 `play` 함수에서 추가된 `step`을 활용해 컴포넌트 테스트의 그룹화가 가능해졌다. + +테스트 그룹을 통해 해당 테스트를 사람이 이해하기 편해졌다. + +## 마무리 + +Storybook v7은 전반적으로 Developer Experience 향상이 눈에 보이므로 스토리북을 지속적으로 사용할 계획이라면 업데이트 하는것이 좋아보인다. + +1. 빨라진 빌드 시간 +2. 개발자 친화적으로 변화한 CSF, MDX +3. 테스트 그룹화 지원으로 그룹 단위 테스트가 가능해졌다. + +## 참고 + +- +- diff --git a/_posts/js/turborepo.md b/_posts/js/turborepo.md new file mode 100644 index 00000000..c00727df --- /dev/null +++ b/_posts/js/turborepo.md @@ -0,0 +1,16 @@ +--- +title: 'Turborepo로 모노레포 개발 경험 향상하기' +description: '모노레포와 터보레포를 간략히 알아보자' +url: 'turborepo' +tags: ['monorepo', 'turborepo', 'packageManager'] +coverImage: 'https://github.com/1ilsang/dev/assets/23524849/2db5a04f-e83c-4bc4-ba59-685d3bb0e5dd' +date: '2022-04-14T15:00:00.000Z' +ogImage: + url: 'https://github.com/1ilsang/dev/assets/23524849/2db5a04f-e83c-4bc4-ba59-685d3bb0e5dd' +--- + +![cover](https://github.com/1ilsang/dev/assets/23524849/b6999846-c0a3-4889-a4b6-6cf9bc21fa94 'cover') + +라인 엔지니어링 블로그에 [작성한 글](https://engineering.linecorp.com/ko/blog/monorepo-with-turborepo/)이다. + +글쓰면서 정말 많이 배웠던것 같다. 모노레포와 함께할 때 캐싱기능은 너무 경험이 좋았기 때문에 앞으로도 꾸준히 사용해볼 예정이다. diff --git a/_posts/js/typescript-subtyping.md b/_posts/js/typescript-subtyping.md new file mode 100644 index 00000000..6e028f8d --- /dev/null +++ b/_posts/js/typescript-subtyping.md @@ -0,0 +1,272 @@ +--- +title: 'Object.keys()는 왜 string[] 타입일까?' +description: '구조적 서브 타이핑과 집합적 특징을 알아보자' +url: 'typescript-subtyping' +tags: ['typescript', 'structural-subtyping', 'object-keys'] +coverImage: 'https://github.com/1ilsang/dev/assets/23524849/a56811d2-a7e2-4c71-b36e-d2bc368989b1' +date: '2024-05-10T13:11:19.101Z' +ogImage: + url: 'https://github.com/1ilsang/dev/assets/23524849/a56811d2-a7e2-4c71-b36e-d2bc368989b1' +--- + +![cover](https://github.com/1ilsang/dev/assets/23524849/8105b3d4-e413-4a36-929f-a0a6a1d27aff 'cover') + +## Index + +- [TL;DR!](#tldr) +- [문제](#문제) +- [구조적 서브 타이핑이란](#구조적-서브-타이핑이란) +- [해결](#해결) +- [마무리](#마무리) +- [One more thing](#one-more-thing) +- [참고](#참고) + +## TL;DR! + +타입스크립트는 자바스크립트의 덕 타입을 표현하기 위해 구조적 서브 타이핑을 채택하고 있다. + +`Object.keys()`는 런타임에서의 안정성을 위해 넓은 타입인 `string[]` 타입으로 추론된다. + +## 문제 + +```ts {12} +interface MyObject { + first: number; + second: string; + third: boolean; +} +const parseMyObject = (object: MyObject) => { + const parsed = { accNum: 0, trueCount: 0 }; + + Object.keys(object).forEach((key) => { + // 🚨 Element implicitly has an 'any' type because expression of type 'string' can't be used to index type 'MyObject'. + // No index signature with a parameter of type 'string' was found on type 'MyObject'.(7053) + const curValue = object[key]; + if (!isNaN(curValue)) { + parsed.accNum += curValue; + } else if (curValue === true) { + parsed.trueCount++; + } + }); + return parsed; +}; +``` + +개발을 하다 보면 자연스럽게 객체를 많이 사용하게 된다. 이때 [Object.keys](https://developer.mozilla.org/ko/docs/Web/JavaScript/Reference/Global_Objects/Object/keys) 메서드를 사용하면 항상 `key` 값이 `string`으로 추론되는 것을 확인할 수 있다. + +```ts +const keyList = Object.keys(obj) as Array; +``` + +`string`으로 [타입 추론(Type Inference)](https://www.typescriptlang.org/docs/handbook/type-inference.html) 되었기 때문에 이후 코딩의 편의성을 위해 다시 [타입 단언(Type Assertion)](https://www.typescriptlang.org/docs/handbook/2/everyday-types.html#type-assertions)을 하게 된다. + +단언을 통한 타입 제어가 마음을 불편하게 하기 때문에 [제너릭 타입](https://www.typescriptlang.org/ko/docs/handbook/2/generics.html)으로라도 추론하고 싶어 진다. + +```ts title="TypeScript/src/lib/es2015.core.d.ts" +interface ObjectConstructor { + keys(o: {}): string[]; +} +``` + +하지만 타입스크립트는 [Object.keys\() 제너릭 타입을 제공하지 않는다](https://github.com/microsoft/TypeScript/blob/36ac4eb700ce596033762b821545753753d13444/src/lib/es2015.core.d.ts#L301-L305). + +여기서 의문이 생긴다. + +- 기존의 key 타입을 왜 추론하지 못할까? +- 제너릭 타입은 왜 제공하지 않았을까? + +오늘은 타입스크립트를 사용하면서 만나는 미묘한 당혹스러움에 대해 파헤쳐 보고자 한다. + +## 구조적 서브 타이핑이란 + +앞에서 다룬 문제의 이유는 [타입스크립트가 구조적 서브 타이핑을 기반](https://www.typescriptlang.org/docs/handbook/type-compatibility.html)으로 하고 있기 때문이다. + +이전에 [우아한 타입스크립트](/posts/woowa-type-review#%ED%83%80-%EC%96%B8%EC%96%B4%EC%9D%98-%ED%83%80%EC%9E%85-%EC%8B%9C%EC%8A%A4%ED%85%9C%EA%B3%BC-%EB%B9%84%EA%B5%90)에서 구조적 타이핑을 잠깐 이야기한 적이 있었다. + +![duck typing](https://github.com/1ilsang/dev/assets/23524849/ff217791-2571-4ecf-9494-b95905d77311) + +> Image Source: [What is duck typing](https://stackoverflow.com/questions/4205130/what-is-duck-typing) + +자바스크립트는 [덕 타이핑](https://ko.wikipedia.org/wiki/%EB%8D%95_%ED%83%80%EC%9D%B4%ED%95%91)을 기반으로 하는 [동적 타이핑](https://developer.mozilla.org/ko/docs/Glossary/Dynamic_typing) 언어이다. + +따라서 타입스크립트는 자바스크립트의 특성(유연한 동적 타입)을 해치지 않으면서 타입을 강제([정적 타이핑](https://developer.mozilla.org/ko/docs/Glossary/Static_typing))하기 위한 고민을 하게 된다. + +```ts +type Book = { + name: string; +}; +``` + +위와 같은 객체 타입 `Book`을 선언하게 되면 일반적인 [명목적 타입 시스템](https://ko.wikipedia.org/wiki/%EB%AA%85%EB%AA%A9%EC%A0%81_%EC%9E%90%EB%A3%8C%ED%98%95_%EC%B2%B4%EA%B3%84)에서는 반드시 `Book { name: string }` 형태의 타입만 와야 한다. + +```ts +const getName = (book: Book) => { + return book.name; +}; + +const book1 = { name: '123' }; +const book2 = { name: '123', model: 'wow' }; +const book3 = { name: '123', model: 'wow', wow: 'line' }; + +getName(book1); // OK +getName(book2); // OK +getName(book3); // OK +``` + +하지만 타입스크립트에서는 위와 같은 모든 형태의 객체가 가능하다. 이것이 바로 구조적 서브 타이핑이다. + +구조적 타입 시스템의 주요 특성은 **값을 할당할 때 정의된 타입에 필요한 속성을 가지고 있다면 호환된다**는 것이다. + +따라서 구조적 타입 시스템에서 타입은 값의 집합으로 생각하면 된다. + +그렇다면 구조적 서브 타이핑과 `Object.keys`의 반환 타입에는 어떤 연관이 있는 것일까? + +```ts +class MyObject { + // https://stackoverflow.com/questions/49464634/difference-between-object-and-object-in-typescript + // object 타입은 원시 타입을 제외한 모든 값이 될 수 있다. + keys(o: T): (keyof T)[]; +} +const keys = MyObject.keys(book1); // "name"[] +const keys = MyObject.keys(book2); // "name"[] +const keys = MyObject.keys(book3); // ("name" | "model" | "wow")[] +``` + +자바스크립트의 덕 타입 덕에 객체는 런타임 단계에서 더 많은 속성을 가질 수 있다. 또한 구조적 서브 타이핑은 필요한 속성을 가지고 있다면 확장된 집합과 호환되며 에러를 노출하지 않는다. + +그렇기 때문에 타입스크립트는 객체 인자에 **`T` 타입의 값만 존재한다는 보장을 할 수 없다**. + +```ts +for (const key of Object.keys(book1)) { + // 🚨 No index signature with a parameter of type 'string' was found on type 'Book'.(7053) + const value = book1[key]; +} +``` + +따라서 타입스크립트는 런타임에서의 안정성을 찾기 위해 좁은 타입의 `(keyof T)[]`가 아닌 넓은 타입인 `string[]`으로 추론한다. + +관련 논의는 [#12253](https://github.com/microsoft/TypeScript/pull/12253#issuecomment-263132208) 이슈 코멘트에서 확인할 수 있다. + +- [글의 말미](/posts/typescript-subtyping#one-more-thing)에서 구조적 서브 타이핑에 대해 더 다루도록 하겠다. + +## 해결 + +이제 타입스크립트가 `Object.keys`의 값을 `string[]`으로 추론하는 이유를 확인했다. + +이를 타입 단언을 사용하지 않고 추론하려면 어떻게 해야 할까? + +### 타입 가드를 통한 타입 좁히기 + +```ts +const book: Book = { name: '123' }; +const book3 = { name: '123', model: 'wow', wow: 'line' }; + +// 타입 좁히기 +const isBook = (key: string): key is keyof Book => { + return Object.keys(book).includes(key); +}; + +for (const key of Object.keys(book3)) { + // 타입 가드로 타입이 존재하는 컨디션 블록이 생기게 됨 + if (isBook(key)) { + // Book 타입의 키 + } else { + // 구조적 서브 타이핑으로 확장된 키 + } +} +``` + +[타입 가드(Type Guards)](https://www.typescriptlang.org/docs/handbook/2/narrowing.html#typeof-type-guards)를 통한 [타입 좁히기(Type Narrowing)](https://www.typescriptlang.org/docs/handbook/2/narrowing.html#using-type-predicates)를 활용하면 타입 단언을 하지 않아도 적절하게 타입을 추론할 수 있게 된다. + +무엇보다 런타임에서도 안전한 코드로 변화했다. + +## ONE MORE THING + +```ts +type Book = { name: string }; +type Car = { model: string }; + +const BookOrCar = {} as Book | Car; +// 🚨 Property 'name' does not exist on type 'BookOrCar'. +// Property 'name' does not exist on type 'Car'.(2339) +BookOrCar.name; +// 🚨 Property 'model' does not exist on type 'BookOrCar'. +// Property 'model' does not exist on type 'Book'.(2339) +BookOrCar.model; + +const BookAndCar = {} as Book & Car; +BookAndCar.name; // string +BookAndCar.model; // string + +type A = 'A'; +type B = 'B'; + +type AorB = A | B; // 'A' | 'B' +type AandB = A & B; // never +``` + +구조적 서브 타이핑을 조금 더 알아보자. + +`Book` 타입과 `Car` 타입이 [유니온](https://www.typescriptlang.org/docs/handbook/2/everyday-types.html#union-types) 혹은 [교차](https://www.typescriptlang.org/docs/handbook/2/objects.html#intersection-types) 될 때 타입 추론이 혼란스러운 부분이 있다. + +`BookOrCar`는 `{ name: string }` 혹은 `{ model: string }` 타입이 되기 때문에 두 값이 공존해야 한다고 느껴진다. 하지만 타입스크립트는 두 값 모두 추론하지 못한다. + +반대로 교차 타입은 `BookAndCar`에서는 모든 값을 가지지만 `AandB`에서는 `never` 타입이 추론된다. + +![what](https://github.com/1ilsang/dev/assets/23524849/eb6ea24b-99e3-4ee0-b97e-ff2c6c78786e 's') + +앞에서 "구조적 타입 시스템에서의 타입은 값의 집합으로 생각하면 된다"고 했다. + +각 타입을 값의 집합으로 나열해 보자. + +```json title="Book 타입에 충족되는 값의 집합" +{ name: "123" }; +{ name: "123", model: "wow" }; +{ name: "123", model: "wow", wow: "line" }; +그 외 `name`이 존재하는 객체 +``` + +```json title="Car 타입에 충족되는 값의 집합" +{ model: "wow" }; +{ name: "123", model: "wow" }; +{ name: "123", model: "wow", wow: "line" }; +그 외 `model`이 존재하는 객체 +``` + +```json title="Book | Car 타입의 모든 값의 집합" +{ name: "123" }; +{ name: "123", model: "wow" }; +{ name: "123", model: "wow", wow: "line" }; +그 외 `name`이 존재하는 객체 +{ model: "wow" }; +{ name: "123", model: "wow" }; +{ name: "123", model: "wow", wow: "line" }; +그 외 `model`이 존재하는 객체 +``` + +`Book | Car`의 경우에 Book 혹은 Car 중 "항상 존재하는 값"이 없는 것을 확인(`name` 혹은 `model`이 반드시 있어야 하는 경우가 없음) 할 수 있다. + +```json title="Book & Car 타입의 모든 값의 집합" +{ name: "123", model: "wow" }; +{ name: "123", model: "wow", wow: "line" }; +``` + +반면 `Book & Car`의 경우 "항상 존재하는 값"이 있는 것을 확인할 수 있다. + +### 결론 + +따라서 `Book | Car`에서는 항상 존재하는 값이 없기 때문에 `name`, `model` 어느 값도 존재하지 않게 되지만 `Book & Car`에서는 `name`, `model` 모두 항상 존재하기 때문에 두 값 모두 존재하게 된다. + +## 마무리 + +타입스크립트를 사용하면서도 언어의 근본적인 철학을 이해하지 못한 상태로 작업한 것 같아 반성하게 되는 계기였다. + +타입을 사용하면서 지금처럼 당혹스러운 부분이 있었는데 이번 기회에 많이 이해할 수 있었다. + +## 참고 + +- [Type Compatibility](https://www.typescriptlang.org/ko/docs/handbook/type-compatibility.html) +- [[번역] 왜 타입스크립트는 Object.keys의 타입을 적절하게 추론하지 못할까요?](https://medium.com/@yujso66/번역-왜-타입스크립트는-object-keys의-타입을-적절하게-추론하지-못할까요-477253b1aafa) +- [Why Object.keys Returns an Array of Strings in TypeScript (And How To Fix It)](https://www.mattstobbs.com/object-keys-typescript/) +- [타입스크립트의 구조적 타이핑](https://www.yongdam.sh/blog/effective-typescript-structural-typing) +- [Object.keys() types refinement, and Object.entries() types bugfix #12253](https://github.com/microsoft/TypeScript/pull/12253#issuecomment-263132208) diff --git a/_posts/js/typescript5.md b/_posts/js/typescript5.md new file mode 100644 index 00000000..b0ccc49a --- /dev/null +++ b/_posts/js/typescript5.md @@ -0,0 +1,478 @@ +--- +title: 'TypeScript 5.0 살펴보기' +description: '5버전은 무엇이 달라졌을까?' +url: 'typescript5' +tags: ['typescript', 'decorator', 'const', 'extends'] +coverImage: 'https://github.com/1ilsang/dev/assets/23524849/a05f9167-23d8-4756-bd67-9381ff38cbb7' +date: '2023-04-09T07:24:41.017Z' +ogImage: + url: 'https://github.com/1ilsang/dev/assets/23524849/a05f9167-23d8-4756-bd67-9381ff38cbb7' +--- + +![cover](https://github.com/1ilsang/dev/assets/23524849/a05f9167-23d8-4756-bd67-9381ff38cbb7 'cover') + +3월 초 TypeScript v5가 공식 릴리즈 되었다. 이 포스트에서는 [MS 블로그에 작성된](https://devblogs.microsoft.com/typescript/announcing-typescript-5-0) 5버전의 기능들을 확인해보고 정리해 보고자 한다. + +목차는 아래와 같이 구성되어 있다. + +- [Decorators](#decorators) + - [실험적 레거시 데코레이터와의 차이점](#실험적-레거시-데코레이터와의-차이점) +- [const Type Parameters](#const-type-parameters) +- [Supporting Multiple Configuration Files in extends](#supporting-multiple-configuration-files-in-extends) +- [All enums Are Union enums](#all-enums-are-union-enums) +- [--moduleResolution bundler](#--moduleresolution-bundler) +- [Support for export type \*](#support-for-export-type-) +- [@overload Support in JSDoc](#overload-support-in-jsdoc) +- [Speed, Memory, and Package Size Optimizations](#speed-memory-and-package-size-optimizations) +- [Breaking Changes and Deprecations](#breaking-changes-and-deprecations) + - [Runtime Requirements](#runtime-requirements) + - [lib.d.ts Changes](#libdts-changes) + - [API Breaking Changes](#api-breaking-changes) + - [Forbidden Implicit Coercions in Relational Operators](#forbidden-implicit-coercions-in-relational-operators) + - [Enum Overhaul](#enum-overhaul) +- [마무리](#마무리) +- [참고](#참고) + +## Decorators + +[Decorators](https://github.com/tc39/proposal-decorators)는 현재(2023/04)기준 [Stage 3단계](https://tc39.es/process-document/)(4단계가 표준 추가)인 ECMAScript 공식 스펙이다. ES2024의 유력한 기능 중 하나이다. + +```ts +class Person { + name: string; + constructor(name: string) { + this.name = name; + } + + greet() { + console.log(`Hello, my name is ${this.name}.`); + } +} + +const p = new Person('Ron'); +p.greet(); // Hello, my name is Ron. +``` + +위와 같은 간단한 Person 클래스의 greet 함수를 디버깅 하기 위해 함수 내부 시작과 끝에 `console.log`를 추가할 경우 데코레이터를 사용하면 편리하게 작업할수 있다. + +```ts +function loggedMethod(headMessage = 'LOG:') { + return function actualDecorator( + originalMethod: any, // 데코레이터를 사용한 함수 + context: ClassMethodDecoratorContext, // 데코레이터를 사용하는 컨텍스트 객체의 데이터 및 함수가 있다(private, static 여부, 메서드 이름 등). + ) { + const methodName = String(context.name); + + function replacementMethod(this: any, ...args: any[]) { + console.log(`${headMessage} Entering method '${methodName}'.`); + const result = originalMethod.call(this, ...args); // 데코레이터를 사용하는 함수가 여기서 실행된다. + console.log(`${headMessage} Exiting method '${methodName}'.`); + return result; // 데코레이터 체이닝을 위해 존재한다. + } + + return replacementMethod; + }; +} + +class Person { + // ... + @loggedMethod('[Name]') + greet() { + console.log(`Hello, my name is ${this.name}.`); + } +} + +const p = new Person('Ron'); +p.greet(); +/** + * [Name] Entering method 'greet'. + * Hello, my name is Ron. + * [Name] Exiting method 'greet'. + **/ +``` + +이로써 모든 함수에 `@loggedMethod`만 추가하면 쉽게 정해진 로그 메서드를 사용할수 있다. + +이 외에도 컨택스트 객체에는 `addInitializer`라는 유용한 함수가 있다. 이는 생성자의 시작 부분(또는 정적 클래스 자체의 초기화)에 연결할 수 있다. + +자바스크립트를 사용하면서 this가 다시 바인딩 되지 않도록 아래와 같은 코딩 스타일을 자주 사용한다. + +```ts +class Person { + name: string; + constructor(name: string) { + this.name = name; + // 오직 CASE 1. 에만 사용되는 줄 + this.greet = this.greet.bind(this); + } + + // CASE 1. + greet() { + console.log(`Hello, my name is ${this.name}.`); + } + // CASE 2. + // greet: () => { + // console.log(`Hello, my name is ${this.name}.`); + // } + // CASE 3. 생성자에서 this.greet.bind(this)를 하지 않은 경우 + // greet() { + // console.log(`Hello, my name is ${this.name}.`); + // } +} + +const greet = new Person('Ron').greet; +greet(); // CASE 1,2 는 정상적으로 동작하지만 3은 this가 글로벌로 바뀌기 때문에 name이 undefined 에러가 발생한다. +``` + +이를 데코레이터로 사용하면 일관된 로직을 추가/변경해 적용할수 있게 된다. + +```ts +function bound(originalMethod: any, context: ClassMethodDecoratorContext) { + const methodName = String(context.name); + if (context.private) { + // private 함수는 bind 하지 않는다는 예제 + throw new Error( + `'bound' cannot decorate private properties like ${methodName}.`, + ); + } + context.addInitializer(function (this: any) { + // 생성자에서 this를 바인드 하게 된다. + this[methodName] = this[methodName].bind(this); + }); +} + +class Person { + name: string; + constructor(name: string) { + this.name = name; + } + + // It Same: @bound @loggedMethod('[Name]') greet() { ... } + @bound + @loggedMethod('[Name]') + greet() { + console.log(`Hello, my name is ${this.name}.`); + } +} +const greet = new Person('Ron').greet; +greet(); // It works! +/** + * [Name] Entering method 'greet'. + * Hello, my name is Ron. + * [Name] Exiting method 'greet'. + **/ +``` + +여기서 유의할 점은 데코레이터는 '역순'으로 실행된다는 점이다. 위 예를 보면, `@loggedMethod`가 `greet` 메서드를 꾸미고, `@bound`가 `@loggedMethod`의 결과를 꾸미게 된다. 데코레이터가 사이드 이펙트를 가지거나 보장된 순서를 원할 경우 유의해야 한다. + +### 실험적 레거시 데코레이터와의 차이점 + +기존에 타입스크립트는 실험적 데코레이터를 지원하고 있었으며 `--experimentalDecorators` 옵션으로 활성화 할수 있었다. + +실험적 데코레이터와 v5 데코레이터(ECMA)의 차이는 매개변수에 데코레이터를 지정하거나, `--emitDecoratorMetadata`와 호환되지 않는 등이 있다. 앞으로 데코레이터의 제안에 해당 내용들을 추가해 간격을 좁혀나갈 예정이다. + +```ts +// allowed +@register +export default class Foo { + // ... +} + +// also allowed +export default +@register +class Bar { + // ... +} + +// error - before *and* after is not allowed +@before +@after +export class Bar { + // ... +} +``` + +데코레이터를 `export` 앞에 놓을 수 있게 되면서 다양하게 선언이 가능해졌지만, 양옆으로 놓을수는 없다. + +데코레이터의 타입을 보장하기 위해서는 상당히 복잡한 타입정의가 될수 있다. 이는 가독성과 상충관계가 있기 때문에 단순하게 유지하라고 조언한다. 데코레이터의 메커니즘에 대해 자세한 내용은 [이 글](https://2ality.com/2022/10/javascript-decorators.html)에 정리되어 있다. + +## const Type Parameters + +```ts +type HasNames = { names: readonly string[] }; +// 우리는 아래 함수를 통해 불변 문자열 배열 타입을 얻고자 한다. +function getNamesExactly(arg: T): T['names'] { + return arg.names; +} +// The type we wanted: +// readonly ["Alice", "Bob", "Eve"] +// The type we got: +// string[] +const names1 = getNamesExactly({ names: ['Alice', 'Bob', 'Eve'] }); + +// Correctly gets what we wanted: +// readonly ["Alice", "Bob", "Eve"] +const names2 = getNamesExactly({ names: ['Alice', 'Bob', 'Eve'] } as const); +``` + +객체의 타입을 추론할때 타입스크립트는 일반적인 타입을 선택한다. 따라서 위의 예에서 `names`는 `string[]` 타입으로 추론된다. + +readonly 타입을 반환하게 할 경우 기존까지는 `as const` [타입 어설션](https://www.typescriptlang.org/docs/handbook/2/everyday-types.html#type-assertions)으로 강제화 해주어야 했는데, 이는 상당히 번거롭다. + +```ts +function getNamesExactly(arg: T): T['names'] { + // ^^^^^ + return arg.names; +} +// Inferred type: readonly ["Alice", "Bob", "Eve"] +// Note: Didn't need to write 'as const' here +const names = getNamesExactly({ names: ['Alice', 'Bob', 'Eve'] }); +``` + +이제 `const` 타입 파라미터를 사용해 `as const` 추론이 가능해졌다. 하지만 이는 함수 호출 내에 작성된 객체, 배열, 표현식에만 영향을 미치므로 주소값을 넘기는 인수로는 동작할수 없음을 알아두어야 한다. + +```ts +const inputNames = ['Alice', 'Bob', 'Eve']; + +// Inferred type: ["Alice", "Bob", "Eve"] +// readonly 내놔!!! +const names = getNamesExactly({ names: inputNames }); +``` + +## Supporting Multiple Configuration Files in extends + +```ts +// packages/front-end/src/tsconfig.json +{ + "extends": ["@tsconfig/strictest/tsconfig.json", "../../../tsconfig.base.json"], + "compilerOptions": { + "outDir": "../lib", + // ... + } +} +``` + +extends 필드에 배열로 여러개의 config 파일을 지원하게 되었다. 개인적으로 상당히 만족 + +## All enums Are Union enums + +```ts +// enum Color는 Red | Orange | Yellow | Green | Blue | Violet의 union 타입이다. +enum Color { + Red, + Orange, + Yellow, + Green, + Blue, + Violet, +} + +// enum 멤버는 참조할 수 있는 자체 유형이 있으므로 값처럼 사용될 수 있다. +type PrimaryColor = Color.Red | Color.Green | Color.Blue; +``` + +모든 enum은 union된 enum이다. 타입스크립트가 처음 enum을 도입했을 때만 해도 enum은 상수 집합에 불과했다(number 타입). 하지만 타입스크립트 2.0에서 enum 리터럴 타입(고유한 값; 상수 10, 20 등이 타입이 됨)이 도입되면서 리터럴 타입은 각 enum 멤버에 고유한 타입을 부여하게 된다. + +각 enum 멤버에 고유한 타입을 부여할 때 발생하는 한 가지 문제는 해당 타입이 멤버의 실제 값과 연관되어 있다는 점이다. + +예를 들어 아래와 같이 enum 멤버가 함수 호출로 초기화될 수 있는 경우, 값을 계산할수 없으므로 초기화 이전까지는 에러가 발생한다. 또한 `enum E`의 예시와 같이 `const a:E`는 `3 | 4`의 타입이 아닌 number가 된다. 이는 리터럴 타입의 장점을 사용하지 못하고 기존 상수 집합을 사용하고 있다는 뜻이 된다. + +이제 타입스크립트 5버전 부터는 각 멤버에 대해 고유한 타입을 생성하여 enum 멤버를 union enum으로 사용할수 있게 되었다. **즉, enum의 모든 멤버를 좁혀서 그 멤버를 타입으로 참조할 수 있게 되었다**. + +```ts +enum E { + three = 3, + four = 4, +} +function takeValue(num: E) {} + +// v4.9.5 ===================================== +const a: E = 55; // It works! +const b: E.three = 4444; // It works! +takeValue(6); // It works! + +enum Color { + random = Math.random(), + two = 2, +} +// Error! Enum type 'Color' has members with initializers that are not literals.(2535) +const c: Color.random = 5; + +// v5.0.3 ===================================== +const a: E = 55; // Error! Type '55' is not assignable to type 'E'.(2322) +const b: E.three = 4444; // Error! Type '4444' is not assignable to type 'E.three'.(2322) +takeValue(6); // Error! Argument of type '6' is not assignable to parameter of type 'E'.(2345) + +enum Color { + random = Math.random(), + two = 2, +} +// It works! +const c: Color.random = 5; // number +``` + +## --moduleResolution bundler + +```json +{ + "compilerOptions": { + "target": "esnext", + "moduleResolution": "bundler" + } +} +``` + +대부분의 최신 번들러는 Node.js에서 ECMAScript 모듈과 CommonJS 조회 규칙의 융합을 사용한다. 번들러의 작동 방식을 모델링하기 위해 타입스크립트는 이제 새로운 전략인 --moduleResolution 번들러를 도입한다. + +하이브리드 조회 전략을 구현하는 Vite, esbuild, swc, Webpack, Parcel 등의 최신 번들러를 사용 중이라면 새로운 bundler옵션이 적합하다. + +## Support for export type \* + +```ts +// models/vehicles.ts +export class Spaceship { + // ... +} + +// It works! +// models/index.ts +export type * as vehicles from './vehicles'; + +// main.ts +import { vehicles } from './models'; + +function takeASpaceship(s: vehicles.Spaceship) { + // ok - `vehicles` only used in a type position +} + +function makeASpaceship() { + return new vehicles.Spaceship(); + // ^^^^^^^^ + // 'vehicles' cannot be used as a value because it was exported using 'export type'. +} +``` + +타입스크립트에서 `export type *` 문법이 가능해졌다. 이를 통해 타입과 값의 분리가 더 명확해졌다. + +## @overload Support in JSDoc + +```ts +// 기존에는 이와 같이 함수를 계속해서 확장해 나가야 했다. +// Our overloads: +function printValue(str: string): void; +function printValue(num: number, maxFractionDigits?: number): void; + +// 이제 아래와 같이 @overload 태그를 사용해 오버로드를 선언할 수 있다 +/** + * @overload + * @param {string} value + * @return {void} + */ +/** + * @overload + * @param {number} value + * @param {number} [maximumFractionDigits] + * @return {void} + */ +/** + * @param {string | number} value + * @param {number} [maximumFractionDigits] + */ +function printValue(value, maximumFractionDigits) { ... } + +// all allowed +printValue("hello!"); +printValue(123.45); +printValue(123.45, 2); + +printValue("hello!", 123); // error! +``` + +기존에 코드로 표현해야 했던 부분을 jsdoc으로 나누어서 표현(example 등)할수 있기 때문에 DX의 향상에 기대가 된다. + +## Speed, Memory, and Package Size Optimizations + +![size](https://github.com/1ilsang/dev/assets/23524849/fa74be28-12b4-4d85-bd7d-d9cbb3379029) + +![compare v5 to v4.9](https://github.com/1ilsang/dev/assets/23524849/a6f4acaf-fa7f-4464-adab-7615b3ed0e77) + +![typescript npm package size](https://github.com/1ilsang/dev/assets/23524849/2571eada-3035-42a1-9e35-1458c0b1d271) + +지표에서도 눈에 띄일만큼 변경사항이 있으며 원문 블로그 자체에서도 대부분의 코드베이스에서 10~20% 정도 속도 향상을 느낄 수 있다고 자신하고 있기 때문에 모노레포에서 타입 참조 시간을 많이 줄일수 있을 것이라 기대하고 있다. + +## Breaking Changes and Deprecations + +### Runtime Requirements + +타입스크립트는 이제 ECMAScript 2018을 대상으로 한다. 최소 엔진은 12.20으로 설정되었다. + +### lib.d.ts Changes + +DOM의 유형이 생성되는 방식이 변경되어 기존 코드에 영향을 미칠 수 있다. 특히 특정 프로퍼티가 숫자에서 숫자 리터럴 타입으로 변환되었으며, 잘라내기, 복사, 붙여넣기 이벤트 처리를 위한 프로퍼티와 메서드가 인터페이스 전반으로 이동되었다. + +### API Breaking Changes + +TypeScript 5.0에서는 모듈로 전환하고, 불필요한 인터페이스를 제거했으며, 일부 정확성을 개선했다. + +### Forbidden Implicit Coercions in Relational Operators + +```ts +function func(ns: number | string) { + return ns * 4; // Error, possible implicit coercion +} +function func(ns: number | string) { + return ns > 4; // Now also an error. number | string 타입은 비교할수 없다(string은 비교 불가능). +} +``` + +TypeScript의 특정 연산은 암시적으로 문자열을 숫자로 강제 변환할 수 있는 코드를 작성할 경우 이미 경고한다. 5.0에서는 관계 연산자(<,>,<=,=>)에도 적용된다. + +```ts +function func(ns: number | string) { + return +ns > 4; // OK +} +``` + +`+` 연산자를 통해 명시적 형변환후 사용하는것은 가능하다. + +### Enum Overhaul + +```ts +enum SomeEvenDigit { + Zero = 0, + Two = 2, + Four = 4, +} + +// Now correctly an error +let m: SomeEvenDigit = 1; + +// ===== +enum Letters { + A = 'a', +} +enum Numbers { + one = 1, + two = Letters.A, // enum의 참조가 있을 경우 number 타입으로 됨 +} + +// Now correctly an error +const t: number = Numbers.two; +const t2: string = Numbers.two; // 5.0 이전에는 여기서 에러가 발생함(-_-) +``` + +enum을 이해하는 개념 수를 줄이기 위해 위의 두 가지 오류가 추가되었다. + +## 마무리 + +decorator 추가 및 enum 명시성 확장, multi extends, jsdoc 등 다양한 편의성이 추가되었기 때문에 기대되는 메이저 업데이트이다. + +이 글에서 다루지 않은 더 자세한 내용은 아래의 원문에 자세하게 추가되어 있다. + +## 참고 + +- +- +- +- diff --git a/_posts/js/use-prevet-leave.md b/_posts/js/use-prevet-leave.md new file mode 100644 index 00000000..1e43b0ec --- /dev/null +++ b/_posts/js/use-prevet-leave.md @@ -0,0 +1,292 @@ +--- +title: '페이지 이탈시 확인 컨펌창 만들기' +description: 'usePreventLeave를 알아보자' +url: 'use-prevent-leave' +tags: ['usePreventLeave', 'beforeunload', 'popstate', 'popup'] +coverImage: 'https://github.com/1ilsang/dev/assets/23524849/bf2cd78f-1d32-4d08-9fc7-1eb326a35288' +date: '2023-03-12T06:14:32.600Z' +ogImage: + url: 'https://github.com/1ilsang/dev/assets/23524849/bf2cd78f-1d32-4d08-9fc7-1eb326a35288' +--- + +유저가 페이지 이탈시 확인 컴펌을 받는 로직이 필요하게 되었고 이에 대한 고민을 공유해 보려고 한다. + +페이지 이탈은 아래와 같은 세가지 방법이 있다고 생각한다. + +1. 브라우저 닫기 +2. 페이지 새로고침 +3. 페이지 이동(e.g, 앞/뒤/URL직접입력 등) + +나는 위의 세 가지 경우 모두를 확인하는 컨펌창을 만들어야 했다. + +모든 경우 `beforeunload` 이벤트를 통해 막아줄수 있기 때문에 간편하게 작업할수 있을것이라 예상했으나, 실제로 작업해보니 3번 페이지 이동간에 이벤트가 발생하지 않아 애를 먹었다. + +정확히는 **동일한 도메인에서 서브패스가 달라졌을때만 이벤트가 발생하지 않았다**(e.g., domain.com/dev -> domain.com/1ilsang). 작업하던 웹앱은 `react-router-dom`을 사용하는 SPA 였기에 해당 문제가 [History API](https://developer.mozilla.org/en-US/docs/Web/API/History_API)와 연관되어 있다고 생각하고 서치를 시작했고, 예상대로 이벤트의 기대 동작과 실제 동작이 달라서 일어난 일이었다. + +![flow-chart](https://github.com/1ilsang/dev/assets/23524849/3594b04d-a60f-4edb-9aad-1fbef82245b1 'l') + +> [자세히 보기](https://developer.chrome.com/docs/web-platform/page-lifecycle-api?hl=ko) + +`beforeunload` 이벤트는 '페이지'간 이동에서 발생하기 때문에 단일 페이지 환경인 SPA에서는 새로운 페이지를 로딩하지 않았기 때문에 당연하게도 이벤트가 발생하지 않는다. + +참고로 beforeunload 이벤트가 언제 발생하는지는 위의 브라우저 라이프사이클 이미지를 확인하면 된다. + +- f. 다른 페이지로 이동 +- g. 활성화된 탭 끄기 +- h. 비활성화된 탭 끄기 + +위 내용을 문장으로 한번 더 풀자면, beforeunload 이벤트는 브라우저 닫기(g,h), 페이지 이동(f), 새로고침(f - 새로 고침도 동일한 페이지로의 '이동'에 해당한다)시 발생하는 이벤트이다. + +다만, **SPA의 경우 실제로 브라우저가 리렌더링 되는것이 아니므로 [라우터 이동시 beforeunload 이벤트가 발생하지 않는다](https://github.com/vercel/next.js/issues/2694#issuecomment-344533432).** + +이 경우 때문에 SPA 환경에서 3번 페이지 이동을 막아주기 위해서는 History API를 직접 수정해 `popstate` 이벤트로 막아줄수 있다. + +따라서 먼저 beforeunload 이벤트로 처리하는 방식을 작성한 다음, SPA 환경을 위한 popstate 처리를 써보려고 한다. + +## beforeunload로 페이지 이탈 방지하기 + +![prevent](https://github.com/1ilsang/dev/assets/23524849/bf2cd78f-1d32-4d08-9fc7-1eb326a35288 's') + +beforeunload 이벤트를 통해 페이지 이동을 감지할 경우 브라우저에서 기본 컨펌창을 제공해 주는데, 크롬 기준 컨펌창은 위의 이미지와 같다. + +```tsx +const handleBeforeUnload = (event: Event) => { + event.preventDefault(); + event.returnValue = false; // Chrome requires returnValue to be set. +}; +window.addEventListener('beforeunload', handleBeforeUnload); +``` + +코드로 작성하면 위와 같다. 이동을 막아줄 path에서 beforeunload 이벤트를 수신하고, `event.preventDefault()`를 통해 이벤트의 진행을 막아준다. 이를 통해 페이지를 떠나기전 이벤트가 멈추게 된다. + +이전에는 `returnValue`로 설정해준 값이 컨펌창에 노출되었지만, 노이즈가 너무 강해(님 진짜 진자 나갈거임? 아 나가지마셈..!! 등의 텍스트) 현재는 브라우저에서 기본 텍스트만 노출하도록 변경되었다. + +크롬의 경우 `returnValue` 값이 필요하므로 추가해주어야 브라우저 컨펌창이 노출된다. 또한 크롬의 경우 [유저의 명시적 액션](https://developer.mozilla.org/en-US/docs/Web/Security/User_activation)이 있어야만 이벤트가 정상적으로 발생한다. + +위의 내용은 MDN [beforeunload_event#compatibility_notes](https://developer.mozilla.org/en-US/docs/Web/API/Window/beforeunload_event#compatibility_notes)에서 자세하게 확인할수 있다. + +![error](https://github.com/1ilsang/dev/assets/23524849/b95db747-a90d-4542-b2af-6a075fdd348a 'l') + +beforeunload 이벤트로 작업하다보면 위와같은 에러를 만날수 있는데, 이는 앞서 말한 유저의 명시적 액션(e.g, mousedown)이 없었기 때문에 발생하는 에러이다. + +이제 이 코드를 리액트로 옮겨보자. + +```tsx +const usePreventLeave = (global = false) => { + const handleBeforeUnload = (event: Event) => { + event.preventDefault(); + event.returnValue = false; // Chrome requires returnValue to be set. + }; + const onPreventLeave = () => { + window.addEventListener('beforeunload', handleBeforeUnload); + }; + const offPreventLeave = () => { + window.removeEventListener('beforeunload', handleBeforeUnload); + }; + + // 만약 페이지 전체에 적용할 경우 global을 true로 입력해 window에 적용하면 된다. + // 단일 요소(e.g., HTMLInputElement)에 별개로 적용할 경우 on/off PreventLeave 이벤트를 사용한다. + useEffect(() => { + if (!global) return; + window.addEventListener('beforeunload', handleBeforeUnload); + return () => { + window.removeEventListener('beforeunload', handleBeforeUnload); + }; + }, [global]); + + return { + onPreventLeave, + offPreventLeave, + }; +}; + +const MyApp: FunctionComponent = () => { + // CASE 1. 페이지 자체에 이벤트 적용(글로벌 적용) + // usePreventLeave(true); + + // CASE 2. 특정 요소가 변경될 경우 감지후 방지 이벤트 적용(e.g., form) + const { onPreventLeave, offPreventLeave } = usePreventLeave(); + const [changed, setChanged] = useState(false); + + const handleInputChange = () => setChanged(true); + const handleClearClick = () => setChanged(false); + + useEffect(() => { + const fn = changed ? onPreventLeave : offPreventLeave; + fn(); + return () => { + offPreventLeave(); + }; + }, [changed]); + + return ( +
+ + +

{changed ? 'changed' : 'none'}

+
+ ); +}; +``` + +위의 `usePreventLeave` 훅에서는 두 가지 방식으로 `beforeunload` 이벤트를 사용하도록 제공하고 있다. 만약 global 값을 true로 넘겨줄 경우 전역에 beforeunload 이벤트를 설정해 컨펌창이 무조건 노출되도록 하지만(물론 앞서 이야기 했듯 유저의 인터랙션이 먼저 있어야 한다) 특정 분기(input 태그의 변화)에 맞춰 컨펌창을 띄우고 싶을 경우 `onPreventLeave` 메서드와 `offPreventLeave` 메서드를 적절하게 사용하여 이벤트를 바인딩 해줄수 있다. + +## SPA에서 페이지 이탈 방지하기 + +기본적으로 SPA는 페이지간 이동이 일어나지 않기 때문에 우리는 history stack의 변경사항을 추적해야한다. 애석하게도 브라우저는 앞/뒤 이동일 때에만 `popstate` 이벤트가 발생하기 때문에 `pushState`로 URL을 변경할 경우 이벤트가 발생하지 않아 추적할수 없게 된다. + +이 때문에 Remix-run에서 만든 [history 라이브러리](https://www.npmjs.com/package/history)를 사용해 세션 history를 추적해 처리하거나 `popstate` 이벤트 발생 및 라우터 이동이 있는 컴포넌트 클릭시 컨펌창을 노출하는 작업을 할 수 있다. + +나는 후자의 길을 선택했는데, 추후 보게 되겠지만 이 경우 페이지에서 라우터 이동이 있는 모든 컴포넌트 클릭에 prevent를 설정해 주어야 하므로 조금 아쉽다. + +```tsx +const usePreventLeave = (global = false) => { + // 앞의 beforeunload 이벤트 코드는 생략하였다. beforeunload 이벤트 코드도 추가해 주어야 모든 상황에서 대처 가능해진다. + const [prevent, setPrevent] = useState(false); + + useEffect(() => { + if (!global) return; + + // 현재 페이지를 push하여 의도적으로 스택을 만든다. 이로써 뒤로가기시 현재 페이지가 다시 노출되며 팝업이 보이게 된다. + window.history.pushState(null, "", window.location.href); + window.addEventListener("popstate", handlePopstate); + return () => { + window.removeEventListener("popstate", handlePopstate); + }; + }, [global]); + + // 특정 컴포넌트가 변경되었을 때에 이벤트를 적용하고 싶다면 beforeunload와 동일하게 처리해준다. + const onPreventLeave = () => { + window.history.pushState(null, "", window.location.href); + window.addEventListener("popstate", handlePopstate); + }; + const offPreventLeave = () => { + window.removeEventListener("popstate", handlePopstate); + }; + + // popstate 이벤트 발생시 팝업 노출을 위해 상태값을 변경한다. + const handlePopstate = () => setPrevent(true); + const handlePopupClose = () => { + window.history.pushState(null, "", window.location.href); + setPrevent(false); + }; + const handlePopupLeave = (onLeave: () => void) => { + setPrevent(false); + onLeave(); + }; + // preventLeave 함수를 외부로 return하여 컨펌창을 의도적으로 띄울수 있도록 한다. + const preventLeave = (event: MouseEvent) => { + event.preventDefault(); + setPrevent(true); + }; + + const PreventPopup: FunctionComponent<{ onLeave: () => void }> = ({ + onLeave, + }) => ( + <> + {/* popstate 이벤트에 따라 prevent 상태값이 변경되면 컨펌창이 노출된다 */} + {prevent && ( +
+

페이지을 떠나시겠습니까?

+ + +
+ )} + + ); + + return { preventLeave, PreventPopup, onPreventLeave, offPreventLeave }; +}; + +const MyApp = () => { + // CASE 1. 페이지 자체에 이벤트 적용(글로벌 적용) + // const { PreventPopup } = usePreventLeave(true); + + // CASE 2. 특정 요소가 변경될 경우 감지후 방지 이벤트 적용(e.g., form) + // - beforeunload 형태와 동일 + // const { preventLeave, PreventPopup, onPreventLeave, offPreventLeave } = + // usePreventLeave(true); + + // CASE 3. 라우터 이동이 있는 컴포넌트 막기 + const { preventLeave, PreventPopup } = usePreventLeave(true); + + return ( +
+ + console.log("left!")} /> + {/* 페이지 이동이 있는 컴포넌트에 preventLeave로 팝업 노출 적용 */} + +
+ ); +}; +``` + +## 합본 코드 + +```tsx +const usePreventLeave = (global = false) => { + const [prevent, setPrevent] = useState(false); + + useEffect(() => { + if (!global) return; + + window.history.pushState(null, '', window.location.href); + window.addEventListener('popstate', handlePopstate); + return () => { + window.removeEventListener('popstate', handlePopstate); + }; + }, [global]); + + const handleBeforeUnload = (event: Event) => { + event.preventDefault(); + event.returnValue = false; // Chrome requires returnValue to be set. + }; + const handlePopstate = () => setPrevent(true); + const handlePopupClose = () => { + window.history.pushState(null, '', window.location.href); + setPrevent(false); + }; + const handlePopupLeave = (onLeave: () => void) => { + setPrevent(false); + onLeave(); + }; + const preventLeave = (event: MouseEvent) => { + event.preventDefault(); + setPrevent(true); + }; + const onPreventLeave = () => { + window.history.pushState(null, '', window.location.href); + window.addEventListener('popstate', handlePopstate); + window.addEventListener('beforeunload', handleBeforeUnload); + }; + const offPreventLeave = () => { + window.removeEventListener('popstate', handlePopstate); + window.removeEventListener('beforeunload', handleBeforeUnload); + }; + + const PreventPopup: FunctionComponent<{ onLeave: () => void }> = ({ + onLeave, + }) => ( + <> + {prevent && ( +
+

페이지을 떠나시겠습니까?

+ + +
+ )} + + ); + + return { preventLeave, PreventPopup, onPreventLeave, offPreventLeave }; +}; +``` + +이로써 유저 이탈시 컨펌창이 노출되는 것에 대한 최소한의 대응은 할수 있게 되었다. + +## 참고 + +- [https://developer.mozilla.org/en-US/docs/Web/API/Window/beforeunload_event](https://developer.mozilla.org/en-US/docs/Web/API/Window/beforeunload_event) +- [https://developer.mozilla.org/en-US/docs/Web/Security/User_activation](https://developer.mozilla.org/en-US/docs/Web/Security/User_activation) +- [https://developer.chrome.com/blog/page-lifecycle-api/](https://developer.chrome.com/blog/page-lifecycle-api/) +- [https://heyjiawei.com/block-user-from-leaving-page-on-single-page-app](https://heyjiawei.com/block-user-from-leaving-page-on-single-page-app) diff --git a/_posts/js/use-transition.md b/_posts/js/use-transition.md new file mode 100644 index 00000000..e11c8d10 --- /dev/null +++ b/_posts/js/use-transition.md @@ -0,0 +1,247 @@ +--- +title: 'useTransition 이해하기' +description: '상태 업데이트의 우선 순위를 지정해보자' +url: 'use-transition' +tags: ['react', 'hooks', 'useTransition', 'throttle', 'debounce', 'suspense'] +coverImage: 'https://github.com/1ilsang/dev/assets/23524849/4c83fd47-4f27-4cec-86bf-64ac64fa9795' +date: '2023-06-04T11:38:16.720Z' +ogImage: + url: 'https://github.com/1ilsang/dev/assets/23524849/4c83fd47-4f27-4cec-86bf-64ac64fa9795' +--- + +![image](https://github.com/1ilsang/dev/assets/23524849/4c83fd47-4f27-4cec-86bf-64ac64fa9795) + +최근 리액트 공식 사이트가 [react.dev](https://react.dev/)로 이사하게 되었다. 이에 맞춰 [한국 번역 페이지](https://ko.react.dev/)도 새롭게 단장하게 되어 기여자를 모집하고 있었다. + +평소 리액트 커뮤니티에 기여할 방법을 찾던 중이었기에 useTransition 파트를 [지원했고](https://github.com/reactjs/ko.react.dev/issues/374#issuecomment-1523237065) 무사히 [번역 PR](https://github.com/reactjs/ko.react.dev/pull/609)을 올릴수 있었다. + +> [번역된 페이지 보기](https://ko.react.dev/reference/react/useTransition) + +번역을 하면서 useTransition에 대해 알게 된 것들을 정리해 보고자 한다. + +## TL;DR! + +useTransition은 컴포넌트 최상위 수준에서 호출되어 `startTransition`을 통해 **우선순위가 낮은** 상태 업데이트(setState)들을 `transition`이라고 표시한다. 리액트는 UI 렌더링시 우선순위에 따라 업데이트 할 수 있게 된다. + +## 목차 + +1. useTransition이란? + - isPending, startTransition 이해하기 + - startTransition 유의 사항 + - 전체 코드로 이해하기 +2. Suspense와 연계하기 +3. 그외 자잘한 팁들 + - vs throttle, debounce + - startTransition에 전달된 함수는 즉시 실행된다 + - useDeferredValue +4. 마무리 + +## useTransition이란? + +`useTransition`은 **UI를 차단하지 않고 상태를 업데이트** 할 수 있는 리액트 훅이다. + +```jsx +const [isPending, startTransition] = useTransition(); +``` + +여기서 _UI를 차단하지 않고_ 라는 문구를 유의하기 바란다. useTransition을 통해 React18에 추가된 많은 기능중 하나인 [Concurrent rendering](https://www.freecodecamp.org/korean/news/riaegteu-18yi-singineung-dongsiseong-rendeoring-concurrent-rendering-jadong-ilgwal-ceori-automatic-batching-deung/)(동시성 렌더링)을 적절하게 사용할 수 있다. + +일반적으로 오래 걸리는 상태 업데이트(setState)가 존재할 경우, 해당 업데이트가 완료된 이후에 렌더링이 일어나기 때문에 그 시간만큼 렌더 트리가 '블락(Block)'된다. 이 때문에 유저는 아무런 동작을 할 수 없는 상태에 빠지게 되므로 UX에 좋지 않은 영향을 준다. + +useTransition은 컴포넌트 최상위 수준에서 호출되어 `startTransition`을 통해 **우선순위가 낮은 상태 업데이트**들을 `transition`이라고 표시해 리액트가 UI 렌더링시 우선순위에 따라 업데이트 할 수 있도록 한다. 이로써 렌더링이 오래 걸리는 컴포넌트의 블락을 피할수 있게 된다. + +`transition`으로 표시된 상태 업데이트(A라 호칭)는 다른 일반적인 상태 업데이트(B)가 호출될때 중단되고 B의 상태 업데이트가 완료된 다음 **다시** A를 렌더링 시작한다. 이를 통해 특정 컴포넌트의 렌더링이 오래 걸리더라도 다른 우선순위 높은 상태의 변경을 통해 User Interaction을 블로킹하지 않고 자연스럽게 동작할 수 있도록 한다. + +### isPending, startTransition 이해하기 + +```jsx +const TabButton = ({ children, onClick }) => { + const [isPending, startTransition] = useTransition(); + const [tab, setTab] = useState('about'); + + if (isPending) { + return {children}; + } + function selectTab(nextTab) { + startTransition(() => { + // NOTE: async 함수는 들어오면 안된다. + setTab(nextTab); + }); + } + // ...아래에서 풀 코드로 설명 +}; +``` + +useTransition은 두 개의 항목이 있는 배열을 반환한다. + +1. `isPending` 플래그는 대기 중인 transition이 있는지 알려준다. +2. `startTransition` 함수는 상태 업데이트(setState)를 transition으로 표시 해주는 함수다. + +#### startTransition 유의 사항 + +1. 동기 함수여야 한다. +2. `transition`으로 표시된 setState는 다른 setState 업데이트시 중단된다. + - 다른 상태 업데이트가 있을 경우 그것을 먼저 처리한다는 뜻 +3. 텍스트 입력을 제어하는 데 사용할 수 없다. + +### 전체 코드로 이해하기 + +전체 코드로 이해해 보자. 중간중간 주석을 통해 동작을 설명하고자 한다. + +[코드 샌드박스는 공식 문서](https://ko.react.dev/reference/react/useTransition#displaying-a-pending-visual-state-during-the-transition)에서 잘 제공해 주므로 직접 실행해 비교해 보면 좋다. + +```jsx +const App = () => { + const [tab, setTab] = useState('about'); + + return ( + <> + {/* 탭을 클릭하면 렌더링할 탭 컴포넌트가 설정된다 */} + setTab('about')}> + About + + setTab('posts')}> + Posts (slow) + + setTab('contact')}> + Contact + +
+ {/* 현재 탭에 따라 탭 컴포넌트가 렌더링 된다 */} + {tab === 'about' && } + {tab === 'posts' && } + {tab === 'contact' && } + + ); +}; + +const TabButton = ({ children, isActive, onClick }) => { + const [isPending, startTransition] = useTransition(); + + // 현재 탭이 활성화 되면 isActive 상태가 된다. + if (isActive) { + return {children}; + } + // 대기 중인 transition이 있다면 isPending이 된다. + if (isPending) { + return {children}; + } + /** + * props로 받은 onClick 함수를 startTransition으로 감싸주기 때문에 + * onClick 함수(setTab)은 transition으로 설정되어 렌더링시 우선순위에서 밀리게 된다. + * 그 결과 오랜시간이 걸리는 PostsTab 컴포넌트를 렌더링 하는 도중 다른 탭을 누르게 되면 + * PostsTab 컴포넌트의 렌더링을 멈추고 다른 컴포넌트를 렌더링하게 된다. + **/ + const handleButtonClick = () => { + startTransition(() => { + onClick(); + }); + }; + return ; +}; + +const AboutTab = () => { + return

Welcome to my profile!

; +}; +const PostsTab = () => { + const startTime = performance.now(); + while (performance.now() - startTime < 1) { + // 1 ms 동안 아무것도 하지 않음으로써 매우 느린 코드를 실행한다. + } + return

PostsTab

; +}; +const ContactTab = () => { + return

ContactTab

; +}; +const ContactTab = () => { + return

ContactTab

; +}; +``` + +## Suspense와 연계하기 + +```jsx +const App = () => { + return ( + }> + {/* + 위의 App 코드와 동일 + */} + + ); +}; +``` + +useTransition의 `startTransition`을 `Suspense`와 함께 사용할 경우 불필요한 로딩 인디케이터 노출을 막을수 있다. + +일반적으로 렌더링이 오래 걸리는 컴포넌트를 Suspense로 감쌀 경우 해당 컴포넌트가 렌더링 될때마다 Suspense의 fallback 컴포넌트를 만나게 된다. 해당 서스펜스 트리 하위의 렌더링을 중단할 수 없기 때문에 오래 걸리는 렌더링을 막을 방법이 없다. 해당 렌더링이 종료될 때까지 폴백 컴포넌트를 마주해야만 한다. + +이때 오래 걸리는 상태 업데이트를 startTransition로 감싸게 될 경우 `transition` 표시가 되면서 "긴급하지 않은" 상태 업데이트로 간주된다. 이로 인해 리액트는 Suspense를 통해 컨텐츠를 숨기지 않고 이전 컨텐츠를 계속 표시하게 된다. + +이는 아래와 같은 장점이 있다. + +1. transition은 중단할 수 있으므로 리렌더링까지 기다릴 필요가 없다. + - Suspense의 경우 하위 컴포넌트가 모두 렌더링 될때까지 fallback을 노출시킨다. +2. transition은 서스펜스 폴백을 방지(대기하지 않으므로)하므로 갑작스러운 로딩 인디케이터 노출을 피할수 있다. + +## 그외 자잘한 팁들 + +### vs throttle, debounce + +디바운싱과 스로틀로 이벤트의 지연 및 제한은 가능하지만 UI 블로킹의 근본적인 문제는 해결할 수 없다. + +아무리 이벤트 실행 시점/횟수를 줄인다 하여도 한번 실행이 되는 순간 블로킹이 되는건 여전하기 때문이다. + +근본적인 원인을 해결하기 위해선 이벤트의 우선순위를 나누어 유저 인터렉션이 일어났을 때 해당 이벤트를 우선적으로 처리해 화면이 멈춘것 처럼 보이지 않게 해야한다. + +### startTransition에 전달된 함수는 즉시 실행된다 + +```jsx +console.log(1); +startTransition(() => { + console.log(2); + setPage('/about'); +}); +console.log(3); + +// 1, 2, 3 +``` + +startTransition의 콜백 함수는 즉시 실행된다. 함수가 실행되는 동안 예약된 모든 상태 업데이트는 `transition`으로 표시된다. + +```jsx +// React 작동 방식의 간소화된 버전 +let isInsideTransition = false; + +function startTransition(scope) { + isInsideTransition = true; + scope(); + isInsideTransition = false; +} + +function setState() { + if (isInsideTransition) { + // ... transition state 업데이트 예약 ... + } else { + // ... 긴급 state 업데이트 예약 ... + } +} +``` + +transition으로 처리된 경우 transitionState로 예약(큐잉)되고 아닌 경우 일반적인 state 업데이트로 예약된다. + +예약된 작업들은 React18의 fiber 엔진(자체적인 스케줄러를 가지고 있다)이 적절하게 스케줄링 해준다. + +### useDeferredValue + +useDeferredValue도 useTransition과 유사하게 낮은 우선순위를 지정하기 위한 훅이다. useTransition은 함수 실행의 우선순위를 지정하는 반면, useDeferredValue는 값의 업데이트 우선순위를 지정한다. + +## 마무리 + +위의 내용을 한번더 정리하며 글을 마무리 하려고 한다. + +1. useTransition은 컴포넌트 최상위 수준에서 호출되어 `startTransition`을 통해 **우선순위가 낮은** 상태 업데이트(setState)들을 `transition`이라고 표시한다. 리액트는 UI 렌더링시 우선순위에 따라 업데이트 할 수 있게 된다. +2. `startTransition` 함수는 동기 함수여야 한다. +3. `transition` 표시된 setState는 다른 setState 업데이트시 중단된다. +4. `transition` 표시된 상태 업데이트는 `Suspense`로 컨텐츠를 숨기지 않고 이전 컨텐츠를 계속 표시한다. +5. fiber 엔진을 통해 `transition`된 상태와 다른 상태의 스케줄링이 가능해졌다. diff --git a/_posts/js/visual-regression-test.md b/_posts/js/visual-regression-test.md new file mode 100644 index 00000000..84e9c6e0 --- /dev/null +++ b/_posts/js/visual-regression-test.md @@ -0,0 +1,482 @@ +--- +title: '시각적 회귀 테스트 도입기' +description: 'playwright 소개와 트러블 슈팅' +url: 'visual-regression-test' +tags: ['test', 'visual-regression-test', 'playwright', 'snapshot'] +coverImage: 'https://github.com/1ilsang/dev/assets/23524849/a6de7fcd-2666-44f5-931a-c440785dc0e5' +date: '2024-05-06T05:34:05.054Z' +ogImage: + url: 'https://github.com/1ilsang/dev/assets/23524849/a6de7fcd-2666-44f5-931a-c440785dc0e5' +--- + +![cover](https://github.com/1ilsang/dev/assets/23524849/aa46489d-c054-41f4-bdc2-fe11c8bab49f 'cover') + +> Image Source: [We're Building a Visual Regression Testing Library for React Native](https://commerce.nearform.com/blog/2022/react-native-owl/) + +블로그에 항상 테스트를 도입해야겠다 생각하고 있었는데 이번에 적용하게 되어 도입 배경과 트러블 슈팅 과정을 포스트로 남겨보고자 한다. + +## Index + +- [TL;DR!](#tldr) +- [도입 배경](#도입-배경) +- [시각적 회귀 테스트란](#시각적-회귀-테스트란) +- [Playwright](#playwright) + - [DOM Snapshot](#dom-snapshot) + - [Screenshot](#screenshot) +- [Github Actions](#github-actions) +- [성능 개선](#성능-개선) + - [테스트 방식](#테스트-방식) + - [테스트 속도](#테스트-속도) + - [CI/CD](#cicd) +- [트러블 슈팅](#트러블-슈팅) + - [로컬 테스트를 포기해야 할까](#로컬-테스트를-포기해야-할까) + - [Timezone](#timezone) + - [테스트 분기](#테스트-분기) + - [1px](#1px) + - [Image load](#image-load) +- [마무리](#마무리) + +## TL;DR! + +Playwright 및 Github Actions로 시각적 회귀 테스트 및 CI/CD를 적용한다. + +1. 시각적 회귀 테스트로 UI 변경 사항을 배포전에 알아차린다 +2. 빠르게 실패하고 실패한 부분만 재실행하자 +3. 로컬 테스트와 CI Test의 통합은 어렵다 + +## 도입 배경 + +![test pyramid](https://github.com/1ilsang/dev/assets/23524849/7b69e87b-280f-4b86-8a3c-bf7b76dfc1e8) + +> Image Source: [사용자 인터페이스 테스트 통합 테스트 및 단위 테스트로 테스트 피라미드](https://kr.freepik.com/premium-vector/test-pyramid-with-user-interface-tests-integration-tests-and-unit-tests_50393283.htm) + +이전부터 블로그에 테스트 코드가 없는 것이 꽤나 찝찝했기 때문에 어떤 방식/도구로 테스트를 적용할까 고민하고 있었다. + +블로그의 특성상 한번 배포된 콘텐츠는 크게 바뀔 일이 없기 때문에 정적 UI 테스트를 도입하기에 적절하다고 생각했고 마침 꽤나 긴 연휴가 있었기 때문에 각 잡고 정적 UI 테스트를 도입하고자 마음먹게 되었다. + +빌드된 결과물을 바탕으로 테스트할 예정이었기 때문에 아래의 두 가지 방식의 테스트를 고려했다. + +1. DOM Snapshot +2. Screen Snapshot + +먼저 DOM 스냅샷 비교를 통해 이후 작업에서 기존 DOM 구조를 변경하는지 확인한다. 하지만 DOM 스냅샷은 CSS의 변경 여부를 알아차리기 어렵다는 단점이 있다. + +따라서 시각적 회귀 테스트인 Screen 스냅샷 비교로 정상적인 렌더링이 되었는지 확인한다. + +## 시각적 회귀 테스트란 + +![failed visual regression test](https://github.com/1ilsang/dev/assets/23524849/b7a57bd0-6059-4c87-87b2-bd78d0ff01d7 'l') + +> (좌) 차이가 생긴 렌더링 결과물. (우) 차이가 생긴 부분 히트맵 + +시각적 회귀 테스트(Visual Regression Test)는 코드 변경 전후의 렌더링 된 UI의 스크린샷을 비교하는 테스트이다. + +위의 좌측 이미지를 확인해 보면 더 명확하게 알 수 있다. 모종의 이유로 하위 이미지 크기가 달라졌고 이에 따라 이후의 시각적 구조가 변경 되었다. + +우측 이미지는 Diff 이미지로, 차이가 생긴 영역에 붉게 표시를 해놓았다. + +이로써 우리는 컴포넌트가 실제로 어떻게 렌더링 되었는지 정확하게 알 수 있게 된다. + +## Playwright + +![playwright](https://github.com/1ilsang/dev/assets/23524849/006e72b3-5f73-4938-af76-3a4d9f39840c) + +어떤 방식의 테스트를 할지 결정되었으니 자연스럽게 어떤 도구로 테스트를 작성할지 고민하게 되었다. + +| Aspect | Playwright | Cypress | +| :-------------------: | :----------------------------------------------------------: | :--------------------------------------------------------------------------: | +| Browser 지원 | Chrome, Firefox, Webkit | Chrome, Firefox, Electron | +| 병렬 실행 | 무료 | 유료 | +| 멀티탭(다중 브라우저) | [가능](https://playwright.dev/docs/pages#handling-new-pages) | [불가능](https://docs.cypress.io/guides/references/trade-offs#Multiple-tabs) | +| 성능 | Headless Event-driven socket 방식으로 빠름 | 실제 브라우저에서 실행하므로 상대적으로 느림 | + +> 더 자세한 내용은 [Cypress vs Playwright: A Detailed Comparison](https://www.lambdatest.com/blog/cypress-vs-playwright/) 참고 + +꾸준히 [Cypress](https://www.cypress.io/)를 사용해 왔지만 병렬 처리에 상당히 답답함을 느끼고 있었기 때문에 이번 기회에 [Playwright](https://playwright.dev/)에 도전하고자 결정했다. + +물론 [Sorry-Cypress](https://sorry-cypress.dev/)로 병렬처리를 할 수 있지만 셀프 호스팅부터 신경 써야 하는 부분이 하나 더 생기기 때문에 기술부채가 싫은 나로서는 선택지에 해당되지 않았다. + +![image (6)](https://github.com/1ilsang/dev/assets/23524849/32d1be8f-db0f-441b-af8d-bb79975ecdd1) + +무엇보다 성능 부분에 차이가 있다. 테스트 결과를 하루종일 기다렸는데 심지어 실패했다? 한 줄 고치고 다시 하루종일 기다려야 한다. + +정말 하기 싫어진다. + +Playwright는 브라우저와 HTTP request 통신 대신 WebSocket으로 Dev tools에 바로 연결한다. 따라서 브라우저의 큰 메모리나 부가적인 리소스가 필요하지 않기 때문에 실제 브라우저와 통신하는 Cypress에 비해 가볍고 빠르다. + +그렇다면 Playwright로 어떻게 기존의 목적, DOM snapshot과 Screen snapshot을 할 수 있는지 살펴보자. + +### DOM Snapshot + +```ts {6} +import { expect, test } from '@playwright/test'; + +test('should match DOM snapshot', async ({ page }) => { + await page.goto('/about'); + const body = await page.locator('#__next').innerHTML(); + expect(body).toMatchSnapshot([`about.html`]); +}); +``` + +나는 Next.js를 사용하고 있으므로 `__next` 하위의 DOM만 비교하고자 한다. + +`innerHTML` 메서드를 통해 DOM 구조를 가져온 다음 playwright에서 제공하는 [toMatchSnapshot](https://playwright.dev/docs/api/class-snapshotassertions#snapshot-assertions-to-match-snapshot-2) 메서드로 DOM 스냅샷을 비교할 수 있다. + +### Screenshot + +```ts {5} +import { expect, test } from '@playwright/test'; + +test('should match Screenshot', async ({ page }) => { + await page.goto('/about'); + await expect(page).toHaveScreenshot({ fullPage: true }); +}); +``` + +스크린샷 또한 playwright에서 제공하는 [toHaveScreenshot](https://playwright.dev/docs/api/class-pageassertions#page-assertions-to-have-screenshot-2) 메서드로 쉽게 적용할 수 있다. + +나는 전체 화면의 비교를 할 것이므로 `fullPage`를 설정했다. + +### 시각적 회귀 테스트는 다양한 이유로 실패할 수 있다 + +1. 테스트가 실행되는 OS에 따라 화면이 달라지기 때문에 실패한다(이모지 등) +2. 동일한 OS라도 버전/브라우저에 따라 화면이 달라질 수 있다 +3. 실행된 머신의 [타임존](https://namu.wiki/w/%EC%8B%9C%EA%B0%84%EB%8C%80/%EB%AA%A9%EB%A1%9D?from=%EC%8B%9C%EA%B0%84%EB%8C%80%2F%EA%B0%81%EA%B5%AD%EC%9D%98%20%EC%8B%9C%EA%B0%84%EB%8C%80)에 따라 Date 값이 달라져 실패할 수 있다 +4. Image와 같은 Resource 로딩 시점에 따라 페이지가 달라질 수 있다 +5. Animation 혹은 setTimeout과 같은 시간에 종속된 동작은 일관성을 보장할 수 없다 +6. 눈에 큰 차이가 안 나더라도 실패할 수 있다(1px 차이로 실패 등) + +위의 내용들은 일반적인 E2E 테스트에서도 발생할 수 있는 실패 케이스들이다. 일부 케이스는 밑의 [트러블 슈팅](#트러블-슈팅)에서 다루겠다. + +이처럼 다양한 사이드 이펙트가 존재하기 때문에 동적인 컴포넌트가 많거나 화면이 자주 바뀐다면 도입 전에 [ROI](https://ko.wikipedia.org/wiki/%ED%88%AC%EC%9E%90%EC%9E%90%EB%B3%B8%EC%88%98%EC%9D%B5%EB%A5%A0)를 따져보는 것이 좋다. + +기본적인 설정은 되었으므로 CI/CD를 구축하자. + +## Github Actions + +![github actions](https://github.com/1ilsang/dev/assets/23524849/a4a8ede8-b9ed-448a-9ed0-ec805ffe74de 'l') + +> Image Source: [CI/CD with GitHub Actions: Step-by-Step Workflow](https://www.linkedin.com/pulse/cicd-github-actions-step-by-step-workflow-mysoly-v-o-f-vngqf/) + +[Github Actions](https://docs.github.com/ko/actions)를 통해 [CI/CD](https://www.redhat.com/ko/topics/devops/what-is-ci-cd)를 간편하게 구축할 수 있다. + +```yml title=".github/workflow/playwright.yml" +name: Playwright Tests +on: + push: + branches: [main] + pull_request: + branches: [main] +jobs: + test: + timeout-minutes: 60 + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version-file: .nvmrc + - name: Install dependencies + run: npm ci + - name: Install Playwright Browsers + run: npx playwright install --with-deps + - name: Run Playwright tests + run: npx playwright test + # artifact에 playwright report를 업로드해 어디서 실패했는지 확인할 수 있다 + - uses: actions/upload-artifact@v4 + if: failure() + with: + name: playwright-report + path: playwright-report/ + retention-days: 30 +``` + +![Actions result](https://github.com/1ilsang/dev/assets/23524849/215c1354-03af-4b70-aadb-df71b92013ac 'l') + +## 성능 개선 + +위의 CI/CD는 3가지 문제가 있다. + +1. 테스트 속도가 느리고 flow를 한눈에 확인하기 어렵다. +2. 캐싱이 전혀 되고 있지 않다. +3. 테스트가 실패하면 다시 처음부터 실행해야 한다. + +이것을 개선해보고자 한다. + +### 테스트 방식 + +![action log](https://github.com/1ilsang/dev/assets/23524849/ea06df55-1950-47ac-9a1a-841dd2b6abc2) + +테스트의 어디까지 성공했는지, 어떤 테스트를 실행 중인지 등의 작업 상황을 보기 위해선 현재는 로그를 확인해야 한다. + +이러한 문제의 근본적인 이유는 특정 기능 단위의 테스트만 실행시키는 방법이 존재하지 않기 때문이다. + +```json title="package.json" +{ + // ... + "e2e:others": "pnpm playwright test --grep-invert /@/", + "e2e:dom": "pnpm playwright test --grep '@dom-snapshot'", + "e2e:screen": "pnpm playwright test --grep '@screen-snapshot'" +} +``` + +따라서 playwright에서 제공하는 [grep](https://playwright.dev/docs/test-cli#reference) 명령어를 활용해 원하는 기능별로 테스트를 적용할 수 있다. + +DOM 스냅샷은 `@dom-snapshot` 키워드를, Screen 스냅샷은 `@screen-snapshot` 키워드를 가지고 있어야 한다. 그 이외의 테스트는 `others`로 실행된다. + +```ts title="e2e/about.spec.ts" +export enum MACRO_SUITE { + DOM_SNAPSHOT = '@dom-snapshot', + SCREEN_SNAPSHOT = '@screen-snapshot', +} + +test.describe('about', () => { + test(MACRO_SUITE.SCREEN_SNAPSHOT, async ({ page }) => { + await screenshotFullPage({ page, url: `/about`, arg: [`about.png`] }); + }); + + test(MACRO_SUITE.DOM_SNAPSHOT, async ({ page }) => { + await gotoUrl({ page, url: '/about' }); + const body = await page.locator('#__next').innerHTML(); + expect(body).toMatchSnapshot([`about.html`]); + }); + + test('should redirect 404', async ({ page }) => { + await gotoUrl({ page, url: '/something_wrong_path', timeout: 60_000 }); + await expect(page.getByText(/404 ERROR/)).toBeVisible(); + }); +}); +``` + +이제 우리는 dom, screen, others 세 가지 테스트 피처를 가지게 되었다. 이것은 후술할 CI/CD에서 큰 역할을 하게 된다. + +### 테스트 속도 + +```ts {7} +test.describe(MACRO_SUITE.SCREEN_SNAPSHOT, () => { + for (let i = 0; i < urls.length; i++) { + const url = urls[i]; + + test(`${url}`, async ({ page }) => { + await page.goto(`/posts/${url}`); + await page.waitForTimeout(3000); + await expect(page).toHaveScreenshot({ fullPage: true }); + }); + } +}); +``` + +테스트 속도에는 많은 것들의 영향이 있겠지만 기본적으로 wait timeout이 가장 좋지 않다. + +특히 위와 같이 반복문으로 작업을 하게 될 경우 N의 배수로 시간이 증가하게 된다. + +이미지 로딩까지 3초의 텀을 두고자 한 위의 코드는 이미지가 빨리 로딩되었다면 불필요한 기다림이 발생하고 이미지가 3초보다 늦게 로딩되면 깨지는 불안정한 코드다. + +```ts +// Image 로딩 wait +const locators = page.locator('img'); +const scrollPromises = (await locators.all()).map(async (locator) => { + // https://playwright.dev/docs/api/class-locator#locator-scroll-into-view-if-needed + // 이미지 요소가 준비되었는지 확인 + return await locator.scrollIntoViewIfNeeded(); +}); +await Promise.all(scrollPromises); +const imgLoadingPromises = (await locators.all()).map((locator) => + locator.evaluate( + // 이미지 요소의 로딩 상태 확인 + (image) => image.complete || new Promise((f) => (image.onload = f)), + ), +); +await Promise.all(imgLoadingPromises); + +// Font wait +await page.evaluate(() => document.fonts.ready); +``` + +이처럼 유동적인 사이드 이펙트는 이벤트로 처리하면 보다 안정적으로 처리할 수 있다. + +### CI/CD + +앞서 언급한 세 가지 문제 + +1. 테스트 속도가 느리고 flow를 한눈에 확인하기 어렵다. +2. 캐싱이 전혀 되고 있지 않다. +3. 테스트가 실패하면 다시 처음부터 실행해야 한다. + +이것은 workflow와 actions를 적절하게 나눠주고 actions/cache를 활용하면 된다. + +```yml title="workflows" +jobs: + others: + uses: './.github/workflows/e2e-reusable.yml' + with: + others: true + + dom-snapshot: + uses: './.github/workflows/e2e-reusable.yml' + with: + dom-snapshot: true + + screen-snapshot: + uses: './.github/workflows/e2e-reusable.yml' + with: + screen-snapshot: true +``` + +![workflow job](https://github.com/1ilsang/dev/assets/23524849/e4efbba0-43ea-4cd1-84cb-704f86c81fac) + +앞에서 나눈 테스트 피처 단위로 workflows의 job을 나눠주고 [workflow_call](https://github.com/1ilsang/dev/blob/23af6919397a28a6a69881d4b376f4c77d0b3584/.github/workflows/e2e-reusable.yml#L5)을 적절하게 사용한다면 편리하고 가독성 좋은 Flow를 만들 수 있다. + +![failed flow](https://github.com/1ilsang/dev/assets/23524849/8c2b620f-515a-42a5-8d64-340675f3b879 'l') + +무엇보다 job을 나누게 되면 실패한 부분만 재실행할 수 있기 때문에 더욱 유연한 테스트를 할 수 있게 된다. + +```yml title="actions" +# playwright 설치 캐시 +- name: Cache Playwright Browsers for Playwright's Version + uses: actions/cache@v4 + with: + # https://playwright.dev/docs/browsers#managing-browser-binaries + path: ~/Library/Caches/ms-playwright + key: ${{ runner.os }}-playwright-${{ steps.playwright-version.outputs.PLAYWRIGHT_VERSION }} + id: cache-playwright-browsers + +- name: Setup Playwright + shell: bash + if: steps.cache-playwright-browsers.outputs.cache-hit != 'true' + run: pnpm e2e:install + +# pnpm 설치 캐시 +- name: Setup pnpm cache + uses: actions/cache@v4 + with: + path: ${{ env.STORE_PATH }} + key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }} + restore-keys: | + ${{ runner.os }}-pnpm-store- + +# Next.js Build and Export 캐시 +- name: Restore Next.js related caches + uses: actions/cache@v4 + with: + path: | + ${{ github.workspace }}/.next + ${{ github.workspace }}/out + key: ${{ runner.os }}-nextjs-store-${{ hashFiles('**/pnpm-lock.yaml') }}-${{ hashFiles('**.[jt]s', '**.[jt]sx', '**.md') }}-${{ inputs.e2e == 'true' && 'e2e' || 'default' }} + restore-keys: | + ${{ runner.os }}-nextjs-store-${{ hashFiles('**/pnpm-lock.yaml') }}- + id: cache-nextjs-build + +- name: Build and Export [default] + shell: bash + if: steps.cache-nextjs-build.outputs.cache-hit != 'true' + run: pnpm e2e:build +``` + +Job을 분리하면 불필요한 반복 빌드 작업이 발생하게 되는데 이를 캐싱을 통해 시간을 단축시킬 수 있다. + +특히 잘 변경되지 않는 정적 블로그의 경우 `pnpm`, `.next`, `out`, `playwright`를 캐싱해 두면 전체 테스트 시간을 아낄 수 있게 된다. + +![compare time](https://github.com/1ilsang/dev/assets/23524849/971e8417-96cc-492b-b429-c991989b2198 's') + +이로써 절반이상 시간을 줄이고 실패에 더 유연한 CI 테스트를 할 수 있게 되었다. + +[완성된 전체 코드](https://github.com/1ilsang/dev/tree/23af6919397a28a6a69881d4b376f4c77d0b3584)는 깃헙에서 확인할 수 있다. `.github` 및 `e2e`를 확인하면 된다. + +## 트러블 슈팅 + +### 로컬 테스트를 포기해야 할까 + +팀 단위의 협업에선 로컬 머신 버전을 강제하기 어렵기 때문에 로컬 테스트와 CI 테스트의 동기화가 어렵다. + +따라서 도커를 활용하든가 CI 테스트만 사용하든가 양자택일로 흐르게 된다. + +하지만 지금 나의 플로우와 같이 1인 개발이라면 로컬과 CI 테스트를 어느 정도 맞춰줄 수 있다. + +![Macos runner version](https://github.com/1ilsang/dev/assets/23524849/cd3eb08d-4dda-4f2a-992d-bdb6a552df27 'l') + +> Runner [전체 목록 확인](https://github.com/actions/runner-images/tree/main?tab=readme-ov-file#available-images) + +```yml +jobs: + my-job: + runs-on: macos-latest +``` + +Github에서 제공해주는 Actions Runner에 MacOS가 존재하기 때문에 로컬과 버전을 맞춰줄 수 있다. + +완벽하다고 장담은 못하겠지만 현재까지는 로컬과 CI 테스트가 모두 동일하게 동작하며 통과하고 있다. + +### Timezone + +CI 테스트에서 가장 많이 실패하는 부분은 Timezone이다. 우리는 +9의 값을 가지고 있기 때문에 스냅샷 테스트에서 반드시 실패한다. + +```yml +jobs: + my-job: + runs-on: macos-latest + env: + TZ: Asia/Seoul +``` + +깃헙 액션에서는 `env`로 타임존 값을 넘길수 있다. 이를 통해 편리하게 머신의 타임존을 변경할 수 있다. + +playwright에서 어떤 브라우저를 선택하느냐에 따라 타임존 기준점이 조금 달라진다. + +- 크롬의 경우 기본적으로 머신의 타임존을 따른다. +- Webkit은 config에 설정한 타임존을 따른다. + +만약 playwright에서 webkit을 사용하고 있다면 아래와 같이 `playwright.config.ts`를 변경해야 한다. + +```ts title="playwright.config.ts" +export default defineConfig({ + // ... + use: { + browserName: 'webkit', + timezoneId: 'Asia/Seoul', + }, +}); +``` + +### 테스트 분기 + +코드에 테스트로 인한 분기점이 생기는 것을 원하지 않지만 어쩔 수 없는 경우(혹은 편의로) 빌드를 나누어 코드에 적용할 수 있다. + +```json title="package.json" +{ + "deploy-blog": "next build && next export", + "e2e:build": "NEXT_PUBLIC_CI=true next build && next export" +} +``` + +e2e를 위한 빌드 스크립트를 만든 다음 환경변수를 주입해 코드에서 적용할 수 있다. + +```tsx +useEffect(() => { + if (process.env.NEXT_PUBLIC_CI) return; +``` + +### 1px + +![1px bug](https://github.com/1ilsang/dev/assets/23524849/1a0e4366-15f4-418c-bf83-6851ce1ad507 'l') + +크롬에서는 스크린샷이 1px 다른 경우가 있다([#18827](https://github.com/microsoft/playwright/issues/18827)). + +이때는 clip으로 고정하거나 height를 강제하는 방법으로 처리할 수 있다. + +### Image load + +로드와 관련된 트러블 슈팅은 [테스트 속도](#테스트-속도)에서 다루었다. + +## 마무리 + +시각적 회귀 테스트를 통해 심신의 안정을 많이 찾을 수 있었다. + +이제 더욱 과감하게 리팩터링을 진행할 수 있게 되었다. + +특히 playwright를 사용하며 경험이 좋았기 때문에 앞으로도 꾸준히 사용해 보고자 한다. + +이 글을 쓰며 참고했던 혹은 유용했던 링크를 남기며 글을 마무리하려고 한다. + +- [우아한형제들 디자인 시스템에 시각적 회귀 테스트 적용하기](https://techblog.woowahan.com/17081/) +- [UI 테스트를 위한 여정](https://tv.kakao.com/channel/3693125/cliplink/414129351) diff --git a/_posts/js/vite-dev-server.md b/_posts/js/vite-dev-server.md new file mode 100644 index 00000000..42965578 --- /dev/null +++ b/_posts/js/vite-dev-server.md @@ -0,0 +1,465 @@ +--- +title: 'Vite Dev Server 이해하기 (feat. HMR)' +description: 'Dev 서버의 동작 방식은 어떻게 될까?' +url: 'vite-dev-server' +tags: ['vite', 'dev-server', 'hmr', 'preact', 'prefresh'] +coverImage: 'https://github.com/1ilsang/dev/assets/23524849/132b52c7-3c2b-4554-b0fb-8ec5f3193d7a' +date: '2024-02-04T13:50:51.772Z' +ogImage: + url: 'https://github.com/1ilsang/dev/assets/23524849/132b52c7-3c2b-4554-b0fb-8ec5f3193d7a' +--- + +![cover](https://github.com/1ilsang/dev/assets/23524849/132b52c7-3c2b-4554-b0fb-8ec5f3193d7a 'cover') + +요즘 [Vite](https://vitejs.dev/)의 매력에 푹 빠져있다. 그러던 도중 "개발 서버는 어떻게 동작하는 걸까?" 의문을 가지게 되었다. 따라서 오늘은 Vite Dev Server의 동작 방식을 이해하고 HMR 과정을 파헤쳐 보려고 한다. + +## Index + +- [TL;DR!](#tldr) +- [1. 개발 서버 실행(서버 초기화)](#1-개발-서버-실행서버-초기화) +- [2. index.html 요청](#2-indexhtml-요청) +- [3. index.html 렌더링과 자원 요청](#3-indexhtml-렌더링과-자원-요청) +- [4. 렌더링 계속 진행(with WebSocket)](#4-렌더링-계속-진행with-websocket) +- [5. 코드 변경 감지](#5-코드-변경-감지) +- [6. 브라우저 리렌더링](#6-브라우저-리렌더링) +- [마무리](#마무리) + +## TL;DR! + +![dev-server-logic-summary](https://github.com/1ilsang/dev/assets/23524849/03dab012-82a9-4649-8d80-15c0dfe0c129 'l') + +> 한 짤로 보는 Dev Server의 동작 방식 + +이 글은 핵심 로직에 해당하는 노란색 박스를 위주로 설명하려고 한다. 위의 도식도를 쫓아오며 글을 읽는다면 도움이 될 것으로 생각한다. + +이 글은 Vite `v5.0.12` [버전을 기준](https://github.com/vitejs/vite/tree/v5.0.12)으로 작성되었다. + +Let's Dive! + +## 1. 개발 서버 실행(서버 초기화) + +![init-server-phase](https://github.com/1ilsang/dev/assets/23524849/7d46712d-6118-4788-9c91-fa2d20f8c3c3) + +> 최초 서버 실행 이후의 상태 + +```ts title="vite/packages/vite/src/node/server/index.ts" +// https://github.com/vitejs/vite/blob/v5.0.12/packages/vite/src/node/server/index.ts#L385 +// server/index.ts +export async function _createServer( + inlineConfig: InlineConfig = {}, + options: { ws: boolean }, +): Promise { + // connect를 사용해 express와 같은 미들웨어 구조를 가진다. + const middlewares = connect() as Connect.Server + + // HTTP 서버와 웹 소켓 서버를 생성한다. + const httpServer = await resolveHttpServer(serverConfig, middlewares, httpsOptions) + const ws = createWebSocketServer(httpServer, config, httpsOptions) + + // 파일 변경 감지를 위해 chokidar를 설정한다. + const watcher = chokidar.watch((...) as FSWatcher) + + // 의존성 관계를 추적할 수 있는 모듈 그래프를 만든다. HMR 및 트리쉐이킹 같은 최적화 작업을 위해 존재한다. + // 서버 초기화 단계에서는 그래프가 비어있다. + const moduleGraph: ModuleGraph = new ModuleGraph(...) + + // Rollup의 플러그인 컨테이너를 활용해 플러그인 구성을 만든다. + const container = await createPluginContainer(config, moduleGraph, watcher) + + // ... +} +``` + +`yarn vite` 등의 커맨드로 Dev Server를 실행시키면 `bin/vite.js`의 `cli.js`가 [호출](https://github.com/vitejs/vite/blob/v5.0.12/packages/vite/bin/vite.js#L44)된다. 이후 `src/node/cli.ts`가 [호출](https://github.com/vitejs/vite/blob/v5.0.12/packages/vite/src/node/cli.ts#L156)되면서 Dev Server가 실행된다. + +### Dev Server는 아래와 같은 프로세스를 거치며 초기화를 진행한다. + +1. Dev Server가 실행되면 HTTP 서버와 웹 소켓 서버가 실행된다. + + - 미들웨어는 Express에서 사용되는 [connect](https://www.npmjs.com/package/connect)로 연결된다. + +2. 파일 시스템 옵저버를 설정한다. + + - 파일 변경 감지를 위해 [chokidar](https://www.npmjs.com/package/chokidar)를 사용한다. + +3. 모듈 그래프를 생성한다. + + - 모듈(파일)의 의존성 관계를 추적한다. + - [HMR(Hot Module Replacement)](https://webpack.kr/concepts/hot-module-replacement/) 및 [트리쉐이킹](https://webpack.kr/guides/tree-shaking/) 같은 최적화 작업을 위해 존재한다. + - 현재(초기화 단계)는 비어있다. + +4. 플러그인 컨테이너를 생성한다. + - Dev Server에 필요한 Built-in(내장된) 플러그인이 추가된다. + - importAnalysis, css, optimizer, json 등이 있다. + - 사용자가 추가한 플러그인(vite.config.ts > plugins)이 추가된다. + - 플러그인은 이후 Dev Server의 특정 시점마다 훅을 실행시켜 미들웨어 역할을 하게 된다. +5. 클라이언트의 요청을 기다린다. + +## 2. index.html 요청 + +![index.html-request-phase](https://github.com/1ilsang/dev/assets/23524849/ec8f01ad-b4a6-4da8-aaa6-0e5015438ba3 'l') + +```ts {11,14} +// server/index.ts +middlewares.use(indexHtmlMiddleware(root, server)) + +// https://github.com/vitejs/vite/blob/v5.0.12/packages/vite/src/node/server/middlewares/indexHtml.ts#L438 +// HTML 파일을 처리하고 변환한다. 스크립트 태그 주입 및 HMR 클라이언트 코드 삽입, 모듈 경로 변환 등의 작업을 한다. +html = await server.transformIndexHtml(url, html, req.originalUrl) + +// transform +export function createDevHtmlTransformFn(...) { + // 필요한 플러그인의 실행 시점에 따라 분류한다. + const [preHooks, normalHooks, postHooks] = resolveHtmlTransforms(...) + return (...) => { + // html에 반영한다. + return applyHtmlTransforms( + html, + [ + preImportMapHook(config), + ...preHooks, + // ... + ], + { ... }, + ) + } +} +``` + +최초 유저의 요청(`GET /`)이 발생하면 `index.html`이 리턴된다. 이 과정에서 `transform`과 같은 [플러그인 훅](https://vitejs.dev/guide/api-plugin.html#universal-hooks)을 거치며 필요한 데이터들을 세팅한다. + +1. 미들웨어에서 `transform` 함수가 실행된다. + + - 플러그인 컨테이너의 플러그인들이 실행 된다. + - 플러그인들은 실행 시점(`pre`, `normal`, `post`)에 맞춰 훅이 실행된다. + +2. 의존성 사전 번들링 + - `node_modules`에 있는 의존성은 [ESM](https://webpack.kr/guides/ecma-script-modules/)이 아닐 수 있다. Vite는 이들을 [사전 번들링](https://ko.vitejs.dev/guide/dep-pre-bundling.html)하여 브라우저가 이해할 수 있는 ESM 형태로 변환한다. + - 이 과정은 esbuild로 실행되어 빠르게 처리된다. + +![transpile-ts-to-js](https://github.com/1ilsang/dev/assets/23524849/23f4b155-b5f5-487d-8500-b39796919829 'l') + +> hmr.ts의 response에 타입이 사라진 모습. + +3. 코드 변환 + + - TS 혹은 JSX 파일의 경우 JS로 변환된다. + - 위의 그림과 같이 파일명 자체는 변경되지 않지만, 코드는 js로 변경된다. + +```ts +// 만약 index.html에서 해당 파일을 import 한다고 가정해 보자. +// playground/hmr/hmr.ts +import { foo as depFoo, nestedFoo } from './hmrDep' +import './importing-updated' +import './invalidation/parent' + +// hmr.ts 파일에 구성된 모듈 의존성 그래프 +ModuleNode { + url: '/hmr.ts', + file: '/User/user/VSCode/vite/playground/hmr/hmr.ts', + type: 'js', + // 클라이언트 측에서 사용되는 모듈들, 즉 브라우저에서 실행되는 모듈들의 목록을 추적하는 데 사용된다. + clientImportModules: Set(10) { + // 재귀적 구조 + ModuleNode: { + url: '/hmrDep.js' // hmr.ts 내부에서 import 되는 hmrDep 이 추가된 모습. + file: '/User/user/VSCode/vite/playground/hmr/hmrDep.js', + clientImportModules: Set(10) { + ModuleNode: { ... } + }. + ModuleNode: { + url: '/importing-updated/index.js', // hmr.ts 내부에서 import 되는 importing-updated가 추가된 모습. + file: '/User/user/VSCode/vite/playground/hmr/importing-updated/index.js', + // ... + } + // ... +``` + +4. 모듈 의존성 그래프 생성 + + - 위 코드를 보면 `hmr.ts`에서 import 되는 `./hmrDep`, `./importing-updated` 등이 `ModuleNode`에 설정되는 것을 알 수 있다. + - 만약 외부 의존성이 있다면 chokidar에 추가된다. + - 파일 시스템에 변경사항이 있을 때 모듈 그래프로 빠르게 전파시킨다. + +5. Dev Server의 기능에 필요한 사전 코드들(`@vite/client` 등)을 응답 자원에 추가한다. + +6. 변환된 html 파일을 리턴한다. + +## 3. index.html 렌더링과 자원 요청 + +![index.html-rendering-phase](https://github.com/1ilsang/dev/assets/23524849/5321cc9c-f569-4653-8179-06669e82f630 'l') + +> 3 ~ 4. 브라우저 렌더링 및 정적 자원 요청 상황. + +![init-html](https://github.com/1ilsang/dev/assets/23524849/a5969d65-b177-4011-ab1b-d45129b9951e) + +![browser-initiator](https://github.com/1ilsang/dev/assets/23524849/3eacdd15-514a-43a9-a71c-cc8ba228d574 'l') + +> 브라우저는 위에서부터 아래로 해석해 나가므로 @vite/client, global.css, hmr.ts가 순차적으로 요청되는 것을 볼 수 있다. + +1. 브라우저는 응답받은 html 파일을 렌더링 하기 시작한다. + +2. 렌더링에 필요한 자원(js, css 등)을 다시 Dev Server에 요청한다. + + - html의 최상단 `/@vite/client`을 시작점으로 `global.css`, `hmr.ts` 등이 요청된다. + +```ts {13} +// server/index.ts +// main transform middleware +middlewares.use(transformMiddleware(server)) + +// https://github.com/vitejs/vite/blob/v5.0.12/packages/vite/src/node/server/middlewares/transform.ts#L175 +export function transformMiddleware(...) { + // resolve, load and transform using the plugin container + const result = await transformRequest(url, server, { + html: req.headers.accept?.includes('text/html'), + }) + if (result) { + // transform된 코드, 소스코드를 캐시 설정해 리턴한다. + return send(req, res, result.code, type, { + etag: result.etag, + cacheControl: isDep ? 'max-age=31536000,immutable' : 'no-cache', + headers: server.config.server.headers, + map: result.map, + }) + } +} +``` + +3. transform 적용 + + - 각 요청에 대해 Dev Server는 [transformMiddleware](https://github.com/vitejs/vite/blob/v5.0.12/packages/vite/src/node/server/middlewares/transform.ts#L175)에서 [2번 html 요청](#2-indexhtml-요청)과 비슷한 과정으로 `transform` 이후 응답한다. + - 이때 `public` 폴더 내의 요청인지 외부 자원 요청인지 등의 분류 작업 또한 [미들웨어에서 진행](https://github.com/vitejs/vite/blob/v5.0.12/packages/vite/src/node/server/index.ts#L774)한다. + - `@fs` prefix는 vite 프로젝트의 루트(config 위치)를 벗어날 경우 설정된다(모노레포 혹은 파일 시스템 직접 접근 등의 경우). + - HMR 코드 적용 + - Dev Server에 내장된 [importAnalysis 플러그인](https://github.com/vitejs/vite/blob/v5.0.12/packages/vite/src/node/plugins/importAnalysis.ts#L209)에서 HMR이 설정된 파일(\*1)이라면 `import.meta.hot`을 파일 최상단에 [추가](https://github.com/vitejs/vite/blob/v5.0.12/packages/vite/src/node/plugins/importAnalysis.ts#L715)한다. + +4. 변환된 자원을 브라우저에 응답(response)한다. + +> (\*1): preact의 prefresh 같은 HMR 라이브러리를 적용했거나(후술) import.meta.hot.accept을 직접 코드에 추가한 경우에 해당(아래 코드)한다. + +```html {7} + + +``` + +![inject-import-meta-hot](https://github.com/1ilsang/dev/assets/23524849/57326d8a-3c7a-41d4-ac7b-b202bc851bc8 'l') + +> 일반 스크립트의 응답에 createHotContext 생성 및 import.meta.hot에 바인딩된 모습. + +## 4. 렌더링 계속 진행(with WebSocket) + +이제 index.html의 요청 파일을 가져왔으므로 브라우저 렌더링이 계속 진행된다. + +```ts title="@vite/client.ts" +function setupWebSocket(...) { + const socket = new WebSocket(`${protocol}://${hostAndPath}`, 'vite-hmr') + socket.addEventListener('message', async ({ data }) => { + handleMessage(JSON.parse(data)) + }); +} +``` + +- `@vite/client` 파일 + - Dev Server와의 통신 및 [HMR에 필요한 코드들](https://github.com/vitejs/vite/blob/v5.0.12/packages/vite/src/client/client.ts#L137)이 작성되어 있다. + - [WebSocket 연결](https://github.com/vitejs/vite/blob/v5.0.12/packages/vite/src/client/client.ts#L68) 및 `update` 이벤트를 기다린다. + +```tsx {2,9,12} +// AS-IS 원본 코드 +import { h, render } from 'preact'; +import App from './MyComponent'; + +render(, document.getElementById('app')); + +// TO-BE 변경된 코드 +import { render } from '/node_modules/.vite/deps/preact.js'; +import { jsxDEV as _jsxDEV } from '/node_modules/.vite/deps/preact_jsx-dev-runtime.js'; +import App from '/src/MyComponent'; + +render(_jsxDEV(App, ...), document.getElementById('app')) +``` + +만약 `react`와 같은 UI 라이브러리를 사용한다면 각 라이브러리가 의존하는 HMR 라이브러리가 호출된다. 여기서는 `preact`를 기준으로 설명(리액트와 거의 동일하다)하겠다. + +`jsxDEV`로 감싸진 하위 컴포넌트들은 HMR이 적용된다. 자세한 내용은 [6. 브라우저 리렌더링](#6-브라우저-리렌더링)에서 다루겠다. + +이제 브라우저가 더 이상 요청할 것이 없을 때까지 3 ~ 4 과정을 반복하며 렌더링을 마무리한다. + +## 5. 코드 변경 감지 + +![file-change-phase](https://github.com/1ilsang/dev/assets/23524849/51800d15-f916-4c25-bc1f-6a6bd2044d54 'l') + +> 코드가 변경되었을 때의 Dev Server 모습 + +```ts +watcher.on('change', async (file) => { + file = normalizePath(file); + // 플러그인 컨테이너에게 update 이벤트 발송. 플러그인에서 필요시 실행된다. + await container.watchChange(file, { event: 'update' }); + // 의존성 그래프에 변경사항 체크 + moduleGraph.onFileChange(file); + // 대망의 HMR 업데이트 시작 + await onHMRUpdate(file, false); +}); +``` + +개발 중 파일이 변경되면(개발자의 코드 수정) chokidar에서 `change` [이벤트를 감지](https://github.com/vitejs/vite/blob/v5.0.12/packages/vite/src/node/server/index.ts#L673)한다. + +- 플러그인 컨테이너에 `update` 이벤트를 전파한다. + - 각 플러그인에서 필요시(listen) 플러그인 코드가 실행된다. +- 의존성 그래프에 변경사항을 적용한다. + - 모듈 캐싱을 무효화해 refresh 되도록 함. +- `onHMRUpdate` 함수를 호출해 hot-reloading을 준비한다. + +```ts {7,18} +function onHMRUpdate() { + // 관련 플러그인 훅 실행 + for (const hook of config.getSortedPluginHooks('handleHotUpdate')) { + const filteredModules = await hook(hmrContext) + } + ... + updateModules(...) +} + +function updateModules(...) { + for (const mod of modules) { + const boundaries: PropagationBoundary[] = [] + // 모듈 그래프 갱신 + const hasDeadEnd = propagateUpdate(mod, traversedModules, boundaries) + moduleGraph.invalidateModule(mod, invalidatedModules, timestamp, true) + } + // 소켓 메시지 전송 + ws.send({ type: 'update', updates }) +``` + +`onHMRUpdate`는 `updateModules`를 [호출](https://github.com/vitejs/vite/blob/v5.0.12/packages/vite/src/node/server/hmr.ts#L142)한다. + +- HMR 관련 플러그인이 있을 때 훅(`handleHotUpdate`)을 실행시킨다. +- 관련된 모듈 그래프를 갱신한다. +- 브라우저에게 파일이 변경되었음을 WebSocket으로 알린다(update 이벤트 전송). + +## 6. 브라우저 리렌더링 + +![socket-update-event](https://github.com/1ilsang/dev/assets/23524849/23181d27-311e-4154-a04f-7efa2fb7cae9 'l') + +> 브라우저 소켓이 Dev Server의 update 소켓 데이터를 받은 모습. + +```ts /hmrClient.fetchUpdate/ {22} +// Step 1. +// @vite/client.ts +case 'update': + notifyListeners('vite:beforeUpdate', payload); + await Promise.all(payload.updates.map(async(update)=> { + if (update.type === 'js-update') { + // queueUpdate는 업데이트 목록의 순서를 유지해준다. + return queueUpdate(hmrClient.fetchUpdate(update)) + } + // ... CSS update는 생략 + }); + notifyListeners('vite:afterUpdate', payload); + +// Step 2. +// HMRClient > fetchUpdate +fetchUpdate(...) { + fetchedModule = await this.importUpdatedModule(update); + +// client/client.ts > importUpdatedModule +async function importUpdatedModule(...) { + // Step 3. + const importPromise = import( + /* @vite-ignore */ + base + + acceptedPathWithoutQuery.slice(1) + + `?${explicitImportRequired ? 'import&' : ''}t=${timestamp}${ + query ? `&${query}` : '' + }` + ) + return await importPromise +}, +``` + +1. `@vite/client`에서 연결된 브라우저의 소켓은 `update` 이벤트를 받고 `hmrClient`에게 [업데이트를 지시](https://github.com/vitejs/vite/blob/v5.0.12/packages/vite/src/client/client.ts#L182)한다. + +2. hmrClient는 `fetchUpdate`에 데이터를 넘기고 `importUpdatedModule`을 [호출](https://github.com/vitejs/vite/blob/v5.0.12/packages/vite/src/shared/hmr.ts#L235)한다. + + - importUpdatedModule은 hmrClient가 [생성될 때 적용](https://github.com/vitejs/vite/blob/v5.0.12/packages/vite/src/client/client.ts#L137)된다. + +![import response](https://github.com/1ilsang/dev/assets/23524849/d2d0de89-4eee-4496-9c51-d99d228eed0b 'l') + +> Step 3 ~ 4. + +3. importUpdatedModule이 호출되면서 변경된 모듈이 `import` 되므로, Dev Server에 새로 요청하게 된다([3. 렌더링 자원 요청](#3-indexhtml-렌더링과-자원-요청)). 이때 `t` 값을 쿼리로 넣어(?t=123214123) 캐싱을 회피해 변경된 모듈의 코드를 응답으로 받을 수 있도록 한다. + +4. import로 요청한 응답이 정상적으로 오면 리렌더링 되기 시작([4. 렌더링 진행](#4-렌더링-계속-진행with-websocket))된다. + +5. HMR이 가능한 파일은 `import.meta.hot.accept` 함수의 [콜백으로 실행](https://ko.vitejs.dev/guide/api-hmr.html#hot-accept-cb)된다. HMR이 불가능한 파일이라면 전체 페이지를 리로딩한다. + +```ts {16} +// vite.config.ts +import preact from '@preact/preset-vite'; + +export default defineConfig({ + // Step 1. preact 플러그인 호출 + plugins: [preact()], +}); + +// Step2 2. preact 플러그인에서 prefresh가 호출되면서 소스코드를 transform 한다. +return { + code: `${prelude}${result.code} + if (import.meta.hot) { + self.$RefreshReg$ = prevRefreshReg; + self.$RefreshSig$ = prevRefreshSig;` + // 중요! Step 3. 해당 코드 덕에 HMR로 인식, Dev Server에서 호출되며 flushUpdate 실행 + `import.meta.hot.accept((m) => { + try { + flushUpdates();` + +// Step 4. 실제 코드 변경 부분. +function flushUpdates() { + self.__PREFRESH__.replaceComponent(prev, next, true); +``` + +앞에서 잠깐 다뤘지만 `react`, `preact` 등 순수 자바스크립트가 아니라면 라이브러리 자체 HMR을 호출한다. 이 HMR 코드는 [[3. 렌더링 자원 요청](#3-indexhtml-렌더링과-자원-요청)] 단계에서 추가된다. + +1. [preact-vite 플러그인](https://github.com/preactjs/preset-vite)(`@preact/preset-vite`)은 내부적으로 [prefresh](https://github.com/preactjs/prefresh)라는 HMR 라이브러리를 사용한다. + +2. preact 플러그인은 `prefreshEnabled` 여부에 따라 [prefresh를 호출](https://github.com/preactjs/preset-vite/blob/a325c1f3811900f70277424304c9eb42fc60f8a7/src/index.ts#L246)한다. + +3. prefresh는 `transform` 단계에서 `import.meta.hot.accept` [코드를 주입](https://github.com/preactjs/prefresh/blob/main/packages/vite/src/index.js#L87)한다. + +4. [[6. 브라우저 리렌더링](#6-브라우저-리렌더링)] 발생시 3번 단계의 `importUpdatedModule`를 거쳐 `import.meta.hot.accept`의 콜백이 실행된다. + +5. perfresh에서 심어둔 `flushUpdates` 함수가 [HMR을 수행](https://github.com/preactjs/prefresh/blob/018f5cc907629b82ffb201c32e948efe4b40098a/packages/utils/src/index.js#L11)(컴포넌트 변경)된다. + +이로써 HMR이 완전히 마무리되면서 다시 개발자의 입력을 기다리게 된다. + +![dev-server-logic-summary](https://github.com/1ilsang/dev/assets/23524849/03dab012-82a9-4649-8d80-15c0dfe0c129 'l') + +> Dev Server 초기화부터 HMR까지. + +## 마무리 + +Vite Dev Server를 사용하면서 모호하게 알고 있던 부분을 이번 기회에 한번 쭉 정리할 수 있었다. 정리하면서 모르는 것이 참 많다고 느꼈다. + +팩트인지 확인하기 위한 소스코드 탐험과 디버깅 과정은 상당히 의미 있었다. 글이 너무 길어질 것 같아 생략한 함수들이 꽤 있는데 감탄하며 본 로직들이 많이 있었다. 역시 남의 코드를 많이 봐야 한다. + +이번 과정을 통해 두 가지 인사이트를 얻을 수 있었다. + +1. Vite Dev Server의 많은 부분들이 Webpack Dev Server의 동작과 비슷하다는 점이 인상적이었다. 하나를 잘해놓는 게 중요하다고 느꼈다. +2. 소스코드의 탐험이 쉽지만은 않았만 어느 정도 자신감이 붙을 수 있었다. 이후에도 이렇게 공부해 나가야겠다고 생각했다. + +이 글을 쓰며 참고했던 혹은 유용했던 링크를 남기며 글을 마무리하려고 한다. + +- [https://rajaraodv.medium.com/webpack-hot-module-replacement-hmr-e756a726a07](https://rajaraodv.medium.com/webpack-hot-module-replacement-hmr-e756a726a07) +- [https://webpack.kr/concepts/hot-module-replacement](https://webpack.kr/concepts/hot-module-replacement) +- [https://ko.vitejs.dev/config/server-options.html](https://ko.vitejs.dev/config/server-options.html) +- [https://github.com/vitejs/vite](https://github.com/vitejs/vite) diff --git a/_posts/retrospect/2023.md b/_posts/retrospect/2023.md new file mode 100644 index 00000000..afdeb161 --- /dev/null +++ b/_posts/retrospect/2023.md @@ -0,0 +1,155 @@ +--- +title: '2023 회고록' +description: '올해를 돌아보며' +url: '2023' +tags: ['retrospect', '2023'] +coverImage: 'https://github.com/1ilsang/dev/assets/23524849/b5692eaa-05d7-428c-8356-ba04da3f4e3f' +date: '2023-12-31T05:27:46.079Z' +ogImage: + url: 'https://github.com/1ilsang/dev/assets/23524849/b5692eaa-05d7-428c-8356-ba04da3f4e3f' +--- + +![cover](https://github.com/1ilsang/dev/assets/23524849/b5692eaa-05d7-428c-8356-ba04da3f4e3f 'cover') + +## 2022년의 목표 + +작년에 분명 2022 회고록을 작성했는데 문서를 잃어버렸다(-\_-). + +많이 당황했지만 반대로 말하면 1년 동안 쳐다도 안 봤다는 것이니 작년에 세웠던 올해의 목표나 다짐은 사실상 없는 것과 같았다. + +그래서인지 올해는 기분 가는 대로 즉흥적으로 시작한 것들이 많았다. + +어느 시점부터 일정이나 해야 할 일이 정리가 안되었고 이것을 해결하기 위해 무던한 노력을 했다. + +올해의 회고는 즉흥적 삶과 그 과정을 KPT로 정리해 내년에 어떻게 에너지를 발산할 수 있을지 고민해 보려고 한다. + +## Keep + +> 현재 만족하고 있는 부분과 계속 이어갔으면 하는 부분 + +### 기술적 관심 + +- CSS가 정말 부족하다고 느껴 넥스터즈에서 [인터랙션 페이지를 개발](https://nexters.github.io/who-really-wants-to-play)했고 현 블로그를 대대적으로 수정했다. +- 이제 flex 쪼끔 쓸 줄 안다 ㅋ +- 글또 스터디를 통해 [이력서를 업데이트](https://1ilsang.dev/about)하면서 커리어에 대한 경각심을 가지게 되었다. 더 전문적일 필요가 있다. +- React-Native로 [앱을 만들었다](https://github.com/1ilsang/linkit). 모바일 앱에 대한 갈망이 늘 있었는데 상당히 해소되었다. +- 알고리즘 22주 스터디를 진행했다. 주에 3문제씩이었으니 [총 66문제를 목표로 했고 12문제를 못 풀었다](https://leetcode.com/1ilsang/). +- 알고리즘 꼭 하자고 맨날 다짐만 했는데 가장 만족하는 스터디였다. +- [Junction Asia 해커톤에 참여](https://github.com/junction-asia-2023/just-label)했다. 밤새는 거 쉽지 않다. +- React-Query를 만나고 개발 방식이 완전히 바뀌게 되었다. 쿼리 못잃어! +- [Rust를 찍먹하고 있다](https://github.com/1ilsang/rust-practice). 만족도가 높다. + +### 회사 + +- LINE과 Yahoo!가 합병하는 과정을 몸으로 겪을 수 있어서 좋았다. `진짜..ㅋㅋ` +- 큰 기업끼리 합을 맞추는건 정말 쉽지 않다. +- 자발적으로 일하려고 노력했다. +- 팀원들에게 친절하려고 노력했다. +- 사내 오픈소스 행사에서 1등 했다. [MDN에 기여](https://github.com/mdn/translated-content/pulls?q=is%3Apr+is%3Aclosed+author%3A1ilsang)했다. +- 기존 Webpack 프로젝트를 Vite로 변경하고 좋은 성능 지표를 얻었다. +- 모든 프로젝트에서 캘린더를 따로 만들고 있어서 라이브러리로 만들어 배포했다. +- 얼떨결에 [프런트엔드 밸런스 게임에 출연](https://youtu.be/mjeW7BUaU1c?si=qLbnlQU56iSAk0dF)하게 됐다. +- ["일의 격"](/posts/quality-of-job-review)을 읽고 느끼는 게 많았다. 연차가 쌓이면서 일을 잘하는 게 무엇인지 고민하게 됐다. + +### 대외활동 + +![nexters](https://github.com/1ilsang/dev/assets/23524849/2e80e8ab-b006-4557-b31c-ef09999ed350) + +- 프런트엔드 반상회에서 [오픈소스를 주제로 발표](/posts/geultto8-open-source-seminar)했다. +- 멘토링 활동을 했다. 하면서 오히려 많이 배웠다. 설명을 잘하기 위해 공부를 많이 했다. +- [글또](https://github.com/geultto/) 활동 덕분에 글쓰기에 사명감을 느끼게 되었다. +- [넥스터즈 회고모임](https://github.com/Nexters/retrospective?tab=readme-ov-file#-2023%EB%85%84-%ED%9A%8C%EA%B3%A0-)을 운영했다. 시각화 잘 나와서 뿌듯했다. + +### 운동 + +![climbing solved chart](https://github.com/1ilsang/dev/assets/23524849/6732e1c3-cd28-4f23-ae20-0b40d791c91e) + +> 띠별 푼 문제 개수이다. [올 한해는 클라이밍과 함께했다.](https://instagram.com/_chul.climb) + +- 파랑 클라이머에서 어느덧 빨강으로 올라왔다. 월별로 변해가는 게 신기하다. +- 7월에 발리에서 서핑을 시작했다. 최고의 활동 중 하나였다. +- 무엇보다 안 다쳤다! 안전이 제일 중요하다. + +### 여행 + +- 도쿄로 출장갔다. 경제가 더 활성화 되었으면 좋겠다. 출장 많이 다니고 싶다. +- [발리에서 한 달 동안 리모트 워크](/posts/bali-remote-work)를 했다. +- 타지의 인연이 생기면서 영어를 더 열심히 해야겠다고 느꼈다. + +### 독서 + +- 북또 활동을 통해 한 달에 한 권 책 읽기를 진행했다. +- 회복탄력성과 소크라테스 익스프레스 두 책이 인상적이었다. 삶을 대하는 시각이 달라졌다. +- [사라진 개발자들](/posts/proving-ground-review)도 흥미롭게 읽었다. 애니악과 관련된 이야기들을 보면서 역사에 흥미가 생겼다. + +## Problem + +> 불편하게 느끼는 부분. 개선이 필요하다고 생각되는 부분 + +### 데이터의 파편화 + +- 한 해 이것저것 뭔가 했는데 시각화하기가 어려웠다. 데이터적 삶을 살고 싶다. +- 계획을 더 세분화해서 진행하고 글로 남기고 싶다. + +### 기술 갈증 + +- 딥다이브를 생각보다 하지 못했다. 원리를 탐구하고 확실하게 설명할 수 있는 개발자가 되어야 한다. +- 공부를 넘어 의미 있는 생산을 하고 싶다. 창업하고 싶은 것은 아니지만 기술자로서 갈증이 있다. +- 좋은 멘토로 성장하고 싶다. + +### 생활 + +- 영어가 너무 아쉽다. +- 조금 더 진심으로 살 수 있었는데 그러지 못한 것 같아 아쉽다. +- 마르쿠스는 명상록에서 "침대 밖으로 나갈 사명이 있다"고 했다. +- '사명'이지 '의무'가 아니다. 사명은 내부에서 의무는 외부에서 온다. 사명은 자신과 타인을 드높이기 위한 자발적 행위다. +- 나는 나의 삶에 사명감을 느끼고 있는가? + +## Try + +> Problem에 대한 해결책. 다음 Action 플랜 + +- 데이터 적 삶을 살기. +- 계획의 세분화. PARA 보드 더 잘 활용하기. +- 계획 짧게 가져가기. 즉각적 피드백으로 빨리빨리 일을 처리하기. +- 한 달에 한 개 사용하고 있는 기술 다이브 하기. 월간 다이브! +- 일단 무언가 만들어보기. +- 영어 열심히 하기. +- 더 진심으로 살기. + +## 총평 + +올해를 쭉 돌아보니 나는 데이터화가 많이 부족하다. 시각화 자료로 만들기 어려운 부분이 많다. + +시각화의 중요성을 많이 느끼고 있기 때문에 나라는 사람의 데이터를 잘 수집하는 것부터 해보자고 생각하게 되었다. + +나는 욕심이 많아서 하고 싶은 것이 많은데 정작 세부 일정은 전무하다. 그래서인지 시간 대비 효율이 높지 못했다. + +다음부터는 일을 진행할 때 세부 계획을 세우고 피드백 시간을 넣어야겠다. + +PARA에 TODO로 쌓이는 것들이 너무 많다(!!). 어느 순간부터 적어만 놓고 안 하는 것들이 생긴다. 사이클을 조금 더 빨리 돌려야겠다. + +더 진심으로 살아갈 필요가 있다. + +## 신년 목표 + +![blue-dragon](https://github.com/1ilsang/dev/assets/23524849/cfdf85c9-428d-4cd3-9db4-81beaf62b487) + +신년의 가장 큰 목표는 "개인 브랜딩"이다. 전문가로 성장하고 싶다. + +1. 개인 브랜딩 + - 전문가로 성장하기 + - 타인에게 친절하기 +2. 데이터 적 삶 + - 한 달에 한 번 회고 +3. 전문성 + - 책 한 권 쓰고 싶다 + - 월간 다이브 진행 + - 알고리즘 진심 모드 + - 영어 시험 준비 + - 세미나 준비하기 + - 한 달에 한 권 +4. 취미 + - 클라이밍 빨클러 + +2024년 잘 부탁드립니다. diff --git a/_posts/retrospect/2024/01.md b/_posts/retrospect/2024/01.md new file mode 100644 index 00000000..5d83fa2b --- /dev/null +++ b/_posts/retrospect/2024/01.md @@ -0,0 +1,92 @@ +--- +title: '2024년 1월 회고' +description: '1월은 설렘의 연속' +url: '2024-01' +tags: ['retrospect', '2024', '01'] +coverImage: 'https://github.com/1ilsang/dev/assets/23524849/80b94675-d239-4f77-917d-52211a6a878d' +date: '2024-02-09T06:06:08.342Z' +ogImage: + url: 'https://github.com/1ilsang/dev/assets/23524849/80b94675-d239-4f77-917d-52211a6a878d' +--- + +![cover](https://github.com/1ilsang/dev/assets/23524849/994f1e84-c960-47fc-91ab-f97faccbde37 'cover') + +[2023년 회고록](/posts/2023)에서 "데이터" 부재의 아쉬움을 이야기했었다. 따라서 올해는 데이터 드리븐 삶을 살기 위해 월간 회고를 진행하려고 한다. + +## 2024년 1월의 목표 + +1월의 핵심 키워드는 "절제"였다. 작년의 문제 중 하나로 지목된 "TODO"에 무한히 쌓이기만 하는 것들을 없애기 위한 일환이었다. + +이번 달의 목표는 아래와 같았다. + +1. 2D1R +2. 월간 다이브 +3. 월간 메이커스 +4. 말해보카 +5. 운동하기 + +## 2D1R + +![leetcode-jan](https://github.com/1ilsang/dev/assets/23524849/c03b18bc-adce-4f74-8278-e88216c1c973 'h-s s') + +2D1R은 2일에 1알고리즘으로, 꾸준하게 가져가고 싶은 습관 중 하나다. + +leetcode를 기준으로 풀고 있다. 화요일과 목요일은 꼭 제출한 모습을 보인다. 이때가 스터디 제출 날이라..ㅋ + +최근에 가장 재밌었던 문제는 [Find-the-duplicate-number](https://leetcode.com/problems/find-the-duplicate-number/description/)이다. [플로이드의 토끼와 거북이 알고리즘](https://dev.to/alisabaj/floyd-s-tortoise-and-hare-algorithm-finding-a-cycle-in-a-linked-list-39af)을 활용하여 해결하는 문제인데, 점화식 도출이 너무 신기했다. + +취준생 때 지금처럼 재미를 느꼈다면 얼마나 좋았을까... + +## 월간 다이브 + +![vite](https://github.com/1ilsang/dev/assets/23524849/132b52c7-3c2b-4554-b0fb-8ec5f3193d7a 's') + +전문성을 기르고자 시작한 월간 다이브. + +특정 기술을 이해하고 넘어가는 것이 아니라 글로 정리해 누군가에게 설명할 수 있도록 하는 것이 목표다. 분석/발표하는 것이 목표다. + +원래는 Axios를 하려고 했는데, 회사에서 Vite 관련 이슈를 만나면서 [Vite Dev Server 이해하기 (feat. HMR)](/posts/vite-dev-server)로 급선회했다. + +시간이 엄청나게 들어갔다. 거의 10일은 매달렸다(-\_-). + +기술 이해를 위한 디버깅 과정이 고난이라 생각했지만 글로 정리하면서 팩트체크 하는 과정이 "진짜"였다. 정말 쉽지 않았다. + +나름 만족하는 글이다. 많은 분들에게 피드백을 받을 수 있었고 회사에서 발표도 진행했다. + +## 월간 메이커스 + +![rust](https://github.com/1ilsang/dev/assets/23524849/39229def-f330-447e-8b6f-77926a7d18e9) + +월간 다이브로 이론적 공부를 했다면 월간 메이커스를 통해 실전 코딩을 하려고 했다. + +목표로 하는 라이브러리 혹은 기술을 잡아 클론코딩 비슷하게 만들어 보는 게 목표이다. + +유데미 [러스트 강의를 수강](/posts/udemy-rust-programming) 하고 나서 의미 있는 걸 만들어보자 생각해 FHF(File Hierarchy Fixer)를 구현해 보려고 했다. + +근데 월간 다이브 과정에서 시간이 끌리며 레포만 만들고 실패했다. + +## 말해보카 + +![말해보카](https://github.com/1ilsang/dev/assets/23524849/551c8c96-336b-47bf-8d09-ef575b6a9e24) + +요즘 [말해보카](https://epop.ai/ko)에 푹 빠져 있다. 영어 재밌을지도..? + +이 녀석 완전 효자다. 대만족 중 + +## 운동하기 + +![chart](https://github.com/1ilsang/dev/assets/23524849/042647fa-bbba-4017-baf5-e6b427ce0245) + +> 클라이밍 날짜와 빨강 푼 개수 + +자주 갔다고 생각했는데 모아보니 많이 가진 못했다. + +1월에는 총 34개를 풀었다. 요즘 야식을 많이 먹어서 그런지 벽 타는 게 상당히 어렵다. + +다음 달에는 좀 더 트레이닝해야겠다. + +## 마치며 + +2월에는 현상 유지하면서 책 한 권만 읽으면 좋을 것 같다. + +가보자고! diff --git a/_posts/rust/udemy-rust-programming.md b/_posts/rust/udemy-rust-programming.md new file mode 100644 index 00000000..0c75e2c0 --- /dev/null +++ b/_posts/rust/udemy-rust-programming.md @@ -0,0 +1,75 @@ +--- +title: '러스트 시작! - 유데미 Rust Programming를 수강하며' +description: '가보자고' +url: 'udemy-rust-programming' +tags: ['udemy', 'rust'] +coverImage: 'https://github.com/1ilsang/dev/assets/23524849/847f2f42-7697-49ff-852d-cbdd7cd8cf50' +date: '2024-01-13T07:47:05.243Z' +ogImage: + url: 'https://github.com/1ilsang/dev/assets/23524849/847f2f42-7697-49ff-852d-cbdd7cd8cf50' +--- + +![cover](https://github.com/1ilsang/dev/assets/23524849/847f2f42-7697-49ff-852d-cbdd7cd8cf50 'cover') + +[글또](https://zzsza.notion.site/zzsza/ac5b18a482fb4df497d4e8257ad4d516)에서 유데미를 수강할 수 있는 기회를 얻었다. 무엇을 선택할지 고민하다 [Rust Programming 핵심 강의](https://www.udemy.com/course/rust-programming-korean/)를 선택했다. + +## 수강 이유 + +작년부터 러스트에 대한 관심이 있었는데 이런저런 핑계로 하지 않았었다. + +새로운 언어를 배워보고 싶기도 했고 프런트엔드 생태계의 여러 도구가 러스트화 되는 것을 보며 올해는 꼭 러스트 해봐야지!라고 신년 다짐을 세우고 있었는데 마침 수강 기회가 있어 바로 선택하게 되었다. + +## 과정 + +수강 이유에서도 밝혔지만, 언어 자체에 흥미가 있었기 때문에 열심히 해보고 싶었다. + +강의만 있으면 분명 미루고 미루다 안 볼 것 같다는 강한 의심이 있었기 때문에 "선언 효과"로 나에게 강제를 주고 동료를 모아 "상호보완"을 하고 싶었다. 따라서 수강하기 전부터 어떤 식으로 학습할지 계획을 세웠다. + +나는 두 가지 방식으로 접근했다. + +1. 슬랙 채널을 만들고 관리하기 +2. 스터디를 모집해 의견 교환하기 + +### 러스또 + +![rustto-pin](https://github.com/1ilsang/dev/assets/23524849/90f49983-3099-486f-8355-fbc1296040ef) + +선언 효과의 일환으로 글또 커뮤니티에 `#러스또` 채널을 만들고 홍보하기 시작했다. + +스터디 모집뿐만 아니라 앞으로의 포부도 밝히며(..) 선언 효과(다른말로 업보) 강하게 적용했다. + +31분이 채널에 들어와 주셨고 채널이 죽지 않도록 주기적으로 업데이트하고자 했다. + +### 정리 및 공유 + +![udemy-summary](https://github.com/1ilsang/dev/assets/23524849/3ed8cd05-d2bb-4fa2-9e4f-ef29293821a7 'l') + +강의 내용이 초심자에게 적절해서 재밌게 볼 수 있었다. 중간중간 내용을 쭉 정리하고 자바스크립트랑 비교도 해보면서 능동적으로 학습하려고 했다. + +예제가 많고 라인바이라인으로 설명해 줘서 잘 따라갈 수 있었다. 키워드 하나도 그냥 넘어가는 게 없었던 것이 좋은 포인트였다. 특정 패턴들은 자주 사용되는 코딩 패턴이라고 설명 및 소개해줘서 실제로 어떤 식으로 코딩을 이어나가야 할지 최소한의 길잡이는 해주었다고 생각한다. + +챕터별 학습 내용뿐만 아니라 중간중간 의문이 든 내용들은 [꽃게탕 레포](https://github.com/1ilsang/rust-practice)에 정리해 나갔고 `#러스또` 채널에도 공유하면서 선지자들의 피드백도 기대했다. + +### 스터디 + +![study](https://github.com/1ilsang/dev/assets/23524849/589b1d6b-3c8f-402a-818d-874474891bb7 'l') + +나는 스터디에서 시너지가 많이 나는 편인 듯하다. 다른 분들에게 더 도움이 되고 싶어 열심히 공부했고 정말 이해하고 있는 건지 알기 위해 설명해 보려고 노력했다. + +`Box`, `*`로 힙 영역을 넘나든다거나 `소유권` 등은 JS에는 없던 개념이기 때문에 프로그래밍 시야가 더 넓어질 기회가 되었다. + +## 마치며 + +![certificate](https://github.com/1ilsang/dev/assets/23524849/406af542-bfdb-4e17-b088-c2c1fa6d72ab) + +위의 과정을 매주 반복한 덕인지는 모르겠지만 다행히 3주 완성으로 아주 기초적인 문법은 배웠다. + +Rust 오픈소스에 기여해 보는 것을 목표로 하고 있기 때문에 토이 프로젝트를 만들면서 기본기를 다지고자 한다. + +지금 생각하고 있는 프로젝트는 모노레포에서 각 디렉터리 구조를 분석해서 차이점이 있는 부분을 찾고 CLI로 수정하는 라이브러리이다. + +- [https://github.com/1ilsang/fhf](https://github.com/1ilsang/fhf) + +가보자고! + +> 해당 콘텐츠는 유데미로부터 강의 쿠폰을 제공받아 작성되었습니다. diff --git a/_posts/tool/mac/init.md b/_posts/tool/mac/init.md new file mode 100644 index 00000000..74f0e299 --- /dev/null +++ b/_posts/tool/mac/init.md @@ -0,0 +1,342 @@ +--- +title: '웹 개발자를 위한 도구 추천 - 유용한 Mac 앱들' +description: '생산성을 올려줄 유용한 맥 앱을 알아보자' +url: 'mac-init-apps' +tags: ['mac', 'settings'] +coverImage: 'https://github.com/1ilsang/dev/assets/23524849/dbe32093-4f4b-4f4b-aa2c-a2b8574d85a0' +date: '2023-12-24T03:54:08.256Z' +ogImage: + url: 'https://github.com/1ilsang/dev/assets/23524849/dbe32093-4f4b-4f4b-aa2c-a2b8574d85a0' +--- + +![cover](https://github.com/1ilsang/dev/assets/23524849/dbe32093-4f4b-4f4b-aa2c-a2b8574d85a0 'cover') + +최근 기기 변경을 하면서 맥 세팅을 처음부터 할 일이 있었다. 그때 꽤 고생한 기억이 있어 이번 기회에 유용했던 것들을 한번 정리해 보려고 한다. + +웹 개발자를 위한 도구 추천 포스트는 3가지 시리즈로 연재 될 예정이다. + +1. 유용한 Mac 앱 +2. VSCode 익스텐션 +3. 크롬 익스텐션 + +직접 사용하면서 유용했던 것들을 모아놓았기 때문에 안정성 문제는 없을 것으로 생각된다. + +## 모아보기 + +- [Chrome](#chrome) +- [Homebrew](#homebrew) +- [Oh My Zsh](#oh-my-zsh) +- [Iterm2](#iterm2) +- [VSCode](#vscode) +- [NeoVim](#neovim) +- [D2Coding](#d2-coding) +- [Node.js](#nodejs) +- [NVM](#nvm) +- [Docker](#docker) +- [GIPHY Capture](#giphy-capture) +- [DeepL](#deepl) +- [ScreenHint](#screenhint) +- [Quick Notes](#quick-notes) +- [Calculator Pro](#calculator-pro) +- [올ㅋ사전](#올ㅋ사전) +- [CodeWhisperer](#codewhisperer) +- [Flycut](#flycut) +- [ScreenBrush](#screenbrush) +- [Keycastr](#keycastr) +- [Ngrok](#ngrok) +- [Hidden bar](#hidden-bar) +- [Digital Color Meter](#digital-color-meter) + +## Chrome + +![chrome-cover](https://github.com/1ilsang/dev/assets/23524849/958e465c-dd1e-4693-ac79-67400c6441dc) + +소개 문구에서부터 포스가 장난 아니다. 테스팅 환경 때문이라도 필요한 웹 브라우저 크롬이다. + +다음에 연재할 크롬 익스텐션 섹션을 통해 크롬이 얼마나 강력한지 후술하고자 한다. + +- [만약 크롬이 실행 되지 않는다면](https://support.google.com/chrome/thread/64580550?hl=ko&msgid=68816629) + +> [다운로드 링크](https://www.google.com/chrome) + +## Homebrew + +![homebrew](https://github.com/1ilsang/dev/assets/23524849/4135c0aa-fe1c-49db-aee1-a72b88cfeea6) + +`homebrew`는 CLI로 편리하게 앱을 설치할 수 있게 해준다. + +환경변수 및 패키지 폴더 구성 등을 자동으로 해주기 때문에 불쾌한 초기 설정을 벗어나게 해준다. + +많은 프로젝트에서 homebrew를 통한 설치 가이드를 제공하고 있을 정도로 대중적이니 꼭 설치하자. + +```sh +# 터미널 설치 +$ /bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)" +``` + +> [다운로드 링크](https://brew.sh/) + +## Oh My Zsh + +![cmd-example](https://github.com/1ilsang/dev/assets/23524849/44e5939d-e8bd-40ec-90bb-fae3e106443d) + +brew로 작업하다 보면 터미널이 참 못생겼다고 느낄 수 있다. 터미널을 위와 같이 원하는 정보가 노출되도록 설정할 수 있다. + +내가 사용하고 있는 테마는 [bullet-train](https://github.com/caiogondim/bullet-train.zsh) 커스텀 테마이다. 해당 테마는 현재 시간 및 작업 소요 시간, 성공 여부, 깃 상태 등 다양한 정보를 노출시켜 주므로 선택하게 되었다. + +```sh +# 터미널 설치 +$ sh -c "$(curl -fsSL https://raw.githubusercontent.com/ohmyzsh/ohmyzsh/master/tools/install.sh)" +``` + +> [다운로드 링크](https://ohmyz.sh/#install) + +## Iterm2 + +![iterm2-example](https://github.com/1ilsang/dev/assets/23524849/7b6b295f-ec11-4da8-bade-c475009b5ca4) + +기본 맥 터미널은 못생겼기 때문에 `iterm2`을 설치해 터미널을 더 예쁘게 커스텀 할 수 있다. + +`zsh`이 터미널의 내용을 관리한다면 터미널 창 자체로 디자인을 제공해 주는 역할은 `iterm2`이다. + +> 만약 커맨드라인에서 cmd + delete로 [한줄 삭제](https://stackoverflow.com/questions/15733312/iterm2-delete-line) 하고 싶다면 +> +> 환경 설정 > Profiles > Keys > Natural Text Editing으로 설정 한다. + +이 외에도 각 탭에서 창 기본 크기, 배경 설정 등 할 수 있다. + +> [다운로드 링크](https://iterm2.com/) + +## VSCode + +![vscode-logo](https://github.com/1ilsang/dev/assets/23524849/6e0c8145-53ab-4d40-9f97-edaf9d54ba91) + +VSCode에 너무 절여져 있어 다른 에디터는 이제 기억이 나지 않는다... 유용한 익스텐션은 다음 포스트로 연재하겠다. + +> [다운로드 링크](https://code.visualstudio.com/) + +## NeoVim + +만약 vi 환경을 좋아한다면 설치하면 좋다. 기본적으로 `vim`은 한글 입력시 문제가 많다(조합 중인 문자 소실 등). + +`NeoVim`은 `vim`을 오픈소스화하여 기존의 문제들 해결하고 커뮤니티 자발적으로 다양한 플러그인을 개발/공유하고 있어 강력한 에디팅을 지원한다. + +```sh +$ brew install neovim +# 기본 vi를 neovim으로 변경하고자 한다면 alias를 변경한다. +$ vi ~/.zshrc +$ alias vi="nvim" +``` + +> [다운로드 링크](https://neovim.io/) + +## D2 Coding + +폰트는 d2 코딩이 가장 편하다고 느껴서 늘 사용하고 있다. + +모호할 수 있는 문자들이 `1ijIlO0tz아야저져쁆뼮뼯뗾` 기본적으로 잘 보인다(이 블로그 폰트도 D2coding이다). + +### iterm2에 d2coding을 기본 폰트로 적용하기 + +> Profiles > Text > Font > D2Coding +> +> [그림으로 보기](https://github.com/1ilsang/dev/assets/23524849/a3aff63a-d3d7-43aa-84c1-68b10d46dadd) + +### VSCode 기본 폰트로 적용하기 + +> Setting(cmd + ,) > Font Family > D2Coding을 제일 앞에 적어준다. +> +> [그림으로 보기](https://github.com/1ilsang/dev/assets/23524849/1eeb1e90-6a2c-4263-a8a4-f6a9ea860f98) + +상당히 개발자 친화적인 폰트라 생각한다. + +> [다운로드 링크](https://github.com/naver/d2codingfont) + +## Node.js + +"신" + +> [다운로드 링크](https://nodejs.org/en) + +## NVM + +프로젝트를 여러개 만들다 보면 노드 버전이 상이한 경우가 종종 생긴다. 이때 노드 버전을 어떻게 처리할까? + +답은 `nvm`을 통해 노드 버전을 프로젝트마다 변경하면 된다. + +```sh +# Nvm 다운로드. +$ curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.7/install.sh | bash +# 터미널 실행시 자동으로 nvm을 사용하도록 설정. +$ vi ~/.zshrc +# 아래 내용을 zshrc 아무곳에 붙여넣는다. +export NVM_DIR="$([ -z "${XDG_CONFIG_HOME-}" ] && printf %s "${HOME}/.nvm" || printf %s "${XDG_CONFIG_HOME}/nvm")" +[ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh" # This loads nvm +# 쉘 반영 +$ source ~/.zshrc +``` + +사용법은 아래와 같다. + +```sh +# .nvmrc 파일이 존재하면 지정된 노드 버전으로 설정됨. +$ nvm use +# Node.js 20.10.0 버전 다운로드. +$ nvm install 20.10.0 +# Node.js 20.10.0 버전 사용. +$ nvm use 20.10.0 +``` + +> [가이드 링크](https://github.com/nvm-sh/nvm/blob/master/README.md) + +## Docker + +두 번째 "신" + +> [다운로드 링크](https://www.docker.com/) + +## GIPHY Capture + +![giphy example](https://media2.giphy.com/media/v1.Y2lkPTc5MGI3NjExZ2k4bHQ1ZjdpaGs4ZjVidjcxMnkyNjUwa29xdDJ3dWJ5MzcyYjd0ZyZlcD12MV9pbnRlcm5hbF9naWZfYnlfaWQmY3Q9Zw/TehXbZX944qv5aoVg5/giphy.gif) + +간단하게 움짤(gif)을 따야 할 때 유용하게 쓸 수 있다. + +> [다운로드 링크](https://apps.apple.com/kr/app/giphy-capture-the-gif-maker/id668208984) + +## DeepL + +번역 퀄리티가 상당히 좋다. 또한 `cmd + c + c`로 빠르게 번역하기도 지원하기 때문에 숏컷 활용도 또한 뛰어나다. + +이후 다룰 크롬 익스텐션과 함께 사용하면 찰떡이다. + +> [다운로드 링크](https://www.deepl.com/ko/app/) + +## ScreenHint + +
+ cover + setting +
+ +정말 추천하고 싶은 프로그램. 원하는 부분만 스크린샷으로 띄워 놓을 수 있다. + +맥 어플 전체화면시에도 올라가 있기 때문에 상당히 편리하다. + +> [다운로드 링크](https://apps.apple.com/kr/app/screenhint/id1566621533?mt=12) + +## Quick Notes + +![quick notes example](https://github.com/1ilsang/dev/assets/23524849/41f304d0-06db-47d6-b2dd-44df271c90f0) + +줌으로 미팅하거나 전체화면으로 열려있는 자료가 많아 잠깐잠깐 메모가 필요할 때 유용한 앱이다. + +유료라서 꼭 필요한 게 아니라면 크게 추천하고 싶진 않다. + +> [다운로드 링크](https://apps.apple.com/kr/app/quick-notes/id1260480179?mt=12) + +## Calculator Pro + +
+ calculator pro example + setting +
+ +맥 환경 특성상 전체화면 된 앱 위에 무엇을 겹치는 것이 불가능하다. 하지만 이 앱은 계산기를 전체화면 된 앱 위에 올려준다. + +알고리즘 풀 때 진짜 꿀이다. + +오른쪽 화면은 내가 쓰는 글로벌 숏컷이다. 껐다켰다하며 사용하기 편하다. + +> [다운로드 링크](https://apps.apple.com/kr/app/calculator-pro-topbar-app/id576215086) + +## 올ㅋ사전 + +
+ example + setting +
+ +특정 단어를 검색해야 할 때 화면 이동을 하는 건 너무 귀찮다. 단축키로 바로 열어서 찾는 것이 편리하다. + +> [다운로드 링크](https://apps.apple.com/kr/app/%EC%98%AC%E3%85%8B%EC%82%AC%EC%A0%84-%EB%A7%A5%EC%97%90%EC%84%9C-%EB%8B%A8%EC%B6%95%ED%82%A4%EB%A5%BC-%EB%88%84%EB%A5%B4%EB%A9%B4-%EC%98%81%EC%96%B4%EC%82%AC%EC%A0%84%EC%9D%B4-%EB%99%87/id1033453958?mt=12) + +## CodeWhisperer + +![CodeWhisperer example](https://github.com/1ilsang/dev/assets/23524849/c6ad644a-c2c9-4315-b335-ba7a484bf595) + +[Fig가 공식적으로 AWS에 흡수](https://fig.io/blog/post/fig-is-sunsetting)되면서 출시된 제품이다. 개인 개발에는 무료로 사용할 수 있다. + +CLI를 자주 사용한다면 정말 유용한 앱이다. 다음 명령어에 대한 힌트뿐만 아니라 해당 명령어의 기대 효과도 같이 알려준다. + +터미널의 효자 그 자체다. + +```sh +$ brew install --cask codewhisperer +``` + +> [다운로드 링크](https://aws.amazon.com/ko/codewhisperer/resources/?refid=d66b5e73-988d-4ff9-aa68-e067ce087ab) + +## Flycut + +![flycut example](https://github.com/1ilsang/dev/assets/23524849/0bcfab83-6bec-4cef-9e07-3b85a730f6ef) + +우리는 복/붙을 상당히 많이 한다. 만약 이전에 복사했던 내용을 다시 가져오고 싶다면 어떻게 하고 있는지 생각해 보자. + +별다른 수가 떠오르지 않는다면 이 앱을 추천한다. 이 앱은 이전에 복사했던 내용들을 기억하고 불러오는 것도 지원해 준다. 심지어 복사된 시간도 알려준다. + +> [다운로드 링크](https://apps.apple.com/kr/app/flycut-clipboard-manager/id442160987?mt=12) + +## ScreenBrush + +![screen brush example](https://github.com/1ilsang/dev/assets/23524849/62fd7619-78e6-4865-b198-e8ebbcb75b18) + +줌과 같은 화상 회의를 할 때나 전체 미팅 때 내 화면을 공유할 일이 많다면 강력히 추천한다. + +화면에 무엇인가 작성하거나 포인터가 필요할 때 예쁘게 시선을 잡아주는 효자 앱이다. + +> [다운로드 링크](https://apps.apple.com/kr/app/screenbrush/id1233965871?mt=12) + +## Keycastr + +![keycastr example](https://github.com/1ilsang/dev/assets/23524849/a5569525-3dd3-4997-96e0-73c1f318cfda) + +`ScreenBrush`와 함께 사용하면 빛나는 앱이다. 내가 어떤 키보드를 입력했는지 화면에 보여준다. + +```sh +$ brew install --cask keycastr +``` + +> [다운로드 링크](https://github.com/keycastr/keycastr) + +## Ngrok + +![ngrok example](https://github.com/1ilsang/dev/assets/23524849/90215fda-ef9b-46e3-8379-1c52cfeee1af) + +배포 없이 로컬에서 작업한 나의 페이지를 다른 사람에게 보여주고 싶다면 어떻게 해야 할까? + +`ngrok`은 프록시 서버를 열어 내 로컬 포트로 접근하게 해준다. 서버 없이 빠르게 데모 페이지를 공유할 때 유용하다. + +```sh +$ ngrok http 3000 # 3000번 포트로 http 통신을 허용한다. +# 위의 이미지처럼 https://7421-1-235-243-130.ngrok-free.app URL이 생성(일회용 랜덤)된다. +# 이후 해당 URL로 접근하면 localhost:3000으로 접속한 것과 같이 된다. +# 이로써 정적 배포/서버 없이 누구에게나 열린 일회용 퍼블릭 URL을 가지게 되었다! +``` + +단, 사용하기 위해선 로그인 이후 인증 토큰을 넣어야 한다. + +> [다운로드 링크](https://ngrok.com/download) + +## Hidden bar + +![hidden bar example](https://github.com/1ilsang/dev/assets/23524849/de1f0c76-bbb7-4d56-a2e1-b9a250d3cf62) + +이쯤 되면 상단바가 상당히 늘어났다는 것을 확인할 수 있다. `Hidden bar`는 필요한 앱들만 상단바에 노출시켜 주는 앱이다. + +> [다운로드 링크](https://apps.apple.com/kr/app/hidden-bar/id1452453066?mt=12) + +## Digital Color Meter + +![example](https://github.com/1ilsang/dev/assets/23524849/9955b395-4547-40c4-a4c8-daa7eed3a46a) + +맥 자체 유용한 앱이다. CSS 작업을 하다 보면 스포이드가 필요한 순간이 있는데 유용하게 사용할 수 있다. diff --git a/e2e/404.spec.ts b/e2e/404.spec.ts new file mode 100644 index 00000000..b879279e --- /dev/null +++ b/e2e/404.spec.ts @@ -0,0 +1,25 @@ +import { expect, test } from '@playwright/test'; +import { gotoUrl, screenshotFullPage, waitImages } from './shared/utils'; +import { MACRO_SUITE } from './shared/constants'; + +test.describe('404', () => { + test('should redirect 404', async ({ page }) => { + await gotoUrl({ page, url: '/something_wrong_path', timeout: 60_000 }); + await expect(page.getByText(/404 ERROR/)).toBeVisible(); + }); + + test(MACRO_SUITE.SCREEN_SNAPSHOT, async ({ page }) => { + await screenshotFullPage({ + page, + url: `/something_wrong_path`, + arg: [`404.png`], + }); + }); + + test(MACRO_SUITE.DOM_SNAPSHOT, async ({ page }) => { + await gotoUrl({ page, url: '/something_wrong_path', timeout: 60_000 }); + await waitImages({ page }); + const body = await page.locator('main').innerHTML(); + expect(body).toMatchSnapshot([`404.html`]); + }); +}); diff --git a/e2e/__snapshots__/404.spec.ts/desktop/404.html b/e2e/__snapshots__/404.spec.ts/desktop/404.html new file mode 100644 index 00000000..bbc6b81e --- /dev/null +++ b/e2e/__snapshots__/404.spec.ts/desktop/404.html @@ -0,0 +1,7 @@ +
surfing

+
+      404 ERROR
+
+ Do you enjoy surfing?
+
+
Image Copyright: Freepik
\ No newline at end of file diff --git a/e2e/__snapshots__/404.spec.ts/desktop/404.png b/e2e/__snapshots__/404.spec.ts/desktop/404.png new file mode 100644 index 00000000..f6255d08 Binary files /dev/null and b/e2e/__snapshots__/404.spec.ts/desktop/404.png differ diff --git a/e2e/__snapshots__/404.spec.ts/mobile/404.html b/e2e/__snapshots__/404.spec.ts/mobile/404.html new file mode 100644 index 00000000..bbc6b81e --- /dev/null +++ b/e2e/__snapshots__/404.spec.ts/mobile/404.html @@ -0,0 +1,7 @@ +
surfing

+
+      404 ERROR
+
+ Do you enjoy surfing?
+
+
Image Copyright: Freepik
\ No newline at end of file diff --git a/e2e/__snapshots__/404.spec.ts/mobile/404.png b/e2e/__snapshots__/404.spec.ts/mobile/404.png new file mode 100644 index 00000000..3b4ff670 Binary files /dev/null and b/e2e/__snapshots__/404.spec.ts/mobile/404.png differ diff --git a/e2e/__snapshots__/about.spec.ts/desktop/about.html b/e2e/__snapshots__/about.spec.ts/desktop/about.html new file mode 100644 index 00000000..da292fe8 --- /dev/null +++ b/e2e/__snapshots__/about.spec.ts/desktop/about.html @@ -0,0 +1 @@ +
!ILSANG
어느덧 5년 차 프런트엔드 개발자가 되었습니다.
"일의 격"을 읽고 저는 일을 어떻게 대하는 사람인지, 어떠한 동료가 되고 싶은지 고민해 봤습니다.
  • 저는 즐겁게 일하고 싶습니다.
  • 저는 기술적 책임을 질 수 있는 동료가 되고 싶습니다.
웃으면서 일하고 싶습니다. 농담을 즐기고 어떻게 하면 동료를 웃길 수 있을지 늘 탐구하고 있습니다.
영향력 있는 동료가 되고 싶습니다. 성장 자극을 줄 수 있는 동료이고 싶습니다. 맡은 부분에 대한 기술적 책임을 지려고 노력합니다.
Smilegate에서 프런트엔드 인턴을 시작으로 TeamBlind에서 풀스택으로 일했으며 이후 LINE+에서 4년간 프런트엔드 개발자로 전문성을 쌓았습니다. 이제 우아한형제들에서 또 다른 커리어를 쌓고자 하고 있습니다. 또한 MDN 한국팀의 Organizer로 활동하고 있습니다.
WORK EXPERIENCE
- Present
Woowa Bros
Frontend Engineer
Coming Soon
- Present
-
LINE Plus Corp
Frontend Engineer
LandPress Content
-
Vite
pnpm
React-Query
Universal Video Player
-
HTMLVideo
Preact10
Zustand
Turborepo
Storybook
Cypress
Webpack
VOOM Live CMS
-
React18
RTL
React-Query
WebSocket
Chart.js
Jotai
Official Account Live CMS
-
React18
RTL
React-Query
WebSocket
MSW
Chart.js
Jotai
LINE Design System - Calendar
-
React18
Vite
Jotai
LINE Place
-
Next12
Redux
Redux-Saga
Swiper
Official Account Profile
-
Next12
-
TeamBlind
Full Stack Engineer
Bleet
-
Node.js
MySQL
Swagger
Firebase
Mybiskit
-
Nuxt2
MySQL
AWS
Puppeteer
Blind
-
Node.js
PHP
Docker
MySQL
Redis
AWS
-
Smilegate
Frontend Engineer(intern)
Stove
-
Vue2
ACTIVITY
EDUCATION
-
가톨릭대학교
미디어공학, 컴퓨터정보공학 전공
\ No newline at end of file diff --git a/e2e/__snapshots__/about.spec.ts/desktop/about.png b/e2e/__snapshots__/about.spec.ts/desktop/about.png new file mode 100644 index 00000000..4b2dc386 Binary files /dev/null and b/e2e/__snapshots__/about.spec.ts/desktop/about.png differ diff --git a/e2e/__snapshots__/about.spec.ts/mobile/about.html b/e2e/__snapshots__/about.spec.ts/mobile/about.html new file mode 100644 index 00000000..da292fe8 --- /dev/null +++ b/e2e/__snapshots__/about.spec.ts/mobile/about.html @@ -0,0 +1 @@ +
!ILSANG
어느덧 5년 차 프런트엔드 개발자가 되었습니다.
"일의 격"을 읽고 저는 일을 어떻게 대하는 사람인지, 어떠한 동료가 되고 싶은지 고민해 봤습니다.
  • 저는 즐겁게 일하고 싶습니다.
  • 저는 기술적 책임을 질 수 있는 동료가 되고 싶습니다.
웃으면서 일하고 싶습니다. 농담을 즐기고 어떻게 하면 동료를 웃길 수 있을지 늘 탐구하고 있습니다.
영향력 있는 동료가 되고 싶습니다. 성장 자극을 줄 수 있는 동료이고 싶습니다. 맡은 부분에 대한 기술적 책임을 지려고 노력합니다.
Smilegate에서 프런트엔드 인턴을 시작으로 TeamBlind에서 풀스택으로 일했으며 이후 LINE+에서 4년간 프런트엔드 개발자로 전문성을 쌓았습니다. 이제 우아한형제들에서 또 다른 커리어를 쌓고자 하고 있습니다. 또한 MDN 한국팀의 Organizer로 활동하고 있습니다.
WORK EXPERIENCE
- Present
Woowa Bros
Frontend Engineer
Coming Soon
- Present
-
LINE Plus Corp
Frontend Engineer
LandPress Content
-
Vite
pnpm
React-Query
Universal Video Player
-
HTMLVideo
Preact10
Zustand
Turborepo
Storybook
Cypress
Webpack
VOOM Live CMS
-
React18
RTL
React-Query
WebSocket
Chart.js
Jotai
Official Account Live CMS
-
React18
RTL
React-Query
WebSocket
MSW
Chart.js
Jotai
LINE Design System - Calendar
-
React18
Vite
Jotai
LINE Place
-
Next12
Redux
Redux-Saga
Swiper
Official Account Profile
-
Next12
-
TeamBlind
Full Stack Engineer
Bleet
-
Node.js
MySQL
Swagger
Firebase
Mybiskit
-
Nuxt2
MySQL
AWS
Puppeteer
Blind
-
Node.js
PHP
Docker
MySQL
Redis
AWS
-
Smilegate
Frontend Engineer(intern)
Stove
-
Vue2
ACTIVITY
EDUCATION
-
가톨릭대학교
미디어공학, 컴퓨터정보공학 전공
\ No newline at end of file diff --git a/e2e/__snapshots__/about.spec.ts/mobile/about.png b/e2e/__snapshots__/about.spec.ts/mobile/about.png new file mode 100644 index 00000000..cb7998ab Binary files /dev/null and b/e2e/__snapshots__/about.spec.ts/mobile/about.png differ diff --git a/e2e/__snapshots__/post/dom.spec.ts/desktop/2023.html b/e2e/__snapshots__/post/dom.spec.ts/desktop/2023.html new file mode 100644 index 00000000..06c2ea19 --- /dev/null +++ b/e2e/__snapshots__/post/dom.spec.ts/desktop/2023.html @@ -0,0 +1,140 @@ +

2023 회고록

1ilsang
클라이밍 하실래염?
#retrospect#2023
Published

cover

+

2022년의 목표

+

작년에 분명 2022 회고록을 작성했는데 문서를 잃어버렸다(-_-).

+

많이 당황했지만 반대로 말하면 1년 동안 쳐다도 안 봤다는 것이니 작년에 세웠던 올해의 목표나 다짐은 사실상 없는 것과 같았다.

+

그래서인지 올해는 기분 가는 대로 즉흥적으로 시작한 것들이 많았다.

+

어느 시점부터 일정이나 해야 할 일이 정리가 안되었고 이것을 해결하기 위해 무던한 노력을 했다.

+

올해의 회고는 즉흥적 삶과 그 과정을 KPT로 정리해 내년에 어떻게 에너지를 발산할 수 있을지 고민해 보려고 한다.

+

Keep

+
+

현재 만족하고 있는 부분과 계속 이어갔으면 하는 부분

+
+

기술적 관심

+ +

회사

+
    +
  • LINE과 Yahoo!가 합병하는 과정을 몸으로 겪을 수 있어서 좋았다. 진짜..ㅋㅋ
  • +
  • 큰 기업끼리 합을 맞추는건 정말 쉽지 않다.
  • +
  • 자발적으로 일하려고 노력했다.
  • +
  • 팀원들에게 친절하려고 노력했다.
  • +
  • 사내 오픈소스 행사에서 1등 했다. MDN에 기여했다.
  • +
  • 기존 Webpack 프로젝트를 Vite로 변경하고 좋은 성능 지표를 얻었다.
  • +
  • 모든 프로젝트에서 캘린더를 따로 만들고 있어서 라이브러리로 만들어 배포했다.
  • +
  • 얼떨결에 프런트엔드 밸런스 게임에 출연하게 됐다.
  • +
  • "일의 격"을 읽고 느끼는 게 많았다. 연차가 쌓이면서 일을 잘하는 게 무엇인지 고민하게 됐다.
  • +
+

대외활동

+

nexters

+
    +
  • 프런트엔드 반상회에서 오픈소스를 주제로 발표했다.
  • +
  • 멘토링 활동을 했다. 하면서 오히려 많이 배웠다. 설명을 잘하기 위해 공부를 많이 했다.
  • +
  • 글또 활동 덕분에 글쓰기에 사명감을 느끼게 되었다.
  • +
  • 넥스터즈 회고모임을 운영했다. 시각화 잘 나와서 뿌듯했다.
  • +
+

운동

+

climbing solved chart

+
+

띠별 푼 문제 개수이다. 올 한해는 클라이밍과 함께했다.

+
+
    +
  • 파랑 클라이머에서 어느덧 빨강으로 올라왔다. 월별로 변해가는 게 신기하다.
  • +
  • 7월에 발리에서 서핑을 시작했다. 최고의 활동 중 하나였다.
  • +
  • 무엇보다 안 다쳤다! 안전이 제일 중요하다.
  • +
+

여행

+
    +
  • 도쿄로 출장갔다. 경제가 더 활성화 되었으면 좋겠다. 출장 많이 다니고 싶다.
  • +
  • 발리에서 한 달 동안 리모트 워크를 했다.
  • +
  • 타지의 인연이 생기면서 영어를 더 열심히 해야겠다고 느꼈다.
  • +
+

독서

+
    +
  • 북또 활동을 통해 한 달에 한 권 책 읽기를 진행했다.
  • +
  • 회복탄력성과 소크라테스 익스프레스 두 책이 인상적이었다. 삶을 대하는 시각이 달라졌다.
  • +
  • 사라진 개발자들도 흥미롭게 읽었다. 애니악과 관련된 이야기들을 보면서 역사에 흥미가 생겼다.
  • +
+

Problem

+
+

불편하게 느끼는 부분. 개선이 필요하다고 생각되는 부분

+
+

데이터의 파편화

+
    +
  • 한 해 이것저것 뭔가 했는데 시각화하기가 어려웠다. 데이터적 삶을 살고 싶다.
  • +
  • 계획을 더 세분화해서 진행하고 글로 남기고 싶다.
  • +
+

기술 갈증

+
    +
  • 딥다이브를 생각보다 하지 못했다. 원리를 탐구하고 확실하게 설명할 수 있는 개발자가 되어야 한다.
  • +
  • 공부를 넘어 의미 있는 생산을 하고 싶다. 창업하고 싶은 것은 아니지만 기술자로서 갈증이 있다.
  • +
  • 좋은 멘토로 성장하고 싶다.
  • +
+

생활

+
    +
  • 영어가 너무 아쉽다.
  • +
  • 조금 더 진심으로 살 수 있었는데 그러지 못한 것 같아 아쉽다.
  • +
  • 마르쿠스는 명상록에서 "침대 밖으로 나갈 사명이 있다"고 했다.
  • +
  • '사명'이지 '의무'가 아니다. 사명은 내부에서 의무는 외부에서 온다. 사명은 자신과 타인을 드높이기 위한 자발적 행위다.
  • +
  • 나는 나의 삶에 사명감을 느끼고 있는가?
  • +
+

Try

+
+

Problem에 대한 해결책. 다음 Action 플랜

+
+
    +
  • 데이터 적 삶을 살기.
  • +
  • 계획의 세분화. PARA 보드 더 잘 활용하기.
  • +
  • 계획 짧게 가져가기. 즉각적 피드백으로 빨리빨리 일을 처리하기.
  • +
  • 한 달에 한 개 사용하고 있는 기술 다이브 하기. 월간 다이브!
  • +
  • 일단 무언가 만들어보기.
  • +
  • 영어 열심히 하기.
  • +
  • 더 진심으로 살기.
  • +
+

총평

+

올해를 쭉 돌아보니 나는 데이터화가 많이 부족하다. 시각화 자료로 만들기 어려운 부분이 많다.

+

시각화의 중요성을 많이 느끼고 있기 때문에 나라는 사람의 데이터를 잘 수집하는 것부터 해보자고 생각하게 되었다.

+

나는 욕심이 많아서 하고 싶은 것이 많은데 정작 세부 일정은 전무하다. 그래서인지 시간 대비 효율이 높지 못했다.

+

다음부터는 일을 진행할 때 세부 계획을 세우고 피드백 시간을 넣어야겠다.

+

PARA에 TODO로 쌓이는 것들이 너무 많다(!!). 어느 순간부터 적어만 놓고 안 하는 것들이 생긴다. 사이클을 조금 더 빨리 돌려야겠다.

+

더 진심으로 살아갈 필요가 있다.

+

신년 목표

+

blue-dragon

+

신년의 가장 큰 목표는 "개인 브랜딩"이다. 전문가로 성장하고 싶다.

+
    +
  1. 개인 브랜딩 +
      +
    • 전문가로 성장하기
    • +
    • 타인에게 친절하기
    • +
    +
  2. +
  3. 데이터 적 삶 +
      +
    • 한 달에 한 번 회고
    • +
    +
  4. +
  5. 전문성 +
      +
    • 책 한 권 쓰고 싶다
    • +
    • 월간 다이브 진행
    • +
    • 알고리즘 진심 모드
    • +
    • 영어 시험 준비
    • +
    • 세미나 준비하기
    • +
    • 한 달에 한 권
    • +
    +
  6. +
  7. 취미 +
      +
    • 클라이밍 빨클러
    • +
    +
  8. +
+

2024년 잘 부탁드립니다.

📮 이 포스트에 관심 있으신가요? 이슈를 남겨주세요! 👍
☕ 소주 한 잔 후원하기
(예금주: 이상철)tosskakao
\ No newline at end of file diff --git a/e2e/__snapshots__/post/dom.spec.ts/desktop/2024-01.html b/e2e/__snapshots__/post/dom.spec.ts/desktop/2024-01.html new file mode 100644 index 00000000..11e7f3c5 --- /dev/null +++ b/e2e/__snapshots__/post/dom.spec.ts/desktop/2024-01.html @@ -0,0 +1,47 @@ +

2024년 1월 회고

1ilsang
클라이밍 하실래염?
#retrospect#2024#01
Published

cover

+

2023년 회고록에서 "데이터" 부재의 아쉬움을 이야기했었다. 따라서 올해는 데이터 드리븐 삶을 살기 위해 월간 회고를 진행하려고 한다.

+

2024년 1월의 목표

+

1월의 핵심 키워드는 "절제"였다. 작년의 문제 중 하나로 지목된 "TODO"에 무한히 쌓이기만 하는 것들을 없애기 위한 일환이었다.

+

이번 달의 목표는 아래와 같았다.

+
    +
  1. 2D1R
  2. +
  3. 월간 다이브
  4. +
  5. 월간 메이커스
  6. +
  7. 말해보카
  8. +
  9. 운동하기
  10. +
+

2D1R

+

leetcode-jan

+

2D1R은 2일에 1알고리즘으로, 꾸준하게 가져가고 싶은 습관 중 하나다.

+

leetcode를 기준으로 풀고 있다. 화요일과 목요일은 꼭 제출한 모습을 보인다. 이때가 스터디 제출 날이라..ㅋ

+

최근에 가장 재밌었던 문제는 Find-the-duplicate-number이다. 플로이드의 토끼와 거북이 알고리즘을 활용하여 해결하는 문제인데, 점화식 도출이 너무 신기했다.

+

취준생 때 지금처럼 재미를 느꼈다면 얼마나 좋았을까...

+

월간 다이브

+

vite

+

전문성을 기르고자 시작한 월간 다이브.

+

특정 기술을 이해하고 넘어가는 것이 아니라 글로 정리해 누군가에게 설명할 수 있도록 하는 것이 목표다. 분석/발표하는 것이 목표다.

+

원래는 Axios를 하려고 했는데, 회사에서 Vite 관련 이슈를 만나면서 Vite Dev Server 이해하기 (feat. HMR)로 급선회했다.

+

시간이 엄청나게 들어갔다. 거의 10일은 매달렸다(-_-).

+

기술 이해를 위한 디버깅 과정이 고난이라 생각했지만 글로 정리하면서 팩트체크 하는 과정이 "진짜"였다. 정말 쉽지 않았다.

+

나름 만족하는 글이다. 많은 분들에게 피드백을 받을 수 있었고 회사에서 발표도 진행했다.

+

월간 메이커스

+

rust

+

월간 다이브로 이론적 공부를 했다면 월간 메이커스를 통해 실전 코딩을 하려고 했다.

+

목표로 하는 라이브러리 혹은 기술을 잡아 클론코딩 비슷하게 만들어 보는 게 목표이다.

+

유데미 러스트 강의를 수강 하고 나서 의미 있는 걸 만들어보자 생각해 FHF(File Hierarchy Fixer)를 구현해 보려고 했다.

+

근데 월간 다이브 과정에서 시간이 끌리며 레포만 만들고 실패했다.

+

말해보카

+

말해보카

+

요즘 말해보카에 푹 빠져 있다. 영어 재밌을지도..?

+

이 녀석 완전 효자다. 대만족 중

+

운동하기

+

chart

+
+

클라이밍 날짜와 빨강 푼 개수

+
+

자주 갔다고 생각했는데 모아보니 많이 가진 못했다.

+

1월에는 총 34개를 풀었다. 요즘 야식을 많이 먹어서 그런지 벽 타는 게 상당히 어렵다.

+

다음 달에는 좀 더 트레이닝해야겠다.

+

마치며

+

2월에는 현상 유지하면서 책 한 권만 읽으면 좋을 것 같다.

+

가보자고!

📮 이 포스트에 관심 있으신가요? 이슈를 남겨주세요! 👍
☕ 소주 한 잔 후원하기
(예금주: 이상철)tosskakao
\ No newline at end of file diff --git a/e2e/__snapshots__/post/dom.spec.ts/desktop/array-prototype-sort.html b/e2e/__snapshots__/post/dom.spec.ts/desktop/array-prototype-sort.html new file mode 100644 index 00000000..0a6c3f45 --- /dev/null +++ b/e2e/__snapshots__/post/dom.spec.ts/desktop/array-prototype-sort.html @@ -0,0 +1,401 @@ +

Array.prototype.sort() 이해하기

1ilsang
클라이밍 하실래염?
#ECMAScript#array#sort
Published

cover

+
[11, 8, 1, 2, 33, 3].sort(); // [1, 11, 2, 3, 33, 8]
+ 
+const arrayLike = { 0: 'c', 2: 'b', 123: '1ilsang', '1ilsang': 123, length: 3 };
+Array.prototype.sort.call(arrayLike); // { 0: 'b', 1: 'c', 123: '1ilsang', '1ilsang': 123, length: 3 };
+// ?????
+

JavaScript에서 sort는 어떻게 구현되어 있을까? stable 한가? 브라우저별 차이는 없을까? ECMA의 명세는 어떻게 되어있을까?

+

Index

+ +

TL;DR!

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
EngineBrowserAlgorithmStableIn-placeECMA Spec
V8ChromeTim sortOXO
WebkitSafariBucket / MergeOXO
SpiderMonkeyFirefoxMerge + InsertionOXO
+
    +
  1. sort 함수는 ECMA 2019부터 stable sort가 되었지만 in-place 하지 않을 수 있다.
  2. +
  3. 유사 배열 객체를 정렬할 때는 length를 기준으로 프로퍼티 값을 비교한다.
  4. +
  5. 브라우저별 sort 함수의 구현체가 다르지만 ECMAScript 명세를 지킨다.
  6. +
+

의문의 시작

+

JavaScript의 sortTim sort로 구현되어 있다고 알고 있었다. 그런데 그 이상의 감동이 나에게 있는지 의문이 들었고 스스로에게 아래와 같이 질문해 보았다.

+
    +
  1. Array.prototype.sort의 명세는 어떻게 되어 있는가?
  2. +
  3. 브라우저는 Array.prototype.sort의 명세대로 sort를 구현했는가?
  4. +
  5. 모든 브라우저가 Tim sort로 구현되어 있는가?
  6. +
  7. compareFn의 유무에 따른 sort 함수의 동작은 무엇이 달라지는가?
  8. +
  9. sort 함수는 in-place하고 stable 한가?
  10. +
  11. 유사 배열 객체 또한 sort 함수로 정렬된다. 어떻게 동작하는가?
  12. +
+

sad

+

자 이제 감동을 채워나가자.

+

Array.prototype.sort() 공식 명세

+

ECMAScript의 내용을 기준으로 설명하겠다.

+

ECMA2019 stable

+

ECMA2019의 업데이트로 Array.prototype.sort 함수는 stable 하도록 명시되었다.

+

해당 명세는 [Normative] Make Array.prototype.sort stable #1340 PR에서 최초 정의되었다.

+

문서의 23.1.3.30에 Array.prototype.sort의 동작이 정의되어 있다. 공식 명세를 따라가며 동작을 확인해 보자.

+

23.1.3.30 Array.prototype.sort (compareFn)

+

ecma official

+
+

Array.prototype.sort의 명세 내용

+
+

한 줄씩 내용을 해석해 보자.

+

1. compareFn의 유효성을 검사한다.

+
const list = [3, 4, 6, 1, 5, 3];
+list.sort(123); // TypeError!
+Array.prototype.sort.call(list, 123); // TypeError!
+
    +
  • compareFn은 비교 콜백 함수를 뜻한다.
  • +
  • compareFn이 undefined가 아니고 IsCallable(호출 가능)하지 않다면, TypeError를 발생시킨다.
  • +
+

2. 배열 객체로 변환한다.

+
Object('123'); // String {'123'}
+Object([1, 2, 3]); // [1,2,3]
+Object({ 1: 'a', 2: 'b' }); // {1: 'a', 2: 'b'}
+
+

처음 공식 문서를 읽으면 여기서 막힌다. ?와 같은 표현을 ReturnIfAbrupt Shorthands라고 한다. 에러가 발생하면 즉시 리턴하고 아니면 결과를 진행한다는 뜻이다. 자세한 내용은 Completion Records 참고.

+
+
    +
  • ToObject를 호출하여 현재 배열(this 값)을 객체로 변환한다. +
      +
    • 위 코드의 첫 번째 예시와 같이 원시 타입 문자열을 문자열 객체로 변환한다.
    • +
    • 암묵적 형변환을 이해하고 싶다면 이 포스트를 읽어보길 추천한다.
    • +
    +
  • +
  • 객체로 변환해 처리하므로, 이는 sort 메서드가 배열이 아닌 객체에도 적용될 수 있음을 뜻한다.
  • +
  • 설정된 객체를 obj라 명한다.
  • +
+

3. 객체의 길이를 계산한다.

+
const arrayLike = { 0: 'c', 1: 'a', 2: 'b', length: 3 };
+console.log(arrayLike[1], arrayLike.length); // 'a', 3
+Array.isArray(arrayLike); // false
+arrayLike instanceof Array; // false
+
    +
  • LengthOfArrayLike를 호출하여 변환된 객체(obj)의 길이를 가져온다. +
      +
    • LengthOfArrayLike 추상 연산은 유사 배열 객체의 length 프로퍼티를 반환한다.
    • +
    • 해당 추상 연산에서 "유사 배열 객체"는 해당 연산이 정상으로 완료되는 객체를 뜻한다. 즉 length 프로퍼티(속성)가 있어야 유사 배열 객체로 성립한다.
    • +
    +
  • +
  • 가져온 길이를 len이라 명한다.
  • +
+

4. 정렬 비교를 위한 추상 클로저를 생성한다.

+
[11, 8, 1, 2, 33, 3].sort(); // [1, 11, 2, 3, 33, 8]
+
    +
  • 매개변수로 x, y가 있는 추상 클로저를 생성한다. 이 클로저는 compareFn을 캡쳐하고 다음 단계를 호출한다.
  • +
  • 클로저가 실행되면 CompareArrayElements를 호출하여 xy를 비교(compareFn)하고 결과를 반환한다. +
      +
    • 결과 값은 -1,0,1 혹은 에러를 반환한다.
    • +
    • compareFn이 제공되지 않으면 각 인자를 ToString으로 변환 후 문자열 비교(유니코드 포인트 순서) 한다. 이 때문에 위와 같이 기본 sort 함수의 동작이 처음에는 당혹스럽게 느껴진다.
    • +
    +
  • +
  • 생성된 추상 클로저를 SortCompare이라 명한다.
  • +
+

5. 새로운 배열에 프로퍼티를 정렬한다.

+
    +
  • SortIndexedProperties를 위에서 생성된 값들과 함께 호출한다. +
      +
    • SortIndexedProperties는 객체(obj)의 인덱싱된 속성들을 SortCompare를 사용해 len 만큼 정렬하는 함수다.
    • +
    • 여기서 SKIP-HOLES는 배열의 빈 요소를 정렬에서 제외하라는 뜻이다.
    • +
    +
  • +
  • SortIndexedProperties의 동작은 대략 아래와 같다. +
      +
    • 빈 리스트 items를 생성한다 +
        +
      • 메모리를 추가 사용한다. 명세는 in-place 하지 않다.
      • +
      +
    • +
    • 숫자 0인 k를 정의한다.
    • +
    • (반복) k < len 이라면 k를 문자열로 변환한 Pk를 생성한다. +
        +
      • obj에 Pk 속성이 있는지 확인하고 있다면(SKIP-HOLES이므로) 가져온다(kValue).
      • +
      • 가져온 값(kValue)을 items에 추가한다.
      • +
      • k의 값을 1 증가시킨다.
      • +
      +
    • +
    • 값이 추가된 items에 SortCompare를 호출해 항목을 정렬한다.
    • +
    +
  • +
  • 정렬된 리스트를 sortedList라 명한다.
  • +
+

6. 정렬된 요소의 개수를 계산한다.

+
    +
  • sortedList에 있는 요소의 개수를 itemCount라 명한다.
  • +
+

7. j를 0으로 설정한다.

+

8. 정렬된 요소를 객체에 설정한다.

+
    +
  • j가 itemCount보다 작을 동안 반복한다.
  • +
  • 객체(obj)의 j 번째 속성을 sortedList[j]로 설정한다. +
      +
    • 원본 객체를 변경하고 있다. mutable 하다.
    • +
    +
  • +
  • j를 1 증가시킨다.
  • +
+

9 ~ 10. 빈 요소를 처리한다.

+
[1, , 2].sort(); // [1, 2, empty]
+ 
+// 인덱스 1이 존재하지 않음.
+const arrayLike = { 0: 'c', 2: 'b', 123: '1ilsang', '1ilsang': 123, length: 3 };
+ 
+// 인덱스 2 가 삭제되고 1이 추가되었다.
+// 또한 length를 벗어나는(혹은 성립하지 않는) 인덱스는 무시(정렬 X)된다.
+Array.prototype.sort.call(arrayLike); // { 0: 'b', 1: 'c', 123: '1ilsang', '1ilsang': 123, length: 3 };
+
    +
  • SortedIndexedProperties 호출 때 SKIP-HOLES를 지정했으므로 빈 요소의 수를 유지하기 위해 DeletePropertyOrThrow를 호출하여 나머지 인덱스를 삭제한다.
  • +
+

11. 객체를 반환한다.

+

ecma official

+

easy-right?

+

Array.prototype.sort의 명세를 보면서 기존의 의문점이 상당히 많이 풀리게 되었다.

+
    +
  • Array.prototype.sort의 명세는 어떻게 되어 있는가? +
      +
    • 위에서 다루었다.
    • +
    +
  • +
  • compareFn의 유무에 따른 sort 함수의 동작은 무엇이 달라지는가? + +
  • +
  • sort 함수는 in-place 하고 stable 한가? +
      +
    • 공식 문서에 따르면 stable 해야 한다.
    • +
    • SortIndexedProperties의 동작을 보면 빈 리스트 items를 생성 후 하나씩 원소를 추가하고 있으므로 in-place 하지 않을 수 있다.
    • +
    +
  • +
  • 유사 배열 객체 또한 sort 함수로 정렬된다. 어떻게 동작하는가? +
      +
    • 공식 스펙 자체가 ToObject로 객체화한 후 처리하고 있으므로 객체 비교를 전제로 동작한다.
    • +
    +
  • +
+

이로써 스펙상의 이야기는 되었다. 하지만, 실제로 브라우저에서 어떻게 구현되어 있는지에 따라 동작이 달라질 수 있으므로 남은 의문의 해결과 실제 sort 함수의 동작을 확인하기 위해 브라우저별 어떻게 구현해 놓았는지 확인해 보자.

+

브라우저별 Sort 구현체

+
    +
  • 브라우저는 Array.prototype.sort의 명세대로 sort를 구현했는가?
  • +
  • 모든 브라우저가 Tim sort로 구현되어 있는가?
  • +
+

이제 위의 질문에 답을 해보자.

+

V8

+
v8/third_party/v8/builtins/array-sort.tq
// https://github.com/v8/v8/blob/12.3.206.1/third_party/v8/builtins/array-sort.tq#L1419
+transitioning javascript builtin ArrayPrototypeSort(
+    js-implicit context: NativeContext, receiver: JSAny)(...arguments): JSAny {
+  // 1. If comparefn is not undefined and IsCallable(comparefn) is false,
+  //    throw a TypeError exception.
+  const comparefnObj: JSAny = arguments[0];
+  const comparefn = Cast<(Undefined | Callable)>(comparefnObj) otherwise
+  ThrowTypeError(MessageTemplate::kBadSortComparisonFunction, comparefnObj);
+ 
+  // 2. Let obj be ? ToObject(this value).
+  const obj: JSReceiver = ToObject(context, receiver);
+ 
+  // 3. Let len be ? ToLength(? Get(obj, "length")).
+  const len: Number = GetLengthProperty(obj);
+ 
+  if (len < 2) return obj;
+ 
+  const isToSorted: constexpr bool = false;
+  const sortState: SortState = NewSortState(obj, comparefn, len, isToSorted);
+  ArrayTimSort(context, sortState);
+ 
+  return obj;
+}
+

V8의 builtin sort 함수인 ArrayPrototypeSort에는 Tim sort가 적용되어 있다. 또한 주석에서도 알 수 있듯 명세의 순서를 따르고 있다.

+

Tim sort는 stable 하지만 in-place하지는 않다(merge sort 보다는 적게 메모리를 사용한다).

+
+

V8 블로그의 글에 따르면 Chrome 70 전에는 퀵 정렬과 삽입 정렬을 혼합해서 사용하고 있었다.

+
+

Webkit

+
Webkit/Source/JavaScriptCore/builtins/ArrayPrototype.js
// https://github.com/WebKit/WebKit/blob/wpewebkit-2.43.1/Source/JavaScriptCore/builtins/ArrayPrototype.js#L509sadfaefafasdf
+function sort(comparator) {
+  "use strict";
+ 
+  var isStringSort = false;
+  if (comparator === @undefined)
+      isStringSort = true;
+  else if (!@isCallable(comparator))
+      @throwTypeError("Array.prototype.sort requires the comparator argument to be a function or undefined");
+ 
+  var receiver = @toObject(this, "Array.prototype.sort requires that |this| not be null or undefined");
+  var receiverLength = @toLength(receiver.length);
+ 
+  // For compatibility with Firefox and Chrome, do nothing observable
+  // to the target array if it has 0 or 1 sortable properties.
+  if (receiverLength < 2)
+      return receiver;
+ 
+  var compacted = [ ];
+  var sorted = null;
+  var undefinedCount = @sortCompact(receiver, receiverLength, compacted, isStringSort);
+ 
+  if (isStringSort) {
+      sorted = @newArrayWithSize(compacted.length);
+      @sortBucketSort(sorted, 0, compacted, 0);
+  } else
+      sorted = @sortMergeSort(compacted, comparator);
+ 
+  @sortCommit(receiver, receiverLength, sorted, undefinedCount);
+  return receiver;
+}
+

Webkit(Safari)의 sort 함수는 스트링일 경우 버킷 정렬을 사용하고 아니라면 합병 정렬(merge sort)을 이용한다. 또한 명세의 순서를 따르고 있다.

+

두 정렬 모두 stable 하다. 하지만 둘 다 in-place 하지 않다.

+

SpiderMonkey

+
gecko-dev/js/src/builtin/Array.js
// https://github.com/mozilla/gecko-dev/blob/661a7d013f6b841e9fbbe56d307cb206f62963c3/js/src/builtin/Array.js#L104
+function ArraySort(comparefn) {
+  // Step 1.
+  if (comparefn !== undefined) {
+    if (!IsCallable(comparefn)) {
+      ThrowTypeError(JSMSG_BAD_SORT_ARG);
+    }
+  }
+  // Step 2.
+  var O = ToObject(this);
+  // First try to sort the array in native code, if that fails, indicated by
+  // returning |false| from ArrayNativeSort, sort it in self-hosted code.
+  if (callFunction(ArrayNativeSort, O, comparefn)) {
+    return O;
+  }
+  // Step 3.
+  var len = ToLength(O.length);
+  // Arrays with less than two elements remain unchanged when sorted.
+  if (len <= 1) {
+    return O;
+  }
+  // Step 4.
+  var wrappedCompareFn = ArraySortCompare(comparefn);
+  // Step 5.
+  // To save effort we will do all of our work on a dense list, then create holes at the end.
+  var denseList = [];
+  var denseLen = 0;
+  for (var i = 0; i < len; i++) {
+    if (i in O) {
+      DefineDataProperty(denseList, denseLen++, O[i]);
+    }
+  }
+  if (denseLen < 1) {
+    return O;
+  }
+  var sorted = MergeSort(denseList, denseLen, wrappedCompareFn);
+  MoveHoles(O, len, sorted, denseLen);
+  return O;
+}
+

SpiderMonkey(Firefox)는 Gecko에 속한 엔진으로, JavaScript 실행에 특화되어 있다. Gecko는 Firefox의 전반적인 렌더링 엔진이다.

+
+

Gecko 깃헙은 mozilla-central 미러링 리포지터리로 Read-only다.

+
+

SpiderMonkey는 합병 정렬을 사용하는데, 합병 정렬의 내부에 최적화 작업을 위해 삽입 정렬을 사용하고 있으며 명세의 순서를 따르고 있다. Tim 정렬과 유사한 부분이 있다.

+

합병 정렬과 삽입 정렬은 모두 stable 하지만 삽입 정렬만 in-place 하다.

+

정리

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
EngineBrowserAlgorithmStableIn-placeECMA Spec
V8ChromeTim sortOXO
WebkitSafariBucket / MergeOXO
SpiderMonkeyFirefoxMerge + InsertionOXO
+
    +
  • 브라우저는 Array.prototype.sort의 명세대로 sort를 구현했는가? +
      +
    • YES
    • +
    +
  • +
  • 모든 브라우저가 Tim sort로 구현되어 있는가? +
      +
    • No
    • +
    +
  • +
+

마무리

+

sort 함수를 이해하면서 공식 문서 및 브라우저들의 소스 코드를 살펴보게 되었다.

+

가볍게 보았음에도 브라우저 코드 형태를 본다거나 ECMAScript를 읽을 수 있게 된 것은 뜻밖의 수확이었다. 가장 큰 수확은 sort뿐만 아니라 다른 명세(map, reduce,...)들에 대한 접근도 두려워하지 않게 되었다는 점이다.

+

처음엔 막막하던 공식 문서도 차근차근 따라가다 보니 읽어 나갈 수 있었다. JavaScript의 암묵적 형 변환과 같은 유연함은 오히려 동작을 이해하기 어렵게 하는 요소가 된다. 공식 문서에서 이러한 동작을 간결하게 표현하기 위한 많은 노력들을 볼 수 있었기에 감동 할 수 있었다.

+

메서드 동작을 명확하게 설명하지 못하는 부분이 늘 존재했었기에 아쉬움이 있었는데 이번 기회로 자신감도 얻고 JavaScript 자체에 더 가까워진 느낌이 든다.

+

재밌었다.

+

참고

+
📮 이 포스트에 관심 있으신가요? 이슈를 남겨주세요! 👍
☕ 소주 한 잔 후원하기
(예금주: 이상철)tosskakao
\ No newline at end of file diff --git a/e2e/__snapshots__/post/dom.spec.ts/desktop/bali-remote-work.html b/e2e/__snapshots__/post/dom.spec.ts/desktop/bali-remote-work.html new file mode 100644 index 00000000..78a5ffd0 --- /dev/null +++ b/e2e/__snapshots__/post/dom.spec.ts/desktop/bali-remote-work.html @@ -0,0 +1,207 @@ +

발리 한 달 리모트 워크 후기

1ilsang
클라이밍 하실래염?
#activity#bali#remote-work
Published

cover

+

시작 계기

+

작년 제주 한 달 리모트 워크 후기가 좋았어서 해외에서 한 달 리모트를 해보자고 결심했다.

+

마침 회사에서 해외 리모트를 허용했기 때문에 또 언제 경험해 볼까 싶어 신청하게 되었다.

+

다양한 선택지가 있었는데, 나는 발리를 선택하게 되었다.

+

사실 그냥 서핑이 하고 싶었다.

+

알아보기

+

면적

+

map

+

발리는 인도네시아에 있는 섬으로, 제주도의 약 3배에 해당한다.

+

또한 세계 1위의 허니문 여행지이기도 하다.

+

시차

+

발리는 서울보다 한 시간 느리다. 서울에서 오후 3시라면 발리에서는 오후 2시이다.

+

날씨

+

강수량

+

4월에서 10월이 비가 적게 오는 건기로 보고있다. 대부분의 여행자 사이트에서는 6월에서 9월을 여행 일자로 추천하고 있다.

+

따라서 나는 2023.07.03 ~ 2023.07.30 7월 한 달간 다녀오기로 했다.

+

서울과 비교해 보면 온도 자체는 더 더운 편이었지만 습하지 않아 상당히 쾌적했다.

+

종교

+

인도네시아 인구의 90%는 이슬람을 믿지만 발리는 90%가 힌두교를 믿는다.

+

발리는 신들의 섬이라 불릴 만큼 다양한 사원이 마을 곳곳에 있어 거리 어디에나 차낭 사리(Canang Sari)라는 제물을 바친다.

+

관광객임을 감안해도 한 달간 종교 문제로 갈등이 있지는 않았다.

+

물가

+

example

+

18,000루피아가 한화로 1,500원 정도인데, 이 돈이면 나시고랭 하나를 먹을 수 있다(ㄷㄷ).

+

항공편

+

인천 -> 방콕 -> 발리 -> 코타키나발루 -> 인천으로 총 54만원이었다.

+

숙소

+

짐바란에서 한 달 1층 독채 에어비엔비 60만원에 지냈다.

+

전기 및 통신

+

카페뿐만 아니라 관광지의 와이파이는 한국과 비교해도 잘되는 편이다. 다만 전기가 잘나간다(인도네시아는 전력 부족 국가다). 이와 관련된 에피소드는 아래에서 풀겠다.

+

LTE가 느리다고 느꼈다. 나는 4G 유심을 샀는데 핫스팟 무지하게 느렸다.

+

교통

+

traffic

+

발리에서 자동차 렌트는 도심 외곽이 아니면 비추다. 오토바이가 훨씬 빠르고 잘 되어있다.

+

고잭/그랩 굿

+

한 달간의 여정

+
+ surf-interior + delivery-food + bibycle +
+

도착 당일

+
    +
  • 늦은 저녁, 공항에 도착했다.
  • +
  • 공항 입구에서부터 서핑보드가 서 있는데(사진 1) 엄청나게 기대됐다.
  • +
  • 입국 심사가 정말 느리다. 너무 배고팠다.
  • +
  • 그랩 정말 저렴했다. 기사님이 친절하셔서 첫인상이 좋았다.
  • +
  • 숙소에 도착하니 Airbnb 호스트가 마중 나오셨다.
  • +
  • 호스트는 일본인 부부셨는데 진짜 말도 안 되게 친절하셨다. 완전 호감이었다.
  • +
  • 따라서 호감작 하기로 마음먹었다. 이 내용은 이후에 나온다.
  • +
  • 너무 배고파서 숙소 도착하자마자 그랩 푸드로 배달했다(사진 2).
  • +
  • 물고기가 상당히 맛있었다.
  • +
+

2일 차

+
    +
  • 여기는 오토바이가 불법이기 때문에 전기 자전거를 한 달간 타고 다니기로 했다(사진 3).
  • +
  • 이 돈이면 오토바이 타는 게 훨씬 저렴한데 아쉬운 감이 없지 않았다.
  • +
  • 전기 자전거 힘이 엄청 좋다. 최고 속력 40까지 봤다(oh...).
  • +
  • ATM에서 백만 루피아씩밖에 안 뽑혀서 킹받았다.
  • +
  • 배달이 싸다. 생선요리가 그나마 비싼 축인 데 저렴하고 맛있음.
  • +
  • 길에 개가 풀려있다. 처음에 당황.
  • +
  • 숙소는 전기를 충전해서 사용해야 한다. 에어컨에 맥북 풀세팅하니 하루에 8씩 나가는 듯하다.
  • +
+

1주일 차

+
    +
  • 나는 관광지보다는 로컬이 많은 곳에 있었다(짐바란).
  • +
  • 그래서인지? 사람들이 다들 친절했다. 많이 웃는다.
  • +
  • 도마뱀이 엄청 많다.
  • +
  • 비가 생각보다 많이 왔다(이때가 우기의 끝자락이었다. 2주 차부터는 한 방울도 안 왔다).
  • +
  • 회사 일하기 바빴다. 아쉬운 부분. 로컬 지역이다 보니 저녁에는 할 게 없었다.
  • +
  • 길의 고저가 크다. 낭떠러지 같은데 도로인 곳이 꽤 있다.
  • +
  • 자전거 타이어 터져서 언덕 밀고 왔는데 진짜 레전드.
  • +
+

busy

+
+

내 거친 코드와 그걸 지켜보는 불안한 눈빛

+
+
+ bird + lizard + monkey + kecak-fire-dance + surf + walk + turtle + medicine + climbing +
+

10일 차

+
    +
  • 자전거 타다가 황천길 갈뻔했다. 낙엽 밟고 미끄러졌는데 바로 옆에서 차가 지나다녔다.
  • +
  • 울루와투 사원 갔는데 케착 파이어 댄스(사진 3) 볼만했다.
  • +
  • 줄 서는데 누가 순수한 선의로 사진 찍고 오라고 줄을 대신 기다려줬다. 인류애는 있었다..!
  • +
  • 원숭이가 아무렇지 않게 물건을 훔쳐 간다.
  • +
  • 저녁을 실외에서 먹는 건 비추다. 벌레가 많다.
  • +
  • 카페에 코딩하는 외국인이 꽤 있다. 말 걸고 싶은데 오지랖인 것 같아 극한으로 참았다.
  • +
+

2주 차

+
    +
  • 갑자기 자주 전기가 끊어졌다. 회의가 많았는데 정말 곤란했다.
  • +
  • 믿었던 LTE 유심마저 잘 안 터졌다. 진짜 곤란했다. 숙소에 발전기 있는지 꼭 확인하자.
  • +
  • 모든 음료에 종이 빨대가 기본인데 너무 빨리 흐물해져서 열받는다.
  • +
  • 현지에 조금 익숙해져서 바로 호감작 시작했다.
  • +
  • 호스트 일본인 부부랑 저녁을 먹었다. 두 분은 발리에서 만났다고 하셨는데 전체 스토리가 로맨틱 그 자체였다. 즐거운 자리여서 또 저녁 먹기로 했다.
  • +
  • 집 앞 카페 알바생과 꽤 친해졌다. 블랙핑크 후광 엄청났다.
  • +
  • 서핑을 시작했다(사진 4). 물 많이 먹긴 했는데 클라이밍 다음으로 재밌다고 느낀 스포츠였다. 바로 다음 스케줄 잡음.
  • +
+

3주 차

+
    +
  • 동네에서 걷기대회?를 주최했다(사진 5). 참여했는데 골목골목마다 사원이 참 많다고 느꼈다.
  • +
  • 거북이 방생하는 캠페인(사진 6)에 참여했다. 인류애 +1 했다.
  • +
  • 배가 너무 아파서 정신 잃을뻔했다. 발리벨리에 걸린 것 같았다. GOAT 약(사진 7) 덕분에 겨우 버텼다.
  • +
  • 발리에서도 클라이밍은 하고 싶었기 때문에 굳이 돈내고 관광지까지 올라가서 탔다(사진 8).
  • +
  • 길을 걷는데 어떤 외국인이랑 우연히 잡담하게 됐다.
  • +
  • 한국 돈을 보여달라고 했는데 현금이 없어서(의심도 됐고) 못 보여줬는데 자신이 사우디에서 왔고 부자라고 어필했다.
  • +
  • ??? 뭐지 하는 데 맥도날드 어디냐고 물어보더라. 희한한 친구였다.
  • +
+

벌써 마지막 주

+
+ canang-sari + la-brisa + la-brisa-pad + motocycle + jungle-water + scuba-diving + merry-go-round + cliff + duck + shop +
+
+

마지막 한 주를 무사히 보내길 염원하며 나도 차낭사리를 켰다.

+
+

일만 하다 돌아가기는 아쉬워서 마지막 주에는 휴가를 냈다. 서핑도 하고 스쿠버다이빙에 클라이밍도 하러 가고 요가도 했다.

+

클라이밍은 가족 단위가 많았는데 꼬맹이들 정신없이 올라가더라...

+

요가가 좀 힙했다. 향 피우고 몽환적인 노래에 축 늘어져 있는데 감성적이었다.

+

워케이션으로 지내던 발리와는 느낌이 너무 달랐다. 휴양지의 발리는 생각보다 비싸고 화려했다.

+

공항에 가기전 친해진 카페 알바나 일본인 호스트랑 이런저런 이야기를 많이 했는데 타지에서 이어진 인연이 신기하고 아쉬웠다.

+

내가 언제 다시 발리를 올지는 모르겠지만 그들이 한국에 온다면 극진히 대접하고 싶다.

+
+ stake + banana-soup + russian + fan-cake + fish + scrumble + duck +
+

음식 이야기가 없으면 안된다고해서;; 음식 사진을 끝으로 마무리 하겠다.

+

마무리

+

good-bye-airport

+

한 달간 해외 리모트 워크를 하면서 "영어"와 "기술"에 대한 갈망이 커졌다.

+
    +
  • 워케이션온 다른 사람들과 가볍게 대화하다 보면 의외의 기회를 잡을 수 있다.
  • +
  • 영어와 기술을 조금 더 준비한다면 내 무대의 제한이 없겠구나 확신했다.
  • +
  • 모든 순간에서 언어가 아쉬웠다. 내 생각을 더 잘 전달하고 싶었다.
  • +
  • 기술을 더 연마해야겠다고 생각했다. 더 빠르게 작업하고 여가를 즐긴 순 없을까?
  • +
  • 기술을 더욱 연마해야겠다고 생각했다. 작은 기술이라도 있었으니 이런 기회가 있지 않았을까?
  • +
  • 기술을 더더욱 연마해야겠다고 생각했다. 팀원들에게 임팩트 있는 사람이 되고 싶다.
  • +
+

나는 무던한 사람인 듯하다.

+
    +
  • 문화 충돌에 큰 거부감이 없었다. 다른 문화에 대한 흥미가 더 컸다.
  • +
  • 다행히도 김치가 그립진 않았다. 현지 음식에 그대로 적응했다.
  • +
  • 타인과 말하는 게 즐거웠다. 상대방에 대한 호기심이 많았다.
  • +
+

내년에는 어디에서 또 어떤 인연을 만날까 두근두근하다.

+

즐거운 한 달이었다.

+

추천하는 장소

+

짐바란

+ +

꾸따

+ +

우붓

+ +

ETC

+
📮 이 포스트에 관심 있으신가요? 이슈를 남겨주세요! 👍
☕ 소주 한 잔 후원하기
(예금주: 이상철)tosskakao
\ No newline at end of file diff --git a/e2e/__snapshots__/post/dom.spec.ts/desktop/deploy-eslint-plugin.html b/e2e/__snapshots__/post/dom.spec.ts/desktop/deploy-eslint-plugin.html new file mode 100644 index 00000000..507874a1 --- /dev/null +++ b/e2e/__snapshots__/post/dom.spec.ts/desktop/deploy-eslint-plugin.html @@ -0,0 +1,151 @@ +

ESLint 플러그인 배포하기

1ilsang
클라이밍 하실래염?
#eslint#plugin#ast
Published

cover

+

ESLint 플러그인 구조를 간단하게 분석하고 커스텀 플러그인을 만들어 배포해 보자.

+

TL;DR!

+
    +
  1. ESLint에서 제공해 주는 generator를 사용해 프로젝트를 만든다.
  2. +
  3. 규칙을 만든다.
  4. +
  5. 배포한다.
  6. +
+

기본 세팅

+

플러그인 구조 만들기

+
# yo는 yeoman의 줄임으로, 스케폴딩 지원 도구다.
+npm install -g yo
+# ESLint 특화 스케폴딩 인터페이스 CLI를 설치한다.
+npm install -g generator-eslint
+

ESLint는 플러그인 구조의 통일을 위해 제너레이터를 제공해 주고 있다.

+

yoyeoman의 줄임으로, 스케폴딩 지원 도구다. 프로젝트에 필요한 디렉토리 및 파일을 커맨드라인으로 생성해 준다.

+

generator-eslint는 yeoman을 활용해 프로젝트를 구조화할 때 ESLint를 기준으로 설치되도록 래핑 된 패키지이다. ESLint에서 관리/지원하고 있으므로 직접 yo로 구조를 설정하지 않아도 어려움 없이 one-line으로 ESLint 구조를 생성할 수 있게 된다.

+

매우 간편하므로 설치한다.

+
# 플러그인 디렉토리 생성
+mkdir eslint-plugin-NAME
+cd eslint-plugin-NAME # 디렉토리로 이동
+ 
+# 전역으로 설치한 yo에서 스케폴딩된 generator-eslint를 실행한다.
+yo eslint:plugin
+? What is your name? GITHUB_NAME # package.json의 author에 추가된다.
+? What is the plugin ID? NAME # 해당 Plugin의 실제 이름(배포 명)이 되므로 적절하게 작성한다.
+? Type a short description of this plugin: PLUGIN_DESCRIPTION # package.json의 description에 나타난다.
+? Does this plugin contain custom ESLint rules? Yes # 커스텀 룰을 추가할 것이므로 Yes.
+? Does this plugin contain one or more processors? No # 우리는 eslint 기본 프로세서를 사용할 것이므로 No를 설정한다.
+ 
+# 초기 세팅이 되었으므로 dependencies를 설치한다.
+npm install
+

default-architecture

+

초기 구조 설정이 완료되면 위와 같이 폴더가 생성된다.

+

ESLint의 다양한 규칙은 lib/rules에 추가되어야 한다. 우리는 커스텀 규칙을 만들 것이므로 해당 디렉토리에 추가해 나가야 한다.

+

플러그인 Rule 구조 만들기

+

친절하게도 ESLint에서 커스텀 룰의 구조도 패키징 해주었기 때문에 yo를 통해 한 번 더 rule 구조를 만들어 준다.

+
var myData = getData123(); // 함수에 숫자가 있으므로 우리의 ESLint 플러그인에서 에러를 발생시킬 것이다!
+

우리는 "함수에 숫자가 있으면 안 된다"는 룰을 만들어 보자.

+
# generator-eslint에 설정되어 있는 rule 옵션으로 yo를 통해 구조를 만든다.
+yo eslint:rule
+? What is your name? 1ilsang # rule 파일에 주석으로 author로 추가된다.
+? Where will this rule be published? ESLint Plugin # core가 아닌 plugin 추가이므로 plugin으로 설정한다.
+? What is the rule ID? no-function-name-number # rule 아이디에 해당한다. rule 파일명이 된다.
+? Type a short description of this rule: The function name must not contain numbers. # rule 설명 추가. 주석으로 파일에 추가된다.
+? Type a short example of the code that will fail: var myData = getData123(); # 에러 케이스를 설정한다. 함수에 숫자가 있으면 안되므로 에러상황이다.
+   create docs/rules/no-function-name-number.md
+   create lib/rules/no-function-name-number.js
+   create tests/lib/rules/no-function-name-number.js
+ 
+No change to package.json was detected. No package manager install will be executed.
+

rule-architecture

+

CLI를 다 작성하면 위와 같이 rules 폴더 밑에 추가된 것을 확인할 수 있다. 이제 우리는 해당 파일에서 규칙을 추가하면 된다.

+

ESLint의 소스코드 파싱과 AST

+

규칙 추가에 앞서 ESLint의 동작 방식을 가볍게 살펴보자.

+

기본적으로 ESLint는 Espree 파서를 사용해 소스코드를 정적 분석한 뒤 AST(Abstract Syntax Tree)를 생성한다.

+

우리는 파싱된 트리에서 구문을 분석해 커스텀 규칙에 위반하는지 확인하면 된다.

+

ast-tree

+

astexplorer 사이트를 활용하면 파싱된 AST 트리를 눈으로 쉽게 볼 수 있다.

+

우리는 함수명을 분석해야 하므로 getData123에 집중한다. 해당 값은 CallExpression > callee > name에 존재한다는 것을 확인할 수 있다.

+

이제 우리의 커스텀 룰에 추가하면 된다!

+

규칙 추가

+
// lib > rules > no-function-name-number.js
+module.exports = {
+  meta: {
+    type: 'problem', // 이 규칙에 위반되는 값은 코드에 없어야 하므로 problem으로 설정한다.
+    docs: {
+      // 해당 규칙에 어긋날 경우 빨간줄 위에 뜨는 문구를 설정할 수 있다.
+      description: 'The function name must not contain numbers.',
+      recommended: true,
+      url: 'https://1ilsang.dev/posts/deploy-eslint-plugin',
+    },
+    fixable: true, // 자동 수정을 추가할 예정으로 true로 한다.
+    schema: [], // 규칙이 여러 옵션을 가지고 있다면 스키마로 분리해 표현할 수 있다.
+  },
+ 
+  create(context) {
+    // 우리의 규칙을 위해 CallExpression > callee > name의 문자열이 숫자가 있는지 확인하면 된다.
+    return {
+      CallExpression(node) {
+        const { callee } = node;
+ 
+        // callee.name에 숫자가 포함되면
+        if (/[0-9]/.test(callee.name)) {
+          context.report({
+            node,
+            data: { wrongFunc: callee.name },
+            // 에러 메시지를 띄운다. wrongFunc는 현재 함수의 토큰 값이다.
+            message: `[{{wrongFunc}}()] 함수에 숫자..?`,
+            // --fix 옵션으로 수정되게 할 수 있다. 숫자를 ''로 치환한다.
+            fix: (fixer) =>
+              fixer.replaceText(callee, callee.name.replaceAll(/[0-9]/g, '')),
+          });
+        }
+      },
+    };
+  },
+};
+
+

각 옵션에 대한 상세 정보는 공식 문서를 읽어보길 추천한다.

+
+

테스트 추가

+

메타테그 및 규칙을 완성하면 테스트를 해보자.

+
// tests > lib > rules > RULE_NAME.js
+ruleTester.run('RULE_NAME', rule, {
+  // 테스트를 통과하는 함수.
+  valid: ['var data = getData();'],
+ 
+  // 테스트를 통과하지 못하는 함수.
+  invalid: [
+    {
+      code: 'var data = getData123();',
+      errors: [
+        {
+          message: '[getData123()] 함수에 숫자..?',
+          type: 'CallExpression',
+        },
+      ],
+    },
+  ],
+});
+

배포하기

+

이제 마지막 단계인 배포를 해보자. npm 로그인이 되어있으면 큰 문제 없이 가능하다.

+
// package.json
+{
+  "name": "eslint-plugin-ID",
+  "version": "0.0.1"
+}
+

ESLint 플러그인은 eslint-plugin prefix가 존재하므로 이름을 지켜준다.

+

배포는 버전을 기준으로 진행하게 되므로 코드 수정내역이 발생해 다시 배포한다면 version을 업데이트해 주어야 한다.

+
npm publish
+

해당 명령어로 배포하면 완료! 만약 ENEEDAUTH 에러가 발생한다면 npm adduser를 통해 로그인을 해주자.

+

사용하기

+

배포된 플러그인을 실제로 사용해 보자. npm i -D eslint-plugin-PLUGIN_ID으로 설치한다.

+
// .eslintrc
+{
+  "plugins": ["PLUGIN_ID"],
+  "rules": {
+    "PLUGIN_ID/RULE": "error"
+  }
+}
+

.eslintrc 파일에 배포된 플러그인 아이디를 설정하고 rule을 지정한다.

+

result

+

이제 IDE에서 함수에 숫자를 사용하면 에러가 노출되는 것을 확인할 수 있다.

+

eslint --fix를 실행하게 되면 isNumber로 함수명이 변경된다.

+

또한 에러 문구의 eslint(plugin/rule)의 링크를 클릭하면 meta > docs > url 값으로 리다이렉트 된다.

+

그럼 이만!

+

Reference

+
📮 이 포스트에 관심 있으신가요? 이슈를 남겨주세요! 👍
☕ 소주 한 잔 후원하기
(예금주: 이상철)tosskakao
\ No newline at end of file diff --git a/e2e/__snapshots__/post/dom.spec.ts/desktop/geultto8-open-source-seminar.html b/e2e/__snapshots__/post/dom.spec.ts/desktop/geultto8-open-source-seminar.html new file mode 100644 index 00000000..eca8ba9f --- /dev/null +++ b/e2e/__snapshots__/post/dom.spec.ts/desktop/geultto8-open-source-seminar.html @@ -0,0 +1,68 @@ +

[글또 세미나] 모여봐요 오픈소스의 숲 발표 후기

1ilsang
클라이밍 하실래염?
#geultto#geultto8#seminar#open-source#eslint#translate
Published

cover

+
+

발표 자료 PDF 다운로드

+
+

글또에서 프런트엔드 반상회를 하게 되었고 발표자를 구하여서 지원하게 되었다.

+

작년 넥스터즈 활동을 마지막으로 외부 발표를 하지 않았는데 이번 기회에 다시 한번 공개적으로 하고 싶다고 생각해 지원하게 되었다.

+

주제 선정

+

프런트엔드 반상회인 만큼 프런트엔드 개발자에게 도움이 되는 내용을 발표하고 싶었다.

+

그래서 주제를 선정하는 도중 "내가 신입일 때 가장 듣고 싶었던 세션이 무엇일까?" 고민하게 되었고 결론은 "오픈소스 분석 방법"이였다.

+

라인에서 처음으로 프런트엔드 커리어를 쌓을 때 받은 미션은 사내 동영상 라이브러리의 유지보수였다. package.json이 뭐 하는 건지도 잘 몰랐기 때문에 UMD, CJS 등은 너무나 생소했고 트리쉐이킹과 다양한 디바이스의 지원은 당시 나에겐 상당히 어려웠다.

+

어쨋건 라이브러리였기 때문에 다른 오픈소스 라이브러리들은 어떻게 개발하고 있는지 틈틈히 분석하면서 늘 이런 오픈소스에 나도 기여하고 싶다고 생각했던것 같다.

+

따라서 나는 초보자를 위한 오픈소스 기여 가이드 및 핵심 코드 진입 방법을 목표로 발표하기로 했다.

+

준비 과정

+

내 세션은 두 가지 내용이 혼합되어 있다.

+
    +
  1. 오픈소스 기여 가이드
  2. +
  3. 핵심 코드 진입 방법
  4. +
+

위의 내용을 통해 오픈소스 코드의 첫 진입점을 찾고 기여할 수 있도록 가이드를 주고자 했다.

+

기여 방식을 설명하는 자리인 만큼 초보 개발자를 위해 익숙하면서도 흥미를 불러올 만한 내용으로 AtZ 친절하게 설명하고자 했다.

+

open source list

+

수많은 오픈소스에서 어떤것을 목표로 할까 고민을했고 역시 내가 기여해 봤고 자주 사용하는 오픈소스 중에서 고르게 되었다.

+

전체적인 구성은 기여 방법을 기준으로 3단계로 나눠 생각했다.

+
    +
  1. [Easy] React.dev 번역 기여와 같이 코드부와 관련 없지만 라이브러리에 기여할수 있는 문서 작업.
  2. +
  3. [Medium] ESLint와 같은 도구의 CLI 진입 코드와 플러그인 구성 방법.
  4. +
  5. [Hard] Jotai와 같이 코드에서 사용되는 코어 라이브러리의 엔트리 진입 방법.
  6. +
+

세 단계를 구분한 이유는 Hard로 갈수록 핵심 코드의 동작 방식을 잘 이해해야 하기 때문이다.

+
    +
  • 번역/문서화 기여는 라이브러리 코드에 진입하지 않기 때문에 비교적 쉽게 기여할 수 있다고 생각한다. 이 단계에서 포크나 PR 방법, 오픈소스 생태계 등을 설명할 예정이다.
  • +
  • ESLint와 같은 개발 도구는 ESLint 자체보단 플러그인에 대해 분석하고자 한다. 코어 로직 주변에서 전반적인 코드 구조/방식을 이해할 수 있어 핵심 코어 기여보단 쉽다고 생각한다. 라이브러리의 확장과 플러그인 구조, CLI 등을 설명하고자 한다.
  • +
  • Jotai와 같은 코어 라이브러리는 라이브러리도 잘 알아야 하고 같이 사용되는(React, Next 등) 코드와의 관계도 이해하고 있어야 하므로 어렵다고 생각한다. 여기는 가볍게 코드 진입과 빌드, 배포에 관해 설명하려고 한다.
  • +
+

어느 정도 틀이 갖춰진 다음에는 빠르게 PPT 작업을 할 수 있었다.

+

20분 발표이고 청중이 초보 개발자인만큼 가볍게 다양한 기여 예제를 보여주고자 했다.

+

장표의 마지막에는 오픈소스에 지속해서 노출되는 방법을 추가하며 마무리했다.

+

발표 당일

+

timetable

+

booth

+

타임테이블이나 스티커의 디자인이 깔끔하게 잘 뽑혔다. 운영진분들의 노고가 느껴졌다.

+
+

압도적 감사..!

+
+

세션은 팀 스파르타에서 진행되었는데 내부가 탁 트이고 깔끔해서 세션하기에 좋은 장소였다.

+
+

start

+

발표는 크게 떨리진 않았던 것 같다.

+

스무스한 행사 진행에 힘입어 청중들도 잘 호응해 주셨다.

+

혼자 떠드는 발표를 하고 싶지 않아서 청중과 눈을 마주친다거나 질문을 한다거나 여유 있게 행동하고 싶었는데 매우 쉽지 않았다.ㅋ

+

장표를 넘기기 전에 다들 어떻게 듣고 계시는지 궁금해서 종종 청중들을 바라봤는데 다들 엄청나게 집중해 주셔서 압도적 감사함을 느꼈다. 그래서인지 자신감을 가지고 더 여유롭게 떨지 않으면서 발표할 수 있었다.

+

확실히 열의 있는 분들과 함께 현장에서 발표하 는게 훨씬 좋고 인상적이라 느꼈다. 영상으로 발표할때는 오히려 힘들었다.

+
+

end

+

20분 정말 짧았다. 마이크 들자마자 끝난 느낌이었다.

+

10분간 QnA도 진행되었는데 생각보다 질문을 많이 해주셔서 조금 기뻤다.

+

특히 인상 깊었던 질문 중 하나는 기여하고 싶은 라이브러리의 조건에 대한 질문이었는데 내가 회사에서 라이브러리를 개발하면서 중요하게 생각했던 부분을 말할 수 있었다.

+

아무리 좋은 라이브러리라도 결국은 다른 개발자가 사용하기에 "편해야"한다. 나는 라이브러리는 DX가 무엇보다 중요하다고 생각한다. 따라서 문서화를 비롯해 IDE 단계에서의 편리함(코드의 간결함이나 타입 추론과 주석 등)이 기여하고 싶은 라이브러리의 조건이 되지 않을까 생각한다.

+

소신것 준비한 만큼 후회 없이 발표했다.

+

마치며

+

flower

+

연사자들에게 꽃을 나눠주셨는데 매우 민망(ㅋㅋ) 감사합니다.

+

발표를 준비하면서 스스로 공부가 많이 되었다.

+

앞으로도 꾸준히 공부해서 좋은 내용으로 다른 분들에게 공유할 수 있도록 노력하고자 한다.

+

열정이 솟아난 8월이었다.

+
+

발표 자료 PDF 다운로드

+
📮 이 포스트에 관심 있으신가요? 이슈를 남겨주세요! 👍
☕ 소주 한 잔 후원하기
(예금주: 이상철)tosskakao
\ No newline at end of file diff --git a/e2e/__snapshots__/post/dom.spec.ts/desktop/google-adsense.html b/e2e/__snapshots__/post/dom.spec.ts/desktop/google-adsense.html new file mode 100644 index 00000000..ef985c08 --- /dev/null +++ b/e2e/__snapshots__/post/dom.spec.ts/desktop/google-adsense.html @@ -0,0 +1,147 @@ +

[Next.js] Google AdSense 광고 적용 및 이해하기

1ilsang
클라이밍 하실래염?
#google#ad#ads#adsense#nextjs
Published

cover

+

Google AdSense를 적용한 내용을 정리해 보려고 한다.

+

Index

+ +

AdSense 적용 결과

+
+ad-bottom-mobile +ad-left-rail-desktop +
+

모바일 화면에서는 바텀 앵커 영역, 데스크탑 화면에서는 좌측 레일 부분에 광고를 실었다(현재는 제거).

+

광고 컴포넌트 영역은 구글이 자동으로 설정하게 할 수도 있고 직접 특정 영역만 설정할 수 있다.

+

이 부분들은 아래에서 자세히 다루겠다.

+ +

맨 처음 광고를 달고자 마음먹으면 곧바로 광고 도메인과 관련된 다양한 키워드에 혼란을 느끼게 된다.

+

구글 공식 문서에 따른 Google AdSense와 Ads의 차이점은 광고 게시자인지 광고주인지에 따라 다르게 선택할 수 있는 플랫폼임을 알려주고 있다.

+

우리는 광고 게시자에 해당하기 때문에 AdSense를 적용해야 한다.

+

AdSense 설정하기

+

사이트 등록

+

site register

+

맨 처음 해야 할 일은 AdSense에 광고를 실을 사이트를 등록하는 것이다.

+

사이트 등록 후 광고 스크립트 한줄만 사이트에 추가하면 사실상의 모든 과정이 끝난다(!).

+

하지만 구글 애드센스를 적용하기 위해선 사이트 심사를 통과해야 한다. 심사 기간은 최대 2주 정도가 소요되며 사이트의 컨텐츠가 갖추어지지 않으면 거절될 수 있다.

+

관련 내용을 살펴보니 설정된 페이지가 주기적으로 조회되지 않으면 심사가 최대 6주까지도 소요된다고 한다.

+

하지만 큰 문제가 없다면 하루 내로 심사가 통과되는 듯하며 이 블로그 또한 하루 내에 심사가 통과되었다.

+

사이트 등록을 했다면 광고 스크립트를 적용해 보자.

+

광고 컴포넌트 적용

+

ad script register

+

사이트 심사 및 소유주 확인을 위해 원하는 확인 방법을 선택해 적용하면 된다.

+

여기 UI가 이상한데, 3개 중 하나를 적용하는 게 아니다.

+

애드센스 코드 스니펫 혹은 메타 태그로 사이트 심사를 받고 Ads.txt로 소유주 확인을 해야 한다.

+

죽, 애드센스 코드 스니펫 혹은 메타 태그 + Ads.txt 2가지를 적용해야 한다.

+

Ads.txt는 아래 사이트 소유권 확인에서 다루겠다. 지금은 넘어가도 된다.

+

나는 애드센스 코드 스니펫을 적용했다.

+
import Script from 'next/script';
+ 
+export const GoogleAdSense: FunctionComponent = () => {
+  if (process.env.NODE_ENV !== 'production') {
+    return null;
+  }
+  return (
+    <Script
+      async
+      src={`https://pagead2.googlesyndication.com/pagead/js/adsbygoogle.js?client=ca-pub-${PID}`}
+      crossOrigin="anonymous"
+      strategy="lazyOnload"
+    />
+  );
+};
+

production에서만 적용되어야 하므로 분기 처리하고 애드센스 코드를 그대로 심어준다. Script strategy 및 자세한 내용은 여기를 참고.

+
app/layout.tsx
export default function RootLayout({ children }) {
+  return (
+    <html>
+      <body>{children}</body>
+      <GoogleAdSense />
+    </html>
+  );
+}
+

그 후 앱의 최상단 레이아웃에 컴포넌트를 추가해 주면 끝난다.

+

이제 사이트 심사가 통과될 때까지 기다리면 된다.

+

사이트 소유권 확인

+

ads text

+

심사를 무사히 통과했다면 소유권을 인증해야 한다. ads.txt를 설정하라고 알려준다.

+
public/ads.txt
google.com, pub-ID, DIRECT, HASH_CODE
+

public/ads.txt를 생성하고 ads.txt 스니펫을 추가한다.

+

이후 구글봇이 돌면서 추가내역을 확인하게 된다.

+

광고 컴포넌트 영역 설정

+

이제 가장 중요한 광고 컴포넌트 영역을 설정해야 한다.

+
+

auto

+

direct

+
+
+

(좌) 사이트 기준 자동 삽입 / (우) 광고 단위 기준 직접 설정

+
+

홈의 광고 탭으로 들어오면 컴포넌트 설정을 할 수 있다. 광고 컴포넌트는 사이트 기준 자동 삽입 혹은 직접 광고 단위 기준으로 추가할 수 있다.

+
    +
  • 사이트 기준 +
      +
    • AdSense 스크립트가 자동으로 페이지 내부에 광고를 삽입
    • +
    +
  • +
  • 광고 단위 기준 +
      +
    • 광고 크기 및 위치를 직접 설정
    • +
    +
  • +
+

사이트 기준

+

ad-exclusive-area

+

사이트 기준으로 할 경우 오버레이, 인페이지 등에서 광고 추가 여부를 UI로 확인/선택할 수 있다.

+

광고가 기재되길 원하지 않는다면 영역을 제외하거나 페이지 자체를 추가할 수 있다.

+

광고 단위 기준

+

ad-target

+

원하는 광고 단위를 선택후 만들면 자동으로 HTML이 생성된다.

+
declare global {
+  interface Window {
+    adsbygoogle: any;
+  }
+}
+ 
+export const GoogleAdSenseComponent = () => {
+  useEffect(() => {
+    (window.adsbygoogle = window.adsbygoogle || []).push({});
+  }, []);
+ 
+  return (
+    <ins
+      class="adsbygoogle"
+      style={{ display: 'block' }}
+      data-ad-client="PID"
+      data-ad-slot="SLOT_KEY"
+      data-ad-format="auto"
+      data-full-width-responsive="true"
+    />
+  );
+};
+

이제 원하는 위치에 컴포넌트를 배치하면 된다.

+

지급 정보 설정

+

가장 중요한 지급 정보(계좌, SWIFT 코드 등)는 $100 이상 되어야 입력할 수 있다(-_-).

+

따라서 주기적으로 광고 실적을 확인해볼 필요성이 있다.

+

수익 구조

+

profit structure

+

애드센스 작동 원리에 따르면 우리가 설정한 광고 컴포넌트에 입찰가가 가장 높은 광고를 기준으로 게재된다고 한다.

+

이때 수익 배분은 사용 중인 제품에 따라 달라지는데 콘텐츠 광고는 게시자가 68%의 수익 지분을 가진다.

+

마무리

+

이로써 AdSense 사용법을 간단하게 살펴봤다. 사용성이 좋기 때문에 특별히 어려운 부분은 없었다.

+

이후 사이드 프로젝트에 꼭 적용해 보길 기원하면서 글을 마무리해본다.

📮 이 포스트에 관심 있으신가요? 이슈를 남겨주세요! 👍
☕ 소주 한 잔 후원하기
(예금주: 이상철)tosskakao
\ No newline at end of file diff --git a/e2e/__snapshots__/post/dom.spec.ts/desktop/goorm-195687.html b/e2e/__snapshots__/post/dom.spec.ts/desktop/goorm-195687.html new file mode 100644 index 00000000..6d83e4dd --- /dev/null +++ b/e2e/__snapshots__/post/dom.spec.ts/desktop/goorm-195687.html @@ -0,0 +1,66 @@ +

[구름톤 챌린지] 이진수 정렬

1ilsang
클라이밍 하실래염?
#algorithm#goorm#binary#memoization
Published

cover

+
+

문제 링크

+
+

10진수 숫자를 2진법으로 변환후 1의 개수가 가장 많은 순부터 정렬해 K번째 위치 값을 출력하면 되는 문제다.

+

접근법

+

N을 순회하면서 각각 2진수로 변환하고 그 값을 저장해 나간다면(O(N^2)) 너무 재미없다.

+

따라서 우리는 메모를 사용해 이전 값을 활용해 다음 값을 구해 나갈 것이다. 이 방법을 사용하면 O(N)으로 처리가 가능하다.

+
// 숫자별 1의 개수를 정리해 본다.
+// Index: [0,1,2,3,4,5,6,7,8,9];
+// Count: [0,1,1,2,1,2,2,3,1,2];
+

9까지의 수를 2진수의 개수로 표현하면 위의 표와 같아진다. 여기서 우리는 두 가지 패턴을 찾을 수 있다.

+
    +
  1. 2의 지수승(2^n)은 무조건 1이다(1, 2, 4, 8은 2진수에서 무조건 1이다).
  2. +
  3. 현재 값에서 2를 나눈 값의 1의 개수와 현재 값을 2로 나눈 나머지를 더하면 현재 값의 1의 개수가 된다.
  4. +
+

7을 기준으로 해보자.

+
7/2 = 3 => 2
+7%2 = 1
+=> 7 = 3
+
    +
  • 7을 2로 나누면 3이 된다. 위의 표에서 3의 1 개수는 2이다.
  • +
  • 7을 2로 나눈 나머지는 1이다.
  • +
  • 2 + 1 = 3이므로 7은 3이 된다.
  • +
+

이전 값을 알면 현재 값을 손쉽게 구할 수 있게 되었다.

+

그러므로 2부터 포문을 돌리면서 메모 배열을 만들고 최댓값을 찾아나가면 된다.

+

정리

+
    +
  1. 메모이제이션을 활용해 들어올 수 있는 숫자의 최댓값 2^20(1048576)까지 이진수의 개수를 구한다.
  2. +
  3. N을 메모이제이션 배열로 정렬한다.
  4. +
  5. K 번 인덱스의 값을 출력한다.
  6. +
+

최종 코드

+
let N, K;
+rl.on('line', (line) => {
+  if (typeof N === 'undefined') {
+    const [n, k] = line.split(' ').map((num) => Number(num));
+    N = n;
+    K = k;
+    return;
+  }
+  const nums = line.split(' ').map((num) => Number(num));
+  const memo = [0, 1];
+ 
+  // 메모 처리
+  for (let i = 2; i <= 1048576; i++) {
+    const before = memo[Math.floor(i / 2)];
+    const remain = i % 2;
+    memo[i] = before + remain;
+  }
+ 
+  // input을 순회하면서 메모 값을 기준으로 정렬한다.
+  const sortedList = nums.sort((a, b) => {
+    const am = memo[a];
+    const bm = memo[b];
+    // 만약 메모 값이 같다면(1의 개수가 동일하다면) 10진수를 기준으로 정렬
+    if (am === bm) {
+      return b - a;
+    }
+    return bm - am;
+  });
+ 
+  console.log(sortedList[K - 1]);
+  rl.close();
+});
📮 이 포스트에 관심 있으신가요? 이슈를 남겨주세요! 👍
☕ 소주 한 잔 후원하기
(예금주: 이상철)tosskakao
\ No newline at end of file diff --git a/e2e/__snapshots__/post/dom.spec.ts/desktop/goorm-195692.html b/e2e/__snapshots__/post/dom.spec.ts/desktop/goorm-195692.html new file mode 100644 index 00000000..3c4e96da --- /dev/null +++ b/e2e/__snapshots__/post/dom.spec.ts/desktop/goorm-195692.html @@ -0,0 +1,145 @@ +

[구름톤 챌린지] GameJam

1ilsang
클라이밍 하실래염?
#algorithm#goorm#simulation#brute-force
Published

cover

+
+

문제 링크

+
+

끔찍한 시뮬레이션 문제이다.

+

별다른 특이 사항 없이 문제에서 제시한 대로 구현하면 된다.

+

접근법

+

구름과 플레이어 두 명이 각각 게임을 진행한다. 따라서 우리는 동일한 게임을 2번 실행해야 한다는 것을 알 수 있다.

+

초기화를 잘하던가, 똑같은 코드를 두 번 실행해야 한다.

+

게임 보드 칸에는 이동 횟수와 방향이 적혀있으며 게이머가 놓은 위치에서부터 칸의 명령을 따라 쭉 실행하면 된다.

+

만약 보드를 이탈하는 경우(처음/끝) 반대쪽 첫 칸으로 이동하게 된다(-1 -> length -1, length -> 0),

+

이때 이전에 방문했던 곳에 다시 온다면 게임이 종료된다. 우리는 두 번의 메모 맵이 필요하다.

+

두 플레이어가 게임을 종료했을 때 점수를 비교해 출력한다.

+

정리

+
    +
  1. 유저의 위치를 받아 점수를 반환하는 함수를 만든다(내부 변수 사용으로 초기화 용이).
  2. +
  3. 이동 거리만큼 이동한다. +
      +
    1. 보드 이탈시 좌표를 반대쪽 첫 칸으로 재설정해 준다.
    2. +
    3. 이동할 때마다 메모를 한다.
    4. +
    +
  4. +
  5. 점수 값을 비교 출력한다.
  6. +
+

최종 코드

+
const readline = require('readline');
+let rl = readline.createInterface({
+  input: process.stdin,
+  output: process.stdout,
+});
+let n;
+const goorm = [];
+const player = [];
+const map = [];
+const d = {
+  U: [-1, 0],
+  R: [0, 1],
+  D: [1, 0],
+  L: [0, -1],
+};
+const getBoardInfo = ({ r, c }) => {
+  const cmd = map[r][c];
+  const count = parseInt(cmd);
+  const direction = cmd.slice(-1);
+  return {
+    cmd,
+    count,
+    direction,
+  };
+};
+const setNextMove = ({ r, c, direction }) => {
+  let nr = r + d[direction][0];
+  let nc = c + d[direction][1];
+ 
+  if (nr < 0) {
+    nr = map.length - 1;
+  } else if (nc < 0) {
+    nc = map[0].length - 1;
+  } else if (nr >= map.length) {
+    nr = 0;
+  } else if (nc >= map[0].length) {
+    nc = 0;
+  }
+  return {
+    nr,
+    nc,
+  };
+};
+const buildMemo = () => {
+  const memo = map.map((row) => {
+    return Array(row.length).fill(0);
+  });
+  return memo;
+};
+const playGame = ({ r, c }) => {
+  // 최초 세팅. 현재 칸의 명령을 파싱한다.
+  let { cmd, count, direction } = getBoardInfo({ r, c });
+  let nr = r;
+  let nc = c;
+  let score = 1;
+ 
+  const memo = buildMemo();
+  memo[r][c] = 1;
+ 
+  while (true) {
+    // 이동 횟수가 0이라면 현재 위치 칸의 명령으로 초기화 한다.
+    if (count === 0) {
+      const curValues = getBoardInfo({ r: nr, c: nc });
+      cmd = curValues.cmd;
+      count = curValues.count;
+      direction = curValues.direction;
+    }
+    // 다음 이동을 위해 nextRow, nextCol 값을 세팅한다.
+    const nextPosition = setNextMove({ r: nr, c: nc, direction });
+    nr = nextPosition.nr;
+    nc = nextPosition.nc;
+    // 만약 다음 좌표가 방문한적이 있다면 루프를 종료한다.
+    if (memo[nr][nc]) {
+      break;
+    }
+    // 이동 거리를 감소시키고 스코어를 추가한뒤 좌표를 메모한다.
+    count--;
+    score++;
+    memo[nr][nc] = 1;
+  }
+  return score;
+};
+const getParsedLineNumbers = (line) =>
+  line.split(' ').map((num) => Number(num) - 1);
+const setFields = (line) => {
+  const parsedLine = line.split(' ');
+  map.push(parsedLine);
+};
+ 
+rl.on('line', (line) => {
+  if (n === undefined) {
+    n = Number(line);
+    return;
+  }
+  if (goorm.length === 0) {
+    goorm.push(...getParsedLineNumbers(line));
+    return;
+  }
+  if (player.length === 0) {
+    player.push(...getParsedLineNumbers(line));
+    return;
+  }
+  if (map.length < n) {
+    setFields(line);
+    if (map.length < n) {
+      return;
+    }
+  }
+  const gScore = playGame({
+    r: goorm[0],
+    c: goorm[1],
+  });
+  const pScore = playGame({
+    r: player[0],
+    c: player[1],
+  });
+  const answer = gScore > pScore ? `goorm ${gScore}` : `player ${pScore}`;
+  console.log(answer);
+  rl.close();
+});
📮 이 포스트에 관심 있으신가요? 이슈를 남겨주세요! 👍
☕ 소주 한 잔 후원하기
(예금주: 이상철)tosskakao
\ No newline at end of file diff --git a/e2e/__snapshots__/post/dom.spec.ts/desktop/goorm-195693.html b/e2e/__snapshots__/post/dom.spec.ts/desktop/goorm-195693.html new file mode 100644 index 00000000..4c13aad0 --- /dev/null +++ b/e2e/__snapshots__/post/dom.spec.ts/desktop/goorm-195693.html @@ -0,0 +1,83 @@ +

[구름톤 챌린지] 통증2

1ilsang
클라이밍 하실래염?
#algorithm#goorm#brute-force#dynamic-programming
Published

cover

+
+

문제 링크

+
+

아이템 A, B를 최소한으로 사용하여 통증을 0으로 맞출 수 있는지 확인하는 문제이다. 불가능하다면 -1을 출력한다.

+

접근법

+

1. 완전 탐색

+

A와 B의 합이 N이 될 때까지 전체 조합을 탐색한다.

+

A가 0...I개 일때 B가 0...J개로 가능한지 확인할 수 있다. 이때 개수로 확인하게 되면 A가 I번 만큼 순회할 때마다 B가 J번 만큼 순회하게 되므로 시간초과가 발생한다.

+

A가 0...I개 일때 B는 0부터 하나씩 올려가며 찾지 않고 N-A가 B로 나누어지는지를 확인하면 O(N)만에 해결이 가능해진다.

+
let answer = Infinity;
+for (let aCost = 0; aCost <= n; aCost += a) {
+  // N에 A를 뺀 값이 B로 나누어떨어지지 않는다면 패스한다.
+  if ((n - aCost) % b !== 0) continue;
+  const aCount = Math.floor(aCost / a);
+  const bCount = Math.floor((n - aCost) / b);
+  const count = aCount + bCount;
+  // 최소 개수를 출력해야 하므로 현재 값이 answer보다 작다면 갱신한다.
+  if (count < answer) {
+    answer = count;
+  }
+}
+// answer가 무한이라면 가능한 조합이 없다는 의미이므로 -1로 변경한다.
+if (answer === Infinity) {
+  answer = -1;
+}
+console.log(answer);
+

2. DP

+

개수가 기준이 아닌 통증의 값 N을 기준으로 생각해 보자.

+

통증 N은 [N - A] + 1 혹은 [N - B] + 1이 될 수 있다. 현재 N값이 되기 위해선 A혹은 B를 더했으므로 역산으로 A 혹은 B를 뺀 개수에 현재 카운트 1을 추가하면 된다.

+

따라서 통증 0부터 N까지 순회하며 dp 테이블을 채워나가면 O(N)으로 처리가 가능해진다.

+
+

관련 문제로 이진수 정렬의 memo 배열이 채워지는 방식과 같다.

+
+
const dp = Array(n + 1).fill(Infinity);
+dp[0] = 0;
+ 
+for (let i = 1; i <= n; i++) {
+  if (i - a >= 0) {
+    dp[i] = Math.min(dp[i - a] + 1, dp[i]);
+  }
+  if (i - b >= 0) {
+    dp[i] = Math.min(dp[i - b] + 1, dp[i]);
+  }
+}
+console.log(dp[n] === Infinity ? -1 : dp[n]);
+

정리

+
    +
  1. 통증 N의 아이템 사용 개수는 N - A 혹은 N - B 값의 +1이다.
  2. +
  3. DP 테이블을 0부터 N까지 채운다.
  4. +
  5. DP[n] 값을 출력한다.
  6. +
+

최종 코드

+
const readline = require('readline');
+let rl = readline.createInterface({
+  input: process.stdin,
+  output: process.stdout,
+});
+let n;
+rl.on('line', (line) => {
+  if (n === undefined) {
+    n = Number(line);
+    return;
+  }
+  const [a, b] = line.split(' ').map((item) => Number(item));
+  const dp = Array(n + 1).fill(Infinity);
+  dp[0] = 0;
+ 
+  for (let i = 1; i <= n; i++) {
+    if (i - a >= 0) {
+      dp[i] = Math.min(dp[i - a] + 1, dp[i]);
+    }
+    if (i - b >= 0) {
+      dp[i] = Math.min(dp[i - b] + 1, dp[i]);
+    }
+  }
+  console.log(dp[n] === Infinity ? -1 : dp[n]);
+  rl.close();
+});
+ 
+rl.on('close', () => {
+  // console.log("Hello Goorm! Your input is " + input);
+});
📮 이 포스트에 관심 있으신가요? 이슈를 남겨주세요! 👍
☕ 소주 한 잔 후원하기
(예금주: 이상철)tosskakao
\ No newline at end of file diff --git a/e2e/__snapshots__/post/dom.spec.ts/desktop/goorm-195696.html b/e2e/__snapshots__/post/dom.spec.ts/desktop/goorm-195696.html new file mode 100644 index 00000000..c2e841f8 --- /dev/null +++ b/e2e/__snapshots__/post/dom.spec.ts/desktop/goorm-195696.html @@ -0,0 +1,81 @@ +

[구름톤 챌린지] 작은 노드

1ilsang
클라이밍 하실래염?
#algorithm#goorm#graph#sort
Published

cover

+
+

문제 링크

+
+

양방향 그래프에서 시작 점으로부터 더 이상 갈 수 없을 때까지 이동한 뒤 출력하면 되는 문제다.

+

접근법

+
// 양방향 그래프
+map[i].push(j); // i 노드가 j 노드로 방향성을 가지고 있다.
+map[j].push(i); // j 노드가 i 노드로 방향성을 가지고 있다.
+console.log(map);
+// { i: [j, ...], j: [i, ...] }
+

양방향 그래프이기 때문에 그래프를 만들 때 좌우 둘 다 추가해 준다.

+

각 정점에서 방문하지 않았던 번호가 낮은 노드로 이동하기 때문에 각 정점의 값은 정렬되어 있어야 한다.

+
[100, 30, 200].sort();
+// [100, 200, 30]
+

유의. 자바스크립트에서 sort함수에 비교함수 콜백을 작성하지 않으면 ASCII값을 기준으로 정렬한다.

+

따라서 "문자"로 비교하기 때문에 30, 100, 200이 아닌 첫 번째 문자의 아스키 값의 우선순위에 따라 100, 200, 30이 출력된다.

+
// a, b중 큰 값은 뒤로(+) 작은 값은 앞으로(-) 가게 된다.
+[100, 30, 200].sort((a, b) => a - b);
+// [30, 100, 200]
+ 
+// 0이므로 a, b는 서로 변경되지 않는다.
+[100, 30, 200].sort((a, b) => 0);
+// [100, 30, 200]
+ 
+// a, b중 큰 값은 앞으로(-) 작은 값은 뒤로(+)가게 된다.
+[100, 30, 200].sort((a, b) => b - a);
+// [200, 100, 30]
+

따라서 비교 함수 콜백을 통해 정렬의 우선순위를 먼저 지정해야 한다.

+
    +
  • 콜백 함수의 리턴값이 0보다 작은 경우(음수) a를 b보다 낮은 인덱스로 정렬한다(a가 먼저 오므로 오름차순이 됨).
  • +
  • 콜백 함수의 리턴값이 0인 경우 a와 b를 서로 변경하지 않고 다른 요소에 대해 정렬한다(현재 두 값으로는 비교 X).
  • +
  • 콜백 함수의 리턴값이 0보다 큰 경우(양수) a를 b보다 높은 인덱스로 정렬한다(b가 먼저 오므로 내림차순이 됨).
  • +
+

방문한 횟수는 방문(메모) 배열에 값을 추가해 나가다 마지막에 배열 길이를 출력하면 된다.

+

정리

+
    +
  1. 양방향 그래프를 그린다.
  2. +
  3. 메모 배열을 활용해 방문 시 체크해 준다.
  4. +
  5. 마지막 노드와 메모 배열의 길이를 출력한다.
  6. +
  7. sort 함수는 문자(ASCII)를 기준으로 정렬하기 때문에 유의해야 한다.
  8. +
+

최종 코드

+
const readline = require('readline');
+let rl = readline.createInterface({
+  input: process.stdin,
+  output: process.stdout,
+});
+const input = [];
+rl.on('line', (line) => {
+  input.push(line);
+});
+ 
+rl.on('close', () => {
+  const map = {};
+ 
+  const [n, m, k] = input[0].split(' ').map(Number);
+  for (const line of input.slice(1)) {
+    const [i, j] = line.split(' ').map(Number);
+    if (!map[i]) map[i] = [];
+    if (!map[j]) map[j] = [];
+    // 양방향 그래프 생성
+    map[i].push(j);
+    map[j].push(i);
+  }
+ 
+  // k 노드부터 출발할 예정이므로 메모와 last 값을 초기화한다.
+  const memo = [k];
+  let last = k;
+  while (true) {
+    // 현재 노드의 값이 없다면 이어진 정점이 없으므로 탈출한다.
+    if (!map[last]) break;
+    const next = map[last]
+      .filter((node) => !memo.includes(node)) // 방문한 적이 없는 값들만 필터링한다.
+      .sort((a, b) => a - b)[0]; // 방문하지 않은 정점들을 오름차순 정렬해 첫 번째 값을 꺼낸다.
+    if (!next) break; // next가 없다면 해당 노드에서 갈 수 있는 모든 정점을 방문한 상태이므로 종료한다.
+    last = next; // 마지막 노드를 갱신한다.
+    memo.push(last); // 메모에 마지막 노드를 추가한다.
+  }
+  console.log(`${memo.length} ${last}`);
+});
📮 이 포스트에 관심 있으신가요? 이슈를 남겨주세요! 👍
☕ 소주 한 잔 후원하기
(예금주: 이상철)tosskakao
\ No newline at end of file diff --git a/e2e/__snapshots__/post/dom.spec.ts/desktop/goorm-195698.html b/e2e/__snapshots__/post/dom.spec.ts/desktop/goorm-195698.html new file mode 100644 index 00000000..786bffe7 --- /dev/null +++ b/e2e/__snapshots__/post/dom.spec.ts/desktop/goorm-195698.html @@ -0,0 +1,147 @@ +

[구름톤 챌린지] 연합

1ilsang
클라이밍 하실래염?
#algorithm#goorm#graph#bfs#union-find
Published

cover

+
+

문제 링크

+
+

노드 사이에 사이클이 발생하면 "연합"이 된다. 각 사이클의 노드가 인접하면 인접한 사이클끼리도 연합이 된다. 이 연합 집합의 개수를 출력하는 문제이다.

+

이 문제는 그래프에서 사이클을 찾고 집합의 구성을 분류하는 기초적인 문제라 개념 잡기에 좋은 문제인듯 하다.

+

접근법

+

사이클을 찾고 해당 사이클이 집합을 이루는지 찾아야 한다. 이 문제는 BFS 또는 Union-Find로 풀릴 수 있다.

+

먼저 각 노드가 사이클을 가지고 있는지는 어떻게 확인할까? 무지성 includes를 해도 되지만 O(1)로 찾지 않으면 시간초과가 발생한다.

+
// 그래프의 방향성을 저장하기 위한 2차원 배열을 생성한다.
+const check = Array.from(Array(n + 1), () => Array(n + 1).fill(0));
+// cur 노드에서 next 노드로 방향성을 가지고 있다면 체크해 준다.
+check[cur][next] = 1;
+ 
+// 현재 노드와 다음 노드의 방향성이 둘 다 1이라면 사이클이다.
+check[cur][next] === 1 && check[next][cur] === 1;
+

문제가 친절하게도 각 정점 사이의 직접 사이클만 요구하므로 사이클을 더 쉽게 찾을 수 있다.

+

각 노드의 사이클 여부를 확인할 수 있게 되었으니 집합을 구성해 주어야 한다.

+

BFS로 집합 구성하기

+

인접한 노드끼리 사이클이면서 사이클끼리 인접하면 연합이 된다.

+

따라서 우리는 각 노드를 순회하면서 사이클을 찾고 사이클이 되는 노드에서 다시 사이클을 찾는 식으로 BFS 탐색을 하면 된다.

+
const bfs = (i) => {
+  const q = [i];
+  memo[i] = 1;
+  while (q.length) {
+    // 현재 노드에서 사이클을 찾아간다.
+    const cur = q.shift();
+    if (!map[cur]) break; // 현재 노드에서 이어진 노드가 없다면 루프를 탈출한다.
+    const nextList = map[cur];
+    for (const next of nextList) {
+      // 다음 노드가 현재 노드로 사이클이 없거나 방문한 적이 있으면 패스한다.
+      if (!check[next][cur] || memo[next]) continue;
+      // 다음 노드와 사이클이면서 방문한 적이 없기 때문에 큐에 넣어준다.
+      memo[next] = 1;
+      q.push(next);
+    }
+  }
+};
+let answer = 0;
+for (let i = 1; i <= n; i++) {
+  if (memo[i]) continue;
+  bfs(i);
+  // BFS에서 탈출했다면 연합 한 개가 구성된 것이므로 카운트를 추가한다.
+  answer++;
+}
+console.log(answer);
+

따라서 각 노드에서 사이클이 되는 노드를 찾고 해당 노드를 큐에 넣어줌으로써 연속된 사이클을 계속해서 찾을 수 있다.

+

Union-Find로 집합 구성하기

+

그래프의 집합을 나타내는 방법으로 Union-Find를 사용할 수 있다.

+
//     index    0  1  2  3  4  5  6  7
+const parent = [0, 1, 1, 1, 4, 5, 4, 4];
+// 1,2,3 인덱스 노드는 부모 노드가 1인 집합이 된다.
+// 4,6,7 인덱스 노드는 부모 노드가 4인 집합이 된다.
+// 0,5 인덱스 노드는 자기 자신만 집합인 노드가 된다.
+ 
+for (let cur = 1; cur <= n; cur++) {
+  const curList = map[cur];
+  for (const next of curList || []) {
+    // next -> cur로 이어져 있지 않다는 것은 사이클이 아니므로 무시한다.
+    if (!check[next][cur]) continue;
+    // 현재 노드와 다음 노드가 사이클이라면 집합을 구한다.
+    union(parent, cur, next);
+  }
+}
+

노드를 순회하면서 Union 조건이 성립(사이클)한다면 각 노드를 합쳐준다.

+

이후 각 노드의 부모 노드를 find 한다. 노드의 부모가 같다면 같은 집합이라는 뜻이 된다.

+

Union-Find에서는 memo 배열을 통한 방문 여부 확인 코드가 없는 것을 알 수 있다.

+
    +
  • BFS의 경우 인접한 모든 노드를 방문하면서 방문 여부로 집합을 구성하지만, Union-Find는 각 노드의 부모로 집합 여부를 확인하기 때문에 사이클이 된다면 각 노드의 부모를 계속해서 갱신해 줘야 한다.
  • +
+
// 오답
+const answer = new Set(parent);
+console.log(answer.size);
+ 
+// 정답
+const answer = new Set();
+for (let i = 1; i <= n; i++) {
+  answer.add(find(parent, i));
+}
+console.log(answer.size);
+

이때 부모 배열의 값으로만 비교해 출력하면 오답이 된다.

+

모든 노드를 순회하면서 부모를 갱신하지 않았기 때문에 마지막에 각 노드를 다시 find 해서 부모를 갱신해야 올바른 값이 된다.

+

정리

+
    +
  1. 양방향 그래프를 그린다.
  2. +
  3. 노드를 순회하면서 사이클 여부를 확인한다.
  4. +
  5. 사이클인 노드들을 집합으로 구분한다.
  6. +
  7. 집합의 개수를 출력한다.
  8. +
+

최종 코드

+
const readline = require('readline');
+let rl = readline.createInterface({
+  input: process.stdin,
+  output: process.stdout,
+});
+const input = [];
+rl.on('line', (line) => {
+  input.push(line);
+});
+const l = console.log;
+ 
+const union = (arr, a, b) => {
+  const aRoot = find(arr, a);
+  const bRoot = find(arr, b);
+  if (aRoot === bRoot) return;
+  const root = Math.min(aRoot, bRoot);
+  arr[Math.max(aRoot, bRoot)] = root;
+};
+ 
+const find = (arr, cur) => {
+  if (arr[cur] === cur) return cur;
+  // find 하면서 path 단축도 같이한다.
+  return (arr[cur] = find(arr, arr[cur]));
+};
+ 
+rl.on('close', () => {
+  const [n, m] = input[0].split(' ').map(Number);
+  // 부모 노드를 체크할 배열을 index의 값을 가지도록 세팅한다.
+  const ufArr = Array(n + 1)
+    .fill(0)
+    .map((_, idx) => idx);
+  const check = Array.from(Array(n + 1), () => Array(n + 1).fill(0));
+  const map = {};
+  input.slice(1).forEach((line) => {
+    const [s, e] = line.split(' ').map(Number);
+    map[s] = [...(map[s] || []), e];
+    check[s][e] = 1; // 해당 노드의 방향성을 체크한다
+  });
+  for (let cur = 1; cur <= n; cur++) {
+    const curList = map[cur];
+    for (const next of curList || []) {
+      // next -> cur로 이어져 있지 않다는 것은 사이클이 아니므로 무시한다.
+      if (!check[next][cur]) continue;
+      // 현재 노드와 다음 노드가 사이클이라면 집합을 구한다.
+      union(ufArr, cur, next);
+    }
+  }
+  const answer = new Set();
+  for (let i = 1; i <= n; i++) {
+    // 각 정점의 부모를 find로 순회하며 찾는다.
+    // Set이므로 중첩된 부모는 제외하고 추가된다.
+    answer.add(find(ufArr, i));
+  }
+  // Set의 size는 중복되지 않는 각 노드의 부모(집합)이므로
+  // 연합(집합)의 개수가 된다.
+  console.log(answer.size);
+});
📮 이 포스트에 관심 있으신가요? 이슈를 남겨주세요! 👍
☕ 소주 한 잔 후원하기
(예금주: 이상철)tosskakao
\ No newline at end of file diff --git a/e2e/__snapshots__/post/dom.spec.ts/desktop/implicit-coercion.html b/e2e/__snapshots__/post/dom.spec.ts/desktop/implicit-coercion.html new file mode 100644 index 00000000..90bcadcb --- /dev/null +++ b/e2e/__snapshots__/post/dom.spec.ts/desktop/implicit-coercion.html @@ -0,0 +1,237 @@ +

알랑말랑 암묵적 형변환 말랑말랑 이해하기

1ilsang
클라이밍 하실래염?
#javascript#type#implicit-coercion
Published
{} == []  // ERROR
+[] == {}  // false
+[] == ''  // true
+[] == []  // false
+"[object Object]" == {}  // true
+[45] == 45  // true
+[45] == '45'  // true
+4 * []  // 0
+[] + {}  // "[object Object]"
+{} + []  // 0
+0 == '\n'  // true
+1 + 2 + '3'  // 33
+NaN == NaN  // false
+undefined == null // true
+ 
+

cover

+

TL;DR!

+
    +
  1. 위 예시의 결과값이 도출되는 과정을 이해한다.
  2. +
  3. 암묵적 형변환을 유도하지 말라
  4. +
  5. 암묵적 형변환을 유도하지 마시오
  6. +
+
+

타입스크립트를 사용하면 되지 않나요?

+
+

들어가기 전에

+

primitive-type

+
+

이미지 주소

+
+

자바스크립트는 6가지 원시 타입과 Object 라는 객체 타입, 총 7가지 타입 이 존재한다.

+
+

ES2020에서 원시 타입에 bigint 타입이 추가되었기 때문에 이제는 총 8가지 타입이 존재하게 되었다.

+
+

기본적으로 암묵적 형변환은 모두 "원시 타입(문자열, 숫자, 불리언)"을 기준으로 하게 된다. 원시타입이 객체타입으로 암묵적 형변환이 되는 케이스는 존재하지 않는다.

+

암묵적 형변환은 언제 일어나나요?

+
// 표현식이 모두 문자열 타입이여야 하는 컨텍스트
+const a = '10' + 2; // "102"
+const b = `1 * 10 = ${1 * 10}`; // "1 * 10 = 10"
+ 
+// 표현식이 모두 숫자 타입이여야 하는 컨텍스트
+5 * '10'; // 50
+ 
+// 표현식이 불리언 타입이여야 하는 컨텍스트
+!0; // true
+if (1) {
+}
+1 == []; // false
+

자바스크립트 엔진은 표현식을 평가할 때 문맥, 즉 컨텍스트(Context)에 고려하여 암묵적 타입 변환을 실행한다.

+
+

https://poiemaweb.com/js-type-coercion#2-암묵적-타입-변환

+
+
    +
  • 산술 연산자(+-*/)의 경우 + 는 문자열이 우선순위가 더 높으며 나머지 연산은 숫자가 더 우선순위가 높다.
  • +
  • 동치 연산자(==)의 경우 피연산자간의 관계에 따라 정의가 다르다.
  • +
+

동치연산자 한짤로 보기

+

example

+
+

출처: MDN

+
+

동치 연산의 관계를 보면 Object 타입의 경우 ToPrimitive 라는 값이 있다.

+

이 함수가 암묵적 형변환의 핵심이며, 이 함수를 이해하면 타입 변환의 과정을 이해할 수 있다.

+

ToPrimitive 는 동치연산 뿐만 아니라 원시값과 비교가 필요한 모든 순간에 동작한다

+

to-primitive

+

Symbol.toPrimitive: A method that converts an object to a corresponding primitive value. Called by the ToPrimitive abstract operation.

+
+

ECMA2020

+
+

설명과 같이 객체의 원시 타입의 값을 반환하는 Symbol.toPrimitive 메서드는 ToPrimitive 추상 명령 에서 사용된다.

+
function toPrimitive(input, PreferredType) {
+  // PreferredType은 호출자가 기대하는 타입
+  if (typeof input === 'object' || typeof input === 'function') {
+    let hint =
+      PreferredType === undefined
+        ? 'default'
+        : typeof PreferredType === 'string'
+          ? 'string'
+          : 'number';
+    let exoticToPrim = input[Symbol.toPrimitive];
+    if (exoticToPrim !== undefined) {
+      let result = exoticToPrim.apply(input, [hint]);
+      if (!(typeof input === 'object' || typeof input === 'function'))
+        return result;
+      throw new TypeError();
+    }
+    if (hint === 'default') hint = 'number';
+    return OrdinaryToPrimitive(input, hint);
+  }
+  return input;
+}
+
+

코드 출처

+
+

input 이 객체이며 toPrimitive 추상 명령이 해당 객체 내에 없다면(input[Symbol.toPrimitive]) OrdinaryToPrimitive 를 호출하고 있다.

+

input[Symbol.toPrimitive] 이 메서드는 객체 프로퍼티로 개발자가 직접 넣은 케이스이므로 여기서는 넘어가겠다.

+

hint 는 어떤 원시 타입을 부를지에 대한 정의로써, 기본 타입이 넘버 타입인 것을 인지하고 넘어가자.

+
function OrdinaryToPrimitive (O, hint) {
+  if ( typeof O === "object" || typeof O === "function" ) {
+    if( typeof hint === "string" && ( hint === "string" || hint === "number" ) ) {
+      let methodNames = hint === "string" ? [ "toString", "valueOf" ] : [ "valueOf", "toString" ];
+      for( name of methodNames ) {
+        let method = O[name];
+        if( typeof method === "function" ) {
+          let result = method.apply(O);
+          if( typeof result !== "object" && typeof result !== "function" ) return result;
+        }
+    }
+  }
+ throw new TypeError();
+}
+
+

코드 출처

+
+

hintstring 이면 [toString, valueOf] 이며 number 이면 [valueOf, toString] 순서로 우선권을 가지는 것을 볼 수 있다.

+

여기서 우선권 이라는 단어를 사용하였는데, 그 이유는 for 문을 통해 apply 하는 순서가 달라지기 때문이다.

+

원시 타입을 찾았다면(if( typeof result !== "object" && typeof result !== "function" )) 결과를 반환하고 아니면 무시된다 이는 굉장히 중요한데, 아래에서 예시로 다루겠다.

+
    +
  • 따라서, 타입간 비교에서 암묵적 형변환들은 모두 원시타입으로 변환하기 위한 과정 속에서 일어난다.
  • +
+

예제로 정리하기

+
1. 4 * []  // 0
+2. 4 + []  // "4"
+3. [] + {}  // "[object Object]"
+4. [45] == 45  // true
+5. {} == []  // ERROR
+6. 0 == '\n'  // true
+
// CASE 1.
+1. 4 * []
+// +를 제외한 산술 연산의 경우 숫자타입이 최상위 우선순위이므로 암묵적 형변환은 Number == ToPrimitive([]) 으로 될 것이다.
+2. 4 * Object([])  // 4 * []
+// Symbol.toPrimitive 정의를 해주지 않았으므로 default hint 는 number 로 설정된다.
+3. 4 * Object([]).valueOf()  // 4 * []
+// Default hint 가 number 이므로 [valueOf, toString] 순으로 원시 값을 가져올 것이다.
+4. 4 * Object([]).valueOf().toString()  // 4 * ""
+// 하지만 valueOf 는 this 반환으로 객체([])를 반환해 원시타입이 아니게 되므로 무시된다. 따라서 후순위의 toString 함수가 실행된다.
+5. 4 * Number(Object([]).valueOf().toString()) // 4 * 0
+// 숫자 * 문자열 연산에서 숫자가 우선순위가 높으므로 Number 타입으로 형변환이 된다.
+6. 0
+// 그 결과 4 * 0 이 되어 0이 최종 리턴된다.
+
// CASE 2.
+1. 4 + []
+2. 4 + Object([]).valueOf().toString() // ""
+// CASE1 예시의 4번까지와 동일하다.
+3. String(4) + Object([]).valueOf().toString() // "4"
+// + 연산자에서는 숫자보다 문자가 우선순위를 가지므로 숫자가 String 으로 변환되었다.
+4. "4"
+
// CASE 3.
+1. [] + {}
+2. Object([]) + Object({})
+3. Object([]).valueOf() + Object({}).valueOf()  // [] + {}
+// 모두 객체자신을 반환하므로 toString 연산까지 진행하게 된다.
+4. Object([]).valueOf().toString() + Object({}).valueOf().toString()  // "" + "[object Object]"
+// 객체의 toString 은 prototype 상속으로 최종 this 결과값을 반환해 object Object 가 나타난다.
+// Object.prototype.toString.call(undefined) 호출시 "[object Undefined]" 가 나오는 것 처럼.
+5. "[object Object]"
+
// CASE 4.
+1. [45] == 45
+2. Object([45]) == 45
+3. Object([45]).valueOf() == 45 // [45] == 45
+4. Object([45]).valueOf().toString() == 45 // "45" == 45
+5. Number(Object([45]).valueOf().toString()) == 45 // 45 == 45
+6. true
+
// CASE 5.
+1. {} == []
+// {} 중괄호는 "객체"로 인식되는 것이 아닌 "블록 스코프"로 인식되어 사라져버린다!
+2. == []
+3. // Uncaught SyntaxError: Unexpected token '=='
+
// CASE 6.
+1. 0 == '\n'
+2. 0 == ""
+3. 0 == Number('')
+4. 0 == 0
+5. true
+

한발 더 나아가기

+
    +
  1. hint 는 언제 default 값을 벗어나게 될까? +
      +
    • <, > 혹은 -, * 와 같이 명확한 숫자 비교에선 number 가 된다.(+ 는 문자열도 포함되므로 제외된다)
    • +
    +
  2. +
  3. Date 객체를 제외한 모든 내장 객체는 defaultnumber 를 동일하게 처리하므로 number 로 이해하는게 편하다. + +
  4. +
  5. boolean 타입의 hint 는 존재하지 않는다. 모든 객체는 true 로 평가되므로 string, number 만 처리하면 된다.
  6. +
  7. [Symbol.toPrimitive] 를 커스텀 할 수 있는가? +
      +
    • 가능하다.
    • +
    +
  8. +
+
const user = {
+  name: '1ilsang',
+  money: 1000,
+ 
+  [Symbol.toPrimitive](hint) {
+    alert(`hint: ${hint}`);
+    return hint == 'string' ? `{name: "${this.name}"}` : this.money;
+  },
+};
+ 
+// 데모:
+alert(user); // hint: string -> {name: "1ilsang"}
+alert([user] == '{name: "1ilsang"}'); // hint: string -> {name: "1ilsang"} == {name: "1ilsang"}; true;
+alert(+user); // hint: number -> 1000
+alert(user + 500); // hint: default -> default는 number가 기본타입이므로 1000 + 500 -> 1500
+alert('3' - user); // hint: number -> '3' - 1000 -> 3 - 1000 -> -997
+alert(user > 10); // hint: number -> 1000 > 10; true
+alert(user + new Date()); // hint: default -> 1000 + 'Sat Apr 22 2023 17:02:00 GMT+0900 (한국 표준시)' -> '1000Sat Apr 22 2023 17:02:00 GMT+0900 (한국 표준시)'
+
    +
  1. NaN 은 모든 경우에서 같지 않다. +
      +
    • NaN == NaN // false
    • +
    +
  2. +
+

결론

+
    +
  1. 타입간 비교에서 암묵적 형변환들은 모두 원시 타입으로 변환하기 위한 과정 속에서 ToPrimitive 추상 명령을 통해 일어난다.
  2. +
  3. == 연산자와 === 연산자의 차이는 무엇인가? +
      +
    • "타입까지 비교 여부" 라고하면 애매하다. "암묵적 형변환을 허용하는가"의 차이가 더 명확한 워딩이다.
    • +
    +
  4. +
  5. 타입스크립트와 === 연산자를 사용하자.
  6. +
+

Ref

+
📮 이 포스트에 관심 있으신가요? 이슈를 남겨주세요! 👍
☕ 소주 한 잔 후원하기
(예금주: 이상철)tosskakao
\ No newline at end of file diff --git a/e2e/__snapshots__/post/dom.spec.ts/desktop/inflearn-meetup-03-dev-career.html b/e2e/__snapshots__/post/dom.spec.ts/desktop/inflearn-meetup-03-dev-career.html new file mode 100644 index 00000000..ab1d8c5f --- /dev/null +++ b/e2e/__snapshots__/post/dom.spec.ts/desktop/inflearn-meetup-03-dev-career.html @@ -0,0 +1,92 @@ +

인프런 판교 퇴근길 밋업 - 개발자 커리어 후기

1ilsang
클라이밍 하실래염?
#inflearn#pangyo#meet-up#seminar
Published

cover

+

학생 때는 개발자 모임에 많이 참여했었는데 요즘은 거의 나간 적이 없었다.

+

마침 인프런에서 재밌어 보이는 모임을 개최하고 있었기에 신청하게 되었다.

+

소개

+

intro

+
+

인프런 공식 행사 소개

+
+

행사는 판교 퇴근길 밋업의 세 번째 주제로 개발자 커리어를 다뤘다.

+

연사는 27년차 실리콘밸리 개발자의 인생 이야기로 유명한 한기용님이다.

+

영상을 보면서 흥미롭다고 느꼈는데 직강을 볼 수 있다는 소식에 설레는 마음으로 신청했던 기억이 난다.

+

"실리콘밸리에서 인정받는 개발자의 특징 10가지"라니 듣지 않을 수가 없었다.

+

행사

+

goods

+
+

개발자 행사의 꽃은 굿즈가 아닐까?

+
+

조금 일찍 갔기 때문에 샌드위치 먹으면서 행사 시작까지 사람들 구경했다.

+
+

main-speech

+

본격적으로 세션이 시작되었고 기용님 본인 소개가 시작되었다.

+

지금까지 쌓아오신 커리어가 다채롭다고 느껴졌다. 그렇기에 이렇게 연사로 계신 걸까? 나도 다양한 환경을 경험해 보고 싶다고 생각했다.

+

세션은 전반부와 후반부로 나뉘어서 진행되었다.

+

커리어를 바라보는 관점

+

전반부는 커리어 어떻게 이어 나갈지에 대한 내용이었다. 내가 공감이 많이 갔던 부분은 아래와 같다.

+
+
    +
  1. 많은 회사를 다녀봐라 +
      +
    • 어떤 매니저가 나랑 잘 맞는지 찾아야 한다
    • +
    +
  2. +
  3. 현재에 충실하기 +
      +
    • 불안하니까 선행학습을 한다. 필요하지 않은 학습을 하지 마라
    • +
    +
  4. +
  5. 서포터를 잘 만나기 +
      +
    • 무언가 추진할 때 지지해 줄 사람이 있어야 한다
    • +
    +
  6. +
+
+

어떤 매니저/동료를 만나느냐에 따라 성장의 곡선과 마음의 상처가 달라지는 것을 겪기도 하고 보기도 했다.

+

본인의 잘못이라고 책망하는 경우가 많은데 시스템적인 문제 혹은 그냥 문화가 안 맞는 것일 수도 있다.

+

나의 경우는 주변에서 칭찬하면 더 열심히 하는 경향이 있는데, 최근 특히나 서포터들의 힘을 많이 느낀다(😭😭😭👏👏).

+

개발자로서 생각해 보면 좋을 10가지

+

후반부는 개발자로서 생각해 보면 좋을 10가지를 소개해 주셨다.

+
+
    +
  1. 기본기
  2. +
  3. 학습 능력
  4. +
  5. 의사소통
  6. +
  7. 문제정의
  8. +
  9. 시간 추정
  10. +
  11. 운영 고려 코드 작성
  12. +
  13. 서비스 사고 대처
  14. +
  15. 결과 지향
  16. +
  17. 영향력
  18. +
  19. 리더 vs 전문가
  20. +
+

임팩트(결과)를 어떻게 낼 것인지에 대한 고민이 핵심이고 그것을 위한 스킬들을 설명해 주셨다고 생각된다.

+

특히 처음부터 끝까지 강조하신 "결과 지향적 개발자"는 내가 가져야 할 핵심 포인트라 느꼈다.

+

Q&A

+

intro

+

세미나가 끝나고 Q&A 시간이 주어졌다. 흥미로웠던 질문 두 가지를 가져와 봤다.

+

"해고에 대한 걱정은 없으신지?"

+
    +
  • 한국에서는 해고가 불명예의 느낌이지만 밸리에서는 누구나 해고당한다.
  • +
  • 해고 패키지를 잘 받도록 노력하자. 첫 번째 레이오프는 많이 챙겨주므로 오히려 먼저 나가는 게 좋을 수 있다.
  • +
+

"나에게 맞는 것, 맞지 않는 것을 어떻게 구분하셨고 받아들이셨나요?"

+
    +
  • 내 매니저가 어떤 사람인가를 많이 봤다.
  • +
  • 매니저를 잘 만나야 이후의 가치 판단이 된다.
  • +
+

네트워킹

+

모든 세션이 끝나고 네트워킹이 진행되었다.

+

네트워킹 시간은 사전 설문조사의 내용을 기반으로 관심도가 비슷한 사람들과 묶어주셨다.

+

잡담을 많이 했는데 시간 가는 줄 몰랐다. 시간이 부족할 정도.

+

마무리

+
+book +letter +
+

기용님이 최근 출판하신 실패는 나침반이다 도서를 지참해서 오면 친필 사인을 해주는 이벤트가 있었다.

+

기용님에 대한 개인적인 관심이 컸기 때문에 얼른 사인받으러 갔다.

+

커리어를 긴 호흡으로 바라보라는 점에 동의하고 있다. 나는 너무 조급한 게 아닐까?

+

조금 더 적극적으로 삶을 가꾸고 나를 사랑해야겠다.

+

오랜만의 개발자 모임에서 에너지를 많이 받았다. 앞으로도 종종 찾아다녀야겠다.

+

Love yourself.

📮 이 포스트에 관심 있으신가요? 이슈를 남겨주세요! 👍
☕ 소주 한 잔 후원하기
(예금주: 이상철)tosskakao
\ No newline at end of file diff --git a/e2e/__snapshots__/post/dom.spec.ts/desktop/jeju-remote-work.html b/e2e/__snapshots__/post/dom.spec.ts/desktop/jeju-remote-work.html new file mode 100644 index 00000000..fafaecb2 --- /dev/null +++ b/e2e/__snapshots__/post/dom.spec.ts/desktop/jeju-remote-work.html @@ -0,0 +1,59 @@ +

제주 한 달 리모트 워크 후기

1ilsang
클라이밍 하실래염?
#activity#jeju#remote-work
Published

cover

+
+

제주 섭지코지

+
+

최근에 해외 리모트로 한 달간 발리를 갔다 왔다.

+

해외 리모트 소감을 쓰려던 중 작년 중순(2022.05) 제주도에서 보낸 한 달간의 리모트가 떠올랐다. 시간 순서대로 적어보고자 하여 기억을 더듬어 그때의 기록을 남기고자 한다.

+

시작 계기

+

재택근무가 지속되던 어느날 회사에서 국내 리모트도 거주지의 확보만 된다면 가능하다고 공지가 나왔다.

+

평소 해외에서 근무해 보고 싶다고 생각을 자주 했었다. 마침 리모트도 되니 이번 기회에 연고지가 없는 곳으로 이동해 워케이션을 해보면서 나는 워케이션이 잘 맞는 사람인지 알고 싶었다.

+

바로 제주도가 떠올랐고 한 달 제주살이를 해보자 마음먹었다.

+

첫 일주일

+

start

+

나는 사람이 많은 곳을 피하고 싶었다. 고민하던 도중 성산일출봉 근처에 관광할 곳이 하나도 없는 지역 근처의 숙소를 구했다.

+

오죽했으면 근처에 카페도 거의 없어서 걸어서 20분 가야 했다. 9_-

+

성산일출봉이 어느 위치에서도 보였기 때문에 나침반 역할을 했다. 평소 서울에서 거의 벗어나질 않았기 때문에 어딜가도 바다와 대자연이 펼쳐진 제주도는 설레기에 충분한 장소였다.

+

주변을 더 자세히 보고자 많이 걸어 다녔고 혼자 사색하는 시간도 많이 가졌었다.

+
+

work

+
+

처참했던 근무환경

+
+

집을 벗어나 일하는게 익숙하지 않았던 나는 큰 실수를 하나 저지르는데 바로 "책상"을 제대로 알아보지 않았다는 점이었다.

+

숙소의 가격이나 주변 환경이나 인터넷 등은 확인했는데 책상은 여부만 확인하고 제대로 알아보지 않았던 점이 큰 실수였다.

+

의자가 제대로 되어있지 않은 책상에서 장시간 코딩은 허리에 무리가 많이 갔다. 퇴근하고 나서는 아예 누워서 했다.

+

평소 모션데스크에 절여져 있어서 서서 코딩하는게 편했는데 집에 있는 책상이 너무 그리웠다.

+
+

landscape

+

green-tea-cave

+

하지만 퇴근 후 혹은 주말에 제주 여러 지역을 돌아다니며 주위를 환기하는 과정은 워케이션을 후회하지 않게 해주기에 충분했다.

+

특히 카페에서 공부하는 걸 좋아하던 나는, 제주의 이색적인 카페에서 아름다운 풍경을 뒤로하고 여유롭게 책을 읽는게 상당히 좋았다.

+

제주에서 몇 가지 목표가 있었는데, 그중 하나가 완성된 웹사이트로 배우는 HTML&CSS 웹 디자인 책 리뷰였다.

+

지금도 CSS가 어렵지만 이때는 정말 flex의 존재도 모를 때였기 때문에 CSS를 꼭 공부해 보고자 생각했었는데 흥미롭게 읽을 수 있었다.

+

그 외에도 지금의 블로그의 토대를 만들고 두루뭉실했던 계획을 세분화하는 등 바쁘다며 미뤄뒀던 여러 작업들을 마칠 수 있었다.

+

벌써 마지막 주

+
+ fire + mountain +
+

당시에 시간이 정말 빠르게 간다고 느꼈다. 하루하루 많은 일이 있었는데 어느덧 마지막 주였던 기억이 난다.

+

중간에 배포가 있어서 야근을 엄청 하기도 했는데 당시 이런 생각이 들었다. 내가 개발을 조금 더 잘했으면 더 빨리 끝내고 편하게 쉬었을까? 물론 그랬겠지만, 여유가 있었다면 그 여유만큼 뭔가 더 일을 만들었을 것 같다. 서울에서는 스터디라던가 커피챗이라던가 다양한 활동/모임에 참여하느라 저녁의 여유를 잘 못 느꼈는데 여기에서 거의 반강제로 집에 있으면서 삶을 바라보는 방향이 많이 바뀔 수 있었다.

+

나는 왜 공부하는가?

+

다양한 답변이 속에서 나왔지만 결국 나는 누군가에게 도움이 되고 싶어서 공부한다는 결론이 나왔었다. 정보 공유를 한다거나 가르쳐줄 때 큰 재미를 느꼈고 그 재미가 내 행동 기반이라는 것을 깨닫게 되었다. 회사에서만 보더라도 다양한 정보 혹은 개발기를 공유하고 싶어서 열심히 일을 하게 되었던 것 같다.

+

이처럼 제주에서 나는 중간중간 스스로에게 질문을 많이 던지면서 천천히 생각해 보는 시간을 많이 가졌다. 이것이 제주에서 느꼈던 가장 좋았던 점이었다.

+

맨날 퇴근 후 다음 작업을 하느라 스스로에게 질문을 하지 못했는데 이번 기회에 삶의 방향을 한번 돌아보게 되었다.

+

pony

+

마무리

+
+ sunset-mount + sunset-sea +
+

워케이션 기간동안 많은 것을 느꼈다.

+
    +
  • 새로운 환경에서 작업하면서 주위를 환기하는 과정은 상당히 즐거웠다.
  • +
  • 나 자신과 스스로 마주할 수 있었기에 조금 더 자신을 알게 되었다.
  • +
  • 나는 "서울에서의 바쁜 일상을 즐겼다"는 결론을 가지게 되었다. 제주 생활도 좋았지만 "서울에 올라가면 꼭 ~ 해야지"라고 서울에서의 일정이 마구 생기는 모습을 보면서 나에게 워케이션은 한번씩의 환기 이벤트라고 생각하게 되었다.
  • +
  • 워케이션을 통해 평범했던 일상을 더욱 좋아하게 되었고 긍정적으로 삶을 바라볼 수 있게 되었다.
  • +
+

이제 다시 평범한 일상으로 돌아가게 되겠지만 새로운 환경이 필요하다고 생각하면 주저 없이 워케이션을 선택할 것 같다.

+

좋은 경험이었다.

📮 이 포스트에 관심 있으신가요? 이슈를 남겨주세요! 👍
☕ 소주 한 잔 후원하기
(예금주: 이상철)tosskakao
\ No newline at end of file diff --git a/e2e/__snapshots__/post/dom.spec.ts/desktop/junction2023.html b/e2e/__snapshots__/post/dom.spec.ts/desktop/junction2023.html new file mode 100644 index 00000000..98dcacad --- /dev/null +++ b/e2e/__snapshots__/post/dom.spec.ts/desktop/junction2023.html @@ -0,0 +1,58 @@ +

Junction Asia 2023 참여 후기

1ilsang
클라이밍 하실래염?
#activity#hackathon#junction2023#busan
Published

cover

+

작년 2022 정션에 참여했던 친구들의 피드백이 상당히 좋았기 때문에 올해는 꼭 참여해 보고 싶었던 해커톤이다.

+

모집 포스터가 올라오자마자 일정 확인 후 바로 휴가 썼다ㅋ. 가보자고!

+

지원 과정

+

나는 사전에 팀을 구성해서 지원했는데 개인으로도 지원할 수 있다.

+

팀으로 지원할 경우 디자이너, 개발자, 기획자가 각각 최소 한 명 이상 5인으로 구성되어야 한다.

+

선발 과정은 서류전형 한 번으로 이루어져 있다.

+

간단한 자소서와 티셔츠 사이즈 등의 설문을 완료하면 지원이 끝난다.

+

pass

+

다행히도 합격 발표가 났다!

+

부산 도착

+

entry

+

작년에는 숙소 제공도 있었는데 이번엔 없었다. 파트너사도 작년과는 많이 달라져서 조금 아쉬웠다.

+

그래도 해커톤 자체가 거의 3년 만에 참여하는 거라 상당히 들뜬 마음으로 부산에 갈 수 있었다.

+

해커톤은 금~일 2박 3일간 진행되었고 접수는 오후 6시부터였다. 접수 전에 스폰서와의 사전 미팅 시간도 있는데 간단한 네트워킹 자리다.

+

공식 언어가 영어이기 때문에 겁을 많이 먹었는데 대부분이 한국인이라 큰 어려움은 없었다.

+

첫날: 아이데이션

+

start

+

첫날에는 트랙 공개 및 첫 번째 미션이 주어졌다.

+

트랙은 아래의 5가지였는데 잘 기억이 안 나서 핵심 주제는 다를 수 있다. 뉘앙스만 봐주시길

+
    +
  1. 공공/빅데이터를 활용한 사회문제 해결
  2. +
  3. 로봇이 만드는 음식의 인식 개선
  4. +
  5. 전자 라벨 솔루션
  6. +
  7. 관광 이동 수단 개선? 관광의 재미 솔루션
  8. +
  9. 배달 음식 생태계? 구조 개선
  10. +
+

미션은 트랙을 선정하고 어떤 것을 개발할지 기획해서 제출하면 됐다.

+

우리는 3번 전자 라벨을 선택했고 상품에만 사용되는 전자 라벨을 사원증에도 사용할 수 있도록 하여 시장을 넓히는 것을 목표로 기획했다.

+
+

event

+

해커톤의 또 다른 재미는 사이드 부스인데 정션은 부스 컨셉들이 재밌었다. 빙고 채우는 재미가 있었음.

+

구석 한쪽에 빈백을 쭉 설치해 놓아서 피곤할 때 리차지하고 올 수 있었다.

+

다들 여기서 떨면서 잠들었는데 매우 슬픔이었다.

+

둘째 날: 개발

+

web

+

우리는 전자 라벨을 사원증으로 확장하는 것을 목표로 기획을 잡았기 때문에 전자 라벨에서 회사의 다양한 정보를 얻을 수 있도록 하고자 하였다.

+

따라서 전자 라벨과 연동된 CMS 페이지를 개발하고 페이지에서 라벨의 색, 이미지, 문구 등을 수정할 수 있게 하고 회의 스케줄을 받을 수 있도록 하였다.

+
+

https://github.com/junction-asia-2023/just-label

+
+

스폰서 API를 활용해 라벨과 통신했고 CMS 페이지는 Vite, React, Jotai, React-Query로 구성했다.

+

생각보다 페이지 찍는 게 시간이 좀 걸렸다. 역시 CSS는 너무 어려운 것임

+

spa

+

밤에는 정션에서 제공해 준 센텀 스파권으로 찜질방에 갔는데 진짜 너무 좋아서 그대로 쭉 있고 싶었다.

+

셋째 날: 발표

+

production

+

위의 CMS 페이지에서 문구나 이미지 등을 설정해 저장하면 전자 라벨에 업데이트가 되게 개발했다. 찌그러지지 않고 잘 나와서 참 다행이었다.

+

역시 갓자이너

+
+

presentation

+

해당 주제의 스폰서분들 앞에서 구현된 걸 기준으로 시현하는데 영어로 말해야 해서 진짜 지옥이었다. 가장 힘든 순간이었다(-_-).

+

발표 이후 여유 시간 동안 옆 팀 외국인들이랑 친해졌는데 이 부분이 좀 인상적이었다. 카이스트 재학 중인 외국인 5명이었는데 엄청 긱한 느낌이었다. 대화에 왜?라고 의문을 많이 가지는데 나도 다시 한번 생각해 볼 수 있어서 흥미롭게 대화할 수 있었다.

+

마무리

+

오랜만에 밤새면서 빠르게 작업하니까 재밌었다. 긴 재택근무 간에 떨어진 열정을 다시 채운 느낌이었다.

+

기회가 된다면 계속 꾸준히 해커톤에 참여하고 싶다.

+

아참 침낭이랑 후드 챙겨갔는데 이거 없었으면 진짜 고통스러울 뻔했다. 진짜 에어컨이 계속 나오기 때문에 너무 추웠다.

+

좋은 경험이었다. 그럼, 이만!

📮 이 포스트에 관심 있으신가요? 이슈를 남겨주세요! 👍
☕ 소주 한 잔 후원하기
(예금주: 이상철)tosskakao
\ No newline at end of file diff --git a/e2e/__snapshots__/post/dom.spec.ts/desktop/leetcode-easy-2727.html b/e2e/__snapshots__/post/dom.spec.ts/desktop/leetcode-easy-2727.html new file mode 100644 index 00000000..2a1058ba --- /dev/null +++ b/e2e/__snapshots__/post/dom.spec.ts/desktop/leetcode-easy-2727.html @@ -0,0 +1,66 @@ +

[LeetCode] 2727. Is Object Empty

1ilsang
클라이밍 하실래염?
#algorithm#leetcode#easy#object#for-in#for-of#keys
Published

cover

+
+

문제 링크

+
+

객체 혹은 배열이 비어있는지 확인하는 문제이다.

+
/**
+ * @param {Object | Array} obj
+ * @return {boolean}
+ */
+const isEmpty = (obj) => {
+  return Object.keys(obj).length === 0;
+};
+

배열 혹은 객체의 키가 있는지 확인하면 되므로 Object.keys의 길이가 0인지 확인하면 된다.

+

만약 keys로 추출 후 비교하는 것이 싫다면 아래와 같은 방법도 있다.

+
const isEmpty = (obj) => {
+  for (i in obj) {
+    return false;
+  }
+  return true;
+};
+

배열 또한 객체이므로 for...in을 통해 속성 키값을 순회할 수 있는지 확인하면 된다.

+

keys로 모든 키값을 가져와 비교하는 것보다 순회와 동시에 객체가 비어있는지 판단이 가능하므로 훨씬 빠르다.

+

참고로 이 문제는 "상속된" 프로퍼티를 비교해야 하는지에 대한 명시가 없어 모호한 부분이 있다.

+

만약 상속된 프로퍼티까지 비교한다면 keysfor-in의 정답 여부가 달라졌을 것이다. 이 부분은 아래에서 다루겠다.

+

++ for-infor-of의 차이가 무엇일까?

+

for...in

+
+

for...in문은 상속된 열거 가능한 속성들을 포함하여 객체에서 문자열로 키가 지정된 모든 열거 가능한 속성에 대해 반복합니다(Symbol로 키가 지정된 속성은 무시합니다.).

+
+

MDN for...in 설명에 따르면 for...in은 상속된 모든 속성(Property)을 포함한 속성 키 값을 반복한다.

+

for..of

+
+

for...of 명령문은 반복가능한 객체 (Array, Map, Set, String, TypedArray, arguments 객체 등을 포함)에 대해서 반복하고 각 개별 속성값에 대해 실행되는 문이 있는 사용자 정의 반복 후크를 호출하는 루프를 생성합니다.

+
+

MDN for...of 설명에 따르면 for...of는 반복 가능한 객체의 속성 값에 대한 순회를 한다.

+

결론

+
Object.prototype.objCustom = function () {};
+Array.prototype.arrCustom = function () {};
+ 
+let iterable = [3, 5, 7];
+iterable.foo = 'hello';
+ 
+for (let i in iterable) {
+  console.log(i); // "0", "1", "2", "foo", "arrCustom", "objCustom"
+}
+for (let i of iterable) {
+  console.log(i); // 3, 5, 7
+}
+console.log(Object.keys(iterable)); // [ "0", "1", "2", "foo" ]
+ 
+// Map 객체
+const m = new Map([
+  ['a', 1],
+  ['b', 2],
+]);
+console.log(m); // Map(2) {'a' => 1, 'b' => 2}
+for (let i in m) {
+  console.log(i); // 순회 되지 않음. undefined
+}
+for (let i of m) {
+  console.log(i); // ['a', 1], ['b', 2]
+}
+console.log(Object.keys(m)); // []
+

for...in 루프는 객체의 모든 열거 가능한 속성에 대해 반복하며 문자열 키 값을 반환한다. 추가로 인덱스의 순서를 보장하지 않는다.

+

for...of 구문은 컬렉션 전용이다. 모든 객체보다는, [Symbol.iterator] 속성이 있는 모든 컬렉션 요소에 대해 반복하며 컬렉션을 반환한다.

+

keys는 해당 키를 가져오지만, 상속된 값은 가져오지 않는다.

📮 이 포스트에 관심 있으신가요? 이슈를 남겨주세요! 👍
☕ 소주 한 잔 후원하기
(예금주: 이상철)tosskakao
\ No newline at end of file diff --git a/e2e/__snapshots__/post/dom.spec.ts/desktop/leetcode-hard-42.html b/e2e/__snapshots__/post/dom.spec.ts/desktop/leetcode-hard-42.html new file mode 100644 index 00000000..3e0cbc0e --- /dev/null +++ b/e2e/__snapshots__/post/dom.spec.ts/desktop/leetcode-hard-42.html @@ -0,0 +1,87 @@ +

[LeetCode] 42. Trapping Rain Water

1ilsang
클라이밍 하실래염?
#algorithm#leetcode#hard#stack#two-pointers
Published

cover

+
+

문제 링크

+
+

빗물이 고일 수 있는 모든 영역을 구하면 되는 문제다.

+

기본적으로 물은 '높은 곳에서 낮은 곳'으로 흐르기 때문에 우리는 높이를 비교하면서 빗물이 고일 수 있는지 판단해야 한다.

+

이 문제는 두 가지 스택과 투포인터 두 가지로 풀 수 있다. 두 방법 모두 알아두면 좋기 때문에 두 가지 해석을 모두 하려고 한다.

+

접근법 1. Stack O(n) T(n)

+

스택 접근은 "가로로 면적을 합해가는" 방식이다.

+

빗물이 고이기 위해서는 양쪽으로 벽이 있어야 한다.

+

스택을 활용해 양 벽을 계산하는 방법은, 높이가 감소할 때는 스택에 푸쉬하고 높이가 이전 탑보다 높아질 때는 팝을 하면서 얼마만큼의 빗물을 저장할 수 있는지 계산하면 된다.

+

example

+

0번째 높이부터 순회해 보자.

+
    +
  1. 모든 원소를 순회할 때마다 스택에 푸쉬한다. 왼쪽의 그림과 같이 높이가 감소해 나갈 때에는 특별한 작업 없이 계속 진행한다.
  2. +
  3. 중앙의 그림(i = 3)의 상황일 경우 현재 벽이 스택의 Top 값보다 더 높기 때문에 빗물이 고일 수 있다. 현재 높이와 같거나 클 때까지 스택에 쌓여있는 벽들을 pop 하며 "가로로" 누적값을 더한다. 여기서는 높이 1이 최대이므로 스택에 추가된 높이 2(i = 0)까지 계산하지 않고 끝난다.
  4. +
  5. 우측의 그림(i = 4)에서도 동일하다. 현재 높이보다 큰 높이가 나올 때까지 팝을 하며 계산한다. 2번에서 이미 높이 1일 때의 경우를 계산했으므로 높이 2일 때의 가로 값만 계산하면 된다.
  6. +
+

마지막 노드까지 위의 방식을 계속해서 하면 모든 면적을 구할 수 있다. 코드와 라인별 해석은 제일 아래에 작성해 두었다.

+

접근법 2. Two Pointers O(n) T(1)

+

투포인터 접근은 "세로로 면적을 합해가는" 방식이다.

+

빗물이 고이기 위해서는 양쪽으로 벽이 있어야 한다.

+

투포인터는 양끝에 포인터를 설정하고 좌우로 움직이며 높이가 더 높은 쪽을 향해 간다. 높이가 높은 쪽을 향해 양 포인터를 옮기면 반대로 낮은 쪽은 빗물이 고이는 곳이기 때문에 세로로 더해나가면 된다.

+

example

+

L, R을 양 끝에 두고 순회하면서 각각 MAX 값을 구한다.

+
    +
  1. 최초의 상태. 최대값(가로선)을 설정한다.
  2. +
  3. L < R이라면 L을 옮기고 아니면 R을 옮긴다. 여기는 L를 옮겼다. i = 1의 높이가 L의 최대높이보다 낮으므로 그 차이를 더한다.
  4. +
  5. L < R일 때까지 L을 옮긴 모습이다.
  6. +
  7. R을 옮겼다. i = 8의 높이가 R의 최대높이보다 낮으므로 그 차이를 더한다.
  8. +
  9. 계속해서 반복하면 결국 LR은 한곳으로 모이고 그 사이의 모든 세로 값이 더해져 빗물의 면적을 구할 수 있다.
  10. +
+

최종 코드

+
// Stack
+const trap = (height) => {
+  let res = 0;
+  let i = 0;
+  const stack = [];
+ 
+  while (i < height.length) {
+    const curHeight = height[i];
+    // 현재 높이가 스택의 마지막 높이보다 높다면
+    while (stack.length > 0 && height[stack[stack.length - 1]] < curHeight) {
+      const lastI = stack.pop();
+      // 스택이 비었다는 의미는 자신뿐이므로 빗물이 고일 수 없기 때문에 탈출한다.
+      if (stack.length === 0) break;
+      const peekI = stack[stack.length - 1];
+      // 현재 위치(i)와 스택의 다음 위치(peekI)의 거리를 구한다.
+      const dist = i - peekI - 1;
+      // 현재 위치의 높이와 스택의 다음 위치 높이중 낮은 값을 기준으로 스택의 마지막 높이를 뺀다.
+      // 마지막 높이만큼은 빗물이 고일 수 없기 때문
+      const h = Math.min(curHeight, height[peekI]) - height[lastI];
+      res += dist * h;
+    }
+    stack.push(i++);
+  }
+ 
+  return res;
+};
+
// Two-pointers
+const trap = (height) => {
+  let res = 0;
+  let l = 0;
+  let r = height.length - 1;
+  let lMax = 0;
+  let rMax = 0;
+ 
+  while (l < r) {
+    const curLeft = height[l];
+    const curRight = height[r];
+    // 매번 max 값을 갱신한다. 그래야 자신이 줄어든 값인지 비교할 수 있다.
+    lMax = Math.max(curLeft, lMax);
+    rMax = Math.max(curRight, rMax);
+ 
+    // 현재 높이가 최대값 보다 적다면 고일 수 있으므로 추가한다.
+    if (curLeft < lMax) {
+      res += lMax - curLeft;
+    }
+    if (curRight < rMax) {
+      res += rMax - curRight;
+    }
+    // 양쪽의 포인터를 비교해서 높이가 더 큰 방향으로 이동한다.
+    curLeft < curRight ? l++ : r--;
+  }
+ 
+  return res;
+};
📮 이 포스트에 관심 있으신가요? 이슈를 남겨주세요! 👍
☕ 소주 한 잔 후원하기
(예금주: 이상철)tosskakao
\ No newline at end of file diff --git a/e2e/__snapshots__/post/dom.spec.ts/desktop/leetcode-medium-238.html b/e2e/__snapshots__/post/dom.spec.ts/desktop/leetcode-medium-238.html new file mode 100644 index 00000000..609ce526 --- /dev/null +++ b/e2e/__snapshots__/post/dom.spec.ts/desktop/leetcode-medium-238.html @@ -0,0 +1,105 @@ +

[LeetCode] 238. Product of Array Except Self

1ilsang
클라이밍 하실래염?
#algorithm#leetcode#medium#product
Published

cover

+
+

문제 링크

+
+

nums 배열 원소의 모두 곱한 값에서 해당 원소를 나눈 값을 출력하는 문제이다. 문제에서는 나눗셈하면 안 된다고 명시하고 있으므로 나눗셈하지 않고 풀어야 하는 게 포인트이다.

+
    +
  1. 나눗셈을 하지 않아야 함.
  2. +
  3. T=O(n), S=O(1)으로 풀어본다.
  4. +
+

따라서 위의 두 가지를 목표로 이 문제를 해결해 보려고 한다.

+

Ideation

+

nums = [1, 2, 3, 4]의 경우를 살펴보자.

+

기대하는 정답은 [24, 12, 8, 6]이다. 해당 배열이 나오기 위해서는 전체 값의 곱(1*2*3*4 = 24)에 각자의 원소를 나누면 된다.

+

전체 곱에서 자신을 나누어 나오는 값은 자신을 제외한 모든 값의 곱과 동일하다.

+
+

e.g. 원소 3을 기준으로 한다면 1*2*3*4 / 3 === 1*2*4이다.

+
+

따라서 우리는 자신을 제외한 곱들의 왼쪽과 오른쪽을 곱해주면 정답이 된다는 것을 알 수 있다.

+
+

e.g. 원소 3을 기준으로 3의 좌/우의 곱은 1*2 * 4 = 8이다.

+
+

그러므로 왼쪽의 곱들과 오른쪽의 곱들을 기억해 두고 각 원소별 값을 출력해 주면 해결할 수 있다.

+

해당 원소까지의 왼쪽, 오른쪽 곱셈 값은 누적 배열을 활용하면 된다.

+
+

e.g. 원소 n 기준 왼쪽 [n-2, n-2 * n-1] * [n+1, n+1 * n+2] 오른쪽

+
+
const nums = [1, 2, 3, 4];
+ 
+// @NOTE: 요소의 왼쪽까지의 곱을 기준으로 하기 때문에 첫 번째는 1으로하고 마지막 원소까지 곱할 필요는 없다.
+const leftArr = [
+  1,
+  1 * nums[0],
+  1 * nums[0] * nums[1],
+  1 * nums[0] * nums[1] * nums[2],
+]; // [1, 1, 2, 6];
+ 
+// @NOTE: 요소의 오른쪽까지의 곱을 기준으로 하기 때문에 직관적으로 역순으로 한다.
+const rightArr = [
+  1 * nums[2] * nums[1] * nums[0],
+  1 * nums[2] * nums[1],
+  1 * nums[2],
+  1,
+]; // [24, 12, 4, 1];
+

Implementation(T=O(n), S=O(n))

+

위의 정리를 토대로 구현을 해보자.

+
    +
  1. n 번째 원소의 왼쪽 곱의 값들을 저장한다.
  2. +
  3. n 번째 원소의 오른쪽 곱의 값들을 저장한다.
  4. +
  5. 전체 원소를 순회하며 n 번째 원소의 좌우 값의 곱을 저장한다.
  6. +
  7. 리턴한다.
  8. +
+
// Left Arr
+const l = nums.reduce(
+  (acc, cur) => {
+    const last = acc[acc.length - 1]; // 누적값
+    acc.push(cur * last); // 이전의 누적값 * 현재 원소를 통해 누적값을 채운다.
+    return acc;
+  },
+  [1],
+);
+l.pop(); // 마지막 원소는 필요 없으므로 빼준다.
+ 
+// Right Arr
+const r = nums.reduce(
+  (acc, _, index) => {
+    const last = acc[0];
+    const cur = nums[nums.length - index - 1];
+    acc.unshift(cur * last);
+    return acc;
+  },
+  [1],
+);
+r.shift();
+ 
+// Left * Right Arr
+const answers = nums.map((_, index) => l[index] * r[index]);
+return answers;
+

위의 구현으로 문제를 해결할 수 있지만 공간 복잡도가 O(n)이기 때문에 더 최적화가 가능하다.

+

Implementation(T=O(n), S=O(1))

+

좌우 배열은 결국 마지막에 곱하기 위해서만 존재한다.

+

따라서 굳이 저장하지 않고 바로바로 곱해나가면 공간복잡도를 O(1)으로 줄일 수 있다.

+
const answers = [];
+ 
+let last = 1;
+// @NOTE: Step1. Answers 배열에 미리 왼쪽 곱 배열을 세팅
+for (let i = 0; i < nums.length; i++) {
+  const cur = nums[i];
+  answers[i] = last;
+  last *= cur; // 누적값 갱신
+}
+ 
+last = 1;
+// @NOTE: Step2. Answers가 이미 좌측 곱 배열이므로 우측 값을 그대로 곱해주면 정답이 된다.
+for (let i = nums.length - 1; i >= 0; i--) {
+  const cur = nums[i];
+  answers[i] *= last;
+  last *= cur;
+}
+return answers;
+
+

NOTE: 어쨌든 answers 배열을 사용하므로 정확히는 O(n)이지만

+

LeetCode에서 return 배열(answers)이 아닌 추가 배열을 사용하지 않는다는 것으로 O(1)으로 취급하고 있다.

+
+

연속되는 곱셈의 합을 이용한 재밌는 문제였다.

+

그럼 이만~

📮 이 포스트에 관심 있으신가요? 이슈를 남겨주세요! 👍
☕ 소주 한 잔 후원하기
(예금주: 이상철)tosskakao
\ No newline at end of file diff --git a/e2e/__snapshots__/post/dom.spec.ts/desktop/mac-init-apps.html b/e2e/__snapshots__/post/dom.spec.ts/desktop/mac-init-apps.html new file mode 100644 index 00000000..574fdb5b --- /dev/null +++ b/e2e/__snapshots__/post/dom.spec.ts/desktop/mac-init-apps.html @@ -0,0 +1,241 @@ +

웹 개발자를 위한 도구 추천 - 유용한 Mac 앱들

1ilsang
클라이밍 하실래염?
#mac#settings
Published

cover

+

최근 기기 변경을 하면서 맥 세팅을 처음부터 할 일이 있었다. 그때 꽤 고생한 기억이 있어 이번 기회에 유용했던 것들을 한번 정리해 보려고 한다.

+

웹 개발자를 위한 도구 추천 포스트는 3가지 시리즈로 연재 될 예정이다.

+
    +
  1. 유용한 Mac 앱
  2. +
  3. VSCode 익스텐션
  4. +
  5. 크롬 익스텐션
  6. +
+

직접 사용하면서 유용했던 것들을 모아놓았기 때문에 안정성 문제는 없을 것으로 생각된다.

+

모아보기

+ +

Chrome

+

chrome-cover

+

소개 문구에서부터 포스가 장난 아니다. 테스팅 환경 때문이라도 필요한 웹 브라우저 크롬이다.

+

다음에 연재할 크롬 익스텐션 섹션을 통해 크롬이 얼마나 강력한지 후술하고자 한다.

+ +
+

다운로드 링크

+
+

Homebrew

+

homebrew

+

homebrew는 CLI로 편리하게 앱을 설치할 수 있게 해준다.

+

환경변수 및 패키지 폴더 구성 등을 자동으로 해주기 때문에 불쾌한 초기 설정을 벗어나게 해준다.

+

많은 프로젝트에서 homebrew를 통한 설치 가이드를 제공하고 있을 정도로 대중적이니 꼭 설치하자.

+
# 터미널 설치
+$ /bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"
+
+

다운로드 링크

+
+

Oh My Zsh

+

cmd-example

+

brew로 작업하다 보면 터미널이 참 못생겼다고 느낄 수 있다. 터미널을 위와 같이 원하는 정보가 노출되도록 설정할 수 있다.

+

내가 사용하고 있는 테마는 bullet-train 커스텀 테마이다. 해당 테마는 현재 시간 및 작업 소요 시간, 성공 여부, 깃 상태 등 다양한 정보를 노출시켜 주므로 선택하게 되었다.

+
# 터미널 설치
+$ sh -c "$(curl -fsSL https://raw.githubusercontent.com/ohmyzsh/ohmyzsh/master/tools/install.sh)"
+
+

다운로드 링크

+
+

Iterm2

+

iterm2-example

+

기본 맥 터미널은 못생겼기 때문에 iterm2을 설치해 터미널을 더 예쁘게 커스텀 할 수 있다.

+

zsh이 터미널의 내용을 관리한다면 터미널 창 자체로 디자인을 제공해 주는 역할은 iterm2이다.

+
+

만약 커맨드라인에서 cmd + delete로 한줄 삭제 하고 싶다면

+

환경 설정 > Profiles > Keys > Natural Text Editing으로 설정 한다.

+
+

이 외에도 각 탭에서 창 기본 크기, 배경 설정 등 할 수 있다.

+
+

다운로드 링크

+
+

VSCode

+

vscode-logo

+

VSCode에 너무 절여져 있어 다른 에디터는 이제 기억이 나지 않는다... 유용한 익스텐션은 다음 포스트로 연재하겠다.

+
+

다운로드 링크

+
+

NeoVim

+

만약 vi 환경을 좋아한다면 설치하면 좋다. 기본적으로 vim은 한글 입력시 문제가 많다(조합 중인 문자 소실 등).

+

NeoVimvim을 오픈소스화하여 기존의 문제들 해결하고 커뮤니티 자발적으로 다양한 플러그인을 개발/공유하고 있어 강력한 에디팅을 지원한다.

+
$ brew install neovim
+# 기본 vi를 neovim으로 변경하고자 한다면 alias를 변경한다.
+$ vi ~/.zshrc
+$ alias vi="nvim"
+
+

다운로드 링크

+
+

D2 Coding

+

폰트는 d2 코딩이 가장 편하다고 느껴서 늘 사용하고 있다.

+

모호할 수 있는 문자들이 1ijIlO0tz아야저져쁆뼮뼯뗾 기본적으로 잘 보인다(이 블로그 폰트도 D2coding이다).

+

iterm2에 d2coding을 기본 폰트로 적용하기

+
+

Profiles > Text > Font > D2Coding

+

그림으로 보기

+
+

VSCode 기본 폰트로 적용하기

+
+

Setting(cmd + ,) > Font Family > D2Coding을 제일 앞에 적어준다.

+

그림으로 보기

+
+

상당히 개발자 친화적인 폰트라 생각한다.

+
+

다운로드 링크

+
+

Node.js

+

"신"

+
+

다운로드 링크

+
+

NVM

+

프로젝트를 여러개 만들다 보면 노드 버전이 상이한 경우가 종종 생긴다. 이때 노드 버전을 어떻게 처리할까?

+

답은 nvm을 통해 노드 버전을 프로젝트마다 변경하면 된다.

+
# Nvm 다운로드.
+$ curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.7/install.sh | bash
+# 터미널 실행시 자동으로 nvm을 사용하도록 설정.
+$ vi ~/.zshrc
+# 아래 내용을 zshrc 아무곳에 붙여넣는다.
+export NVM_DIR="$([ -z "${XDG_CONFIG_HOME-}" ] && printf %s "${HOME}/.nvm" || printf %s "${XDG_CONFIG_HOME}/nvm")"
+[ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh" # This loads nvm
+# 쉘 반영
+$ source ~/.zshrc
+

사용법은 아래와 같다.

+
# .nvmrc 파일이 존재하면 지정된 노드 버전으로 설정됨.
+$ nvm use
+# Node.js 20.10.0 버전 다운로드.
+$ nvm install 20.10.0
+# Node.js 20.10.0 버전 사용.
+$ nvm use 20.10.0
+
+

가이드 링크

+
+

Docker

+

두 번째 "신"

+
+

다운로드 링크

+
+

GIPHY Capture

+

giphy example

+

간단하게 움짤(gif)을 따야 할 때 유용하게 쓸 수 있다.

+
+

다운로드 링크

+
+

DeepL

+

번역 퀄리티가 상당히 좋다. 또한 cmd + c + c로 빠르게 번역하기도 지원하기 때문에 숏컷 활용도 또한 뛰어나다.

+

이후 다룰 크롬 익스텐션과 함께 사용하면 찰떡이다.

+
+

다운로드 링크

+
+

ScreenHint

+
+ cover + setting +
+

정말 추천하고 싶은 프로그램. 원하는 부분만 스크린샷으로 띄워 놓을 수 있다.

+

맥 어플 전체화면시에도 올라가 있기 때문에 상당히 편리하다.

+
+

다운로드 링크

+
+

Quick Notes

+

quick notes example

+

줌으로 미팅하거나 전체화면으로 열려있는 자료가 많아 잠깐잠깐 메모가 필요할 때 유용한 앱이다.

+

유료라서 꼭 필요한 게 아니라면 크게 추천하고 싶진 않다.

+
+

다운로드 링크

+
+

Calculator Pro

+
+ calculator pro example + setting +
+

맥 환경 특성상 전체화면 된 앱 위에 무엇을 겹치는 것이 불가능하다. 하지만 이 앱은 계산기를 전체화면 된 앱 위에 올려준다.

+

알고리즘 풀 때 진짜 꿀이다.

+

오른쪽 화면은 내가 쓰는 글로벌 숏컷이다. 껐다켰다하며 사용하기 편하다.

+
+

다운로드 링크

+
+

올ㅋ사전

+
+ example + setting +
+

특정 단어를 검색해야 할 때 화면 이동을 하는 건 너무 귀찮다. 단축키로 바로 열어서 찾는 것이 편리하다.

+
+

다운로드 링크

+
+

CodeWhisperer

+

CodeWhisperer example

+

Fig가 공식적으로 AWS에 흡수되면서 출시된 제품이다. 개인 개발에는 무료로 사용할 수 있다.

+

CLI를 자주 사용한다면 정말 유용한 앱이다. 다음 명령어에 대한 힌트뿐만 아니라 해당 명령어의 기대 효과도 같이 알려준다.

+

터미널의 효자 그 자체다.

+
$ brew install --cask codewhisperer
+
+

다운로드 링크

+
+

Flycut

+

flycut example

+

우리는 복/붙을 상당히 많이 한다. 만약 이전에 복사했던 내용을 다시 가져오고 싶다면 어떻게 하고 있는지 생각해 보자.

+

별다른 수가 떠오르지 않는다면 이 앱을 추천한다. 이 앱은 이전에 복사했던 내용들을 기억하고 불러오는 것도 지원해 준다. 심지어 복사된 시간도 알려준다.

+
+

다운로드 링크

+
+

ScreenBrush

+

screen brush example

+

줌과 같은 화상 회의를 할 때나 전체 미팅 때 내 화면을 공유할 일이 많다면 강력히 추천한다.

+

화면에 무엇인가 작성하거나 포인터가 필요할 때 예쁘게 시선을 잡아주는 효자 앱이다.

+
+

다운로드 링크

+
+

Keycastr

+

keycastr example

+

ScreenBrush와 함께 사용하면 빛나는 앱이다. 내가 어떤 키보드를 입력했는지 화면에 보여준다.

+
$ brew install --cask keycastr
+
+

다운로드 링크

+
+

Ngrok

+

ngrok example

+

배포 없이 로컬에서 작업한 나의 페이지를 다른 사람에게 보여주고 싶다면 어떻게 해야 할까?

+

ngrok은 프록시 서버를 열어 내 로컬 포트로 접근하게 해준다. 서버 없이 빠르게 데모 페이지를 공유할 때 유용하다.

+
$ ngrok http 3000 # 3000번 포트로 http 통신을 허용한다.
+# 위의 이미지처럼 https://7421-1-235-243-130.ngrok-free.app URL이 생성(일회용 랜덤)된다.
+# 이후 해당 URL로 접근하면 localhost:3000으로 접속한 것과 같이 된다.
+# 이로써 정적 배포/서버 없이 누구에게나 열린 일회용 퍼블릭 URL을 가지게 되었다!
+

단, 사용하기 위해선 로그인 이후 인증 토큰을 넣어야 한다.

+
+

다운로드 링크

+
+

Hidden bar

+

hidden bar example

+

이쯤 되면 상단바가 상당히 늘어났다는 것을 확인할 수 있다. Hidden bar는 필요한 앱들만 상단바에 노출시켜 주는 앱이다.

+
+

다운로드 링크

+
+

Digital Color Meter

+

example

+

맥 자체 유용한 앱이다. CSS 작업을 하다 보면 스포이드가 필요한 순간이 있는데 유용하게 사용할 수 있다.

📮 이 포스트에 관심 있으신가요? 이슈를 남겨주세요! 👍
☕ 소주 한 잔 후원하기
(예금주: 이상철)tosskakao
\ No newline at end of file diff --git a/e2e/__snapshots__/post/dom.spec.ts/desktop/mdn-ko-organizer.html b/e2e/__snapshots__/post/dom.spec.ts/desktop/mdn-ko-organizer.html new file mode 100644 index 00000000..2fc5fb18 --- /dev/null +++ b/e2e/__snapshots__/post/dom.spec.ts/desktop/mdn-ko-organizer.html @@ -0,0 +1,81 @@ +

@mdn/yari-content-ko Organizer 합류 여정

1ilsang
클라이밍 하실래염?
#mdn#mozilla#open-source
Published

cover

+

mdn local

+

최근 @mdn/yari-content-ko 팀에 합류하게 되었다.

+

오픈소스 프로젝트의 방향성을 계획하고, 리뷰어로 활동하는 것은 처음이었기에 들뜬 마음으로 임할 수 있었다.

+

이 글을 통해 MDN 및 나의 합류 과정을 정리해 보려고 한다.

+

Index

+ +

MDN?

+

mdn readme

+
+

https://github.com/mdn

+
+

MDN은 Readme에 그 목적이 잘 나타나 있다.

+

MDN 웹 문서는 CSS, HTML, JavaScript, Web API를 비롯한 웹 플랫폼 기술을 문서화하는 오픈소스 프로젝트이다.

+
+

image

+

MDN 사이트에 들어가면 방대한 기술 문서를 확인할 수 있다.

+

임무

+

MDN의 임무는 "더 나은 인터넷을 위한 청사진을 제공하고 새로운 세대의 개발자와 콘텐츠 제작자가 이를 구축할 수 있도록 지원하는 것"이라고 되어 있다.

+

해당 임무에 걸맞게 MDN 문서들은 다양한 웹 플랫폼 기술의 올바른 사용법과 해석을 하기 위해 노력하고 있다.

+

역사

+

MDN은 2005년에 시작되어 문서 전체가 오픈소스로 운영되어 누구나 참여할 수 있는 프로젝트이다.

+

초창기에는 모든 문서가 SQL 데이터베이스에 존재하고 WYSIWYG 편집기로 변경했다. 이때는 한국 로케이션이 활성화 안되어 있었기 때문에 번역 문서에 "역자주" 등 주관적 의견이 많이 추가되어 있었다. 이는 긍정적인 면도 있지만 전체적으론 통일성이 부족해지고 각 문서의 품질이 떨어지기도 했다.

+

2020년 기존 문서화 툴을 yari로 변경하면서 git을 통한 체계적인 기여와 통일성 있는 문서로 발전하게 되었다.

+
+

관련 내용: Welcome Yari: MDN Web Docs has a new platform

+
+

이후 2021년 4월 yari-content-ko팀이 창설되면서 한국 로케일도 활성화 되었다. 이때부터 한국 MDN 문서도 체계적인 리뷰 시스템이 존재하게 되었다.

+

합류 여정

+

이제 나의 썰을 조금 풀어보려고 한다.

+

문서화에 대한 관심

+

webpack blog post

+

2021년에 번역 오픈소스 기여 가이드 글을 작성한 적이 있다.

+

본문에서 언급되어 있듯 나는 오픈소스에 기여하고 싶었지만 기술 이전에 영어에 대한 부족함을 많이 느꼈다. 어쩌면 이것이 문서화에 대한 열망으로 표출되었다고 생각한다.

+

당시 내가 재직 중이던 LINE+에서 Webpack 한글화 작업이 진행되고 있었다.

+

운이 좋았다고 생각된다. 팀원들의 기여 과정을 어깨너머로 보면서 하고 싶다고 느꼈고 실제로 조금씩 기여하기 시작했다.

+

이때의 경험이 상당히 좋았기 때문에 이후 React.devMDN에도 조금씩 기여하게 되었다.

+

사내 오픈소스 스프린트 참여

+

사내 DevRel 팀에서 오픈소스 기여 행사를 열었다. 이때 MDN 문서 번역 프로젝트가 있어 참여해 본격적으로 번역 기여를 하기 시작했다.

+

이때 PR을 꽤 열심히 날려서 기여 1등으로 행사를 마무리했다.

+

온보딩 과정 진행

+

행사 이후에도 MDN에 꾸준히 기여하던 중 운이 좋게도 yari-content-ko 팀원 제안 메일을 받게 되었다.

+

내 대답은 당연히 YES였기 때문에 바로 온라인 티타임을 가졌다. 상당히 친절하게 맞아주셔서 감동이었다.

+

onboarding

+

이후 본격적인 리뷰어 온보딩 과정이 시작되었고 오픈소스답게 공개적으로 이슈를 생성해 과정을 전체 공유했다.

+

pr

+

다행히 무사히 과제를 끝낼 수 있었고 본격적으로 리뷰어로 활동하게 되었다.

+

합류 후

+

@mdn/yari-content-ko 팀은 MDN 한국 문서에 대한 전체 권한을 가지고 있다. 기여 PR 리뷰와 유지보수 및 한국 지역 활성화에 대한 고민을 함께 하고 있다.

+

팀에 합류하면서 크게 3가지 달라진 점이 있었다.

+

정기 회의

+

정기 회의를 통해 전체적인 방향성에 대한 싱크를 맞추고 PR 리뷰에 이상이 없는지 등 검증하는 시간을 가졌다.

+

공개 논의

+

public discussion

+
+

https://github.com/orgs/mdn/discussions/655

+
+

문서 번역 리뷰나 프로젝트에 대한 의견 제시 등 공개적인 논의를 함께 이야기하게 되었다.

+

리뷰어 활동

+

reviewer action

+

아마도 가장 크게 달라진 부분이라 생각한다. 컨트리뷰터에서 리뷰어가 되면서 기여해 주신 PR을 검토하고 있다.

+

이 부분이 꽤 까다롭지만 보람을 느끼고 있다. 기여자의 열정이 식지 않도록 빠르고 친절하게 응답하려고 노력하고 있다.

+

이모지의 힘이 크다고 느끼고 있다. 하트 감사합니다.

+

올해 목표

+

CSS Goal

+

팀원이 기여 목표를 세우는 것을 보고 감명받아 나도 세웠다.

+
    +
  1. CSS 한국어 번역 50%까지 올리기
  2. +
  3. 번역 자동화 스크립트 추가
  4. +
+

현재는 번역 리뷰 시 Glossary를 수동으로 확인하고 있다. 이 부분을 자동화하고 CSS 번역을 꾸준히 해보려고 한다.

+

마무리

+

번역은 오픈소스 입문의 좋은 시작점이라 생각한다.

+

그렇기 때문에 리뷰어로서 사명감을 느끼고 있다. 오픈소스를 시작하려는 분들이 꾸준히 기여하고 생태계를 끌어 나갈 인재로 성장할 수 있도록 좋은 경험을 주고 싶다.

+

MDN 문서 번역에 관심이 생겼다면 첫 기여자들을 위한 안내서를 참고해 주시길 바란다.

📮 이 포스트에 관심 있으신가요? 이슈를 남겨주세요! 👍
☕ 소주 한 잔 후원하기
(예금주: 이상철)tosskakao
\ No newline at end of file diff --git a/e2e/__snapshots__/post/dom.spec.ts/desktop/micro-state-management-review.html b/e2e/__snapshots__/post/dom.spec.ts/desktop/micro-state-management-review.html new file mode 100644 index 00000000..91118c97 --- /dev/null +++ b/e2e/__snapshots__/post/dom.spec.ts/desktop/micro-state-management-review.html @@ -0,0 +1,171 @@ +

Micro State Management with React Hooks 리뷰

1ilsang
클라이밍 하실래염?
#book#review#react#hooks#context#zustand#jotai#valtio
Published

cover

+

Micro State Management with React Hooks 리뷰를 해보려고 한다.

+

선택하게 된 계기

+

이 책은 작년에 읽었었는데, 당시에는 이해가 많이 안 돼서 깊이 있게 생각하지 못했었다.

+

실무에서 Jotai를 사용하기 시작하면서 사용하는 라이브러리에 대한 이해를 높이고자 다시금 이 책을 읽게 되었다.

+

이 책은 세 가지 흥미로운 부분이 있다.

+
    +
  1. 원서다. 영어 기술 서적은 처음 읽어서 꽤 도전적이었다.
  2. +
  3. Zustand, Jotai 등을 만든 상태관리에 진심인 메인테이너 Daishi Kato가 직접 펴낸 책이다.
  4. +
  5. React에서의 상태 관리 전략을 다양하게 보여주기 때문에 시야가 넓어진다.
  6. +
+

다 읽고 나서 알게 되었는데, 이 책은 최근에 한국어로 번역되었다(-_-). 이때 아니면 언제 원서 읽었겠나 싶어 만족하려고 한다.

+

간단한 요약

+
    +
  • 상태란 무엇일까?
  • +
  • 상태는 어떻게 존재할 수 있을까?
  • +
  • 상태를 변화시키는 방법은 어떤 것들이 있을까?
  • +
  • React hooks은 상태 관리 라이브러리에 어떤 영향을 주었을까?
  • +
  • 우리는 리렌더링을 어떻게 회피할 수 있을까?
  • +
+

이 책을 읽으면서 얻을 수 있었던 인사이트들이다.

+

너무 어렵게 들어가지 않으면서 상태 관리의 다양한 기법들을 제시하기 때문에 주니어부터 시니어까지 충분히 배울게 많은 책이라는 생각이 든다.

+

인상 깊었던 부분

+

책을 읽으면서 좋았던 예제나 포인트들을 가볍게 소개하고자 한다.

+

1. React State에 대한 이해

+

React에서 상태(state)는 UI를 나타내는 모든 데이터다. React는 상태와 함께 렌더링 할 컴포넌트를 처리한다.

+

책의 서두에서 기존 상태 관리의 문제점에 대해 이야기한다.

+
React Hooks 이전에는 모놀리식 상태 라이브러리들이 유행했다.
+이는 DX 향상에 큰 도움을 주었지만 사용되지 않는 기능들도 포함된다는 문제가 있었다.
+ 
+1. Form 상태는 글로벌 상태와 별도로 다루어져야 하지만, 단일 상태 솔루션에서는 불가능하다.
+2. 서버 캐시 상태는 refetching과 같은 다른 상태들과는 다른 독특한 특징을 가지고 있으나 분리가 불가능하다.
+3. 브라우저 네비게이션 상태는 원본값이 브라우저 측에 기반한다는 성질이 있어 단일 상태 솔루션에 적합하지 않다.
+ 
+이런 문제들을 해결하는 것이 React Hooks의 목표 중 하나이다.
+

위 내용을 요약하면 Hooks 이전의 상태는 상태의 순수함이 결여되어 있었다는 점이 문제라고 꼬집는다.

+

상태는 전역 상태와 지역 상태가 있다. 지역으로 존재해야 할 데이터들이 전역 스토어에 혼재되어 있고 서버/브라우저 상태가 무분별하게 스토어에 들어 있었다.

+

React Hooks로 위의 내용들을 해결하고자 한다는 내용이 인상적이었다.

+

2. useState vs useReducer

+

React 컴포넌트가 상태를 가지고 있으려면 어떻게 해야 할까?

+

일반 변수나 전역 변수로 선언하고 사용할 수도 있다. 하지만 해당 값이 변경되었다고 한들 컴포넌트는 리렌더링 되지 않는다.

+

컴포넌트가 지역 상태의 변경을 감지하고 리렌더링 하려면 useState 혹은 useReducer를 사용해야 한다.

+
+

+1. React는 전역 상태를 제공하지 않는다.

+
+
// CASE 1.
+const [count, setCount] = useState(0);
+setCount(1);
+setCount(1); // Not render. 동일한 값이므로 렌더링 하지 않는다.
+ 
+// CASE 2.
+const [state, setState] = useState({ count: 0 });
+setState({ count: 1 });
+setState({ count: 1 }); // Re-render! 주소 참조는 항상 다른 값이 된다.
+ 
+// CASE 3.
+state.count = 1;
+setState(state); // Not render. state 주소값은 변하지 않았기 때문에 렌더링 하지 않는다.
+ 
+// CASE 4.
+setCount(count + 1);
+setCount(count + 1); // 동일하게 두 번 호출되면 +2가 아닌 +1만 될 수 있다. 이를 해결하기 위해선 함수 업데이트가 필요하다.
+ 
+// CASE 5.
+setCount((prev) => prev + 1); // 함수로 작성하게 될 경우 아무리 빠르게 눌러도 횟수만큼의 업데이트가 될 것을 보장한다. 이는 내부적으로 함수를 연속적으로 호출하기 때문이다.
+ 
+// CASE 6.
+setCount((prev) => prev); // 결과값이 직전과 동일하기 때문에 리렌더링이 일어나지 않는다.
+ 
+/**
+ * init 함수는 useState를 호출하기 전에 실행되지 않는다(lazy initialize).
+ * 이는 컴포넌트가 마운트 될 때 한 번만 호출함을 뜻한다.
+ **/
+const init = () => 0;
+const [count, setCount] = useState(init);
+

useState의 다양한 사용 사례를 들어 리렌더링이 되는 경우를 설명한다. 또한 지연 초기화 경우를 들어 상태 관리의 최적화에 관해 설명한다.

+
const init = (count) => ({ count, text: 'hi' });
+const reducer = (state, action) => {
+	switch(action.type) {
+		case 'INCREMENT':
+			return { ...state, count: state.count + 1 };
+		case 'SET_TEXT':
+			if(!action.text) {
+				return state; // THIS IS Bailout! 리렌더링 되지 않음.
+		return { ... state, text: action.text };
+		default:
+			throw new Error(`unkown action type`);
+    }
+  }
+}
+ 
+const Component = () => {
+  const [state, dispatch] = useReducer(reducer, 10, init);
+	return (
+		<div>{state.count}
+			<button onClick={() => dispatch({ type: 'INCREMENT' })}>Increment</button>
+			<input value={state.text} onChange={(e) => dispatch({ type: 'SET_TEXT', text: e.target.value })} />
+

useReducer 부분에는 useState에서는 불가능한, reducer만의 특별한 기능들을 언급하며 왜 useState는 useReducer로 대체 가능하지만, 역은 안되는지에 대해 설명한다.

+

useState와 다르게 useReducer에서 인라인으로 함수를 선언하면 사이드 이펙트가 발생한다거나(useReducer는 렌더링 단계에서 reducer를 호출하므로) 복잡한 상태 관리를 처리하기 위한 reducer만의 기법들은 좋은 인사이트를 주었다.

+

3. ContextAPI vs Import

+

Context를 활용한 상태와 모듈(import) 상태에 대한 비교는 내가 가지고 있던 전역/지역 상태에 대한 시야를 넓혀주었다.

+
+

모듈 상태는 ESM 스코프에 특정 상수 혹은 변수를 정의하는 것을 의미.

+

export const store = {} 와 같다.

+
+

우리는 전역 상태가 앱 전반에서 접근할 수 있는 상태라고 알고 있다. 그런데 이 "앱 전반"은 "React 외부에서 접근"한다는 것까지 포함해야 한다.

+

ContextAPI로 작성된 전역 상태(root provider)는 React 외부에서 접근이 불가능하다. 하지만 모듈로 작성된 코드는 외부에서 접근이 가능하다.

+

또한 Context는 기본적으로 싱글턴 패턴을 위해 디자인되지 않았다. 여러 프로바이더에서 사용될 수 있으며 여러 서브 트리에서 다양한 상태로 존재할 수 있다. 하지만 모듈 상태는 싱글턴으로 존재한다. 따라서 단일 전역 상태를 위해서는 모듈 상태를 사용해야 한다. 이는 인메모리에 올라간 단일 변수로 취급되기 때문이다.

+

4. Zustand vs Jotai vs Valtio

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
ZustandJotaiValtio
상태의 위치ModuleReact ComponentModule
상태의 형태ImmutableImmutableMutable
상태 변경 전략SelectorContextAPIProxy
재사용성모듈 상태는 재사용이 까다롭다(싱글턴). 재사용을 위해 결국 Context를 쓰게 된다Provider를 통해 쉽게 재사용이 가능하다Zustand와 동일
코드 학습량셀렉터에 대한 이해가 있어야 한다Context 및 Atoms에 대한 이해가 있어야 한다순수 자바스크립트로 이루어져 있어 학습량이 거의 없다
리렌더링 최적화개발자가 셀렉터를 잘 써야 한다 한다개발자가 Atom 단위를 적절하게 활용해야 한다Proxy가 자동으로 해준다
비고셀렉터 최적화, 객체 참조와 메모이제이션에 익숙하다면 편하게 단일 스토어를 사용할 수 있다Context 기반이므로 Jotai에서 가능한 것들은 Context에서도 가능하다. React LifeCycle과 공존하므로 예측 가능하다(Suspense 지원 등)자동 리렌더링 최적화 및 순수 JS 기반이라 편하게 사용 가능하다. 불변성을 위해 코드가 복잡해지지 않아도 된다. 하지만 디버깅 과정이 어렵다
+

저자가 직접 만든 상태 관리 라이브러리들을 비교 하는 부분은 이 책의 하이라이트라고 생각한다.

+

왜 여러 상태 라이브러리를 만들수 밖에 없었는지, 각 라이브러리의 리렌더링 회피 전략과 차이를 설명한다. 또한 공통점도 설명하는데 세 라이브러리 모두 "코드량이 적다".

+

저자가 가장 중요하게 생각하는 포인트라고 생각한다.

+

맺으며

+

기본적으로 React를 어느정도 이해하고 있는 개발자를 대상으로 작성된 책이지만 코드 자체가 어렵진 않아서 초심자도 읽어볼 만하다고 생각한다.

+

책을 읽으면서 상태 관리에 대해 시야가 넓어질 수 있었다.

+

다음 월간 다이브에는 "상태"를 주제로 해보려고 한다. 책을 통해 배운 것들을 잘 풀어보고 싶다.

+

상태 관리의 종류와 기법들에 대해 이해하고 싶다면 추천하고 싶은 책이다.

📮 이 포스트에 관심 있으신가요? 이슈를 남겨주세요! 👍
☕ 소주 한 잔 후원하기
(예금주: 이상철)tosskakao
\ No newline at end of file diff --git a/e2e/__snapshots__/post/dom.spec.ts/desktop/prettier3.html b/e2e/__snapshots__/post/dom.spec.ts/desktop/prettier3.html new file mode 100644 index 00000000..aa8c41b0 --- /dev/null +++ b/e2e/__snapshots__/post/dom.spec.ts/desktop/prettier3.html @@ -0,0 +1,219 @@ +

Prettier v3 변경사항 살펴보기

1ilsang
클라이밍 하실래염?
#prettier#lint
Published

cover

+

Prettier v3.0이 어제 7월 5일에 공개되었다.

+

내용은 읽으면서 흥미로웠던 부분들 위주로 주요 변경점들을 기준으로 간략하게 소개 하려고 한다.

+
+

Prettier 플러그인 개발쪽의 변경사항도 크지만 실제 사용성 위주로 정리를 했다.

+
+

TL;DR!

+
    +
  1. trailingComma 옵션이 all으로 변경되었다.
  2. +
  3. 주석 및 MD 문서의 린팅 편의성이 높아졌다(한국어 린트가 향상되었다)
  4. +
  5. 최소 요구 Node 버전이 14가 되었다.
  6. +
  7. .gitignore가 드디어 기본적으로 무시된다(.prettierignore는 유지).
  8. +
  9. 화살표 함수/타입의 린트가 코드 패턴을 지원.
  10. +
+

Markdown

+

한국어 처리의 향상

+
<!-- Input -->
+ 
+노래를 못해요.
+ 
+<!-- Prettier 2.8 with --prose-wrap always --print-width 9 -->
+ 
+노래를 못
+해요.
+ 
+<!-- Prettier 2.8, subsequent reformat with --prose-wrap always --print-width 80 -->
+ 
+노래를 못 해요.
+ 
+<!-- Prettier 3.0 with --prose-wrap always --print-width 9 -->
+ 
+노래를
+못해요.
+ 
+<!-- Prettier 3.0, subsequent reformat with --prose-wrap always --print-width 80 -->
+ 
+노래를 못해요.
+

한국어는 공백의 위치에 따라 문장의 의미가 변경되는 특성이 있다.

+
    +
  • 노래를 못해요: 나는 노래를 잘 못한다.
  • +
  • 노래를 못 해요: 나는 (어떠한 이유로) 노래를 할 수 없다.
  • +
+

v3에서는 위의 특성을 고려해 단어를 '분해하지' 않는다. 영어와 동일하게 줄바꿈이 일어나게 된다.

+

인라인 코드 여백 유지

+
<!-- Input -->
+ 
+`   foo   bar   baz   `
+ 
+<!-- Prettier 2.8 -->
+ 
+`foo bar baz`
+ 
+<!-- Prettier 3.0 -->
+ 
+`   foo   bar   baz   `
+

다수의 여백이 하나로 줄여지던 문제가 해결되었다.

+

JavaScript / TypeScript

+

trailing-comma

+
// Input
+type Foo = [
+  {
+    from: string;
+    to: string;
+  }, // <- 1
+];
+type Foo = Promise<
+  | { ok: true; bar: string; baz: SomeOtherLongType }
+  | { ok: false; bar: SomeOtherLongType } // <- 2
+>;
+ 
+// Prettier 2.8
+type Foo = [
+  {
+    from: string;
+    to: string;
+  }, // <- 1
+];
+type Foo = Promise<
+  | { ok: true; bar: string; baz: SomeOtherLongType }
+  | { ok: false; bar: SomeOtherLongType } // <- 2
+>;
+ 
+// Prettier 3.0
+type Foo = [
+  {
+    from: string;
+    to: string;
+  }, // <- 1
+];
+type Foo = Promise<
+  | { ok: true; bar: string; baz: SomeOtherLongType }
+  | { ok: false; bar: SomeOtherLongType } // <- 2
+>;
+

타입 매개변수 및 튜플에서도 쉼표가 추가되었다.

+

Decorated function 패턴 지원

+
// Prettier 2.8
+const Counter = decorator('my-counter')((props: {
+  initialCount?: number;
+  label?: string;
+}) => {
+  // ...
+});
+ 
+// Prettier 3.0
+const Counter = decorator('my-counter')((props: {
+  initialCount?: number;
+  label?: string;
+}) => {
+  // ...
+});
+

데코레이터 패턴에서 들여쓰기를 줄이기 위해 화살표 함수의 가독성을 희생하도록 변경되었다.

+

누락된 await 괄호 추가

+
// Input
+async function request(url) {
+  return (
+    // prettier-ignore
+    (await fetch(url)).json()
+  );
+}
+ 
+// Prettier 2.8
+async function request(url) {
+  return (
+    // prettier-ignore
+    await fetch(url).json()
+  );
+}
+ 
+// Prettier 3.0
+async function request(url) {
+  return (
+    // prettier-ignore
+    (await fetch(url)).json()
+  );
+}
+

커링과 화살표 함수간 괄호 일관성 개선

+
// Input
+Y(() => (a ? b : c));
+Y(() => () => (a ? b : c));
+ 
+// Prettier 2.8
+Y(() => (a ? b : c));
+Y(() => () => (a ? b : c));
+ 
+// Prettier 3.0
+Y(() => (a ? b : c));
+Y(() => () => (a ? b : c));
+

Import Attributes를 지원

+
import json from './foo.json' with { type: 'json' };
+import('./foo.json', { with: { type: 'json' } });
+

주석이 있는 경우 유니온 타입의 개행이 유지

+
// Input
+type FooBar =
+  | Number // this documents the first option
+  | void; // this documents the second option
+ 
+// Prettier 2.8
+type FooBar = Number | void; // this documents the first option // this documents the second option
+ 
+// Prettier 3.0
+type FooBar =
+  | Number // this documents the first option
+  | void; // this documents the second option
+

extends 줄바꿈 개선

+
// Input
+export type OuterType2<
+  LongerLongerLongerLongerInnerType extends
+    LongerLongerLongerLongerLongerLongerLongerLongerOtherType,
+> = { a: 1 };
+ 
+// Prettier 2.8
+export type OuterType2<
+  LongerLongerLongerLongerInnerType extends
+    LongerLongerLongerLongerLongerLongerLongerLongerOtherType,
+> = { a: 1 };
+ 
+// Prettier 3.0
+export type OuterType2<
+  LongerLongerLongerLongerInnerType extends
+    LongerLongerLongerLongerLongerLongerLongerLongerOtherType,
+> = { a: 1 };
+

HTML

+

SVG 내부 script 린트 향상

+
<!-- Input -->
+<svg viewBox="0 0 200 200" xmlns="http://www.w3.org/2000/svg">
+  <script>
+    document.addEventListener('DOMContentLoaded', () => {
+      const element = document.getElementById('foo');
+      if (element) {
+        element.fillStyle = 'currentColor';
+      }
+    });
+  </script>
+</svg>
+ 
+<!-- Prettier 2.8 -->
+<svg viewBox="0 0 200 200" xmlns="http://www.w3.org/2000/svg">
+  <script>
+    document.addEventListener( 'DOMContentLoaded', () => { const element =
+    document.getElementById('foo') if (element) { element.fillStyle =
+    'currentColor' } });
+  </script>
+</svg>
+ 
+<!-- Prettier 3.0 -->
+<svg viewBox="0 0 200 200" xmlns="http://www.w3.org/2000/svg">
+  <script>
+    document.addEventListener('DOMContentLoaded', () => {
+      const element = document.getElementById('foo');
+      if (element) {
+        element.fillStyle = 'currentColor';
+      }
+    });
+  </script>
+</svg>
+

Reference

+
📮 이 포스트에 관심 있으신가요? 이슈를 남겨주세요! 👍
☕ 소주 한 잔 후원하기
(예금주: 이상철)tosskakao
\ No newline at end of file diff --git a/e2e/__snapshots__/post/dom.spec.ts/desktop/proving-ground-review.html b/e2e/__snapshots__/post/dom.spec.ts/desktop/proving-ground-review.html new file mode 100644 index 00000000..81a1f2fe --- /dev/null +++ b/e2e/__snapshots__/post/dom.spec.ts/desktop/proving-ground-review.html @@ -0,0 +1,27 @@ +

"사라진 개발자들" 리뷰

1ilsang
클라이밍 하실래염?
#book#review#사라진개발자들#eniac
Published

cover

+

여성 에니악 개발자 6인의 이야기를 중심으로 최초의 컴퓨터 탄생과 프로그래머의 역사를 풀고 있는 "사라진 개발자들"을 리뷰해 보려고 한다. 스포가 어느 정도 있다.

+

선택하게 된 계기

+

기술 서적을 읽다 보면 가끔 전혀 이해할 수 없는 혹은 접해보지 못한 키워드를 만날때가 있다(e.g. 천공 카드). 그때마다 그 단어의 기원을 알아가는 것이 재밌었다.

+

이 책 또한 위의 흥미에서 시작되었다. "전자식 숫자 적분 및 계산기(Electronic Numerical Integrator And Computer; ENIAC, 에니악)"이라니 정보처리기사 공부할 때 말고는 들어본 적도 없다. 거기에 최초의 개발자라니! 선택을 안 할 수가 없었다!

+

간단한 요약

+

미국은 2차 세계대전에서 우위를 점하기 위해 대포의 정확도를 높이고자 무던히 노력했다. 땅의 상태나 온도, 바람의 세기 등 다양한 변수를 고려해 최대한 정확한 사표(射表)를 만들고자 했다. 전시의 상황은 시시각각 변했기 때문에 빠르고 정확한 사표가 필요했고 이를 위해 필라델피아의 탄도 연구소에서는 계산을 위한 컴퓨터(compute-er)들을 고용하기 시작했다. 많은 남성들이 전장으로 갔기 때문에 자연스럽게 컴퓨터들은 수학과를 졸업한 여성들로 채워지게 되었다. 이후 에니악 6인이 될 여성들은 여기서 컴퓨터로써 만나게 된다.

+

한편 진공관의 가능성을 믿은 존과 프레스(에커트)는 에니악을 개발한다. 에니악의 실행을 위해선 배선을 옮기고 각각의 논리를 이어 나가야 했는데 그 과정을 에니악 6인이 진행해 나가게 된다. 여기의 논리들에는 이후 모든 프로그래밍에서 사용되는 if, loop 등이 있었다. 그녀들은 최초의 프로그래머로써 최초의 컴퓨터(당시)와 작업을 진행하게 된다.

+

첫 인상

+

맨 처음 이 책을 선택했을 때 나의 기대는 "에니악"을 중심으로 여성 개발자들의 활약상을 보는 것이었다. 하지만 프롤로그를 읽으면서 책의 흐름은 나의 기대와 다르게 흘러갈 것을 짐작할 수 있었다. 저자는 어떤 여성이 컴퓨팅 분야에 어떤 업적을 남겼는지 관심을 가졌고 그 역사를 찾다 흑백 에니악 사진에서 이름을 알 수 없는 여성들을 알게 되어 그들의 이야기를 중심으로 책을 만들었다.

+

이는 조금 낭패였다. 나는 그들의 삶에는 관심이 없었다. 기술의 기원과 발전에만 흥미가 있었다. 그렇기 때문에 책의 초반 그녀들의 학창 시절과 가족들에 대한 이야기는 꽤 곤혹스러웠다.

+

그런데 그녀들의 이야기 속에서 당시 미국의 분위기를 생생하게 접할 수 있었는데 이는 또 다른 매력이었다. 책의 마지막 부에 이르러서 나는 그녀들의 행방이 궁금해 검색하지 않을 수 없었다.

+

인상 깊었던 부분

+

책의 중간중간에는 당시 분위기를 엿볼 수 있는 문장들이 있다.

+
    +
  • 대공황 시대에 남성에게만 허용한 일자리가 2차 세계대전 동안 여성으로 채워지게 되었다
  • +
  • 전자식 컴퓨터는 불가능하고 불필요하다고 당시 주류 학계는 생각했다
  • +
  • 루즈벨트 대통령은 정기적으로 라디오를 이용해 각 가정에 개인적인 메시지를 전했다
  • +
+

이외에도 인물들의 이야기 속에서 진주만 습격부터 히로시마 원폭, 로스앨러모스 과학자와 폰 노이만 등 올스타들이 등장한다. 이는 상당히 흥미로웠다. 영화 오펜하이머를 최근에 보았기 때문에 에니악이 수소 폭탄 폭파 장치의 계산을 도왔다는 이야기는 텔러를 떠올리기에 충분했다. 임팩트 있는 역사적인 사건들이 중간중간 나올 때마다 에니악이 얼마나 중요한 위치에 있었는지 이해할 수 있었다.

+

에니악 6인의 프로그래밍 여정 또한 흥미롭게 읽을 수 있었다. LOOPIF-THEN 구문을 활용하는 대목이나 ROM(read-only-memory)의 기원, 벤치 테스트 과정, 에니악 병렬 프로그래밍과 분할-정복법, breakpoint 등이 소개되었다. 그녀들이 성공적으로 에니악 로직을 작성하기 위해 얼마나 노력했는지 알 수 있었다.

+

에니악에 대한 이야기도 있었는데, 탄도 연구소에서 90억의 투자를 했다는 점이나 이후 악삭박박의 탄생 여정 및 폰 노이만 형님의 설계까지 흥미롭게 읽을 수 있었다. 직접 프로그래밍에서 프로그래밍 내장식 컴퓨터가 되기까지 대학교에서 잠깐 들었던 내용들이 나오니 상당히 반가웠다.

+

맺으며

+

책의 한 문장을 뽑으라면 역시 나는 최초의 프로그래머를 선언하는 부분을 가져오고 싶다.

+

이 과정에서 프로그래머라는 직업이 탄생했다. 문제를 가진 사람과 컴퓨터를 연결해 문제를 해결하도록 돕는 역할을 하는 사람이 등장한 것이다. 여섯 여성은 현대 컴퓨터 분야 최초의 직업 프로그래머였다. -289p

+

우리는 영웅의 그림자에 가려진 또 다른 영웅들을 발굴하고 기억해야 한다고 느꼈다.

+

흥미롭게 읽은 책이다.

📮 이 포스트에 관심 있으신가요? 이슈를 남겨주세요! 👍
☕ 소주 한 잔 후원하기
(예금주: 이상철)tosskakao
\ No newline at end of file diff --git a/e2e/__snapshots__/post/dom.spec.ts/desktop/quality-of-job-review.html b/e2e/__snapshots__/post/dom.spec.ts/desktop/quality-of-job-review.html new file mode 100644 index 00000000..80fd313b --- /dev/null +++ b/e2e/__snapshots__/post/dom.spec.ts/desktop/quality-of-job-review.html @@ -0,0 +1,108 @@ +

"일의 격"을 읽고

1ilsang
클라이밍 하실래염?
#일의격#book#review
Published

cover

+

최근 일을 어떻게 마주해야 할지, 지속 가능한 회사 생활이 뭘까 고민하던 도중 이 책을 접하게 되었다.

+

나는 '회사에서의 나'를 굳이 일상에서 분리해야 할까? 라는 생각을 평소에 많이 했기 때문에 '즐거운 회사 생활'을 원했고, 회사 생활을 즐기려고 노력했다.

+

앞으로도 이렇게 살아가면 될지? 회사에서 나는 어떤 포지션/페르소나를 가졌는지 의문이 있던 차, 이 책의 다양한 예제와 격언을 통해 나는 어떤 사람인지 조금 더 가시화되었다.

+

하여, 읽으면서 나에게 인상 깊었던 구절을 정리해 보려고 한다.

+

성장하는 나

+

안타를 맞는다는 것은 스트라이크를 던질 수 있다는 의미이다(97p)

+

예제가 너무 인상적이어서 적어두려고 한다.

+
+

심적 불안을 가진 투수가 공을 가운데로 던지지 못한다. 감독은 "스트라이크로 삼진 시키든지 아니면 홈런을 맞아라."고 지시한다. 결국 투수는 홈런을 맞게 되지만 다들 미소짓는다. 그가 홈런을 맞았다는 의미는 이제 그가 볼을 중앙에 던질 수 있음을 의미하기 때문이다.

+
+

난 이 일화가 너무 와닿았고 좋았다. 투수를 훈련시키기 위한 적절한 격언을 할 줄 알며 홈런을 맞아도 여유 있는 감독이 내 주변에는 몇 명이나 있을까? 나는 그러한 감독인가?

+

'즐긴다'는 말의 허상(128p)

+
+

즐겨서는 최고의 결과를 얻을 수 없다. 최고가 되려고 하면 그 과정을 즐길 수 없다.

+
+

즐거움이 삶의 모토인 나에게는 정말 슬픈 문장이었다. 이 문장을 반박하고 싶지만, 적절한 예가 떠오르지 않았다.

+

즐기면서 한다는 것은 정말 허상인가?

+

자신이 전문가라면 더 말해야 한다(147p)

+

나는 이 부분이 상당히 공감되었다.

+

최근 개발자 부트 캠프나 인터넷 강의가 우후죽순 생기면서 회사에서는 엉망인 사람들이 외부 이미지로 강의를 팔아먹는 형태를 규탄하는 글을 봤다.

+

그분들이 잘못한 부분도 어느 정도 있겠지만 정말 전문가라면 자신의 지식을 공유하고 더 대중들 앞으로 나와 그런 분들이 자연스럽게 사라질 수 있도록 해야 한다고 생각한다. 그것이 전문가가 개발자 생태계에 기여하는 방법이라 나는 믿고 있다.

+

뒤에서 상대방을 비난하기만 한다면 무슨 의미가 있을까?

+

당신의 재능이 최고의 재산이다(153p)

+
+

당신의 직업은 당신의 목적이 아니다. 세상에 보탬이 되는 당신의 재능을 찾아라.

+
+
    +
  1. 당신의 재능은 무엇인가? 어떻게 하면 그 재능으로 남을 도울 수 있는가?
  2. +
  3. 무슨 일을 할 때 제일 살아있다는 느낌이 드는가? 그 열정을 누구와 나누고 싶은가?
  4. +
  5. 당신의 가이드와 멘토는 누구인가? 누가 자신이 올바른 길을 가는 데 도움을 주고 지지해 주는가?
  6. +
  7. 당신은 주위 사람이 재능을 발견하고 원하는 것을 성취하도록 어떤 도움을 줄 수 있는가?
  8. +
+

책에 나오는 4가지 질문이다. 개발자들은 비교적 쉽게 1~4번을 채울 수 있을 것 같다.

+

성공하는 조직

+

리더는 체스 플레이어가 아니라 정원사다(164p)

+

동료를 장기 말처럼 움직이는 것(마이크로 매니징)이 아니라 스스로 행동하게 하되 그 방향성을 키워주는 것이 좋은 리더라고 생각한다.

+

상사에게 직언을 어떻게 해야 하나?(187p)

+
+

직언은 상대의 이익을 섞어서 해야 한다.

+
+

위의 문장이 좋아서 추가했다. 아무리 좋은 말이라 하여도 날것으로 전달하면 분명 불편해지는 면이 있다고 생각한다.

+

상대방의 이익을 섞어서 말한다면 훨씬 더 대화가 매끄럽게 진행된다. 말 잘하고 싶다!

+

비효율의 숙달화(224p)

+

분명 비효율적인데 익숙해져서 그대로 하는 관행들이 있다. 운이 좋게도 나는 관행을 타파하는 회사들에 다녔고 비효율을 바로잡고자 하는 사람들의 옆에서 좋은 인사이트를 많이 얻을 수 있었다.

+

그럼에도 나 개인의 프로젝트에서는 비효율의 숙달화가 심한 면들이 있기에 반성하고자 언급한다.

+

좋은 회사란 무엇인가?(225p)

+
+

회사의 가치와 자신이 맞는가?

+
+

이 부분이 정말 중요하다고 생각한다. 나는 취준생일 때 반드시 IT 기업에 가겠다는 강한 신념이 있었다. 개발이 재밌었고 파괴적인 부분을 좋아했다.

+

서로 핏이 맞지 않는다면 그 기간 내내 서로 힘들 뿐이라 생각한다. 따라서 맞지않는 회사에 갈 필요도 있을 이유도 없다고 생각한다.

+

이는 개인의 에너지와도 직결된다고 생각한다. 직원의 무능은 개인의 부족한 면도 있겠지만 회사가 자신과 안 맞을 확률이 크다고 생각한다.

+

유능한 직원을 무능하게 만드는 간단한 방법(233p)

+
+

부하직원을 의심한다. 부하직원은 자존심이 상하고 업무 의욕이 점점 감퇴한다. 그리고 상사를 조금씩 불편하게 대하게 된다. 상사는 이 모습을 보고 더 의심하게 된다. 악순환이 반복된다.

+
+

필패 신드롬의 내용이다. "칭찬은 고래도 춤추게 한다"는 문장을 좋아하는 나에겐 특히 공감되는 문장이다.

+

내가 말하지 않으면 리더도 나를 잘 모른다(235p)

+

이제까지는 리더가 자각해야 할 부분이라면 이 문장은 팀원으로서 자각해야 하는 부분이라 생각해 언급한다.

+

책에서도 나오지만 사실 그 누가 나는 악당이 되겠다 하며 회사 생활을 하겠는가? 커뮤니케이션의 실수가 가장 크다.

+

리더의 덕목~ 뺄셈의 리더쉽 등등 리더를 위한 격언은 많지만, 팀원을 위한 내용은 상대적으로 부족한듯 하다.

+

리더도 분명 사람이기에 그들에게 자신의 상황/성과를 잘 전파하고 명확하게 의견을 이야기하는 것이 중요하다.

+

리더는 직원과 어느 정도 개인적 유대를 맺어야 할까?(246p)

+

여기 예시가 인상적이었는데 길기 때문에 적진 않았다.

+

분명한 건 서로가 서로에게 관심이 있다는 것을 인지할 수 있는 유대는 있어야 한다고 생각한다.

+

상대가 진짜 똑똑한지 허풍인지 구별하는 방법(259p)

+
+

가장 똑똑한 사람들은 끊임없이 자신의 이해를 수정한다. 그들은 이미 해결했던 문제들에 대해서도 다시 고려해본다. 그들은 기존 사고에 대항하는 새로운 관점, 정보, 생각, 모순, 도전 등에 대해 열려있다. 자신의 예전 생각이 잘못되었다면 언제든 바꾼다.

+
+

지속적인 만남을 하다 보면 정말 진국이라고 생각되는 사람이 있다. 그러한 사람들은 위와 같은 부류인 경우가 많았다.

+

그들은 자신의 주관은 분명하게 있지만 늘 새로운 도전에 열려있었고 옳다고 느끼면 주관을 바꿀 줄 아는 사람들이었다. 주관을 바꾸면서 자신만의 철학을 리빌딩 하기 때문에 말을 함에 있어 똑똑하다고 느껴지는 경우가 많았다.

+

말 잘하는 것이 정말 중요하다고 느끼기 때문에 본받고 싶은 스킬이라 생각한다.

+

성숙한 삶

+

과제의 분리(275p)

+
+

그들이 나를 좋아하고 싫어하는 것은 그들의 과제이니 자신과 분리시켜야 한다.

+
+

나는 얼마나 나를 사랑하고 있는가? 나는 남을 얼마나 신경 쓰고 있는가?

+

친구가 과제를 대신해달라고 하면 그렇게 화내면서 왜 남의 과제를 하고 있는 것인가?

+

더 많이 행동하면 더 행복해진다(279p)

+

최근 아침에는 수영을 저녁에는 클라이밍을 하고 있는데 정말 행복하다. 운동뿐만 아니라 무엇인가를 한다는 것은 참 의미 있다고 생각한다.

+

그게 다다(285p)

+
+

실수를 했으면 고치면 되고, 잘못을 하면 꾸중을 듣고, 성과가 안 나오면 교훈 삼아 다음에 잘 하면 되고, 차였으면 다른 사람을 찾으면 된다. 그게 다다.

+
+

정말로. 그게 다다.

+

내가 나를 좌절시키는 것이다(291p)

+

과제의 분리와 이어진다고 생각한다. 높은 자존감은 자기애에서 시작한다.

+

자랑할 것, 자부심을 가질 것이 무엇인가?(307p)

+
+

당신은 무엇에 자부심을 가지고 있는가?

+
+

+

억누르지 말고 관점을 재해석 하라(324p)

+
+

관점의 변화, 즉 재해석이 우리의 행동을 바꾼다.

+
+

여기에 문구들이 주옥같다. 누군가 나의 발을 밟는다면 화가나 뒤돌아볼 것이다. 하지만 그 상대가 맹인이라면? 눈 녹듯 분노가 사라지고 부끄러움이 밀려온다.

+

부정적 감정은 관점의 재해석으로 해결된다.

+

편협한 꼰대는 관점의 고정화로 나타나게 되는 것 아닐까?

+

인간관계와 우연이 삶에 미치는 영향(328p)

+

공정하다는 착각의 내용이 떠오르는 부분이었다.

+

취업할 때도 운7기3이라는 말이 있으니 그만큼 운이 삶에 미치는 영향은 지대하다고 생각한다.

+

그렇지만 운도 준비된 자가 잡을 수 있다는 말이 있듯 행운이 다가올 때 잡을 수 있도록 늘 준비해야 한다고 생각한다.

+

맺으며

+

위 격언들을 한 번씩 되돌아보며 더욱 성장한 나. 성공한 삶을 살아갈 수 있도록 노력해야겠다.

+

그럼 이만

📮 이 포스트에 관심 있으신가요? 이슈를 남겨주세요! 👍
☕ 소주 한 잔 후원하기
(예금주: 이상철)tosskakao
\ No newline at end of file diff --git a/e2e/__snapshots__/post/dom.spec.ts/desktop/renovate.html b/e2e/__snapshots__/post/dom.spec.ts/desktop/renovate.html new file mode 100644 index 00000000..c2e3d781 --- /dev/null +++ b/e2e/__snapshots__/post/dom.spec.ts/desktop/renovate.html @@ -0,0 +1,113 @@ +

Renovate 간단하게 살펴보기

1ilsang
클라이밍 하실래염?
#renovate#packageManager#bot#dependency
Published

cover

+

이번에는 디펜던시를 자동으로 최신화 해주는 Renovate를 소개해보고자 한다.

+

Index

+
    +
  • INTRO
  • +
  • Renovate란?
  • +
  • Renovate의 장점
  • +
  • 적용 방법
  • +
  • 마무리
  • +
+

INTRO

+

repo-alert

+

리포지터리에서 위와 같은 노티를 봤을수도 있다. 혹여나 critical severity가 존재한다면 마음 한켠이 굉장히 불안해지기 시작한다. "그날이 왔구나" 생각하며 일정을 산정해 버전업 계획을 세우게 된다.

+

오래된 버전을 올리는것은 굉장히 고통스러운 작업을 동반한다.

+

노티로 알려주는 패키지에는 "종속성"에 포함되는 패키지도 있기 때문에 복잡하게 얽힌 의존 관계를 한땀한땀 쫓아가며 올려야 하는 패키지들을 수색하는 과정이 필요하다.

+

file-hierarchy

+

만약 minimist 라는 라이브러리의 버전을 올려야 한다고 할 경우, 이 라이브러리를 종속성으로 가지고 있는 "실제로 설치된" 라이브러리를 yarn.lock과 같은 락파일에서 디펜던시 그래프를 찾아 올라가야 한다.

+

위의 예에서는 detective 패키지가 종속성을 가지고 있다. 하지만 detective는 설치한 적이 없기 때문에 package.json에 없다. 따라서 detective 라이브러리를 종속하고 있는 또 다른 라이브러리를 찾아 올라가야 한다.

+

이는 굉장히 고통스러운 과정이다.

+

의존성이 커지기 전에 조금씩 버전을 올렸다면 이런 문제는 없지 않았을까 생각하게 된다. Renovate를 통해 이 문제를 해결해 보자.

+
+

깃헙의 공식 툴인 dependabot 과의 차이점도 향후 작성해 볼 예정이다. 레딧에서 다양한 의견을 볼 수 있다.

+
+

Renovate란?

+

renovate-logo

+

Renovate는 자동으로 디펜던시를 업데이트 해주는 봇이다.

+

리포지터리의 패키지 관리자 파일을 확인하고 업데이트가 필요한 종속성을 발견하면 Pull Request 를 자동으로 해준다.

+

Renovate의 장점

+
    +
  • MIT license / 오픈소스
  • +
  • 간단한 봇 설치 및 유지보수가 필요하지 않다. +
      +
    • 리포지터리의 디펜던시에 renovate가 추가되지 않는다는 큰 장점이 있다(레포와 완전히 별개로 동작).
    • +
    +
  • +
  • 풍부한 json 봇 동작 설정.
  • +
  • PR 자동 생성 + 릴리즈 노트.
  • +
  • monorepo 지원.
  • +
+

pr-example

+

Renovate를 적용하면 PR이 생성된다.

+

PR을 확인해보면 아래와 같은 특징을 찾아볼 수 있다.

+
    +
  1. 캐럿 -> PIN 버전으로 변경되었다. +
      +
    • 버전을 특정하고 이후의 버전은 새로운 PR로 생성된다.
    • +
    +
  2. +
  3. 릴리즈 노트를 제공한다. +
      +
    • 현재 버전과 타겟 버전 사이의 추가 내역을 제공해주기 때문에 로그를 명시적으로 확인할 수 있다.
    • +
    +
  4. +
  5. Compare Source를 제공합니다. +
      +
    • 라이브러리 코드의 어디가 바뀌었는지 정확히 알 수 있다.
    • +
    • 덤으로 메인테이너의 코드 리뷰나 discussion도 눈팅할수 있다.
    • +
    +
  6. +
  7. PR이므로 DroneCI 및 actions와 조합해 2중 검증을 할 수 있다.
  8. +
+

그 외에도 main 브랜치에 다른 브랜치가 merge되어 rebase가 필요할 경우 토글 버튼만으로 처리할수 있다.

+
{
+  "extends": ["config:base"],
+  // PR의 기본 라벨 설정
+  "labels": ["renovate", "translate"],
+  "packageRules": [
+    {
+      // 타입패키지들은 major업데이트가 아닌 이상 자동 merge
+      "packagePatterns": ["^@types/"],
+      "automerge": true,
+      // automerge시 comment를 설정할 수 있다.
+      "automergeType": "pr-comment",
+      "automergeComment": "types: auto merge",
+      "major": {
+        "automerge": false
+      }
+    },
+    {
+      // lint 관련 패키지들은 하나의 PR로 생성하도록 설정
+      "groupName": "lints",
+      "matchPackagePatterns": ["^eslint", "^prettier", "^markdownlint"],
+      "labels": ["lint"]
+    }
+  ]
+}
+

또한 JSON 설정을 통해 auto merge, label, semver 범위 지정, PR 스케줄 설정, 최대 PR 개수 설정(기본 10개) 등의 옵션을 설정할수 있다.

+ +

적용 방법

+

적용 방법은 상당히 간단하다. 봇을 설치해 주면 사실상 끝이다.

+
    +
  1. Renovate app을 설치한다.
  2. +
+

Renovate app 봇을 설치한다.

+

repo-bot

+

그후 설치 페이지로 진입해서 봇을 추가할 리포지터리를 선택한다.

+
    +
  1. Renovate PR을 merge 한다.
  2. +
+

앱을 레포에 등록하면 자동으로 PR이 생성된다. 해당 PR을 머지한다.

+
    +
  1. PR 확인 및 renovate.json 설정
  2. +
+

이제 앞에서 본것과 같이 업데이트가 필요한 라이브러리의 PR이 자동으로 생성된다. 기본값은 10개이기 때문에 renovate.json 값을 수정해 원하는 방식으로 조정할 수 있다.

+

마무리

+

finish

+

그동안 디펜던시는 "기간 잡아서 한방에 처리하자"로 남겨두고 있었다.

+

그렇기 때문에 정말 특정한 이슈가 없는 이상 버전 올릴 생각을 잘 하지 않게 되었고, 그 결과 수많은 Breaking change를 만나며 고생했던 기억이 있다.

+

무엇보다 "사용하고 있는 라이브러리의 최신 근황"에 대해 궁금해 하지 않았던 점도 한몫 했다.

+

이제, Renovate가 제공해주는 지속되는 PR을 통해 놓치지 않고 새로운 버전을 쫓아갈 수 있을거라 생각하고 있다. 또한 changeLog 및 sourceCompare를 통해 각 라이브러리의 근황도 자연스럽게 알게 될거라 기대하고 있다.

+

그럼 이만!

📮 이 포스트에 관심 있으신가요? 이슈를 남겨주세요! 👍
☕ 소주 한 잔 후원하기
(예금주: 이상철)tosskakao
\ No newline at end of file diff --git a/e2e/__snapshots__/post/dom.spec.ts/desktop/storybook7.html b/e2e/__snapshots__/post/dom.spec.ts/desktop/storybook7.html new file mode 100644 index 00000000..f4925ce6 --- /dev/null +++ b/e2e/__snapshots__/post/dom.spec.ts/desktop/storybook7.html @@ -0,0 +1,144 @@ +

Storybook 7.0 살펴보기

1ilsang
클라이밍 하실래염?
#storybook#decorator#const#extends
Published

cover

+

4월 초 Storybook v7이 공식 릴리즈 되었다. 이 포스트에서는 스토리북 블로그에 작성된 7버전의 기능들을 확인해보고 정리해 보고자 한다.

+

TL;DR!

+
    +
  • 사전 번들 제공으로 DX 향상
  • +
  • Webpack4 -> Webpack5
  • +
  • CSF(Component Story Format) v3 업데이트로 인한 스토리 Props 직관성 향상
  • +
  • MDX v2 지원
  • +
  • Vite, NextJS, SvelteKit 지원
  • +
  • 컴포넌트 테스트 지원 향상
  • +
+

사전 번들 제공

+

Storybook v7의 주요 기능중 가장 마음에 드는 부분은 사전 번들 제공이다.

+

기존에 v6을 사용할 때에는 Storybook도 번들링되기 때문에 번들 시간이 상당히 길었다. 하지만 이번 업데이트로 번들링된 파일이 제공되므로 Storybook의 번들 시간이 없어졌고 연계된 에드온 또한 런타임 부담 없이 더 안정적으로 사용할수 있게 되었다.

+

또한 Webpack또한 v4에서 v5로 업데이트 되었기 때문에 번들 속도는 더욱 빨라졌다.

+

compare-speed

+
+

20초 걸리던 매니저 빌드 타임이 사전 번들 덕분에 1초대로 줄었으며 프리뷰 영역도 2초 정도 단축되었다. wow

+
+

CSF v3

+

Component Story Format(CSF)도 상당 부분 변경되었다. 컴포넌트 형식에 맞춰 통일화된 규격을 제공한다.

+
    +
  1. stories 파일의 default export가 변경되었다. 이제 스토리 메타데이터를 정의하는 객체를 리턴한다.
  2. +
  3. stories 정의 방식이 변경되었다. 스토리는 스토리 메타데이터 객체 내부에 정의되어야 한다.
  4. +
  5. stories 템플릿을 제공한다. 템플릿으로 스토리를 정의할수 있기 때문에 재사용성이 향상되었다.
  6. +
  7. stories 이름을 정의하는 방식이 변경되었다. id, title 값이 메타데이터 객체로 들어오게 되었다.
  8. +
+
// v6 {id}.stories.tsx
+export const Pair = Template.bind({});
+Pair.argTypes = {
+  type: {
+    options: ['mobile', 'pc'],
+    control: { type: 'radio' },
+    defaultValue: 'mobile',
+  },
+  slot: {
+    options: ['header', 'toolbar left', 'toolbar right', 'more'],
+    control: { type: 'radio' },
+    defaultValue: 'header',
+  },
+};
+Pair.args = {
+  /* ... */
+};
+Pair.parameter = {
+  /* ... */
+};
+Pair.action = clickPair('toolbar');
+ 
+// v7 {id}.stories.tsx
+export default {
+  title: 'Buttons/color',
+  argTypes: {
+    type: {
+      options: ['mobile', 'pc'],
+      control: { type: 'radio' },
+    },
+    slot: {
+      options: ['header', 'toolbar left', 'toolbar right', 'more'],
+      control: { type: 'radio' },
+    },
+  },
+};
+export const Pair = {
+  name: 'Pair',
+  action: clickPair('toolbar'),
+  render: Template,
+  args: {
+    type: 'mobile',
+    slot: 'header',
+  },
+  parameter: {
+    /* ... */
+  },
+};
+

MDX v2

+

MDX

+
// v6 guide.stories.mdx
+<Meta title="Component/Title/Guide" />
+<Story id="component-title--red-title" />
+ 
+// v7 guide.mdx
+import TitleGuide, {RedTitle} from "./Component/TitleGuide";
+<Meta of={TitleGuide} />
+<Story of={RedTitle} />
+ 
+// ./Component/TitleGuide.stories.mdx
+export const RedTitle = { /* ... */ };
+export default {
+  title:'Component/Title/Guide',
+};
+

v7이 되면서 MDX1에서 MDX2로 업데이트 되었다.

+

기존에는 mdx 파일과 스토리 파일을 ID 스트링으로 연결했었다. v7 부터는 조금 더 코드 친화적으로 컴포넌트와 문서를 이어줄 수 있게 되었다.

+

MDX2는 내장 jsx 및 플러그인을 지원하기 때문에 동적인 문서를 만들기에 더욱 좋아졌다.

+

확장자에 변화도 생겼다. {name}.stories.mdx와 같이 닷(.)으로 이어진 확장자는 인식하지 못한다. {name}.mdx로 파일명 수정이 필요하다.

+
|   name   |       type       |    description     |
+| :------: | :--------------: | :----------------: |
+| videoRef | HTMLVideoElement |   video element    |
+|  event   |    MouseEvent    | click event object |
+

기본적으로 MDX는 GitHub-flavored markdown(GFM)이 꺼져있으므로 위와 같은 테이블 마크다운이 깨질 수 있다.

+

이는 remarkGfm을 설치하여 수정하여야 한다.

+

그 외

+

support

+

설정 수정 없이 Vite, NextJS, SvelteKit을 지원한다.

+

본인은 Webpack에서 Vite로 마이그레이션을 고려하고 있었는데 이번 버전이 좋은 기회가 될꺼라 기대하고 있다.

+

test-coverage

+

스토리북은 이전부터 테스팅 도구로써의 포지션을 견고히 하고자 하는데, 이번 버전에서도 상당부분 업데이트가 되어 있다.

+

v7에는 코드 커버리지 기능이 추가되었다. 테스트 코드의 누락을 조금 더 쉽게 찾을수 있게 되었다.

+

test

+
const meta: Meta<typeof SignupForm> = {
+  title: 'SignupForm',
+  component: SignupForm,
+};
+export default meta;
+type Story = StoryObj<typeof SignupForm>;
+ 
+export const Submitted: Story = {
+  play: async ({ args, canvasElement, step }) => {
+    const canvas = within(canvasElement);
+ 
+    await step('Enter email and password', async () => {
+      await userEvent.type(canvas.getByTestId('email'), 'hi@example.com');
+      await userEvent.type(canvas.getByTestId('password'), 'supersecret');
+    });
+ 
+    await step('Submit form', async () => {
+      await userEvent.click(canvas.getByRole('button'));
+    });
+  },
+};
+

기존의 play 함수에서 추가된 step을 활용해 컴포넌트 테스트의 그룹화가 가능해졌다.

+

테스트 그룹을 통해 해당 테스트를 사람이 이해하기 편해졌다.

+

마무리

+

Storybook v7은 전반적으로 Developer Experience 향상이 눈에 보이므로 스토리북을 지속적으로 사용할 계획이라면 업데이트 하는것이 좋아보인다.

+
    +
  1. 빨라진 빌드 시간
  2. +
  3. 개발자 친화적으로 변화한 CSF, MDX
  4. +
  5. 테스트 그룹화 지원으로 그룹 단위 테스트가 가능해졌다.
  6. +
+

참고

+
📮 이 포스트에 관심 있으신가요? 이슈를 남겨주세요! 👍
☕ 소주 한 잔 후원하기
(예금주: 이상철)tosskakao
\ No newline at end of file diff --git a/e2e/__snapshots__/post/dom.spec.ts/desktop/turborepo.html b/e2e/__snapshots__/post/dom.spec.ts/desktop/turborepo.html new file mode 100644 index 00000000..cee01e1e --- /dev/null +++ b/e2e/__snapshots__/post/dom.spec.ts/desktop/turborepo.html @@ -0,0 +1,3 @@ +

Turborepo로 모노레포 개발 경험 향상하기

1ilsang
클라이밍 하실래염?
#monorepo#turborepo#packageManager
Published

cover

+

라인 엔지니어링 블로그에 작성한 글이다.

+

글쓰면서 정말 많이 배웠던것 같다. 모노레포와 함께할 때 캐싱기능은 너무 경험이 좋았기 때문에 앞으로도 꾸준히 사용해볼 예정이다.

📮 이 포스트에 관심 있으신가요? 이슈를 남겨주세요! 👍
☕ 소주 한 잔 후원하기
(예금주: 이상철)tosskakao
\ No newline at end of file diff --git a/e2e/__snapshots__/post/dom.spec.ts/desktop/typescript-subtyping.html b/e2e/__snapshots__/post/dom.spec.ts/desktop/typescript-subtyping.html new file mode 100644 index 00000000..faa9d579 --- /dev/null +++ b/e2e/__snapshots__/post/dom.spec.ts/desktop/typescript-subtyping.html @@ -0,0 +1,179 @@ +

Object.keys()는 왜 string[] 타입일까?

1ilsang
클라이밍 하실래염?
#typescript#structural-subtyping#object-keys
Published

cover

+

Index

+ +

TL;DR!

+

타입스크립트는 자바스크립트의 덕 타입을 표현하기 위해 구조적 서브 타이핑을 채택하고 있다.

+

Object.keys()는 런타임에서의 안정성을 위해 넓은 타입인 string[] 타입으로 추론된다.

+

문제

+
interface MyObject {
+  first: number;
+  second: string;
+  third: boolean;
+}
+const parseMyObject = (object: MyObject) => {
+  const parsed = { accNum: 0, trueCount: 0 };
+ 
+  Object.keys(object).forEach((key) => {
+    // 🚨 Element implicitly has an 'any' type because expression of type 'string' can't be used to index type 'MyObject'.
+    // No index signature with a parameter of type 'string' was found on type 'MyObject'.(7053)
+    const curValue = object[key];
+    if (!isNaN(curValue)) {
+      parsed.accNum += curValue;
+    } else if (curValue === true) {
+      parsed.trueCount++;
+    }
+  });
+  return parsed;
+};
+

개발을 하다 보면 자연스럽게 객체를 많이 사용하게 된다. 이때 Object.keys 메서드를 사용하면 항상 key 값이 string으로 추론되는 것을 확인할 수 있다.

+
const keyList = Object.keys(obj) as Array<keyof typeof obj>;
+

string으로 타입 추론(Type Inference) 되었기 때문에 이후 코딩의 편의성을 위해 다시 타입 단언(Type Assertion)을 하게 된다.

+

단언을 통한 타입 제어가 마음을 불편하게 하기 때문에 제너릭 타입으로라도 추론하고 싶어 진다.

+
TypeScript/src/lib/es2015.core.d.ts
interface ObjectConstructor {
+  keys(o: {}): string[];
+}
+

하지만 타입스크립트는 Object.keys<T>() 제너릭 타입을 제공하지 않는다.

+

여기서 의문이 생긴다.

+
    +
  • 기존의 key 타입을 왜 추론하지 못할까?
  • +
  • 제너릭 타입은 왜 제공하지 않았을까?
  • +
+

오늘은 타입스크립트를 사용하면서 만나는 미묘한 당혹스러움에 대해 파헤쳐 보고자 한다.

+

구조적 서브 타이핑이란

+

앞에서 다룬 문제의 이유는 타입스크립트가 구조적 서브 타이핑을 기반으로 하고 있기 때문이다.

+

이전에 우아한 타입스크립트에서 구조적 타이핑을 잠깐 이야기한 적이 있었다.

+

duck typing

+
+

Image Source: What is duck typing

+
+

자바스크립트는 덕 타이핑을 기반으로 하는 동적 타이핑 언어이다.

+

따라서 타입스크립트는 자바스크립트의 특성(유연한 동적 타입)을 해치지 않으면서 타입을 강제(정적 타이핑)하기 위한 고민을 하게 된다.

+
type Book = {
+  name: string;
+};
+

위와 같은 객체 타입 Book을 선언하게 되면 일반적인 명목적 타입 시스템에서는 반드시 Book { name: string } 형태의 타입만 와야 한다.

+
const getName = (book: Book) => {
+  return book.name;
+};
+ 
+const book1 = { name: '123' };
+const book2 = { name: '123', model: 'wow' };
+const book3 = { name: '123', model: 'wow', wow: 'line' };
+ 
+getName(book1); // OK
+getName(book2); // OK
+getName(book3); // OK
+

하지만 타입스크립트에서는 위와 같은 모든 형태의 객체가 가능하다. 이것이 바로 구조적 서브 타이핑이다.

+

구조적 타입 시스템의 주요 특성은 값을 할당할 때 정의된 타입에 필요한 속성을 가지고 있다면 호환된다는 것이다.

+

따라서 구조적 타입 시스템에서 타입은 값의 집합으로 생각하면 된다.

+

그렇다면 구조적 서브 타이핑과 Object.keys의 반환 타입에는 어떤 연관이 있는 것일까?

+
class MyObject {
+  // https://stackoverflow.com/questions/49464634/difference-between-object-and-object-in-typescript
+  // object 타입은 원시 타입을 제외한 모든 값이 될 수 있다.
+  keys<T extends object>(o: T): (keyof T)[];
+}
+const keys = MyObject.keys<Book>(book1); // "name"[]
+const keys = MyObject.keys<Book>(book2); // "name"[]
+const keys = MyObject.keys(book3); // ("name" | "model" | "wow")[]
+

자바스크립트의 덕 타입 덕에 객체는 런타임 단계에서 더 많은 속성을 가질 수 있다. 또한 구조적 서브 타이핑은 필요한 속성을 가지고 있다면 확장된 집합과 호환되며 에러를 노출하지 않는다.

+

그렇기 때문에 타입스크립트는 객체 인자에 T 타입의 값만 존재한다는 보장을 할 수 없다.

+
for (const key of Object.keys(book1)) {
+  // 🚨 No index signature with a parameter of type 'string' was found on type 'Book'.(7053)
+  const value = book1[key];
+}
+

따라서 타입스크립트는 런타임에서의 안정성을 찾기 위해 좁은 타입의 (keyof T)[]가 아닌 넓은 타입인 string[]으로 추론한다.

+

관련 논의는 #12253 이슈 코멘트에서 확인할 수 있다.

+
    +
  • 글의 말미에서 구조적 서브 타이핑에 대해 더 다루도록 하겠다.
  • +
+

해결

+

이제 타입스크립트가 Object.keys의 값을 string[]으로 추론하는 이유를 확인했다.

+

이를 타입 단언을 사용하지 않고 추론하려면 어떻게 해야 할까?

+

타입 가드를 통한 타입 좁히기

+
const book: Book = { name: '123' };
+const book3 = { name: '123', model: 'wow', wow: 'line' };
+ 
+// 타입 좁히기
+const isBook = (key: string): key is keyof Book => {
+  return Object.keys(book).includes(key);
+};
+ 
+for (const key of Object.keys(book3)) {
+  // 타입 가드로 타입이 존재하는 컨디션 블록이 생기게 됨
+  if (isBook(key)) {
+    // Book 타입의 키
+  } else {
+    // 구조적 서브 타이핑으로 확장된 키
+  }
+}
+

타입 가드(Type Guards)를 통한 타입 좁히기(Type Narrowing)를 활용하면 타입 단언을 하지 않아도 적절하게 타입을 추론할 수 있게 된다.

+

무엇보다 런타임에서도 안전한 코드로 변화했다.

+

ONE MORE THING

+
type Book = { name: string };
+type Car = { model: string };
+ 
+const BookOrCar = {} as Book | Car;
+// 🚨 Property 'name' does not exist on type 'BookOrCar'.
+// Property 'name' does not exist on type 'Car'.(2339)
+BookOrCar.name;
+// 🚨 Property 'model' does not exist on type 'BookOrCar'.
+// Property 'model' does not exist on type 'Book'.(2339)
+BookOrCar.model;
+ 
+const BookAndCar = {} as Book & Car;
+BookAndCar.name; // string
+BookAndCar.model; // string
+ 
+type A = 'A';
+type B = 'B';
+ 
+type AorB = A | B; // 'A' | 'B'
+type AandB = A & B; // never
+

구조적 서브 타이핑을 조금 더 알아보자.

+

Book 타입과 Car 타입이 유니온 혹은 교차 될 때 타입 추론이 혼란스러운 부분이 있다.

+

BookOrCar{ name: string } 혹은 { model: string } 타입이 되기 때문에 두 값이 공존해야 한다고 느껴진다. 하지만 타입스크립트는 두 값 모두 추론하지 못한다.

+

반대로 교차 타입은 BookAndCar에서는 모든 값을 가지지만 AandB에서는 never 타입이 추론된다.

+

what

+

앞에서 "구조적 타입 시스템에서의 타입은 값의 집합으로 생각하면 된다"고 했다.

+

각 타입을 값의 집합으로 나열해 보자.

+
Book 타입에 충족되는 값의 집합
{ name: "123" };
+{ name: "123", model: "wow" };
+{ name: "123", model: "wow", wow: "line" };
+그 외 `name`이 존재하는 객체
+
Car 타입에 충족되는 값의 집합
{ model: "wow" };
+{ name: "123", model: "wow" };
+{ name: "123", model: "wow", wow: "line" };
+그 외 `model`이 존재하는 객체
+
Book | Car 타입의 모든 값의 집합
{ name: "123" };
+{ name: "123", model: "wow" };
+{ name: "123", model: "wow", wow: "line" };
+그 외 `name`이 존재하는 객체
+{ model: "wow" };
+{ name: "123", model: "wow" };
+{ name: "123", model: "wow", wow: "line" };
+그 외 `model`이 존재하는 객체
+

Book | Car의 경우에 Book 혹은 Car 중 "항상 존재하는 값"이 없는 것을 확인(name 혹은 model이 반드시 있어야 하는 경우가 없음) 할 수 있다.

+
Book & Car 타입의 모든 값의 집합
{ name: "123", model: "wow" };
+{ name: "123", model: "wow", wow: "line" };
+

반면 Book & Car의 경우 "항상 존재하는 값"이 있는 것을 확인할 수 있다.

+

결론

+

따라서 Book | Car에서는 항상 존재하는 값이 없기 때문에 name, model 어느 값도 존재하지 않게 되지만 Book & Car에서는 name, model 모두 항상 존재하기 때문에 두 값 모두 존재하게 된다.

+

마무리

+

타입스크립트를 사용하면서도 언어의 근본적인 철학을 이해하지 못한 상태로 작업한 것 같아 반성하게 되는 계기였다.

+

타입을 사용하면서 지금처럼 당혹스러운 부분이 있었는데 이번 기회에 많이 이해할 수 있었다.

+

참고

+
📮 이 포스트에 관심 있으신가요? 이슈를 남겨주세요! 👍
☕ 소주 한 잔 후원하기
(예금주: 이상철)tosskakao
\ No newline at end of file diff --git a/e2e/__snapshots__/post/dom.spec.ts/desktop/typescript5.html b/e2e/__snapshots__/post/dom.spec.ts/desktop/typescript5.html new file mode 100644 index 00000000..bbe02618 --- /dev/null +++ b/e2e/__snapshots__/post/dom.spec.ts/desktop/typescript5.html @@ -0,0 +1,370 @@ +

TypeScript 5.0 살펴보기

1ilsang
클라이밍 하실래염?
#typescript#decorator#const#extends
Published

cover

+

3월 초 TypeScript v5가 공식 릴리즈 되었다. 이 포스트에서는 MS 블로그에 작성된 5버전의 기능들을 확인해보고 정리해 보고자 한다.

+

목차는 아래와 같이 구성되어 있다.

+ +

Decorators

+

Decorators는 현재(2023/04)기준 Stage 3단계(4단계가 표준 추가)인 ECMAScript 공식 스펙이다. ES2024의 유력한 기능 중 하나이다.

+
class Person {
+  name: string;
+  constructor(name: string) {
+    this.name = name;
+  }
+ 
+  greet() {
+    console.log(`Hello, my name is ${this.name}.`);
+  }
+}
+ 
+const p = new Person('Ron');
+p.greet(); // Hello, my name is Ron.
+

위와 같은 간단한 Person 클래스의 greet 함수를 디버깅 하기 위해 함수 내부 시작과 끝에 console.log를 추가할 경우 데코레이터를 사용하면 편리하게 작업할수 있다.

+
function loggedMethod(headMessage = 'LOG:') {
+  return function actualDecorator(
+    originalMethod: any, // 데코레이터를 사용한 함수
+    context: ClassMethodDecoratorContext, // 데코레이터를 사용하는 컨텍스트 객체의 데이터 및 함수가 있다(private, static 여부, 메서드 이름 등).
+  ) {
+    const methodName = String(context.name);
+ 
+    function replacementMethod(this: any, ...args: any[]) {
+      console.log(`${headMessage} Entering method '${methodName}'.`);
+      const result = originalMethod.call(this, ...args); // 데코레이터를 사용하는 함수가 여기서 실행된다.
+      console.log(`${headMessage} Exiting method '${methodName}'.`);
+      return result; // 데코레이터 체이닝을 위해 존재한다.
+    }
+ 
+    return replacementMethod;
+  };
+}
+ 
+class Person {
+  // ...
+  @loggedMethod('[Name]')
+  greet() {
+    console.log(`Hello, my name is ${this.name}.`);
+  }
+}
+ 
+const p = new Person('Ron');
+p.greet();
+/**
+ * [Name] Entering method 'greet'.
+ * Hello, my name is Ron.
+ * [Name] Exiting method 'greet'.
+ **/
+

이로써 모든 함수에 @loggedMethod만 추가하면 쉽게 정해진 로그 메서드를 사용할수 있다.

+

이 외에도 컨택스트 객체에는 addInitializer라는 유용한 함수가 있다. 이는 생성자의 시작 부분(또는 정적 클래스 자체의 초기화)에 연결할 수 있다.

+

자바스크립트를 사용하면서 this가 다시 바인딩 되지 않도록 아래와 같은 코딩 스타일을 자주 사용한다.

+
class Person {
+  name: string;
+  constructor(name: string) {
+    this.name = name;
+    // 오직 CASE 1. 에만 사용되는 줄
+    this.greet = this.greet.bind(this);
+  }
+ 
+  // CASE 1.
+  greet() {
+    console.log(`Hello, my name is ${this.name}.`);
+  }
+  // CASE 2.
+  // greet: () => {
+  //   console.log(`Hello, my name is ${this.name}.`);
+  // }
+  // CASE 3. 생성자에서 this.greet.bind(this)를 하지 않은 경우
+  // greet() {
+  //   console.log(`Hello, my name is ${this.name}.`);
+  // }
+}
+ 
+const greet = new Person('Ron').greet;
+greet(); // CASE 1,2 는 정상적으로 동작하지만 3은 this가 글로벌로 바뀌기 때문에 name이 undefined 에러가 발생한다.
+

이를 데코레이터로 사용하면 일관된 로직을 추가/변경해 적용할수 있게 된다.

+
function bound(originalMethod: any, context: ClassMethodDecoratorContext) {
+  const methodName = String(context.name);
+  if (context.private) {
+    // private 함수는 bind 하지 않는다는 예제
+    throw new Error(
+      `'bound' cannot decorate private properties like ${methodName}.`,
+    );
+  }
+  context.addInitializer(function (this: any) {
+    // 생성자에서 this를 바인드 하게 된다.
+    this[methodName] = this[methodName].bind(this);
+  });
+}
+ 
+class Person {
+  name: string;
+  constructor(name: string) {
+    this.name = name;
+  }
+ 
+  // It Same: @bound @loggedMethod('[Name]') greet() { ... }
+  @bound
+  @loggedMethod('[Name]')
+  greet() {
+    console.log(`Hello, my name is ${this.name}.`);
+  }
+}
+const greet = new Person('Ron').greet;
+greet(); // It works!
+/**
+ * [Name] Entering method 'greet'.
+ * Hello, my name is Ron.
+ * [Name] Exiting method 'greet'.
+ **/
+

여기서 유의할 점은 데코레이터는 '역순'으로 실행된다는 점이다. 위 예를 보면, @loggedMethodgreet 메서드를 꾸미고, @bound@loggedMethod의 결과를 꾸미게 된다. 데코레이터가 사이드 이펙트를 가지거나 보장된 순서를 원할 경우 유의해야 한다.

+

실험적 레거시 데코레이터와의 차이점

+

기존에 타입스크립트는 실험적 데코레이터를 지원하고 있었으며 --experimentalDecorators 옵션으로 활성화 할수 있었다.

+

실험적 데코레이터와 v5 데코레이터(ECMA)의 차이는 매개변수에 데코레이터를 지정하거나, --emitDecoratorMetadata와 호환되지 않는 등이 있다. 앞으로 데코레이터의 제안에 해당 내용들을 추가해 간격을 좁혀나갈 예정이다.

+
// allowed
+@register
+export default class Foo {
+  // ...
+}
+ 
+// also allowed
+export default
+@register
+class Bar {
+  // ...
+}
+ 
+// error - before *and* after is not allowed
+@before
+@after
+export class Bar {
+  // ...
+}
+

데코레이터를 export 앞에 놓을 수 있게 되면서 다양하게 선언이 가능해졌지만, 양옆으로 놓을수는 없다.

+

데코레이터의 타입을 보장하기 위해서는 상당히 복잡한 타입정의가 될수 있다. 이는 가독성과 상충관계가 있기 때문에 단순하게 유지하라고 조언한다. 데코레이터의 메커니즘에 대해 자세한 내용은 이 글에 정리되어 있다.

+

const Type Parameters

+
type HasNames = { names: readonly string[] };
+// 우리는 아래 함수를 통해 불변 문자열 배열 타입을 얻고자 한다.
+function getNamesExactly<T extends HasNames>(arg: T): T['names'] {
+  return arg.names;
+}
+// The type we wanted:
+//    readonly ["Alice", "Bob", "Eve"]
+// The type we got:
+//    string[]
+const names1 = getNamesExactly({ names: ['Alice', 'Bob', 'Eve'] });
+ 
+// Correctly gets what we wanted:
+//    readonly ["Alice", "Bob", "Eve"]
+const names2 = getNamesExactly({ names: ['Alice', 'Bob', 'Eve'] } as const);
+

객체의 타입을 추론할때 타입스크립트는 일반적인 타입을 선택한다. 따라서 위의 예에서 namesstring[] 타입으로 추론된다.

+

readonly 타입을 반환하게 할 경우 기존까지는 as const 타입 어설션으로 강제화 해주어야 했는데, 이는 상당히 번거롭다.

+
function getNamesExactly<const T extends HasNames>(arg: T): T['names'] {
+  //                       ^^^^^
+  return arg.names;
+}
+// Inferred type: readonly ["Alice", "Bob", "Eve"]
+// Note: Didn't need to write 'as const' here
+const names = getNamesExactly({ names: ['Alice', 'Bob', 'Eve'] });
+

이제 const 타입 파라미터를 사용해 as const 추론이 가능해졌다. 하지만 이는 함수 호출 내에 작성된 객체, 배열, 표현식에만 영향을 미치므로 주소값을 넘기는 인수로는 동작할수 없음을 알아두어야 한다.

+
const inputNames = ['Alice', 'Bob', 'Eve'];
+ 
+// Inferred type: ["Alice", "Bob", "Eve"]
+// readonly 내놔!!!
+const names = getNamesExactly({ names: inputNames });
+

Supporting Multiple Configuration Files in extends

+
// packages/front-end/src/tsconfig.json
+{
+  "extends": ["@tsconfig/strictest/tsconfig.json", "../../../tsconfig.base.json"],
+  "compilerOptions": {
+      "outDir": "../lib",
+      // ...
+  }
+}
+

extends 필드에 배열로 여러개의 config 파일을 지원하게 되었다. 개인적으로 상당히 만족

+

All enums Are Union enums

+
// enum Color는 Red | Orange | Yellow | Green | Blue | Violet의 union 타입이다.
+enum Color {
+  Red,
+  Orange,
+  Yellow,
+  Green,
+  Blue,
+  Violet,
+}
+ 
+// enum 멤버는 참조할 수 있는 자체 유형이 있으므로 값처럼 사용될 수 있다.
+type PrimaryColor = Color.Red | Color.Green | Color.Blue;
+

모든 enum은 union된 enum이다. 타입스크립트가 처음 enum을 도입했을 때만 해도 enum은 상수 집합에 불과했다(number 타입). 하지만 타입스크립트 2.0에서 enum 리터럴 타입(고유한 값; 상수 10, 20 등이 타입이 됨)이 도입되면서 리터럴 타입은 각 enum 멤버에 고유한 타입을 부여하게 된다.

+

각 enum 멤버에 고유한 타입을 부여할 때 발생하는 한 가지 문제는 해당 타입이 멤버의 실제 값과 연관되어 있다는 점이다.

+

예를 들어 아래와 같이 enum 멤버가 함수 호출로 초기화될 수 있는 경우, 값을 계산할수 없으므로 초기화 이전까지는 에러가 발생한다. 또한 enum E의 예시와 같이 const a:E3 | 4의 타입이 아닌 number가 된다. 이는 리터럴 타입의 장점을 사용하지 못하고 기존 상수 집합을 사용하고 있다는 뜻이 된다.

+

이제 타입스크립트 5버전 부터는 각 멤버에 대해 고유한 타입을 생성하여 enum 멤버를 union enum으로 사용할수 있게 되었다. 즉, enum의 모든 멤버를 좁혀서 그 멤버를 타입으로 참조할 수 있게 되었다.

+
enum E {
+  three = 3,
+  four = 4,
+}
+function takeValue(num: E) {}
+ 
+// v4.9.5 =====================================
+const a: E = 55; // It works!
+const b: E.three = 4444; // It works!
+takeValue(6); // It works!
+ 
+enum Color {
+  random = Math.random(),
+  two = 2,
+}
+// Error! Enum type 'Color' has members with initializers that are not literals.(2535)
+const c: Color.random = 5;
+ 
+// v5.0.3 =====================================
+const a: E = 55; // Error! Type '55' is not assignable to type 'E'.(2322)
+const b: E.three = 4444; // Error! Type '4444' is not assignable to type 'E.three'.(2322)
+takeValue(6); // Error! Argument of type '6' is not assignable to parameter of type 'E'.(2345)
+ 
+enum Color {
+  random = Math.random(),
+  two = 2,
+}
+// It works!
+const c: Color.random = 5; // number
+

--moduleResolution bundler

+
{
+  "compilerOptions": {
+    "target": "esnext",
+    "moduleResolution": "bundler"
+  }
+}
+

대부분의 최신 번들러는 Node.js에서 ECMAScript 모듈과 CommonJS 조회 규칙의 융합을 사용한다. 번들러의 작동 방식을 모델링하기 위해 타입스크립트는 이제 새로운 전략인 --moduleResolution 번들러를 도입한다.

+

하이브리드 조회 전략을 구현하는 Vite, esbuild, swc, Webpack, Parcel 등의 최신 번들러를 사용 중이라면 새로운 bundler옵션이 적합하다.

+

Support for export type *

+
// models/vehicles.ts
+export class Spaceship {
+  // ...
+}
+ 
+// It works!
+// models/index.ts
+export type * as vehicles from './vehicles';
+ 
+// main.ts
+import { vehicles } from './models';
+ 
+function takeASpaceship(s: vehicles.Spaceship) {
+  //  ok - `vehicles` only used in a type position
+}
+ 
+function makeASpaceship() {
+  return new vehicles.Spaceship();
+  //         ^^^^^^^^
+  // 'vehicles' cannot be used as a value because it was exported using 'export type'.
+}
+

타입스크립트에서 export type * 문법이 가능해졌다. 이를 통해 타입과 값의 분리가 더 명확해졌다.

+

@overload Support in JSDoc

+
// 기존에는 이와 같이 함수를 계속해서 확장해 나가야 했다.
+// Our overloads:
+function printValue(str: string): void;
+function printValue(num: number, maxFractionDigits?: number): void;
+ 
+// 이제 아래와 같이 @overload 태그를 사용해 오버로드를 선언할 수 있다
+/**
+ * @overload
+ * @param {string} value
+ * @return {void}
+ */
+/**
+ * @overload
+ * @param {number} value
+ * @param {number} [maximumFractionDigits]
+ * @return {void}
+ */
+/**
+ * @param {string | number} value
+ * @param {number} [maximumFractionDigits]
+ */
+function printValue(value, maximumFractionDigits) { ... }
+ 
+// all allowed
+printValue("hello!");
+printValue(123.45);
+printValue(123.45, 2);
+ 
+printValue("hello!", 123); // error!
+

기존에 코드로 표현해야 했던 부분을 jsdoc으로 나누어서 표현(example 등)할수 있기 때문에 DX의 향상에 기대가 된다.

+

Speed, Memory, and Package Size Optimizations

+

size

+

compare v5 to v4.9

+

typescript npm package size

+

지표에서도 눈에 띄일만큼 변경사항이 있으며 원문 블로그 자체에서도 대부분의 코드베이스에서 10~20% 정도 속도 향상을 느낄 수 있다고 자신하고 있기 때문에 모노레포에서 타입 참조 시간을 많이 줄일수 있을 것이라 기대하고 있다.

+

Breaking Changes and Deprecations

+

Runtime Requirements

+

타입스크립트는 이제 ECMAScript 2018을 대상으로 한다. 최소 엔진은 12.20으로 설정되었다.

+

lib.d.ts Changes

+

DOM의 유형이 생성되는 방식이 변경되어 기존 코드에 영향을 미칠 수 있다. 특히 특정 프로퍼티가 숫자에서 숫자 리터럴 타입으로 변환되었으며, 잘라내기, 복사, 붙여넣기 이벤트 처리를 위한 프로퍼티와 메서드가 인터페이스 전반으로 이동되었다.

+

API Breaking Changes

+

TypeScript 5.0에서는 모듈로 전환하고, 불필요한 인터페이스를 제거했으며, 일부 정확성을 개선했다.

+

Forbidden Implicit Coercions in Relational Operators

+
function func(ns: number | string) {
+  return ns * 4; // Error, possible implicit coercion
+}
+function func(ns: number | string) {
+  return ns > 4; // Now also an error. number | string 타입은 비교할수 없다(string은 비교 불가능).
+}
+

TypeScript의 특정 연산은 암시적으로 문자열을 숫자로 강제 변환할 수 있는 코드를 작성할 경우 이미 경고한다. 5.0에서는 관계 연산자(<,>,<=,=>)에도 적용된다.

+
function func(ns: number | string) {
+  return +ns > 4; // OK
+}
+

+ 연산자를 통해 명시적 형변환후 사용하는것은 가능하다.

+

Enum Overhaul

+
enum SomeEvenDigit {
+  Zero = 0,
+  Two = 2,
+  Four = 4,
+}
+ 
+// Now correctly an error
+let m: SomeEvenDigit = 1;
+ 
+// =====
+enum Letters {
+  A = 'a',
+}
+enum Numbers {
+  one = 1,
+  two = Letters.A, // enum의 참조가 있을 경우 number 타입으로 됨
+}
+ 
+// Now correctly an error
+const t: number = Numbers.two;
+const t2: string = Numbers.two; // 5.0 이전에는 여기서 에러가 발생함(-_-)
+

enum을 이해하는 개념 수를 줄이기 위해 위의 두 가지 오류가 추가되었다.

+

마무리

+

decorator 추가 및 enum 명시성 확장, multi extends, jsdoc 등 다양한 편의성이 추가되었기 때문에 기대되는 메이저 업데이트이다.

+

이 글에서 다루지 않은 더 자세한 내용은 아래의 원문에 자세하게 추가되어 있다.

+

참고

+
📮 이 포스트에 관심 있으신가요? 이슈를 남겨주세요! 👍
☕ 소주 한 잔 후원하기
(예금주: 이상철)tosskakao
\ No newline at end of file diff --git a/e2e/__snapshots__/post/dom.spec.ts/desktop/udemy-rust-programming.html b/e2e/__snapshots__/post/dom.spec.ts/desktop/udemy-rust-programming.html new file mode 100644 index 00000000..3128fed5 --- /dev/null +++ b/e2e/__snapshots__/post/dom.spec.ts/desktop/udemy-rust-programming.html @@ -0,0 +1,39 @@ +

러스트 시작! - 유데미 Rust Programming를 수강하며

1ilsang
클라이밍 하실래염?
#udemy#rust
Published

cover

+

글또에서 유데미를 수강할 수 있는 기회를 얻었다. 무엇을 선택할지 고민하다 Rust Programming 핵심 강의를 선택했다.

+

수강 이유

+

작년부터 러스트에 대한 관심이 있었는데 이런저런 핑계로 하지 않았었다.

+

새로운 언어를 배워보고 싶기도 했고 프런트엔드 생태계의 여러 도구가 러스트화 되는 것을 보며 올해는 꼭 러스트 해봐야지!라고 신년 다짐을 세우고 있었는데 마침 수강 기회가 있어 바로 선택하게 되었다.

+

과정

+

수강 이유에서도 밝혔지만, 언어 자체에 흥미가 있었기 때문에 열심히 해보고 싶었다.

+

강의만 있으면 분명 미루고 미루다 안 볼 것 같다는 강한 의심이 있었기 때문에 "선언 효과"로 나에게 강제를 주고 동료를 모아 "상호보완"을 하고 싶었다. 따라서 수강하기 전부터 어떤 식으로 학습할지 계획을 세웠다.

+

나는 두 가지 방식으로 접근했다.

+
    +
  1. 슬랙 채널을 만들고 관리하기
  2. +
  3. 스터디를 모집해 의견 교환하기
  4. +
+

러스또

+

rustto-pin

+

선언 효과의 일환으로 글또 커뮤니티에 #러스또 채널을 만들고 홍보하기 시작했다.

+

스터디 모집뿐만 아니라 앞으로의 포부도 밝히며(..) 선언 효과(다른말로 업보) 강하게 적용했다.

+

31분이 채널에 들어와 주셨고 채널이 죽지 않도록 주기적으로 업데이트하고자 했다.

+

정리 및 공유

+

udemy-summary

+

강의 내용이 초심자에게 적절해서 재밌게 볼 수 있었다. 중간중간 내용을 쭉 정리하고 자바스크립트랑 비교도 해보면서 능동적으로 학습하려고 했다.

+

예제가 많고 라인바이라인으로 설명해 줘서 잘 따라갈 수 있었다. 키워드 하나도 그냥 넘어가는 게 없었던 것이 좋은 포인트였다. 특정 패턴들은 자주 사용되는 코딩 패턴이라고 설명 및 소개해줘서 실제로 어떤 식으로 코딩을 이어나가야 할지 최소한의 길잡이는 해주었다고 생각한다.

+

챕터별 학습 내용뿐만 아니라 중간중간 의문이 든 내용들은 꽃게탕 레포에 정리해 나갔고 #러스또 채널에도 공유하면서 선지자들의 피드백도 기대했다.

+

스터디

+

study

+

나는 스터디에서 시너지가 많이 나는 편인 듯하다. 다른 분들에게 더 도움이 되고 싶어 열심히 공부했고 정말 이해하고 있는 건지 알기 위해 설명해 보려고 노력했다.

+

Box, *로 힙 영역을 넘나든다거나 소유권 등은 JS에는 없던 개념이기 때문에 프로그래밍 시야가 더 넓어질 기회가 되었다.

+

마치며

+

certificate

+

위의 과정을 매주 반복한 덕인지는 모르겠지만 다행히 3주 완성으로 아주 기초적인 문법은 배웠다.

+

Rust 오픈소스에 기여해 보는 것을 목표로 하고 있기 때문에 토이 프로젝트를 만들면서 기본기를 다지고자 한다.

+

지금 생각하고 있는 프로젝트는 모노레포에서 각 디렉터리 구조를 분석해서 차이점이 있는 부분을 찾고 CLI로 수정하는 라이브러리이다.

+ +

가보자고!

+
+

해당 콘텐츠는 유데미로부터 강의 쿠폰을 제공받아 작성되었습니다.

+
📮 이 포스트에 관심 있으신가요? 이슈를 남겨주세요! 👍
☕ 소주 한 잔 후원하기
(예금주: 이상철)tosskakao
\ No newline at end of file diff --git a/e2e/__snapshots__/post/dom.spec.ts/desktop/use-prevent-leave.html b/e2e/__snapshots__/post/dom.spec.ts/desktop/use-prevent-leave.html new file mode 100644 index 00000000..b936b3e3 --- /dev/null +++ b/e2e/__snapshots__/post/dom.spec.ts/desktop/use-prevent-leave.html @@ -0,0 +1,244 @@ +

페이지 이탈시 확인 컨펌창 만들기

1ilsang
클라이밍 하실래염?
#usePreventLeave#beforeunload#popstate#popup
Published

유저가 페이지 이탈시 확인 컴펌을 받는 로직이 필요하게 되었고 이에 대한 고민을 공유해 보려고 한다.

+

페이지 이탈은 아래와 같은 세가지 방법이 있다고 생각한다.

+
    +
  1. 브라우저 닫기
  2. +
  3. 페이지 새로고침
  4. +
  5. 페이지 이동(e.g, 앞/뒤/URL직접입력 등)
  6. +
+

나는 위의 세 가지 경우 모두를 확인하는 컨펌창을 만들어야 했다.

+

모든 경우 beforeunload 이벤트를 통해 막아줄수 있기 때문에 간편하게 작업할수 있을것이라 예상했으나, 실제로 작업해보니 3번 페이지 이동간에 이벤트가 발생하지 않아 애를 먹었다.

+

정확히는 동일한 도메인에서 서브패스가 달라졌을때만 이벤트가 발생하지 않았다(e.g., domain.com/dev -> domain.com/1ilsang). 작업하던 웹앱은 react-router-dom을 사용하는 SPA 였기에 해당 문제가 History API와 연관되어 있다고 생각하고 서치를 시작했고, 예상대로 이벤트의 기대 동작과 실제 동작이 달라서 일어난 일이었다.

+

flow-chart

+
+

자세히 보기

+
+

beforeunload 이벤트는 '페이지'간 이동에서 발생하기 때문에 단일 페이지 환경인 SPA에서는 새로운 페이지를 로딩하지 않았기 때문에 당연하게도 이벤트가 발생하지 않는다.

+

참고로 beforeunload 이벤트가 언제 발생하는지는 위의 브라우저 라이프사이클 이미지를 확인하면 된다.

+
    +
  • f. 다른 페이지로 이동
  • +
  • g. 활성화된 탭 끄기
  • +
  • h. 비활성화된 탭 끄기
  • +
+

위 내용을 문장으로 한번 더 풀자면, beforeunload 이벤트는 브라우저 닫기(g,h), 페이지 이동(f), 새로고침(f - 새로 고침도 동일한 페이지로의 '이동'에 해당한다)시 발생하는 이벤트이다.

+

다만, SPA의 경우 실제로 브라우저가 리렌더링 되는것이 아니므로 라우터 이동시 beforeunload 이벤트가 발생하지 않는다.

+

이 경우 때문에 SPA 환경에서 3번 페이지 이동을 막아주기 위해서는 History API를 직접 수정해 popstate 이벤트로 막아줄수 있다.

+

따라서 먼저 beforeunload 이벤트로 처리하는 방식을 작성한 다음, SPA 환경을 위한 popstate 처리를 써보려고 한다.

+

beforeunload로 페이지 이탈 방지하기

+

prevent

+

beforeunload 이벤트를 통해 페이지 이동을 감지할 경우 브라우저에서 기본 컨펌창을 제공해 주는데, 크롬 기준 컨펌창은 위의 이미지와 같다.

+
const handleBeforeUnload = (event: Event) => {
+  event.preventDefault();
+  event.returnValue = false; // Chrome requires returnValue to be set.
+};
+window.addEventListener('beforeunload', handleBeforeUnload);
+

코드로 작성하면 위와 같다. 이동을 막아줄 path에서 beforeunload 이벤트를 수신하고, event.preventDefault()를 통해 이벤트의 진행을 막아준다. 이를 통해 페이지를 떠나기전 이벤트가 멈추게 된다.

+

이전에는 returnValue로 설정해준 값이 컨펌창에 노출되었지만, 노이즈가 너무 강해(님 진짜 진자 나갈거임? 아 나가지마셈..!! 등의 텍스트) 현재는 브라우저에서 기본 텍스트만 노출하도록 변경되었다.

+

크롬의 경우 returnValue 값이 필요하므로 추가해주어야 브라우저 컨펌창이 노출된다. 또한 크롬의 경우 유저의 명시적 액션이 있어야만 이벤트가 정상적으로 발생한다.

+

위의 내용은 MDN beforeunload_event#compatibility_notes에서 자세하게 확인할수 있다.

+

error

+

beforeunload 이벤트로 작업하다보면 위와같은 에러를 만날수 있는데, 이는 앞서 말한 유저의 명시적 액션(e.g, mousedown)이 없었기 때문에 발생하는 에러이다.

+

이제 이 코드를 리액트로 옮겨보자.

+
const usePreventLeave = (global = false) => {
+  const handleBeforeUnload = (event: Event) => {
+    event.preventDefault();
+    event.returnValue = false; // Chrome requires returnValue to be set.
+  };
+  const onPreventLeave = () => {
+    window.addEventListener('beforeunload', handleBeforeUnload);
+  };
+  const offPreventLeave = () => {
+    window.removeEventListener('beforeunload', handleBeforeUnload);
+  };
+ 
+  // 만약 페이지 전체에 적용할 경우 global을 true로 입력해 window에 적용하면 된다.
+  // 단일 요소(e.g., HTMLInputElement)에 별개로 적용할 경우 on/off PreventLeave 이벤트를 사용한다.
+  useEffect(() => {
+    if (!global) return;
+    window.addEventListener('beforeunload', handleBeforeUnload);
+    return () => {
+      window.removeEventListener('beforeunload', handleBeforeUnload);
+    };
+  }, [global]);
+ 
+  return {
+    onPreventLeave,
+    offPreventLeave,
+  };
+};
+ 
+const MyApp: FunctionComponent = () => {
+  // CASE 1. 페이지 자체에 이벤트 적용(글로벌 적용)
+  // usePreventLeave(true);
+ 
+  // CASE 2. 특정 요소가 변경될 경우 감지후 방지 이벤트 적용(e.g., form)
+  const { onPreventLeave, offPreventLeave } = usePreventLeave();
+  const [changed, setChanged] = useState(false);
+ 
+  const handleInputChange = () => setChanged(true);
+  const handleClearClick = () => setChanged(false);
+ 
+  useEffect(() => {
+    const fn = changed ? onPreventLeave : offPreventLeave;
+    fn();
+    return () => {
+      offPreventLeave();
+    };
+  }, [changed]);
+ 
+  return (
+    <div>
+      <input type="text" onChange={handleInputChange} />
+      <button onClick={handleClearClick}>clear</button>
+      <h1>{changed ? 'changed' : 'none'}</h1>
+    </div>
+  );
+};
+

위의 usePreventLeave 훅에서는 두 가지 방식으로 beforeunload 이벤트를 사용하도록 제공하고 있다. 만약 global 값을 true로 넘겨줄 경우 전역에 beforeunload 이벤트를 설정해 컨펌창이 무조건 노출되도록 하지만(물론 앞서 이야기 했듯 유저의 인터랙션이 먼저 있어야 한다) 특정 분기(input 태그의 변화)에 맞춰 컨펌창을 띄우고 싶을 경우 onPreventLeave 메서드와 offPreventLeave 메서드를 적절하게 사용하여 이벤트를 바인딩 해줄수 있다.

+

SPA에서 페이지 이탈 방지하기

+

기본적으로 SPA는 페이지간 이동이 일어나지 않기 때문에 우리는 history stack의 변경사항을 추적해야한다. 애석하게도 브라우저는 앞/뒤 이동일 때에만 popstate 이벤트가 발생하기 때문에 pushState로 URL을 변경할 경우 이벤트가 발생하지 않아 추적할수 없게 된다.

+

이 때문에 Remix-run에서 만든 history 라이브러리를 사용해 세션 history를 추적해 처리하거나 popstate 이벤트 발생 및 라우터 이동이 있는 컴포넌트 클릭시 컨펌창을 노출하는 작업을 할 수 있다.

+

나는 후자의 길을 선택했는데, 추후 보게 되겠지만 이 경우 페이지에서 라우터 이동이 있는 모든 컴포넌트 클릭에 prevent를 설정해 주어야 하므로 조금 아쉽다.

+
const usePreventLeave = (global = false) => {
+  // 앞의 beforeunload 이벤트 코드는 생략하였다. beforeunload 이벤트 코드도 추가해 주어야 모든 상황에서 대처 가능해진다.
+  const [prevent, setPrevent] = useState(false);
+ 
+  useEffect(() => {
+    if (!global) return;
+ 
+    // 현재 페이지를 push하여 의도적으로 스택을 만든다. 이로써 뒤로가기시 현재 페이지가 다시 노출되며 팝업이 보이게 된다.
+    window.history.pushState(null, "", window.location.href);
+    window.addEventListener("popstate", handlePopstate);
+    return () => {
+      window.removeEventListener("popstate", handlePopstate);
+    };
+  }, [global]);
+ 
+  // 특정 컴포넌트가 변경되었을 때에 이벤트를 적용하고 싶다면 beforeunload와 동일하게 처리해준다.
+  const onPreventLeave = () => {
+    window.history.pushState(null, "", window.location.href);
+    window.addEventListener("popstate", handlePopstate);
+  };
+  const offPreventLeave = () => {
+    window.removeEventListener("popstate", handlePopstate);
+  };
+ 
+  // popstate 이벤트 발생시 팝업 노출을 위해 상태값을 변경한다.
+  const handlePopstate = () => setPrevent(true);
+  const handlePopupClose = () => {
+    window.history.pushState(null, "", window.location.href);
+    setPrevent(false);
+  };
+  const handlePopupLeave = (onLeave: () => void) => {
+    setPrevent(false);
+    onLeave();
+  };
+  // preventLeave 함수를 외부로 return하여 컨펌창을 의도적으로 띄울수 있도록 한다.
+  const preventLeave = (event: MouseEvent<HTMLElement>) => {
+    event.preventDefault();
+    setPrevent(true);
+  };
+ 
+  const PreventPopup: FunctionComponent<{ onLeave: () => void }> = ({
+    onLeave,
+  }) => (
+    <>
+      {/* popstate 이벤트에 따라 prevent 상태값이 변경되면 컨펌창이 노출된다 */}
+      {prevent && (
+        <div>
+          <h1>페이지을 떠나시겠습니까?</h1>
+          <button onClick={handlePopupClose}>아니요</button>
+          <button onClick={() => handlePopupLeave(onLeave)}></button>
+        </div>
+      )}
+    </>
+  );
+ 
+  return { preventLeave, PreventPopup, onPreventLeave, offPreventLeave };
+};
+ 
+const MyApp = () => {
+  // CASE 1. 페이지 자체에 이벤트 적용(글로벌 적용)
+  // const { PreventPopup } = usePreventLeave(true);
+ 
+  // CASE 2. 특정 요소가 변경될 경우 감지후 방지 이벤트 적용(e.g., form)
+  //   - beforeunload 형태와 동일
+  // const { preventLeave, PreventPopup, onPreventLeave, offPreventLeave } =
+  //   usePreventLeave(true);
+ 
+  // CASE 3. 라우터 이동이 있는 컴포넌트 막기
+  const { preventLeave, PreventPopup } = usePreventLeave(true);
+ 
+  return (
+    <div>
+      <input type="text" onChange={handleInputChange} />
+      <PreventPopup onLeave={() => console.log("left!")} />
+      {/* 페이지 이동이 있는 컴포넌트에 preventLeave로 팝업 노출 적용 */}
+      <Link to="/1ilsang" onClick={preventLeave}>
+    </div>
+  );
+};
+

합본 코드

+
const usePreventLeave = (global = false) => {
+  const [prevent, setPrevent] = useState(false);
+ 
+  useEffect(() => {
+    if (!global) return;
+ 
+    window.history.pushState(null, '', window.location.href);
+    window.addEventListener('popstate', handlePopstate);
+    return () => {
+      window.removeEventListener('popstate', handlePopstate);
+    };
+  }, [global]);
+ 
+  const handleBeforeUnload = (event: Event) => {
+    event.preventDefault();
+    event.returnValue = false; // Chrome requires returnValue to be set.
+  };
+  const handlePopstate = () => setPrevent(true);
+  const handlePopupClose = () => {
+    window.history.pushState(null, '', window.location.href);
+    setPrevent(false);
+  };
+  const handlePopupLeave = (onLeave: () => void) => {
+    setPrevent(false);
+    onLeave();
+  };
+  const preventLeave = (event: MouseEvent<HTMLElement>) => {
+    event.preventDefault();
+    setPrevent(true);
+  };
+  const onPreventLeave = () => {
+    window.history.pushState(null, '', window.location.href);
+    window.addEventListener('popstate', handlePopstate);
+    window.addEventListener('beforeunload', handleBeforeUnload);
+  };
+  const offPreventLeave = () => {
+    window.removeEventListener('popstate', handlePopstate);
+    window.removeEventListener('beforeunload', handleBeforeUnload);
+  };
+ 
+  const PreventPopup: FunctionComponent<{ onLeave: () => void }> = ({
+    onLeave,
+  }) => (
+    <>
+      {prevent && (
+        <div>
+          <h1>페이지을 떠나시겠습니까?</h1>
+          <button onClick={handlePopupClose}>아니요</button>
+          <button onClick={() => handlePopupLeave(onLeave)}></button>
+        </div>
+      )}
+    </>
+  );
+ 
+  return { preventLeave, PreventPopup, onPreventLeave, offPreventLeave };
+};
+

이로써 유저 이탈시 컨펌창이 노출되는 것에 대한 최소한의 대응은 할수 있게 되었다.

+

참고

+
📮 이 포스트에 관심 있으신가요? 이슈를 남겨주세요! 👍
☕ 소주 한 잔 후원하기
(예금주: 이상철)tosskakao
\ No newline at end of file diff --git a/e2e/__snapshots__/post/dom.spec.ts/desktop/use-transition.html b/e2e/__snapshots__/post/dom.spec.ts/desktop/use-transition.html new file mode 100644 index 00000000..cfa107b6 --- /dev/null +++ b/e2e/__snapshots__/post/dom.spec.ts/desktop/use-transition.html @@ -0,0 +1,203 @@ +

useTransition 이해하기

1ilsang
클라이밍 하실래염?
#react#hooks#useTransition#throttle#debounce#suspense
Published

image

+

최근 리액트 공식 사이트가 react.dev로 이사하게 되었다. 이에 맞춰 한국 번역 페이지도 새롭게 단장하게 되어 기여자를 모집하고 있었다.

+

평소 리액트 커뮤니티에 기여할 방법을 찾던 중이었기에 useTransition 파트를 지원했고 무사히 번역 PR을 올릴수 있었다.

+
+

번역된 페이지 보기

+
+

번역을 하면서 useTransition에 대해 알게 된 것들을 정리해 보고자 한다.

+

TL;DR!

+

useTransition은 컴포넌트 최상위 수준에서 호출되어 startTransition을 통해 우선순위가 낮은 상태 업데이트(setState)들을 transition이라고 표시한다. 리액트는 UI 렌더링시 우선순위에 따라 업데이트 할 수 있게 된다.

+

목차

+
    +
  1. useTransition이란? +
      +
    • isPending, startTransition 이해하기 +
        +
      • startTransition 유의 사항
      • +
      +
    • +
    • 전체 코드로 이해하기
    • +
    +
  2. +
  3. Suspense와 연계하기
  4. +
  5. 그외 자잘한 팁들 +
      +
    • vs throttle, debounce
    • +
    • startTransition에 전달된 함수는 즉시 실행된다
    • +
    • useDeferredValue
    • +
    +
  6. +
  7. 마무리
  8. +
+

useTransition이란?

+

useTransitionUI를 차단하지 않고 상태를 업데이트 할 수 있는 리액트 훅이다.

+
const [isPending, startTransition] = useTransition();
+

여기서 UI를 차단하지 않고 라는 문구를 유의하기 바란다. useTransition을 통해 React18에 추가된 많은 기능중 하나인 Concurrent rendering(동시성 렌더링)을 적절하게 사용할 수 있다.

+

일반적으로 오래 걸리는 상태 업데이트(setState)가 존재할 경우, 해당 업데이트가 완료된 이후에 렌더링이 일어나기 때문에 그 시간만큼 렌더 트리가 '블락(Block)'된다. 이 때문에 유저는 아무런 동작을 할 수 없는 상태에 빠지게 되므로 UX에 좋지 않은 영향을 준다.

+

useTransition은 컴포넌트 최상위 수준에서 호출되어 startTransition을 통해 우선순위가 낮은 상태 업데이트들을 transition이라고 표시해 리액트가 UI 렌더링시 우선순위에 따라 업데이트 할 수 있도록 한다. 이로써 렌더링이 오래 걸리는 컴포넌트의 블락을 피할수 있게 된다.

+

transition으로 표시된 상태 업데이트(A라 호칭)는 다른 일반적인 상태 업데이트(B)가 호출될때 중단되고 B의 상태 업데이트가 완료된 다음 다시 A를 렌더링 시작한다. 이를 통해 특정 컴포넌트의 렌더링이 오래 걸리더라도 다른 우선순위 높은 상태의 변경을 통해 User Interaction을 블로킹하지 않고 자연스럽게 동작할 수 있도록 한다.

+

isPending, startTransition 이해하기

+
const TabButton = ({ children, onClick }) => {
+  const [isPending, startTransition] = useTransition();
+  const [tab, setTab] = useState('about');
+ 
+  if (isPending) {
+    return <b className="pending">{children}</b>;
+  }
+  function selectTab(nextTab) {
+    startTransition(() => {
+      // NOTE: async 함수는 들어오면 안된다.
+      setTab(nextTab);
+    });
+  }
+  // ...아래에서 풀 코드로 설명
+};
+

useTransition은 두 개의 항목이 있는 배열을 반환한다.

+
    +
  1. isPending 플래그는 대기 중인 transition이 있는지 알려준다.
  2. +
  3. startTransition 함수는 상태 업데이트(setState)를 transition으로 표시 해주는 함수다.
  4. +
+

startTransition 유의 사항

+
    +
  1. 동기 함수여야 한다.
  2. +
  3. transition으로 표시된 setState는 다른 setState 업데이트시 중단된다. +
      +
    • 다른 상태 업데이트가 있을 경우 그것을 먼저 처리한다는 뜻
    • +
    +
  4. +
  5. 텍스트 입력을 제어하는 데 사용할 수 없다.
  6. +
+

전체 코드로 이해하기

+

전체 코드로 이해해 보자. 중간중간 주석을 통해 동작을 설명하고자 한다.

+

코드 샌드박스는 공식 문서에서 잘 제공해 주므로 직접 실행해 비교해 보면 좋다.

+
const App = () => {
+  const [tab, setTab] = useState('about');
+ 
+  return (
+    <>
+      {/* 탭을 클릭하면 렌더링할 탭 컴포넌트가 설정된다 */}
+      <TabButton isActive={tab === 'about'} onClick={() => setTab('about')}>
+        About
+      </TabButton>
+      <TabButton isActive={tab === 'posts'} onClick={() => setTab('posts')}>
+        Posts (slow)
+      </TabButton>
+      <TabButton isActive={tab === 'contact'} onClick={() => setTab('contact')}>
+        Contact
+      </TabButton>
+      <hr />
+      {/* 현재 탭에 따라 탭 컴포넌트가 렌더링 된다 */}
+      {tab === 'about' && <AboutTab />}
+      {tab === 'posts' && <PostsTab />}
+      {tab === 'contact' && <ContactTab />}
+    </>
+  );
+};
+ 
+const TabButton = ({ children, isActive, onClick }) => {
+  const [isPending, startTransition] = useTransition();
+ 
+  // 현재 탭이 활성화 되면 isActive 상태가 된다.
+  if (isActive) {
+    return <b>{children}</b>;
+  }
+  // 대기 중인 transition이 있다면 isPending이 된다.
+  if (isPending) {
+    return <b className="pending">{children}</b>;
+  }
+  /**
+   * props로 받은 onClick 함수를 startTransition으로 감싸주기 때문에
+   * onClick 함수(setTab)은 transition으로 설정되어 렌더링시 우선순위에서 밀리게 된다.
+   * 그 결과 오랜시간이 걸리는 PostsTab 컴포넌트를 렌더링 하는 도중 다른 탭을 누르게 되면
+   * PostsTab 컴포넌트의 렌더링을 멈추고 다른 컴포넌트를 렌더링하게 된다.
+   **/
+  const handleButtonClick = () => {
+    startTransition(() => {
+      onClick();
+    });
+  };
+  return <button onClick={handleButtonClick}>{children}</button>;
+};
+ 
+const AboutTab = () => {
+  return <p>Welcome to my profile!</p>;
+};
+const PostsTab = () => {
+  const startTime = performance.now();
+  while (performance.now() - startTime < 1) {
+    // 1 ms 동안 아무것도 하지 않음으로써 매우 느린 코드를 실행한다.
+  }
+  return <p>PostsTab</p>;
+};
+const ContactTab = () => {
+  return <p>ContactTab</p>;
+};
+const ContactTab = () => {
+  return <p>ContactTab</p>;
+};
+

Suspense와 연계하기

+
const App = () => {
+  return (
+    <Suspense fallback={<Spinner />}>
+      {/*
+        위의 App 코드와 동일
+       */}
+    </Suspense>
+  );
+};
+

useTransition의 startTransitionSuspense와 함께 사용할 경우 불필요한 로딩 인디케이터 노출을 막을수 있다.

+

일반적으로 렌더링이 오래 걸리는 컴포넌트를 Suspense로 감쌀 경우 해당 컴포넌트가 렌더링 될때마다 Suspense의 fallback 컴포넌트를 만나게 된다. 해당 서스펜스 트리 하위의 렌더링을 중단할 수 없기 때문에 오래 걸리는 렌더링을 막을 방법이 없다. 해당 렌더링이 종료될 때까지 폴백 컴포넌트를 마주해야만 한다.

+

이때 오래 걸리는 상태 업데이트를 startTransition로 감싸게 될 경우 transition 표시가 되면서 "긴급하지 않은" 상태 업데이트로 간주된다. 이로 인해 리액트는 Suspense를 통해 컨텐츠를 숨기지 않고 이전 컨텐츠를 계속 표시하게 된다.

+

이는 아래와 같은 장점이 있다.

+
    +
  1. transition은 중단할 수 있으므로 리렌더링까지 기다릴 필요가 없다. +
      +
    • Suspense의 경우 하위 컴포넌트가 모두 렌더링 될때까지 fallback을 노출시킨다.
    • +
    +
  2. +
  3. transition은 서스펜스 폴백을 방지(대기하지 않으므로)하므로 갑작스러운 로딩 인디케이터 노출을 피할수 있다.
  4. +
+

그외 자잘한 팁들

+

vs throttle, debounce

+

디바운싱과 스로틀로 이벤트의 지연 및 제한은 가능하지만 UI 블로킹의 근본적인 문제는 해결할 수 없다.

+

아무리 이벤트 실행 시점/횟수를 줄인다 하여도 한번 실행이 되는 순간 블로킹이 되는건 여전하기 때문이다.

+

근본적인 원인을 해결하기 위해선 이벤트의 우선순위를 나누어 유저 인터렉션이 일어났을 때 해당 이벤트를 우선적으로 처리해 화면이 멈춘것 처럼 보이지 않게 해야한다.

+

startTransition에 전달된 함수는 즉시 실행된다

+
console.log(1);
+startTransition(() => {
+  console.log(2);
+  setPage('/about');
+});
+console.log(3);
+ 
+// 1, 2, 3
+

startTransition의 콜백 함수는 즉시 실행된다. 함수가 실행되는 동안 예약된 모든 상태 업데이트는 transition으로 표시된다.

+
// React 작동 방식의 간소화된 버전
+let isInsideTransition = false;
+ 
+function startTransition(scope) {
+  isInsideTransition = true;
+  scope();
+  isInsideTransition = false;
+}
+ 
+function setState() {
+  if (isInsideTransition) {
+    // ... transition state 업데이트 예약 ...
+  } else {
+    // ... 긴급 state 업데이트 예약 ...
+  }
+}
+

transition으로 처리된 경우 transitionState로 예약(큐잉)되고 아닌 경우 일반적인 state 업데이트로 예약된다.

+

예약된 작업들은 React18의 fiber 엔진(자체적인 스케줄러를 가지고 있다)이 적절하게 스케줄링 해준다.

+

useDeferredValue

+

useDeferredValue도 useTransition과 유사하게 낮은 우선순위를 지정하기 위한 훅이다. useTransition은 함수 실행의 우선순위를 지정하는 반면, useDeferredValue는 값의 업데이트 우선순위를 지정한다.

+

마무리

+

위의 내용을 한번더 정리하며 글을 마무리 하려고 한다.

+
    +
  1. useTransition은 컴포넌트 최상위 수준에서 호출되어 startTransition을 통해 우선순위가 낮은 상태 업데이트(setState)들을 transition이라고 표시한다. 리액트는 UI 렌더링시 우선순위에 따라 업데이트 할 수 있게 된다.
  2. +
  3. startTransition 함수는 동기 함수여야 한다.
  4. +
  5. transition 표시된 setState는 다른 setState 업데이트시 중단된다.
  6. +
  7. transition 표시된 상태 업데이트는 Suspense로 컨텐츠를 숨기지 않고 이전 컨텐츠를 계속 표시한다.
  8. +
  9. fiber 엔진을 통해 transition된 상태와 다른 상태의 스케줄링이 가능해졌다.
  10. +
📮 이 포스트에 관심 있으신가요? 이슈를 남겨주세요! 👍
☕ 소주 한 잔 후원하기
(예금주: 이상철)tosskakao
\ No newline at end of file diff --git a/e2e/__snapshots__/post/dom.spec.ts/desktop/visual-regression-test.html b/e2e/__snapshots__/post/dom.spec.ts/desktop/visual-regression-test.html new file mode 100644 index 00000000..d503f284 --- /dev/null +++ b/e2e/__snapshots__/post/dom.spec.ts/desktop/visual-regression-test.html @@ -0,0 +1,381 @@ +

시각적 회귀 테스트 도입기

1ilsang
클라이밍 하실래염?
#test#visual-regression-test#playwright#snapshot
Published

cover

+
+

Image Source: We're Building a Visual Regression Testing Library for React Native

+
+

블로그에 항상 테스트를 도입해야겠다 생각하고 있었는데 이번에 적용하게 되어 도입 배경과 트러블 슈팅 과정을 포스트로 남겨보고자 한다.

+

Index

+ +

TL;DR!

+

Playwright 및 Github Actions로 시각적 회귀 테스트 및 CI/CD를 적용한다.

+
    +
  1. 시각적 회귀 테스트로 UI 변경 사항을 배포전에 알아차린다
  2. +
  3. 빠르게 실패하고 실패한 부분만 재실행하자
  4. +
  5. 로컬 테스트와 CI Test의 통합은 어렵다
  6. +
+

도입 배경

+

test pyramid

+
+

Image Source: 사용자 인터페이스 테스트 통합 테스트 및 단위 테스트로 테스트 피라미드

+
+

이전부터 블로그에 테스트 코드가 없는 것이 꽤나 찝찝했기 때문에 어떤 방식/도구로 테스트를 적용할까 고민하고 있었다.

+

블로그의 특성상 한번 배포된 콘텐츠는 크게 바뀔 일이 없기 때문에 정적 UI 테스트를 도입하기에 적절하다고 생각했고 마침 꽤나 긴 연휴가 있었기 때문에 각 잡고 정적 UI 테스트를 도입하고자 마음먹게 되었다.

+

빌드된 결과물을 바탕으로 테스트할 예정이었기 때문에 아래의 두 가지 방식의 테스트를 고려했다.

+
    +
  1. DOM Snapshot
  2. +
  3. Screen Snapshot
  4. +
+

먼저 DOM 스냅샷 비교를 통해 이후 작업에서 기존 DOM 구조를 변경하는지 확인한다. 하지만 DOM 스냅샷은 CSS의 변경 여부를 알아차리기 어렵다는 단점이 있다.

+

따라서 시각적 회귀 테스트인 Screen 스냅샷 비교로 정상적인 렌더링이 되었는지 확인한다.

+

시각적 회귀 테스트란

+

failed visual regression test

+
+

(좌) 차이가 생긴 렌더링 결과물. (우) 차이가 생긴 부분 히트맵

+
+

시각적 회귀 테스트(Visual Regression Test)는 코드 변경 전후의 렌더링 된 UI의 스크린샷을 비교하는 테스트이다.

+

위의 좌측 이미지를 확인해 보면 더 명확하게 알 수 있다. 모종의 이유로 하위 이미지 크기가 달라졌고 이에 따라 이후의 시각적 구조가 변경 되었다.

+

우측 이미지는 Diff 이미지로, 차이가 생긴 영역에 붉게 표시를 해놓았다.

+

이로써 우리는 컴포넌트가 실제로 어떻게 렌더링 되었는지 정확하게 알 수 있게 된다.

+

Playwright

+

playwright

+

어떤 방식의 테스트를 할지 결정되었으니 자연스럽게 어떤 도구로 테스트를 작성할지 고민하게 되었다.

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
AspectPlaywrightCypress
Browser 지원Chrome, Firefox, WebkitChrome, Firefox, Electron
병렬 실행무료유료
멀티탭(다중 브라우저)가능불가능
성능Headless Event-driven socket 방식으로 빠름실제 브라우저에서 실행하므로 상대적으로 느림
+
+

더 자세한 내용은 Cypress vs Playwright: A Detailed Comparison 참고

+
+

꾸준히 Cypress를 사용해 왔지만 병렬 처리에 상당히 답답함을 느끼고 있었기 때문에 이번 기회에 Playwright에 도전하고자 결정했다.

+

물론 Sorry-Cypress로 병렬처리를 할 수 있지만 셀프 호스팅부터 신경 써야 하는 부분이 하나 더 생기기 때문에 기술부채가 싫은 나로서는 선택지에 해당되지 않았다.

+

image (6)

+

무엇보다 성능 부분에 차이가 있다. 테스트 결과를 하루종일 기다렸는데 심지어 실패했다? 한 줄 고치고 다시 하루종일 기다려야 한다.

+

정말 하기 싫어진다.

+

Playwright는 브라우저와 HTTP request 통신 대신 WebSocket으로 Dev tools에 바로 연결한다. 따라서 브라우저의 큰 메모리나 부가적인 리소스가 필요하지 않기 때문에 실제 브라우저와 통신하는 Cypress에 비해 가볍고 빠르다.

+

그렇다면 Playwright로 어떻게 기존의 목적, DOM snapshot과 Screen snapshot을 할 수 있는지 살펴보자.

+

DOM Snapshot

+
import { expect, test } from '@playwright/test';
+ 
+test('should match DOM snapshot', async ({ page }) => {
+  await page.goto('/about');
+  const body = await page.locator('#__next').innerHTML();
+  expect(body).toMatchSnapshot([`about.html`]);
+});
+

나는 Next.js를 사용하고 있으므로 __next 하위의 DOM만 비교하고자 한다.

+

innerHTML 메서드를 통해 DOM 구조를 가져온 다음 playwright에서 제공하는 toMatchSnapshot 메서드로 DOM 스냅샷을 비교할 수 있다.

+

Screenshot

+
import { expect, test } from '@playwright/test';
+ 
+test('should match Screenshot', async ({ page }) => {
+  await page.goto('/about');
+  await expect(page).toHaveScreenshot({ fullPage: true });
+});
+

스크린샷 또한 playwright에서 제공하는 toHaveScreenshot 메서드로 쉽게 적용할 수 있다.

+

나는 전체 화면의 비교를 할 것이므로 fullPage를 설정했다.

+

시각적 회귀 테스트는 다양한 이유로 실패할 수 있다

+
    +
  1. 테스트가 실행되는 OS에 따라 화면이 달라지기 때문에 실패한다(이모지 등)
  2. +
  3. 동일한 OS라도 버전/브라우저에 따라 화면이 달라질 수 있다
  4. +
  5. 실행된 머신의 타임존에 따라 Date 값이 달라져 실패할 수 있다
  6. +
  7. Image와 같은 Resource 로딩 시점에 따라 페이지가 달라질 수 있다
  8. +
  9. Animation 혹은 setTimeout과 같은 시간에 종속된 동작은 일관성을 보장할 수 없다
  10. +
  11. 눈에 큰 차이가 안 나더라도 실패할 수 있다(1px 차이로 실패 등)
  12. +
+

위의 내용들은 일반적인 E2E 테스트에서도 발생할 수 있는 실패 케이스들이다. 일부 케이스는 밑의 트러블 슈팅에서 다루겠다.

+

이처럼 다양한 사이드 이펙트가 존재하기 때문에 동적인 컴포넌트가 많거나 화면이 자주 바뀐다면 도입 전에 ROI를 따져보는 것이 좋다.

+

기본적인 설정은 되었으므로 CI/CD를 구축하자.

+

Github Actions

+

github actions

+
+

Image Source: CI/CD with GitHub Actions: Step-by-Step Workflow

+
+

Github Actions를 통해 CI/CD를 간편하게 구축할 수 있다.

+
.github/workflow/playwright.yml
name: Playwright Tests
+on:
+  push:
+    branches: [main]
+  pull_request:
+    branches: [main]
+jobs:
+  test:
+    timeout-minutes: 60
+    runs-on: ubuntu-latest
+    steps:
+      - uses: actions/checkout@v4
+      - uses: actions/setup-node@v4
+        with:
+          node-version-file: .nvmrc
+      - name: Install dependencies
+        run: npm ci
+      - name: Install Playwright Browsers
+        run: npx playwright install --with-deps
+      - name: Run Playwright tests
+        run: npx playwright test
+      # artifact에 playwright report를 업로드해 어디서 실패했는지 확인할 수 있다
+      - uses: actions/upload-artifact@v4
+        if: failure()
+        with:
+          name: playwright-report
+          path: playwright-report/
+          retention-days: 30
+

Actions result

+

성능 개선

+

위의 CI/CD는 3가지 문제가 있다.

+
    +
  1. 테스트 속도가 느리고 flow를 한눈에 확인하기 어렵다.
  2. +
  3. 캐싱이 전혀 되고 있지 않다.
  4. +
  5. 테스트가 실패하면 다시 처음부터 실행해야 한다.
  6. +
+

이것을 개선해보고자 한다.

+

테스트 방식

+

action log

+

테스트의 어디까지 성공했는지, 어떤 테스트를 실행 중인지 등의 작업 상황을 보기 위해선 현재는 로그를 확인해야 한다.

+

이러한 문제의 근본적인 이유는 특정 기능 단위의 테스트만 실행시키는 방법이 존재하지 않기 때문이다.

+
package.json
{
+  // ...
+  "e2e:others": "pnpm playwright test --grep-invert /@/",
+  "e2e:dom": "pnpm playwright test --grep '@dom-snapshot'",
+  "e2e:screen": "pnpm playwright test --grep '@screen-snapshot'"
+}
+

따라서 playwright에서 제공하는 grep 명령어를 활용해 원하는 기능별로 테스트를 적용할 수 있다.

+

DOM 스냅샷은 @dom-snapshot 키워드를, Screen 스냅샷은 @screen-snapshot 키워드를 가지고 있어야 한다. 그 이외의 테스트는 others로 실행된다.

+
e2e/about.spec.ts
export enum MACRO_SUITE {
+  DOM_SNAPSHOT = '@dom-snapshot',
+  SCREEN_SNAPSHOT = '@screen-snapshot',
+}
+ 
+test.describe('about', () => {
+  test(MACRO_SUITE.SCREEN_SNAPSHOT, async ({ page }) => {
+    await screenshotFullPage({ page, url: `/about`, arg: [`about.png`] });
+  });
+ 
+  test(MACRO_SUITE.DOM_SNAPSHOT, async ({ page }) => {
+    await gotoUrl({ page, url: '/about' });
+    const body = await page.locator('#__next').innerHTML();
+    expect(body).toMatchSnapshot([`about.html`]);
+  });
+ 
+  test('should redirect 404', async ({ page }) => {
+    await gotoUrl({ page, url: '/something_wrong_path', timeout: 60_000 });
+    await expect(page.getByText(/404 ERROR/)).toBeVisible();
+  });
+});
+

이제 우리는 dom, screen, others 세 가지 테스트 피처를 가지게 되었다. 이것은 후술할 CI/CD에서 큰 역할을 하게 된다.

+

테스트 속도

+
test.describe(MACRO_SUITE.SCREEN_SNAPSHOT, () => {
+  for (let i = 0; i < urls.length; i++) {
+    const url = urls[i];
+ 
+    test(`${url}`, async ({ page }) => {
+      await page.goto(`/posts/${url}`);
+      await page.waitForTimeout(3000);
+      await expect(page).toHaveScreenshot({ fullPage: true });
+    });
+  }
+});
+

테스트 속도에는 많은 것들의 영향이 있겠지만 기본적으로 wait timeout이 가장 좋지 않다.

+

특히 위와 같이 반복문으로 작업을 하게 될 경우 N의 배수로 시간이 증가하게 된다.

+

이미지 로딩까지 3초의 텀을 두고자 한 위의 코드는 이미지가 빨리 로딩되었다면 불필요한 기다림이 발생하고 이미지가 3초보다 늦게 로딩되면 깨지는 불안정한 코드다.

+
// Image 로딩 wait
+const locators = page.locator('img');
+const scrollPromises = (await locators.all()).map(async (locator) => {
+  // https://playwright.dev/docs/api/class-locator#locator-scroll-into-view-if-needed
+  // 이미지 요소가 준비되었는지 확인
+  return await locator.scrollIntoViewIfNeeded();
+});
+await Promise.all(scrollPromises);
+const imgLoadingPromises = (await locators.all()).map((locator) =>
+  locator.evaluate<any, HTMLImageElement>(
+    // 이미지 요소의 로딩 상태 확인
+    (image) => image.complete || new Promise((f) => (image.onload = f)),
+  ),
+);
+await Promise.all(imgLoadingPromises);
+ 
+// Font wait
+await page.evaluate(() => document.fonts.ready);
+

이처럼 유동적인 사이드 이펙트는 이벤트로 처리하면 보다 안정적으로 처리할 수 있다.

+

CI/CD

+

앞서 언급한 세 가지 문제

+
    +
  1. 테스트 속도가 느리고 flow를 한눈에 확인하기 어렵다.
  2. +
  3. 캐싱이 전혀 되고 있지 않다.
  4. +
  5. 테스트가 실패하면 다시 처음부터 실행해야 한다.
  6. +
+

이것은 workflow와 actions를 적절하게 나눠주고 actions/cache를 활용하면 된다.

+
workflows
jobs:
+  others:
+    uses: './.github/workflows/e2e-reusable.yml'
+    with:
+      others: true
+ 
+  dom-snapshot:
+    uses: './.github/workflows/e2e-reusable.yml'
+    with:
+      dom-snapshot: true
+ 
+  screen-snapshot:
+    uses: './.github/workflows/e2e-reusable.yml'
+    with:
+      screen-snapshot: true
+

workflow job

+

앞에서 나눈 테스트 피처 단위로 workflows의 job을 나눠주고 workflow_call을 적절하게 사용한다면 편리하고 가독성 좋은 Flow를 만들 수 있다.

+

failed flow

+

무엇보다 job을 나누게 되면 실패한 부분만 재실행할 수 있기 때문에 더욱 유연한 테스트를 할 수 있게 된다.

+
actions
# playwright 설치 캐시
+- name: Cache Playwright Browsers for Playwright's Version
+  uses: actions/cache@v4
+  with:
+    # https://playwright.dev/docs/browsers#managing-browser-binaries
+    path: ~/Library/Caches/ms-playwright
+    key: ${{ runner.os }}-playwright-${{ steps.playwright-version.outputs.PLAYWRIGHT_VERSION }}
+  id: cache-playwright-browsers
+ 
+- name: Setup Playwright
+  shell: bash
+  if: steps.cache-playwright-browsers.outputs.cache-hit != 'true'
+  run: pnpm e2e:install
+ 
+# pnpm 설치 캐시
+- name: Setup pnpm cache
+  uses: actions/cache@v4
+  with:
+    path: ${{ env.STORE_PATH }}
+    key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
+    restore-keys: |
+      ${{ runner.os }}-pnpm-store-
+ 
+# Next.js Build and Export 캐시
+- name: Restore Next.js related caches
+  uses: actions/cache@v4
+  with:
+    path: |
+      ${{ github.workspace }}/.next
+      ${{ github.workspace }}/out
+    key: ${{ runner.os }}-nextjs-store-${{ hashFiles('**/pnpm-lock.yaml') }}-${{ hashFiles('**.[jt]s', '**.[jt]sx', '**.md') }}-${{ inputs.e2e == 'true' && 'e2e' || 'default' }}
+    restore-keys: |
+      ${{ runner.os }}-nextjs-store-${{ hashFiles('**/pnpm-lock.yaml') }}-
+  id: cache-nextjs-build
+ 
+- name: Build and Export [default]
+  shell: bash
+  if: steps.cache-nextjs-build.outputs.cache-hit != 'true'
+  run: pnpm e2e:build
+

Job을 분리하면 불필요한 반복 빌드 작업이 발생하게 되는데 이를 캐싱을 통해 시간을 단축시킬 수 있다.

+

특히 잘 변경되지 않는 정적 블로그의 경우 pnpm, .next, out, playwright를 캐싱해 두면 전체 테스트 시간을 아낄 수 있게 된다.

+

compare time

+

이로써 절반이상 시간을 줄이고 실패에 더 유연한 CI 테스트를 할 수 있게 되었다.

+

완성된 전체 코드는 깃헙에서 확인할 수 있다. .githube2e를 확인하면 된다.

+

트러블 슈팅

+

로컬 테스트를 포기해야 할까

+

팀 단위의 협업에선 로컬 머신 버전을 강제하기 어렵기 때문에 로컬 테스트와 CI 테스트의 동기화가 어렵다.

+

따라서 도커를 활용하든가 CI 테스트만 사용하든가 양자택일로 흐르게 된다.

+

하지만 지금 나의 플로우와 같이 1인 개발이라면 로컬과 CI 테스트를 어느 정도 맞춰줄 수 있다.

+

Macos runner version

+
+

Runner 전체 목록 확인

+
+
jobs:
+  my-job:
+    runs-on: macos-latest
+

Github에서 제공해주는 Actions Runner에 MacOS가 존재하기 때문에 로컬과 버전을 맞춰줄 수 있다.

+

완벽하다고 장담은 못하겠지만 현재까지는 로컬과 CI 테스트가 모두 동일하게 동작하며 통과하고 있다.

+

Timezone

+

CI 테스트에서 가장 많이 실패하는 부분은 Timezone이다. 우리는 +9의 값을 가지고 있기 때문에 스냅샷 테스트에서 반드시 실패한다.

+
jobs:
+  my-job:
+    runs-on: macos-latest
+    env:
+      TZ: Asia/Seoul
+

깃헙 액션에서는 env로 타임존 값을 넘길수 있다. 이를 통해 편리하게 머신의 타임존을 변경할 수 있다.

+

playwright에서 어떤 브라우저를 선택하느냐에 따라 타임존 기준점이 조금 달라진다.

+
    +
  • 크롬의 경우 기본적으로 머신의 타임존을 따른다.
  • +
  • Webkit은 config에 설정한 타임존을 따른다.
  • +
+

만약 playwright에서 webkit을 사용하고 있다면 아래와 같이 playwright.config.ts를 변경해야 한다.

+
playwright.config.ts
export default defineConfig({
+  // ...
+  use: {
+    browserName: 'webkit',
+    timezoneId: 'Asia/Seoul',
+  },
+});
+

테스트 분기

+

코드에 테스트로 인한 분기점이 생기는 것을 원하지 않지만 어쩔 수 없는 경우(혹은 편의로) 빌드를 나누어 코드에 적용할 수 있다.

+
package.json
{
+  "deploy-blog": "next build && next export",
+  "e2e:build": "NEXT_PUBLIC_CI=true next build && next export"
+}
+

e2e를 위한 빌드 스크립트를 만든 다음 환경변수를 주입해 코드에서 적용할 수 있다.

+
useEffect(() => {
+  if (process.env.NEXT_PUBLIC_CI) return;
+

1px

+

1px bug

+

크롬에서는 스크린샷이 1px 다른 경우가 있다(#18827).

+

이때는 clip으로 고정하거나 height를 강제하는 방법으로 처리할 수 있다.

+

Image load

+

로드와 관련된 트러블 슈팅은 테스트 속도에서 다루었다.

+

마무리

+

시각적 회귀 테스트를 통해 심신의 안정을 많이 찾을 수 있었다.

+

이제 더욱 과감하게 리팩터링을 진행할 수 있게 되었다.

+

특히 playwright를 사용하며 경험이 좋았기 때문에 앞으로도 꾸준히 사용해 보고자 한다.

+

이 글을 쓰며 참고했던 혹은 유용했던 링크를 남기며 글을 마무리하려고 한다.

+
📮 이 포스트에 관심 있으신가요? 이슈를 남겨주세요! 👍
☕ 소주 한 잔 후원하기
(예금주: 이상철)tosskakao
\ No newline at end of file diff --git a/e2e/__snapshots__/post/dom.spec.ts/desktop/vite-dev-server.html b/e2e/__snapshots__/post/dom.spec.ts/desktop/vite-dev-server.html new file mode 100644 index 00000000..7374d042 --- /dev/null +++ b/e2e/__snapshots__/post/dom.spec.ts/desktop/vite-dev-server.html @@ -0,0 +1,485 @@ +

Vite Dev Server 이해하기 (feat. HMR)

1ilsang
클라이밍 하실래염?
#vite#dev-server#hmr#preact#prefresh
Published

cover

+

요즘 Vite의 매력에 푹 빠져있다. 그러던 도중 "개발 서버는 어떻게 동작하는 걸까?" 의문을 가지게 되었다. 따라서 오늘은 Vite Dev Server의 동작 방식을 이해하고 HMR 과정을 파헤쳐 보려고 한다.

+

Index

+ +

TL;DR!

+

dev-server-logic-summary

+
+

한 짤로 보는 Dev Server의 동작 방식

+
+

이 글은 핵심 로직에 해당하는 노란색 박스를 위주로 설명하려고 한다. 위의 도식도를 쫓아오며 글을 읽는다면 도움이 될 것으로 생각한다.

+

이 글은 Vite v5.0.12 버전을 기준으로 작성되었다.

+

Let's Dive!

+

1. 개발 서버 실행(서버 초기화)

+

init-server-phase

+
+

최초 서버 실행 이후의 상태

+
+
vite/packages/vite/src/node/server/index.ts
// https://github.com/vitejs/vite/blob/v5.0.12/packages/vite/src/node/server/index.ts#L385
+// server/index.ts
+export async function _createServer(
+  inlineConfig: InlineConfig = {},
+  options: { ws: boolean },
+): Promise<ViteDevServer> {
+  // connect를 사용해 express와 같은 미들웨어 구조를 가진다.
+  const middlewares = connect() as Connect.Server
+ 
+  // HTTP 서버와 웹 소켓 서버를 생성한다.
+  const httpServer = await resolveHttpServer(serverConfig, middlewares, httpsOptions)
+  const ws = createWebSocketServer(httpServer, config, httpsOptions)
+ 
+  // 파일 변경 감지를 위해 chokidar를 설정한다.
+  const watcher = chokidar.watch((...) as FSWatcher)
+ 
+  // 의존성 관계를 추적할 수 있는 모듈 그래프를 만든다. HMR 및 트리쉐이킹 같은 최적화 작업을 위해 존재한다.
+  // 서버 초기화 단계에서는 그래프가 비어있다.
+  const moduleGraph: ModuleGraph = new ModuleGraph(...)
+ 
+  // Rollup의 플러그인 컨테이너를 활용해 플러그인 구성을 만든다.
+  const container = await createPluginContainer(config, moduleGraph, watcher)
+ 
+  // ...
+}
+

yarn vite 등의 커맨드로 Dev Server를 실행시키면 bin/vite.jscli.js호출된다. 이후 src/node/cli.ts호출되면서 Dev Server가 실행된다.

+

Dev Server는 아래와 같은 프로세스를 거치며 초기화를 진행한다.

+
    +
  1. +

    Dev Server가 실행되면 HTTP 서버와 웹 소켓 서버가 실행된다.

    +
      +
    • 미들웨어는 Express에서 사용되는 connect로 연결된다.
    • +
    +
  2. +
  3. +

    파일 시스템 옵저버를 설정한다.

    +
      +
    • 파일 변경 감지를 위해 chokidar를 사용한다.
    • +
    +
  4. +
  5. +

    모듈 그래프를 생성한다.

    + +
  6. +
  7. +

    플러그인 컨테이너를 생성한다.

    +
      +
    • Dev Server에 필요한 Built-in(내장된) 플러그인이 추가된다. +
        +
      • importAnalysis, css, optimizer, json 등이 있다.
      • +
      +
    • +
    • 사용자가 추가한 플러그인(vite.config.ts > plugins)이 추가된다.
    • +
    • 플러그인은 이후 Dev Server의 특정 시점마다 훅을 실행시켜 미들웨어 역할을 하게 된다.
    • +
    +
  8. +
  9. +

    클라이언트의 요청을 기다린다.

    +
  10. +
+

2. index.html 요청

+

index.html-request-phase

+
// server/index.ts
+middlewares.use(indexHtmlMiddleware(root, server))
+ 
+// https://github.com/vitejs/vite/blob/v5.0.12/packages/vite/src/node/server/middlewares/indexHtml.ts#L438
+// HTML 파일을 처리하고 변환한다. 스크립트 태그 주입 및 HMR 클라이언트 코드 삽입, 모듈 경로 변환 등의 작업을 한다.
+html = await server.transformIndexHtml(url, html, req.originalUrl)
+ 
+// transform
+export function createDevHtmlTransformFn(...) {
+  // 필요한 플러그인의 실행 시점에 따라 분류한다.
+  const [preHooks, normalHooks, postHooks] = resolveHtmlTransforms(...)
+  return (...) => {
+    // html에 반영한다.
+    return applyHtmlTransforms(
+      html,
+      [
+        preImportMapHook(config),
+        ...preHooks,
+        // ...
+      ],
+      { ... },
+    )
+  }
+}
+

최초 유저의 요청(GET /)이 발생하면 index.html이 리턴된다. 이 과정에서 transform과 같은 플러그인 훅을 거치며 필요한 데이터들을 세팅한다.

+
    +
  1. +

    미들웨어에서 transform 함수가 실행된다.

    +
      +
    • 플러그인 컨테이너의 플러그인들이 실행 된다. +
        +
      • 플러그인들은 실행 시점(pre, normal, post)에 맞춰 훅이 실행된다.
      • +
      +
    • +
    +
  2. +
  3. +

    의존성 사전 번들링

    +
      +
    • node_modules에 있는 의존성은 ESM이 아닐 수 있다. Vite는 이들을 사전 번들링하여 브라우저가 이해할 수 있는 ESM 형태로 변환한다. +
        +
      • 이 과정은 esbuild로 실행되어 빠르게 처리된다.
      • +
      +
    • +
    +
  4. +
+

transpile-ts-to-js

+
+

hmr.ts의 response에 타입이 사라진 모습.

+
+
    +
  1. +

    코드 변환

    +
      +
    • TS 혹은 JSX 파일의 경우 JS로 변환된다. +
        +
      • 위의 그림과 같이 파일명 자체는 변경되지 않지만, 코드는 js로 변경된다.
      • +
      +
    • +
    +
  2. +
+
// 만약 index.html에서 해당 파일을 import 한다고 가정해 보자.
+// playground/hmr/hmr.ts
+import { foo as depFoo, nestedFoo } from './hmrDep'
+import './importing-updated'
+import './invalidation/parent'
+ 
+// hmr.ts 파일에 구성된 모듈 의존성 그래프
+ModuleNode {
+  url: '/hmr.ts',
+  file: '/User/user/VSCode/vite/playground/hmr/hmr.ts',
+  type: 'js',
+  // 클라이언트 측에서 사용되는 모듈들, 즉 브라우저에서 실행되는 모듈들의 목록을 추적하는 데 사용된다.
+  clientImportModules: Set(10) {
+    // 재귀적 구조
+    ModuleNode: {
+      url: '/hmrDep.js' // hmr.ts 내부에서 import 되는 hmrDep 이 추가된 모습.
+      file: '/User/user/VSCode/vite/playground/hmr/hmrDep.js',
+      clientImportModules: Set(10) {
+        ModuleNode: { ... }
+    }.
+    ModuleNode: {
+      url: '/importing-updated/index.js', // hmr.ts 내부에서 import 되는 importing-updated가 추가된 모습.
+      file: '/User/user/VSCode/vite/playground/hmr/importing-updated/index.js',
+      // ...
+    }
+  // ...
+
    +
  1. +

    모듈 의존성 그래프 생성

    +
      +
    • 위 코드를 보면 hmr.ts에서 import 되는 ./hmrDep, ./importing-updated 등이 ModuleNode에 설정되는 것을 알 수 있다.
    • +
    • 만약 외부 의존성이 있다면 chokidar에 추가된다.
    • +
    • 파일 시스템에 변경사항이 있을 때 모듈 그래프로 빠르게 전파시킨다.
    • +
    +
  2. +
  3. +

    Dev Server의 기능에 필요한 사전 코드들(@vite/client 등)을 응답 자원에 추가한다.

    +
  4. +
  5. +

    변환된 html 파일을 리턴한다.

    +
  6. +
+

3. index.html 렌더링과 자원 요청

+

index.html-rendering-phase

+
+

3 ~ 4. 브라우저 렌더링 및 정적 자원 요청 상황.

+
+

init-html

+

browser-initiator

+
+

브라우저는 위에서부터 아래로 해석해 나가므로 @vite/client, global.css, hmr.ts가 순차적으로 요청되는 것을 볼 수 있다.

+
+
    +
  1. +

    브라우저는 응답받은 html 파일을 렌더링 하기 시작한다.

    +
  2. +
  3. +

    렌더링에 필요한 자원(js, css 등)을 다시 Dev Server에 요청한다.

    +
      +
    • html의 최상단 /@vite/client을 시작점으로 global.css, hmr.ts 등이 요청된다.
    • +
    +
  4. +
+
// server/index.ts
+// main transform middleware
+middlewares.use(transformMiddleware(server))
+ 
+// https://github.com/vitejs/vite/blob/v5.0.12/packages/vite/src/node/server/middlewares/transform.ts#L175
+export function transformMiddleware(...) {
+  // resolve, load and transform using the plugin container
+  const result = await transformRequest(url, server, {
+    html: req.headers.accept?.includes('text/html'),
+  })
+  if (result) {
+    // transform된 코드, 소스코드를 캐시 설정해 리턴한다.
+    return send(req, res, result.code, type, {
+      etag: result.etag,
+      cacheControl: isDep ? 'max-age=31536000,immutable' : 'no-cache',
+      headers: server.config.server.headers,
+      map: result.map,
+    })
+  }
+}
+
    +
  1. +

    transform 적용

    +
      +
    • 각 요청에 대해 Dev Server는 transformMiddleware에서 2번 html 요청과 비슷한 과정으로 transform 이후 응답한다. +
        +
      • 이때 public 폴더 내의 요청인지 외부 자원 요청인지 등의 분류 작업 또한 미들웨어에서 진행한다.
      • +
      • @fs prefix는 vite 프로젝트의 루트(config 위치)를 벗어날 경우 설정된다(모노레포 혹은 파일 시스템 직접 접근 등의 경우).
      • +
      +
    • +
    • HMR 코드 적용 + +
    • +
    +
  2. +
  3. +

    변환된 자원을 브라우저에 응답(response)한다.

    +
  4. +
+
+

(*1): preact의 prefresh 같은 HMR 라이브러리를 적용했거나(후술) import.meta.hot.accept을 직접 코드에 추가한 경우에 해당(아래 코드)한다.

+
+
<!-- 
+  import.meta.hot.accept가 코드에 있다면 HMR을 허용한 파일이라고 인식한다.
+  importAnalysis 플러그인이 createHotContext를 추가한다. -->
+<script type="module">
+  if (import.meta.hot) {
+    // https://vitejs.dev/guide/api-hmr#hot-accept-cb
+    import.meta.hot.accept((param) => {
+      console.log('param', param);
+    });
+  }
+</script>
+

inject-import-meta-hot

+
+

일반 스크립트의 응답에 createHotContext 생성 및 import.meta.hot에 바인딩된 모습.

+
+

4. 렌더링 계속 진행(with WebSocket)

+

이제 index.html의 요청 파일을 가져왔으므로 브라우저 렌더링이 계속 진행된다.

+
@vite/client.ts
function setupWebSocket(...) {
+  const socket = new WebSocket(`${protocol}://${hostAndPath}`, 'vite-hmr')
+  socket.addEventListener('message', async ({ data }) => {
+    handleMessage(JSON.parse(data))
+  });
+}
+ +
// AS-IS 원본 코드
+import { h, render } from 'preact';
+import App from './MyComponent';
+ 
+render(<App />, document.getElementById('app'));
+ 
+// TO-BE 변경된 코드
+import { render } from '/node_modules/.vite/deps/preact.js';
+import { jsxDEV as _jsxDEV } from '/node_modules/.vite/deps/preact_jsx-dev-runtime.js';
+import App from '/src/MyComponent';
+ 
+render(_jsxDEV(App, ...), document.getElementById('app'))
+

만약 react와 같은 UI 라이브러리를 사용한다면 각 라이브러리가 의존하는 HMR 라이브러리가 호출된다. 여기서는 preact를 기준으로 설명(리액트와 거의 동일하다)하겠다.

+

jsxDEV로 감싸진 하위 컴포넌트들은 HMR이 적용된다. 자세한 내용은 6. 브라우저 리렌더링에서 다루겠다.

+

이제 브라우저가 더 이상 요청할 것이 없을 때까지 3 ~ 4 과정을 반복하며 렌더링을 마무리한다.

+

5. 코드 변경 감지

+

file-change-phase

+
+

코드가 변경되었을 때의 Dev Server 모습

+
+
watcher.on('change', async (file) => {
+  file = normalizePath(file);
+  // 플러그인 컨테이너에게 update 이벤트 발송. 플러그인에서 필요시 실행된다.
+  await container.watchChange(file, { event: 'update' });
+  // 의존성 그래프에 변경사항 체크
+  moduleGraph.onFileChange(file);
+  // 대망의 HMR 업데이트 시작
+  await onHMRUpdate(file, false);
+});
+

개발 중 파일이 변경되면(개발자의 코드 수정) chokidar에서 change 이벤트를 감지한다.

+
    +
  • 플러그인 컨테이너에 update 이벤트를 전파한다. +
      +
    • 각 플러그인에서 필요시(listen) 플러그인 코드가 실행된다.
    • +
    +
  • +
  • 의존성 그래프에 변경사항을 적용한다. +
      +
    • 모듈 캐싱을 무효화해 refresh 되도록 함.
    • +
    +
  • +
  • onHMRUpdate 함수를 호출해 hot-reloading을 준비한다.
  • +
+
function onHMRUpdate() {
+  // 관련 플러그인 훅 실행
+  for (const hook of config.getSortedPluginHooks('handleHotUpdate')) {
+    const filteredModules = await hook(hmrContext)
+  }
+  ...
+  updateModules(...)
+}
+ 
+function updateModules(...) {
+  for (const mod of modules) {
+    const boundaries: PropagationBoundary[] = []
+    // 모듈 그래프 갱신
+    const hasDeadEnd = propagateUpdate(mod, traversedModules, boundaries)
+    moduleGraph.invalidateModule(mod, invalidatedModules, timestamp, true)
+  }
+  // 소켓 메시지 전송
+  ws.send({ type: 'update', updates })
+

onHMRUpdateupdateModules호출한다.

+
    +
  • HMR 관련 플러그인이 있을 때 훅(handleHotUpdate)을 실행시킨다.
  • +
  • 관련된 모듈 그래프를 갱신한다.
  • +
  • 브라우저에게 파일이 변경되었음을 WebSocket으로 알린다(update 이벤트 전송).
  • +
+

6. 브라우저 리렌더링

+

socket-update-event

+
+

브라우저 소켓이 Dev Server의 update 소켓 데이터를 받은 모습.

+
+
// Step 1.
+// @vite/client.ts
+case 'update':
+  notifyListeners('vite:beforeUpdate', payload);
+  await Promise.all(payload.updates.map(async(update)=> {
+      if (update.type === 'js-update') {
+            // queueUpdate는 업데이트 목록의 순서를 유지해준다.
+            return queueUpdate(hmrClient.fetchUpdate(update))
+      }
+      // ... CSS update는 생략
+  });
+  notifyListeners('vite:afterUpdate', payload);
+ 
+// Step 2.
+// HMRClient > fetchUpdate
+fetchUpdate(...) {
+  fetchedModule = await this.importUpdatedModule(update);
+ 
+// client/client.ts > importUpdatedModule
+async function importUpdatedModule(...) {
+  // Step 3.
+  const importPromise = import(
+    /* @vite-ignore */
+    base +
+      acceptedPathWithoutQuery.slice(1) +
+      `?${explicitImportRequired ? 'import&' : ''}t=${timestamp}${
+        query ? `&${query}` : ''
+      }`
+  )
+  return await importPromise
+},
+
    +
  1. +

    @vite/client에서 연결된 브라우저의 소켓은 update 이벤트를 받고 hmrClient에게 업데이트를 지시한다.

    +
  2. +
  3. +

    hmrClient는 fetchUpdate에 데이터를 넘기고 importUpdatedModule호출한다.

    + +
  4. +
+

import response

+
+

Step 3 ~ 4.

+
+
    +
  1. +

    importUpdatedModule이 호출되면서 변경된 모듈이 import 되므로, Dev Server에 새로 요청하게 된다(3. 렌더링 자원 요청). 이때 t 값을 쿼리로 넣어(?t=123214123) 캐싱을 회피해 변경된 모듈의 코드를 응답으로 받을 수 있도록 한다.

    +
  2. +
  3. +

    import로 요청한 응답이 정상적으로 오면 리렌더링 되기 시작(4. 렌더링 진행)된다.

    +
  4. +
  5. +

    HMR이 가능한 파일은 import.meta.hot.accept 함수의 콜백으로 실행된다. HMR이 불가능한 파일이라면 전체 페이지를 리로딩한다.

    +
  6. +
+
// vite.config.ts
+import preact from '@preact/preset-vite';
+ 
+export default defineConfig({
+  // Step 1. preact 플러그인 호출
+  plugins: [preact()],
+});
+ 
+// Step2 2. preact 플러그인에서 prefresh가 호출되면서 소스코드를 transform 한다.
+return {
+  code: `${prelude}${result.code}
+  if (import.meta.hot) {
+    self.$RefreshReg$ = prevRefreshReg;
+    self.$RefreshSig$ = prevRefreshSig;`
+    // 중요! Step 3. 해당 코드 덕에 HMR로 인식, Dev Server에서 호출되며 flushUpdate 실행
+    `import.meta.hot.accept((m) => {
+      try {
+        flushUpdates();`
+ 
+// Step 4. 실제 코드 변경 부분.
+function flushUpdates() {
+  self.__PREFRESH__.replaceComponent(prev, next, true);
+

앞에서 잠깐 다뤘지만 react, preact 등 순수 자바스크립트가 아니라면 라이브러리 자체 HMR을 호출한다. 이 HMR 코드는 [3. 렌더링 자원 요청] 단계에서 추가된다.

+
    +
  1. +

    preact-vite 플러그인(@preact/preset-vite)은 내부적으로 prefresh라는 HMR 라이브러리를 사용한다.

    +
  2. +
  3. +

    preact 플러그인은 prefreshEnabled 여부에 따라 prefresh를 호출한다.

    +
  4. +
  5. +

    prefresh는 transform 단계에서 import.meta.hot.accept 코드를 주입한다.

    +
  6. +
  7. +

    [6. 브라우저 리렌더링] 발생시 3번 단계의 importUpdatedModule를 거쳐 import.meta.hot.accept의 콜백이 실행된다.

    +
  8. +
  9. +

    perfresh에서 심어둔 flushUpdates 함수가 HMR을 수행(컴포넌트 변경)된다.

    +
  10. +
+

이로써 HMR이 완전히 마무리되면서 다시 개발자의 입력을 기다리게 된다.

+

dev-server-logic-summary

+
+

Dev Server 초기화부터 HMR까지.

+
+

마무리

+

Vite Dev Server를 사용하면서 모호하게 알고 있던 부분을 이번 기회에 한번 쭉 정리할 수 있었다. 정리하면서 모르는 것이 참 많다고 느꼈다.

+

팩트인지 확인하기 위한 소스코드 탐험과 디버깅 과정은 상당히 의미 있었다. 글이 너무 길어질 것 같아 생략한 함수들이 꽤 있는데 감탄하며 본 로직들이 많이 있었다. 역시 남의 코드를 많이 봐야 한다.

+

이번 과정을 통해 두 가지 인사이트를 얻을 수 있었다.

+
    +
  1. Vite Dev Server의 많은 부분들이 Webpack Dev Server의 동작과 비슷하다는 점이 인상적이었다. 하나를 잘해놓는 게 중요하다고 느꼈다.
  2. +
  3. 소스코드의 탐험이 쉽지만은 않았만 어느 정도 자신감이 붙을 수 있었다. 이후에도 이렇게 공부해 나가야겠다고 생각했다.
  4. +
+

이 글을 쓰며 참고했던 혹은 유용했던 링크를 남기며 글을 마무리하려고 한다.

+
📮 이 포스트에 관심 있으신가요? 이슈를 남겨주세요! 👍
☕ 소주 한 잔 후원하기
(예금주: 이상철)tosskakao
\ No newline at end of file diff --git a/e2e/__snapshots__/post/dom.spec.ts/desktop/woowa-type-review.html b/e2e/__snapshots__/post/dom.spec.ts/desktop/woowa-type-review.html new file mode 100644 index 00000000..60920fcf --- /dev/null +++ b/e2e/__snapshots__/post/dom.spec.ts/desktop/woowa-type-review.html @@ -0,0 +1,118 @@ +

우아한 타입스크립트 with 리액트 리뷰

1ilsang
클라이밍 하실래염?
#book#review#typescript#react
Published

cover

+

"우아한 타입스크립트 with 리액트"의 리뷰를 해보려고 한다.

+

선택하게 된 계기

+

두 가지 캐치프라이즈가 나를 이끌었다고 생각한다.

+
    +
  1. 배달의민족 개발 사례로 살펴보는
  2. +
  3. 주니어 개발자를 위한 온보딩 가이드
  4. +
+

기술 스택이 동일하다 하여도 회사별로 사용 방식이 상당히 상이하다고 느낄 때가 많았기 때문에 이렇게 간접적으로나마 문화나 기술 적용 방식을 체험해 볼 수 있는 서적을 선호한다.

+

또한 주니어 개발자를 위한 온보딩 가이드를 내걸었으므로 어떻게 신입이 회사의 일원으로 빠르게 흡수될 수 있을지 고민한 흔적이 있을 것이라 기대해 선택하게 되었다.

+

간단한 요약

+

이 책은 앞에서 타입스크립트를 다루고 뒷부분은 리액트를 활용한 여러 기법이나 패턴에 관해 설명한다.

+

앞쪽 타입은 신입이 보기에 조금 어려운 타입 좁히기까지 잘 다루고 있다. extendsinfer를 통한 타입 추론이 익숙하지 않다면 읽어보기를 추천하고 싶다.

+

물론 타입스크립트나 리액트를 깊이 있게 공부하려고 이 책을 선택한다면 조금 부족할 수 있다고 생각한다(애초에 주니어 온보딩 책이다).

+

온보딩 책인 만큼 API, 리렌더링, 훅스, State 등을 다양한 예제와 패턴을 통해 소개하는데 내용이 좋으므로 사수가 없는 환경에서 개발하는 분들이라면 읽기를 추천하고 싶다.

+

인상 깊었던 부분

+

책을 읽으면서 좋았던 예제나 포인트들을 가볍게 소개하고자 한다.

+

타 언어의 타입 시스템과 비교

+

2장에서 타 언어의 타입 시스템을 거론하며 타입스크립트의 타입 철학을 엿볼 수 있게 해준다. 나는 이 부분이 좋았다.

+
    +
  • 타입스크립트는 다른 명목적으로 구체화한 타입 시스템(Java, C++)과 다르게 구조로 타입을 구분한다.
  • +
  • 타입스크립트는 타입 시스템을 집합으로 이해하면 된다. 타입은 값의 집합으로 생각할 수 있다.
  • +
+
class Person {
+  name: string;
+  constructor(name: string) {
+    this.name = name;
+  }
+}
+class Developer {
+  name: string;
+  age: number;
+  constructor(name: string, age: number) {
+    this.name = name;
+    this.age = age;
+  }
+}
+function greet(p: Person) {
+  console.log(p.name);
+}
+const developer = new Developer('zig', 20);
+ 
+// Person 타입을 받지만 Developer 타입 값을 넣어도 무관하다.
+// 구조적 서브 타입이기 때문에 에러를 발생시키지 않는다.
+// 서로 다른 두 타입 간의 호환성은 오직 타입 내부의 구조에 의해 결정된다.
+greet(developer); // OK
+
    +
  • 타입스크립트가 위와 같은 구조적 타이핑을 채택한 이유는 자바스크립트를 모델링한 언어이기 때문이다.
  • +
  • 자바스크립트는 덕 타이핑(duck typing)을 기반으로 만들어졌다. 덕타입은 매개변수가 올바르게 주어진다면 그 값이 어떻게 만들어졌는지 신경 쓰지 않고 허용한다.
  • +
+

타입에 대한 고찰

+
type IdType = string | number;
+type Numeric = number | boolean;
+// 교차 타입은 두 타입을 모두 만족하는 경우에만 유지된다.
+type Universal = IdType & Numeric; // number
+ 
+// 따라서 두 타입을 만족하지 못하는 경우 never가 된다.
+type DeliveryTip = {
+  tip: number;
+}
+type Filter = {
+  tip: string;
+} & DeliveryTip;
+const filter: Filter = { tip: ... } // Type '...' is not assignable to type 'never'.
+

타입스크립트의 특징(집합적 특징과 구조적 타이핑 등)은 교차 타입에서 혼란을 야기할 수 있다. 이 부분에 대한 내용을 명확하게 설명하고 있다.

+

개발팀과의 인터뷰

+

woowa-story

+

중간중간 배민 개발팀들이 참조 출연해 해당 타입/기술을 쓰는지, 어떻게 생각하는지 인터뷰하는 것들이 있는데 실무에서 어떻게 생각하는지 생생하게 볼 수 있어서 흥미로웠다.

+

자연스럽게 나 또한 질문에 대한 답을 해보곤 하면서 더 몰입할 수 있었다.

+

친절한 설명

+
type CreateMutable<Type> = {
+  -readonly [Property in keyof Type]-?: Type[Property]; // - 는 오타가 아니다.
+};
+ 
+// 우아한 타입스크립트 https://www.youtube.com/whatch?v=ViS8DLd6o-E
+// 제너릭 T 타입이 K로 추론되는 Promise<K>라면 K를 반환하고 아니라면 any를 반환한다.
+// 이를 통해 Promise 반환 타입을 좁혀서 추론할 수 있다.
+type UnpackPromise<T> = T extends Promise<infer K>[] ? K : any;
+

타입스크립트의 문법 중에는 처음에 이해하기 어려운 것들이 있는데, 하나하나 과정을 풀어서 설명해 준다.

+

실용적인 예제

+

특정 개념을 설명하고 활용하는 방법을 실무 코드를 기준으로 알려주기 때문에 상당히 실용적인 예제들로 채워져있다.

+
const BottomSheetMap = {
+  RECENT_CONTACTS: RecentContactsBottomSheet,
+  CARD_SELECT: CardSelectBottomSheet,
+};
+type BOTTOM_SHEET_ID = keyof typeof BottomSheetMap;
+type BottomSheetStore = {
+  // BOTTOM_SHEET_ID(BottomSheetMap 객체의 key 값)의 property 값을 변환한다.
+  [index in BOTTOM_SHEET_ID as `${index}_BOTTOM_SHEET_ID`]: {
+    // ...
+  };
+};
+const store: BottomSheetStore = {
+  RECENT_CONTACTS_BOTTOM_SHEET_ID: { ... }, // key 값이 index로 가져온 값으로 변환된다.
+  CARD_SELECT_BOTTOM_SHEET_ID: { ... },
+};
+

바텀 시트별 스토어를 선언하면서 키값을 특정하게 강제하고 있다.

+
type ProductPrice = 100 | 200 | 300;
+const getProductName = (productPrice: ProductPrice): string => {
+  if (productPrice === 100) return '배민 상품권 100원';
+  if (productPrice === 200) return '배민 상품권 200원';
+  else {
+    // exhaustiveCheck를 안 해주면 300원에 대한 코드가 없어도 에러가 발생하지 않음.
+    // 조건별 완벽한 타입 검증을 위해 사용하는 패턴
+    exhaustiveCheck(productPrice); // Argument of type 'number' is not assignable to parameter of type 'never'.
+    return '배민 상품권';
+  }
+};
+// param 값이 never 타입이므로 해당 함수가 호출되기 전에 타입 가드가 다 되어 있어야 한다.
+const exhaustiveCheck = (param: never) => {
+  throw new Error(`type error!`);
+};
+

Exhaustiveness Checking 패턴을 통해 이후 타입이 추가되어도 무결성을 지킬 수 있게 된다.

+

이 외에도 컴파일 과정에 대한 설명이나 Axios 가이드 및 훅에서 유의할 점 등 여러 꿀팁들이 있다.

+

맺으며

+

배민의 공유 문화는 본받을만하다고 생각한다.

+

기술업계 특성상 비판적인 시선이 기본적으로 있기 때문에 외부 공개를 꺼릴 수도 있었겠지만, 기술에 대한 공유를 두려워하지 않고 책으로 펴낸 것에 리스펙하게 된다.

+

여러 예제가 실제 코딩에 도움이 되기 때문에 추천하고 싶은 책이다.

📮 이 포스트에 관심 있으신가요? 이슈를 남겨주세요! 👍
☕ 소주 한 잔 후원하기
(예금주: 이상철)tosskakao
\ No newline at end of file diff --git a/e2e/__snapshots__/post/dom.spec.ts/mobile/2023.html b/e2e/__snapshots__/post/dom.spec.ts/mobile/2023.html new file mode 100644 index 00000000..06c2ea19 --- /dev/null +++ b/e2e/__snapshots__/post/dom.spec.ts/mobile/2023.html @@ -0,0 +1,140 @@ +

2023 회고록

1ilsang
클라이밍 하실래염?
#retrospect#2023
Published

cover

+

2022년의 목표

+

작년에 분명 2022 회고록을 작성했는데 문서를 잃어버렸다(-_-).

+

많이 당황했지만 반대로 말하면 1년 동안 쳐다도 안 봤다는 것이니 작년에 세웠던 올해의 목표나 다짐은 사실상 없는 것과 같았다.

+

그래서인지 올해는 기분 가는 대로 즉흥적으로 시작한 것들이 많았다.

+

어느 시점부터 일정이나 해야 할 일이 정리가 안되었고 이것을 해결하기 위해 무던한 노력을 했다.

+

올해의 회고는 즉흥적 삶과 그 과정을 KPT로 정리해 내년에 어떻게 에너지를 발산할 수 있을지 고민해 보려고 한다.

+

Keep

+
+

현재 만족하고 있는 부분과 계속 이어갔으면 하는 부분

+
+

기술적 관심

+ +

회사

+
    +
  • LINE과 Yahoo!가 합병하는 과정을 몸으로 겪을 수 있어서 좋았다. 진짜..ㅋㅋ
  • +
  • 큰 기업끼리 합을 맞추는건 정말 쉽지 않다.
  • +
  • 자발적으로 일하려고 노력했다.
  • +
  • 팀원들에게 친절하려고 노력했다.
  • +
  • 사내 오픈소스 행사에서 1등 했다. MDN에 기여했다.
  • +
  • 기존 Webpack 프로젝트를 Vite로 변경하고 좋은 성능 지표를 얻었다.
  • +
  • 모든 프로젝트에서 캘린더를 따로 만들고 있어서 라이브러리로 만들어 배포했다.
  • +
  • 얼떨결에 프런트엔드 밸런스 게임에 출연하게 됐다.
  • +
  • "일의 격"을 읽고 느끼는 게 많았다. 연차가 쌓이면서 일을 잘하는 게 무엇인지 고민하게 됐다.
  • +
+

대외활동

+

nexters

+
    +
  • 프런트엔드 반상회에서 오픈소스를 주제로 발표했다.
  • +
  • 멘토링 활동을 했다. 하면서 오히려 많이 배웠다. 설명을 잘하기 위해 공부를 많이 했다.
  • +
  • 글또 활동 덕분에 글쓰기에 사명감을 느끼게 되었다.
  • +
  • 넥스터즈 회고모임을 운영했다. 시각화 잘 나와서 뿌듯했다.
  • +
+

운동

+

climbing solved chart

+
+

띠별 푼 문제 개수이다. 올 한해는 클라이밍과 함께했다.

+
+
    +
  • 파랑 클라이머에서 어느덧 빨강으로 올라왔다. 월별로 변해가는 게 신기하다.
  • +
  • 7월에 발리에서 서핑을 시작했다. 최고의 활동 중 하나였다.
  • +
  • 무엇보다 안 다쳤다! 안전이 제일 중요하다.
  • +
+

여행

+
    +
  • 도쿄로 출장갔다. 경제가 더 활성화 되었으면 좋겠다. 출장 많이 다니고 싶다.
  • +
  • 발리에서 한 달 동안 리모트 워크를 했다.
  • +
  • 타지의 인연이 생기면서 영어를 더 열심히 해야겠다고 느꼈다.
  • +
+

독서

+
    +
  • 북또 활동을 통해 한 달에 한 권 책 읽기를 진행했다.
  • +
  • 회복탄력성과 소크라테스 익스프레스 두 책이 인상적이었다. 삶을 대하는 시각이 달라졌다.
  • +
  • 사라진 개발자들도 흥미롭게 읽었다. 애니악과 관련된 이야기들을 보면서 역사에 흥미가 생겼다.
  • +
+

Problem

+
+

불편하게 느끼는 부분. 개선이 필요하다고 생각되는 부분

+
+

데이터의 파편화

+
    +
  • 한 해 이것저것 뭔가 했는데 시각화하기가 어려웠다. 데이터적 삶을 살고 싶다.
  • +
  • 계획을 더 세분화해서 진행하고 글로 남기고 싶다.
  • +
+

기술 갈증

+
    +
  • 딥다이브를 생각보다 하지 못했다. 원리를 탐구하고 확실하게 설명할 수 있는 개발자가 되어야 한다.
  • +
  • 공부를 넘어 의미 있는 생산을 하고 싶다. 창업하고 싶은 것은 아니지만 기술자로서 갈증이 있다.
  • +
  • 좋은 멘토로 성장하고 싶다.
  • +
+

생활

+
    +
  • 영어가 너무 아쉽다.
  • +
  • 조금 더 진심으로 살 수 있었는데 그러지 못한 것 같아 아쉽다.
  • +
  • 마르쿠스는 명상록에서 "침대 밖으로 나갈 사명이 있다"고 했다.
  • +
  • '사명'이지 '의무'가 아니다. 사명은 내부에서 의무는 외부에서 온다. 사명은 자신과 타인을 드높이기 위한 자발적 행위다.
  • +
  • 나는 나의 삶에 사명감을 느끼고 있는가?
  • +
+

Try

+
+

Problem에 대한 해결책. 다음 Action 플랜

+
+
    +
  • 데이터 적 삶을 살기.
  • +
  • 계획의 세분화. PARA 보드 더 잘 활용하기.
  • +
  • 계획 짧게 가져가기. 즉각적 피드백으로 빨리빨리 일을 처리하기.
  • +
  • 한 달에 한 개 사용하고 있는 기술 다이브 하기. 월간 다이브!
  • +
  • 일단 무언가 만들어보기.
  • +
  • 영어 열심히 하기.
  • +
  • 더 진심으로 살기.
  • +
+

총평

+

올해를 쭉 돌아보니 나는 데이터화가 많이 부족하다. 시각화 자료로 만들기 어려운 부분이 많다.

+

시각화의 중요성을 많이 느끼고 있기 때문에 나라는 사람의 데이터를 잘 수집하는 것부터 해보자고 생각하게 되었다.

+

나는 욕심이 많아서 하고 싶은 것이 많은데 정작 세부 일정은 전무하다. 그래서인지 시간 대비 효율이 높지 못했다.

+

다음부터는 일을 진행할 때 세부 계획을 세우고 피드백 시간을 넣어야겠다.

+

PARA에 TODO로 쌓이는 것들이 너무 많다(!!). 어느 순간부터 적어만 놓고 안 하는 것들이 생긴다. 사이클을 조금 더 빨리 돌려야겠다.

+

더 진심으로 살아갈 필요가 있다.

+

신년 목표

+

blue-dragon

+

신년의 가장 큰 목표는 "개인 브랜딩"이다. 전문가로 성장하고 싶다.

+
    +
  1. 개인 브랜딩 +
      +
    • 전문가로 성장하기
    • +
    • 타인에게 친절하기
    • +
    +
  2. +
  3. 데이터 적 삶 +
      +
    • 한 달에 한 번 회고
    • +
    +
  4. +
  5. 전문성 +
      +
    • 책 한 권 쓰고 싶다
    • +
    • 월간 다이브 진행
    • +
    • 알고리즘 진심 모드
    • +
    • 영어 시험 준비
    • +
    • 세미나 준비하기
    • +
    • 한 달에 한 권
    • +
    +
  6. +
  7. 취미 +
      +
    • 클라이밍 빨클러
    • +
    +
  8. +
+

2024년 잘 부탁드립니다.

📮 이 포스트에 관심 있으신가요? 이슈를 남겨주세요! 👍
☕ 소주 한 잔 후원하기
(예금주: 이상철)tosskakao
\ No newline at end of file diff --git a/e2e/__snapshots__/post/dom.spec.ts/mobile/2024-01.html b/e2e/__snapshots__/post/dom.spec.ts/mobile/2024-01.html new file mode 100644 index 00000000..11e7f3c5 --- /dev/null +++ b/e2e/__snapshots__/post/dom.spec.ts/mobile/2024-01.html @@ -0,0 +1,47 @@ +

2024년 1월 회고

1ilsang
클라이밍 하실래염?
#retrospect#2024#01
Published

cover

+

2023년 회고록에서 "데이터" 부재의 아쉬움을 이야기했었다. 따라서 올해는 데이터 드리븐 삶을 살기 위해 월간 회고를 진행하려고 한다.

+

2024년 1월의 목표

+

1월의 핵심 키워드는 "절제"였다. 작년의 문제 중 하나로 지목된 "TODO"에 무한히 쌓이기만 하는 것들을 없애기 위한 일환이었다.

+

이번 달의 목표는 아래와 같았다.

+
    +
  1. 2D1R
  2. +
  3. 월간 다이브
  4. +
  5. 월간 메이커스
  6. +
  7. 말해보카
  8. +
  9. 운동하기
  10. +
+

2D1R

+

leetcode-jan

+

2D1R은 2일에 1알고리즘으로, 꾸준하게 가져가고 싶은 습관 중 하나다.

+

leetcode를 기준으로 풀고 있다. 화요일과 목요일은 꼭 제출한 모습을 보인다. 이때가 스터디 제출 날이라..ㅋ

+

최근에 가장 재밌었던 문제는 Find-the-duplicate-number이다. 플로이드의 토끼와 거북이 알고리즘을 활용하여 해결하는 문제인데, 점화식 도출이 너무 신기했다.

+

취준생 때 지금처럼 재미를 느꼈다면 얼마나 좋았을까...

+

월간 다이브

+

vite

+

전문성을 기르고자 시작한 월간 다이브.

+

특정 기술을 이해하고 넘어가는 것이 아니라 글로 정리해 누군가에게 설명할 수 있도록 하는 것이 목표다. 분석/발표하는 것이 목표다.

+

원래는 Axios를 하려고 했는데, 회사에서 Vite 관련 이슈를 만나면서 Vite Dev Server 이해하기 (feat. HMR)로 급선회했다.

+

시간이 엄청나게 들어갔다. 거의 10일은 매달렸다(-_-).

+

기술 이해를 위한 디버깅 과정이 고난이라 생각했지만 글로 정리하면서 팩트체크 하는 과정이 "진짜"였다. 정말 쉽지 않았다.

+

나름 만족하는 글이다. 많은 분들에게 피드백을 받을 수 있었고 회사에서 발표도 진행했다.

+

월간 메이커스

+

rust

+

월간 다이브로 이론적 공부를 했다면 월간 메이커스를 통해 실전 코딩을 하려고 했다.

+

목표로 하는 라이브러리 혹은 기술을 잡아 클론코딩 비슷하게 만들어 보는 게 목표이다.

+

유데미 러스트 강의를 수강 하고 나서 의미 있는 걸 만들어보자 생각해 FHF(File Hierarchy Fixer)를 구현해 보려고 했다.

+

근데 월간 다이브 과정에서 시간이 끌리며 레포만 만들고 실패했다.

+

말해보카

+

말해보카

+

요즘 말해보카에 푹 빠져 있다. 영어 재밌을지도..?

+

이 녀석 완전 효자다. 대만족 중

+

운동하기

+

chart

+
+

클라이밍 날짜와 빨강 푼 개수

+
+

자주 갔다고 생각했는데 모아보니 많이 가진 못했다.

+

1월에는 총 34개를 풀었다. 요즘 야식을 많이 먹어서 그런지 벽 타는 게 상당히 어렵다.

+

다음 달에는 좀 더 트레이닝해야겠다.

+

마치며

+

2월에는 현상 유지하면서 책 한 권만 읽으면 좋을 것 같다.

+

가보자고!

📮 이 포스트에 관심 있으신가요? 이슈를 남겨주세요! 👍
☕ 소주 한 잔 후원하기
(예금주: 이상철)tosskakao
\ No newline at end of file diff --git a/e2e/__snapshots__/post/dom.spec.ts/mobile/array-prototype-sort.html b/e2e/__snapshots__/post/dom.spec.ts/mobile/array-prototype-sort.html new file mode 100644 index 00000000..0a6c3f45 --- /dev/null +++ b/e2e/__snapshots__/post/dom.spec.ts/mobile/array-prototype-sort.html @@ -0,0 +1,401 @@ +

Array.prototype.sort() 이해하기

1ilsang
클라이밍 하실래염?
#ECMAScript#array#sort
Published

cover

+
[11, 8, 1, 2, 33, 3].sort(); // [1, 11, 2, 3, 33, 8]
+ 
+const arrayLike = { 0: 'c', 2: 'b', 123: '1ilsang', '1ilsang': 123, length: 3 };
+Array.prototype.sort.call(arrayLike); // { 0: 'b', 1: 'c', 123: '1ilsang', '1ilsang': 123, length: 3 };
+// ?????
+

JavaScript에서 sort는 어떻게 구현되어 있을까? stable 한가? 브라우저별 차이는 없을까? ECMA의 명세는 어떻게 되어있을까?

+

Index

+ +

TL;DR!

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
EngineBrowserAlgorithmStableIn-placeECMA Spec
V8ChromeTim sortOXO
WebkitSafariBucket / MergeOXO
SpiderMonkeyFirefoxMerge + InsertionOXO
+
    +
  1. sort 함수는 ECMA 2019부터 stable sort가 되었지만 in-place 하지 않을 수 있다.
  2. +
  3. 유사 배열 객체를 정렬할 때는 length를 기준으로 프로퍼티 값을 비교한다.
  4. +
  5. 브라우저별 sort 함수의 구현체가 다르지만 ECMAScript 명세를 지킨다.
  6. +
+

의문의 시작

+

JavaScript의 sortTim sort로 구현되어 있다고 알고 있었다. 그런데 그 이상의 감동이 나에게 있는지 의문이 들었고 스스로에게 아래와 같이 질문해 보았다.

+
    +
  1. Array.prototype.sort의 명세는 어떻게 되어 있는가?
  2. +
  3. 브라우저는 Array.prototype.sort의 명세대로 sort를 구현했는가?
  4. +
  5. 모든 브라우저가 Tim sort로 구현되어 있는가?
  6. +
  7. compareFn의 유무에 따른 sort 함수의 동작은 무엇이 달라지는가?
  8. +
  9. sort 함수는 in-place하고 stable 한가?
  10. +
  11. 유사 배열 객체 또한 sort 함수로 정렬된다. 어떻게 동작하는가?
  12. +
+

sad

+

자 이제 감동을 채워나가자.

+

Array.prototype.sort() 공식 명세

+

ECMAScript의 내용을 기준으로 설명하겠다.

+

ECMA2019 stable

+

ECMA2019의 업데이트로 Array.prototype.sort 함수는 stable 하도록 명시되었다.

+

해당 명세는 [Normative] Make Array.prototype.sort stable #1340 PR에서 최초 정의되었다.

+

문서의 23.1.3.30에 Array.prototype.sort의 동작이 정의되어 있다. 공식 명세를 따라가며 동작을 확인해 보자.

+

23.1.3.30 Array.prototype.sort (compareFn)

+

ecma official

+
+

Array.prototype.sort의 명세 내용

+
+

한 줄씩 내용을 해석해 보자.

+

1. compareFn의 유효성을 검사한다.

+
const list = [3, 4, 6, 1, 5, 3];
+list.sort(123); // TypeError!
+Array.prototype.sort.call(list, 123); // TypeError!
+
    +
  • compareFn은 비교 콜백 함수를 뜻한다.
  • +
  • compareFn이 undefined가 아니고 IsCallable(호출 가능)하지 않다면, TypeError를 발생시킨다.
  • +
+

2. 배열 객체로 변환한다.

+
Object('123'); // String {'123'}
+Object([1, 2, 3]); // [1,2,3]
+Object({ 1: 'a', 2: 'b' }); // {1: 'a', 2: 'b'}
+
+

처음 공식 문서를 읽으면 여기서 막힌다. ?와 같은 표현을 ReturnIfAbrupt Shorthands라고 한다. 에러가 발생하면 즉시 리턴하고 아니면 결과를 진행한다는 뜻이다. 자세한 내용은 Completion Records 참고.

+
+
    +
  • ToObject를 호출하여 현재 배열(this 값)을 객체로 변환한다. +
      +
    • 위 코드의 첫 번째 예시와 같이 원시 타입 문자열을 문자열 객체로 변환한다.
    • +
    • 암묵적 형변환을 이해하고 싶다면 이 포스트를 읽어보길 추천한다.
    • +
    +
  • +
  • 객체로 변환해 처리하므로, 이는 sort 메서드가 배열이 아닌 객체에도 적용될 수 있음을 뜻한다.
  • +
  • 설정된 객체를 obj라 명한다.
  • +
+

3. 객체의 길이를 계산한다.

+
const arrayLike = { 0: 'c', 1: 'a', 2: 'b', length: 3 };
+console.log(arrayLike[1], arrayLike.length); // 'a', 3
+Array.isArray(arrayLike); // false
+arrayLike instanceof Array; // false
+
    +
  • LengthOfArrayLike를 호출하여 변환된 객체(obj)의 길이를 가져온다. +
      +
    • LengthOfArrayLike 추상 연산은 유사 배열 객체의 length 프로퍼티를 반환한다.
    • +
    • 해당 추상 연산에서 "유사 배열 객체"는 해당 연산이 정상으로 완료되는 객체를 뜻한다. 즉 length 프로퍼티(속성)가 있어야 유사 배열 객체로 성립한다.
    • +
    +
  • +
  • 가져온 길이를 len이라 명한다.
  • +
+

4. 정렬 비교를 위한 추상 클로저를 생성한다.

+
[11, 8, 1, 2, 33, 3].sort(); // [1, 11, 2, 3, 33, 8]
+
    +
  • 매개변수로 x, y가 있는 추상 클로저를 생성한다. 이 클로저는 compareFn을 캡쳐하고 다음 단계를 호출한다.
  • +
  • 클로저가 실행되면 CompareArrayElements를 호출하여 xy를 비교(compareFn)하고 결과를 반환한다. +
      +
    • 결과 값은 -1,0,1 혹은 에러를 반환한다.
    • +
    • compareFn이 제공되지 않으면 각 인자를 ToString으로 변환 후 문자열 비교(유니코드 포인트 순서) 한다. 이 때문에 위와 같이 기본 sort 함수의 동작이 처음에는 당혹스럽게 느껴진다.
    • +
    +
  • +
  • 생성된 추상 클로저를 SortCompare이라 명한다.
  • +
+

5. 새로운 배열에 프로퍼티를 정렬한다.

+
    +
  • SortIndexedProperties를 위에서 생성된 값들과 함께 호출한다. +
      +
    • SortIndexedProperties는 객체(obj)의 인덱싱된 속성들을 SortCompare를 사용해 len 만큼 정렬하는 함수다.
    • +
    • 여기서 SKIP-HOLES는 배열의 빈 요소를 정렬에서 제외하라는 뜻이다.
    • +
    +
  • +
  • SortIndexedProperties의 동작은 대략 아래와 같다. +
      +
    • 빈 리스트 items를 생성한다 +
        +
      • 메모리를 추가 사용한다. 명세는 in-place 하지 않다.
      • +
      +
    • +
    • 숫자 0인 k를 정의한다.
    • +
    • (반복) k < len 이라면 k를 문자열로 변환한 Pk를 생성한다. +
        +
      • obj에 Pk 속성이 있는지 확인하고 있다면(SKIP-HOLES이므로) 가져온다(kValue).
      • +
      • 가져온 값(kValue)을 items에 추가한다.
      • +
      • k의 값을 1 증가시킨다.
      • +
      +
    • +
    • 값이 추가된 items에 SortCompare를 호출해 항목을 정렬한다.
    • +
    +
  • +
  • 정렬된 리스트를 sortedList라 명한다.
  • +
+

6. 정렬된 요소의 개수를 계산한다.

+
    +
  • sortedList에 있는 요소의 개수를 itemCount라 명한다.
  • +
+

7. j를 0으로 설정한다.

+

8. 정렬된 요소를 객체에 설정한다.

+
    +
  • j가 itemCount보다 작을 동안 반복한다.
  • +
  • 객체(obj)의 j 번째 속성을 sortedList[j]로 설정한다. +
      +
    • 원본 객체를 변경하고 있다. mutable 하다.
    • +
    +
  • +
  • j를 1 증가시킨다.
  • +
+

9 ~ 10. 빈 요소를 처리한다.

+
[1, , 2].sort(); // [1, 2, empty]
+ 
+// 인덱스 1이 존재하지 않음.
+const arrayLike = { 0: 'c', 2: 'b', 123: '1ilsang', '1ilsang': 123, length: 3 };
+ 
+// 인덱스 2 가 삭제되고 1이 추가되었다.
+// 또한 length를 벗어나는(혹은 성립하지 않는) 인덱스는 무시(정렬 X)된다.
+Array.prototype.sort.call(arrayLike); // { 0: 'b', 1: 'c', 123: '1ilsang', '1ilsang': 123, length: 3 };
+
    +
  • SortedIndexedProperties 호출 때 SKIP-HOLES를 지정했으므로 빈 요소의 수를 유지하기 위해 DeletePropertyOrThrow를 호출하여 나머지 인덱스를 삭제한다.
  • +
+

11. 객체를 반환한다.

+

ecma official

+

easy-right?

+

Array.prototype.sort의 명세를 보면서 기존의 의문점이 상당히 많이 풀리게 되었다.

+
    +
  • Array.prototype.sort의 명세는 어떻게 되어 있는가? +
      +
    • 위에서 다루었다.
    • +
    +
  • +
  • compareFn의 유무에 따른 sort 함수의 동작은 무엇이 달라지는가? + +
  • +
  • sort 함수는 in-place 하고 stable 한가? +
      +
    • 공식 문서에 따르면 stable 해야 한다.
    • +
    • SortIndexedProperties의 동작을 보면 빈 리스트 items를 생성 후 하나씩 원소를 추가하고 있으므로 in-place 하지 않을 수 있다.
    • +
    +
  • +
  • 유사 배열 객체 또한 sort 함수로 정렬된다. 어떻게 동작하는가? +
      +
    • 공식 스펙 자체가 ToObject로 객체화한 후 처리하고 있으므로 객체 비교를 전제로 동작한다.
    • +
    +
  • +
+

이로써 스펙상의 이야기는 되었다. 하지만, 실제로 브라우저에서 어떻게 구현되어 있는지에 따라 동작이 달라질 수 있으므로 남은 의문의 해결과 실제 sort 함수의 동작을 확인하기 위해 브라우저별 어떻게 구현해 놓았는지 확인해 보자.

+

브라우저별 Sort 구현체

+
    +
  • 브라우저는 Array.prototype.sort의 명세대로 sort를 구현했는가?
  • +
  • 모든 브라우저가 Tim sort로 구현되어 있는가?
  • +
+

이제 위의 질문에 답을 해보자.

+

V8

+
v8/third_party/v8/builtins/array-sort.tq
// https://github.com/v8/v8/blob/12.3.206.1/third_party/v8/builtins/array-sort.tq#L1419
+transitioning javascript builtin ArrayPrototypeSort(
+    js-implicit context: NativeContext, receiver: JSAny)(...arguments): JSAny {
+  // 1. If comparefn is not undefined and IsCallable(comparefn) is false,
+  //    throw a TypeError exception.
+  const comparefnObj: JSAny = arguments[0];
+  const comparefn = Cast<(Undefined | Callable)>(comparefnObj) otherwise
+  ThrowTypeError(MessageTemplate::kBadSortComparisonFunction, comparefnObj);
+ 
+  // 2. Let obj be ? ToObject(this value).
+  const obj: JSReceiver = ToObject(context, receiver);
+ 
+  // 3. Let len be ? ToLength(? Get(obj, "length")).
+  const len: Number = GetLengthProperty(obj);
+ 
+  if (len < 2) return obj;
+ 
+  const isToSorted: constexpr bool = false;
+  const sortState: SortState = NewSortState(obj, comparefn, len, isToSorted);
+  ArrayTimSort(context, sortState);
+ 
+  return obj;
+}
+

V8의 builtin sort 함수인 ArrayPrototypeSort에는 Tim sort가 적용되어 있다. 또한 주석에서도 알 수 있듯 명세의 순서를 따르고 있다.

+

Tim sort는 stable 하지만 in-place하지는 않다(merge sort 보다는 적게 메모리를 사용한다).

+
+

V8 블로그의 글에 따르면 Chrome 70 전에는 퀵 정렬과 삽입 정렬을 혼합해서 사용하고 있었다.

+
+

Webkit

+
Webkit/Source/JavaScriptCore/builtins/ArrayPrototype.js
// https://github.com/WebKit/WebKit/blob/wpewebkit-2.43.1/Source/JavaScriptCore/builtins/ArrayPrototype.js#L509sadfaefafasdf
+function sort(comparator) {
+  "use strict";
+ 
+  var isStringSort = false;
+  if (comparator === @undefined)
+      isStringSort = true;
+  else if (!@isCallable(comparator))
+      @throwTypeError("Array.prototype.sort requires the comparator argument to be a function or undefined");
+ 
+  var receiver = @toObject(this, "Array.prototype.sort requires that |this| not be null or undefined");
+  var receiverLength = @toLength(receiver.length);
+ 
+  // For compatibility with Firefox and Chrome, do nothing observable
+  // to the target array if it has 0 or 1 sortable properties.
+  if (receiverLength < 2)
+      return receiver;
+ 
+  var compacted = [ ];
+  var sorted = null;
+  var undefinedCount = @sortCompact(receiver, receiverLength, compacted, isStringSort);
+ 
+  if (isStringSort) {
+      sorted = @newArrayWithSize(compacted.length);
+      @sortBucketSort(sorted, 0, compacted, 0);
+  } else
+      sorted = @sortMergeSort(compacted, comparator);
+ 
+  @sortCommit(receiver, receiverLength, sorted, undefinedCount);
+  return receiver;
+}
+

Webkit(Safari)의 sort 함수는 스트링일 경우 버킷 정렬을 사용하고 아니라면 합병 정렬(merge sort)을 이용한다. 또한 명세의 순서를 따르고 있다.

+

두 정렬 모두 stable 하다. 하지만 둘 다 in-place 하지 않다.

+

SpiderMonkey

+
gecko-dev/js/src/builtin/Array.js
// https://github.com/mozilla/gecko-dev/blob/661a7d013f6b841e9fbbe56d307cb206f62963c3/js/src/builtin/Array.js#L104
+function ArraySort(comparefn) {
+  // Step 1.
+  if (comparefn !== undefined) {
+    if (!IsCallable(comparefn)) {
+      ThrowTypeError(JSMSG_BAD_SORT_ARG);
+    }
+  }
+  // Step 2.
+  var O = ToObject(this);
+  // First try to sort the array in native code, if that fails, indicated by
+  // returning |false| from ArrayNativeSort, sort it in self-hosted code.
+  if (callFunction(ArrayNativeSort, O, comparefn)) {
+    return O;
+  }
+  // Step 3.
+  var len = ToLength(O.length);
+  // Arrays with less than two elements remain unchanged when sorted.
+  if (len <= 1) {
+    return O;
+  }
+  // Step 4.
+  var wrappedCompareFn = ArraySortCompare(comparefn);
+  // Step 5.
+  // To save effort we will do all of our work on a dense list, then create holes at the end.
+  var denseList = [];
+  var denseLen = 0;
+  for (var i = 0; i < len; i++) {
+    if (i in O) {
+      DefineDataProperty(denseList, denseLen++, O[i]);
+    }
+  }
+  if (denseLen < 1) {
+    return O;
+  }
+  var sorted = MergeSort(denseList, denseLen, wrappedCompareFn);
+  MoveHoles(O, len, sorted, denseLen);
+  return O;
+}
+

SpiderMonkey(Firefox)는 Gecko에 속한 엔진으로, JavaScript 실행에 특화되어 있다. Gecko는 Firefox의 전반적인 렌더링 엔진이다.

+
+

Gecko 깃헙은 mozilla-central 미러링 리포지터리로 Read-only다.

+
+

SpiderMonkey는 합병 정렬을 사용하는데, 합병 정렬의 내부에 최적화 작업을 위해 삽입 정렬을 사용하고 있으며 명세의 순서를 따르고 있다. Tim 정렬과 유사한 부분이 있다.

+

합병 정렬과 삽입 정렬은 모두 stable 하지만 삽입 정렬만 in-place 하다.

+

정리

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
EngineBrowserAlgorithmStableIn-placeECMA Spec
V8ChromeTim sortOXO
WebkitSafariBucket / MergeOXO
SpiderMonkeyFirefoxMerge + InsertionOXO
+
    +
  • 브라우저는 Array.prototype.sort의 명세대로 sort를 구현했는가? +
      +
    • YES
    • +
    +
  • +
  • 모든 브라우저가 Tim sort로 구현되어 있는가? +
      +
    • No
    • +
    +
  • +
+

마무리

+

sort 함수를 이해하면서 공식 문서 및 브라우저들의 소스 코드를 살펴보게 되었다.

+

가볍게 보았음에도 브라우저 코드 형태를 본다거나 ECMAScript를 읽을 수 있게 된 것은 뜻밖의 수확이었다. 가장 큰 수확은 sort뿐만 아니라 다른 명세(map, reduce,...)들에 대한 접근도 두려워하지 않게 되었다는 점이다.

+

처음엔 막막하던 공식 문서도 차근차근 따라가다 보니 읽어 나갈 수 있었다. JavaScript의 암묵적 형 변환과 같은 유연함은 오히려 동작을 이해하기 어렵게 하는 요소가 된다. 공식 문서에서 이러한 동작을 간결하게 표현하기 위한 많은 노력들을 볼 수 있었기에 감동 할 수 있었다.

+

메서드 동작을 명확하게 설명하지 못하는 부분이 늘 존재했었기에 아쉬움이 있었는데 이번 기회로 자신감도 얻고 JavaScript 자체에 더 가까워진 느낌이 든다.

+

재밌었다.

+

참고

+
📮 이 포스트에 관심 있으신가요? 이슈를 남겨주세요! 👍
☕ 소주 한 잔 후원하기
(예금주: 이상철)tosskakao
\ No newline at end of file diff --git a/e2e/__snapshots__/post/dom.spec.ts/mobile/bali-remote-work.html b/e2e/__snapshots__/post/dom.spec.ts/mobile/bali-remote-work.html new file mode 100644 index 00000000..78a5ffd0 --- /dev/null +++ b/e2e/__snapshots__/post/dom.spec.ts/mobile/bali-remote-work.html @@ -0,0 +1,207 @@ +

발리 한 달 리모트 워크 후기

1ilsang
클라이밍 하실래염?
#activity#bali#remote-work
Published

cover

+

시작 계기

+

작년 제주 한 달 리모트 워크 후기가 좋았어서 해외에서 한 달 리모트를 해보자고 결심했다.

+

마침 회사에서 해외 리모트를 허용했기 때문에 또 언제 경험해 볼까 싶어 신청하게 되었다.

+

다양한 선택지가 있었는데, 나는 발리를 선택하게 되었다.

+

사실 그냥 서핑이 하고 싶었다.

+

알아보기

+

면적

+

map

+

발리는 인도네시아에 있는 섬으로, 제주도의 약 3배에 해당한다.

+

또한 세계 1위의 허니문 여행지이기도 하다.

+

시차

+

발리는 서울보다 한 시간 느리다. 서울에서 오후 3시라면 발리에서는 오후 2시이다.

+

날씨

+

강수량

+

4월에서 10월이 비가 적게 오는 건기로 보고있다. 대부분의 여행자 사이트에서는 6월에서 9월을 여행 일자로 추천하고 있다.

+

따라서 나는 2023.07.03 ~ 2023.07.30 7월 한 달간 다녀오기로 했다.

+

서울과 비교해 보면 온도 자체는 더 더운 편이었지만 습하지 않아 상당히 쾌적했다.

+

종교

+

인도네시아 인구의 90%는 이슬람을 믿지만 발리는 90%가 힌두교를 믿는다.

+

발리는 신들의 섬이라 불릴 만큼 다양한 사원이 마을 곳곳에 있어 거리 어디에나 차낭 사리(Canang Sari)라는 제물을 바친다.

+

관광객임을 감안해도 한 달간 종교 문제로 갈등이 있지는 않았다.

+

물가

+

example

+

18,000루피아가 한화로 1,500원 정도인데, 이 돈이면 나시고랭 하나를 먹을 수 있다(ㄷㄷ).

+

항공편

+

인천 -> 방콕 -> 발리 -> 코타키나발루 -> 인천으로 총 54만원이었다.

+

숙소

+

짐바란에서 한 달 1층 독채 에어비엔비 60만원에 지냈다.

+

전기 및 통신

+

카페뿐만 아니라 관광지의 와이파이는 한국과 비교해도 잘되는 편이다. 다만 전기가 잘나간다(인도네시아는 전력 부족 국가다). 이와 관련된 에피소드는 아래에서 풀겠다.

+

LTE가 느리다고 느꼈다. 나는 4G 유심을 샀는데 핫스팟 무지하게 느렸다.

+

교통

+

traffic

+

발리에서 자동차 렌트는 도심 외곽이 아니면 비추다. 오토바이가 훨씬 빠르고 잘 되어있다.

+

고잭/그랩 굿

+

한 달간의 여정

+
+ surf-interior + delivery-food + bibycle +
+

도착 당일

+
    +
  • 늦은 저녁, 공항에 도착했다.
  • +
  • 공항 입구에서부터 서핑보드가 서 있는데(사진 1) 엄청나게 기대됐다.
  • +
  • 입국 심사가 정말 느리다. 너무 배고팠다.
  • +
  • 그랩 정말 저렴했다. 기사님이 친절하셔서 첫인상이 좋았다.
  • +
  • 숙소에 도착하니 Airbnb 호스트가 마중 나오셨다.
  • +
  • 호스트는 일본인 부부셨는데 진짜 말도 안 되게 친절하셨다. 완전 호감이었다.
  • +
  • 따라서 호감작 하기로 마음먹었다. 이 내용은 이후에 나온다.
  • +
  • 너무 배고파서 숙소 도착하자마자 그랩 푸드로 배달했다(사진 2).
  • +
  • 물고기가 상당히 맛있었다.
  • +
+

2일 차

+
    +
  • 여기는 오토바이가 불법이기 때문에 전기 자전거를 한 달간 타고 다니기로 했다(사진 3).
  • +
  • 이 돈이면 오토바이 타는 게 훨씬 저렴한데 아쉬운 감이 없지 않았다.
  • +
  • 전기 자전거 힘이 엄청 좋다. 최고 속력 40까지 봤다(oh...).
  • +
  • ATM에서 백만 루피아씩밖에 안 뽑혀서 킹받았다.
  • +
  • 배달이 싸다. 생선요리가 그나마 비싼 축인 데 저렴하고 맛있음.
  • +
  • 길에 개가 풀려있다. 처음에 당황.
  • +
  • 숙소는 전기를 충전해서 사용해야 한다. 에어컨에 맥북 풀세팅하니 하루에 8씩 나가는 듯하다.
  • +
+

1주일 차

+
    +
  • 나는 관광지보다는 로컬이 많은 곳에 있었다(짐바란).
  • +
  • 그래서인지? 사람들이 다들 친절했다. 많이 웃는다.
  • +
  • 도마뱀이 엄청 많다.
  • +
  • 비가 생각보다 많이 왔다(이때가 우기의 끝자락이었다. 2주 차부터는 한 방울도 안 왔다).
  • +
  • 회사 일하기 바빴다. 아쉬운 부분. 로컬 지역이다 보니 저녁에는 할 게 없었다.
  • +
  • 길의 고저가 크다. 낭떠러지 같은데 도로인 곳이 꽤 있다.
  • +
  • 자전거 타이어 터져서 언덕 밀고 왔는데 진짜 레전드.
  • +
+

busy

+
+

내 거친 코드와 그걸 지켜보는 불안한 눈빛

+
+
+ bird + lizard + monkey + kecak-fire-dance + surf + walk + turtle + medicine + climbing +
+

10일 차

+
    +
  • 자전거 타다가 황천길 갈뻔했다. 낙엽 밟고 미끄러졌는데 바로 옆에서 차가 지나다녔다.
  • +
  • 울루와투 사원 갔는데 케착 파이어 댄스(사진 3) 볼만했다.
  • +
  • 줄 서는데 누가 순수한 선의로 사진 찍고 오라고 줄을 대신 기다려줬다. 인류애는 있었다..!
  • +
  • 원숭이가 아무렇지 않게 물건을 훔쳐 간다.
  • +
  • 저녁을 실외에서 먹는 건 비추다. 벌레가 많다.
  • +
  • 카페에 코딩하는 외국인이 꽤 있다. 말 걸고 싶은데 오지랖인 것 같아 극한으로 참았다.
  • +
+

2주 차

+
    +
  • 갑자기 자주 전기가 끊어졌다. 회의가 많았는데 정말 곤란했다.
  • +
  • 믿었던 LTE 유심마저 잘 안 터졌다. 진짜 곤란했다. 숙소에 발전기 있는지 꼭 확인하자.
  • +
  • 모든 음료에 종이 빨대가 기본인데 너무 빨리 흐물해져서 열받는다.
  • +
  • 현지에 조금 익숙해져서 바로 호감작 시작했다.
  • +
  • 호스트 일본인 부부랑 저녁을 먹었다. 두 분은 발리에서 만났다고 하셨는데 전체 스토리가 로맨틱 그 자체였다. 즐거운 자리여서 또 저녁 먹기로 했다.
  • +
  • 집 앞 카페 알바생과 꽤 친해졌다. 블랙핑크 후광 엄청났다.
  • +
  • 서핑을 시작했다(사진 4). 물 많이 먹긴 했는데 클라이밍 다음으로 재밌다고 느낀 스포츠였다. 바로 다음 스케줄 잡음.
  • +
+

3주 차

+
    +
  • 동네에서 걷기대회?를 주최했다(사진 5). 참여했는데 골목골목마다 사원이 참 많다고 느꼈다.
  • +
  • 거북이 방생하는 캠페인(사진 6)에 참여했다. 인류애 +1 했다.
  • +
  • 배가 너무 아파서 정신 잃을뻔했다. 발리벨리에 걸린 것 같았다. GOAT 약(사진 7) 덕분에 겨우 버텼다.
  • +
  • 발리에서도 클라이밍은 하고 싶었기 때문에 굳이 돈내고 관광지까지 올라가서 탔다(사진 8).
  • +
  • 길을 걷는데 어떤 외국인이랑 우연히 잡담하게 됐다.
  • +
  • 한국 돈을 보여달라고 했는데 현금이 없어서(의심도 됐고) 못 보여줬는데 자신이 사우디에서 왔고 부자라고 어필했다.
  • +
  • ??? 뭐지 하는 데 맥도날드 어디냐고 물어보더라. 희한한 친구였다.
  • +
+

벌써 마지막 주

+
+ canang-sari + la-brisa + la-brisa-pad + motocycle + jungle-water + scuba-diving + merry-go-round + cliff + duck + shop +
+
+

마지막 한 주를 무사히 보내길 염원하며 나도 차낭사리를 켰다.

+
+

일만 하다 돌아가기는 아쉬워서 마지막 주에는 휴가를 냈다. 서핑도 하고 스쿠버다이빙에 클라이밍도 하러 가고 요가도 했다.

+

클라이밍은 가족 단위가 많았는데 꼬맹이들 정신없이 올라가더라...

+

요가가 좀 힙했다. 향 피우고 몽환적인 노래에 축 늘어져 있는데 감성적이었다.

+

워케이션으로 지내던 발리와는 느낌이 너무 달랐다. 휴양지의 발리는 생각보다 비싸고 화려했다.

+

공항에 가기전 친해진 카페 알바나 일본인 호스트랑 이런저런 이야기를 많이 했는데 타지에서 이어진 인연이 신기하고 아쉬웠다.

+

내가 언제 다시 발리를 올지는 모르겠지만 그들이 한국에 온다면 극진히 대접하고 싶다.

+
+ stake + banana-soup + russian + fan-cake + fish + scrumble + duck +
+

음식 이야기가 없으면 안된다고해서;; 음식 사진을 끝으로 마무리 하겠다.

+

마무리

+

good-bye-airport

+

한 달간 해외 리모트 워크를 하면서 "영어"와 "기술"에 대한 갈망이 커졌다.

+
    +
  • 워케이션온 다른 사람들과 가볍게 대화하다 보면 의외의 기회를 잡을 수 있다.
  • +
  • 영어와 기술을 조금 더 준비한다면 내 무대의 제한이 없겠구나 확신했다.
  • +
  • 모든 순간에서 언어가 아쉬웠다. 내 생각을 더 잘 전달하고 싶었다.
  • +
  • 기술을 더 연마해야겠다고 생각했다. 더 빠르게 작업하고 여가를 즐긴 순 없을까?
  • +
  • 기술을 더욱 연마해야겠다고 생각했다. 작은 기술이라도 있었으니 이런 기회가 있지 않았을까?
  • +
  • 기술을 더더욱 연마해야겠다고 생각했다. 팀원들에게 임팩트 있는 사람이 되고 싶다.
  • +
+

나는 무던한 사람인 듯하다.

+
    +
  • 문화 충돌에 큰 거부감이 없었다. 다른 문화에 대한 흥미가 더 컸다.
  • +
  • 다행히도 김치가 그립진 않았다. 현지 음식에 그대로 적응했다.
  • +
  • 타인과 말하는 게 즐거웠다. 상대방에 대한 호기심이 많았다.
  • +
+

내년에는 어디에서 또 어떤 인연을 만날까 두근두근하다.

+

즐거운 한 달이었다.

+

추천하는 장소

+

짐바란

+ +

꾸따

+ +

우붓

+ +

ETC

+
📮 이 포스트에 관심 있으신가요? 이슈를 남겨주세요! 👍
☕ 소주 한 잔 후원하기
(예금주: 이상철)tosskakao
\ No newline at end of file diff --git a/e2e/__snapshots__/post/dom.spec.ts/mobile/deploy-eslint-plugin.html b/e2e/__snapshots__/post/dom.spec.ts/mobile/deploy-eslint-plugin.html new file mode 100644 index 00000000..507874a1 --- /dev/null +++ b/e2e/__snapshots__/post/dom.spec.ts/mobile/deploy-eslint-plugin.html @@ -0,0 +1,151 @@ +

ESLint 플러그인 배포하기

1ilsang
클라이밍 하실래염?
#eslint#plugin#ast
Published

cover

+

ESLint 플러그인 구조를 간단하게 분석하고 커스텀 플러그인을 만들어 배포해 보자.

+

TL;DR!

+
    +
  1. ESLint에서 제공해 주는 generator를 사용해 프로젝트를 만든다.
  2. +
  3. 규칙을 만든다.
  4. +
  5. 배포한다.
  6. +
+

기본 세팅

+

플러그인 구조 만들기

+
# yo는 yeoman의 줄임으로, 스케폴딩 지원 도구다.
+npm install -g yo
+# ESLint 특화 스케폴딩 인터페이스 CLI를 설치한다.
+npm install -g generator-eslint
+

ESLint는 플러그인 구조의 통일을 위해 제너레이터를 제공해 주고 있다.

+

yoyeoman의 줄임으로, 스케폴딩 지원 도구다. 프로젝트에 필요한 디렉토리 및 파일을 커맨드라인으로 생성해 준다.

+

generator-eslint는 yeoman을 활용해 프로젝트를 구조화할 때 ESLint를 기준으로 설치되도록 래핑 된 패키지이다. ESLint에서 관리/지원하고 있으므로 직접 yo로 구조를 설정하지 않아도 어려움 없이 one-line으로 ESLint 구조를 생성할 수 있게 된다.

+

매우 간편하므로 설치한다.

+
# 플러그인 디렉토리 생성
+mkdir eslint-plugin-NAME
+cd eslint-plugin-NAME # 디렉토리로 이동
+ 
+# 전역으로 설치한 yo에서 스케폴딩된 generator-eslint를 실행한다.
+yo eslint:plugin
+? What is your name? GITHUB_NAME # package.json의 author에 추가된다.
+? What is the plugin ID? NAME # 해당 Plugin의 실제 이름(배포 명)이 되므로 적절하게 작성한다.
+? Type a short description of this plugin: PLUGIN_DESCRIPTION # package.json의 description에 나타난다.
+? Does this plugin contain custom ESLint rules? Yes # 커스텀 룰을 추가할 것이므로 Yes.
+? Does this plugin contain one or more processors? No # 우리는 eslint 기본 프로세서를 사용할 것이므로 No를 설정한다.
+ 
+# 초기 세팅이 되었으므로 dependencies를 설치한다.
+npm install
+

default-architecture

+

초기 구조 설정이 완료되면 위와 같이 폴더가 생성된다.

+

ESLint의 다양한 규칙은 lib/rules에 추가되어야 한다. 우리는 커스텀 규칙을 만들 것이므로 해당 디렉토리에 추가해 나가야 한다.

+

플러그인 Rule 구조 만들기

+

친절하게도 ESLint에서 커스텀 룰의 구조도 패키징 해주었기 때문에 yo를 통해 한 번 더 rule 구조를 만들어 준다.

+
var myData = getData123(); // 함수에 숫자가 있으므로 우리의 ESLint 플러그인에서 에러를 발생시킬 것이다!
+

우리는 "함수에 숫자가 있으면 안 된다"는 룰을 만들어 보자.

+
# generator-eslint에 설정되어 있는 rule 옵션으로 yo를 통해 구조를 만든다.
+yo eslint:rule
+? What is your name? 1ilsang # rule 파일에 주석으로 author로 추가된다.
+? Where will this rule be published? ESLint Plugin # core가 아닌 plugin 추가이므로 plugin으로 설정한다.
+? What is the rule ID? no-function-name-number # rule 아이디에 해당한다. rule 파일명이 된다.
+? Type a short description of this rule: The function name must not contain numbers. # rule 설명 추가. 주석으로 파일에 추가된다.
+? Type a short example of the code that will fail: var myData = getData123(); # 에러 케이스를 설정한다. 함수에 숫자가 있으면 안되므로 에러상황이다.
+   create docs/rules/no-function-name-number.md
+   create lib/rules/no-function-name-number.js
+   create tests/lib/rules/no-function-name-number.js
+ 
+No change to package.json was detected. No package manager install will be executed.
+

rule-architecture

+

CLI를 다 작성하면 위와 같이 rules 폴더 밑에 추가된 것을 확인할 수 있다. 이제 우리는 해당 파일에서 규칙을 추가하면 된다.

+

ESLint의 소스코드 파싱과 AST

+

규칙 추가에 앞서 ESLint의 동작 방식을 가볍게 살펴보자.

+

기본적으로 ESLint는 Espree 파서를 사용해 소스코드를 정적 분석한 뒤 AST(Abstract Syntax Tree)를 생성한다.

+

우리는 파싱된 트리에서 구문을 분석해 커스텀 규칙에 위반하는지 확인하면 된다.

+

ast-tree

+

astexplorer 사이트를 활용하면 파싱된 AST 트리를 눈으로 쉽게 볼 수 있다.

+

우리는 함수명을 분석해야 하므로 getData123에 집중한다. 해당 값은 CallExpression > callee > name에 존재한다는 것을 확인할 수 있다.

+

이제 우리의 커스텀 룰에 추가하면 된다!

+

규칙 추가

+
// lib > rules > no-function-name-number.js
+module.exports = {
+  meta: {
+    type: 'problem', // 이 규칙에 위반되는 값은 코드에 없어야 하므로 problem으로 설정한다.
+    docs: {
+      // 해당 규칙에 어긋날 경우 빨간줄 위에 뜨는 문구를 설정할 수 있다.
+      description: 'The function name must not contain numbers.',
+      recommended: true,
+      url: 'https://1ilsang.dev/posts/deploy-eslint-plugin',
+    },
+    fixable: true, // 자동 수정을 추가할 예정으로 true로 한다.
+    schema: [], // 규칙이 여러 옵션을 가지고 있다면 스키마로 분리해 표현할 수 있다.
+  },
+ 
+  create(context) {
+    // 우리의 규칙을 위해 CallExpression > callee > name의 문자열이 숫자가 있는지 확인하면 된다.
+    return {
+      CallExpression(node) {
+        const { callee } = node;
+ 
+        // callee.name에 숫자가 포함되면
+        if (/[0-9]/.test(callee.name)) {
+          context.report({
+            node,
+            data: { wrongFunc: callee.name },
+            // 에러 메시지를 띄운다. wrongFunc는 현재 함수의 토큰 값이다.
+            message: `[{{wrongFunc}}()] 함수에 숫자..?`,
+            // --fix 옵션으로 수정되게 할 수 있다. 숫자를 ''로 치환한다.
+            fix: (fixer) =>
+              fixer.replaceText(callee, callee.name.replaceAll(/[0-9]/g, '')),
+          });
+        }
+      },
+    };
+  },
+};
+
+

각 옵션에 대한 상세 정보는 공식 문서를 읽어보길 추천한다.

+
+

테스트 추가

+

메타테그 및 규칙을 완성하면 테스트를 해보자.

+
// tests > lib > rules > RULE_NAME.js
+ruleTester.run('RULE_NAME', rule, {
+  // 테스트를 통과하는 함수.
+  valid: ['var data = getData();'],
+ 
+  // 테스트를 통과하지 못하는 함수.
+  invalid: [
+    {
+      code: 'var data = getData123();',
+      errors: [
+        {
+          message: '[getData123()] 함수에 숫자..?',
+          type: 'CallExpression',
+        },
+      ],
+    },
+  ],
+});
+

배포하기

+

이제 마지막 단계인 배포를 해보자. npm 로그인이 되어있으면 큰 문제 없이 가능하다.

+
// package.json
+{
+  "name": "eslint-plugin-ID",
+  "version": "0.0.1"
+}
+

ESLint 플러그인은 eslint-plugin prefix가 존재하므로 이름을 지켜준다.

+

배포는 버전을 기준으로 진행하게 되므로 코드 수정내역이 발생해 다시 배포한다면 version을 업데이트해 주어야 한다.

+
npm publish
+

해당 명령어로 배포하면 완료! 만약 ENEEDAUTH 에러가 발생한다면 npm adduser를 통해 로그인을 해주자.

+

사용하기

+

배포된 플러그인을 실제로 사용해 보자. npm i -D eslint-plugin-PLUGIN_ID으로 설치한다.

+
// .eslintrc
+{
+  "plugins": ["PLUGIN_ID"],
+  "rules": {
+    "PLUGIN_ID/RULE": "error"
+  }
+}
+

.eslintrc 파일에 배포된 플러그인 아이디를 설정하고 rule을 지정한다.

+

result

+

이제 IDE에서 함수에 숫자를 사용하면 에러가 노출되는 것을 확인할 수 있다.

+

eslint --fix를 실행하게 되면 isNumber로 함수명이 변경된다.

+

또한 에러 문구의 eslint(plugin/rule)의 링크를 클릭하면 meta > docs > url 값으로 리다이렉트 된다.

+

그럼 이만!

+

Reference

+
📮 이 포스트에 관심 있으신가요? 이슈를 남겨주세요! 👍
☕ 소주 한 잔 후원하기
(예금주: 이상철)tosskakao
\ No newline at end of file diff --git a/e2e/__snapshots__/post/dom.spec.ts/mobile/geultto8-open-source-seminar.html b/e2e/__snapshots__/post/dom.spec.ts/mobile/geultto8-open-source-seminar.html new file mode 100644 index 00000000..eca8ba9f --- /dev/null +++ b/e2e/__snapshots__/post/dom.spec.ts/mobile/geultto8-open-source-seminar.html @@ -0,0 +1,68 @@ +

[글또 세미나] 모여봐요 오픈소스의 숲 발표 후기

1ilsang
클라이밍 하실래염?
#geultto#geultto8#seminar#open-source#eslint#translate
Published

cover

+
+

발표 자료 PDF 다운로드

+
+

글또에서 프런트엔드 반상회를 하게 되었고 발표자를 구하여서 지원하게 되었다.

+

작년 넥스터즈 활동을 마지막으로 외부 발표를 하지 않았는데 이번 기회에 다시 한번 공개적으로 하고 싶다고 생각해 지원하게 되었다.

+

주제 선정

+

프런트엔드 반상회인 만큼 프런트엔드 개발자에게 도움이 되는 내용을 발표하고 싶었다.

+

그래서 주제를 선정하는 도중 "내가 신입일 때 가장 듣고 싶었던 세션이 무엇일까?" 고민하게 되었고 결론은 "오픈소스 분석 방법"이였다.

+

라인에서 처음으로 프런트엔드 커리어를 쌓을 때 받은 미션은 사내 동영상 라이브러리의 유지보수였다. package.json이 뭐 하는 건지도 잘 몰랐기 때문에 UMD, CJS 등은 너무나 생소했고 트리쉐이킹과 다양한 디바이스의 지원은 당시 나에겐 상당히 어려웠다.

+

어쨋건 라이브러리였기 때문에 다른 오픈소스 라이브러리들은 어떻게 개발하고 있는지 틈틈히 분석하면서 늘 이런 오픈소스에 나도 기여하고 싶다고 생각했던것 같다.

+

따라서 나는 초보자를 위한 오픈소스 기여 가이드 및 핵심 코드 진입 방법을 목표로 발표하기로 했다.

+

준비 과정

+

내 세션은 두 가지 내용이 혼합되어 있다.

+
    +
  1. 오픈소스 기여 가이드
  2. +
  3. 핵심 코드 진입 방법
  4. +
+

위의 내용을 통해 오픈소스 코드의 첫 진입점을 찾고 기여할 수 있도록 가이드를 주고자 했다.

+

기여 방식을 설명하는 자리인 만큼 초보 개발자를 위해 익숙하면서도 흥미를 불러올 만한 내용으로 AtZ 친절하게 설명하고자 했다.

+

open source list

+

수많은 오픈소스에서 어떤것을 목표로 할까 고민을했고 역시 내가 기여해 봤고 자주 사용하는 오픈소스 중에서 고르게 되었다.

+

전체적인 구성은 기여 방법을 기준으로 3단계로 나눠 생각했다.

+
    +
  1. [Easy] React.dev 번역 기여와 같이 코드부와 관련 없지만 라이브러리에 기여할수 있는 문서 작업.
  2. +
  3. [Medium] ESLint와 같은 도구의 CLI 진입 코드와 플러그인 구성 방법.
  4. +
  5. [Hard] Jotai와 같이 코드에서 사용되는 코어 라이브러리의 엔트리 진입 방법.
  6. +
+

세 단계를 구분한 이유는 Hard로 갈수록 핵심 코드의 동작 방식을 잘 이해해야 하기 때문이다.

+
    +
  • 번역/문서화 기여는 라이브러리 코드에 진입하지 않기 때문에 비교적 쉽게 기여할 수 있다고 생각한다. 이 단계에서 포크나 PR 방법, 오픈소스 생태계 등을 설명할 예정이다.
  • +
  • ESLint와 같은 개발 도구는 ESLint 자체보단 플러그인에 대해 분석하고자 한다. 코어 로직 주변에서 전반적인 코드 구조/방식을 이해할 수 있어 핵심 코어 기여보단 쉽다고 생각한다. 라이브러리의 확장과 플러그인 구조, CLI 등을 설명하고자 한다.
  • +
  • Jotai와 같은 코어 라이브러리는 라이브러리도 잘 알아야 하고 같이 사용되는(React, Next 등) 코드와의 관계도 이해하고 있어야 하므로 어렵다고 생각한다. 여기는 가볍게 코드 진입과 빌드, 배포에 관해 설명하려고 한다.
  • +
+

어느 정도 틀이 갖춰진 다음에는 빠르게 PPT 작업을 할 수 있었다.

+

20분 발표이고 청중이 초보 개발자인만큼 가볍게 다양한 기여 예제를 보여주고자 했다.

+

장표의 마지막에는 오픈소스에 지속해서 노출되는 방법을 추가하며 마무리했다.

+

발표 당일

+

timetable

+

booth

+

타임테이블이나 스티커의 디자인이 깔끔하게 잘 뽑혔다. 운영진분들의 노고가 느껴졌다.

+
+

압도적 감사..!

+
+

세션은 팀 스파르타에서 진행되었는데 내부가 탁 트이고 깔끔해서 세션하기에 좋은 장소였다.

+
+

start

+

발표는 크게 떨리진 않았던 것 같다.

+

스무스한 행사 진행에 힘입어 청중들도 잘 호응해 주셨다.

+

혼자 떠드는 발표를 하고 싶지 않아서 청중과 눈을 마주친다거나 질문을 한다거나 여유 있게 행동하고 싶었는데 매우 쉽지 않았다.ㅋ

+

장표를 넘기기 전에 다들 어떻게 듣고 계시는지 궁금해서 종종 청중들을 바라봤는데 다들 엄청나게 집중해 주셔서 압도적 감사함을 느꼈다. 그래서인지 자신감을 가지고 더 여유롭게 떨지 않으면서 발표할 수 있었다.

+

확실히 열의 있는 분들과 함께 현장에서 발표하 는게 훨씬 좋고 인상적이라 느꼈다. 영상으로 발표할때는 오히려 힘들었다.

+
+

end

+

20분 정말 짧았다. 마이크 들자마자 끝난 느낌이었다.

+

10분간 QnA도 진행되었는데 생각보다 질문을 많이 해주셔서 조금 기뻤다.

+

특히 인상 깊었던 질문 중 하나는 기여하고 싶은 라이브러리의 조건에 대한 질문이었는데 내가 회사에서 라이브러리를 개발하면서 중요하게 생각했던 부분을 말할 수 있었다.

+

아무리 좋은 라이브러리라도 결국은 다른 개발자가 사용하기에 "편해야"한다. 나는 라이브러리는 DX가 무엇보다 중요하다고 생각한다. 따라서 문서화를 비롯해 IDE 단계에서의 편리함(코드의 간결함이나 타입 추론과 주석 등)이 기여하고 싶은 라이브러리의 조건이 되지 않을까 생각한다.

+

소신것 준비한 만큼 후회 없이 발표했다.

+

마치며

+

flower

+

연사자들에게 꽃을 나눠주셨는데 매우 민망(ㅋㅋ) 감사합니다.

+

발표를 준비하면서 스스로 공부가 많이 되었다.

+

앞으로도 꾸준히 공부해서 좋은 내용으로 다른 분들에게 공유할 수 있도록 노력하고자 한다.

+

열정이 솟아난 8월이었다.

+
+

발표 자료 PDF 다운로드

+
📮 이 포스트에 관심 있으신가요? 이슈를 남겨주세요! 👍
☕ 소주 한 잔 후원하기
(예금주: 이상철)tosskakao
\ No newline at end of file diff --git a/e2e/__snapshots__/post/dom.spec.ts/mobile/google-adsense.html b/e2e/__snapshots__/post/dom.spec.ts/mobile/google-adsense.html new file mode 100644 index 00000000..ef985c08 --- /dev/null +++ b/e2e/__snapshots__/post/dom.spec.ts/mobile/google-adsense.html @@ -0,0 +1,147 @@ +

[Next.js] Google AdSense 광고 적용 및 이해하기

1ilsang
클라이밍 하실래염?
#google#ad#ads#adsense#nextjs
Published

cover

+

Google AdSense를 적용한 내용을 정리해 보려고 한다.

+

Index

+ +

AdSense 적용 결과

+
+ad-bottom-mobile +ad-left-rail-desktop +
+

모바일 화면에서는 바텀 앵커 영역, 데스크탑 화면에서는 좌측 레일 부분에 광고를 실었다(현재는 제거).

+

광고 컴포넌트 영역은 구글이 자동으로 설정하게 할 수도 있고 직접 특정 영역만 설정할 수 있다.

+

이 부분들은 아래에서 자세히 다루겠다.

+ +

맨 처음 광고를 달고자 마음먹으면 곧바로 광고 도메인과 관련된 다양한 키워드에 혼란을 느끼게 된다.

+

구글 공식 문서에 따른 Google AdSense와 Ads의 차이점은 광고 게시자인지 광고주인지에 따라 다르게 선택할 수 있는 플랫폼임을 알려주고 있다.

+

우리는 광고 게시자에 해당하기 때문에 AdSense를 적용해야 한다.

+

AdSense 설정하기

+

사이트 등록

+

site register

+

맨 처음 해야 할 일은 AdSense에 광고를 실을 사이트를 등록하는 것이다.

+

사이트 등록 후 광고 스크립트 한줄만 사이트에 추가하면 사실상의 모든 과정이 끝난다(!).

+

하지만 구글 애드센스를 적용하기 위해선 사이트 심사를 통과해야 한다. 심사 기간은 최대 2주 정도가 소요되며 사이트의 컨텐츠가 갖추어지지 않으면 거절될 수 있다.

+

관련 내용을 살펴보니 설정된 페이지가 주기적으로 조회되지 않으면 심사가 최대 6주까지도 소요된다고 한다.

+

하지만 큰 문제가 없다면 하루 내로 심사가 통과되는 듯하며 이 블로그 또한 하루 내에 심사가 통과되었다.

+

사이트 등록을 했다면 광고 스크립트를 적용해 보자.

+

광고 컴포넌트 적용

+

ad script register

+

사이트 심사 및 소유주 확인을 위해 원하는 확인 방법을 선택해 적용하면 된다.

+

여기 UI가 이상한데, 3개 중 하나를 적용하는 게 아니다.

+

애드센스 코드 스니펫 혹은 메타 태그로 사이트 심사를 받고 Ads.txt로 소유주 확인을 해야 한다.

+

죽, 애드센스 코드 스니펫 혹은 메타 태그 + Ads.txt 2가지를 적용해야 한다.

+

Ads.txt는 아래 사이트 소유권 확인에서 다루겠다. 지금은 넘어가도 된다.

+

나는 애드센스 코드 스니펫을 적용했다.

+
import Script from 'next/script';
+ 
+export const GoogleAdSense: FunctionComponent = () => {
+  if (process.env.NODE_ENV !== 'production') {
+    return null;
+  }
+  return (
+    <Script
+      async
+      src={`https://pagead2.googlesyndication.com/pagead/js/adsbygoogle.js?client=ca-pub-${PID}`}
+      crossOrigin="anonymous"
+      strategy="lazyOnload"
+    />
+  );
+};
+

production에서만 적용되어야 하므로 분기 처리하고 애드센스 코드를 그대로 심어준다. Script strategy 및 자세한 내용은 여기를 참고.

+
app/layout.tsx
export default function RootLayout({ children }) {
+  return (
+    <html>
+      <body>{children}</body>
+      <GoogleAdSense />
+    </html>
+  );
+}
+

그 후 앱의 최상단 레이아웃에 컴포넌트를 추가해 주면 끝난다.

+

이제 사이트 심사가 통과될 때까지 기다리면 된다.

+

사이트 소유권 확인

+

ads text

+

심사를 무사히 통과했다면 소유권을 인증해야 한다. ads.txt를 설정하라고 알려준다.

+
public/ads.txt
google.com, pub-ID, DIRECT, HASH_CODE
+

public/ads.txt를 생성하고 ads.txt 스니펫을 추가한다.

+

이후 구글봇이 돌면서 추가내역을 확인하게 된다.

+

광고 컴포넌트 영역 설정

+

이제 가장 중요한 광고 컴포넌트 영역을 설정해야 한다.

+
+

auto

+

direct

+
+
+

(좌) 사이트 기준 자동 삽입 / (우) 광고 단위 기준 직접 설정

+
+

홈의 광고 탭으로 들어오면 컴포넌트 설정을 할 수 있다. 광고 컴포넌트는 사이트 기준 자동 삽입 혹은 직접 광고 단위 기준으로 추가할 수 있다.

+
    +
  • 사이트 기준 +
      +
    • AdSense 스크립트가 자동으로 페이지 내부에 광고를 삽입
    • +
    +
  • +
  • 광고 단위 기준 +
      +
    • 광고 크기 및 위치를 직접 설정
    • +
    +
  • +
+

사이트 기준

+

ad-exclusive-area

+

사이트 기준으로 할 경우 오버레이, 인페이지 등에서 광고 추가 여부를 UI로 확인/선택할 수 있다.

+

광고가 기재되길 원하지 않는다면 영역을 제외하거나 페이지 자체를 추가할 수 있다.

+

광고 단위 기준

+

ad-target

+

원하는 광고 단위를 선택후 만들면 자동으로 HTML이 생성된다.

+
declare global {
+  interface Window {
+    adsbygoogle: any;
+  }
+}
+ 
+export const GoogleAdSenseComponent = () => {
+  useEffect(() => {
+    (window.adsbygoogle = window.adsbygoogle || []).push({});
+  }, []);
+ 
+  return (
+    <ins
+      class="adsbygoogle"
+      style={{ display: 'block' }}
+      data-ad-client="PID"
+      data-ad-slot="SLOT_KEY"
+      data-ad-format="auto"
+      data-full-width-responsive="true"
+    />
+  );
+};
+

이제 원하는 위치에 컴포넌트를 배치하면 된다.

+

지급 정보 설정

+

가장 중요한 지급 정보(계좌, SWIFT 코드 등)는 $100 이상 되어야 입력할 수 있다(-_-).

+

따라서 주기적으로 광고 실적을 확인해볼 필요성이 있다.

+

수익 구조

+

profit structure

+

애드센스 작동 원리에 따르면 우리가 설정한 광고 컴포넌트에 입찰가가 가장 높은 광고를 기준으로 게재된다고 한다.

+

이때 수익 배분은 사용 중인 제품에 따라 달라지는데 콘텐츠 광고는 게시자가 68%의 수익 지분을 가진다.

+

마무리

+

이로써 AdSense 사용법을 간단하게 살펴봤다. 사용성이 좋기 때문에 특별히 어려운 부분은 없었다.

+

이후 사이드 프로젝트에 꼭 적용해 보길 기원하면서 글을 마무리해본다.

📮 이 포스트에 관심 있으신가요? 이슈를 남겨주세요! 👍
☕ 소주 한 잔 후원하기
(예금주: 이상철)tosskakao
\ No newline at end of file diff --git a/e2e/__snapshots__/post/dom.spec.ts/mobile/goorm-195687.html b/e2e/__snapshots__/post/dom.spec.ts/mobile/goorm-195687.html new file mode 100644 index 00000000..6d83e4dd --- /dev/null +++ b/e2e/__snapshots__/post/dom.spec.ts/mobile/goorm-195687.html @@ -0,0 +1,66 @@ +

[구름톤 챌린지] 이진수 정렬

1ilsang
클라이밍 하실래염?
#algorithm#goorm#binary#memoization
Published

cover

+
+

문제 링크

+
+

10진수 숫자를 2진법으로 변환후 1의 개수가 가장 많은 순부터 정렬해 K번째 위치 값을 출력하면 되는 문제다.

+

접근법

+

N을 순회하면서 각각 2진수로 변환하고 그 값을 저장해 나간다면(O(N^2)) 너무 재미없다.

+

따라서 우리는 메모를 사용해 이전 값을 활용해 다음 값을 구해 나갈 것이다. 이 방법을 사용하면 O(N)으로 처리가 가능하다.

+
// 숫자별 1의 개수를 정리해 본다.
+// Index: [0,1,2,3,4,5,6,7,8,9];
+// Count: [0,1,1,2,1,2,2,3,1,2];
+

9까지의 수를 2진수의 개수로 표현하면 위의 표와 같아진다. 여기서 우리는 두 가지 패턴을 찾을 수 있다.

+
    +
  1. 2의 지수승(2^n)은 무조건 1이다(1, 2, 4, 8은 2진수에서 무조건 1이다).
  2. +
  3. 현재 값에서 2를 나눈 값의 1의 개수와 현재 값을 2로 나눈 나머지를 더하면 현재 값의 1의 개수가 된다.
  4. +
+

7을 기준으로 해보자.

+
7/2 = 3 => 2
+7%2 = 1
+=> 7 = 3
+
    +
  • 7을 2로 나누면 3이 된다. 위의 표에서 3의 1 개수는 2이다.
  • +
  • 7을 2로 나눈 나머지는 1이다.
  • +
  • 2 + 1 = 3이므로 7은 3이 된다.
  • +
+

이전 값을 알면 현재 값을 손쉽게 구할 수 있게 되었다.

+

그러므로 2부터 포문을 돌리면서 메모 배열을 만들고 최댓값을 찾아나가면 된다.

+

정리

+
    +
  1. 메모이제이션을 활용해 들어올 수 있는 숫자의 최댓값 2^20(1048576)까지 이진수의 개수를 구한다.
  2. +
  3. N을 메모이제이션 배열로 정렬한다.
  4. +
  5. K 번 인덱스의 값을 출력한다.
  6. +
+

최종 코드

+
let N, K;
+rl.on('line', (line) => {
+  if (typeof N === 'undefined') {
+    const [n, k] = line.split(' ').map((num) => Number(num));
+    N = n;
+    K = k;
+    return;
+  }
+  const nums = line.split(' ').map((num) => Number(num));
+  const memo = [0, 1];
+ 
+  // 메모 처리
+  for (let i = 2; i <= 1048576; i++) {
+    const before = memo[Math.floor(i / 2)];
+    const remain = i % 2;
+    memo[i] = before + remain;
+  }
+ 
+  // input을 순회하면서 메모 값을 기준으로 정렬한다.
+  const sortedList = nums.sort((a, b) => {
+    const am = memo[a];
+    const bm = memo[b];
+    // 만약 메모 값이 같다면(1의 개수가 동일하다면) 10진수를 기준으로 정렬
+    if (am === bm) {
+      return b - a;
+    }
+    return bm - am;
+  });
+ 
+  console.log(sortedList[K - 1]);
+  rl.close();
+});
📮 이 포스트에 관심 있으신가요? 이슈를 남겨주세요! 👍
☕ 소주 한 잔 후원하기
(예금주: 이상철)tosskakao
\ No newline at end of file diff --git a/e2e/__snapshots__/post/dom.spec.ts/mobile/goorm-195692.html b/e2e/__snapshots__/post/dom.spec.ts/mobile/goorm-195692.html new file mode 100644 index 00000000..3c4e96da --- /dev/null +++ b/e2e/__snapshots__/post/dom.spec.ts/mobile/goorm-195692.html @@ -0,0 +1,145 @@ +

[구름톤 챌린지] GameJam

1ilsang
클라이밍 하실래염?
#algorithm#goorm#simulation#brute-force
Published

cover

+
+

문제 링크

+
+

끔찍한 시뮬레이션 문제이다.

+

별다른 특이 사항 없이 문제에서 제시한 대로 구현하면 된다.

+

접근법

+

구름과 플레이어 두 명이 각각 게임을 진행한다. 따라서 우리는 동일한 게임을 2번 실행해야 한다는 것을 알 수 있다.

+

초기화를 잘하던가, 똑같은 코드를 두 번 실행해야 한다.

+

게임 보드 칸에는 이동 횟수와 방향이 적혀있으며 게이머가 놓은 위치에서부터 칸의 명령을 따라 쭉 실행하면 된다.

+

만약 보드를 이탈하는 경우(처음/끝) 반대쪽 첫 칸으로 이동하게 된다(-1 -> length -1, length -> 0),

+

이때 이전에 방문했던 곳에 다시 온다면 게임이 종료된다. 우리는 두 번의 메모 맵이 필요하다.

+

두 플레이어가 게임을 종료했을 때 점수를 비교해 출력한다.

+

정리

+
    +
  1. 유저의 위치를 받아 점수를 반환하는 함수를 만든다(내부 변수 사용으로 초기화 용이).
  2. +
  3. 이동 거리만큼 이동한다. +
      +
    1. 보드 이탈시 좌표를 반대쪽 첫 칸으로 재설정해 준다.
    2. +
    3. 이동할 때마다 메모를 한다.
    4. +
    +
  4. +
  5. 점수 값을 비교 출력한다.
  6. +
+

최종 코드

+
const readline = require('readline');
+let rl = readline.createInterface({
+  input: process.stdin,
+  output: process.stdout,
+});
+let n;
+const goorm = [];
+const player = [];
+const map = [];
+const d = {
+  U: [-1, 0],
+  R: [0, 1],
+  D: [1, 0],
+  L: [0, -1],
+};
+const getBoardInfo = ({ r, c }) => {
+  const cmd = map[r][c];
+  const count = parseInt(cmd);
+  const direction = cmd.slice(-1);
+  return {
+    cmd,
+    count,
+    direction,
+  };
+};
+const setNextMove = ({ r, c, direction }) => {
+  let nr = r + d[direction][0];
+  let nc = c + d[direction][1];
+ 
+  if (nr < 0) {
+    nr = map.length - 1;
+  } else if (nc < 0) {
+    nc = map[0].length - 1;
+  } else if (nr >= map.length) {
+    nr = 0;
+  } else if (nc >= map[0].length) {
+    nc = 0;
+  }
+  return {
+    nr,
+    nc,
+  };
+};
+const buildMemo = () => {
+  const memo = map.map((row) => {
+    return Array(row.length).fill(0);
+  });
+  return memo;
+};
+const playGame = ({ r, c }) => {
+  // 최초 세팅. 현재 칸의 명령을 파싱한다.
+  let { cmd, count, direction } = getBoardInfo({ r, c });
+  let nr = r;
+  let nc = c;
+  let score = 1;
+ 
+  const memo = buildMemo();
+  memo[r][c] = 1;
+ 
+  while (true) {
+    // 이동 횟수가 0이라면 현재 위치 칸의 명령으로 초기화 한다.
+    if (count === 0) {
+      const curValues = getBoardInfo({ r: nr, c: nc });
+      cmd = curValues.cmd;
+      count = curValues.count;
+      direction = curValues.direction;
+    }
+    // 다음 이동을 위해 nextRow, nextCol 값을 세팅한다.
+    const nextPosition = setNextMove({ r: nr, c: nc, direction });
+    nr = nextPosition.nr;
+    nc = nextPosition.nc;
+    // 만약 다음 좌표가 방문한적이 있다면 루프를 종료한다.
+    if (memo[nr][nc]) {
+      break;
+    }
+    // 이동 거리를 감소시키고 스코어를 추가한뒤 좌표를 메모한다.
+    count--;
+    score++;
+    memo[nr][nc] = 1;
+  }
+  return score;
+};
+const getParsedLineNumbers = (line) =>
+  line.split(' ').map((num) => Number(num) - 1);
+const setFields = (line) => {
+  const parsedLine = line.split(' ');
+  map.push(parsedLine);
+};
+ 
+rl.on('line', (line) => {
+  if (n === undefined) {
+    n = Number(line);
+    return;
+  }
+  if (goorm.length === 0) {
+    goorm.push(...getParsedLineNumbers(line));
+    return;
+  }
+  if (player.length === 0) {
+    player.push(...getParsedLineNumbers(line));
+    return;
+  }
+  if (map.length < n) {
+    setFields(line);
+    if (map.length < n) {
+      return;
+    }
+  }
+  const gScore = playGame({
+    r: goorm[0],
+    c: goorm[1],
+  });
+  const pScore = playGame({
+    r: player[0],
+    c: player[1],
+  });
+  const answer = gScore > pScore ? `goorm ${gScore}` : `player ${pScore}`;
+  console.log(answer);
+  rl.close();
+});
📮 이 포스트에 관심 있으신가요? 이슈를 남겨주세요! 👍
☕ 소주 한 잔 후원하기
(예금주: 이상철)tosskakao
\ No newline at end of file diff --git a/e2e/__snapshots__/post/dom.spec.ts/mobile/goorm-195693.html b/e2e/__snapshots__/post/dom.spec.ts/mobile/goorm-195693.html new file mode 100644 index 00000000..4c13aad0 --- /dev/null +++ b/e2e/__snapshots__/post/dom.spec.ts/mobile/goorm-195693.html @@ -0,0 +1,83 @@ +

[구름톤 챌린지] 통증2

1ilsang
클라이밍 하실래염?
#algorithm#goorm#brute-force#dynamic-programming
Published

cover

+
+

문제 링크

+
+

아이템 A, B를 최소한으로 사용하여 통증을 0으로 맞출 수 있는지 확인하는 문제이다. 불가능하다면 -1을 출력한다.

+

접근법

+

1. 완전 탐색

+

A와 B의 합이 N이 될 때까지 전체 조합을 탐색한다.

+

A가 0...I개 일때 B가 0...J개로 가능한지 확인할 수 있다. 이때 개수로 확인하게 되면 A가 I번 만큼 순회할 때마다 B가 J번 만큼 순회하게 되므로 시간초과가 발생한다.

+

A가 0...I개 일때 B는 0부터 하나씩 올려가며 찾지 않고 N-A가 B로 나누어지는지를 확인하면 O(N)만에 해결이 가능해진다.

+
let answer = Infinity;
+for (let aCost = 0; aCost <= n; aCost += a) {
+  // N에 A를 뺀 값이 B로 나누어떨어지지 않는다면 패스한다.
+  if ((n - aCost) % b !== 0) continue;
+  const aCount = Math.floor(aCost / a);
+  const bCount = Math.floor((n - aCost) / b);
+  const count = aCount + bCount;
+  // 최소 개수를 출력해야 하므로 현재 값이 answer보다 작다면 갱신한다.
+  if (count < answer) {
+    answer = count;
+  }
+}
+// answer가 무한이라면 가능한 조합이 없다는 의미이므로 -1로 변경한다.
+if (answer === Infinity) {
+  answer = -1;
+}
+console.log(answer);
+

2. DP

+

개수가 기준이 아닌 통증의 값 N을 기준으로 생각해 보자.

+

통증 N은 [N - A] + 1 혹은 [N - B] + 1이 될 수 있다. 현재 N값이 되기 위해선 A혹은 B를 더했으므로 역산으로 A 혹은 B를 뺀 개수에 현재 카운트 1을 추가하면 된다.

+

따라서 통증 0부터 N까지 순회하며 dp 테이블을 채워나가면 O(N)으로 처리가 가능해진다.

+
+

관련 문제로 이진수 정렬의 memo 배열이 채워지는 방식과 같다.

+
+
const dp = Array(n + 1).fill(Infinity);
+dp[0] = 0;
+ 
+for (let i = 1; i <= n; i++) {
+  if (i - a >= 0) {
+    dp[i] = Math.min(dp[i - a] + 1, dp[i]);
+  }
+  if (i - b >= 0) {
+    dp[i] = Math.min(dp[i - b] + 1, dp[i]);
+  }
+}
+console.log(dp[n] === Infinity ? -1 : dp[n]);
+

정리

+
    +
  1. 통증 N의 아이템 사용 개수는 N - A 혹은 N - B 값의 +1이다.
  2. +
  3. DP 테이블을 0부터 N까지 채운다.
  4. +
  5. DP[n] 값을 출력한다.
  6. +
+

최종 코드

+
const readline = require('readline');
+let rl = readline.createInterface({
+  input: process.stdin,
+  output: process.stdout,
+});
+let n;
+rl.on('line', (line) => {
+  if (n === undefined) {
+    n = Number(line);
+    return;
+  }
+  const [a, b] = line.split(' ').map((item) => Number(item));
+  const dp = Array(n + 1).fill(Infinity);
+  dp[0] = 0;
+ 
+  for (let i = 1; i <= n; i++) {
+    if (i - a >= 0) {
+      dp[i] = Math.min(dp[i - a] + 1, dp[i]);
+    }
+    if (i - b >= 0) {
+      dp[i] = Math.min(dp[i - b] + 1, dp[i]);
+    }
+  }
+  console.log(dp[n] === Infinity ? -1 : dp[n]);
+  rl.close();
+});
+ 
+rl.on('close', () => {
+  // console.log("Hello Goorm! Your input is " + input);
+});
📮 이 포스트에 관심 있으신가요? 이슈를 남겨주세요! 👍
☕ 소주 한 잔 후원하기
(예금주: 이상철)tosskakao
\ No newline at end of file diff --git a/e2e/__snapshots__/post/dom.spec.ts/mobile/goorm-195696.html b/e2e/__snapshots__/post/dom.spec.ts/mobile/goorm-195696.html new file mode 100644 index 00000000..c2e841f8 --- /dev/null +++ b/e2e/__snapshots__/post/dom.spec.ts/mobile/goorm-195696.html @@ -0,0 +1,81 @@ +

[구름톤 챌린지] 작은 노드

1ilsang
클라이밍 하실래염?
#algorithm#goorm#graph#sort
Published

cover

+
+

문제 링크

+
+

양방향 그래프에서 시작 점으로부터 더 이상 갈 수 없을 때까지 이동한 뒤 출력하면 되는 문제다.

+

접근법

+
// 양방향 그래프
+map[i].push(j); // i 노드가 j 노드로 방향성을 가지고 있다.
+map[j].push(i); // j 노드가 i 노드로 방향성을 가지고 있다.
+console.log(map);
+// { i: [j, ...], j: [i, ...] }
+

양방향 그래프이기 때문에 그래프를 만들 때 좌우 둘 다 추가해 준다.

+

각 정점에서 방문하지 않았던 번호가 낮은 노드로 이동하기 때문에 각 정점의 값은 정렬되어 있어야 한다.

+
[100, 30, 200].sort();
+// [100, 200, 30]
+

유의. 자바스크립트에서 sort함수에 비교함수 콜백을 작성하지 않으면 ASCII값을 기준으로 정렬한다.

+

따라서 "문자"로 비교하기 때문에 30, 100, 200이 아닌 첫 번째 문자의 아스키 값의 우선순위에 따라 100, 200, 30이 출력된다.

+
// a, b중 큰 값은 뒤로(+) 작은 값은 앞으로(-) 가게 된다.
+[100, 30, 200].sort((a, b) => a - b);
+// [30, 100, 200]
+ 
+// 0이므로 a, b는 서로 변경되지 않는다.
+[100, 30, 200].sort((a, b) => 0);
+// [100, 30, 200]
+ 
+// a, b중 큰 값은 앞으로(-) 작은 값은 뒤로(+)가게 된다.
+[100, 30, 200].sort((a, b) => b - a);
+// [200, 100, 30]
+

따라서 비교 함수 콜백을 통해 정렬의 우선순위를 먼저 지정해야 한다.

+
    +
  • 콜백 함수의 리턴값이 0보다 작은 경우(음수) a를 b보다 낮은 인덱스로 정렬한다(a가 먼저 오므로 오름차순이 됨).
  • +
  • 콜백 함수의 리턴값이 0인 경우 a와 b를 서로 변경하지 않고 다른 요소에 대해 정렬한다(현재 두 값으로는 비교 X).
  • +
  • 콜백 함수의 리턴값이 0보다 큰 경우(양수) a를 b보다 높은 인덱스로 정렬한다(b가 먼저 오므로 내림차순이 됨).
  • +
+

방문한 횟수는 방문(메모) 배열에 값을 추가해 나가다 마지막에 배열 길이를 출력하면 된다.

+

정리

+
    +
  1. 양방향 그래프를 그린다.
  2. +
  3. 메모 배열을 활용해 방문 시 체크해 준다.
  4. +
  5. 마지막 노드와 메모 배열의 길이를 출력한다.
  6. +
  7. sort 함수는 문자(ASCII)를 기준으로 정렬하기 때문에 유의해야 한다.
  8. +
+

최종 코드

+
const readline = require('readline');
+let rl = readline.createInterface({
+  input: process.stdin,
+  output: process.stdout,
+});
+const input = [];
+rl.on('line', (line) => {
+  input.push(line);
+});
+ 
+rl.on('close', () => {
+  const map = {};
+ 
+  const [n, m, k] = input[0].split(' ').map(Number);
+  for (const line of input.slice(1)) {
+    const [i, j] = line.split(' ').map(Number);
+    if (!map[i]) map[i] = [];
+    if (!map[j]) map[j] = [];
+    // 양방향 그래프 생성
+    map[i].push(j);
+    map[j].push(i);
+  }
+ 
+  // k 노드부터 출발할 예정이므로 메모와 last 값을 초기화한다.
+  const memo = [k];
+  let last = k;
+  while (true) {
+    // 현재 노드의 값이 없다면 이어진 정점이 없으므로 탈출한다.
+    if (!map[last]) break;
+    const next = map[last]
+      .filter((node) => !memo.includes(node)) // 방문한 적이 없는 값들만 필터링한다.
+      .sort((a, b) => a - b)[0]; // 방문하지 않은 정점들을 오름차순 정렬해 첫 번째 값을 꺼낸다.
+    if (!next) break; // next가 없다면 해당 노드에서 갈 수 있는 모든 정점을 방문한 상태이므로 종료한다.
+    last = next; // 마지막 노드를 갱신한다.
+    memo.push(last); // 메모에 마지막 노드를 추가한다.
+  }
+  console.log(`${memo.length} ${last}`);
+});
📮 이 포스트에 관심 있으신가요? 이슈를 남겨주세요! 👍
☕ 소주 한 잔 후원하기
(예금주: 이상철)tosskakao
\ No newline at end of file diff --git a/e2e/__snapshots__/post/dom.spec.ts/mobile/goorm-195698.html b/e2e/__snapshots__/post/dom.spec.ts/mobile/goorm-195698.html new file mode 100644 index 00000000..786bffe7 --- /dev/null +++ b/e2e/__snapshots__/post/dom.spec.ts/mobile/goorm-195698.html @@ -0,0 +1,147 @@ +

[구름톤 챌린지] 연합

1ilsang
클라이밍 하실래염?
#algorithm#goorm#graph#bfs#union-find
Published

cover

+
+

문제 링크

+
+

노드 사이에 사이클이 발생하면 "연합"이 된다. 각 사이클의 노드가 인접하면 인접한 사이클끼리도 연합이 된다. 이 연합 집합의 개수를 출력하는 문제이다.

+

이 문제는 그래프에서 사이클을 찾고 집합의 구성을 분류하는 기초적인 문제라 개념 잡기에 좋은 문제인듯 하다.

+

접근법

+

사이클을 찾고 해당 사이클이 집합을 이루는지 찾아야 한다. 이 문제는 BFS 또는 Union-Find로 풀릴 수 있다.

+

먼저 각 노드가 사이클을 가지고 있는지는 어떻게 확인할까? 무지성 includes를 해도 되지만 O(1)로 찾지 않으면 시간초과가 발생한다.

+
// 그래프의 방향성을 저장하기 위한 2차원 배열을 생성한다.
+const check = Array.from(Array(n + 1), () => Array(n + 1).fill(0));
+// cur 노드에서 next 노드로 방향성을 가지고 있다면 체크해 준다.
+check[cur][next] = 1;
+ 
+// 현재 노드와 다음 노드의 방향성이 둘 다 1이라면 사이클이다.
+check[cur][next] === 1 && check[next][cur] === 1;
+

문제가 친절하게도 각 정점 사이의 직접 사이클만 요구하므로 사이클을 더 쉽게 찾을 수 있다.

+

각 노드의 사이클 여부를 확인할 수 있게 되었으니 집합을 구성해 주어야 한다.

+

BFS로 집합 구성하기

+

인접한 노드끼리 사이클이면서 사이클끼리 인접하면 연합이 된다.

+

따라서 우리는 각 노드를 순회하면서 사이클을 찾고 사이클이 되는 노드에서 다시 사이클을 찾는 식으로 BFS 탐색을 하면 된다.

+
const bfs = (i) => {
+  const q = [i];
+  memo[i] = 1;
+  while (q.length) {
+    // 현재 노드에서 사이클을 찾아간다.
+    const cur = q.shift();
+    if (!map[cur]) break; // 현재 노드에서 이어진 노드가 없다면 루프를 탈출한다.
+    const nextList = map[cur];
+    for (const next of nextList) {
+      // 다음 노드가 현재 노드로 사이클이 없거나 방문한 적이 있으면 패스한다.
+      if (!check[next][cur] || memo[next]) continue;
+      // 다음 노드와 사이클이면서 방문한 적이 없기 때문에 큐에 넣어준다.
+      memo[next] = 1;
+      q.push(next);
+    }
+  }
+};
+let answer = 0;
+for (let i = 1; i <= n; i++) {
+  if (memo[i]) continue;
+  bfs(i);
+  // BFS에서 탈출했다면 연합 한 개가 구성된 것이므로 카운트를 추가한다.
+  answer++;
+}
+console.log(answer);
+

따라서 각 노드에서 사이클이 되는 노드를 찾고 해당 노드를 큐에 넣어줌으로써 연속된 사이클을 계속해서 찾을 수 있다.

+

Union-Find로 집합 구성하기

+

그래프의 집합을 나타내는 방법으로 Union-Find를 사용할 수 있다.

+
//     index    0  1  2  3  4  5  6  7
+const parent = [0, 1, 1, 1, 4, 5, 4, 4];
+// 1,2,3 인덱스 노드는 부모 노드가 1인 집합이 된다.
+// 4,6,7 인덱스 노드는 부모 노드가 4인 집합이 된다.
+// 0,5 인덱스 노드는 자기 자신만 집합인 노드가 된다.
+ 
+for (let cur = 1; cur <= n; cur++) {
+  const curList = map[cur];
+  for (const next of curList || []) {
+    // next -> cur로 이어져 있지 않다는 것은 사이클이 아니므로 무시한다.
+    if (!check[next][cur]) continue;
+    // 현재 노드와 다음 노드가 사이클이라면 집합을 구한다.
+    union(parent, cur, next);
+  }
+}
+

노드를 순회하면서 Union 조건이 성립(사이클)한다면 각 노드를 합쳐준다.

+

이후 각 노드의 부모 노드를 find 한다. 노드의 부모가 같다면 같은 집합이라는 뜻이 된다.

+

Union-Find에서는 memo 배열을 통한 방문 여부 확인 코드가 없는 것을 알 수 있다.

+
    +
  • BFS의 경우 인접한 모든 노드를 방문하면서 방문 여부로 집합을 구성하지만, Union-Find는 각 노드의 부모로 집합 여부를 확인하기 때문에 사이클이 된다면 각 노드의 부모를 계속해서 갱신해 줘야 한다.
  • +
+
// 오답
+const answer = new Set(parent);
+console.log(answer.size);
+ 
+// 정답
+const answer = new Set();
+for (let i = 1; i <= n; i++) {
+  answer.add(find(parent, i));
+}
+console.log(answer.size);
+

이때 부모 배열의 값으로만 비교해 출력하면 오답이 된다.

+

모든 노드를 순회하면서 부모를 갱신하지 않았기 때문에 마지막에 각 노드를 다시 find 해서 부모를 갱신해야 올바른 값이 된다.

+

정리

+
    +
  1. 양방향 그래프를 그린다.
  2. +
  3. 노드를 순회하면서 사이클 여부를 확인한다.
  4. +
  5. 사이클인 노드들을 집합으로 구분한다.
  6. +
  7. 집합의 개수를 출력한다.
  8. +
+

최종 코드

+
const readline = require('readline');
+let rl = readline.createInterface({
+  input: process.stdin,
+  output: process.stdout,
+});
+const input = [];
+rl.on('line', (line) => {
+  input.push(line);
+});
+const l = console.log;
+ 
+const union = (arr, a, b) => {
+  const aRoot = find(arr, a);
+  const bRoot = find(arr, b);
+  if (aRoot === bRoot) return;
+  const root = Math.min(aRoot, bRoot);
+  arr[Math.max(aRoot, bRoot)] = root;
+};
+ 
+const find = (arr, cur) => {
+  if (arr[cur] === cur) return cur;
+  // find 하면서 path 단축도 같이한다.
+  return (arr[cur] = find(arr, arr[cur]));
+};
+ 
+rl.on('close', () => {
+  const [n, m] = input[0].split(' ').map(Number);
+  // 부모 노드를 체크할 배열을 index의 값을 가지도록 세팅한다.
+  const ufArr = Array(n + 1)
+    .fill(0)
+    .map((_, idx) => idx);
+  const check = Array.from(Array(n + 1), () => Array(n + 1).fill(0));
+  const map = {};
+  input.slice(1).forEach((line) => {
+    const [s, e] = line.split(' ').map(Number);
+    map[s] = [...(map[s] || []), e];
+    check[s][e] = 1; // 해당 노드의 방향성을 체크한다
+  });
+  for (let cur = 1; cur <= n; cur++) {
+    const curList = map[cur];
+    for (const next of curList || []) {
+      // next -> cur로 이어져 있지 않다는 것은 사이클이 아니므로 무시한다.
+      if (!check[next][cur]) continue;
+      // 현재 노드와 다음 노드가 사이클이라면 집합을 구한다.
+      union(ufArr, cur, next);
+    }
+  }
+  const answer = new Set();
+  for (let i = 1; i <= n; i++) {
+    // 각 정점의 부모를 find로 순회하며 찾는다.
+    // Set이므로 중첩된 부모는 제외하고 추가된다.
+    answer.add(find(ufArr, i));
+  }
+  // Set의 size는 중복되지 않는 각 노드의 부모(집합)이므로
+  // 연합(집합)의 개수가 된다.
+  console.log(answer.size);
+});
📮 이 포스트에 관심 있으신가요? 이슈를 남겨주세요! 👍
☕ 소주 한 잔 후원하기
(예금주: 이상철)tosskakao
\ No newline at end of file diff --git a/e2e/__snapshots__/post/dom.spec.ts/mobile/implicit-coercion.html b/e2e/__snapshots__/post/dom.spec.ts/mobile/implicit-coercion.html new file mode 100644 index 00000000..90bcadcb --- /dev/null +++ b/e2e/__snapshots__/post/dom.spec.ts/mobile/implicit-coercion.html @@ -0,0 +1,237 @@ +

알랑말랑 암묵적 형변환 말랑말랑 이해하기

1ilsang
클라이밍 하실래염?
#javascript#type#implicit-coercion
Published
{} == []  // ERROR
+[] == {}  // false
+[] == ''  // true
+[] == []  // false
+"[object Object]" == {}  // true
+[45] == 45  // true
+[45] == '45'  // true
+4 * []  // 0
+[] + {}  // "[object Object]"
+{} + []  // 0
+0 == '\n'  // true
+1 + 2 + '3'  // 33
+NaN == NaN  // false
+undefined == null // true
+ 
+

cover

+

TL;DR!

+
    +
  1. 위 예시의 결과값이 도출되는 과정을 이해한다.
  2. +
  3. 암묵적 형변환을 유도하지 말라
  4. +
  5. 암묵적 형변환을 유도하지 마시오
  6. +
+
+

타입스크립트를 사용하면 되지 않나요?

+
+

들어가기 전에

+

primitive-type

+
+

이미지 주소

+
+

자바스크립트는 6가지 원시 타입과 Object 라는 객체 타입, 총 7가지 타입 이 존재한다.

+
+

ES2020에서 원시 타입에 bigint 타입이 추가되었기 때문에 이제는 총 8가지 타입이 존재하게 되었다.

+
+

기본적으로 암묵적 형변환은 모두 "원시 타입(문자열, 숫자, 불리언)"을 기준으로 하게 된다. 원시타입이 객체타입으로 암묵적 형변환이 되는 케이스는 존재하지 않는다.

+

암묵적 형변환은 언제 일어나나요?

+
// 표현식이 모두 문자열 타입이여야 하는 컨텍스트
+const a = '10' + 2; // "102"
+const b = `1 * 10 = ${1 * 10}`; // "1 * 10 = 10"
+ 
+// 표현식이 모두 숫자 타입이여야 하는 컨텍스트
+5 * '10'; // 50
+ 
+// 표현식이 불리언 타입이여야 하는 컨텍스트
+!0; // true
+if (1) {
+}
+1 == []; // false
+

자바스크립트 엔진은 표현식을 평가할 때 문맥, 즉 컨텍스트(Context)에 고려하여 암묵적 타입 변환을 실행한다.

+
+

https://poiemaweb.com/js-type-coercion#2-암묵적-타입-변환

+
+
    +
  • 산술 연산자(+-*/)의 경우 + 는 문자열이 우선순위가 더 높으며 나머지 연산은 숫자가 더 우선순위가 높다.
  • +
  • 동치 연산자(==)의 경우 피연산자간의 관계에 따라 정의가 다르다.
  • +
+

동치연산자 한짤로 보기

+

example

+
+

출처: MDN

+
+

동치 연산의 관계를 보면 Object 타입의 경우 ToPrimitive 라는 값이 있다.

+

이 함수가 암묵적 형변환의 핵심이며, 이 함수를 이해하면 타입 변환의 과정을 이해할 수 있다.

+

ToPrimitive 는 동치연산 뿐만 아니라 원시값과 비교가 필요한 모든 순간에 동작한다

+

to-primitive

+

Symbol.toPrimitive: A method that converts an object to a corresponding primitive value. Called by the ToPrimitive abstract operation.

+
+

ECMA2020

+
+

설명과 같이 객체의 원시 타입의 값을 반환하는 Symbol.toPrimitive 메서드는 ToPrimitive 추상 명령 에서 사용된다.

+
function toPrimitive(input, PreferredType) {
+  // PreferredType은 호출자가 기대하는 타입
+  if (typeof input === 'object' || typeof input === 'function') {
+    let hint =
+      PreferredType === undefined
+        ? 'default'
+        : typeof PreferredType === 'string'
+          ? 'string'
+          : 'number';
+    let exoticToPrim = input[Symbol.toPrimitive];
+    if (exoticToPrim !== undefined) {
+      let result = exoticToPrim.apply(input, [hint]);
+      if (!(typeof input === 'object' || typeof input === 'function'))
+        return result;
+      throw new TypeError();
+    }
+    if (hint === 'default') hint = 'number';
+    return OrdinaryToPrimitive(input, hint);
+  }
+  return input;
+}
+
+

코드 출처

+
+

input 이 객체이며 toPrimitive 추상 명령이 해당 객체 내에 없다면(input[Symbol.toPrimitive]) OrdinaryToPrimitive 를 호출하고 있다.

+

input[Symbol.toPrimitive] 이 메서드는 객체 프로퍼티로 개발자가 직접 넣은 케이스이므로 여기서는 넘어가겠다.

+

hint 는 어떤 원시 타입을 부를지에 대한 정의로써, 기본 타입이 넘버 타입인 것을 인지하고 넘어가자.

+
function OrdinaryToPrimitive (O, hint) {
+  if ( typeof O === "object" || typeof O === "function" ) {
+    if( typeof hint === "string" && ( hint === "string" || hint === "number" ) ) {
+      let methodNames = hint === "string" ? [ "toString", "valueOf" ] : [ "valueOf", "toString" ];
+      for( name of methodNames ) {
+        let method = O[name];
+        if( typeof method === "function" ) {
+          let result = method.apply(O);
+          if( typeof result !== "object" && typeof result !== "function" ) return result;
+        }
+    }
+  }
+ throw new TypeError();
+}
+
+

코드 출처

+
+

hintstring 이면 [toString, valueOf] 이며 number 이면 [valueOf, toString] 순서로 우선권을 가지는 것을 볼 수 있다.

+

여기서 우선권 이라는 단어를 사용하였는데, 그 이유는 for 문을 통해 apply 하는 순서가 달라지기 때문이다.

+

원시 타입을 찾았다면(if( typeof result !== "object" && typeof result !== "function" )) 결과를 반환하고 아니면 무시된다 이는 굉장히 중요한데, 아래에서 예시로 다루겠다.

+
    +
  • 따라서, 타입간 비교에서 암묵적 형변환들은 모두 원시타입으로 변환하기 위한 과정 속에서 일어난다.
  • +
+

예제로 정리하기

+
1. 4 * []  // 0
+2. 4 + []  // "4"
+3. [] + {}  // "[object Object]"
+4. [45] == 45  // true
+5. {} == []  // ERROR
+6. 0 == '\n'  // true
+
// CASE 1.
+1. 4 * []
+// +를 제외한 산술 연산의 경우 숫자타입이 최상위 우선순위이므로 암묵적 형변환은 Number == ToPrimitive([]) 으로 될 것이다.
+2. 4 * Object([])  // 4 * []
+// Symbol.toPrimitive 정의를 해주지 않았으므로 default hint 는 number 로 설정된다.
+3. 4 * Object([]).valueOf()  // 4 * []
+// Default hint 가 number 이므로 [valueOf, toString] 순으로 원시 값을 가져올 것이다.
+4. 4 * Object([]).valueOf().toString()  // 4 * ""
+// 하지만 valueOf 는 this 반환으로 객체([])를 반환해 원시타입이 아니게 되므로 무시된다. 따라서 후순위의 toString 함수가 실행된다.
+5. 4 * Number(Object([]).valueOf().toString()) // 4 * 0
+// 숫자 * 문자열 연산에서 숫자가 우선순위가 높으므로 Number 타입으로 형변환이 된다.
+6. 0
+// 그 결과 4 * 0 이 되어 0이 최종 리턴된다.
+
// CASE 2.
+1. 4 + []
+2. 4 + Object([]).valueOf().toString() // ""
+// CASE1 예시의 4번까지와 동일하다.
+3. String(4) + Object([]).valueOf().toString() // "4"
+// + 연산자에서는 숫자보다 문자가 우선순위를 가지므로 숫자가 String 으로 변환되었다.
+4. "4"
+
// CASE 3.
+1. [] + {}
+2. Object([]) + Object({})
+3. Object([]).valueOf() + Object({}).valueOf()  // [] + {}
+// 모두 객체자신을 반환하므로 toString 연산까지 진행하게 된다.
+4. Object([]).valueOf().toString() + Object({}).valueOf().toString()  // "" + "[object Object]"
+// 객체의 toString 은 prototype 상속으로 최종 this 결과값을 반환해 object Object 가 나타난다.
+// Object.prototype.toString.call(undefined) 호출시 "[object Undefined]" 가 나오는 것 처럼.
+5. "[object Object]"
+
// CASE 4.
+1. [45] == 45
+2. Object([45]) == 45
+3. Object([45]).valueOf() == 45 // [45] == 45
+4. Object([45]).valueOf().toString() == 45 // "45" == 45
+5. Number(Object([45]).valueOf().toString()) == 45 // 45 == 45
+6. true
+
// CASE 5.
+1. {} == []
+// {} 중괄호는 "객체"로 인식되는 것이 아닌 "블록 스코프"로 인식되어 사라져버린다!
+2. == []
+3. // Uncaught SyntaxError: Unexpected token '=='
+
// CASE 6.
+1. 0 == '\n'
+2. 0 == ""
+3. 0 == Number('')
+4. 0 == 0
+5. true
+

한발 더 나아가기

+
    +
  1. hint 는 언제 default 값을 벗어나게 될까? +
      +
    • <, > 혹은 -, * 와 같이 명확한 숫자 비교에선 number 가 된다.(+ 는 문자열도 포함되므로 제외된다)
    • +
    +
  2. +
  3. Date 객체를 제외한 모든 내장 객체는 defaultnumber 를 동일하게 처리하므로 number 로 이해하는게 편하다. + +
  4. +
  5. boolean 타입의 hint 는 존재하지 않는다. 모든 객체는 true 로 평가되므로 string, number 만 처리하면 된다.
  6. +
  7. [Symbol.toPrimitive] 를 커스텀 할 수 있는가? +
      +
    • 가능하다.
    • +
    +
  8. +
+
const user = {
+  name: '1ilsang',
+  money: 1000,
+ 
+  [Symbol.toPrimitive](hint) {
+    alert(`hint: ${hint}`);
+    return hint == 'string' ? `{name: "${this.name}"}` : this.money;
+  },
+};
+ 
+// 데모:
+alert(user); // hint: string -> {name: "1ilsang"}
+alert([user] == '{name: "1ilsang"}'); // hint: string -> {name: "1ilsang"} == {name: "1ilsang"}; true;
+alert(+user); // hint: number -> 1000
+alert(user + 500); // hint: default -> default는 number가 기본타입이므로 1000 + 500 -> 1500
+alert('3' - user); // hint: number -> '3' - 1000 -> 3 - 1000 -> -997
+alert(user > 10); // hint: number -> 1000 > 10; true
+alert(user + new Date()); // hint: default -> 1000 + 'Sat Apr 22 2023 17:02:00 GMT+0900 (한국 표준시)' -> '1000Sat Apr 22 2023 17:02:00 GMT+0900 (한국 표준시)'
+
    +
  1. NaN 은 모든 경우에서 같지 않다. +
      +
    • NaN == NaN // false
    • +
    +
  2. +
+

결론

+
    +
  1. 타입간 비교에서 암묵적 형변환들은 모두 원시 타입으로 변환하기 위한 과정 속에서 ToPrimitive 추상 명령을 통해 일어난다.
  2. +
  3. == 연산자와 === 연산자의 차이는 무엇인가? +
      +
    • "타입까지 비교 여부" 라고하면 애매하다. "암묵적 형변환을 허용하는가"의 차이가 더 명확한 워딩이다.
    • +
    +
  4. +
  5. 타입스크립트와 === 연산자를 사용하자.
  6. +
+

Ref

+
📮 이 포스트에 관심 있으신가요? 이슈를 남겨주세요! 👍
☕ 소주 한 잔 후원하기
(예금주: 이상철)tosskakao
\ No newline at end of file diff --git a/e2e/__snapshots__/post/dom.spec.ts/mobile/inflearn-meetup-03-dev-career.html b/e2e/__snapshots__/post/dom.spec.ts/mobile/inflearn-meetup-03-dev-career.html new file mode 100644 index 00000000..ab1d8c5f --- /dev/null +++ b/e2e/__snapshots__/post/dom.spec.ts/mobile/inflearn-meetup-03-dev-career.html @@ -0,0 +1,92 @@ +

인프런 판교 퇴근길 밋업 - 개발자 커리어 후기

1ilsang
클라이밍 하실래염?
#inflearn#pangyo#meet-up#seminar
Published

cover

+

학생 때는 개발자 모임에 많이 참여했었는데 요즘은 거의 나간 적이 없었다.

+

마침 인프런에서 재밌어 보이는 모임을 개최하고 있었기에 신청하게 되었다.

+

소개

+

intro

+
+

인프런 공식 행사 소개

+
+

행사는 판교 퇴근길 밋업의 세 번째 주제로 개발자 커리어를 다뤘다.

+

연사는 27년차 실리콘밸리 개발자의 인생 이야기로 유명한 한기용님이다.

+

영상을 보면서 흥미롭다고 느꼈는데 직강을 볼 수 있다는 소식에 설레는 마음으로 신청했던 기억이 난다.

+

"실리콘밸리에서 인정받는 개발자의 특징 10가지"라니 듣지 않을 수가 없었다.

+

행사

+

goods

+
+

개발자 행사의 꽃은 굿즈가 아닐까?

+
+

조금 일찍 갔기 때문에 샌드위치 먹으면서 행사 시작까지 사람들 구경했다.

+
+

main-speech

+

본격적으로 세션이 시작되었고 기용님 본인 소개가 시작되었다.

+

지금까지 쌓아오신 커리어가 다채롭다고 느껴졌다. 그렇기에 이렇게 연사로 계신 걸까? 나도 다양한 환경을 경험해 보고 싶다고 생각했다.

+

세션은 전반부와 후반부로 나뉘어서 진행되었다.

+

커리어를 바라보는 관점

+

전반부는 커리어 어떻게 이어 나갈지에 대한 내용이었다. 내가 공감이 많이 갔던 부분은 아래와 같다.

+
+
    +
  1. 많은 회사를 다녀봐라 +
      +
    • 어떤 매니저가 나랑 잘 맞는지 찾아야 한다
    • +
    +
  2. +
  3. 현재에 충실하기 +
      +
    • 불안하니까 선행학습을 한다. 필요하지 않은 학습을 하지 마라
    • +
    +
  4. +
  5. 서포터를 잘 만나기 +
      +
    • 무언가 추진할 때 지지해 줄 사람이 있어야 한다
    • +
    +
  6. +
+
+

어떤 매니저/동료를 만나느냐에 따라 성장의 곡선과 마음의 상처가 달라지는 것을 겪기도 하고 보기도 했다.

+

본인의 잘못이라고 책망하는 경우가 많은데 시스템적인 문제 혹은 그냥 문화가 안 맞는 것일 수도 있다.

+

나의 경우는 주변에서 칭찬하면 더 열심히 하는 경향이 있는데, 최근 특히나 서포터들의 힘을 많이 느낀다(😭😭😭👏👏).

+

개발자로서 생각해 보면 좋을 10가지

+

후반부는 개발자로서 생각해 보면 좋을 10가지를 소개해 주셨다.

+
+
    +
  1. 기본기
  2. +
  3. 학습 능력
  4. +
  5. 의사소통
  6. +
  7. 문제정의
  8. +
  9. 시간 추정
  10. +
  11. 운영 고려 코드 작성
  12. +
  13. 서비스 사고 대처
  14. +
  15. 결과 지향
  16. +
  17. 영향력
  18. +
  19. 리더 vs 전문가
  20. +
+

임팩트(결과)를 어떻게 낼 것인지에 대한 고민이 핵심이고 그것을 위한 스킬들을 설명해 주셨다고 생각된다.

+

특히 처음부터 끝까지 강조하신 "결과 지향적 개발자"는 내가 가져야 할 핵심 포인트라 느꼈다.

+

Q&A

+

intro

+

세미나가 끝나고 Q&A 시간이 주어졌다. 흥미로웠던 질문 두 가지를 가져와 봤다.

+

"해고에 대한 걱정은 없으신지?"

+
    +
  • 한국에서는 해고가 불명예의 느낌이지만 밸리에서는 누구나 해고당한다.
  • +
  • 해고 패키지를 잘 받도록 노력하자. 첫 번째 레이오프는 많이 챙겨주므로 오히려 먼저 나가는 게 좋을 수 있다.
  • +
+

"나에게 맞는 것, 맞지 않는 것을 어떻게 구분하셨고 받아들이셨나요?"

+
    +
  • 내 매니저가 어떤 사람인가를 많이 봤다.
  • +
  • 매니저를 잘 만나야 이후의 가치 판단이 된다.
  • +
+

네트워킹

+

모든 세션이 끝나고 네트워킹이 진행되었다.

+

네트워킹 시간은 사전 설문조사의 내용을 기반으로 관심도가 비슷한 사람들과 묶어주셨다.

+

잡담을 많이 했는데 시간 가는 줄 몰랐다. 시간이 부족할 정도.

+

마무리

+
+book +letter +
+

기용님이 최근 출판하신 실패는 나침반이다 도서를 지참해서 오면 친필 사인을 해주는 이벤트가 있었다.

+

기용님에 대한 개인적인 관심이 컸기 때문에 얼른 사인받으러 갔다.

+

커리어를 긴 호흡으로 바라보라는 점에 동의하고 있다. 나는 너무 조급한 게 아닐까?

+

조금 더 적극적으로 삶을 가꾸고 나를 사랑해야겠다.

+

오랜만의 개발자 모임에서 에너지를 많이 받았다. 앞으로도 종종 찾아다녀야겠다.

+

Love yourself.

📮 이 포스트에 관심 있으신가요? 이슈를 남겨주세요! 👍
☕ 소주 한 잔 후원하기
(예금주: 이상철)tosskakao
\ No newline at end of file diff --git a/e2e/__snapshots__/post/dom.spec.ts/mobile/jeju-remote-work.html b/e2e/__snapshots__/post/dom.spec.ts/mobile/jeju-remote-work.html new file mode 100644 index 00000000..fafaecb2 --- /dev/null +++ b/e2e/__snapshots__/post/dom.spec.ts/mobile/jeju-remote-work.html @@ -0,0 +1,59 @@ +

제주 한 달 리모트 워크 후기

1ilsang
클라이밍 하실래염?
#activity#jeju#remote-work
Published

cover

+
+

제주 섭지코지

+
+

최근에 해외 리모트로 한 달간 발리를 갔다 왔다.

+

해외 리모트 소감을 쓰려던 중 작년 중순(2022.05) 제주도에서 보낸 한 달간의 리모트가 떠올랐다. 시간 순서대로 적어보고자 하여 기억을 더듬어 그때의 기록을 남기고자 한다.

+

시작 계기

+

재택근무가 지속되던 어느날 회사에서 국내 리모트도 거주지의 확보만 된다면 가능하다고 공지가 나왔다.

+

평소 해외에서 근무해 보고 싶다고 생각을 자주 했었다. 마침 리모트도 되니 이번 기회에 연고지가 없는 곳으로 이동해 워케이션을 해보면서 나는 워케이션이 잘 맞는 사람인지 알고 싶었다.

+

바로 제주도가 떠올랐고 한 달 제주살이를 해보자 마음먹었다.

+

첫 일주일

+

start

+

나는 사람이 많은 곳을 피하고 싶었다. 고민하던 도중 성산일출봉 근처에 관광할 곳이 하나도 없는 지역 근처의 숙소를 구했다.

+

오죽했으면 근처에 카페도 거의 없어서 걸어서 20분 가야 했다. 9_-

+

성산일출봉이 어느 위치에서도 보였기 때문에 나침반 역할을 했다. 평소 서울에서 거의 벗어나질 않았기 때문에 어딜가도 바다와 대자연이 펼쳐진 제주도는 설레기에 충분한 장소였다.

+

주변을 더 자세히 보고자 많이 걸어 다녔고 혼자 사색하는 시간도 많이 가졌었다.

+
+

work

+
+

처참했던 근무환경

+
+

집을 벗어나 일하는게 익숙하지 않았던 나는 큰 실수를 하나 저지르는데 바로 "책상"을 제대로 알아보지 않았다는 점이었다.

+

숙소의 가격이나 주변 환경이나 인터넷 등은 확인했는데 책상은 여부만 확인하고 제대로 알아보지 않았던 점이 큰 실수였다.

+

의자가 제대로 되어있지 않은 책상에서 장시간 코딩은 허리에 무리가 많이 갔다. 퇴근하고 나서는 아예 누워서 했다.

+

평소 모션데스크에 절여져 있어서 서서 코딩하는게 편했는데 집에 있는 책상이 너무 그리웠다.

+
+

landscape

+

green-tea-cave

+

하지만 퇴근 후 혹은 주말에 제주 여러 지역을 돌아다니며 주위를 환기하는 과정은 워케이션을 후회하지 않게 해주기에 충분했다.

+

특히 카페에서 공부하는 걸 좋아하던 나는, 제주의 이색적인 카페에서 아름다운 풍경을 뒤로하고 여유롭게 책을 읽는게 상당히 좋았다.

+

제주에서 몇 가지 목표가 있었는데, 그중 하나가 완성된 웹사이트로 배우는 HTML&CSS 웹 디자인 책 리뷰였다.

+

지금도 CSS가 어렵지만 이때는 정말 flex의 존재도 모를 때였기 때문에 CSS를 꼭 공부해 보고자 생각했었는데 흥미롭게 읽을 수 있었다.

+

그 외에도 지금의 블로그의 토대를 만들고 두루뭉실했던 계획을 세분화하는 등 바쁘다며 미뤄뒀던 여러 작업들을 마칠 수 있었다.

+

벌써 마지막 주

+
+ fire + mountain +
+

당시에 시간이 정말 빠르게 간다고 느꼈다. 하루하루 많은 일이 있었는데 어느덧 마지막 주였던 기억이 난다.

+

중간에 배포가 있어서 야근을 엄청 하기도 했는데 당시 이런 생각이 들었다. 내가 개발을 조금 더 잘했으면 더 빨리 끝내고 편하게 쉬었을까? 물론 그랬겠지만, 여유가 있었다면 그 여유만큼 뭔가 더 일을 만들었을 것 같다. 서울에서는 스터디라던가 커피챗이라던가 다양한 활동/모임에 참여하느라 저녁의 여유를 잘 못 느꼈는데 여기에서 거의 반강제로 집에 있으면서 삶을 바라보는 방향이 많이 바뀔 수 있었다.

+

나는 왜 공부하는가?

+

다양한 답변이 속에서 나왔지만 결국 나는 누군가에게 도움이 되고 싶어서 공부한다는 결론이 나왔었다. 정보 공유를 한다거나 가르쳐줄 때 큰 재미를 느꼈고 그 재미가 내 행동 기반이라는 것을 깨닫게 되었다. 회사에서만 보더라도 다양한 정보 혹은 개발기를 공유하고 싶어서 열심히 일을 하게 되었던 것 같다.

+

이처럼 제주에서 나는 중간중간 스스로에게 질문을 많이 던지면서 천천히 생각해 보는 시간을 많이 가졌다. 이것이 제주에서 느꼈던 가장 좋았던 점이었다.

+

맨날 퇴근 후 다음 작업을 하느라 스스로에게 질문을 하지 못했는데 이번 기회에 삶의 방향을 한번 돌아보게 되었다.

+

pony

+

마무리

+
+ sunset-mount + sunset-sea +
+

워케이션 기간동안 많은 것을 느꼈다.

+
    +
  • 새로운 환경에서 작업하면서 주위를 환기하는 과정은 상당히 즐거웠다.
  • +
  • 나 자신과 스스로 마주할 수 있었기에 조금 더 자신을 알게 되었다.
  • +
  • 나는 "서울에서의 바쁜 일상을 즐겼다"는 결론을 가지게 되었다. 제주 생활도 좋았지만 "서울에 올라가면 꼭 ~ 해야지"라고 서울에서의 일정이 마구 생기는 모습을 보면서 나에게 워케이션은 한번씩의 환기 이벤트라고 생각하게 되었다.
  • +
  • 워케이션을 통해 평범했던 일상을 더욱 좋아하게 되었고 긍정적으로 삶을 바라볼 수 있게 되었다.
  • +
+

이제 다시 평범한 일상으로 돌아가게 되겠지만 새로운 환경이 필요하다고 생각하면 주저 없이 워케이션을 선택할 것 같다.

+

좋은 경험이었다.

📮 이 포스트에 관심 있으신가요? 이슈를 남겨주세요! 👍
☕ 소주 한 잔 후원하기
(예금주: 이상철)tosskakao
\ No newline at end of file diff --git a/e2e/__snapshots__/post/dom.spec.ts/mobile/junction2023.html b/e2e/__snapshots__/post/dom.spec.ts/mobile/junction2023.html new file mode 100644 index 00000000..98dcacad --- /dev/null +++ b/e2e/__snapshots__/post/dom.spec.ts/mobile/junction2023.html @@ -0,0 +1,58 @@ +

Junction Asia 2023 참여 후기

1ilsang
클라이밍 하실래염?
#activity#hackathon#junction2023#busan
Published

cover

+

작년 2022 정션에 참여했던 친구들의 피드백이 상당히 좋았기 때문에 올해는 꼭 참여해 보고 싶었던 해커톤이다.

+

모집 포스터가 올라오자마자 일정 확인 후 바로 휴가 썼다ㅋ. 가보자고!

+

지원 과정

+

나는 사전에 팀을 구성해서 지원했는데 개인으로도 지원할 수 있다.

+

팀으로 지원할 경우 디자이너, 개발자, 기획자가 각각 최소 한 명 이상 5인으로 구성되어야 한다.

+

선발 과정은 서류전형 한 번으로 이루어져 있다.

+

간단한 자소서와 티셔츠 사이즈 등의 설문을 완료하면 지원이 끝난다.

+

pass

+

다행히도 합격 발표가 났다!

+

부산 도착

+

entry

+

작년에는 숙소 제공도 있었는데 이번엔 없었다. 파트너사도 작년과는 많이 달라져서 조금 아쉬웠다.

+

그래도 해커톤 자체가 거의 3년 만에 참여하는 거라 상당히 들뜬 마음으로 부산에 갈 수 있었다.

+

해커톤은 금~일 2박 3일간 진행되었고 접수는 오후 6시부터였다. 접수 전에 스폰서와의 사전 미팅 시간도 있는데 간단한 네트워킹 자리다.

+

공식 언어가 영어이기 때문에 겁을 많이 먹었는데 대부분이 한국인이라 큰 어려움은 없었다.

+

첫날: 아이데이션

+

start

+

첫날에는 트랙 공개 및 첫 번째 미션이 주어졌다.

+

트랙은 아래의 5가지였는데 잘 기억이 안 나서 핵심 주제는 다를 수 있다. 뉘앙스만 봐주시길

+
    +
  1. 공공/빅데이터를 활용한 사회문제 해결
  2. +
  3. 로봇이 만드는 음식의 인식 개선
  4. +
  5. 전자 라벨 솔루션
  6. +
  7. 관광 이동 수단 개선? 관광의 재미 솔루션
  8. +
  9. 배달 음식 생태계? 구조 개선
  10. +
+

미션은 트랙을 선정하고 어떤 것을 개발할지 기획해서 제출하면 됐다.

+

우리는 3번 전자 라벨을 선택했고 상품에만 사용되는 전자 라벨을 사원증에도 사용할 수 있도록 하여 시장을 넓히는 것을 목표로 기획했다.

+
+

event

+

해커톤의 또 다른 재미는 사이드 부스인데 정션은 부스 컨셉들이 재밌었다. 빙고 채우는 재미가 있었음.

+

구석 한쪽에 빈백을 쭉 설치해 놓아서 피곤할 때 리차지하고 올 수 있었다.

+

다들 여기서 떨면서 잠들었는데 매우 슬픔이었다.

+

둘째 날: 개발

+

web

+

우리는 전자 라벨을 사원증으로 확장하는 것을 목표로 기획을 잡았기 때문에 전자 라벨에서 회사의 다양한 정보를 얻을 수 있도록 하고자 하였다.

+

따라서 전자 라벨과 연동된 CMS 페이지를 개발하고 페이지에서 라벨의 색, 이미지, 문구 등을 수정할 수 있게 하고 회의 스케줄을 받을 수 있도록 하였다.

+
+

https://github.com/junction-asia-2023/just-label

+
+

스폰서 API를 활용해 라벨과 통신했고 CMS 페이지는 Vite, React, Jotai, React-Query로 구성했다.

+

생각보다 페이지 찍는 게 시간이 좀 걸렸다. 역시 CSS는 너무 어려운 것임

+

spa

+

밤에는 정션에서 제공해 준 센텀 스파권으로 찜질방에 갔는데 진짜 너무 좋아서 그대로 쭉 있고 싶었다.

+

셋째 날: 발표

+

production

+

위의 CMS 페이지에서 문구나 이미지 등을 설정해 저장하면 전자 라벨에 업데이트가 되게 개발했다. 찌그러지지 않고 잘 나와서 참 다행이었다.

+

역시 갓자이너

+
+

presentation

+

해당 주제의 스폰서분들 앞에서 구현된 걸 기준으로 시현하는데 영어로 말해야 해서 진짜 지옥이었다. 가장 힘든 순간이었다(-_-).

+

발표 이후 여유 시간 동안 옆 팀 외국인들이랑 친해졌는데 이 부분이 좀 인상적이었다. 카이스트 재학 중인 외국인 5명이었는데 엄청 긱한 느낌이었다. 대화에 왜?라고 의문을 많이 가지는데 나도 다시 한번 생각해 볼 수 있어서 흥미롭게 대화할 수 있었다.

+

마무리

+

오랜만에 밤새면서 빠르게 작업하니까 재밌었다. 긴 재택근무 간에 떨어진 열정을 다시 채운 느낌이었다.

+

기회가 된다면 계속 꾸준히 해커톤에 참여하고 싶다.

+

아참 침낭이랑 후드 챙겨갔는데 이거 없었으면 진짜 고통스러울 뻔했다. 진짜 에어컨이 계속 나오기 때문에 너무 추웠다.

+

좋은 경험이었다. 그럼, 이만!

📮 이 포스트에 관심 있으신가요? 이슈를 남겨주세요! 👍
☕ 소주 한 잔 후원하기
(예금주: 이상철)tosskakao
\ No newline at end of file diff --git a/e2e/__snapshots__/post/dom.spec.ts/mobile/leetcode-easy-2727.html b/e2e/__snapshots__/post/dom.spec.ts/mobile/leetcode-easy-2727.html new file mode 100644 index 00000000..2a1058ba --- /dev/null +++ b/e2e/__snapshots__/post/dom.spec.ts/mobile/leetcode-easy-2727.html @@ -0,0 +1,66 @@ +

[LeetCode] 2727. Is Object Empty

1ilsang
클라이밍 하실래염?
#algorithm#leetcode#easy#object#for-in#for-of#keys
Published

cover

+
+

문제 링크

+
+

객체 혹은 배열이 비어있는지 확인하는 문제이다.

+
/**
+ * @param {Object | Array} obj
+ * @return {boolean}
+ */
+const isEmpty = (obj) => {
+  return Object.keys(obj).length === 0;
+};
+

배열 혹은 객체의 키가 있는지 확인하면 되므로 Object.keys의 길이가 0인지 확인하면 된다.

+

만약 keys로 추출 후 비교하는 것이 싫다면 아래와 같은 방법도 있다.

+
const isEmpty = (obj) => {
+  for (i in obj) {
+    return false;
+  }
+  return true;
+};
+

배열 또한 객체이므로 for...in을 통해 속성 키값을 순회할 수 있는지 확인하면 된다.

+

keys로 모든 키값을 가져와 비교하는 것보다 순회와 동시에 객체가 비어있는지 판단이 가능하므로 훨씬 빠르다.

+

참고로 이 문제는 "상속된" 프로퍼티를 비교해야 하는지에 대한 명시가 없어 모호한 부분이 있다.

+

만약 상속된 프로퍼티까지 비교한다면 keysfor-in의 정답 여부가 달라졌을 것이다. 이 부분은 아래에서 다루겠다.

+

++ for-infor-of의 차이가 무엇일까?

+

for...in

+
+

for...in문은 상속된 열거 가능한 속성들을 포함하여 객체에서 문자열로 키가 지정된 모든 열거 가능한 속성에 대해 반복합니다(Symbol로 키가 지정된 속성은 무시합니다.).

+
+

MDN for...in 설명에 따르면 for...in은 상속된 모든 속성(Property)을 포함한 속성 키 값을 반복한다.

+

for..of

+
+

for...of 명령문은 반복가능한 객체 (Array, Map, Set, String, TypedArray, arguments 객체 등을 포함)에 대해서 반복하고 각 개별 속성값에 대해 실행되는 문이 있는 사용자 정의 반복 후크를 호출하는 루프를 생성합니다.

+
+

MDN for...of 설명에 따르면 for...of는 반복 가능한 객체의 속성 값에 대한 순회를 한다.

+

결론

+
Object.prototype.objCustom = function () {};
+Array.prototype.arrCustom = function () {};
+ 
+let iterable = [3, 5, 7];
+iterable.foo = 'hello';
+ 
+for (let i in iterable) {
+  console.log(i); // "0", "1", "2", "foo", "arrCustom", "objCustom"
+}
+for (let i of iterable) {
+  console.log(i); // 3, 5, 7
+}
+console.log(Object.keys(iterable)); // [ "0", "1", "2", "foo" ]
+ 
+// Map 객체
+const m = new Map([
+  ['a', 1],
+  ['b', 2],
+]);
+console.log(m); // Map(2) {'a' => 1, 'b' => 2}
+for (let i in m) {
+  console.log(i); // 순회 되지 않음. undefined
+}
+for (let i of m) {
+  console.log(i); // ['a', 1], ['b', 2]
+}
+console.log(Object.keys(m)); // []
+

for...in 루프는 객체의 모든 열거 가능한 속성에 대해 반복하며 문자열 키 값을 반환한다. 추가로 인덱스의 순서를 보장하지 않는다.

+

for...of 구문은 컬렉션 전용이다. 모든 객체보다는, [Symbol.iterator] 속성이 있는 모든 컬렉션 요소에 대해 반복하며 컬렉션을 반환한다.

+

keys는 해당 키를 가져오지만, 상속된 값은 가져오지 않는다.

📮 이 포스트에 관심 있으신가요? 이슈를 남겨주세요! 👍
☕ 소주 한 잔 후원하기
(예금주: 이상철)tosskakao
\ No newline at end of file diff --git a/e2e/__snapshots__/post/dom.spec.ts/mobile/leetcode-hard-42.html b/e2e/__snapshots__/post/dom.spec.ts/mobile/leetcode-hard-42.html new file mode 100644 index 00000000..3e0cbc0e --- /dev/null +++ b/e2e/__snapshots__/post/dom.spec.ts/mobile/leetcode-hard-42.html @@ -0,0 +1,87 @@ +

[LeetCode] 42. Trapping Rain Water

1ilsang
클라이밍 하실래염?
#algorithm#leetcode#hard#stack#two-pointers
Published

cover

+
+

문제 링크

+
+

빗물이 고일 수 있는 모든 영역을 구하면 되는 문제다.

+

기본적으로 물은 '높은 곳에서 낮은 곳'으로 흐르기 때문에 우리는 높이를 비교하면서 빗물이 고일 수 있는지 판단해야 한다.

+

이 문제는 두 가지 스택과 투포인터 두 가지로 풀 수 있다. 두 방법 모두 알아두면 좋기 때문에 두 가지 해석을 모두 하려고 한다.

+

접근법 1. Stack O(n) T(n)

+

스택 접근은 "가로로 면적을 합해가는" 방식이다.

+

빗물이 고이기 위해서는 양쪽으로 벽이 있어야 한다.

+

스택을 활용해 양 벽을 계산하는 방법은, 높이가 감소할 때는 스택에 푸쉬하고 높이가 이전 탑보다 높아질 때는 팝을 하면서 얼마만큼의 빗물을 저장할 수 있는지 계산하면 된다.

+

example

+

0번째 높이부터 순회해 보자.

+
    +
  1. 모든 원소를 순회할 때마다 스택에 푸쉬한다. 왼쪽의 그림과 같이 높이가 감소해 나갈 때에는 특별한 작업 없이 계속 진행한다.
  2. +
  3. 중앙의 그림(i = 3)의 상황일 경우 현재 벽이 스택의 Top 값보다 더 높기 때문에 빗물이 고일 수 있다. 현재 높이와 같거나 클 때까지 스택에 쌓여있는 벽들을 pop 하며 "가로로" 누적값을 더한다. 여기서는 높이 1이 최대이므로 스택에 추가된 높이 2(i = 0)까지 계산하지 않고 끝난다.
  4. +
  5. 우측의 그림(i = 4)에서도 동일하다. 현재 높이보다 큰 높이가 나올 때까지 팝을 하며 계산한다. 2번에서 이미 높이 1일 때의 경우를 계산했으므로 높이 2일 때의 가로 값만 계산하면 된다.
  6. +
+

마지막 노드까지 위의 방식을 계속해서 하면 모든 면적을 구할 수 있다. 코드와 라인별 해석은 제일 아래에 작성해 두었다.

+

접근법 2. Two Pointers O(n) T(1)

+

투포인터 접근은 "세로로 면적을 합해가는" 방식이다.

+

빗물이 고이기 위해서는 양쪽으로 벽이 있어야 한다.

+

투포인터는 양끝에 포인터를 설정하고 좌우로 움직이며 높이가 더 높은 쪽을 향해 간다. 높이가 높은 쪽을 향해 양 포인터를 옮기면 반대로 낮은 쪽은 빗물이 고이는 곳이기 때문에 세로로 더해나가면 된다.

+

example

+

L, R을 양 끝에 두고 순회하면서 각각 MAX 값을 구한다.

+
    +
  1. 최초의 상태. 최대값(가로선)을 설정한다.
  2. +
  3. L < R이라면 L을 옮기고 아니면 R을 옮긴다. 여기는 L를 옮겼다. i = 1의 높이가 L의 최대높이보다 낮으므로 그 차이를 더한다.
  4. +
  5. L < R일 때까지 L을 옮긴 모습이다.
  6. +
  7. R을 옮겼다. i = 8의 높이가 R의 최대높이보다 낮으므로 그 차이를 더한다.
  8. +
  9. 계속해서 반복하면 결국 LR은 한곳으로 모이고 그 사이의 모든 세로 값이 더해져 빗물의 면적을 구할 수 있다.
  10. +
+

최종 코드

+
// Stack
+const trap = (height) => {
+  let res = 0;
+  let i = 0;
+  const stack = [];
+ 
+  while (i < height.length) {
+    const curHeight = height[i];
+    // 현재 높이가 스택의 마지막 높이보다 높다면
+    while (stack.length > 0 && height[stack[stack.length - 1]] < curHeight) {
+      const lastI = stack.pop();
+      // 스택이 비었다는 의미는 자신뿐이므로 빗물이 고일 수 없기 때문에 탈출한다.
+      if (stack.length === 0) break;
+      const peekI = stack[stack.length - 1];
+      // 현재 위치(i)와 스택의 다음 위치(peekI)의 거리를 구한다.
+      const dist = i - peekI - 1;
+      // 현재 위치의 높이와 스택의 다음 위치 높이중 낮은 값을 기준으로 스택의 마지막 높이를 뺀다.
+      // 마지막 높이만큼은 빗물이 고일 수 없기 때문
+      const h = Math.min(curHeight, height[peekI]) - height[lastI];
+      res += dist * h;
+    }
+    stack.push(i++);
+  }
+ 
+  return res;
+};
+
// Two-pointers
+const trap = (height) => {
+  let res = 0;
+  let l = 0;
+  let r = height.length - 1;
+  let lMax = 0;
+  let rMax = 0;
+ 
+  while (l < r) {
+    const curLeft = height[l];
+    const curRight = height[r];
+    // 매번 max 값을 갱신한다. 그래야 자신이 줄어든 값인지 비교할 수 있다.
+    lMax = Math.max(curLeft, lMax);
+    rMax = Math.max(curRight, rMax);
+ 
+    // 현재 높이가 최대값 보다 적다면 고일 수 있으므로 추가한다.
+    if (curLeft < lMax) {
+      res += lMax - curLeft;
+    }
+    if (curRight < rMax) {
+      res += rMax - curRight;
+    }
+    // 양쪽의 포인터를 비교해서 높이가 더 큰 방향으로 이동한다.
+    curLeft < curRight ? l++ : r--;
+  }
+ 
+  return res;
+};
📮 이 포스트에 관심 있으신가요? 이슈를 남겨주세요! 👍
☕ 소주 한 잔 후원하기
(예금주: 이상철)tosskakao
\ No newline at end of file diff --git a/e2e/__snapshots__/post/dom.spec.ts/mobile/leetcode-medium-238.html b/e2e/__snapshots__/post/dom.spec.ts/mobile/leetcode-medium-238.html new file mode 100644 index 00000000..609ce526 --- /dev/null +++ b/e2e/__snapshots__/post/dom.spec.ts/mobile/leetcode-medium-238.html @@ -0,0 +1,105 @@ +

[LeetCode] 238. Product of Array Except Self

1ilsang
클라이밍 하실래염?
#algorithm#leetcode#medium#product
Published

cover

+
+

문제 링크

+
+

nums 배열 원소의 모두 곱한 값에서 해당 원소를 나눈 값을 출력하는 문제이다. 문제에서는 나눗셈하면 안 된다고 명시하고 있으므로 나눗셈하지 않고 풀어야 하는 게 포인트이다.

+
    +
  1. 나눗셈을 하지 않아야 함.
  2. +
  3. T=O(n), S=O(1)으로 풀어본다.
  4. +
+

따라서 위의 두 가지를 목표로 이 문제를 해결해 보려고 한다.

+

Ideation

+

nums = [1, 2, 3, 4]의 경우를 살펴보자.

+

기대하는 정답은 [24, 12, 8, 6]이다. 해당 배열이 나오기 위해서는 전체 값의 곱(1*2*3*4 = 24)에 각자의 원소를 나누면 된다.

+

전체 곱에서 자신을 나누어 나오는 값은 자신을 제외한 모든 값의 곱과 동일하다.

+
+

e.g. 원소 3을 기준으로 한다면 1*2*3*4 / 3 === 1*2*4이다.

+
+

따라서 우리는 자신을 제외한 곱들의 왼쪽과 오른쪽을 곱해주면 정답이 된다는 것을 알 수 있다.

+
+

e.g. 원소 3을 기준으로 3의 좌/우의 곱은 1*2 * 4 = 8이다.

+
+

그러므로 왼쪽의 곱들과 오른쪽의 곱들을 기억해 두고 각 원소별 값을 출력해 주면 해결할 수 있다.

+

해당 원소까지의 왼쪽, 오른쪽 곱셈 값은 누적 배열을 활용하면 된다.

+
+

e.g. 원소 n 기준 왼쪽 [n-2, n-2 * n-1] * [n+1, n+1 * n+2] 오른쪽

+
+
const nums = [1, 2, 3, 4];
+ 
+// @NOTE: 요소의 왼쪽까지의 곱을 기준으로 하기 때문에 첫 번째는 1으로하고 마지막 원소까지 곱할 필요는 없다.
+const leftArr = [
+  1,
+  1 * nums[0],
+  1 * nums[0] * nums[1],
+  1 * nums[0] * nums[1] * nums[2],
+]; // [1, 1, 2, 6];
+ 
+// @NOTE: 요소의 오른쪽까지의 곱을 기준으로 하기 때문에 직관적으로 역순으로 한다.
+const rightArr = [
+  1 * nums[2] * nums[1] * nums[0],
+  1 * nums[2] * nums[1],
+  1 * nums[2],
+  1,
+]; // [24, 12, 4, 1];
+

Implementation(T=O(n), S=O(n))

+

위의 정리를 토대로 구현을 해보자.

+
    +
  1. n 번째 원소의 왼쪽 곱의 값들을 저장한다.
  2. +
  3. n 번째 원소의 오른쪽 곱의 값들을 저장한다.
  4. +
  5. 전체 원소를 순회하며 n 번째 원소의 좌우 값의 곱을 저장한다.
  6. +
  7. 리턴한다.
  8. +
+
// Left Arr
+const l = nums.reduce(
+  (acc, cur) => {
+    const last = acc[acc.length - 1]; // 누적값
+    acc.push(cur * last); // 이전의 누적값 * 현재 원소를 통해 누적값을 채운다.
+    return acc;
+  },
+  [1],
+);
+l.pop(); // 마지막 원소는 필요 없으므로 빼준다.
+ 
+// Right Arr
+const r = nums.reduce(
+  (acc, _, index) => {
+    const last = acc[0];
+    const cur = nums[nums.length - index - 1];
+    acc.unshift(cur * last);
+    return acc;
+  },
+  [1],
+);
+r.shift();
+ 
+// Left * Right Arr
+const answers = nums.map((_, index) => l[index] * r[index]);
+return answers;
+

위의 구현으로 문제를 해결할 수 있지만 공간 복잡도가 O(n)이기 때문에 더 최적화가 가능하다.

+

Implementation(T=O(n), S=O(1))

+

좌우 배열은 결국 마지막에 곱하기 위해서만 존재한다.

+

따라서 굳이 저장하지 않고 바로바로 곱해나가면 공간복잡도를 O(1)으로 줄일 수 있다.

+
const answers = [];
+ 
+let last = 1;
+// @NOTE: Step1. Answers 배열에 미리 왼쪽 곱 배열을 세팅
+for (let i = 0; i < nums.length; i++) {
+  const cur = nums[i];
+  answers[i] = last;
+  last *= cur; // 누적값 갱신
+}
+ 
+last = 1;
+// @NOTE: Step2. Answers가 이미 좌측 곱 배열이므로 우측 값을 그대로 곱해주면 정답이 된다.
+for (let i = nums.length - 1; i >= 0; i--) {
+  const cur = nums[i];
+  answers[i] *= last;
+  last *= cur;
+}
+return answers;
+
+

NOTE: 어쨌든 answers 배열을 사용하므로 정확히는 O(n)이지만

+

LeetCode에서 return 배열(answers)이 아닌 추가 배열을 사용하지 않는다는 것으로 O(1)으로 취급하고 있다.

+
+

연속되는 곱셈의 합을 이용한 재밌는 문제였다.

+

그럼 이만~

📮 이 포스트에 관심 있으신가요? 이슈를 남겨주세요! 👍
☕ 소주 한 잔 후원하기
(예금주: 이상철)tosskakao
\ No newline at end of file diff --git a/e2e/__snapshots__/post/dom.spec.ts/mobile/mac-init-apps.html b/e2e/__snapshots__/post/dom.spec.ts/mobile/mac-init-apps.html new file mode 100644 index 00000000..574fdb5b --- /dev/null +++ b/e2e/__snapshots__/post/dom.spec.ts/mobile/mac-init-apps.html @@ -0,0 +1,241 @@ +

웹 개발자를 위한 도구 추천 - 유용한 Mac 앱들

1ilsang
클라이밍 하실래염?
#mac#settings
Published

cover

+

최근 기기 변경을 하면서 맥 세팅을 처음부터 할 일이 있었다. 그때 꽤 고생한 기억이 있어 이번 기회에 유용했던 것들을 한번 정리해 보려고 한다.

+

웹 개발자를 위한 도구 추천 포스트는 3가지 시리즈로 연재 될 예정이다.

+
    +
  1. 유용한 Mac 앱
  2. +
  3. VSCode 익스텐션
  4. +
  5. 크롬 익스텐션
  6. +
+

직접 사용하면서 유용했던 것들을 모아놓았기 때문에 안정성 문제는 없을 것으로 생각된다.

+

모아보기

+ +

Chrome

+

chrome-cover

+

소개 문구에서부터 포스가 장난 아니다. 테스팅 환경 때문이라도 필요한 웹 브라우저 크롬이다.

+

다음에 연재할 크롬 익스텐션 섹션을 통해 크롬이 얼마나 강력한지 후술하고자 한다.

+ +
+

다운로드 링크

+
+

Homebrew

+

homebrew

+

homebrew는 CLI로 편리하게 앱을 설치할 수 있게 해준다.

+

환경변수 및 패키지 폴더 구성 등을 자동으로 해주기 때문에 불쾌한 초기 설정을 벗어나게 해준다.

+

많은 프로젝트에서 homebrew를 통한 설치 가이드를 제공하고 있을 정도로 대중적이니 꼭 설치하자.

+
# 터미널 설치
+$ /bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"
+
+

다운로드 링크

+
+

Oh My Zsh

+

cmd-example

+

brew로 작업하다 보면 터미널이 참 못생겼다고 느낄 수 있다. 터미널을 위와 같이 원하는 정보가 노출되도록 설정할 수 있다.

+

내가 사용하고 있는 테마는 bullet-train 커스텀 테마이다. 해당 테마는 현재 시간 및 작업 소요 시간, 성공 여부, 깃 상태 등 다양한 정보를 노출시켜 주므로 선택하게 되었다.

+
# 터미널 설치
+$ sh -c "$(curl -fsSL https://raw.githubusercontent.com/ohmyzsh/ohmyzsh/master/tools/install.sh)"
+
+

다운로드 링크

+
+

Iterm2

+

iterm2-example

+

기본 맥 터미널은 못생겼기 때문에 iterm2을 설치해 터미널을 더 예쁘게 커스텀 할 수 있다.

+

zsh이 터미널의 내용을 관리한다면 터미널 창 자체로 디자인을 제공해 주는 역할은 iterm2이다.

+
+

만약 커맨드라인에서 cmd + delete로 한줄 삭제 하고 싶다면

+

환경 설정 > Profiles > Keys > Natural Text Editing으로 설정 한다.

+
+

이 외에도 각 탭에서 창 기본 크기, 배경 설정 등 할 수 있다.

+
+

다운로드 링크

+
+

VSCode

+

vscode-logo

+

VSCode에 너무 절여져 있어 다른 에디터는 이제 기억이 나지 않는다... 유용한 익스텐션은 다음 포스트로 연재하겠다.

+
+

다운로드 링크

+
+

NeoVim

+

만약 vi 환경을 좋아한다면 설치하면 좋다. 기본적으로 vim은 한글 입력시 문제가 많다(조합 중인 문자 소실 등).

+

NeoVimvim을 오픈소스화하여 기존의 문제들 해결하고 커뮤니티 자발적으로 다양한 플러그인을 개발/공유하고 있어 강력한 에디팅을 지원한다.

+
$ brew install neovim
+# 기본 vi를 neovim으로 변경하고자 한다면 alias를 변경한다.
+$ vi ~/.zshrc
+$ alias vi="nvim"
+
+

다운로드 링크

+
+

D2 Coding

+

폰트는 d2 코딩이 가장 편하다고 느껴서 늘 사용하고 있다.

+

모호할 수 있는 문자들이 1ijIlO0tz아야저져쁆뼮뼯뗾 기본적으로 잘 보인다(이 블로그 폰트도 D2coding이다).

+

iterm2에 d2coding을 기본 폰트로 적용하기

+
+

Profiles > Text > Font > D2Coding

+

그림으로 보기

+
+

VSCode 기본 폰트로 적용하기

+
+

Setting(cmd + ,) > Font Family > D2Coding을 제일 앞에 적어준다.

+

그림으로 보기

+
+

상당히 개발자 친화적인 폰트라 생각한다.

+
+

다운로드 링크

+
+

Node.js

+

"신"

+
+

다운로드 링크

+
+

NVM

+

프로젝트를 여러개 만들다 보면 노드 버전이 상이한 경우가 종종 생긴다. 이때 노드 버전을 어떻게 처리할까?

+

답은 nvm을 통해 노드 버전을 프로젝트마다 변경하면 된다.

+
# Nvm 다운로드.
+$ curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.7/install.sh | bash
+# 터미널 실행시 자동으로 nvm을 사용하도록 설정.
+$ vi ~/.zshrc
+# 아래 내용을 zshrc 아무곳에 붙여넣는다.
+export NVM_DIR="$([ -z "${XDG_CONFIG_HOME-}" ] && printf %s "${HOME}/.nvm" || printf %s "${XDG_CONFIG_HOME}/nvm")"
+[ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh" # This loads nvm
+# 쉘 반영
+$ source ~/.zshrc
+

사용법은 아래와 같다.

+
# .nvmrc 파일이 존재하면 지정된 노드 버전으로 설정됨.
+$ nvm use
+# Node.js 20.10.0 버전 다운로드.
+$ nvm install 20.10.0
+# Node.js 20.10.0 버전 사용.
+$ nvm use 20.10.0
+
+

가이드 링크

+
+

Docker

+

두 번째 "신"

+
+

다운로드 링크

+
+

GIPHY Capture

+

giphy example

+

간단하게 움짤(gif)을 따야 할 때 유용하게 쓸 수 있다.

+
+

다운로드 링크

+
+

DeepL

+

번역 퀄리티가 상당히 좋다. 또한 cmd + c + c로 빠르게 번역하기도 지원하기 때문에 숏컷 활용도 또한 뛰어나다.

+

이후 다룰 크롬 익스텐션과 함께 사용하면 찰떡이다.

+
+

다운로드 링크

+
+

ScreenHint

+
+ cover + setting +
+

정말 추천하고 싶은 프로그램. 원하는 부분만 스크린샷으로 띄워 놓을 수 있다.

+

맥 어플 전체화면시에도 올라가 있기 때문에 상당히 편리하다.

+
+

다운로드 링크

+
+

Quick Notes

+

quick notes example

+

줌으로 미팅하거나 전체화면으로 열려있는 자료가 많아 잠깐잠깐 메모가 필요할 때 유용한 앱이다.

+

유료라서 꼭 필요한 게 아니라면 크게 추천하고 싶진 않다.

+
+

다운로드 링크

+
+

Calculator Pro

+
+ calculator pro example + setting +
+

맥 환경 특성상 전체화면 된 앱 위에 무엇을 겹치는 것이 불가능하다. 하지만 이 앱은 계산기를 전체화면 된 앱 위에 올려준다.

+

알고리즘 풀 때 진짜 꿀이다.

+

오른쪽 화면은 내가 쓰는 글로벌 숏컷이다. 껐다켰다하며 사용하기 편하다.

+
+

다운로드 링크

+
+

올ㅋ사전

+
+ example + setting +
+

특정 단어를 검색해야 할 때 화면 이동을 하는 건 너무 귀찮다. 단축키로 바로 열어서 찾는 것이 편리하다.

+
+

다운로드 링크

+
+

CodeWhisperer

+

CodeWhisperer example

+

Fig가 공식적으로 AWS에 흡수되면서 출시된 제품이다. 개인 개발에는 무료로 사용할 수 있다.

+

CLI를 자주 사용한다면 정말 유용한 앱이다. 다음 명령어에 대한 힌트뿐만 아니라 해당 명령어의 기대 효과도 같이 알려준다.

+

터미널의 효자 그 자체다.

+
$ brew install --cask codewhisperer
+
+

다운로드 링크

+
+

Flycut

+

flycut example

+

우리는 복/붙을 상당히 많이 한다. 만약 이전에 복사했던 내용을 다시 가져오고 싶다면 어떻게 하고 있는지 생각해 보자.

+

별다른 수가 떠오르지 않는다면 이 앱을 추천한다. 이 앱은 이전에 복사했던 내용들을 기억하고 불러오는 것도 지원해 준다. 심지어 복사된 시간도 알려준다.

+
+

다운로드 링크

+
+

ScreenBrush

+

screen brush example

+

줌과 같은 화상 회의를 할 때나 전체 미팅 때 내 화면을 공유할 일이 많다면 강력히 추천한다.

+

화면에 무엇인가 작성하거나 포인터가 필요할 때 예쁘게 시선을 잡아주는 효자 앱이다.

+
+

다운로드 링크

+
+

Keycastr

+

keycastr example

+

ScreenBrush와 함께 사용하면 빛나는 앱이다. 내가 어떤 키보드를 입력했는지 화면에 보여준다.

+
$ brew install --cask keycastr
+
+

다운로드 링크

+
+

Ngrok

+

ngrok example

+

배포 없이 로컬에서 작업한 나의 페이지를 다른 사람에게 보여주고 싶다면 어떻게 해야 할까?

+

ngrok은 프록시 서버를 열어 내 로컬 포트로 접근하게 해준다. 서버 없이 빠르게 데모 페이지를 공유할 때 유용하다.

+
$ ngrok http 3000 # 3000번 포트로 http 통신을 허용한다.
+# 위의 이미지처럼 https://7421-1-235-243-130.ngrok-free.app URL이 생성(일회용 랜덤)된다.
+# 이후 해당 URL로 접근하면 localhost:3000으로 접속한 것과 같이 된다.
+# 이로써 정적 배포/서버 없이 누구에게나 열린 일회용 퍼블릭 URL을 가지게 되었다!
+

단, 사용하기 위해선 로그인 이후 인증 토큰을 넣어야 한다.

+
+

다운로드 링크

+
+

Hidden bar

+

hidden bar example

+

이쯤 되면 상단바가 상당히 늘어났다는 것을 확인할 수 있다. Hidden bar는 필요한 앱들만 상단바에 노출시켜 주는 앱이다.

+
+

다운로드 링크

+
+

Digital Color Meter

+

example

+

맥 자체 유용한 앱이다. CSS 작업을 하다 보면 스포이드가 필요한 순간이 있는데 유용하게 사용할 수 있다.

📮 이 포스트에 관심 있으신가요? 이슈를 남겨주세요! 👍
☕ 소주 한 잔 후원하기
(예금주: 이상철)tosskakao
\ No newline at end of file diff --git a/e2e/__snapshots__/post/dom.spec.ts/mobile/mdn-ko-organizer.html b/e2e/__snapshots__/post/dom.spec.ts/mobile/mdn-ko-organizer.html new file mode 100644 index 00000000..2fc5fb18 --- /dev/null +++ b/e2e/__snapshots__/post/dom.spec.ts/mobile/mdn-ko-organizer.html @@ -0,0 +1,81 @@ +

@mdn/yari-content-ko Organizer 합류 여정

1ilsang
클라이밍 하실래염?
#mdn#mozilla#open-source
Published

cover

+

mdn local

+

최근 @mdn/yari-content-ko 팀에 합류하게 되었다.

+

오픈소스 프로젝트의 방향성을 계획하고, 리뷰어로 활동하는 것은 처음이었기에 들뜬 마음으로 임할 수 있었다.

+

이 글을 통해 MDN 및 나의 합류 과정을 정리해 보려고 한다.

+

Index

+ +

MDN?

+

mdn readme

+
+

https://github.com/mdn

+
+

MDN은 Readme에 그 목적이 잘 나타나 있다.

+

MDN 웹 문서는 CSS, HTML, JavaScript, Web API를 비롯한 웹 플랫폼 기술을 문서화하는 오픈소스 프로젝트이다.

+
+

image

+

MDN 사이트에 들어가면 방대한 기술 문서를 확인할 수 있다.

+

임무

+

MDN의 임무는 "더 나은 인터넷을 위한 청사진을 제공하고 새로운 세대의 개발자와 콘텐츠 제작자가 이를 구축할 수 있도록 지원하는 것"이라고 되어 있다.

+

해당 임무에 걸맞게 MDN 문서들은 다양한 웹 플랫폼 기술의 올바른 사용법과 해석을 하기 위해 노력하고 있다.

+

역사

+

MDN은 2005년에 시작되어 문서 전체가 오픈소스로 운영되어 누구나 참여할 수 있는 프로젝트이다.

+

초창기에는 모든 문서가 SQL 데이터베이스에 존재하고 WYSIWYG 편집기로 변경했다. 이때는 한국 로케이션이 활성화 안되어 있었기 때문에 번역 문서에 "역자주" 등 주관적 의견이 많이 추가되어 있었다. 이는 긍정적인 면도 있지만 전체적으론 통일성이 부족해지고 각 문서의 품질이 떨어지기도 했다.

+

2020년 기존 문서화 툴을 yari로 변경하면서 git을 통한 체계적인 기여와 통일성 있는 문서로 발전하게 되었다.

+
+

관련 내용: Welcome Yari: MDN Web Docs has a new platform

+
+

이후 2021년 4월 yari-content-ko팀이 창설되면서 한국 로케일도 활성화 되었다. 이때부터 한국 MDN 문서도 체계적인 리뷰 시스템이 존재하게 되었다.

+

합류 여정

+

이제 나의 썰을 조금 풀어보려고 한다.

+

문서화에 대한 관심

+

webpack blog post

+

2021년에 번역 오픈소스 기여 가이드 글을 작성한 적이 있다.

+

본문에서 언급되어 있듯 나는 오픈소스에 기여하고 싶었지만 기술 이전에 영어에 대한 부족함을 많이 느꼈다. 어쩌면 이것이 문서화에 대한 열망으로 표출되었다고 생각한다.

+

당시 내가 재직 중이던 LINE+에서 Webpack 한글화 작업이 진행되고 있었다.

+

운이 좋았다고 생각된다. 팀원들의 기여 과정을 어깨너머로 보면서 하고 싶다고 느꼈고 실제로 조금씩 기여하기 시작했다.

+

이때의 경험이 상당히 좋았기 때문에 이후 React.devMDN에도 조금씩 기여하게 되었다.

+

사내 오픈소스 스프린트 참여

+

사내 DevRel 팀에서 오픈소스 기여 행사를 열었다. 이때 MDN 문서 번역 프로젝트가 있어 참여해 본격적으로 번역 기여를 하기 시작했다.

+

이때 PR을 꽤 열심히 날려서 기여 1등으로 행사를 마무리했다.

+

온보딩 과정 진행

+

행사 이후에도 MDN에 꾸준히 기여하던 중 운이 좋게도 yari-content-ko 팀원 제안 메일을 받게 되었다.

+

내 대답은 당연히 YES였기 때문에 바로 온라인 티타임을 가졌다. 상당히 친절하게 맞아주셔서 감동이었다.

+

onboarding

+

이후 본격적인 리뷰어 온보딩 과정이 시작되었고 오픈소스답게 공개적으로 이슈를 생성해 과정을 전체 공유했다.

+

pr

+

다행히 무사히 과제를 끝낼 수 있었고 본격적으로 리뷰어로 활동하게 되었다.

+

합류 후

+

@mdn/yari-content-ko 팀은 MDN 한국 문서에 대한 전체 권한을 가지고 있다. 기여 PR 리뷰와 유지보수 및 한국 지역 활성화에 대한 고민을 함께 하고 있다.

+

팀에 합류하면서 크게 3가지 달라진 점이 있었다.

+

정기 회의

+

정기 회의를 통해 전체적인 방향성에 대한 싱크를 맞추고 PR 리뷰에 이상이 없는지 등 검증하는 시간을 가졌다.

+

공개 논의

+

public discussion

+
+

https://github.com/orgs/mdn/discussions/655

+
+

문서 번역 리뷰나 프로젝트에 대한 의견 제시 등 공개적인 논의를 함께 이야기하게 되었다.

+

리뷰어 활동

+

reviewer action

+

아마도 가장 크게 달라진 부분이라 생각한다. 컨트리뷰터에서 리뷰어가 되면서 기여해 주신 PR을 검토하고 있다.

+

이 부분이 꽤 까다롭지만 보람을 느끼고 있다. 기여자의 열정이 식지 않도록 빠르고 친절하게 응답하려고 노력하고 있다.

+

이모지의 힘이 크다고 느끼고 있다. 하트 감사합니다.

+

올해 목표

+

CSS Goal

+

팀원이 기여 목표를 세우는 것을 보고 감명받아 나도 세웠다.

+
    +
  1. CSS 한국어 번역 50%까지 올리기
  2. +
  3. 번역 자동화 스크립트 추가
  4. +
+

현재는 번역 리뷰 시 Glossary를 수동으로 확인하고 있다. 이 부분을 자동화하고 CSS 번역을 꾸준히 해보려고 한다.

+

마무리

+

번역은 오픈소스 입문의 좋은 시작점이라 생각한다.

+

그렇기 때문에 리뷰어로서 사명감을 느끼고 있다. 오픈소스를 시작하려는 분들이 꾸준히 기여하고 생태계를 끌어 나갈 인재로 성장할 수 있도록 좋은 경험을 주고 싶다.

+

MDN 문서 번역에 관심이 생겼다면 첫 기여자들을 위한 안내서를 참고해 주시길 바란다.

📮 이 포스트에 관심 있으신가요? 이슈를 남겨주세요! 👍
☕ 소주 한 잔 후원하기
(예금주: 이상철)tosskakao
\ No newline at end of file diff --git a/e2e/__snapshots__/post/dom.spec.ts/mobile/micro-state-management-review.html b/e2e/__snapshots__/post/dom.spec.ts/mobile/micro-state-management-review.html new file mode 100644 index 00000000..91118c97 --- /dev/null +++ b/e2e/__snapshots__/post/dom.spec.ts/mobile/micro-state-management-review.html @@ -0,0 +1,171 @@ +

Micro State Management with React Hooks 리뷰

1ilsang
클라이밍 하실래염?
#book#review#react#hooks#context#zustand#jotai#valtio
Published

cover

+

Micro State Management with React Hooks 리뷰를 해보려고 한다.

+

선택하게 된 계기

+

이 책은 작년에 읽었었는데, 당시에는 이해가 많이 안 돼서 깊이 있게 생각하지 못했었다.

+

실무에서 Jotai를 사용하기 시작하면서 사용하는 라이브러리에 대한 이해를 높이고자 다시금 이 책을 읽게 되었다.

+

이 책은 세 가지 흥미로운 부분이 있다.

+
    +
  1. 원서다. 영어 기술 서적은 처음 읽어서 꽤 도전적이었다.
  2. +
  3. Zustand, Jotai 등을 만든 상태관리에 진심인 메인테이너 Daishi Kato가 직접 펴낸 책이다.
  4. +
  5. React에서의 상태 관리 전략을 다양하게 보여주기 때문에 시야가 넓어진다.
  6. +
+

다 읽고 나서 알게 되었는데, 이 책은 최근에 한국어로 번역되었다(-_-). 이때 아니면 언제 원서 읽었겠나 싶어 만족하려고 한다.

+

간단한 요약

+
    +
  • 상태란 무엇일까?
  • +
  • 상태는 어떻게 존재할 수 있을까?
  • +
  • 상태를 변화시키는 방법은 어떤 것들이 있을까?
  • +
  • React hooks은 상태 관리 라이브러리에 어떤 영향을 주었을까?
  • +
  • 우리는 리렌더링을 어떻게 회피할 수 있을까?
  • +
+

이 책을 읽으면서 얻을 수 있었던 인사이트들이다.

+

너무 어렵게 들어가지 않으면서 상태 관리의 다양한 기법들을 제시하기 때문에 주니어부터 시니어까지 충분히 배울게 많은 책이라는 생각이 든다.

+

인상 깊었던 부분

+

책을 읽으면서 좋았던 예제나 포인트들을 가볍게 소개하고자 한다.

+

1. React State에 대한 이해

+

React에서 상태(state)는 UI를 나타내는 모든 데이터다. React는 상태와 함께 렌더링 할 컴포넌트를 처리한다.

+

책의 서두에서 기존 상태 관리의 문제점에 대해 이야기한다.

+
React Hooks 이전에는 모놀리식 상태 라이브러리들이 유행했다.
+이는 DX 향상에 큰 도움을 주었지만 사용되지 않는 기능들도 포함된다는 문제가 있었다.
+ 
+1. Form 상태는 글로벌 상태와 별도로 다루어져야 하지만, 단일 상태 솔루션에서는 불가능하다.
+2. 서버 캐시 상태는 refetching과 같은 다른 상태들과는 다른 독특한 특징을 가지고 있으나 분리가 불가능하다.
+3. 브라우저 네비게이션 상태는 원본값이 브라우저 측에 기반한다는 성질이 있어 단일 상태 솔루션에 적합하지 않다.
+ 
+이런 문제들을 해결하는 것이 React Hooks의 목표 중 하나이다.
+

위 내용을 요약하면 Hooks 이전의 상태는 상태의 순수함이 결여되어 있었다는 점이 문제라고 꼬집는다.

+

상태는 전역 상태와 지역 상태가 있다. 지역으로 존재해야 할 데이터들이 전역 스토어에 혼재되어 있고 서버/브라우저 상태가 무분별하게 스토어에 들어 있었다.

+

React Hooks로 위의 내용들을 해결하고자 한다는 내용이 인상적이었다.

+

2. useState vs useReducer

+

React 컴포넌트가 상태를 가지고 있으려면 어떻게 해야 할까?

+

일반 변수나 전역 변수로 선언하고 사용할 수도 있다. 하지만 해당 값이 변경되었다고 한들 컴포넌트는 리렌더링 되지 않는다.

+

컴포넌트가 지역 상태의 변경을 감지하고 리렌더링 하려면 useState 혹은 useReducer를 사용해야 한다.

+
+

+1. React는 전역 상태를 제공하지 않는다.

+
+
// CASE 1.
+const [count, setCount] = useState(0);
+setCount(1);
+setCount(1); // Not render. 동일한 값이므로 렌더링 하지 않는다.
+ 
+// CASE 2.
+const [state, setState] = useState({ count: 0 });
+setState({ count: 1 });
+setState({ count: 1 }); // Re-render! 주소 참조는 항상 다른 값이 된다.
+ 
+// CASE 3.
+state.count = 1;
+setState(state); // Not render. state 주소값은 변하지 않았기 때문에 렌더링 하지 않는다.
+ 
+// CASE 4.
+setCount(count + 1);
+setCount(count + 1); // 동일하게 두 번 호출되면 +2가 아닌 +1만 될 수 있다. 이를 해결하기 위해선 함수 업데이트가 필요하다.
+ 
+// CASE 5.
+setCount((prev) => prev + 1); // 함수로 작성하게 될 경우 아무리 빠르게 눌러도 횟수만큼의 업데이트가 될 것을 보장한다. 이는 내부적으로 함수를 연속적으로 호출하기 때문이다.
+ 
+// CASE 6.
+setCount((prev) => prev); // 결과값이 직전과 동일하기 때문에 리렌더링이 일어나지 않는다.
+ 
+/**
+ * init 함수는 useState를 호출하기 전에 실행되지 않는다(lazy initialize).
+ * 이는 컴포넌트가 마운트 될 때 한 번만 호출함을 뜻한다.
+ **/
+const init = () => 0;
+const [count, setCount] = useState(init);
+

useState의 다양한 사용 사례를 들어 리렌더링이 되는 경우를 설명한다. 또한 지연 초기화 경우를 들어 상태 관리의 최적화에 관해 설명한다.

+
const init = (count) => ({ count, text: 'hi' });
+const reducer = (state, action) => {
+	switch(action.type) {
+		case 'INCREMENT':
+			return { ...state, count: state.count + 1 };
+		case 'SET_TEXT':
+			if(!action.text) {
+				return state; // THIS IS Bailout! 리렌더링 되지 않음.
+		return { ... state, text: action.text };
+		default:
+			throw new Error(`unkown action type`);
+    }
+  }
+}
+ 
+const Component = () => {
+  const [state, dispatch] = useReducer(reducer, 10, init);
+	return (
+		<div>{state.count}
+			<button onClick={() => dispatch({ type: 'INCREMENT' })}>Increment</button>
+			<input value={state.text} onChange={(e) => dispatch({ type: 'SET_TEXT', text: e.target.value })} />
+

useReducer 부분에는 useState에서는 불가능한, reducer만의 특별한 기능들을 언급하며 왜 useState는 useReducer로 대체 가능하지만, 역은 안되는지에 대해 설명한다.

+

useState와 다르게 useReducer에서 인라인으로 함수를 선언하면 사이드 이펙트가 발생한다거나(useReducer는 렌더링 단계에서 reducer를 호출하므로) 복잡한 상태 관리를 처리하기 위한 reducer만의 기법들은 좋은 인사이트를 주었다.

+

3. ContextAPI vs Import

+

Context를 활용한 상태와 모듈(import) 상태에 대한 비교는 내가 가지고 있던 전역/지역 상태에 대한 시야를 넓혀주었다.

+
+

모듈 상태는 ESM 스코프에 특정 상수 혹은 변수를 정의하는 것을 의미.

+

export const store = {} 와 같다.

+
+

우리는 전역 상태가 앱 전반에서 접근할 수 있는 상태라고 알고 있다. 그런데 이 "앱 전반"은 "React 외부에서 접근"한다는 것까지 포함해야 한다.

+

ContextAPI로 작성된 전역 상태(root provider)는 React 외부에서 접근이 불가능하다. 하지만 모듈로 작성된 코드는 외부에서 접근이 가능하다.

+

또한 Context는 기본적으로 싱글턴 패턴을 위해 디자인되지 않았다. 여러 프로바이더에서 사용될 수 있으며 여러 서브 트리에서 다양한 상태로 존재할 수 있다. 하지만 모듈 상태는 싱글턴으로 존재한다. 따라서 단일 전역 상태를 위해서는 모듈 상태를 사용해야 한다. 이는 인메모리에 올라간 단일 변수로 취급되기 때문이다.

+

4. Zustand vs Jotai vs Valtio

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
ZustandJotaiValtio
상태의 위치ModuleReact ComponentModule
상태의 형태ImmutableImmutableMutable
상태 변경 전략SelectorContextAPIProxy
재사용성모듈 상태는 재사용이 까다롭다(싱글턴). 재사용을 위해 결국 Context를 쓰게 된다Provider를 통해 쉽게 재사용이 가능하다Zustand와 동일
코드 학습량셀렉터에 대한 이해가 있어야 한다Context 및 Atoms에 대한 이해가 있어야 한다순수 자바스크립트로 이루어져 있어 학습량이 거의 없다
리렌더링 최적화개발자가 셀렉터를 잘 써야 한다 한다개발자가 Atom 단위를 적절하게 활용해야 한다Proxy가 자동으로 해준다
비고셀렉터 최적화, 객체 참조와 메모이제이션에 익숙하다면 편하게 단일 스토어를 사용할 수 있다Context 기반이므로 Jotai에서 가능한 것들은 Context에서도 가능하다. React LifeCycle과 공존하므로 예측 가능하다(Suspense 지원 등)자동 리렌더링 최적화 및 순수 JS 기반이라 편하게 사용 가능하다. 불변성을 위해 코드가 복잡해지지 않아도 된다. 하지만 디버깅 과정이 어렵다
+

저자가 직접 만든 상태 관리 라이브러리들을 비교 하는 부분은 이 책의 하이라이트라고 생각한다.

+

왜 여러 상태 라이브러리를 만들수 밖에 없었는지, 각 라이브러리의 리렌더링 회피 전략과 차이를 설명한다. 또한 공통점도 설명하는데 세 라이브러리 모두 "코드량이 적다".

+

저자가 가장 중요하게 생각하는 포인트라고 생각한다.

+

맺으며

+

기본적으로 React를 어느정도 이해하고 있는 개발자를 대상으로 작성된 책이지만 코드 자체가 어렵진 않아서 초심자도 읽어볼 만하다고 생각한다.

+

책을 읽으면서 상태 관리에 대해 시야가 넓어질 수 있었다.

+

다음 월간 다이브에는 "상태"를 주제로 해보려고 한다. 책을 통해 배운 것들을 잘 풀어보고 싶다.

+

상태 관리의 종류와 기법들에 대해 이해하고 싶다면 추천하고 싶은 책이다.

📮 이 포스트에 관심 있으신가요? 이슈를 남겨주세요! 👍
☕ 소주 한 잔 후원하기
(예금주: 이상철)tosskakao
\ No newline at end of file diff --git a/e2e/__snapshots__/post/dom.spec.ts/mobile/prettier3.html b/e2e/__snapshots__/post/dom.spec.ts/mobile/prettier3.html new file mode 100644 index 00000000..aa8c41b0 --- /dev/null +++ b/e2e/__snapshots__/post/dom.spec.ts/mobile/prettier3.html @@ -0,0 +1,219 @@ +

Prettier v3 변경사항 살펴보기

1ilsang
클라이밍 하실래염?
#prettier#lint
Published

cover

+

Prettier v3.0이 어제 7월 5일에 공개되었다.

+

내용은 읽으면서 흥미로웠던 부분들 위주로 주요 변경점들을 기준으로 간략하게 소개 하려고 한다.

+
+

Prettier 플러그인 개발쪽의 변경사항도 크지만 실제 사용성 위주로 정리를 했다.

+
+

TL;DR!

+
    +
  1. trailingComma 옵션이 all으로 변경되었다.
  2. +
  3. 주석 및 MD 문서의 린팅 편의성이 높아졌다(한국어 린트가 향상되었다)
  4. +
  5. 최소 요구 Node 버전이 14가 되었다.
  6. +
  7. .gitignore가 드디어 기본적으로 무시된다(.prettierignore는 유지).
  8. +
  9. 화살표 함수/타입의 린트가 코드 패턴을 지원.
  10. +
+

Markdown

+

한국어 처리의 향상

+
<!-- Input -->
+ 
+노래를 못해요.
+ 
+<!-- Prettier 2.8 with --prose-wrap always --print-width 9 -->
+ 
+노래를 못
+해요.
+ 
+<!-- Prettier 2.8, subsequent reformat with --prose-wrap always --print-width 80 -->
+ 
+노래를 못 해요.
+ 
+<!-- Prettier 3.0 with --prose-wrap always --print-width 9 -->
+ 
+노래를
+못해요.
+ 
+<!-- Prettier 3.0, subsequent reformat with --prose-wrap always --print-width 80 -->
+ 
+노래를 못해요.
+

한국어는 공백의 위치에 따라 문장의 의미가 변경되는 특성이 있다.

+
    +
  • 노래를 못해요: 나는 노래를 잘 못한다.
  • +
  • 노래를 못 해요: 나는 (어떠한 이유로) 노래를 할 수 없다.
  • +
+

v3에서는 위의 특성을 고려해 단어를 '분해하지' 않는다. 영어와 동일하게 줄바꿈이 일어나게 된다.

+

인라인 코드 여백 유지

+
<!-- Input -->
+ 
+`   foo   bar   baz   `
+ 
+<!-- Prettier 2.8 -->
+ 
+`foo bar baz`
+ 
+<!-- Prettier 3.0 -->
+ 
+`   foo   bar   baz   `
+

다수의 여백이 하나로 줄여지던 문제가 해결되었다.

+

JavaScript / TypeScript

+

trailing-comma

+
// Input
+type Foo = [
+  {
+    from: string;
+    to: string;
+  }, // <- 1
+];
+type Foo = Promise<
+  | { ok: true; bar: string; baz: SomeOtherLongType }
+  | { ok: false; bar: SomeOtherLongType } // <- 2
+>;
+ 
+// Prettier 2.8
+type Foo = [
+  {
+    from: string;
+    to: string;
+  }, // <- 1
+];
+type Foo = Promise<
+  | { ok: true; bar: string; baz: SomeOtherLongType }
+  | { ok: false; bar: SomeOtherLongType } // <- 2
+>;
+ 
+// Prettier 3.0
+type Foo = [
+  {
+    from: string;
+    to: string;
+  }, // <- 1
+];
+type Foo = Promise<
+  | { ok: true; bar: string; baz: SomeOtherLongType }
+  | { ok: false; bar: SomeOtherLongType } // <- 2
+>;
+

타입 매개변수 및 튜플에서도 쉼표가 추가되었다.

+

Decorated function 패턴 지원

+
// Prettier 2.8
+const Counter = decorator('my-counter')((props: {
+  initialCount?: number;
+  label?: string;
+}) => {
+  // ...
+});
+ 
+// Prettier 3.0
+const Counter = decorator('my-counter')((props: {
+  initialCount?: number;
+  label?: string;
+}) => {
+  // ...
+});
+

데코레이터 패턴에서 들여쓰기를 줄이기 위해 화살표 함수의 가독성을 희생하도록 변경되었다.

+

누락된 await 괄호 추가

+
// Input
+async function request(url) {
+  return (
+    // prettier-ignore
+    (await fetch(url)).json()
+  );
+}
+ 
+// Prettier 2.8
+async function request(url) {
+  return (
+    // prettier-ignore
+    await fetch(url).json()
+  );
+}
+ 
+// Prettier 3.0
+async function request(url) {
+  return (
+    // prettier-ignore
+    (await fetch(url)).json()
+  );
+}
+

커링과 화살표 함수간 괄호 일관성 개선

+
// Input
+Y(() => (a ? b : c));
+Y(() => () => (a ? b : c));
+ 
+// Prettier 2.8
+Y(() => (a ? b : c));
+Y(() => () => (a ? b : c));
+ 
+// Prettier 3.0
+Y(() => (a ? b : c));
+Y(() => () => (a ? b : c));
+

Import Attributes를 지원

+
import json from './foo.json' with { type: 'json' };
+import('./foo.json', { with: { type: 'json' } });
+

주석이 있는 경우 유니온 타입의 개행이 유지

+
// Input
+type FooBar =
+  | Number // this documents the first option
+  | void; // this documents the second option
+ 
+// Prettier 2.8
+type FooBar = Number | void; // this documents the first option // this documents the second option
+ 
+// Prettier 3.0
+type FooBar =
+  | Number // this documents the first option
+  | void; // this documents the second option
+

extends 줄바꿈 개선

+
// Input
+export type OuterType2<
+  LongerLongerLongerLongerInnerType extends
+    LongerLongerLongerLongerLongerLongerLongerLongerOtherType,
+> = { a: 1 };
+ 
+// Prettier 2.8
+export type OuterType2<
+  LongerLongerLongerLongerInnerType extends
+    LongerLongerLongerLongerLongerLongerLongerLongerOtherType,
+> = { a: 1 };
+ 
+// Prettier 3.0
+export type OuterType2<
+  LongerLongerLongerLongerInnerType extends
+    LongerLongerLongerLongerLongerLongerLongerLongerOtherType,
+> = { a: 1 };
+

HTML

+

SVG 내부 script 린트 향상

+
<!-- Input -->
+<svg viewBox="0 0 200 200" xmlns="http://www.w3.org/2000/svg">
+  <script>
+    document.addEventListener('DOMContentLoaded', () => {
+      const element = document.getElementById('foo');
+      if (element) {
+        element.fillStyle = 'currentColor';
+      }
+    });
+  </script>
+</svg>
+ 
+<!-- Prettier 2.8 -->
+<svg viewBox="0 0 200 200" xmlns="http://www.w3.org/2000/svg">
+  <script>
+    document.addEventListener( 'DOMContentLoaded', () => { const element =
+    document.getElementById('foo') if (element) { element.fillStyle =
+    'currentColor' } });
+  </script>
+</svg>
+ 
+<!-- Prettier 3.0 -->
+<svg viewBox="0 0 200 200" xmlns="http://www.w3.org/2000/svg">
+  <script>
+    document.addEventListener('DOMContentLoaded', () => {
+      const element = document.getElementById('foo');
+      if (element) {
+        element.fillStyle = 'currentColor';
+      }
+    });
+  </script>
+</svg>
+

Reference

+
📮 이 포스트에 관심 있으신가요? 이슈를 남겨주세요! 👍
☕ 소주 한 잔 후원하기
(예금주: 이상철)tosskakao
\ No newline at end of file diff --git a/e2e/__snapshots__/post/dom.spec.ts/mobile/proving-ground-review.html b/e2e/__snapshots__/post/dom.spec.ts/mobile/proving-ground-review.html new file mode 100644 index 00000000..81a1f2fe --- /dev/null +++ b/e2e/__snapshots__/post/dom.spec.ts/mobile/proving-ground-review.html @@ -0,0 +1,27 @@ +

"사라진 개발자들" 리뷰

1ilsang
클라이밍 하실래염?
#book#review#사라진개발자들#eniac
Published

cover

+

여성 에니악 개발자 6인의 이야기를 중심으로 최초의 컴퓨터 탄생과 프로그래머의 역사를 풀고 있는 "사라진 개발자들"을 리뷰해 보려고 한다. 스포가 어느 정도 있다.

+

선택하게 된 계기

+

기술 서적을 읽다 보면 가끔 전혀 이해할 수 없는 혹은 접해보지 못한 키워드를 만날때가 있다(e.g. 천공 카드). 그때마다 그 단어의 기원을 알아가는 것이 재밌었다.

+

이 책 또한 위의 흥미에서 시작되었다. "전자식 숫자 적분 및 계산기(Electronic Numerical Integrator And Computer; ENIAC, 에니악)"이라니 정보처리기사 공부할 때 말고는 들어본 적도 없다. 거기에 최초의 개발자라니! 선택을 안 할 수가 없었다!

+

간단한 요약

+

미국은 2차 세계대전에서 우위를 점하기 위해 대포의 정확도를 높이고자 무던히 노력했다. 땅의 상태나 온도, 바람의 세기 등 다양한 변수를 고려해 최대한 정확한 사표(射表)를 만들고자 했다. 전시의 상황은 시시각각 변했기 때문에 빠르고 정확한 사표가 필요했고 이를 위해 필라델피아의 탄도 연구소에서는 계산을 위한 컴퓨터(compute-er)들을 고용하기 시작했다. 많은 남성들이 전장으로 갔기 때문에 자연스럽게 컴퓨터들은 수학과를 졸업한 여성들로 채워지게 되었다. 이후 에니악 6인이 될 여성들은 여기서 컴퓨터로써 만나게 된다.

+

한편 진공관의 가능성을 믿은 존과 프레스(에커트)는 에니악을 개발한다. 에니악의 실행을 위해선 배선을 옮기고 각각의 논리를 이어 나가야 했는데 그 과정을 에니악 6인이 진행해 나가게 된다. 여기의 논리들에는 이후 모든 프로그래밍에서 사용되는 if, loop 등이 있었다. 그녀들은 최초의 프로그래머로써 최초의 컴퓨터(당시)와 작업을 진행하게 된다.

+

첫 인상

+

맨 처음 이 책을 선택했을 때 나의 기대는 "에니악"을 중심으로 여성 개발자들의 활약상을 보는 것이었다. 하지만 프롤로그를 읽으면서 책의 흐름은 나의 기대와 다르게 흘러갈 것을 짐작할 수 있었다. 저자는 어떤 여성이 컴퓨팅 분야에 어떤 업적을 남겼는지 관심을 가졌고 그 역사를 찾다 흑백 에니악 사진에서 이름을 알 수 없는 여성들을 알게 되어 그들의 이야기를 중심으로 책을 만들었다.

+

이는 조금 낭패였다. 나는 그들의 삶에는 관심이 없었다. 기술의 기원과 발전에만 흥미가 있었다. 그렇기 때문에 책의 초반 그녀들의 학창 시절과 가족들에 대한 이야기는 꽤 곤혹스러웠다.

+

그런데 그녀들의 이야기 속에서 당시 미국의 분위기를 생생하게 접할 수 있었는데 이는 또 다른 매력이었다. 책의 마지막 부에 이르러서 나는 그녀들의 행방이 궁금해 검색하지 않을 수 없었다.

+

인상 깊었던 부분

+

책의 중간중간에는 당시 분위기를 엿볼 수 있는 문장들이 있다.

+
    +
  • 대공황 시대에 남성에게만 허용한 일자리가 2차 세계대전 동안 여성으로 채워지게 되었다
  • +
  • 전자식 컴퓨터는 불가능하고 불필요하다고 당시 주류 학계는 생각했다
  • +
  • 루즈벨트 대통령은 정기적으로 라디오를 이용해 각 가정에 개인적인 메시지를 전했다
  • +
+

이외에도 인물들의 이야기 속에서 진주만 습격부터 히로시마 원폭, 로스앨러모스 과학자와 폰 노이만 등 올스타들이 등장한다. 이는 상당히 흥미로웠다. 영화 오펜하이머를 최근에 보았기 때문에 에니악이 수소 폭탄 폭파 장치의 계산을 도왔다는 이야기는 텔러를 떠올리기에 충분했다. 임팩트 있는 역사적인 사건들이 중간중간 나올 때마다 에니악이 얼마나 중요한 위치에 있었는지 이해할 수 있었다.

+

에니악 6인의 프로그래밍 여정 또한 흥미롭게 읽을 수 있었다. LOOPIF-THEN 구문을 활용하는 대목이나 ROM(read-only-memory)의 기원, 벤치 테스트 과정, 에니악 병렬 프로그래밍과 분할-정복법, breakpoint 등이 소개되었다. 그녀들이 성공적으로 에니악 로직을 작성하기 위해 얼마나 노력했는지 알 수 있었다.

+

에니악에 대한 이야기도 있었는데, 탄도 연구소에서 90억의 투자를 했다는 점이나 이후 악삭박박의 탄생 여정 및 폰 노이만 형님의 설계까지 흥미롭게 읽을 수 있었다. 직접 프로그래밍에서 프로그래밍 내장식 컴퓨터가 되기까지 대학교에서 잠깐 들었던 내용들이 나오니 상당히 반가웠다.

+

맺으며

+

책의 한 문장을 뽑으라면 역시 나는 최초의 프로그래머를 선언하는 부분을 가져오고 싶다.

+

이 과정에서 프로그래머라는 직업이 탄생했다. 문제를 가진 사람과 컴퓨터를 연결해 문제를 해결하도록 돕는 역할을 하는 사람이 등장한 것이다. 여섯 여성은 현대 컴퓨터 분야 최초의 직업 프로그래머였다. -289p

+

우리는 영웅의 그림자에 가려진 또 다른 영웅들을 발굴하고 기억해야 한다고 느꼈다.

+

흥미롭게 읽은 책이다.

📮 이 포스트에 관심 있으신가요? 이슈를 남겨주세요! 👍
☕ 소주 한 잔 후원하기
(예금주: 이상철)tosskakao
\ No newline at end of file diff --git a/e2e/__snapshots__/post/dom.spec.ts/mobile/quality-of-job-review.html b/e2e/__snapshots__/post/dom.spec.ts/mobile/quality-of-job-review.html new file mode 100644 index 00000000..80fd313b --- /dev/null +++ b/e2e/__snapshots__/post/dom.spec.ts/mobile/quality-of-job-review.html @@ -0,0 +1,108 @@ +

"일의 격"을 읽고

1ilsang
클라이밍 하실래염?
#일의격#book#review
Published

cover

+

최근 일을 어떻게 마주해야 할지, 지속 가능한 회사 생활이 뭘까 고민하던 도중 이 책을 접하게 되었다.

+

나는 '회사에서의 나'를 굳이 일상에서 분리해야 할까? 라는 생각을 평소에 많이 했기 때문에 '즐거운 회사 생활'을 원했고, 회사 생활을 즐기려고 노력했다.

+

앞으로도 이렇게 살아가면 될지? 회사에서 나는 어떤 포지션/페르소나를 가졌는지 의문이 있던 차, 이 책의 다양한 예제와 격언을 통해 나는 어떤 사람인지 조금 더 가시화되었다.

+

하여, 읽으면서 나에게 인상 깊었던 구절을 정리해 보려고 한다.

+

성장하는 나

+

안타를 맞는다는 것은 스트라이크를 던질 수 있다는 의미이다(97p)

+

예제가 너무 인상적이어서 적어두려고 한다.

+
+

심적 불안을 가진 투수가 공을 가운데로 던지지 못한다. 감독은 "스트라이크로 삼진 시키든지 아니면 홈런을 맞아라."고 지시한다. 결국 투수는 홈런을 맞게 되지만 다들 미소짓는다. 그가 홈런을 맞았다는 의미는 이제 그가 볼을 중앙에 던질 수 있음을 의미하기 때문이다.

+
+

난 이 일화가 너무 와닿았고 좋았다. 투수를 훈련시키기 위한 적절한 격언을 할 줄 알며 홈런을 맞아도 여유 있는 감독이 내 주변에는 몇 명이나 있을까? 나는 그러한 감독인가?

+

'즐긴다'는 말의 허상(128p)

+
+

즐겨서는 최고의 결과를 얻을 수 없다. 최고가 되려고 하면 그 과정을 즐길 수 없다.

+
+

즐거움이 삶의 모토인 나에게는 정말 슬픈 문장이었다. 이 문장을 반박하고 싶지만, 적절한 예가 떠오르지 않았다.

+

즐기면서 한다는 것은 정말 허상인가?

+

자신이 전문가라면 더 말해야 한다(147p)

+

나는 이 부분이 상당히 공감되었다.

+

최근 개발자 부트 캠프나 인터넷 강의가 우후죽순 생기면서 회사에서는 엉망인 사람들이 외부 이미지로 강의를 팔아먹는 형태를 규탄하는 글을 봤다.

+

그분들이 잘못한 부분도 어느 정도 있겠지만 정말 전문가라면 자신의 지식을 공유하고 더 대중들 앞으로 나와 그런 분들이 자연스럽게 사라질 수 있도록 해야 한다고 생각한다. 그것이 전문가가 개발자 생태계에 기여하는 방법이라 나는 믿고 있다.

+

뒤에서 상대방을 비난하기만 한다면 무슨 의미가 있을까?

+

당신의 재능이 최고의 재산이다(153p)

+
+

당신의 직업은 당신의 목적이 아니다. 세상에 보탬이 되는 당신의 재능을 찾아라.

+
+
    +
  1. 당신의 재능은 무엇인가? 어떻게 하면 그 재능으로 남을 도울 수 있는가?
  2. +
  3. 무슨 일을 할 때 제일 살아있다는 느낌이 드는가? 그 열정을 누구와 나누고 싶은가?
  4. +
  5. 당신의 가이드와 멘토는 누구인가? 누가 자신이 올바른 길을 가는 데 도움을 주고 지지해 주는가?
  6. +
  7. 당신은 주위 사람이 재능을 발견하고 원하는 것을 성취하도록 어떤 도움을 줄 수 있는가?
  8. +
+

책에 나오는 4가지 질문이다. 개발자들은 비교적 쉽게 1~4번을 채울 수 있을 것 같다.

+

성공하는 조직

+

리더는 체스 플레이어가 아니라 정원사다(164p)

+

동료를 장기 말처럼 움직이는 것(마이크로 매니징)이 아니라 스스로 행동하게 하되 그 방향성을 키워주는 것이 좋은 리더라고 생각한다.

+

상사에게 직언을 어떻게 해야 하나?(187p)

+
+

직언은 상대의 이익을 섞어서 해야 한다.

+
+

위의 문장이 좋아서 추가했다. 아무리 좋은 말이라 하여도 날것으로 전달하면 분명 불편해지는 면이 있다고 생각한다.

+

상대방의 이익을 섞어서 말한다면 훨씬 더 대화가 매끄럽게 진행된다. 말 잘하고 싶다!

+

비효율의 숙달화(224p)

+

분명 비효율적인데 익숙해져서 그대로 하는 관행들이 있다. 운이 좋게도 나는 관행을 타파하는 회사들에 다녔고 비효율을 바로잡고자 하는 사람들의 옆에서 좋은 인사이트를 많이 얻을 수 있었다.

+

그럼에도 나 개인의 프로젝트에서는 비효율의 숙달화가 심한 면들이 있기에 반성하고자 언급한다.

+

좋은 회사란 무엇인가?(225p)

+
+

회사의 가치와 자신이 맞는가?

+
+

이 부분이 정말 중요하다고 생각한다. 나는 취준생일 때 반드시 IT 기업에 가겠다는 강한 신념이 있었다. 개발이 재밌었고 파괴적인 부분을 좋아했다.

+

서로 핏이 맞지 않는다면 그 기간 내내 서로 힘들 뿐이라 생각한다. 따라서 맞지않는 회사에 갈 필요도 있을 이유도 없다고 생각한다.

+

이는 개인의 에너지와도 직결된다고 생각한다. 직원의 무능은 개인의 부족한 면도 있겠지만 회사가 자신과 안 맞을 확률이 크다고 생각한다.

+

유능한 직원을 무능하게 만드는 간단한 방법(233p)

+
+

부하직원을 의심한다. 부하직원은 자존심이 상하고 업무 의욕이 점점 감퇴한다. 그리고 상사를 조금씩 불편하게 대하게 된다. 상사는 이 모습을 보고 더 의심하게 된다. 악순환이 반복된다.

+
+

필패 신드롬의 내용이다. "칭찬은 고래도 춤추게 한다"는 문장을 좋아하는 나에겐 특히 공감되는 문장이다.

+

내가 말하지 않으면 리더도 나를 잘 모른다(235p)

+

이제까지는 리더가 자각해야 할 부분이라면 이 문장은 팀원으로서 자각해야 하는 부분이라 생각해 언급한다.

+

책에서도 나오지만 사실 그 누가 나는 악당이 되겠다 하며 회사 생활을 하겠는가? 커뮤니케이션의 실수가 가장 크다.

+

리더의 덕목~ 뺄셈의 리더쉽 등등 리더를 위한 격언은 많지만, 팀원을 위한 내용은 상대적으로 부족한듯 하다.

+

리더도 분명 사람이기에 그들에게 자신의 상황/성과를 잘 전파하고 명확하게 의견을 이야기하는 것이 중요하다.

+

리더는 직원과 어느 정도 개인적 유대를 맺어야 할까?(246p)

+

여기 예시가 인상적이었는데 길기 때문에 적진 않았다.

+

분명한 건 서로가 서로에게 관심이 있다는 것을 인지할 수 있는 유대는 있어야 한다고 생각한다.

+

상대가 진짜 똑똑한지 허풍인지 구별하는 방법(259p)

+
+

가장 똑똑한 사람들은 끊임없이 자신의 이해를 수정한다. 그들은 이미 해결했던 문제들에 대해서도 다시 고려해본다. 그들은 기존 사고에 대항하는 새로운 관점, 정보, 생각, 모순, 도전 등에 대해 열려있다. 자신의 예전 생각이 잘못되었다면 언제든 바꾼다.

+
+

지속적인 만남을 하다 보면 정말 진국이라고 생각되는 사람이 있다. 그러한 사람들은 위와 같은 부류인 경우가 많았다.

+

그들은 자신의 주관은 분명하게 있지만 늘 새로운 도전에 열려있었고 옳다고 느끼면 주관을 바꿀 줄 아는 사람들이었다. 주관을 바꾸면서 자신만의 철학을 리빌딩 하기 때문에 말을 함에 있어 똑똑하다고 느껴지는 경우가 많았다.

+

말 잘하는 것이 정말 중요하다고 느끼기 때문에 본받고 싶은 스킬이라 생각한다.

+

성숙한 삶

+

과제의 분리(275p)

+
+

그들이 나를 좋아하고 싫어하는 것은 그들의 과제이니 자신과 분리시켜야 한다.

+
+

나는 얼마나 나를 사랑하고 있는가? 나는 남을 얼마나 신경 쓰고 있는가?

+

친구가 과제를 대신해달라고 하면 그렇게 화내면서 왜 남의 과제를 하고 있는 것인가?

+

더 많이 행동하면 더 행복해진다(279p)

+

최근 아침에는 수영을 저녁에는 클라이밍을 하고 있는데 정말 행복하다. 운동뿐만 아니라 무엇인가를 한다는 것은 참 의미 있다고 생각한다.

+

그게 다다(285p)

+
+

실수를 했으면 고치면 되고, 잘못을 하면 꾸중을 듣고, 성과가 안 나오면 교훈 삼아 다음에 잘 하면 되고, 차였으면 다른 사람을 찾으면 된다. 그게 다다.

+
+

정말로. 그게 다다.

+

내가 나를 좌절시키는 것이다(291p)

+

과제의 분리와 이어진다고 생각한다. 높은 자존감은 자기애에서 시작한다.

+

자랑할 것, 자부심을 가질 것이 무엇인가?(307p)

+
+

당신은 무엇에 자부심을 가지고 있는가?

+
+

+

억누르지 말고 관점을 재해석 하라(324p)

+
+

관점의 변화, 즉 재해석이 우리의 행동을 바꾼다.

+
+

여기에 문구들이 주옥같다. 누군가 나의 발을 밟는다면 화가나 뒤돌아볼 것이다. 하지만 그 상대가 맹인이라면? 눈 녹듯 분노가 사라지고 부끄러움이 밀려온다.

+

부정적 감정은 관점의 재해석으로 해결된다.

+

편협한 꼰대는 관점의 고정화로 나타나게 되는 것 아닐까?

+

인간관계와 우연이 삶에 미치는 영향(328p)

+

공정하다는 착각의 내용이 떠오르는 부분이었다.

+

취업할 때도 운7기3이라는 말이 있으니 그만큼 운이 삶에 미치는 영향은 지대하다고 생각한다.

+

그렇지만 운도 준비된 자가 잡을 수 있다는 말이 있듯 행운이 다가올 때 잡을 수 있도록 늘 준비해야 한다고 생각한다.

+

맺으며

+

위 격언들을 한 번씩 되돌아보며 더욱 성장한 나. 성공한 삶을 살아갈 수 있도록 노력해야겠다.

+

그럼 이만

📮 이 포스트에 관심 있으신가요? 이슈를 남겨주세요! 👍
☕ 소주 한 잔 후원하기
(예금주: 이상철)tosskakao
\ No newline at end of file diff --git a/e2e/__snapshots__/post/dom.spec.ts/mobile/renovate.html b/e2e/__snapshots__/post/dom.spec.ts/mobile/renovate.html new file mode 100644 index 00000000..c2e3d781 --- /dev/null +++ b/e2e/__snapshots__/post/dom.spec.ts/mobile/renovate.html @@ -0,0 +1,113 @@ +

Renovate 간단하게 살펴보기

1ilsang
클라이밍 하실래염?
#renovate#packageManager#bot#dependency
Published

cover

+

이번에는 디펜던시를 자동으로 최신화 해주는 Renovate를 소개해보고자 한다.

+

Index

+
    +
  • INTRO
  • +
  • Renovate란?
  • +
  • Renovate의 장점
  • +
  • 적용 방법
  • +
  • 마무리
  • +
+

INTRO

+

repo-alert

+

리포지터리에서 위와 같은 노티를 봤을수도 있다. 혹여나 critical severity가 존재한다면 마음 한켠이 굉장히 불안해지기 시작한다. "그날이 왔구나" 생각하며 일정을 산정해 버전업 계획을 세우게 된다.

+

오래된 버전을 올리는것은 굉장히 고통스러운 작업을 동반한다.

+

노티로 알려주는 패키지에는 "종속성"에 포함되는 패키지도 있기 때문에 복잡하게 얽힌 의존 관계를 한땀한땀 쫓아가며 올려야 하는 패키지들을 수색하는 과정이 필요하다.

+

file-hierarchy

+

만약 minimist 라는 라이브러리의 버전을 올려야 한다고 할 경우, 이 라이브러리를 종속성으로 가지고 있는 "실제로 설치된" 라이브러리를 yarn.lock과 같은 락파일에서 디펜던시 그래프를 찾아 올라가야 한다.

+

위의 예에서는 detective 패키지가 종속성을 가지고 있다. 하지만 detective는 설치한 적이 없기 때문에 package.json에 없다. 따라서 detective 라이브러리를 종속하고 있는 또 다른 라이브러리를 찾아 올라가야 한다.

+

이는 굉장히 고통스러운 과정이다.

+

의존성이 커지기 전에 조금씩 버전을 올렸다면 이런 문제는 없지 않았을까 생각하게 된다. Renovate를 통해 이 문제를 해결해 보자.

+
+

깃헙의 공식 툴인 dependabot 과의 차이점도 향후 작성해 볼 예정이다. 레딧에서 다양한 의견을 볼 수 있다.

+
+

Renovate란?

+

renovate-logo

+

Renovate는 자동으로 디펜던시를 업데이트 해주는 봇이다.

+

리포지터리의 패키지 관리자 파일을 확인하고 업데이트가 필요한 종속성을 발견하면 Pull Request 를 자동으로 해준다.

+

Renovate의 장점

+
    +
  • MIT license / 오픈소스
  • +
  • 간단한 봇 설치 및 유지보수가 필요하지 않다. +
      +
    • 리포지터리의 디펜던시에 renovate가 추가되지 않는다는 큰 장점이 있다(레포와 완전히 별개로 동작).
    • +
    +
  • +
  • 풍부한 json 봇 동작 설정.
  • +
  • PR 자동 생성 + 릴리즈 노트.
  • +
  • monorepo 지원.
  • +
+

pr-example

+

Renovate를 적용하면 PR이 생성된다.

+

PR을 확인해보면 아래와 같은 특징을 찾아볼 수 있다.

+
    +
  1. 캐럿 -> PIN 버전으로 변경되었다. +
      +
    • 버전을 특정하고 이후의 버전은 새로운 PR로 생성된다.
    • +
    +
  2. +
  3. 릴리즈 노트를 제공한다. +
      +
    • 현재 버전과 타겟 버전 사이의 추가 내역을 제공해주기 때문에 로그를 명시적으로 확인할 수 있다.
    • +
    +
  4. +
  5. Compare Source를 제공합니다. +
      +
    • 라이브러리 코드의 어디가 바뀌었는지 정확히 알 수 있다.
    • +
    • 덤으로 메인테이너의 코드 리뷰나 discussion도 눈팅할수 있다.
    • +
    +
  6. +
  7. PR이므로 DroneCI 및 actions와 조합해 2중 검증을 할 수 있다.
  8. +
+

그 외에도 main 브랜치에 다른 브랜치가 merge되어 rebase가 필요할 경우 토글 버튼만으로 처리할수 있다.

+
{
+  "extends": ["config:base"],
+  // PR의 기본 라벨 설정
+  "labels": ["renovate", "translate"],
+  "packageRules": [
+    {
+      // 타입패키지들은 major업데이트가 아닌 이상 자동 merge
+      "packagePatterns": ["^@types/"],
+      "automerge": true,
+      // automerge시 comment를 설정할 수 있다.
+      "automergeType": "pr-comment",
+      "automergeComment": "types: auto merge",
+      "major": {
+        "automerge": false
+      }
+    },
+    {
+      // lint 관련 패키지들은 하나의 PR로 생성하도록 설정
+      "groupName": "lints",
+      "matchPackagePatterns": ["^eslint", "^prettier", "^markdownlint"],
+      "labels": ["lint"]
+    }
+  ]
+}
+

또한 JSON 설정을 통해 auto merge, label, semver 범위 지정, PR 스케줄 설정, 최대 PR 개수 설정(기본 10개) 등의 옵션을 설정할수 있다.

+ +

적용 방법

+

적용 방법은 상당히 간단하다. 봇을 설치해 주면 사실상 끝이다.

+
    +
  1. Renovate app을 설치한다.
  2. +
+

Renovate app 봇을 설치한다.

+

repo-bot

+

그후 설치 페이지로 진입해서 봇을 추가할 리포지터리를 선택한다.

+
    +
  1. Renovate PR을 merge 한다.
  2. +
+

앱을 레포에 등록하면 자동으로 PR이 생성된다. 해당 PR을 머지한다.

+
    +
  1. PR 확인 및 renovate.json 설정
  2. +
+

이제 앞에서 본것과 같이 업데이트가 필요한 라이브러리의 PR이 자동으로 생성된다. 기본값은 10개이기 때문에 renovate.json 값을 수정해 원하는 방식으로 조정할 수 있다.

+

마무리

+

finish

+

그동안 디펜던시는 "기간 잡아서 한방에 처리하자"로 남겨두고 있었다.

+

그렇기 때문에 정말 특정한 이슈가 없는 이상 버전 올릴 생각을 잘 하지 않게 되었고, 그 결과 수많은 Breaking change를 만나며 고생했던 기억이 있다.

+

무엇보다 "사용하고 있는 라이브러리의 최신 근황"에 대해 궁금해 하지 않았던 점도 한몫 했다.

+

이제, Renovate가 제공해주는 지속되는 PR을 통해 놓치지 않고 새로운 버전을 쫓아갈 수 있을거라 생각하고 있다. 또한 changeLog 및 sourceCompare를 통해 각 라이브러리의 근황도 자연스럽게 알게 될거라 기대하고 있다.

+

그럼 이만!

📮 이 포스트에 관심 있으신가요? 이슈를 남겨주세요! 👍
☕ 소주 한 잔 후원하기
(예금주: 이상철)tosskakao
\ No newline at end of file diff --git a/e2e/__snapshots__/post/dom.spec.ts/mobile/storybook7.html b/e2e/__snapshots__/post/dom.spec.ts/mobile/storybook7.html new file mode 100644 index 00000000..f4925ce6 --- /dev/null +++ b/e2e/__snapshots__/post/dom.spec.ts/mobile/storybook7.html @@ -0,0 +1,144 @@ +

Storybook 7.0 살펴보기

1ilsang
클라이밍 하실래염?
#storybook#decorator#const#extends
Published

cover

+

4월 초 Storybook v7이 공식 릴리즈 되었다. 이 포스트에서는 스토리북 블로그에 작성된 7버전의 기능들을 확인해보고 정리해 보고자 한다.

+

TL;DR!

+
    +
  • 사전 번들 제공으로 DX 향상
  • +
  • Webpack4 -> Webpack5
  • +
  • CSF(Component Story Format) v3 업데이트로 인한 스토리 Props 직관성 향상
  • +
  • MDX v2 지원
  • +
  • Vite, NextJS, SvelteKit 지원
  • +
  • 컴포넌트 테스트 지원 향상
  • +
+

사전 번들 제공

+

Storybook v7의 주요 기능중 가장 마음에 드는 부분은 사전 번들 제공이다.

+

기존에 v6을 사용할 때에는 Storybook도 번들링되기 때문에 번들 시간이 상당히 길었다. 하지만 이번 업데이트로 번들링된 파일이 제공되므로 Storybook의 번들 시간이 없어졌고 연계된 에드온 또한 런타임 부담 없이 더 안정적으로 사용할수 있게 되었다.

+

또한 Webpack또한 v4에서 v5로 업데이트 되었기 때문에 번들 속도는 더욱 빨라졌다.

+

compare-speed

+
+

20초 걸리던 매니저 빌드 타임이 사전 번들 덕분에 1초대로 줄었으며 프리뷰 영역도 2초 정도 단축되었다. wow

+
+

CSF v3

+

Component Story Format(CSF)도 상당 부분 변경되었다. 컴포넌트 형식에 맞춰 통일화된 규격을 제공한다.

+
    +
  1. stories 파일의 default export가 변경되었다. 이제 스토리 메타데이터를 정의하는 객체를 리턴한다.
  2. +
  3. stories 정의 방식이 변경되었다. 스토리는 스토리 메타데이터 객체 내부에 정의되어야 한다.
  4. +
  5. stories 템플릿을 제공한다. 템플릿으로 스토리를 정의할수 있기 때문에 재사용성이 향상되었다.
  6. +
  7. stories 이름을 정의하는 방식이 변경되었다. id, title 값이 메타데이터 객체로 들어오게 되었다.
  8. +
+
// v6 {id}.stories.tsx
+export const Pair = Template.bind({});
+Pair.argTypes = {
+  type: {
+    options: ['mobile', 'pc'],
+    control: { type: 'radio' },
+    defaultValue: 'mobile',
+  },
+  slot: {
+    options: ['header', 'toolbar left', 'toolbar right', 'more'],
+    control: { type: 'radio' },
+    defaultValue: 'header',
+  },
+};
+Pair.args = {
+  /* ... */
+};
+Pair.parameter = {
+  /* ... */
+};
+Pair.action = clickPair('toolbar');
+ 
+// v7 {id}.stories.tsx
+export default {
+  title: 'Buttons/color',
+  argTypes: {
+    type: {
+      options: ['mobile', 'pc'],
+      control: { type: 'radio' },
+    },
+    slot: {
+      options: ['header', 'toolbar left', 'toolbar right', 'more'],
+      control: { type: 'radio' },
+    },
+  },
+};
+export const Pair = {
+  name: 'Pair',
+  action: clickPair('toolbar'),
+  render: Template,
+  args: {
+    type: 'mobile',
+    slot: 'header',
+  },
+  parameter: {
+    /* ... */
+  },
+};
+

MDX v2

+

MDX

+
// v6 guide.stories.mdx
+<Meta title="Component/Title/Guide" />
+<Story id="component-title--red-title" />
+ 
+// v7 guide.mdx
+import TitleGuide, {RedTitle} from "./Component/TitleGuide";
+<Meta of={TitleGuide} />
+<Story of={RedTitle} />
+ 
+// ./Component/TitleGuide.stories.mdx
+export const RedTitle = { /* ... */ };
+export default {
+  title:'Component/Title/Guide',
+};
+

v7이 되면서 MDX1에서 MDX2로 업데이트 되었다.

+

기존에는 mdx 파일과 스토리 파일을 ID 스트링으로 연결했었다. v7 부터는 조금 더 코드 친화적으로 컴포넌트와 문서를 이어줄 수 있게 되었다.

+

MDX2는 내장 jsx 및 플러그인을 지원하기 때문에 동적인 문서를 만들기에 더욱 좋아졌다.

+

확장자에 변화도 생겼다. {name}.stories.mdx와 같이 닷(.)으로 이어진 확장자는 인식하지 못한다. {name}.mdx로 파일명 수정이 필요하다.

+
|   name   |       type       |    description     |
+| :------: | :--------------: | :----------------: |
+| videoRef | HTMLVideoElement |   video element    |
+|  event   |    MouseEvent    | click event object |
+

기본적으로 MDX는 GitHub-flavored markdown(GFM)이 꺼져있으므로 위와 같은 테이블 마크다운이 깨질 수 있다.

+

이는 remarkGfm을 설치하여 수정하여야 한다.

+

그 외

+

support

+

설정 수정 없이 Vite, NextJS, SvelteKit을 지원한다.

+

본인은 Webpack에서 Vite로 마이그레이션을 고려하고 있었는데 이번 버전이 좋은 기회가 될꺼라 기대하고 있다.

+

test-coverage

+

스토리북은 이전부터 테스팅 도구로써의 포지션을 견고히 하고자 하는데, 이번 버전에서도 상당부분 업데이트가 되어 있다.

+

v7에는 코드 커버리지 기능이 추가되었다. 테스트 코드의 누락을 조금 더 쉽게 찾을수 있게 되었다.

+

test

+
const meta: Meta<typeof SignupForm> = {
+  title: 'SignupForm',
+  component: SignupForm,
+};
+export default meta;
+type Story = StoryObj<typeof SignupForm>;
+ 
+export const Submitted: Story = {
+  play: async ({ args, canvasElement, step }) => {
+    const canvas = within(canvasElement);
+ 
+    await step('Enter email and password', async () => {
+      await userEvent.type(canvas.getByTestId('email'), 'hi@example.com');
+      await userEvent.type(canvas.getByTestId('password'), 'supersecret');
+    });
+ 
+    await step('Submit form', async () => {
+      await userEvent.click(canvas.getByRole('button'));
+    });
+  },
+};
+

기존의 play 함수에서 추가된 step을 활용해 컴포넌트 테스트의 그룹화가 가능해졌다.

+

테스트 그룹을 통해 해당 테스트를 사람이 이해하기 편해졌다.

+

마무리

+

Storybook v7은 전반적으로 Developer Experience 향상이 눈에 보이므로 스토리북을 지속적으로 사용할 계획이라면 업데이트 하는것이 좋아보인다.

+
    +
  1. 빨라진 빌드 시간
  2. +
  3. 개발자 친화적으로 변화한 CSF, MDX
  4. +
  5. 테스트 그룹화 지원으로 그룹 단위 테스트가 가능해졌다.
  6. +
+

참고

+
📮 이 포스트에 관심 있으신가요? 이슈를 남겨주세요! 👍
☕ 소주 한 잔 후원하기
(예금주: 이상철)tosskakao
\ No newline at end of file diff --git a/e2e/__snapshots__/post/dom.spec.ts/mobile/turborepo.html b/e2e/__snapshots__/post/dom.spec.ts/mobile/turborepo.html new file mode 100644 index 00000000..cee01e1e --- /dev/null +++ b/e2e/__snapshots__/post/dom.spec.ts/mobile/turborepo.html @@ -0,0 +1,3 @@ +

Turborepo로 모노레포 개발 경험 향상하기

1ilsang
클라이밍 하실래염?
#monorepo#turborepo#packageManager
Published

cover

+

라인 엔지니어링 블로그에 작성한 글이다.

+

글쓰면서 정말 많이 배웠던것 같다. 모노레포와 함께할 때 캐싱기능은 너무 경험이 좋았기 때문에 앞으로도 꾸준히 사용해볼 예정이다.

📮 이 포스트에 관심 있으신가요? 이슈를 남겨주세요! 👍
☕ 소주 한 잔 후원하기
(예금주: 이상철)tosskakao
\ No newline at end of file diff --git a/e2e/__snapshots__/post/dom.spec.ts/mobile/typescript-subtyping.html b/e2e/__snapshots__/post/dom.spec.ts/mobile/typescript-subtyping.html new file mode 100644 index 00000000..faa9d579 --- /dev/null +++ b/e2e/__snapshots__/post/dom.spec.ts/mobile/typescript-subtyping.html @@ -0,0 +1,179 @@ +

Object.keys()는 왜 string[] 타입일까?

1ilsang
클라이밍 하실래염?
#typescript#structural-subtyping#object-keys
Published

cover

+

Index

+ +

TL;DR!

+

타입스크립트는 자바스크립트의 덕 타입을 표현하기 위해 구조적 서브 타이핑을 채택하고 있다.

+

Object.keys()는 런타임에서의 안정성을 위해 넓은 타입인 string[] 타입으로 추론된다.

+

문제

+
interface MyObject {
+  first: number;
+  second: string;
+  third: boolean;
+}
+const parseMyObject = (object: MyObject) => {
+  const parsed = { accNum: 0, trueCount: 0 };
+ 
+  Object.keys(object).forEach((key) => {
+    // 🚨 Element implicitly has an 'any' type because expression of type 'string' can't be used to index type 'MyObject'.
+    // No index signature with a parameter of type 'string' was found on type 'MyObject'.(7053)
+    const curValue = object[key];
+    if (!isNaN(curValue)) {
+      parsed.accNum += curValue;
+    } else if (curValue === true) {
+      parsed.trueCount++;
+    }
+  });
+  return parsed;
+};
+

개발을 하다 보면 자연스럽게 객체를 많이 사용하게 된다. 이때 Object.keys 메서드를 사용하면 항상 key 값이 string으로 추론되는 것을 확인할 수 있다.

+
const keyList = Object.keys(obj) as Array<keyof typeof obj>;
+

string으로 타입 추론(Type Inference) 되었기 때문에 이후 코딩의 편의성을 위해 다시 타입 단언(Type Assertion)을 하게 된다.

+

단언을 통한 타입 제어가 마음을 불편하게 하기 때문에 제너릭 타입으로라도 추론하고 싶어 진다.

+
TypeScript/src/lib/es2015.core.d.ts
interface ObjectConstructor {
+  keys(o: {}): string[];
+}
+

하지만 타입스크립트는 Object.keys<T>() 제너릭 타입을 제공하지 않는다.

+

여기서 의문이 생긴다.

+
    +
  • 기존의 key 타입을 왜 추론하지 못할까?
  • +
  • 제너릭 타입은 왜 제공하지 않았을까?
  • +
+

오늘은 타입스크립트를 사용하면서 만나는 미묘한 당혹스러움에 대해 파헤쳐 보고자 한다.

+

구조적 서브 타이핑이란

+

앞에서 다룬 문제의 이유는 타입스크립트가 구조적 서브 타이핑을 기반으로 하고 있기 때문이다.

+

이전에 우아한 타입스크립트에서 구조적 타이핑을 잠깐 이야기한 적이 있었다.

+

duck typing

+
+

Image Source: What is duck typing

+
+

자바스크립트는 덕 타이핑을 기반으로 하는 동적 타이핑 언어이다.

+

따라서 타입스크립트는 자바스크립트의 특성(유연한 동적 타입)을 해치지 않으면서 타입을 강제(정적 타이핑)하기 위한 고민을 하게 된다.

+
type Book = {
+  name: string;
+};
+

위와 같은 객체 타입 Book을 선언하게 되면 일반적인 명목적 타입 시스템에서는 반드시 Book { name: string } 형태의 타입만 와야 한다.

+
const getName = (book: Book) => {
+  return book.name;
+};
+ 
+const book1 = { name: '123' };
+const book2 = { name: '123', model: 'wow' };
+const book3 = { name: '123', model: 'wow', wow: 'line' };
+ 
+getName(book1); // OK
+getName(book2); // OK
+getName(book3); // OK
+

하지만 타입스크립트에서는 위와 같은 모든 형태의 객체가 가능하다. 이것이 바로 구조적 서브 타이핑이다.

+

구조적 타입 시스템의 주요 특성은 값을 할당할 때 정의된 타입에 필요한 속성을 가지고 있다면 호환된다는 것이다.

+

따라서 구조적 타입 시스템에서 타입은 값의 집합으로 생각하면 된다.

+

그렇다면 구조적 서브 타이핑과 Object.keys의 반환 타입에는 어떤 연관이 있는 것일까?

+
class MyObject {
+  // https://stackoverflow.com/questions/49464634/difference-between-object-and-object-in-typescript
+  // object 타입은 원시 타입을 제외한 모든 값이 될 수 있다.
+  keys<T extends object>(o: T): (keyof T)[];
+}
+const keys = MyObject.keys<Book>(book1); // "name"[]
+const keys = MyObject.keys<Book>(book2); // "name"[]
+const keys = MyObject.keys(book3); // ("name" | "model" | "wow")[]
+

자바스크립트의 덕 타입 덕에 객체는 런타임 단계에서 더 많은 속성을 가질 수 있다. 또한 구조적 서브 타이핑은 필요한 속성을 가지고 있다면 확장된 집합과 호환되며 에러를 노출하지 않는다.

+

그렇기 때문에 타입스크립트는 객체 인자에 T 타입의 값만 존재한다는 보장을 할 수 없다.

+
for (const key of Object.keys(book1)) {
+  // 🚨 No index signature with a parameter of type 'string' was found on type 'Book'.(7053)
+  const value = book1[key];
+}
+

따라서 타입스크립트는 런타임에서의 안정성을 찾기 위해 좁은 타입의 (keyof T)[]가 아닌 넓은 타입인 string[]으로 추론한다.

+

관련 논의는 #12253 이슈 코멘트에서 확인할 수 있다.

+
    +
  • 글의 말미에서 구조적 서브 타이핑에 대해 더 다루도록 하겠다.
  • +
+

해결

+

이제 타입스크립트가 Object.keys의 값을 string[]으로 추론하는 이유를 확인했다.

+

이를 타입 단언을 사용하지 않고 추론하려면 어떻게 해야 할까?

+

타입 가드를 통한 타입 좁히기

+
const book: Book = { name: '123' };
+const book3 = { name: '123', model: 'wow', wow: 'line' };
+ 
+// 타입 좁히기
+const isBook = (key: string): key is keyof Book => {
+  return Object.keys(book).includes(key);
+};
+ 
+for (const key of Object.keys(book3)) {
+  // 타입 가드로 타입이 존재하는 컨디션 블록이 생기게 됨
+  if (isBook(key)) {
+    // Book 타입의 키
+  } else {
+    // 구조적 서브 타이핑으로 확장된 키
+  }
+}
+

타입 가드(Type Guards)를 통한 타입 좁히기(Type Narrowing)를 활용하면 타입 단언을 하지 않아도 적절하게 타입을 추론할 수 있게 된다.

+

무엇보다 런타임에서도 안전한 코드로 변화했다.

+

ONE MORE THING

+
type Book = { name: string };
+type Car = { model: string };
+ 
+const BookOrCar = {} as Book | Car;
+// 🚨 Property 'name' does not exist on type 'BookOrCar'.
+// Property 'name' does not exist on type 'Car'.(2339)
+BookOrCar.name;
+// 🚨 Property 'model' does not exist on type 'BookOrCar'.
+// Property 'model' does not exist on type 'Book'.(2339)
+BookOrCar.model;
+ 
+const BookAndCar = {} as Book & Car;
+BookAndCar.name; // string
+BookAndCar.model; // string
+ 
+type A = 'A';
+type B = 'B';
+ 
+type AorB = A | B; // 'A' | 'B'
+type AandB = A & B; // never
+

구조적 서브 타이핑을 조금 더 알아보자.

+

Book 타입과 Car 타입이 유니온 혹은 교차 될 때 타입 추론이 혼란스러운 부분이 있다.

+

BookOrCar{ name: string } 혹은 { model: string } 타입이 되기 때문에 두 값이 공존해야 한다고 느껴진다. 하지만 타입스크립트는 두 값 모두 추론하지 못한다.

+

반대로 교차 타입은 BookAndCar에서는 모든 값을 가지지만 AandB에서는 never 타입이 추론된다.

+

what

+

앞에서 "구조적 타입 시스템에서의 타입은 값의 집합으로 생각하면 된다"고 했다.

+

각 타입을 값의 집합으로 나열해 보자.

+
Book 타입에 충족되는 값의 집합
{ name: "123" };
+{ name: "123", model: "wow" };
+{ name: "123", model: "wow", wow: "line" };
+그 외 `name`이 존재하는 객체
+
Car 타입에 충족되는 값의 집합
{ model: "wow" };
+{ name: "123", model: "wow" };
+{ name: "123", model: "wow", wow: "line" };
+그 외 `model`이 존재하는 객체
+
Book | Car 타입의 모든 값의 집합
{ name: "123" };
+{ name: "123", model: "wow" };
+{ name: "123", model: "wow", wow: "line" };
+그 외 `name`이 존재하는 객체
+{ model: "wow" };
+{ name: "123", model: "wow" };
+{ name: "123", model: "wow", wow: "line" };
+그 외 `model`이 존재하는 객체
+

Book | Car의 경우에 Book 혹은 Car 중 "항상 존재하는 값"이 없는 것을 확인(name 혹은 model이 반드시 있어야 하는 경우가 없음) 할 수 있다.

+
Book & Car 타입의 모든 값의 집합
{ name: "123", model: "wow" };
+{ name: "123", model: "wow", wow: "line" };
+

반면 Book & Car의 경우 "항상 존재하는 값"이 있는 것을 확인할 수 있다.

+

결론

+

따라서 Book | Car에서는 항상 존재하는 값이 없기 때문에 name, model 어느 값도 존재하지 않게 되지만 Book & Car에서는 name, model 모두 항상 존재하기 때문에 두 값 모두 존재하게 된다.

+

마무리

+

타입스크립트를 사용하면서도 언어의 근본적인 철학을 이해하지 못한 상태로 작업한 것 같아 반성하게 되는 계기였다.

+

타입을 사용하면서 지금처럼 당혹스러운 부분이 있었는데 이번 기회에 많이 이해할 수 있었다.

+

참고

+
📮 이 포스트에 관심 있으신가요? 이슈를 남겨주세요! 👍
☕ 소주 한 잔 후원하기
(예금주: 이상철)tosskakao
\ No newline at end of file diff --git a/e2e/__snapshots__/post/dom.spec.ts/mobile/typescript5.html b/e2e/__snapshots__/post/dom.spec.ts/mobile/typescript5.html new file mode 100644 index 00000000..bbe02618 --- /dev/null +++ b/e2e/__snapshots__/post/dom.spec.ts/mobile/typescript5.html @@ -0,0 +1,370 @@ +

TypeScript 5.0 살펴보기

1ilsang
클라이밍 하실래염?
#typescript#decorator#const#extends
Published

cover

+

3월 초 TypeScript v5가 공식 릴리즈 되었다. 이 포스트에서는 MS 블로그에 작성된 5버전의 기능들을 확인해보고 정리해 보고자 한다.

+

목차는 아래와 같이 구성되어 있다.

+ +

Decorators

+

Decorators는 현재(2023/04)기준 Stage 3단계(4단계가 표준 추가)인 ECMAScript 공식 스펙이다. ES2024의 유력한 기능 중 하나이다.

+
class Person {
+  name: string;
+  constructor(name: string) {
+    this.name = name;
+  }
+ 
+  greet() {
+    console.log(`Hello, my name is ${this.name}.`);
+  }
+}
+ 
+const p = new Person('Ron');
+p.greet(); // Hello, my name is Ron.
+

위와 같은 간단한 Person 클래스의 greet 함수를 디버깅 하기 위해 함수 내부 시작과 끝에 console.log를 추가할 경우 데코레이터를 사용하면 편리하게 작업할수 있다.

+
function loggedMethod(headMessage = 'LOG:') {
+  return function actualDecorator(
+    originalMethod: any, // 데코레이터를 사용한 함수
+    context: ClassMethodDecoratorContext, // 데코레이터를 사용하는 컨텍스트 객체의 데이터 및 함수가 있다(private, static 여부, 메서드 이름 등).
+  ) {
+    const methodName = String(context.name);
+ 
+    function replacementMethod(this: any, ...args: any[]) {
+      console.log(`${headMessage} Entering method '${methodName}'.`);
+      const result = originalMethod.call(this, ...args); // 데코레이터를 사용하는 함수가 여기서 실행된다.
+      console.log(`${headMessage} Exiting method '${methodName}'.`);
+      return result; // 데코레이터 체이닝을 위해 존재한다.
+    }
+ 
+    return replacementMethod;
+  };
+}
+ 
+class Person {
+  // ...
+  @loggedMethod('[Name]')
+  greet() {
+    console.log(`Hello, my name is ${this.name}.`);
+  }
+}
+ 
+const p = new Person('Ron');
+p.greet();
+/**
+ * [Name] Entering method 'greet'.
+ * Hello, my name is Ron.
+ * [Name] Exiting method 'greet'.
+ **/
+

이로써 모든 함수에 @loggedMethod만 추가하면 쉽게 정해진 로그 메서드를 사용할수 있다.

+

이 외에도 컨택스트 객체에는 addInitializer라는 유용한 함수가 있다. 이는 생성자의 시작 부분(또는 정적 클래스 자체의 초기화)에 연결할 수 있다.

+

자바스크립트를 사용하면서 this가 다시 바인딩 되지 않도록 아래와 같은 코딩 스타일을 자주 사용한다.

+
class Person {
+  name: string;
+  constructor(name: string) {
+    this.name = name;
+    // 오직 CASE 1. 에만 사용되는 줄
+    this.greet = this.greet.bind(this);
+  }
+ 
+  // CASE 1.
+  greet() {
+    console.log(`Hello, my name is ${this.name}.`);
+  }
+  // CASE 2.
+  // greet: () => {
+  //   console.log(`Hello, my name is ${this.name}.`);
+  // }
+  // CASE 3. 생성자에서 this.greet.bind(this)를 하지 않은 경우
+  // greet() {
+  //   console.log(`Hello, my name is ${this.name}.`);
+  // }
+}
+ 
+const greet = new Person('Ron').greet;
+greet(); // CASE 1,2 는 정상적으로 동작하지만 3은 this가 글로벌로 바뀌기 때문에 name이 undefined 에러가 발생한다.
+

이를 데코레이터로 사용하면 일관된 로직을 추가/변경해 적용할수 있게 된다.

+
function bound(originalMethod: any, context: ClassMethodDecoratorContext) {
+  const methodName = String(context.name);
+  if (context.private) {
+    // private 함수는 bind 하지 않는다는 예제
+    throw new Error(
+      `'bound' cannot decorate private properties like ${methodName}.`,
+    );
+  }
+  context.addInitializer(function (this: any) {
+    // 생성자에서 this를 바인드 하게 된다.
+    this[methodName] = this[methodName].bind(this);
+  });
+}
+ 
+class Person {
+  name: string;
+  constructor(name: string) {
+    this.name = name;
+  }
+ 
+  // It Same: @bound @loggedMethod('[Name]') greet() { ... }
+  @bound
+  @loggedMethod('[Name]')
+  greet() {
+    console.log(`Hello, my name is ${this.name}.`);
+  }
+}
+const greet = new Person('Ron').greet;
+greet(); // It works!
+/**
+ * [Name] Entering method 'greet'.
+ * Hello, my name is Ron.
+ * [Name] Exiting method 'greet'.
+ **/
+

여기서 유의할 점은 데코레이터는 '역순'으로 실행된다는 점이다. 위 예를 보면, @loggedMethodgreet 메서드를 꾸미고, @bound@loggedMethod의 결과를 꾸미게 된다. 데코레이터가 사이드 이펙트를 가지거나 보장된 순서를 원할 경우 유의해야 한다.

+

실험적 레거시 데코레이터와의 차이점

+

기존에 타입스크립트는 실험적 데코레이터를 지원하고 있었으며 --experimentalDecorators 옵션으로 활성화 할수 있었다.

+

실험적 데코레이터와 v5 데코레이터(ECMA)의 차이는 매개변수에 데코레이터를 지정하거나, --emitDecoratorMetadata와 호환되지 않는 등이 있다. 앞으로 데코레이터의 제안에 해당 내용들을 추가해 간격을 좁혀나갈 예정이다.

+
// allowed
+@register
+export default class Foo {
+  // ...
+}
+ 
+// also allowed
+export default
+@register
+class Bar {
+  // ...
+}
+ 
+// error - before *and* after is not allowed
+@before
+@after
+export class Bar {
+  // ...
+}
+

데코레이터를 export 앞에 놓을 수 있게 되면서 다양하게 선언이 가능해졌지만, 양옆으로 놓을수는 없다.

+

데코레이터의 타입을 보장하기 위해서는 상당히 복잡한 타입정의가 될수 있다. 이는 가독성과 상충관계가 있기 때문에 단순하게 유지하라고 조언한다. 데코레이터의 메커니즘에 대해 자세한 내용은 이 글에 정리되어 있다.

+

const Type Parameters

+
type HasNames = { names: readonly string[] };
+// 우리는 아래 함수를 통해 불변 문자열 배열 타입을 얻고자 한다.
+function getNamesExactly<T extends HasNames>(arg: T): T['names'] {
+  return arg.names;
+}
+// The type we wanted:
+//    readonly ["Alice", "Bob", "Eve"]
+// The type we got:
+//    string[]
+const names1 = getNamesExactly({ names: ['Alice', 'Bob', 'Eve'] });
+ 
+// Correctly gets what we wanted:
+//    readonly ["Alice", "Bob", "Eve"]
+const names2 = getNamesExactly({ names: ['Alice', 'Bob', 'Eve'] } as const);
+

객체의 타입을 추론할때 타입스크립트는 일반적인 타입을 선택한다. 따라서 위의 예에서 namesstring[] 타입으로 추론된다.

+

readonly 타입을 반환하게 할 경우 기존까지는 as const 타입 어설션으로 강제화 해주어야 했는데, 이는 상당히 번거롭다.

+
function getNamesExactly<const T extends HasNames>(arg: T): T['names'] {
+  //                       ^^^^^
+  return arg.names;
+}
+// Inferred type: readonly ["Alice", "Bob", "Eve"]
+// Note: Didn't need to write 'as const' here
+const names = getNamesExactly({ names: ['Alice', 'Bob', 'Eve'] });
+

이제 const 타입 파라미터를 사용해 as const 추론이 가능해졌다. 하지만 이는 함수 호출 내에 작성된 객체, 배열, 표현식에만 영향을 미치므로 주소값을 넘기는 인수로는 동작할수 없음을 알아두어야 한다.

+
const inputNames = ['Alice', 'Bob', 'Eve'];
+ 
+// Inferred type: ["Alice", "Bob", "Eve"]
+// readonly 내놔!!!
+const names = getNamesExactly({ names: inputNames });
+

Supporting Multiple Configuration Files in extends

+
// packages/front-end/src/tsconfig.json
+{
+  "extends": ["@tsconfig/strictest/tsconfig.json", "../../../tsconfig.base.json"],
+  "compilerOptions": {
+      "outDir": "../lib",
+      // ...
+  }
+}
+

extends 필드에 배열로 여러개의 config 파일을 지원하게 되었다. 개인적으로 상당히 만족

+

All enums Are Union enums

+
// enum Color는 Red | Orange | Yellow | Green | Blue | Violet의 union 타입이다.
+enum Color {
+  Red,
+  Orange,
+  Yellow,
+  Green,
+  Blue,
+  Violet,
+}
+ 
+// enum 멤버는 참조할 수 있는 자체 유형이 있으므로 값처럼 사용될 수 있다.
+type PrimaryColor = Color.Red | Color.Green | Color.Blue;
+

모든 enum은 union된 enum이다. 타입스크립트가 처음 enum을 도입했을 때만 해도 enum은 상수 집합에 불과했다(number 타입). 하지만 타입스크립트 2.0에서 enum 리터럴 타입(고유한 값; 상수 10, 20 등이 타입이 됨)이 도입되면서 리터럴 타입은 각 enum 멤버에 고유한 타입을 부여하게 된다.

+

각 enum 멤버에 고유한 타입을 부여할 때 발생하는 한 가지 문제는 해당 타입이 멤버의 실제 값과 연관되어 있다는 점이다.

+

예를 들어 아래와 같이 enum 멤버가 함수 호출로 초기화될 수 있는 경우, 값을 계산할수 없으므로 초기화 이전까지는 에러가 발생한다. 또한 enum E의 예시와 같이 const a:E3 | 4의 타입이 아닌 number가 된다. 이는 리터럴 타입의 장점을 사용하지 못하고 기존 상수 집합을 사용하고 있다는 뜻이 된다.

+

이제 타입스크립트 5버전 부터는 각 멤버에 대해 고유한 타입을 생성하여 enum 멤버를 union enum으로 사용할수 있게 되었다. 즉, enum의 모든 멤버를 좁혀서 그 멤버를 타입으로 참조할 수 있게 되었다.

+
enum E {
+  three = 3,
+  four = 4,
+}
+function takeValue(num: E) {}
+ 
+// v4.9.5 =====================================
+const a: E = 55; // It works!
+const b: E.three = 4444; // It works!
+takeValue(6); // It works!
+ 
+enum Color {
+  random = Math.random(),
+  two = 2,
+}
+// Error! Enum type 'Color' has members with initializers that are not literals.(2535)
+const c: Color.random = 5;
+ 
+// v5.0.3 =====================================
+const a: E = 55; // Error! Type '55' is not assignable to type 'E'.(2322)
+const b: E.three = 4444; // Error! Type '4444' is not assignable to type 'E.three'.(2322)
+takeValue(6); // Error! Argument of type '6' is not assignable to parameter of type 'E'.(2345)
+ 
+enum Color {
+  random = Math.random(),
+  two = 2,
+}
+// It works!
+const c: Color.random = 5; // number
+

--moduleResolution bundler

+
{
+  "compilerOptions": {
+    "target": "esnext",
+    "moduleResolution": "bundler"
+  }
+}
+

대부분의 최신 번들러는 Node.js에서 ECMAScript 모듈과 CommonJS 조회 규칙의 융합을 사용한다. 번들러의 작동 방식을 모델링하기 위해 타입스크립트는 이제 새로운 전략인 --moduleResolution 번들러를 도입한다.

+

하이브리드 조회 전략을 구현하는 Vite, esbuild, swc, Webpack, Parcel 등의 최신 번들러를 사용 중이라면 새로운 bundler옵션이 적합하다.

+

Support for export type *

+
// models/vehicles.ts
+export class Spaceship {
+  // ...
+}
+ 
+// It works!
+// models/index.ts
+export type * as vehicles from './vehicles';
+ 
+// main.ts
+import { vehicles } from './models';
+ 
+function takeASpaceship(s: vehicles.Spaceship) {
+  //  ok - `vehicles` only used in a type position
+}
+ 
+function makeASpaceship() {
+  return new vehicles.Spaceship();
+  //         ^^^^^^^^
+  // 'vehicles' cannot be used as a value because it was exported using 'export type'.
+}
+

타입스크립트에서 export type * 문법이 가능해졌다. 이를 통해 타입과 값의 분리가 더 명확해졌다.

+

@overload Support in JSDoc

+
// 기존에는 이와 같이 함수를 계속해서 확장해 나가야 했다.
+// Our overloads:
+function printValue(str: string): void;
+function printValue(num: number, maxFractionDigits?: number): void;
+ 
+// 이제 아래와 같이 @overload 태그를 사용해 오버로드를 선언할 수 있다
+/**
+ * @overload
+ * @param {string} value
+ * @return {void}
+ */
+/**
+ * @overload
+ * @param {number} value
+ * @param {number} [maximumFractionDigits]
+ * @return {void}
+ */
+/**
+ * @param {string | number} value
+ * @param {number} [maximumFractionDigits]
+ */
+function printValue(value, maximumFractionDigits) { ... }
+ 
+// all allowed
+printValue("hello!");
+printValue(123.45);
+printValue(123.45, 2);
+ 
+printValue("hello!", 123); // error!
+

기존에 코드로 표현해야 했던 부분을 jsdoc으로 나누어서 표현(example 등)할수 있기 때문에 DX의 향상에 기대가 된다.

+

Speed, Memory, and Package Size Optimizations

+

size

+

compare v5 to v4.9

+

typescript npm package size

+

지표에서도 눈에 띄일만큼 변경사항이 있으며 원문 블로그 자체에서도 대부분의 코드베이스에서 10~20% 정도 속도 향상을 느낄 수 있다고 자신하고 있기 때문에 모노레포에서 타입 참조 시간을 많이 줄일수 있을 것이라 기대하고 있다.

+

Breaking Changes and Deprecations

+

Runtime Requirements

+

타입스크립트는 이제 ECMAScript 2018을 대상으로 한다. 최소 엔진은 12.20으로 설정되었다.

+

lib.d.ts Changes

+

DOM의 유형이 생성되는 방식이 변경되어 기존 코드에 영향을 미칠 수 있다. 특히 특정 프로퍼티가 숫자에서 숫자 리터럴 타입으로 변환되었으며, 잘라내기, 복사, 붙여넣기 이벤트 처리를 위한 프로퍼티와 메서드가 인터페이스 전반으로 이동되었다.

+

API Breaking Changes

+

TypeScript 5.0에서는 모듈로 전환하고, 불필요한 인터페이스를 제거했으며, 일부 정확성을 개선했다.

+

Forbidden Implicit Coercions in Relational Operators

+
function func(ns: number | string) {
+  return ns * 4; // Error, possible implicit coercion
+}
+function func(ns: number | string) {
+  return ns > 4; // Now also an error. number | string 타입은 비교할수 없다(string은 비교 불가능).
+}
+

TypeScript의 특정 연산은 암시적으로 문자열을 숫자로 강제 변환할 수 있는 코드를 작성할 경우 이미 경고한다. 5.0에서는 관계 연산자(<,>,<=,=>)에도 적용된다.

+
function func(ns: number | string) {
+  return +ns > 4; // OK
+}
+

+ 연산자를 통해 명시적 형변환후 사용하는것은 가능하다.

+

Enum Overhaul

+
enum SomeEvenDigit {
+  Zero = 0,
+  Two = 2,
+  Four = 4,
+}
+ 
+// Now correctly an error
+let m: SomeEvenDigit = 1;
+ 
+// =====
+enum Letters {
+  A = 'a',
+}
+enum Numbers {
+  one = 1,
+  two = Letters.A, // enum의 참조가 있을 경우 number 타입으로 됨
+}
+ 
+// Now correctly an error
+const t: number = Numbers.two;
+const t2: string = Numbers.two; // 5.0 이전에는 여기서 에러가 발생함(-_-)
+

enum을 이해하는 개념 수를 줄이기 위해 위의 두 가지 오류가 추가되었다.

+

마무리

+

decorator 추가 및 enum 명시성 확장, multi extends, jsdoc 등 다양한 편의성이 추가되었기 때문에 기대되는 메이저 업데이트이다.

+

이 글에서 다루지 않은 더 자세한 내용은 아래의 원문에 자세하게 추가되어 있다.

+

참고

+
📮 이 포스트에 관심 있으신가요? 이슈를 남겨주세요! 👍
☕ 소주 한 잔 후원하기
(예금주: 이상철)tosskakao
\ No newline at end of file diff --git a/e2e/__snapshots__/post/dom.spec.ts/mobile/udemy-rust-programming.html b/e2e/__snapshots__/post/dom.spec.ts/mobile/udemy-rust-programming.html new file mode 100644 index 00000000..3128fed5 --- /dev/null +++ b/e2e/__snapshots__/post/dom.spec.ts/mobile/udemy-rust-programming.html @@ -0,0 +1,39 @@ +

러스트 시작! - 유데미 Rust Programming를 수강하며

1ilsang
클라이밍 하실래염?
#udemy#rust
Published

cover

+

글또에서 유데미를 수강할 수 있는 기회를 얻었다. 무엇을 선택할지 고민하다 Rust Programming 핵심 강의를 선택했다.

+

수강 이유

+

작년부터 러스트에 대한 관심이 있었는데 이런저런 핑계로 하지 않았었다.

+

새로운 언어를 배워보고 싶기도 했고 프런트엔드 생태계의 여러 도구가 러스트화 되는 것을 보며 올해는 꼭 러스트 해봐야지!라고 신년 다짐을 세우고 있었는데 마침 수강 기회가 있어 바로 선택하게 되었다.

+

과정

+

수강 이유에서도 밝혔지만, 언어 자체에 흥미가 있었기 때문에 열심히 해보고 싶었다.

+

강의만 있으면 분명 미루고 미루다 안 볼 것 같다는 강한 의심이 있었기 때문에 "선언 효과"로 나에게 강제를 주고 동료를 모아 "상호보완"을 하고 싶었다. 따라서 수강하기 전부터 어떤 식으로 학습할지 계획을 세웠다.

+

나는 두 가지 방식으로 접근했다.

+
    +
  1. 슬랙 채널을 만들고 관리하기
  2. +
  3. 스터디를 모집해 의견 교환하기
  4. +
+

러스또

+

rustto-pin

+

선언 효과의 일환으로 글또 커뮤니티에 #러스또 채널을 만들고 홍보하기 시작했다.

+

스터디 모집뿐만 아니라 앞으로의 포부도 밝히며(..) 선언 효과(다른말로 업보) 강하게 적용했다.

+

31분이 채널에 들어와 주셨고 채널이 죽지 않도록 주기적으로 업데이트하고자 했다.

+

정리 및 공유

+

udemy-summary

+

강의 내용이 초심자에게 적절해서 재밌게 볼 수 있었다. 중간중간 내용을 쭉 정리하고 자바스크립트랑 비교도 해보면서 능동적으로 학습하려고 했다.

+

예제가 많고 라인바이라인으로 설명해 줘서 잘 따라갈 수 있었다. 키워드 하나도 그냥 넘어가는 게 없었던 것이 좋은 포인트였다. 특정 패턴들은 자주 사용되는 코딩 패턴이라고 설명 및 소개해줘서 실제로 어떤 식으로 코딩을 이어나가야 할지 최소한의 길잡이는 해주었다고 생각한다.

+

챕터별 학습 내용뿐만 아니라 중간중간 의문이 든 내용들은 꽃게탕 레포에 정리해 나갔고 #러스또 채널에도 공유하면서 선지자들의 피드백도 기대했다.

+

스터디

+

study

+

나는 스터디에서 시너지가 많이 나는 편인 듯하다. 다른 분들에게 더 도움이 되고 싶어 열심히 공부했고 정말 이해하고 있는 건지 알기 위해 설명해 보려고 노력했다.

+

Box, *로 힙 영역을 넘나든다거나 소유권 등은 JS에는 없던 개념이기 때문에 프로그래밍 시야가 더 넓어질 기회가 되었다.

+

마치며

+

certificate

+

위의 과정을 매주 반복한 덕인지는 모르겠지만 다행히 3주 완성으로 아주 기초적인 문법은 배웠다.

+

Rust 오픈소스에 기여해 보는 것을 목표로 하고 있기 때문에 토이 프로젝트를 만들면서 기본기를 다지고자 한다.

+

지금 생각하고 있는 프로젝트는 모노레포에서 각 디렉터리 구조를 분석해서 차이점이 있는 부분을 찾고 CLI로 수정하는 라이브러리이다.

+ +

가보자고!

+
+

해당 콘텐츠는 유데미로부터 강의 쿠폰을 제공받아 작성되었습니다.

+
📮 이 포스트에 관심 있으신가요? 이슈를 남겨주세요! 👍
☕ 소주 한 잔 후원하기
(예금주: 이상철)tosskakao
\ No newline at end of file diff --git a/e2e/__snapshots__/post/dom.spec.ts/mobile/use-prevent-leave.html b/e2e/__snapshots__/post/dom.spec.ts/mobile/use-prevent-leave.html new file mode 100644 index 00000000..b936b3e3 --- /dev/null +++ b/e2e/__snapshots__/post/dom.spec.ts/mobile/use-prevent-leave.html @@ -0,0 +1,244 @@ +

페이지 이탈시 확인 컨펌창 만들기

1ilsang
클라이밍 하실래염?
#usePreventLeave#beforeunload#popstate#popup
Published

유저가 페이지 이탈시 확인 컴펌을 받는 로직이 필요하게 되었고 이에 대한 고민을 공유해 보려고 한다.

+

페이지 이탈은 아래와 같은 세가지 방법이 있다고 생각한다.

+
    +
  1. 브라우저 닫기
  2. +
  3. 페이지 새로고침
  4. +
  5. 페이지 이동(e.g, 앞/뒤/URL직접입력 등)
  6. +
+

나는 위의 세 가지 경우 모두를 확인하는 컨펌창을 만들어야 했다.

+

모든 경우 beforeunload 이벤트를 통해 막아줄수 있기 때문에 간편하게 작업할수 있을것이라 예상했으나, 실제로 작업해보니 3번 페이지 이동간에 이벤트가 발생하지 않아 애를 먹었다.

+

정확히는 동일한 도메인에서 서브패스가 달라졌을때만 이벤트가 발생하지 않았다(e.g., domain.com/dev -> domain.com/1ilsang). 작업하던 웹앱은 react-router-dom을 사용하는 SPA 였기에 해당 문제가 History API와 연관되어 있다고 생각하고 서치를 시작했고, 예상대로 이벤트의 기대 동작과 실제 동작이 달라서 일어난 일이었다.

+

flow-chart

+
+

자세히 보기

+
+

beforeunload 이벤트는 '페이지'간 이동에서 발생하기 때문에 단일 페이지 환경인 SPA에서는 새로운 페이지를 로딩하지 않았기 때문에 당연하게도 이벤트가 발생하지 않는다.

+

참고로 beforeunload 이벤트가 언제 발생하는지는 위의 브라우저 라이프사이클 이미지를 확인하면 된다.

+
    +
  • f. 다른 페이지로 이동
  • +
  • g. 활성화된 탭 끄기
  • +
  • h. 비활성화된 탭 끄기
  • +
+

위 내용을 문장으로 한번 더 풀자면, beforeunload 이벤트는 브라우저 닫기(g,h), 페이지 이동(f), 새로고침(f - 새로 고침도 동일한 페이지로의 '이동'에 해당한다)시 발생하는 이벤트이다.

+

다만, SPA의 경우 실제로 브라우저가 리렌더링 되는것이 아니므로 라우터 이동시 beforeunload 이벤트가 발생하지 않는다.

+

이 경우 때문에 SPA 환경에서 3번 페이지 이동을 막아주기 위해서는 History API를 직접 수정해 popstate 이벤트로 막아줄수 있다.

+

따라서 먼저 beforeunload 이벤트로 처리하는 방식을 작성한 다음, SPA 환경을 위한 popstate 처리를 써보려고 한다.

+

beforeunload로 페이지 이탈 방지하기

+

prevent

+

beforeunload 이벤트를 통해 페이지 이동을 감지할 경우 브라우저에서 기본 컨펌창을 제공해 주는데, 크롬 기준 컨펌창은 위의 이미지와 같다.

+
const handleBeforeUnload = (event: Event) => {
+  event.preventDefault();
+  event.returnValue = false; // Chrome requires returnValue to be set.
+};
+window.addEventListener('beforeunload', handleBeforeUnload);
+

코드로 작성하면 위와 같다. 이동을 막아줄 path에서 beforeunload 이벤트를 수신하고, event.preventDefault()를 통해 이벤트의 진행을 막아준다. 이를 통해 페이지를 떠나기전 이벤트가 멈추게 된다.

+

이전에는 returnValue로 설정해준 값이 컨펌창에 노출되었지만, 노이즈가 너무 강해(님 진짜 진자 나갈거임? 아 나가지마셈..!! 등의 텍스트) 현재는 브라우저에서 기본 텍스트만 노출하도록 변경되었다.

+

크롬의 경우 returnValue 값이 필요하므로 추가해주어야 브라우저 컨펌창이 노출된다. 또한 크롬의 경우 유저의 명시적 액션이 있어야만 이벤트가 정상적으로 발생한다.

+

위의 내용은 MDN beforeunload_event#compatibility_notes에서 자세하게 확인할수 있다.

+

error

+

beforeunload 이벤트로 작업하다보면 위와같은 에러를 만날수 있는데, 이는 앞서 말한 유저의 명시적 액션(e.g, mousedown)이 없었기 때문에 발생하는 에러이다.

+

이제 이 코드를 리액트로 옮겨보자.

+
const usePreventLeave = (global = false) => {
+  const handleBeforeUnload = (event: Event) => {
+    event.preventDefault();
+    event.returnValue = false; // Chrome requires returnValue to be set.
+  };
+  const onPreventLeave = () => {
+    window.addEventListener('beforeunload', handleBeforeUnload);
+  };
+  const offPreventLeave = () => {
+    window.removeEventListener('beforeunload', handleBeforeUnload);
+  };
+ 
+  // 만약 페이지 전체에 적용할 경우 global을 true로 입력해 window에 적용하면 된다.
+  // 단일 요소(e.g., HTMLInputElement)에 별개로 적용할 경우 on/off PreventLeave 이벤트를 사용한다.
+  useEffect(() => {
+    if (!global) return;
+    window.addEventListener('beforeunload', handleBeforeUnload);
+    return () => {
+      window.removeEventListener('beforeunload', handleBeforeUnload);
+    };
+  }, [global]);
+ 
+  return {
+    onPreventLeave,
+    offPreventLeave,
+  };
+};
+ 
+const MyApp: FunctionComponent = () => {
+  // CASE 1. 페이지 자체에 이벤트 적용(글로벌 적용)
+  // usePreventLeave(true);
+ 
+  // CASE 2. 특정 요소가 변경될 경우 감지후 방지 이벤트 적용(e.g., form)
+  const { onPreventLeave, offPreventLeave } = usePreventLeave();
+  const [changed, setChanged] = useState(false);
+ 
+  const handleInputChange = () => setChanged(true);
+  const handleClearClick = () => setChanged(false);
+ 
+  useEffect(() => {
+    const fn = changed ? onPreventLeave : offPreventLeave;
+    fn();
+    return () => {
+      offPreventLeave();
+    };
+  }, [changed]);
+ 
+  return (
+    <div>
+      <input type="text" onChange={handleInputChange} />
+      <button onClick={handleClearClick}>clear</button>
+      <h1>{changed ? 'changed' : 'none'}</h1>
+    </div>
+  );
+};
+

위의 usePreventLeave 훅에서는 두 가지 방식으로 beforeunload 이벤트를 사용하도록 제공하고 있다. 만약 global 값을 true로 넘겨줄 경우 전역에 beforeunload 이벤트를 설정해 컨펌창이 무조건 노출되도록 하지만(물론 앞서 이야기 했듯 유저의 인터랙션이 먼저 있어야 한다) 특정 분기(input 태그의 변화)에 맞춰 컨펌창을 띄우고 싶을 경우 onPreventLeave 메서드와 offPreventLeave 메서드를 적절하게 사용하여 이벤트를 바인딩 해줄수 있다.

+

SPA에서 페이지 이탈 방지하기

+

기본적으로 SPA는 페이지간 이동이 일어나지 않기 때문에 우리는 history stack의 변경사항을 추적해야한다. 애석하게도 브라우저는 앞/뒤 이동일 때에만 popstate 이벤트가 발생하기 때문에 pushState로 URL을 변경할 경우 이벤트가 발생하지 않아 추적할수 없게 된다.

+

이 때문에 Remix-run에서 만든 history 라이브러리를 사용해 세션 history를 추적해 처리하거나 popstate 이벤트 발생 및 라우터 이동이 있는 컴포넌트 클릭시 컨펌창을 노출하는 작업을 할 수 있다.

+

나는 후자의 길을 선택했는데, 추후 보게 되겠지만 이 경우 페이지에서 라우터 이동이 있는 모든 컴포넌트 클릭에 prevent를 설정해 주어야 하므로 조금 아쉽다.

+
const usePreventLeave = (global = false) => {
+  // 앞의 beforeunload 이벤트 코드는 생략하였다. beforeunload 이벤트 코드도 추가해 주어야 모든 상황에서 대처 가능해진다.
+  const [prevent, setPrevent] = useState(false);
+ 
+  useEffect(() => {
+    if (!global) return;
+ 
+    // 현재 페이지를 push하여 의도적으로 스택을 만든다. 이로써 뒤로가기시 현재 페이지가 다시 노출되며 팝업이 보이게 된다.
+    window.history.pushState(null, "", window.location.href);
+    window.addEventListener("popstate", handlePopstate);
+    return () => {
+      window.removeEventListener("popstate", handlePopstate);
+    };
+  }, [global]);
+ 
+  // 특정 컴포넌트가 변경되었을 때에 이벤트를 적용하고 싶다면 beforeunload와 동일하게 처리해준다.
+  const onPreventLeave = () => {
+    window.history.pushState(null, "", window.location.href);
+    window.addEventListener("popstate", handlePopstate);
+  };
+  const offPreventLeave = () => {
+    window.removeEventListener("popstate", handlePopstate);
+  };
+ 
+  // popstate 이벤트 발생시 팝업 노출을 위해 상태값을 변경한다.
+  const handlePopstate = () => setPrevent(true);
+  const handlePopupClose = () => {
+    window.history.pushState(null, "", window.location.href);
+    setPrevent(false);
+  };
+  const handlePopupLeave = (onLeave: () => void) => {
+    setPrevent(false);
+    onLeave();
+  };
+  // preventLeave 함수를 외부로 return하여 컨펌창을 의도적으로 띄울수 있도록 한다.
+  const preventLeave = (event: MouseEvent<HTMLElement>) => {
+    event.preventDefault();
+    setPrevent(true);
+  };
+ 
+  const PreventPopup: FunctionComponent<{ onLeave: () => void }> = ({
+    onLeave,
+  }) => (
+    <>
+      {/* popstate 이벤트에 따라 prevent 상태값이 변경되면 컨펌창이 노출된다 */}
+      {prevent && (
+        <div>
+          <h1>페이지을 떠나시겠습니까?</h1>
+          <button onClick={handlePopupClose}>아니요</button>
+          <button onClick={() => handlePopupLeave(onLeave)}></button>
+        </div>
+      )}
+    </>
+  );
+ 
+  return { preventLeave, PreventPopup, onPreventLeave, offPreventLeave };
+};
+ 
+const MyApp = () => {
+  // CASE 1. 페이지 자체에 이벤트 적용(글로벌 적용)
+  // const { PreventPopup } = usePreventLeave(true);
+ 
+  // CASE 2. 특정 요소가 변경될 경우 감지후 방지 이벤트 적용(e.g., form)
+  //   - beforeunload 형태와 동일
+  // const { preventLeave, PreventPopup, onPreventLeave, offPreventLeave } =
+  //   usePreventLeave(true);
+ 
+  // CASE 3. 라우터 이동이 있는 컴포넌트 막기
+  const { preventLeave, PreventPopup } = usePreventLeave(true);
+ 
+  return (
+    <div>
+      <input type="text" onChange={handleInputChange} />
+      <PreventPopup onLeave={() => console.log("left!")} />
+      {/* 페이지 이동이 있는 컴포넌트에 preventLeave로 팝업 노출 적용 */}
+      <Link to="/1ilsang" onClick={preventLeave}>
+    </div>
+  );
+};
+

합본 코드

+
const usePreventLeave = (global = false) => {
+  const [prevent, setPrevent] = useState(false);
+ 
+  useEffect(() => {
+    if (!global) return;
+ 
+    window.history.pushState(null, '', window.location.href);
+    window.addEventListener('popstate', handlePopstate);
+    return () => {
+      window.removeEventListener('popstate', handlePopstate);
+    };
+  }, [global]);
+ 
+  const handleBeforeUnload = (event: Event) => {
+    event.preventDefault();
+    event.returnValue = false; // Chrome requires returnValue to be set.
+  };
+  const handlePopstate = () => setPrevent(true);
+  const handlePopupClose = () => {
+    window.history.pushState(null, '', window.location.href);
+    setPrevent(false);
+  };
+  const handlePopupLeave = (onLeave: () => void) => {
+    setPrevent(false);
+    onLeave();
+  };
+  const preventLeave = (event: MouseEvent<HTMLElement>) => {
+    event.preventDefault();
+    setPrevent(true);
+  };
+  const onPreventLeave = () => {
+    window.history.pushState(null, '', window.location.href);
+    window.addEventListener('popstate', handlePopstate);
+    window.addEventListener('beforeunload', handleBeforeUnload);
+  };
+  const offPreventLeave = () => {
+    window.removeEventListener('popstate', handlePopstate);
+    window.removeEventListener('beforeunload', handleBeforeUnload);
+  };
+ 
+  const PreventPopup: FunctionComponent<{ onLeave: () => void }> = ({
+    onLeave,
+  }) => (
+    <>
+      {prevent && (
+        <div>
+          <h1>페이지을 떠나시겠습니까?</h1>
+          <button onClick={handlePopupClose}>아니요</button>
+          <button onClick={() => handlePopupLeave(onLeave)}></button>
+        </div>
+      )}
+    </>
+  );
+ 
+  return { preventLeave, PreventPopup, onPreventLeave, offPreventLeave };
+};
+

이로써 유저 이탈시 컨펌창이 노출되는 것에 대한 최소한의 대응은 할수 있게 되었다.

+

참고

+
📮 이 포스트에 관심 있으신가요? 이슈를 남겨주세요! 👍
☕ 소주 한 잔 후원하기
(예금주: 이상철)tosskakao
\ No newline at end of file diff --git a/e2e/__snapshots__/post/dom.spec.ts/mobile/use-transition.html b/e2e/__snapshots__/post/dom.spec.ts/mobile/use-transition.html new file mode 100644 index 00000000..cfa107b6 --- /dev/null +++ b/e2e/__snapshots__/post/dom.spec.ts/mobile/use-transition.html @@ -0,0 +1,203 @@ +

useTransition 이해하기

1ilsang
클라이밍 하실래염?
#react#hooks#useTransition#throttle#debounce#suspense
Published

image

+

최근 리액트 공식 사이트가 react.dev로 이사하게 되었다. 이에 맞춰 한국 번역 페이지도 새롭게 단장하게 되어 기여자를 모집하고 있었다.

+

평소 리액트 커뮤니티에 기여할 방법을 찾던 중이었기에 useTransition 파트를 지원했고 무사히 번역 PR을 올릴수 있었다.

+
+

번역된 페이지 보기

+
+

번역을 하면서 useTransition에 대해 알게 된 것들을 정리해 보고자 한다.

+

TL;DR!

+

useTransition은 컴포넌트 최상위 수준에서 호출되어 startTransition을 통해 우선순위가 낮은 상태 업데이트(setState)들을 transition이라고 표시한다. 리액트는 UI 렌더링시 우선순위에 따라 업데이트 할 수 있게 된다.

+

목차

+
    +
  1. useTransition이란? +
      +
    • isPending, startTransition 이해하기 +
        +
      • startTransition 유의 사항
      • +
      +
    • +
    • 전체 코드로 이해하기
    • +
    +
  2. +
  3. Suspense와 연계하기
  4. +
  5. 그외 자잘한 팁들 +
      +
    • vs throttle, debounce
    • +
    • startTransition에 전달된 함수는 즉시 실행된다
    • +
    • useDeferredValue
    • +
    +
  6. +
  7. 마무리
  8. +
+

useTransition이란?

+

useTransitionUI를 차단하지 않고 상태를 업데이트 할 수 있는 리액트 훅이다.

+
const [isPending, startTransition] = useTransition();
+

여기서 UI를 차단하지 않고 라는 문구를 유의하기 바란다. useTransition을 통해 React18에 추가된 많은 기능중 하나인 Concurrent rendering(동시성 렌더링)을 적절하게 사용할 수 있다.

+

일반적으로 오래 걸리는 상태 업데이트(setState)가 존재할 경우, 해당 업데이트가 완료된 이후에 렌더링이 일어나기 때문에 그 시간만큼 렌더 트리가 '블락(Block)'된다. 이 때문에 유저는 아무런 동작을 할 수 없는 상태에 빠지게 되므로 UX에 좋지 않은 영향을 준다.

+

useTransition은 컴포넌트 최상위 수준에서 호출되어 startTransition을 통해 우선순위가 낮은 상태 업데이트들을 transition이라고 표시해 리액트가 UI 렌더링시 우선순위에 따라 업데이트 할 수 있도록 한다. 이로써 렌더링이 오래 걸리는 컴포넌트의 블락을 피할수 있게 된다.

+

transition으로 표시된 상태 업데이트(A라 호칭)는 다른 일반적인 상태 업데이트(B)가 호출될때 중단되고 B의 상태 업데이트가 완료된 다음 다시 A를 렌더링 시작한다. 이를 통해 특정 컴포넌트의 렌더링이 오래 걸리더라도 다른 우선순위 높은 상태의 변경을 통해 User Interaction을 블로킹하지 않고 자연스럽게 동작할 수 있도록 한다.

+

isPending, startTransition 이해하기

+
const TabButton = ({ children, onClick }) => {
+  const [isPending, startTransition] = useTransition();
+  const [tab, setTab] = useState('about');
+ 
+  if (isPending) {
+    return <b className="pending">{children}</b>;
+  }
+  function selectTab(nextTab) {
+    startTransition(() => {
+      // NOTE: async 함수는 들어오면 안된다.
+      setTab(nextTab);
+    });
+  }
+  // ...아래에서 풀 코드로 설명
+};
+

useTransition은 두 개의 항목이 있는 배열을 반환한다.

+
    +
  1. isPending 플래그는 대기 중인 transition이 있는지 알려준다.
  2. +
  3. startTransition 함수는 상태 업데이트(setState)를 transition으로 표시 해주는 함수다.
  4. +
+

startTransition 유의 사항

+
    +
  1. 동기 함수여야 한다.
  2. +
  3. transition으로 표시된 setState는 다른 setState 업데이트시 중단된다. +
      +
    • 다른 상태 업데이트가 있을 경우 그것을 먼저 처리한다는 뜻
    • +
    +
  4. +
  5. 텍스트 입력을 제어하는 데 사용할 수 없다.
  6. +
+

전체 코드로 이해하기

+

전체 코드로 이해해 보자. 중간중간 주석을 통해 동작을 설명하고자 한다.

+

코드 샌드박스는 공식 문서에서 잘 제공해 주므로 직접 실행해 비교해 보면 좋다.

+
const App = () => {
+  const [tab, setTab] = useState('about');
+ 
+  return (
+    <>
+      {/* 탭을 클릭하면 렌더링할 탭 컴포넌트가 설정된다 */}
+      <TabButton isActive={tab === 'about'} onClick={() => setTab('about')}>
+        About
+      </TabButton>
+      <TabButton isActive={tab === 'posts'} onClick={() => setTab('posts')}>
+        Posts (slow)
+      </TabButton>
+      <TabButton isActive={tab === 'contact'} onClick={() => setTab('contact')}>
+        Contact
+      </TabButton>
+      <hr />
+      {/* 현재 탭에 따라 탭 컴포넌트가 렌더링 된다 */}
+      {tab === 'about' && <AboutTab />}
+      {tab === 'posts' && <PostsTab />}
+      {tab === 'contact' && <ContactTab />}
+    </>
+  );
+};
+ 
+const TabButton = ({ children, isActive, onClick }) => {
+  const [isPending, startTransition] = useTransition();
+ 
+  // 현재 탭이 활성화 되면 isActive 상태가 된다.
+  if (isActive) {
+    return <b>{children}</b>;
+  }
+  // 대기 중인 transition이 있다면 isPending이 된다.
+  if (isPending) {
+    return <b className="pending">{children}</b>;
+  }
+  /**
+   * props로 받은 onClick 함수를 startTransition으로 감싸주기 때문에
+   * onClick 함수(setTab)은 transition으로 설정되어 렌더링시 우선순위에서 밀리게 된다.
+   * 그 결과 오랜시간이 걸리는 PostsTab 컴포넌트를 렌더링 하는 도중 다른 탭을 누르게 되면
+   * PostsTab 컴포넌트의 렌더링을 멈추고 다른 컴포넌트를 렌더링하게 된다.
+   **/
+  const handleButtonClick = () => {
+    startTransition(() => {
+      onClick();
+    });
+  };
+  return <button onClick={handleButtonClick}>{children}</button>;
+};
+ 
+const AboutTab = () => {
+  return <p>Welcome to my profile!</p>;
+};
+const PostsTab = () => {
+  const startTime = performance.now();
+  while (performance.now() - startTime < 1) {
+    // 1 ms 동안 아무것도 하지 않음으로써 매우 느린 코드를 실행한다.
+  }
+  return <p>PostsTab</p>;
+};
+const ContactTab = () => {
+  return <p>ContactTab</p>;
+};
+const ContactTab = () => {
+  return <p>ContactTab</p>;
+};
+

Suspense와 연계하기

+
const App = () => {
+  return (
+    <Suspense fallback={<Spinner />}>
+      {/*
+        위의 App 코드와 동일
+       */}
+    </Suspense>
+  );
+};
+

useTransition의 startTransitionSuspense와 함께 사용할 경우 불필요한 로딩 인디케이터 노출을 막을수 있다.

+

일반적으로 렌더링이 오래 걸리는 컴포넌트를 Suspense로 감쌀 경우 해당 컴포넌트가 렌더링 될때마다 Suspense의 fallback 컴포넌트를 만나게 된다. 해당 서스펜스 트리 하위의 렌더링을 중단할 수 없기 때문에 오래 걸리는 렌더링을 막을 방법이 없다. 해당 렌더링이 종료될 때까지 폴백 컴포넌트를 마주해야만 한다.

+

이때 오래 걸리는 상태 업데이트를 startTransition로 감싸게 될 경우 transition 표시가 되면서 "긴급하지 않은" 상태 업데이트로 간주된다. 이로 인해 리액트는 Suspense를 통해 컨텐츠를 숨기지 않고 이전 컨텐츠를 계속 표시하게 된다.

+

이는 아래와 같은 장점이 있다.

+
    +
  1. transition은 중단할 수 있으므로 리렌더링까지 기다릴 필요가 없다. +
      +
    • Suspense의 경우 하위 컴포넌트가 모두 렌더링 될때까지 fallback을 노출시킨다.
    • +
    +
  2. +
  3. transition은 서스펜스 폴백을 방지(대기하지 않으므로)하므로 갑작스러운 로딩 인디케이터 노출을 피할수 있다.
  4. +
+

그외 자잘한 팁들

+

vs throttle, debounce

+

디바운싱과 스로틀로 이벤트의 지연 및 제한은 가능하지만 UI 블로킹의 근본적인 문제는 해결할 수 없다.

+

아무리 이벤트 실행 시점/횟수를 줄인다 하여도 한번 실행이 되는 순간 블로킹이 되는건 여전하기 때문이다.

+

근본적인 원인을 해결하기 위해선 이벤트의 우선순위를 나누어 유저 인터렉션이 일어났을 때 해당 이벤트를 우선적으로 처리해 화면이 멈춘것 처럼 보이지 않게 해야한다.

+

startTransition에 전달된 함수는 즉시 실행된다

+
console.log(1);
+startTransition(() => {
+  console.log(2);
+  setPage('/about');
+});
+console.log(3);
+ 
+// 1, 2, 3
+

startTransition의 콜백 함수는 즉시 실행된다. 함수가 실행되는 동안 예약된 모든 상태 업데이트는 transition으로 표시된다.

+
// React 작동 방식의 간소화된 버전
+let isInsideTransition = false;
+ 
+function startTransition(scope) {
+  isInsideTransition = true;
+  scope();
+  isInsideTransition = false;
+}
+ 
+function setState() {
+  if (isInsideTransition) {
+    // ... transition state 업데이트 예약 ...
+  } else {
+    // ... 긴급 state 업데이트 예약 ...
+  }
+}
+

transition으로 처리된 경우 transitionState로 예약(큐잉)되고 아닌 경우 일반적인 state 업데이트로 예약된다.

+

예약된 작업들은 React18의 fiber 엔진(자체적인 스케줄러를 가지고 있다)이 적절하게 스케줄링 해준다.

+

useDeferredValue

+

useDeferredValue도 useTransition과 유사하게 낮은 우선순위를 지정하기 위한 훅이다. useTransition은 함수 실행의 우선순위를 지정하는 반면, useDeferredValue는 값의 업데이트 우선순위를 지정한다.

+

마무리

+

위의 내용을 한번더 정리하며 글을 마무리 하려고 한다.

+
    +
  1. useTransition은 컴포넌트 최상위 수준에서 호출되어 startTransition을 통해 우선순위가 낮은 상태 업데이트(setState)들을 transition이라고 표시한다. 리액트는 UI 렌더링시 우선순위에 따라 업데이트 할 수 있게 된다.
  2. +
  3. startTransition 함수는 동기 함수여야 한다.
  4. +
  5. transition 표시된 setState는 다른 setState 업데이트시 중단된다.
  6. +
  7. transition 표시된 상태 업데이트는 Suspense로 컨텐츠를 숨기지 않고 이전 컨텐츠를 계속 표시한다.
  8. +
  9. fiber 엔진을 통해 transition된 상태와 다른 상태의 스케줄링이 가능해졌다.
  10. +
📮 이 포스트에 관심 있으신가요? 이슈를 남겨주세요! 👍
☕ 소주 한 잔 후원하기
(예금주: 이상철)tosskakao
\ No newline at end of file diff --git a/e2e/__snapshots__/post/dom.spec.ts/mobile/visual-regression-test.html b/e2e/__snapshots__/post/dom.spec.ts/mobile/visual-regression-test.html new file mode 100644 index 00000000..d503f284 --- /dev/null +++ b/e2e/__snapshots__/post/dom.spec.ts/mobile/visual-regression-test.html @@ -0,0 +1,381 @@ +

시각적 회귀 테스트 도입기

1ilsang
클라이밍 하실래염?
#test#visual-regression-test#playwright#snapshot
Published

cover

+
+

Image Source: We're Building a Visual Regression Testing Library for React Native

+
+

블로그에 항상 테스트를 도입해야겠다 생각하고 있었는데 이번에 적용하게 되어 도입 배경과 트러블 슈팅 과정을 포스트로 남겨보고자 한다.

+

Index

+ +

TL;DR!

+

Playwright 및 Github Actions로 시각적 회귀 테스트 및 CI/CD를 적용한다.

+
    +
  1. 시각적 회귀 테스트로 UI 변경 사항을 배포전에 알아차린다
  2. +
  3. 빠르게 실패하고 실패한 부분만 재실행하자
  4. +
  5. 로컬 테스트와 CI Test의 통합은 어렵다
  6. +
+

도입 배경

+

test pyramid

+
+

Image Source: 사용자 인터페이스 테스트 통합 테스트 및 단위 테스트로 테스트 피라미드

+
+

이전부터 블로그에 테스트 코드가 없는 것이 꽤나 찝찝했기 때문에 어떤 방식/도구로 테스트를 적용할까 고민하고 있었다.

+

블로그의 특성상 한번 배포된 콘텐츠는 크게 바뀔 일이 없기 때문에 정적 UI 테스트를 도입하기에 적절하다고 생각했고 마침 꽤나 긴 연휴가 있었기 때문에 각 잡고 정적 UI 테스트를 도입하고자 마음먹게 되었다.

+

빌드된 결과물을 바탕으로 테스트할 예정이었기 때문에 아래의 두 가지 방식의 테스트를 고려했다.

+
    +
  1. DOM Snapshot
  2. +
  3. Screen Snapshot
  4. +
+

먼저 DOM 스냅샷 비교를 통해 이후 작업에서 기존 DOM 구조를 변경하는지 확인한다. 하지만 DOM 스냅샷은 CSS의 변경 여부를 알아차리기 어렵다는 단점이 있다.

+

따라서 시각적 회귀 테스트인 Screen 스냅샷 비교로 정상적인 렌더링이 되었는지 확인한다.

+

시각적 회귀 테스트란

+

failed visual regression test

+
+

(좌) 차이가 생긴 렌더링 결과물. (우) 차이가 생긴 부분 히트맵

+
+

시각적 회귀 테스트(Visual Regression Test)는 코드 변경 전후의 렌더링 된 UI의 스크린샷을 비교하는 테스트이다.

+

위의 좌측 이미지를 확인해 보면 더 명확하게 알 수 있다. 모종의 이유로 하위 이미지 크기가 달라졌고 이에 따라 이후의 시각적 구조가 변경 되었다.

+

우측 이미지는 Diff 이미지로, 차이가 생긴 영역에 붉게 표시를 해놓았다.

+

이로써 우리는 컴포넌트가 실제로 어떻게 렌더링 되었는지 정확하게 알 수 있게 된다.

+

Playwright

+

playwright

+

어떤 방식의 테스트를 할지 결정되었으니 자연스럽게 어떤 도구로 테스트를 작성할지 고민하게 되었다.

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
AspectPlaywrightCypress
Browser 지원Chrome, Firefox, WebkitChrome, Firefox, Electron
병렬 실행무료유료
멀티탭(다중 브라우저)가능불가능
성능Headless Event-driven socket 방식으로 빠름실제 브라우저에서 실행하므로 상대적으로 느림
+
+

더 자세한 내용은 Cypress vs Playwright: A Detailed Comparison 참고

+
+

꾸준히 Cypress를 사용해 왔지만 병렬 처리에 상당히 답답함을 느끼고 있었기 때문에 이번 기회에 Playwright에 도전하고자 결정했다.

+

물론 Sorry-Cypress로 병렬처리를 할 수 있지만 셀프 호스팅부터 신경 써야 하는 부분이 하나 더 생기기 때문에 기술부채가 싫은 나로서는 선택지에 해당되지 않았다.

+

image (6)

+

무엇보다 성능 부분에 차이가 있다. 테스트 결과를 하루종일 기다렸는데 심지어 실패했다? 한 줄 고치고 다시 하루종일 기다려야 한다.

+

정말 하기 싫어진다.

+

Playwright는 브라우저와 HTTP request 통신 대신 WebSocket으로 Dev tools에 바로 연결한다. 따라서 브라우저의 큰 메모리나 부가적인 리소스가 필요하지 않기 때문에 실제 브라우저와 통신하는 Cypress에 비해 가볍고 빠르다.

+

그렇다면 Playwright로 어떻게 기존의 목적, DOM snapshot과 Screen snapshot을 할 수 있는지 살펴보자.

+

DOM Snapshot

+
import { expect, test } from '@playwright/test';
+ 
+test('should match DOM snapshot', async ({ page }) => {
+  await page.goto('/about');
+  const body = await page.locator('#__next').innerHTML();
+  expect(body).toMatchSnapshot([`about.html`]);
+});
+

나는 Next.js를 사용하고 있으므로 __next 하위의 DOM만 비교하고자 한다.

+

innerHTML 메서드를 통해 DOM 구조를 가져온 다음 playwright에서 제공하는 toMatchSnapshot 메서드로 DOM 스냅샷을 비교할 수 있다.

+

Screenshot

+
import { expect, test } from '@playwright/test';
+ 
+test('should match Screenshot', async ({ page }) => {
+  await page.goto('/about');
+  await expect(page).toHaveScreenshot({ fullPage: true });
+});
+

스크린샷 또한 playwright에서 제공하는 toHaveScreenshot 메서드로 쉽게 적용할 수 있다.

+

나는 전체 화면의 비교를 할 것이므로 fullPage를 설정했다.

+

시각적 회귀 테스트는 다양한 이유로 실패할 수 있다

+
    +
  1. 테스트가 실행되는 OS에 따라 화면이 달라지기 때문에 실패한다(이모지 등)
  2. +
  3. 동일한 OS라도 버전/브라우저에 따라 화면이 달라질 수 있다
  4. +
  5. 실행된 머신의 타임존에 따라 Date 값이 달라져 실패할 수 있다
  6. +
  7. Image와 같은 Resource 로딩 시점에 따라 페이지가 달라질 수 있다
  8. +
  9. Animation 혹은 setTimeout과 같은 시간에 종속된 동작은 일관성을 보장할 수 없다
  10. +
  11. 눈에 큰 차이가 안 나더라도 실패할 수 있다(1px 차이로 실패 등)
  12. +
+

위의 내용들은 일반적인 E2E 테스트에서도 발생할 수 있는 실패 케이스들이다. 일부 케이스는 밑의 트러블 슈팅에서 다루겠다.

+

이처럼 다양한 사이드 이펙트가 존재하기 때문에 동적인 컴포넌트가 많거나 화면이 자주 바뀐다면 도입 전에 ROI를 따져보는 것이 좋다.

+

기본적인 설정은 되었으므로 CI/CD를 구축하자.

+

Github Actions

+

github actions

+
+

Image Source: CI/CD with GitHub Actions: Step-by-Step Workflow

+
+

Github Actions를 통해 CI/CD를 간편하게 구축할 수 있다.

+
.github/workflow/playwright.yml
name: Playwright Tests
+on:
+  push:
+    branches: [main]
+  pull_request:
+    branches: [main]
+jobs:
+  test:
+    timeout-minutes: 60
+    runs-on: ubuntu-latest
+    steps:
+      - uses: actions/checkout@v4
+      - uses: actions/setup-node@v4
+        with:
+          node-version-file: .nvmrc
+      - name: Install dependencies
+        run: npm ci
+      - name: Install Playwright Browsers
+        run: npx playwright install --with-deps
+      - name: Run Playwright tests
+        run: npx playwright test
+      # artifact에 playwright report를 업로드해 어디서 실패했는지 확인할 수 있다
+      - uses: actions/upload-artifact@v4
+        if: failure()
+        with:
+          name: playwright-report
+          path: playwright-report/
+          retention-days: 30
+

Actions result

+

성능 개선

+

위의 CI/CD는 3가지 문제가 있다.

+
    +
  1. 테스트 속도가 느리고 flow를 한눈에 확인하기 어렵다.
  2. +
  3. 캐싱이 전혀 되고 있지 않다.
  4. +
  5. 테스트가 실패하면 다시 처음부터 실행해야 한다.
  6. +
+

이것을 개선해보고자 한다.

+

테스트 방식

+

action log

+

테스트의 어디까지 성공했는지, 어떤 테스트를 실행 중인지 등의 작업 상황을 보기 위해선 현재는 로그를 확인해야 한다.

+

이러한 문제의 근본적인 이유는 특정 기능 단위의 테스트만 실행시키는 방법이 존재하지 않기 때문이다.

+
package.json
{
+  // ...
+  "e2e:others": "pnpm playwright test --grep-invert /@/",
+  "e2e:dom": "pnpm playwright test --grep '@dom-snapshot'",
+  "e2e:screen": "pnpm playwright test --grep '@screen-snapshot'"
+}
+

따라서 playwright에서 제공하는 grep 명령어를 활용해 원하는 기능별로 테스트를 적용할 수 있다.

+

DOM 스냅샷은 @dom-snapshot 키워드를, Screen 스냅샷은 @screen-snapshot 키워드를 가지고 있어야 한다. 그 이외의 테스트는 others로 실행된다.

+
e2e/about.spec.ts
export enum MACRO_SUITE {
+  DOM_SNAPSHOT = '@dom-snapshot',
+  SCREEN_SNAPSHOT = '@screen-snapshot',
+}
+ 
+test.describe('about', () => {
+  test(MACRO_SUITE.SCREEN_SNAPSHOT, async ({ page }) => {
+    await screenshotFullPage({ page, url: `/about`, arg: [`about.png`] });
+  });
+ 
+  test(MACRO_SUITE.DOM_SNAPSHOT, async ({ page }) => {
+    await gotoUrl({ page, url: '/about' });
+    const body = await page.locator('#__next').innerHTML();
+    expect(body).toMatchSnapshot([`about.html`]);
+  });
+ 
+  test('should redirect 404', async ({ page }) => {
+    await gotoUrl({ page, url: '/something_wrong_path', timeout: 60_000 });
+    await expect(page.getByText(/404 ERROR/)).toBeVisible();
+  });
+});
+

이제 우리는 dom, screen, others 세 가지 테스트 피처를 가지게 되었다. 이것은 후술할 CI/CD에서 큰 역할을 하게 된다.

+

테스트 속도

+
test.describe(MACRO_SUITE.SCREEN_SNAPSHOT, () => {
+  for (let i = 0; i < urls.length; i++) {
+    const url = urls[i];
+ 
+    test(`${url}`, async ({ page }) => {
+      await page.goto(`/posts/${url}`);
+      await page.waitForTimeout(3000);
+      await expect(page).toHaveScreenshot({ fullPage: true });
+    });
+  }
+});
+

테스트 속도에는 많은 것들의 영향이 있겠지만 기본적으로 wait timeout이 가장 좋지 않다.

+

특히 위와 같이 반복문으로 작업을 하게 될 경우 N의 배수로 시간이 증가하게 된다.

+

이미지 로딩까지 3초의 텀을 두고자 한 위의 코드는 이미지가 빨리 로딩되었다면 불필요한 기다림이 발생하고 이미지가 3초보다 늦게 로딩되면 깨지는 불안정한 코드다.

+
// Image 로딩 wait
+const locators = page.locator('img');
+const scrollPromises = (await locators.all()).map(async (locator) => {
+  // https://playwright.dev/docs/api/class-locator#locator-scroll-into-view-if-needed
+  // 이미지 요소가 준비되었는지 확인
+  return await locator.scrollIntoViewIfNeeded();
+});
+await Promise.all(scrollPromises);
+const imgLoadingPromises = (await locators.all()).map((locator) =>
+  locator.evaluate<any, HTMLImageElement>(
+    // 이미지 요소의 로딩 상태 확인
+    (image) => image.complete || new Promise((f) => (image.onload = f)),
+  ),
+);
+await Promise.all(imgLoadingPromises);
+ 
+// Font wait
+await page.evaluate(() => document.fonts.ready);
+

이처럼 유동적인 사이드 이펙트는 이벤트로 처리하면 보다 안정적으로 처리할 수 있다.

+

CI/CD

+

앞서 언급한 세 가지 문제

+
    +
  1. 테스트 속도가 느리고 flow를 한눈에 확인하기 어렵다.
  2. +
  3. 캐싱이 전혀 되고 있지 않다.
  4. +
  5. 테스트가 실패하면 다시 처음부터 실행해야 한다.
  6. +
+

이것은 workflow와 actions를 적절하게 나눠주고 actions/cache를 활용하면 된다.

+
workflows
jobs:
+  others:
+    uses: './.github/workflows/e2e-reusable.yml'
+    with:
+      others: true
+ 
+  dom-snapshot:
+    uses: './.github/workflows/e2e-reusable.yml'
+    with:
+      dom-snapshot: true
+ 
+  screen-snapshot:
+    uses: './.github/workflows/e2e-reusable.yml'
+    with:
+      screen-snapshot: true
+

workflow job

+

앞에서 나눈 테스트 피처 단위로 workflows의 job을 나눠주고 workflow_call을 적절하게 사용한다면 편리하고 가독성 좋은 Flow를 만들 수 있다.

+

failed flow

+

무엇보다 job을 나누게 되면 실패한 부분만 재실행할 수 있기 때문에 더욱 유연한 테스트를 할 수 있게 된다.

+
actions
# playwright 설치 캐시
+- name: Cache Playwright Browsers for Playwright's Version
+  uses: actions/cache@v4
+  with:
+    # https://playwright.dev/docs/browsers#managing-browser-binaries
+    path: ~/Library/Caches/ms-playwright
+    key: ${{ runner.os }}-playwright-${{ steps.playwright-version.outputs.PLAYWRIGHT_VERSION }}
+  id: cache-playwright-browsers
+ 
+- name: Setup Playwright
+  shell: bash
+  if: steps.cache-playwright-browsers.outputs.cache-hit != 'true'
+  run: pnpm e2e:install
+ 
+# pnpm 설치 캐시
+- name: Setup pnpm cache
+  uses: actions/cache@v4
+  with:
+    path: ${{ env.STORE_PATH }}
+    key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
+    restore-keys: |
+      ${{ runner.os }}-pnpm-store-
+ 
+# Next.js Build and Export 캐시
+- name: Restore Next.js related caches
+  uses: actions/cache@v4
+  with:
+    path: |
+      ${{ github.workspace }}/.next
+      ${{ github.workspace }}/out
+    key: ${{ runner.os }}-nextjs-store-${{ hashFiles('**/pnpm-lock.yaml') }}-${{ hashFiles('**.[jt]s', '**.[jt]sx', '**.md') }}-${{ inputs.e2e == 'true' && 'e2e' || 'default' }}
+    restore-keys: |
+      ${{ runner.os }}-nextjs-store-${{ hashFiles('**/pnpm-lock.yaml') }}-
+  id: cache-nextjs-build
+ 
+- name: Build and Export [default]
+  shell: bash
+  if: steps.cache-nextjs-build.outputs.cache-hit != 'true'
+  run: pnpm e2e:build
+

Job을 분리하면 불필요한 반복 빌드 작업이 발생하게 되는데 이를 캐싱을 통해 시간을 단축시킬 수 있다.

+

특히 잘 변경되지 않는 정적 블로그의 경우 pnpm, .next, out, playwright를 캐싱해 두면 전체 테스트 시간을 아낄 수 있게 된다.

+

compare time

+

이로써 절반이상 시간을 줄이고 실패에 더 유연한 CI 테스트를 할 수 있게 되었다.

+

완성된 전체 코드는 깃헙에서 확인할 수 있다. .githube2e를 확인하면 된다.

+

트러블 슈팅

+

로컬 테스트를 포기해야 할까

+

팀 단위의 협업에선 로컬 머신 버전을 강제하기 어렵기 때문에 로컬 테스트와 CI 테스트의 동기화가 어렵다.

+

따라서 도커를 활용하든가 CI 테스트만 사용하든가 양자택일로 흐르게 된다.

+

하지만 지금 나의 플로우와 같이 1인 개발이라면 로컬과 CI 테스트를 어느 정도 맞춰줄 수 있다.

+

Macos runner version

+
+

Runner 전체 목록 확인

+
+
jobs:
+  my-job:
+    runs-on: macos-latest
+

Github에서 제공해주는 Actions Runner에 MacOS가 존재하기 때문에 로컬과 버전을 맞춰줄 수 있다.

+

완벽하다고 장담은 못하겠지만 현재까지는 로컬과 CI 테스트가 모두 동일하게 동작하며 통과하고 있다.

+

Timezone

+

CI 테스트에서 가장 많이 실패하는 부분은 Timezone이다. 우리는 +9의 값을 가지고 있기 때문에 스냅샷 테스트에서 반드시 실패한다.

+
jobs:
+  my-job:
+    runs-on: macos-latest
+    env:
+      TZ: Asia/Seoul
+

깃헙 액션에서는 env로 타임존 값을 넘길수 있다. 이를 통해 편리하게 머신의 타임존을 변경할 수 있다.

+

playwright에서 어떤 브라우저를 선택하느냐에 따라 타임존 기준점이 조금 달라진다.

+
    +
  • 크롬의 경우 기본적으로 머신의 타임존을 따른다.
  • +
  • Webkit은 config에 설정한 타임존을 따른다.
  • +
+

만약 playwright에서 webkit을 사용하고 있다면 아래와 같이 playwright.config.ts를 변경해야 한다.

+
playwright.config.ts
export default defineConfig({
+  // ...
+  use: {
+    browserName: 'webkit',
+    timezoneId: 'Asia/Seoul',
+  },
+});
+

테스트 분기

+

코드에 테스트로 인한 분기점이 생기는 것을 원하지 않지만 어쩔 수 없는 경우(혹은 편의로) 빌드를 나누어 코드에 적용할 수 있다.

+
package.json
{
+  "deploy-blog": "next build && next export",
+  "e2e:build": "NEXT_PUBLIC_CI=true next build && next export"
+}
+

e2e를 위한 빌드 스크립트를 만든 다음 환경변수를 주입해 코드에서 적용할 수 있다.

+
useEffect(() => {
+  if (process.env.NEXT_PUBLIC_CI) return;
+

1px

+

1px bug

+

크롬에서는 스크린샷이 1px 다른 경우가 있다(#18827).

+

이때는 clip으로 고정하거나 height를 강제하는 방법으로 처리할 수 있다.

+

Image load

+

로드와 관련된 트러블 슈팅은 테스트 속도에서 다루었다.

+

마무리

+

시각적 회귀 테스트를 통해 심신의 안정을 많이 찾을 수 있었다.

+

이제 더욱 과감하게 리팩터링을 진행할 수 있게 되었다.

+

특히 playwright를 사용하며 경험이 좋았기 때문에 앞으로도 꾸준히 사용해 보고자 한다.

+

이 글을 쓰며 참고했던 혹은 유용했던 링크를 남기며 글을 마무리하려고 한다.

+
📮 이 포스트에 관심 있으신가요? 이슈를 남겨주세요! 👍
☕ 소주 한 잔 후원하기
(예금주: 이상철)tosskakao
\ No newline at end of file diff --git a/e2e/__snapshots__/post/dom.spec.ts/mobile/vite-dev-server.html b/e2e/__snapshots__/post/dom.spec.ts/mobile/vite-dev-server.html new file mode 100644 index 00000000..7374d042 --- /dev/null +++ b/e2e/__snapshots__/post/dom.spec.ts/mobile/vite-dev-server.html @@ -0,0 +1,485 @@ +

Vite Dev Server 이해하기 (feat. HMR)

1ilsang
클라이밍 하실래염?
#vite#dev-server#hmr#preact#prefresh
Published

cover

+

요즘 Vite의 매력에 푹 빠져있다. 그러던 도중 "개발 서버는 어떻게 동작하는 걸까?" 의문을 가지게 되었다. 따라서 오늘은 Vite Dev Server의 동작 방식을 이해하고 HMR 과정을 파헤쳐 보려고 한다.

+

Index

+ +

TL;DR!

+

dev-server-logic-summary

+
+

한 짤로 보는 Dev Server의 동작 방식

+
+

이 글은 핵심 로직에 해당하는 노란색 박스를 위주로 설명하려고 한다. 위의 도식도를 쫓아오며 글을 읽는다면 도움이 될 것으로 생각한다.

+

이 글은 Vite v5.0.12 버전을 기준으로 작성되었다.

+

Let's Dive!

+

1. 개발 서버 실행(서버 초기화)

+

init-server-phase

+
+

최초 서버 실행 이후의 상태

+
+
vite/packages/vite/src/node/server/index.ts
// https://github.com/vitejs/vite/blob/v5.0.12/packages/vite/src/node/server/index.ts#L385
+// server/index.ts
+export async function _createServer(
+  inlineConfig: InlineConfig = {},
+  options: { ws: boolean },
+): Promise<ViteDevServer> {
+  // connect를 사용해 express와 같은 미들웨어 구조를 가진다.
+  const middlewares = connect() as Connect.Server
+ 
+  // HTTP 서버와 웹 소켓 서버를 생성한다.
+  const httpServer = await resolveHttpServer(serverConfig, middlewares, httpsOptions)
+  const ws = createWebSocketServer(httpServer, config, httpsOptions)
+ 
+  // 파일 변경 감지를 위해 chokidar를 설정한다.
+  const watcher = chokidar.watch((...) as FSWatcher)
+ 
+  // 의존성 관계를 추적할 수 있는 모듈 그래프를 만든다. HMR 및 트리쉐이킹 같은 최적화 작업을 위해 존재한다.
+  // 서버 초기화 단계에서는 그래프가 비어있다.
+  const moduleGraph: ModuleGraph = new ModuleGraph(...)
+ 
+  // Rollup의 플러그인 컨테이너를 활용해 플러그인 구성을 만든다.
+  const container = await createPluginContainer(config, moduleGraph, watcher)
+ 
+  // ...
+}
+

yarn vite 등의 커맨드로 Dev Server를 실행시키면 bin/vite.jscli.js호출된다. 이후 src/node/cli.ts호출되면서 Dev Server가 실행된다.

+

Dev Server는 아래와 같은 프로세스를 거치며 초기화를 진행한다.

+
    +
  1. +

    Dev Server가 실행되면 HTTP 서버와 웹 소켓 서버가 실행된다.

    +
      +
    • 미들웨어는 Express에서 사용되는 connect로 연결된다.
    • +
    +
  2. +
  3. +

    파일 시스템 옵저버를 설정한다.

    +
      +
    • 파일 변경 감지를 위해 chokidar를 사용한다.
    • +
    +
  4. +
  5. +

    모듈 그래프를 생성한다.

    + +
  6. +
  7. +

    플러그인 컨테이너를 생성한다.

    +
      +
    • Dev Server에 필요한 Built-in(내장된) 플러그인이 추가된다. +
        +
      • importAnalysis, css, optimizer, json 등이 있다.
      • +
      +
    • +
    • 사용자가 추가한 플러그인(vite.config.ts > plugins)이 추가된다.
    • +
    • 플러그인은 이후 Dev Server의 특정 시점마다 훅을 실행시켜 미들웨어 역할을 하게 된다.
    • +
    +
  8. +
  9. +

    클라이언트의 요청을 기다린다.

    +
  10. +
+

2. index.html 요청

+

index.html-request-phase

+
// server/index.ts
+middlewares.use(indexHtmlMiddleware(root, server))
+ 
+// https://github.com/vitejs/vite/blob/v5.0.12/packages/vite/src/node/server/middlewares/indexHtml.ts#L438
+// HTML 파일을 처리하고 변환한다. 스크립트 태그 주입 및 HMR 클라이언트 코드 삽입, 모듈 경로 변환 등의 작업을 한다.
+html = await server.transformIndexHtml(url, html, req.originalUrl)
+ 
+// transform
+export function createDevHtmlTransformFn(...) {
+  // 필요한 플러그인의 실행 시점에 따라 분류한다.
+  const [preHooks, normalHooks, postHooks] = resolveHtmlTransforms(...)
+  return (...) => {
+    // html에 반영한다.
+    return applyHtmlTransforms(
+      html,
+      [
+        preImportMapHook(config),
+        ...preHooks,
+        // ...
+      ],
+      { ... },
+    )
+  }
+}
+

최초 유저의 요청(GET /)이 발생하면 index.html이 리턴된다. 이 과정에서 transform과 같은 플러그인 훅을 거치며 필요한 데이터들을 세팅한다.

+
    +
  1. +

    미들웨어에서 transform 함수가 실행된다.

    +
      +
    • 플러그인 컨테이너의 플러그인들이 실행 된다. +
        +
      • 플러그인들은 실행 시점(pre, normal, post)에 맞춰 훅이 실행된다.
      • +
      +
    • +
    +
  2. +
  3. +

    의존성 사전 번들링

    +
      +
    • node_modules에 있는 의존성은 ESM이 아닐 수 있다. Vite는 이들을 사전 번들링하여 브라우저가 이해할 수 있는 ESM 형태로 변환한다. +
        +
      • 이 과정은 esbuild로 실행되어 빠르게 처리된다.
      • +
      +
    • +
    +
  4. +
+

transpile-ts-to-js

+
+

hmr.ts의 response에 타입이 사라진 모습.

+
+
    +
  1. +

    코드 변환

    +
      +
    • TS 혹은 JSX 파일의 경우 JS로 변환된다. +
        +
      • 위의 그림과 같이 파일명 자체는 변경되지 않지만, 코드는 js로 변경된다.
      • +
      +
    • +
    +
  2. +
+
// 만약 index.html에서 해당 파일을 import 한다고 가정해 보자.
+// playground/hmr/hmr.ts
+import { foo as depFoo, nestedFoo } from './hmrDep'
+import './importing-updated'
+import './invalidation/parent'
+ 
+// hmr.ts 파일에 구성된 모듈 의존성 그래프
+ModuleNode {
+  url: '/hmr.ts',
+  file: '/User/user/VSCode/vite/playground/hmr/hmr.ts',
+  type: 'js',
+  // 클라이언트 측에서 사용되는 모듈들, 즉 브라우저에서 실행되는 모듈들의 목록을 추적하는 데 사용된다.
+  clientImportModules: Set(10) {
+    // 재귀적 구조
+    ModuleNode: {
+      url: '/hmrDep.js' // hmr.ts 내부에서 import 되는 hmrDep 이 추가된 모습.
+      file: '/User/user/VSCode/vite/playground/hmr/hmrDep.js',
+      clientImportModules: Set(10) {
+        ModuleNode: { ... }
+    }.
+    ModuleNode: {
+      url: '/importing-updated/index.js', // hmr.ts 내부에서 import 되는 importing-updated가 추가된 모습.
+      file: '/User/user/VSCode/vite/playground/hmr/importing-updated/index.js',
+      // ...
+    }
+  // ...
+
    +
  1. +

    모듈 의존성 그래프 생성

    +
      +
    • 위 코드를 보면 hmr.ts에서 import 되는 ./hmrDep, ./importing-updated 등이 ModuleNode에 설정되는 것을 알 수 있다.
    • +
    • 만약 외부 의존성이 있다면 chokidar에 추가된다.
    • +
    • 파일 시스템에 변경사항이 있을 때 모듈 그래프로 빠르게 전파시킨다.
    • +
    +
  2. +
  3. +

    Dev Server의 기능에 필요한 사전 코드들(@vite/client 등)을 응답 자원에 추가한다.

    +
  4. +
  5. +

    변환된 html 파일을 리턴한다.

    +
  6. +
+

3. index.html 렌더링과 자원 요청

+

index.html-rendering-phase

+
+

3 ~ 4. 브라우저 렌더링 및 정적 자원 요청 상황.

+
+

init-html

+

browser-initiator

+
+

브라우저는 위에서부터 아래로 해석해 나가므로 @vite/client, global.css, hmr.ts가 순차적으로 요청되는 것을 볼 수 있다.

+
+
    +
  1. +

    브라우저는 응답받은 html 파일을 렌더링 하기 시작한다.

    +
  2. +
  3. +

    렌더링에 필요한 자원(js, css 등)을 다시 Dev Server에 요청한다.

    +
      +
    • html의 최상단 /@vite/client을 시작점으로 global.css, hmr.ts 등이 요청된다.
    • +
    +
  4. +
+
// server/index.ts
+// main transform middleware
+middlewares.use(transformMiddleware(server))
+ 
+// https://github.com/vitejs/vite/blob/v5.0.12/packages/vite/src/node/server/middlewares/transform.ts#L175
+export function transformMiddleware(...) {
+  // resolve, load and transform using the plugin container
+  const result = await transformRequest(url, server, {
+    html: req.headers.accept?.includes('text/html'),
+  })
+  if (result) {
+    // transform된 코드, 소스코드를 캐시 설정해 리턴한다.
+    return send(req, res, result.code, type, {
+      etag: result.etag,
+      cacheControl: isDep ? 'max-age=31536000,immutable' : 'no-cache',
+      headers: server.config.server.headers,
+      map: result.map,
+    })
+  }
+}
+
    +
  1. +

    transform 적용

    +
      +
    • 각 요청에 대해 Dev Server는 transformMiddleware에서 2번 html 요청과 비슷한 과정으로 transform 이후 응답한다. +
        +
      • 이때 public 폴더 내의 요청인지 외부 자원 요청인지 등의 분류 작업 또한 미들웨어에서 진행한다.
      • +
      • @fs prefix는 vite 프로젝트의 루트(config 위치)를 벗어날 경우 설정된다(모노레포 혹은 파일 시스템 직접 접근 등의 경우).
      • +
      +
    • +
    • HMR 코드 적용 + +
    • +
    +
  2. +
  3. +

    변환된 자원을 브라우저에 응답(response)한다.

    +
  4. +
+
+

(*1): preact의 prefresh 같은 HMR 라이브러리를 적용했거나(후술) import.meta.hot.accept을 직접 코드에 추가한 경우에 해당(아래 코드)한다.

+
+
<!-- 
+  import.meta.hot.accept가 코드에 있다면 HMR을 허용한 파일이라고 인식한다.
+  importAnalysis 플러그인이 createHotContext를 추가한다. -->
+<script type="module">
+  if (import.meta.hot) {
+    // https://vitejs.dev/guide/api-hmr#hot-accept-cb
+    import.meta.hot.accept((param) => {
+      console.log('param', param);
+    });
+  }
+</script>
+

inject-import-meta-hot

+
+

일반 스크립트의 응답에 createHotContext 생성 및 import.meta.hot에 바인딩된 모습.

+
+

4. 렌더링 계속 진행(with WebSocket)

+

이제 index.html의 요청 파일을 가져왔으므로 브라우저 렌더링이 계속 진행된다.

+
@vite/client.ts
function setupWebSocket(...) {
+  const socket = new WebSocket(`${protocol}://${hostAndPath}`, 'vite-hmr')
+  socket.addEventListener('message', async ({ data }) => {
+    handleMessage(JSON.parse(data))
+  });
+}
+ +
// AS-IS 원본 코드
+import { h, render } from 'preact';
+import App from './MyComponent';
+ 
+render(<App />, document.getElementById('app'));
+ 
+// TO-BE 변경된 코드
+import { render } from '/node_modules/.vite/deps/preact.js';
+import { jsxDEV as _jsxDEV } from '/node_modules/.vite/deps/preact_jsx-dev-runtime.js';
+import App from '/src/MyComponent';
+ 
+render(_jsxDEV(App, ...), document.getElementById('app'))
+

만약 react와 같은 UI 라이브러리를 사용한다면 각 라이브러리가 의존하는 HMR 라이브러리가 호출된다. 여기서는 preact를 기준으로 설명(리액트와 거의 동일하다)하겠다.

+

jsxDEV로 감싸진 하위 컴포넌트들은 HMR이 적용된다. 자세한 내용은 6. 브라우저 리렌더링에서 다루겠다.

+

이제 브라우저가 더 이상 요청할 것이 없을 때까지 3 ~ 4 과정을 반복하며 렌더링을 마무리한다.

+

5. 코드 변경 감지

+

file-change-phase

+
+

코드가 변경되었을 때의 Dev Server 모습

+
+
watcher.on('change', async (file) => {
+  file = normalizePath(file);
+  // 플러그인 컨테이너에게 update 이벤트 발송. 플러그인에서 필요시 실행된다.
+  await container.watchChange(file, { event: 'update' });
+  // 의존성 그래프에 변경사항 체크
+  moduleGraph.onFileChange(file);
+  // 대망의 HMR 업데이트 시작
+  await onHMRUpdate(file, false);
+});
+

개발 중 파일이 변경되면(개발자의 코드 수정) chokidar에서 change 이벤트를 감지한다.

+
    +
  • 플러그인 컨테이너에 update 이벤트를 전파한다. +
      +
    • 각 플러그인에서 필요시(listen) 플러그인 코드가 실행된다.
    • +
    +
  • +
  • 의존성 그래프에 변경사항을 적용한다. +
      +
    • 모듈 캐싱을 무효화해 refresh 되도록 함.
    • +
    +
  • +
  • onHMRUpdate 함수를 호출해 hot-reloading을 준비한다.
  • +
+
function onHMRUpdate() {
+  // 관련 플러그인 훅 실행
+  for (const hook of config.getSortedPluginHooks('handleHotUpdate')) {
+    const filteredModules = await hook(hmrContext)
+  }
+  ...
+  updateModules(...)
+}
+ 
+function updateModules(...) {
+  for (const mod of modules) {
+    const boundaries: PropagationBoundary[] = []
+    // 모듈 그래프 갱신
+    const hasDeadEnd = propagateUpdate(mod, traversedModules, boundaries)
+    moduleGraph.invalidateModule(mod, invalidatedModules, timestamp, true)
+  }
+  // 소켓 메시지 전송
+  ws.send({ type: 'update', updates })
+

onHMRUpdateupdateModules호출한다.

+
    +
  • HMR 관련 플러그인이 있을 때 훅(handleHotUpdate)을 실행시킨다.
  • +
  • 관련된 모듈 그래프를 갱신한다.
  • +
  • 브라우저에게 파일이 변경되었음을 WebSocket으로 알린다(update 이벤트 전송).
  • +
+

6. 브라우저 리렌더링

+

socket-update-event

+
+

브라우저 소켓이 Dev Server의 update 소켓 데이터를 받은 모습.

+
+
// Step 1.
+// @vite/client.ts
+case 'update':
+  notifyListeners('vite:beforeUpdate', payload);
+  await Promise.all(payload.updates.map(async(update)=> {
+      if (update.type === 'js-update') {
+            // queueUpdate는 업데이트 목록의 순서를 유지해준다.
+            return queueUpdate(hmrClient.fetchUpdate(update))
+      }
+      // ... CSS update는 생략
+  });
+  notifyListeners('vite:afterUpdate', payload);
+ 
+// Step 2.
+// HMRClient > fetchUpdate
+fetchUpdate(...) {
+  fetchedModule = await this.importUpdatedModule(update);
+ 
+// client/client.ts > importUpdatedModule
+async function importUpdatedModule(...) {
+  // Step 3.
+  const importPromise = import(
+    /* @vite-ignore */
+    base +
+      acceptedPathWithoutQuery.slice(1) +
+      `?${explicitImportRequired ? 'import&' : ''}t=${timestamp}${
+        query ? `&${query}` : ''
+      }`
+  )
+  return await importPromise
+},
+
    +
  1. +

    @vite/client에서 연결된 브라우저의 소켓은 update 이벤트를 받고 hmrClient에게 업데이트를 지시한다.

    +
  2. +
  3. +

    hmrClient는 fetchUpdate에 데이터를 넘기고 importUpdatedModule호출한다.

    + +
  4. +
+

import response

+
+

Step 3 ~ 4.

+
+
    +
  1. +

    importUpdatedModule이 호출되면서 변경된 모듈이 import 되므로, Dev Server에 새로 요청하게 된다(3. 렌더링 자원 요청). 이때 t 값을 쿼리로 넣어(?t=123214123) 캐싱을 회피해 변경된 모듈의 코드를 응답으로 받을 수 있도록 한다.

    +
  2. +
  3. +

    import로 요청한 응답이 정상적으로 오면 리렌더링 되기 시작(4. 렌더링 진행)된다.

    +
  4. +
  5. +

    HMR이 가능한 파일은 import.meta.hot.accept 함수의 콜백으로 실행된다. HMR이 불가능한 파일이라면 전체 페이지를 리로딩한다.

    +
  6. +
+
// vite.config.ts
+import preact from '@preact/preset-vite';
+ 
+export default defineConfig({
+  // Step 1. preact 플러그인 호출
+  plugins: [preact()],
+});
+ 
+// Step2 2. preact 플러그인에서 prefresh가 호출되면서 소스코드를 transform 한다.
+return {
+  code: `${prelude}${result.code}
+  if (import.meta.hot) {
+    self.$RefreshReg$ = prevRefreshReg;
+    self.$RefreshSig$ = prevRefreshSig;`
+    // 중요! Step 3. 해당 코드 덕에 HMR로 인식, Dev Server에서 호출되며 flushUpdate 실행
+    `import.meta.hot.accept((m) => {
+      try {
+        flushUpdates();`
+ 
+// Step 4. 실제 코드 변경 부분.
+function flushUpdates() {
+  self.__PREFRESH__.replaceComponent(prev, next, true);
+

앞에서 잠깐 다뤘지만 react, preact 등 순수 자바스크립트가 아니라면 라이브러리 자체 HMR을 호출한다. 이 HMR 코드는 [3. 렌더링 자원 요청] 단계에서 추가된다.

+
    +
  1. +

    preact-vite 플러그인(@preact/preset-vite)은 내부적으로 prefresh라는 HMR 라이브러리를 사용한다.

    +
  2. +
  3. +

    preact 플러그인은 prefreshEnabled 여부에 따라 prefresh를 호출한다.

    +
  4. +
  5. +

    prefresh는 transform 단계에서 import.meta.hot.accept 코드를 주입한다.

    +
  6. +
  7. +

    [6. 브라우저 리렌더링] 발생시 3번 단계의 importUpdatedModule를 거쳐 import.meta.hot.accept의 콜백이 실행된다.

    +
  8. +
  9. +

    perfresh에서 심어둔 flushUpdates 함수가 HMR을 수행(컴포넌트 변경)된다.

    +
  10. +
+

이로써 HMR이 완전히 마무리되면서 다시 개발자의 입력을 기다리게 된다.

+

dev-server-logic-summary

+
+

Dev Server 초기화부터 HMR까지.

+
+

마무리

+

Vite Dev Server를 사용하면서 모호하게 알고 있던 부분을 이번 기회에 한번 쭉 정리할 수 있었다. 정리하면서 모르는 것이 참 많다고 느꼈다.

+

팩트인지 확인하기 위한 소스코드 탐험과 디버깅 과정은 상당히 의미 있었다. 글이 너무 길어질 것 같아 생략한 함수들이 꽤 있는데 감탄하며 본 로직들이 많이 있었다. 역시 남의 코드를 많이 봐야 한다.

+

이번 과정을 통해 두 가지 인사이트를 얻을 수 있었다.

+
    +
  1. Vite Dev Server의 많은 부분들이 Webpack Dev Server의 동작과 비슷하다는 점이 인상적이었다. 하나를 잘해놓는 게 중요하다고 느꼈다.
  2. +
  3. 소스코드의 탐험이 쉽지만은 않았만 어느 정도 자신감이 붙을 수 있었다. 이후에도 이렇게 공부해 나가야겠다고 생각했다.
  4. +
+

이 글을 쓰며 참고했던 혹은 유용했던 링크를 남기며 글을 마무리하려고 한다.

+
📮 이 포스트에 관심 있으신가요? 이슈를 남겨주세요! 👍
☕ 소주 한 잔 후원하기
(예금주: 이상철)tosskakao
\ No newline at end of file diff --git a/e2e/__snapshots__/post/dom.spec.ts/mobile/woowa-type-review.html b/e2e/__snapshots__/post/dom.spec.ts/mobile/woowa-type-review.html new file mode 100644 index 00000000..60920fcf --- /dev/null +++ b/e2e/__snapshots__/post/dom.spec.ts/mobile/woowa-type-review.html @@ -0,0 +1,118 @@ +

우아한 타입스크립트 with 리액트 리뷰

1ilsang
클라이밍 하실래염?
#book#review#typescript#react
Published

cover

+

"우아한 타입스크립트 with 리액트"의 리뷰를 해보려고 한다.

+

선택하게 된 계기

+

두 가지 캐치프라이즈가 나를 이끌었다고 생각한다.

+
    +
  1. 배달의민족 개발 사례로 살펴보는
  2. +
  3. 주니어 개발자를 위한 온보딩 가이드
  4. +
+

기술 스택이 동일하다 하여도 회사별로 사용 방식이 상당히 상이하다고 느낄 때가 많았기 때문에 이렇게 간접적으로나마 문화나 기술 적용 방식을 체험해 볼 수 있는 서적을 선호한다.

+

또한 주니어 개발자를 위한 온보딩 가이드를 내걸었으므로 어떻게 신입이 회사의 일원으로 빠르게 흡수될 수 있을지 고민한 흔적이 있을 것이라 기대해 선택하게 되었다.

+

간단한 요약

+

이 책은 앞에서 타입스크립트를 다루고 뒷부분은 리액트를 활용한 여러 기법이나 패턴에 관해 설명한다.

+

앞쪽 타입은 신입이 보기에 조금 어려운 타입 좁히기까지 잘 다루고 있다. extendsinfer를 통한 타입 추론이 익숙하지 않다면 읽어보기를 추천하고 싶다.

+

물론 타입스크립트나 리액트를 깊이 있게 공부하려고 이 책을 선택한다면 조금 부족할 수 있다고 생각한다(애초에 주니어 온보딩 책이다).

+

온보딩 책인 만큼 API, 리렌더링, 훅스, State 등을 다양한 예제와 패턴을 통해 소개하는데 내용이 좋으므로 사수가 없는 환경에서 개발하는 분들이라면 읽기를 추천하고 싶다.

+

인상 깊었던 부분

+

책을 읽으면서 좋았던 예제나 포인트들을 가볍게 소개하고자 한다.

+

타 언어의 타입 시스템과 비교

+

2장에서 타 언어의 타입 시스템을 거론하며 타입스크립트의 타입 철학을 엿볼 수 있게 해준다. 나는 이 부분이 좋았다.

+
    +
  • 타입스크립트는 다른 명목적으로 구체화한 타입 시스템(Java, C++)과 다르게 구조로 타입을 구분한다.
  • +
  • 타입스크립트는 타입 시스템을 집합으로 이해하면 된다. 타입은 값의 집합으로 생각할 수 있다.
  • +
+
class Person {
+  name: string;
+  constructor(name: string) {
+    this.name = name;
+  }
+}
+class Developer {
+  name: string;
+  age: number;
+  constructor(name: string, age: number) {
+    this.name = name;
+    this.age = age;
+  }
+}
+function greet(p: Person) {
+  console.log(p.name);
+}
+const developer = new Developer('zig', 20);
+ 
+// Person 타입을 받지만 Developer 타입 값을 넣어도 무관하다.
+// 구조적 서브 타입이기 때문에 에러를 발생시키지 않는다.
+// 서로 다른 두 타입 간의 호환성은 오직 타입 내부의 구조에 의해 결정된다.
+greet(developer); // OK
+
    +
  • 타입스크립트가 위와 같은 구조적 타이핑을 채택한 이유는 자바스크립트를 모델링한 언어이기 때문이다.
  • +
  • 자바스크립트는 덕 타이핑(duck typing)을 기반으로 만들어졌다. 덕타입은 매개변수가 올바르게 주어진다면 그 값이 어떻게 만들어졌는지 신경 쓰지 않고 허용한다.
  • +
+

타입에 대한 고찰

+
type IdType = string | number;
+type Numeric = number | boolean;
+// 교차 타입은 두 타입을 모두 만족하는 경우에만 유지된다.
+type Universal = IdType & Numeric; // number
+ 
+// 따라서 두 타입을 만족하지 못하는 경우 never가 된다.
+type DeliveryTip = {
+  tip: number;
+}
+type Filter = {
+  tip: string;
+} & DeliveryTip;
+const filter: Filter = { tip: ... } // Type '...' is not assignable to type 'never'.
+

타입스크립트의 특징(집합적 특징과 구조적 타이핑 등)은 교차 타입에서 혼란을 야기할 수 있다. 이 부분에 대한 내용을 명확하게 설명하고 있다.

+

개발팀과의 인터뷰

+

woowa-story

+

중간중간 배민 개발팀들이 참조 출연해 해당 타입/기술을 쓰는지, 어떻게 생각하는지 인터뷰하는 것들이 있는데 실무에서 어떻게 생각하는지 생생하게 볼 수 있어서 흥미로웠다.

+

자연스럽게 나 또한 질문에 대한 답을 해보곤 하면서 더 몰입할 수 있었다.

+

친절한 설명

+
type CreateMutable<Type> = {
+  -readonly [Property in keyof Type]-?: Type[Property]; // - 는 오타가 아니다.
+};
+ 
+// 우아한 타입스크립트 https://www.youtube.com/whatch?v=ViS8DLd6o-E
+// 제너릭 T 타입이 K로 추론되는 Promise<K>라면 K를 반환하고 아니라면 any를 반환한다.
+// 이를 통해 Promise 반환 타입을 좁혀서 추론할 수 있다.
+type UnpackPromise<T> = T extends Promise<infer K>[] ? K : any;
+

타입스크립트의 문법 중에는 처음에 이해하기 어려운 것들이 있는데, 하나하나 과정을 풀어서 설명해 준다.

+

실용적인 예제

+

특정 개념을 설명하고 활용하는 방법을 실무 코드를 기준으로 알려주기 때문에 상당히 실용적인 예제들로 채워져있다.

+
const BottomSheetMap = {
+  RECENT_CONTACTS: RecentContactsBottomSheet,
+  CARD_SELECT: CardSelectBottomSheet,
+};
+type BOTTOM_SHEET_ID = keyof typeof BottomSheetMap;
+type BottomSheetStore = {
+  // BOTTOM_SHEET_ID(BottomSheetMap 객체의 key 값)의 property 값을 변환한다.
+  [index in BOTTOM_SHEET_ID as `${index}_BOTTOM_SHEET_ID`]: {
+    // ...
+  };
+};
+const store: BottomSheetStore = {
+  RECENT_CONTACTS_BOTTOM_SHEET_ID: { ... }, // key 값이 index로 가져온 값으로 변환된다.
+  CARD_SELECT_BOTTOM_SHEET_ID: { ... },
+};
+

바텀 시트별 스토어를 선언하면서 키값을 특정하게 강제하고 있다.

+
type ProductPrice = 100 | 200 | 300;
+const getProductName = (productPrice: ProductPrice): string => {
+  if (productPrice === 100) return '배민 상품권 100원';
+  if (productPrice === 200) return '배민 상품권 200원';
+  else {
+    // exhaustiveCheck를 안 해주면 300원에 대한 코드가 없어도 에러가 발생하지 않음.
+    // 조건별 완벽한 타입 검증을 위해 사용하는 패턴
+    exhaustiveCheck(productPrice); // Argument of type 'number' is not assignable to parameter of type 'never'.
+    return '배민 상품권';
+  }
+};
+// param 값이 never 타입이므로 해당 함수가 호출되기 전에 타입 가드가 다 되어 있어야 한다.
+const exhaustiveCheck = (param: never) => {
+  throw new Error(`type error!`);
+};
+

Exhaustiveness Checking 패턴을 통해 이후 타입이 추가되어도 무결성을 지킬 수 있게 된다.

+

이 외에도 컴파일 과정에 대한 설명이나 Axios 가이드 및 훅에서 유의할 점 등 여러 꿀팁들이 있다.

+

맺으며

+

배민의 공유 문화는 본받을만하다고 생각한다.

+

기술업계 특성상 비판적인 시선이 기본적으로 있기 때문에 외부 공개를 꺼릴 수도 있었겠지만, 기술에 대한 공유를 두려워하지 않고 책으로 펴낸 것에 리스펙하게 된다.

+

여러 예제가 실제 코딩에 도움이 되기 때문에 추천하고 싶은 책이다.

📮 이 포스트에 관심 있으신가요? 이슈를 남겨주세요! 👍
☕ 소주 한 잔 후원하기
(예금주: 이상철)tosskakao
\ No newline at end of file diff --git a/e2e/__snapshots__/post/screen.spec.ts/desktop/2023.png b/e2e/__snapshots__/post/screen.spec.ts/desktop/2023.png new file mode 100644 index 00000000..53e5bce7 Binary files /dev/null and b/e2e/__snapshots__/post/screen.spec.ts/desktop/2023.png differ diff --git a/e2e/__snapshots__/post/screen.spec.ts/desktop/2024-01.png b/e2e/__snapshots__/post/screen.spec.ts/desktop/2024-01.png new file mode 100644 index 00000000..6c9cf3c3 Binary files /dev/null and b/e2e/__snapshots__/post/screen.spec.ts/desktop/2024-01.png differ diff --git a/e2e/__snapshots__/post/screen.spec.ts/desktop/array-prototype-sort.png b/e2e/__snapshots__/post/screen.spec.ts/desktop/array-prototype-sort.png new file mode 100644 index 00000000..4a8ec89d Binary files /dev/null and b/e2e/__snapshots__/post/screen.spec.ts/desktop/array-prototype-sort.png differ diff --git a/e2e/__snapshots__/post/screen.spec.ts/desktop/bali-remote-work.png b/e2e/__snapshots__/post/screen.spec.ts/desktop/bali-remote-work.png new file mode 100644 index 00000000..cf191114 Binary files /dev/null and b/e2e/__snapshots__/post/screen.spec.ts/desktop/bali-remote-work.png differ diff --git a/e2e/__snapshots__/post/screen.spec.ts/desktop/deploy-eslint-plugin.png b/e2e/__snapshots__/post/screen.spec.ts/desktop/deploy-eslint-plugin.png new file mode 100644 index 00000000..ef7aa67f Binary files /dev/null and b/e2e/__snapshots__/post/screen.spec.ts/desktop/deploy-eslint-plugin.png differ diff --git a/e2e/__snapshots__/post/screen.spec.ts/desktop/geultto8-open-source-seminar.png b/e2e/__snapshots__/post/screen.spec.ts/desktop/geultto8-open-source-seminar.png new file mode 100644 index 00000000..bf6b7d61 Binary files /dev/null and b/e2e/__snapshots__/post/screen.spec.ts/desktop/geultto8-open-source-seminar.png differ diff --git a/e2e/__snapshots__/post/screen.spec.ts/desktop/google-adsense.png b/e2e/__snapshots__/post/screen.spec.ts/desktop/google-adsense.png new file mode 100644 index 00000000..a93f9831 Binary files /dev/null and b/e2e/__snapshots__/post/screen.spec.ts/desktop/google-adsense.png differ diff --git a/e2e/__snapshots__/post/screen.spec.ts/desktop/goorm-195687.png b/e2e/__snapshots__/post/screen.spec.ts/desktop/goorm-195687.png new file mode 100644 index 00000000..8c6abcab Binary files /dev/null and b/e2e/__snapshots__/post/screen.spec.ts/desktop/goorm-195687.png differ diff --git a/e2e/__snapshots__/post/screen.spec.ts/desktop/goorm-195692.png b/e2e/__snapshots__/post/screen.spec.ts/desktop/goorm-195692.png new file mode 100644 index 00000000..779aed79 Binary files /dev/null and b/e2e/__snapshots__/post/screen.spec.ts/desktop/goorm-195692.png differ diff --git a/e2e/__snapshots__/post/screen.spec.ts/desktop/goorm-195693.png b/e2e/__snapshots__/post/screen.spec.ts/desktop/goorm-195693.png new file mode 100644 index 00000000..21ff7872 Binary files /dev/null and b/e2e/__snapshots__/post/screen.spec.ts/desktop/goorm-195693.png differ diff --git a/e2e/__snapshots__/post/screen.spec.ts/desktop/goorm-195696.png b/e2e/__snapshots__/post/screen.spec.ts/desktop/goorm-195696.png new file mode 100644 index 00000000..06aa1405 Binary files /dev/null and b/e2e/__snapshots__/post/screen.spec.ts/desktop/goorm-195696.png differ diff --git a/e2e/__snapshots__/post/screen.spec.ts/desktop/goorm-195698.png b/e2e/__snapshots__/post/screen.spec.ts/desktop/goorm-195698.png new file mode 100644 index 00000000..ff8cbfe1 Binary files /dev/null and b/e2e/__snapshots__/post/screen.spec.ts/desktop/goorm-195698.png differ diff --git a/e2e/__snapshots__/post/screen.spec.ts/desktop/implicit-coercion.png b/e2e/__snapshots__/post/screen.spec.ts/desktop/implicit-coercion.png new file mode 100644 index 00000000..0b5a9748 Binary files /dev/null and b/e2e/__snapshots__/post/screen.spec.ts/desktop/implicit-coercion.png differ diff --git a/e2e/__snapshots__/post/screen.spec.ts/desktop/inflearn-meetup-03-dev-career.png b/e2e/__snapshots__/post/screen.spec.ts/desktop/inflearn-meetup-03-dev-career.png new file mode 100644 index 00000000..0e98f0ce Binary files /dev/null and b/e2e/__snapshots__/post/screen.spec.ts/desktop/inflearn-meetup-03-dev-career.png differ diff --git a/e2e/__snapshots__/post/screen.spec.ts/desktop/jeju-remote-work.png b/e2e/__snapshots__/post/screen.spec.ts/desktop/jeju-remote-work.png new file mode 100644 index 00000000..dd19c4b3 Binary files /dev/null and b/e2e/__snapshots__/post/screen.spec.ts/desktop/jeju-remote-work.png differ diff --git a/e2e/__snapshots__/post/screen.spec.ts/desktop/junction2023.png b/e2e/__snapshots__/post/screen.spec.ts/desktop/junction2023.png new file mode 100644 index 00000000..4f80cd73 Binary files /dev/null and b/e2e/__snapshots__/post/screen.spec.ts/desktop/junction2023.png differ diff --git a/e2e/__snapshots__/post/screen.spec.ts/desktop/leetcode-easy-2727.png b/e2e/__snapshots__/post/screen.spec.ts/desktop/leetcode-easy-2727.png new file mode 100644 index 00000000..b8728280 Binary files /dev/null and b/e2e/__snapshots__/post/screen.spec.ts/desktop/leetcode-easy-2727.png differ diff --git a/e2e/__snapshots__/post/screen.spec.ts/desktop/leetcode-hard-42.png b/e2e/__snapshots__/post/screen.spec.ts/desktop/leetcode-hard-42.png new file mode 100644 index 00000000..f6fe9811 Binary files /dev/null and b/e2e/__snapshots__/post/screen.spec.ts/desktop/leetcode-hard-42.png differ diff --git a/e2e/__snapshots__/post/screen.spec.ts/desktop/leetcode-medium-238.png b/e2e/__snapshots__/post/screen.spec.ts/desktop/leetcode-medium-238.png new file mode 100644 index 00000000..82449ed7 Binary files /dev/null and b/e2e/__snapshots__/post/screen.spec.ts/desktop/leetcode-medium-238.png differ diff --git a/e2e/__snapshots__/post/screen.spec.ts/desktop/mac-init-apps.png b/e2e/__snapshots__/post/screen.spec.ts/desktop/mac-init-apps.png new file mode 100644 index 00000000..a14a02e6 Binary files /dev/null and b/e2e/__snapshots__/post/screen.spec.ts/desktop/mac-init-apps.png differ diff --git a/e2e/__snapshots__/post/screen.spec.ts/desktop/mdn-ko-organizer.png b/e2e/__snapshots__/post/screen.spec.ts/desktop/mdn-ko-organizer.png new file mode 100644 index 00000000..e8f93a05 Binary files /dev/null and b/e2e/__snapshots__/post/screen.spec.ts/desktop/mdn-ko-organizer.png differ diff --git a/e2e/__snapshots__/post/screen.spec.ts/desktop/micro-state-management-review.png b/e2e/__snapshots__/post/screen.spec.ts/desktop/micro-state-management-review.png new file mode 100644 index 00000000..4e2d8069 Binary files /dev/null and b/e2e/__snapshots__/post/screen.spec.ts/desktop/micro-state-management-review.png differ diff --git a/e2e/__snapshots__/post/screen.spec.ts/desktop/prettier3.png b/e2e/__snapshots__/post/screen.spec.ts/desktop/prettier3.png new file mode 100644 index 00000000..56ea5ff5 Binary files /dev/null and b/e2e/__snapshots__/post/screen.spec.ts/desktop/prettier3.png differ diff --git a/e2e/__snapshots__/post/screen.spec.ts/desktop/proving-ground-review.png b/e2e/__snapshots__/post/screen.spec.ts/desktop/proving-ground-review.png new file mode 100644 index 00000000..aab5b629 Binary files /dev/null and b/e2e/__snapshots__/post/screen.spec.ts/desktop/proving-ground-review.png differ diff --git a/e2e/__snapshots__/post/screen.spec.ts/desktop/quality-of-job-review.png b/e2e/__snapshots__/post/screen.spec.ts/desktop/quality-of-job-review.png new file mode 100644 index 00000000..06668a5c Binary files /dev/null and b/e2e/__snapshots__/post/screen.spec.ts/desktop/quality-of-job-review.png differ diff --git a/e2e/__snapshots__/post/screen.spec.ts/desktop/renovate.png b/e2e/__snapshots__/post/screen.spec.ts/desktop/renovate.png new file mode 100644 index 00000000..8e21608b Binary files /dev/null and b/e2e/__snapshots__/post/screen.spec.ts/desktop/renovate.png differ diff --git a/e2e/__snapshots__/post/screen.spec.ts/desktop/storybook7.png b/e2e/__snapshots__/post/screen.spec.ts/desktop/storybook7.png new file mode 100644 index 00000000..47e382fd Binary files /dev/null and b/e2e/__snapshots__/post/screen.spec.ts/desktop/storybook7.png differ diff --git a/e2e/__snapshots__/post/screen.spec.ts/desktop/turborepo.png b/e2e/__snapshots__/post/screen.spec.ts/desktop/turborepo.png new file mode 100644 index 00000000..3c425263 Binary files /dev/null and b/e2e/__snapshots__/post/screen.spec.ts/desktop/turborepo.png differ diff --git a/e2e/__snapshots__/post/screen.spec.ts/desktop/typescript-subtyping.png b/e2e/__snapshots__/post/screen.spec.ts/desktop/typescript-subtyping.png new file mode 100644 index 00000000..ca6bb461 Binary files /dev/null and b/e2e/__snapshots__/post/screen.spec.ts/desktop/typescript-subtyping.png differ diff --git a/e2e/__snapshots__/post/screen.spec.ts/desktop/typescript5.png b/e2e/__snapshots__/post/screen.spec.ts/desktop/typescript5.png new file mode 100644 index 00000000..f8a4be22 Binary files /dev/null and b/e2e/__snapshots__/post/screen.spec.ts/desktop/typescript5.png differ diff --git a/e2e/__snapshots__/post/screen.spec.ts/desktop/udemy-rust-programming.png b/e2e/__snapshots__/post/screen.spec.ts/desktop/udemy-rust-programming.png new file mode 100644 index 00000000..c3e92d18 Binary files /dev/null and b/e2e/__snapshots__/post/screen.spec.ts/desktop/udemy-rust-programming.png differ diff --git a/e2e/__snapshots__/post/screen.spec.ts/desktop/use-prevent-leave.png b/e2e/__snapshots__/post/screen.spec.ts/desktop/use-prevent-leave.png new file mode 100644 index 00000000..2bb9981f Binary files /dev/null and b/e2e/__snapshots__/post/screen.spec.ts/desktop/use-prevent-leave.png differ diff --git a/e2e/__snapshots__/post/screen.spec.ts/desktop/use-transition.png b/e2e/__snapshots__/post/screen.spec.ts/desktop/use-transition.png new file mode 100644 index 00000000..525ff706 Binary files /dev/null and b/e2e/__snapshots__/post/screen.spec.ts/desktop/use-transition.png differ diff --git a/e2e/__snapshots__/post/screen.spec.ts/desktop/visual-regression-test.png b/e2e/__snapshots__/post/screen.spec.ts/desktop/visual-regression-test.png new file mode 100644 index 00000000..8294c88b Binary files /dev/null and b/e2e/__snapshots__/post/screen.spec.ts/desktop/visual-regression-test.png differ diff --git a/e2e/__snapshots__/post/screen.spec.ts/desktop/vite-dev-server.png b/e2e/__snapshots__/post/screen.spec.ts/desktop/vite-dev-server.png new file mode 100644 index 00000000..cdbfc337 Binary files /dev/null and b/e2e/__snapshots__/post/screen.spec.ts/desktop/vite-dev-server.png differ diff --git a/e2e/__snapshots__/post/screen.spec.ts/desktop/woowa-type-review.png b/e2e/__snapshots__/post/screen.spec.ts/desktop/woowa-type-review.png new file mode 100644 index 00000000..3ae81e26 Binary files /dev/null and b/e2e/__snapshots__/post/screen.spec.ts/desktop/woowa-type-review.png differ diff --git a/e2e/__snapshots__/post/screen.spec.ts/mobile/2023.png b/e2e/__snapshots__/post/screen.spec.ts/mobile/2023.png new file mode 100644 index 00000000..42f520f0 Binary files /dev/null and b/e2e/__snapshots__/post/screen.spec.ts/mobile/2023.png differ diff --git a/e2e/__snapshots__/post/screen.spec.ts/mobile/2024-01.png b/e2e/__snapshots__/post/screen.spec.ts/mobile/2024-01.png new file mode 100644 index 00000000..e1b621bc Binary files /dev/null and b/e2e/__snapshots__/post/screen.spec.ts/mobile/2024-01.png differ diff --git a/e2e/__snapshots__/post/screen.spec.ts/mobile/array-prototype-sort.png b/e2e/__snapshots__/post/screen.spec.ts/mobile/array-prototype-sort.png new file mode 100644 index 00000000..0bace2d0 Binary files /dev/null and b/e2e/__snapshots__/post/screen.spec.ts/mobile/array-prototype-sort.png differ diff --git a/e2e/__snapshots__/post/screen.spec.ts/mobile/bali-remote-work.png b/e2e/__snapshots__/post/screen.spec.ts/mobile/bali-remote-work.png new file mode 100644 index 00000000..9ec1328c Binary files /dev/null and b/e2e/__snapshots__/post/screen.spec.ts/mobile/bali-remote-work.png differ diff --git a/e2e/__snapshots__/post/screen.spec.ts/mobile/deploy-eslint-plugin.png b/e2e/__snapshots__/post/screen.spec.ts/mobile/deploy-eslint-plugin.png new file mode 100644 index 00000000..9520a563 Binary files /dev/null and b/e2e/__snapshots__/post/screen.spec.ts/mobile/deploy-eslint-plugin.png differ diff --git a/e2e/__snapshots__/post/screen.spec.ts/mobile/geultto8-open-source-seminar.png b/e2e/__snapshots__/post/screen.spec.ts/mobile/geultto8-open-source-seminar.png new file mode 100644 index 00000000..04aa2b66 Binary files /dev/null and b/e2e/__snapshots__/post/screen.spec.ts/mobile/geultto8-open-source-seminar.png differ diff --git a/e2e/__snapshots__/post/screen.spec.ts/mobile/google-adsense.png b/e2e/__snapshots__/post/screen.spec.ts/mobile/google-adsense.png new file mode 100644 index 00000000..89958004 Binary files /dev/null and b/e2e/__snapshots__/post/screen.spec.ts/mobile/google-adsense.png differ diff --git a/e2e/__snapshots__/post/screen.spec.ts/mobile/goorm-195687.png b/e2e/__snapshots__/post/screen.spec.ts/mobile/goorm-195687.png new file mode 100644 index 00000000..d1899ef7 Binary files /dev/null and b/e2e/__snapshots__/post/screen.spec.ts/mobile/goorm-195687.png differ diff --git a/e2e/__snapshots__/post/screen.spec.ts/mobile/goorm-195692.png b/e2e/__snapshots__/post/screen.spec.ts/mobile/goorm-195692.png new file mode 100644 index 00000000..64f2c38a Binary files /dev/null and b/e2e/__snapshots__/post/screen.spec.ts/mobile/goorm-195692.png differ diff --git a/e2e/__snapshots__/post/screen.spec.ts/mobile/goorm-195693.png b/e2e/__snapshots__/post/screen.spec.ts/mobile/goorm-195693.png new file mode 100644 index 00000000..0e0044b4 Binary files /dev/null and b/e2e/__snapshots__/post/screen.spec.ts/mobile/goorm-195693.png differ diff --git a/e2e/__snapshots__/post/screen.spec.ts/mobile/goorm-195696.png b/e2e/__snapshots__/post/screen.spec.ts/mobile/goorm-195696.png new file mode 100644 index 00000000..6dc806c9 Binary files /dev/null and b/e2e/__snapshots__/post/screen.spec.ts/mobile/goorm-195696.png differ diff --git a/e2e/__snapshots__/post/screen.spec.ts/mobile/goorm-195698.png b/e2e/__snapshots__/post/screen.spec.ts/mobile/goorm-195698.png new file mode 100644 index 00000000..2d873e10 Binary files /dev/null and b/e2e/__snapshots__/post/screen.spec.ts/mobile/goorm-195698.png differ diff --git a/e2e/__snapshots__/post/screen.spec.ts/mobile/implicit-coercion.png b/e2e/__snapshots__/post/screen.spec.ts/mobile/implicit-coercion.png new file mode 100644 index 00000000..e81f7b38 Binary files /dev/null and b/e2e/__snapshots__/post/screen.spec.ts/mobile/implicit-coercion.png differ diff --git a/e2e/__snapshots__/post/screen.spec.ts/mobile/inflearn-meetup-03-dev-career.png b/e2e/__snapshots__/post/screen.spec.ts/mobile/inflearn-meetup-03-dev-career.png new file mode 100644 index 00000000..68379f90 Binary files /dev/null and b/e2e/__snapshots__/post/screen.spec.ts/mobile/inflearn-meetup-03-dev-career.png differ diff --git a/e2e/__snapshots__/post/screen.spec.ts/mobile/jeju-remote-work.png b/e2e/__snapshots__/post/screen.spec.ts/mobile/jeju-remote-work.png new file mode 100644 index 00000000..992de1ba Binary files /dev/null and b/e2e/__snapshots__/post/screen.spec.ts/mobile/jeju-remote-work.png differ diff --git a/e2e/__snapshots__/post/screen.spec.ts/mobile/junction2023.png b/e2e/__snapshots__/post/screen.spec.ts/mobile/junction2023.png new file mode 100644 index 00000000..015b6243 Binary files /dev/null and b/e2e/__snapshots__/post/screen.spec.ts/mobile/junction2023.png differ diff --git a/e2e/__snapshots__/post/screen.spec.ts/mobile/leetcode-easy-2727.png b/e2e/__snapshots__/post/screen.spec.ts/mobile/leetcode-easy-2727.png new file mode 100644 index 00000000..6527a393 Binary files /dev/null and b/e2e/__snapshots__/post/screen.spec.ts/mobile/leetcode-easy-2727.png differ diff --git a/e2e/__snapshots__/post/screen.spec.ts/mobile/leetcode-hard-42.png b/e2e/__snapshots__/post/screen.spec.ts/mobile/leetcode-hard-42.png new file mode 100644 index 00000000..7a303d91 Binary files /dev/null and b/e2e/__snapshots__/post/screen.spec.ts/mobile/leetcode-hard-42.png differ diff --git a/e2e/__snapshots__/post/screen.spec.ts/mobile/leetcode-medium-238.png b/e2e/__snapshots__/post/screen.spec.ts/mobile/leetcode-medium-238.png new file mode 100644 index 00000000..c6c198ab Binary files /dev/null and b/e2e/__snapshots__/post/screen.spec.ts/mobile/leetcode-medium-238.png differ diff --git a/e2e/__snapshots__/post/screen.spec.ts/mobile/mac-init-apps.png b/e2e/__snapshots__/post/screen.spec.ts/mobile/mac-init-apps.png new file mode 100644 index 00000000..733043a0 Binary files /dev/null and b/e2e/__snapshots__/post/screen.spec.ts/mobile/mac-init-apps.png differ diff --git a/e2e/__snapshots__/post/screen.spec.ts/mobile/mdn-ko-organizer.png b/e2e/__snapshots__/post/screen.spec.ts/mobile/mdn-ko-organizer.png new file mode 100644 index 00000000..c28bbcc9 Binary files /dev/null and b/e2e/__snapshots__/post/screen.spec.ts/mobile/mdn-ko-organizer.png differ diff --git a/e2e/__snapshots__/post/screen.spec.ts/mobile/micro-state-management-review.png b/e2e/__snapshots__/post/screen.spec.ts/mobile/micro-state-management-review.png new file mode 100644 index 00000000..4ab1c00e Binary files /dev/null and b/e2e/__snapshots__/post/screen.spec.ts/mobile/micro-state-management-review.png differ diff --git a/e2e/__snapshots__/post/screen.spec.ts/mobile/prettier3.png b/e2e/__snapshots__/post/screen.spec.ts/mobile/prettier3.png new file mode 100644 index 00000000..4e3135b7 Binary files /dev/null and b/e2e/__snapshots__/post/screen.spec.ts/mobile/prettier3.png differ diff --git a/e2e/__snapshots__/post/screen.spec.ts/mobile/proving-ground-review.png b/e2e/__snapshots__/post/screen.spec.ts/mobile/proving-ground-review.png new file mode 100644 index 00000000..9729e05c Binary files /dev/null and b/e2e/__snapshots__/post/screen.spec.ts/mobile/proving-ground-review.png differ diff --git a/e2e/__snapshots__/post/screen.spec.ts/mobile/quality-of-job-review.png b/e2e/__snapshots__/post/screen.spec.ts/mobile/quality-of-job-review.png new file mode 100644 index 00000000..722cc271 Binary files /dev/null and b/e2e/__snapshots__/post/screen.spec.ts/mobile/quality-of-job-review.png differ diff --git a/e2e/__snapshots__/post/screen.spec.ts/mobile/renovate.png b/e2e/__snapshots__/post/screen.spec.ts/mobile/renovate.png new file mode 100644 index 00000000..7b878af0 Binary files /dev/null and b/e2e/__snapshots__/post/screen.spec.ts/mobile/renovate.png differ diff --git a/e2e/__snapshots__/post/screen.spec.ts/mobile/storybook7.png b/e2e/__snapshots__/post/screen.spec.ts/mobile/storybook7.png new file mode 100644 index 00000000..1561bb6e Binary files /dev/null and b/e2e/__snapshots__/post/screen.spec.ts/mobile/storybook7.png differ diff --git a/e2e/__snapshots__/post/screen.spec.ts/mobile/turborepo.png b/e2e/__snapshots__/post/screen.spec.ts/mobile/turborepo.png new file mode 100644 index 00000000..b05fd3eb Binary files /dev/null and b/e2e/__snapshots__/post/screen.spec.ts/mobile/turborepo.png differ diff --git a/e2e/__snapshots__/post/screen.spec.ts/mobile/typescript-subtyping.png b/e2e/__snapshots__/post/screen.spec.ts/mobile/typescript-subtyping.png new file mode 100644 index 00000000..c984b322 Binary files /dev/null and b/e2e/__snapshots__/post/screen.spec.ts/mobile/typescript-subtyping.png differ diff --git a/e2e/__snapshots__/post/screen.spec.ts/mobile/typescript5.png b/e2e/__snapshots__/post/screen.spec.ts/mobile/typescript5.png new file mode 100644 index 00000000..faa870b9 Binary files /dev/null and b/e2e/__snapshots__/post/screen.spec.ts/mobile/typescript5.png differ diff --git a/e2e/__snapshots__/post/screen.spec.ts/mobile/udemy-rust-programming.png b/e2e/__snapshots__/post/screen.spec.ts/mobile/udemy-rust-programming.png new file mode 100644 index 00000000..804eee9c Binary files /dev/null and b/e2e/__snapshots__/post/screen.spec.ts/mobile/udemy-rust-programming.png differ diff --git a/e2e/__snapshots__/post/screen.spec.ts/mobile/use-prevent-leave.png b/e2e/__snapshots__/post/screen.spec.ts/mobile/use-prevent-leave.png new file mode 100644 index 00000000..1d8d54d7 Binary files /dev/null and b/e2e/__snapshots__/post/screen.spec.ts/mobile/use-prevent-leave.png differ diff --git a/e2e/__snapshots__/post/screen.spec.ts/mobile/use-transition.png b/e2e/__snapshots__/post/screen.spec.ts/mobile/use-transition.png new file mode 100644 index 00000000..88b85893 Binary files /dev/null and b/e2e/__snapshots__/post/screen.spec.ts/mobile/use-transition.png differ diff --git a/e2e/__snapshots__/post/screen.spec.ts/mobile/visual-regression-test.png b/e2e/__snapshots__/post/screen.spec.ts/mobile/visual-regression-test.png new file mode 100644 index 00000000..d0a5d47f Binary files /dev/null and b/e2e/__snapshots__/post/screen.spec.ts/mobile/visual-regression-test.png differ diff --git a/e2e/__snapshots__/post/screen.spec.ts/mobile/vite-dev-server.png b/e2e/__snapshots__/post/screen.spec.ts/mobile/vite-dev-server.png new file mode 100644 index 00000000..8f807307 Binary files /dev/null and b/e2e/__snapshots__/post/screen.spec.ts/mobile/vite-dev-server.png differ diff --git a/e2e/__snapshots__/post/screen.spec.ts/mobile/woowa-type-review.png b/e2e/__snapshots__/post/screen.spec.ts/mobile/woowa-type-review.png new file mode 100644 index 00000000..f0fac793 Binary files /dev/null and b/e2e/__snapshots__/post/screen.spec.ts/mobile/woowa-type-review.png differ diff --git a/e2e/__snapshots__/posts.spec.ts/desktop/posts.html b/e2e/__snapshots__/posts.spec.ts/desktop/posts.html new file mode 100644 index 00000000..5d518f33 --- /dev/null +++ b/e2e/__snapshots__/posts.spec.ts/desktop/posts.html @@ -0,0 +1 @@ +
JavaScriptRustActivityBookRetrospectToolAlgorithm
\ No newline at end of file diff --git a/e2e/__snapshots__/posts.spec.ts/desktop/posts.png b/e2e/__snapshots__/posts.spec.ts/desktop/posts.png new file mode 100644 index 00000000..0bfb8700 Binary files /dev/null and b/e2e/__snapshots__/posts.spec.ts/desktop/posts.png differ diff --git a/e2e/__snapshots__/posts.spec.ts/mobile/posts.html b/e2e/__snapshots__/posts.spec.ts/mobile/posts.html new file mode 100644 index 00000000..5d518f33 --- /dev/null +++ b/e2e/__snapshots__/posts.spec.ts/mobile/posts.html @@ -0,0 +1 @@ +
JavaScriptRustActivityBookRetrospectToolAlgorithm
\ No newline at end of file diff --git a/e2e/__snapshots__/posts.spec.ts/mobile/posts.png b/e2e/__snapshots__/posts.spec.ts/mobile/posts.png new file mode 100644 index 00000000..0b12f556 Binary files /dev/null and b/e2e/__snapshots__/posts.spec.ts/mobile/posts.png differ diff --git a/e2e/about.spec.ts b/e2e/about.spec.ts new file mode 100644 index 00000000..1819c493 --- /dev/null +++ b/e2e/about.spec.ts @@ -0,0 +1,27 @@ +import { expect, test } from '@playwright/test'; +import { gotoUrl, screenshotFullPage } from './shared/utils'; +import { MACRO_SUITE } from './shared/constants'; + +test.describe('about', () => { + test(MACRO_SUITE.SCREEN_SNAPSHOT, async ({ page }) => { + await screenshotFullPage({ page, url: `/about`, arg: [`about.png`] }); + }); + + test(MACRO_SUITE.DOM_SNAPSHOT, async ({ page }) => { + await gotoUrl({ page, url: '/about' }); + const body = await page.locator('main').innerHTML(); + expect(body).toMatchSnapshot([`about.html`]); + }); + + test(`should exist favicon`, async ({ page }) => { + await gotoUrl({ page, url: '/about' }); + const faviconUrl = await page.evaluate(() => { + const link = document.querySelector( + 'link[rel="icon"]', + ) as HTMLLinkElement | null; + return link?.href ?? ''; + }); + + expect(faviconUrl.endsWith('/favicon/favicon-32x32.png')).toBe(true); + }); +}); diff --git a/e2e/post/common.spec.ts b/e2e/post/common.spec.ts new file mode 100644 index 00000000..d561982e --- /dev/null +++ b/e2e/post/common.spec.ts @@ -0,0 +1,9 @@ +import { expect, test } from '@playwright/test'; +import { urls } from './utils'; + +test.describe('common', () => { + test('Check all post count', () => { + const ALL_POST_COUNT = 36; + expect(urls.length).toEqual(ALL_POST_COUNT); + }); +}); diff --git a/e2e/post/dom.spec.ts b/e2e/post/dom.spec.ts new file mode 100644 index 00000000..261ba371 --- /dev/null +++ b/e2e/post/dom.spec.ts @@ -0,0 +1,16 @@ +import { expect, test } from '@playwright/test'; +import { urls } from './utils'; +import { MACRO_SUITE } from 'e2e/shared/constants'; +import { gotoUrl } from 'e2e/shared/utils'; + +test.describe(MACRO_SUITE.DOM_SNAPSHOT, () => { + for (let i = 0; i < urls.length; i++) { + const url = urls[i]; + + test(`${url}`, async ({ page }) => { + await gotoUrl({ page, url: `/posts/${url}` }); + const body = await page.locator('main').innerHTML(); + expect(body).toMatchSnapshot([`${url}.html`]); + }); + } +}); diff --git a/e2e/post/screen.spec.ts b/e2e/post/screen.spec.ts new file mode 100644 index 00000000..203a8e3f --- /dev/null +++ b/e2e/post/screen.spec.ts @@ -0,0 +1,19 @@ +import { test } from '@playwright/test'; +import { urls } from './utils'; +import { screenshotFullPage } from 'e2e/shared/utils'; +import { MACRO_SUITE } from 'e2e/shared/constants'; + +test.describe(MACRO_SUITE.SCREEN_SNAPSHOT, () => { + for (let i = 0; i < urls.length; i++) { + const url = urls[i]; + + test(`${url}`, async ({ page }) => { + await screenshotFullPage({ + page, + url: `/posts/${url}`, + arg: [`${url}.png`], + timeout: 10 * 1000, + }); + }); + } +}); diff --git a/e2e/post/utils.ts b/e2e/post/utils.ts new file mode 100644 index 00000000..edffb3b1 --- /dev/null +++ b/e2e/post/utils.ts @@ -0,0 +1,4 @@ +import { getAllPosts, urlToSlugMap } from '~/shared/helpers/post'; + +getAllPosts(); +export const urls = Object.keys(urlToSlugMap); diff --git a/e2e/posts.spec.ts b/e2e/posts.spec.ts new file mode 100644 index 00000000..7be74d16 --- /dev/null +++ b/e2e/posts.spec.ts @@ -0,0 +1,15 @@ +import { expect, test } from '@playwright/test'; +import { MACRO_SUITE } from './shared/constants'; +import { gotoUrl, screenshotFullPage } from './shared/utils'; + +test.describe('posts', () => { + test(MACRO_SUITE.DOM_SNAPSHOT, async ({ page }) => { + await gotoUrl({ page, url: '/posts' }); + const body = await page.locator('main').innerHTML(); + expect(body).toMatchSnapshot([`posts.html`]); + }); + + test(MACRO_SUITE.SCREEN_SNAPSHOT, async ({ page }) => { + await screenshotFullPage({ page, url: '/posts', arg: [`posts.png`] }); + }); +}); diff --git a/e2e/shared/constants.ts b/e2e/shared/constants.ts new file mode 100644 index 00000000..64d63fd2 --- /dev/null +++ b/e2e/shared/constants.ts @@ -0,0 +1,5 @@ +// https://stackoverflow.com/a/76497222 +export enum MACRO_SUITE { + DOM_SNAPSHOT = '@dom-snapshot', + SCREEN_SNAPSHOT = '@screen-snapshot', +} diff --git a/e2e/shared/utils.ts b/e2e/shared/utils.ts new file mode 100644 index 00000000..5406e8e2 --- /dev/null +++ b/e2e/shared/utils.ts @@ -0,0 +1,85 @@ +import { expect, type Page } from '@playwright/test'; + +type ScreenshotOptions = { + fullPage: boolean; + timeout?: number; + clip?: { x: number; y: number; width: number; height: number }; +}; + +export const gotoUrl = async ({ + page, + url, + timeout = 30_000, +}: { + page: Page; + url: string; + timeout?: number; +}) => { + await page.goto(url, { timeout }); + await page.evaluate(() => document.fonts.ready); +}; + +export const waitImages = async ({ page }: { page: Page }) => { + // Step 1. 모든 이미지 로딩을 기다림 + // https://stackoverflow.com/questions/77287441/how-to-wait-for-full-rendered-image-in-playwright + const locators = page.locator('img'); + const scrollPromises = (await locators.all()).map(async (locator) => { + // https://playwright.dev/docs/api/class-locator#locator-scroll-into-view-if-needed + // 이미지 요소가 준비되었는지 확인 + return await locator.scrollIntoViewIfNeeded(); + }); + await Promise.all(scrollPromises); + // Set up listeners concurrently + const imgLoadingPromises = (await locators.all()).map((locator) => { + return locator.evaluate((image) => { + // 로드는 성공했으나 이미지 크기가 0이므로 정상적인 이미지 로딩에 실패 + if (image.complete && image.naturalWidth === 0) { + throw new Error(`\nImage Load failure: [${image.src}]`); + } + return ( + image.complete || new Promise((resolve) => (image.onload = resolve)) + ); + }); + }); + // Wait for all once + await Promise.all(imgLoadingPromises); + + // Step 2. body 기준 뷰포트 설정 + // 스크롤바 커스텀으로인해 window가 아닌 body에 스크롤이 걸려있음. body 컴포넌트 크기로 뷰포트 변경 + const viewportSize = await page.locator('body').evaluate((body) => { + return { + width: body.scrollWidth, + height: body.scrollHeight, + }; + }); + await page.setViewportSize(viewportSize); + + // Step 3. 최상단으로 스크롤 이동 + // https://github.com/microsoft/playwright/issues/18827#issuecomment-2015560128 + await page.evaluate(() => document.body.scrollTo(0, 0)); + await page.waitForFunction(() => document.body.scrollTop === 0); +}; + +export const screenshotFullPage = async ({ + page, + url, + arg, + timeout, +}: { + page: Page; + url: string; + arg: string[]; + timeout?: number; +}) => { + await gotoUrl({ page, url }); + await waitImages({ page }); + + const options: ScreenshotOptions = { + fullPage: true, + }; + if (timeout >= 0) { + options.timeout = timeout; + } + + await expect(page).toHaveScreenshot([...arg], options); +}; diff --git a/eslint.config.js b/eslint.config.js new file mode 100644 index 00000000..0ccc0821 --- /dev/null +++ b/eslint.config.js @@ -0,0 +1,17 @@ +// @ts-check + +import eslintConfigPrettier from 'eslint-config-prettier'; +import eslint from '@eslint/js'; +import tseslint from 'typescript-eslint'; + +export default tseslint.config( + eslint.configs.recommended, + eslintConfigPrettier, + { ignores: ['.next', 'playwright-report', 'out', '*.cjs', 'test-results'] }, + { + rules: { + '@typescript-eslint/consistent-type-imports': 'error', + }, + }, + ...tseslint.configs.recommended, +); diff --git a/jest.config.ts b/jest.config.ts new file mode 100644 index 00000000..4530bb72 --- /dev/null +++ b/jest.config.ts @@ -0,0 +1,207 @@ +/** + * For a detailed explanation regarding each configuration property, visit: + * https://jestjs.io/docs/configuration + */ + +import type { Config } from 'jest'; +import nextJest from 'next/jest.js'; + +const createJestConfig = nextJest({ + // Provide the path to your Next.js app to load next.config.js and .env files in your test environment + dir: './', +}); + +const config: Config = { + // All imported modules in your tests should be mocked automatically + // automock: false, + + // Stop running tests after `n` failures + // bail: 0, + + // The directory where Jest should store its cached dependency information + // cacheDirectory: "/private/var/folders/wq/lxb5ss_94cg73hgt018j31z80000gp/T/jest_dy", + + // Automatically clear mock calls, instances, contexts and results before every test + clearMocks: true, + + // Indicates whether the coverage information should be collected while executing the test + collectCoverage: true, + + // An array of glob patterns indicating a set of files for which coverage information should be collected + // collectCoverageFrom: undefined, + + // The directory where Jest should output its coverage files + coverageDirectory: 'coverage', + + // An array of regexp pattern strings used to skip coverage collection + // coveragePathIgnorePatterns: [ + // "/node_modules/" + // ], + + // Indicates which provider should be used to instrument code for coverage + coverageProvider: 'v8', + + // A list of reporter names that Jest uses when writing coverage reports + // coverageReporters: [ + // "json", + // "text", + // "lcov", + // "clover" + // ], + + // An object that configures minimum threshold enforcement for coverage results + // coverageThreshold: undefined, + + // A path to a custom dependency extractor + // dependencyExtractor: undefined, + + // Make calling deprecated APIs throw helpful error messages + // errorOnDeprecated: false, + + // The default configuration for fake timers + // fakeTimers: { + // "enableGlobally": false + // }, + + // Force coverage collection from ignored files using an array of glob patterns + // forceCoverageMatch: [], + + // A path to a module which exports an async function that is triggered once before all test suites + // globalSetup: undefined, + + // A path to a module which exports an async function that is triggered once after all test suites + // globalTeardown: undefined, + + // A set of global variables that need to be available in all test environments + // globals: {}, + + // The maximum amount of workers used to run your tests. Can be specified as % or a number. E.g. maxWorkers: 10% will use 10% of your CPU amount + 1 as the maximum worker number. maxWorkers: 2 will use a maximum of 2 workers. + // maxWorkers: "50%", + + // An array of directory names to be searched recursively up from the requiring module's location + // moduleDirectories: [ + // "node_modules" + // ], + + // An array of file extensions your modules use + // moduleFileExtensions: [ + // "js", + // "mjs", + // "cjs", + // "jsx", + // "ts", + // "tsx", + // "json", + // "node" + // ], + + // A map from regular expressions to module names or to arrays of module names that allow to stub out resources with a single module + moduleNameMapper: { + '^~/(.*)$': ['/src/features/*'], + '^~/app/(.*)$': ['/src/app/*'], + '^~/styles/(.*)$': ['/src/styles/*'], + }, + + // An array of regexp pattern strings, matched against all module paths before considered 'visible' to the module loader + // modulePathIgnorePatterns: [], + + // Activates notifications for test results + // notify: false, + + // An enum that specifies notification mode. Requires { notify: true } + // notifyMode: "failure-change", + + // A preset that is used as a base for Jest's configuration + // preset: undefined, + + // Run tests from one or more projects + // projects: undefined, + + // Use this configuration option to add custom reporters to Jest + // reporters: undefined, + + // Automatically reset mock state before every test + // resetMocks: false, + + // Reset the module registry before running each individual test + // resetModules: false, + + // A path to a custom resolver + // resolver: undefined, + + // Automatically restore mock state and implementation before every test + // restoreMocks: false, + + // The root directory that Jest should scan for tests and modules within + // rootDir: undefined, + + // A list of paths to directories that Jest should use to search for files in + // roots: [ + // "" + // ], + + // Allows you to use a custom runner instead of Jest's default test runner + // runner: "jest-runner", + + // The paths to modules that run some code to configure or set up the testing environment before each test + // setupFiles: [], + + // A list of paths to modules that run some code to configure or set up the testing framework before each test + setupFilesAfterEnv: ['/jest.setup.ts'], + + // The number of seconds after which a test is considered as slow and reported as such in the results. + // slowTestThreshold: 5, + + // A list of paths to snapshot serializer modules Jest should use for snapshot testing + // snapshotSerializers: [], + + // The test environment that will be used for testing + testEnvironment: 'jest-environment-jsdom', + + // Options that will be passed to the testEnvironment + // testEnvironmentOptions: {}, + + // Adds a location field to test results + // testLocationInResults: false, + + // The glob patterns Jest uses to detect test files + testMatch: [ + // '**/__tests__/**/*.[jt]s?(x)', + '**/src/**/?(*.)+(spec|test).[tj]s?(x)', + ], + + // An array of regexp pattern strings that are matched against all test paths, matched tests are skipped + testPathIgnorePatterns: ['/node_modules/', '/e2e/'], + + // The regexp pattern or array of patterns that Jest uses to detect test files + // testRegex: [], + + // This option allows the use of a custom results processor + // testResultsProcessor: undefined, + + // This option allows use of a custom test runner + // testRunner: "jest-circus/runner", + + // A map from regular expressions to paths to transformers + // transform: undefined, + + // An array of regexp pattern strings that are matched against all source file paths, matched files will skip transformation + // transformIgnorePatterns: [ + // "/node_modules/", + // "\\.pnp\\.[^\\/]+$" + // ], + + // An array of regexp pattern strings that are matched against all modules before the module loader will automatically return a mock for them + // unmockedModulePathPatterns: undefined, + + // Indicates whether each individual test should be reported during the run + // verbose: undefined, + + // An array of regexp patterns that are matched against all source file paths before re-running tests in watch mode + // watchPathIgnorePatterns: [], + + // Whether to use watchman for file crawling + // watchman: true, +}; + +export default createJestConfig(config); diff --git a/jest.setup.ts b/jest.setup.ts new file mode 100644 index 00000000..d063f9e4 --- /dev/null +++ b/jest.setup.ts @@ -0,0 +1,21 @@ +import '@testing-library/jest-dom'; +import '@testing-library/user-event'; + +// Mock IntersectionObserver +class IntersectionObserver { + observe = jest.fn(); + disconnect = jest.fn(); + unobserve = jest.fn(); +} + +Object.defineProperty(window, 'IntersectionObserver', { + writable: true, + configurable: true, + value: IntersectionObserver, +}); + +Object.defineProperty(global, 'IntersectionObserver', { + writable: true, + configurable: true, + value: IntersectionObserver, +}); diff --git a/next-env.d.ts b/next-env.d.ts new file mode 100644 index 00000000..4f11a03d --- /dev/null +++ b/next-env.d.ts @@ -0,0 +1,5 @@ +/// +/// + +// NOTE: This file should not be edited +// see https://nextjs.org/docs/basic-features/typescript for more information. diff --git a/next.config.mjs b/next.config.mjs new file mode 100644 index 00000000..f1aed0e2 --- /dev/null +++ b/next.config.mjs @@ -0,0 +1,30 @@ +import path from 'path'; +import { PHASE_PRODUCTION_BUILD } from 'next/constants.js'; +import { fileURLToPath } from 'url'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +export default (phase) => { + /** @type {import('next').NextConfig} */ + const nextConfig = { + reactStrictMode: true, + experimental: { + // this includes files from the monorepo base two directories up + // outputFileTracingRoot: path.join(__dirname, "../../packages/content/posts"__dirname), + }, + images: { + loader: 'akamai', + path: '/', + }, + sassOptions: { + includePaths: [path.join(__dirname, 'styles')], + }, + }; + + if (phase === PHASE_PRODUCTION_BUILD) { + nextConfig.output = 'export'; + } + + return nextConfig; +}; diff --git a/package.json b/package.json new file mode 100644 index 00000000..3f6f9230 --- /dev/null +++ b/package.json @@ -0,0 +1,85 @@ +{ + "name": "1ilsang.dev", + "packageManager": "pnpm@9.7.1", + "private": true, + "version": "0.0.0", + "author": "1ilsang <1ilsang@naver.com>", + "description": "https://1ilsang.dev", + "license": "MIT", + "type": "module", + "scripts": { + "dev": "next dev", + "build": "next build", + "start": "next start", + "lint:stylelint": "stylelint --config './.stylelintrc.cjs' './src/**/*.scss'", + "lint:prettier": "prettier --check './src/**/*.{json,html,yml,tsx,ts,js,jsx,scss}'", + "lint:eslint": "eslint", + "lint:markdown": "markdownlint --config './.markdownlint.cjs' './_posts/**/*.{md,mdx}'", + "lint:typeCheck": "tsc --pretty", + "lint": "pnpm run '/^lint:.*/'", + "e2e:build": "NEXT_PUBLIC_E2E=true next build", + "e2e:update:screen": "pnpm playwright test --grep '@screen-snapshot' --update-snapshots", + "e2e:update": "pnpm playwright test --update-snapshots", + "e2e:report": "pnpm exec playwright show-report", + "e2e:install": "pnpm exec playwright install --with-deps", + "e2e:others": "pnpm playwright test --grep-invert /@/", + "e2e:dom": "pnpm playwright test --grep '@dom-snapshot'", + "e2e:screen": "pnpm playwright test --grep '@screen-snapshot'", + "e2e": "pnpm playwright test", + "coverage": "jest --coverage", + "jest:watch": "jest --watch", + "jest": "jest", + "clean": "rm -rf .next out node_modules", + "knip": "pnpm dlx knip", + "nf": "netlify", + "prepare": "husky", + "preinstall": "npx only-allow pnpm" + }, + "dependencies": { + "classnames": "^2.5.1", + "gray-matter": "4.0.3", + "jotai": "^2.9.0", + "next": "14.2.5", + "react": "18.3.1", + "react-dom": "18.3.1", + "rehype-autolink-headings": "^7.1.0", + "rehype-pretty-code": "^0.13.2", + "rehype-slug": "^6.0.0", + "rehype-stringify": "^10.0.0", + "remark-gfm": "^4.0.0", + "remark-html": "16.0.1", + "remark-parse": "^11.0.0", + "remark-rehype": "^11.1.0", + "unified": "^11.0.5" + }, + "devDependencies": { + "@eslint/js": "^9.8.0", + "@playwright/test": "^1.45.1", + "@testing-library/dom": "^10.4.0", + "@testing-library/jest-dom": "^6.4.8", + "@testing-library/react": "^16.0.0", + "@testing-library/user-event": "^14.5.2", + "@types/eslint__js": "^8.42.3", + "@types/jest": "^29.5.12", + "@types/node": "20.15.0", + "@types/react": "18.3.3", + "@types/react-dom": "18.3.0", + "autoprefixer": "10.4.20", + "eslint": "^9.8.0", + "eslint-config-prettier": "^9.1.0", + "husky": "9.1.4", + "jest": "^29.7.0", + "jest-environment-jsdom": "^29.7.0", + "markdownlint": "0.34.0", + "markdownlint-cli": "0.41.0", + "netlify-cli": "^17.33.3", + "postcss": "^8.4.39", + "prettier": "^3.3.3", + "sass": "1.77.8", + "stylelint": "^16.7.0", + "stylelint-config-standard-scss": "^13.1.0", + "tailwindcss": "3.4.10", + "typescript": "5.5.4", + "typescript-eslint": "^8.0.1" + } +} diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 00000000..81571248 --- /dev/null +++ b/playwright.config.ts @@ -0,0 +1,68 @@ +import { defineConfig, devices } from '@playwright/test'; + +/** + * Read environment variables from file. + * https://github.com/motdotla/dotenv + */ +// require('dotenv').config(); + +/** + * See https://playwright.dev/docs/test-configuration. + */ +export default defineConfig({ + testDir: './e2e', + + /* https://playwright.dev/docs/api/class-testconfig#test-config-snapshot-path-template */ + snapshotPathTemplate: + '{testDir}/__snapshots__/{testFilePath}/{projectName}/{arg}{ext}', + + /* Run tests in files in parallel */ + fullyParallel: true, + /* Fail the build on CI if you accidentally left test.only in the source code. */ + forbidOnly: !!process.env.CI, + /* Retry on CI only */ + retries: 0, + /* Opt out of parallel tests on CI. */ + workers: process.env.CI ? 1 : undefined, + /* Reporter to use. See https://playwright.dev/docs/test-reporters */ + reporter: 'html', + /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ + use: { + /* Base URL to use in actions like `await page.goto('/')`. */ + baseURL: 'http://127.0.0.1:3000', + browserName: 'webkit', + /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */ + trace: 'off', + timezoneId: 'Asia/Seoul', + }, + + /* Configure projects for major browsers */ + projects: [ + { + name: 'desktop', + use: { + ...devices['Desktop Safari'], + viewport: { width: 1321, height: 1080 }, + }, + }, + { + name: 'mobile', + use: { ...devices['iPhone 14'] }, + }, + ], + + /* Run your local dev server before starting the tests */ + webServer: { + command: 'npm run start', + url: 'http://127.0.0.1:3000', + timeout: 3 * 1000, + reuseExistingServer: false, + }, + + /* For global expect settings */ + expect: { + toHaveScreenshot: { + maxDiffPixelRatio: 0.1, + }, + }, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml new file mode 100644 index 00000000..5e91a373 --- /dev/null +++ b/pnpm-lock.yaml @@ -0,0 +1,13506 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + classnames: + specifier: ^2.5.1 + version: 2.5.1 + gray-matter: + specifier: 4.0.3 + version: 4.0.3 + jotai: + specifier: ^2.9.0 + version: 2.9.3(@types/react@18.3.3)(react@18.3.1) + next: + specifier: 14.2.5 + version: 14.2.5(@babel/core@7.25.2)(@opentelemetry/api@1.8.0)(@playwright/test@1.46.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.77.8) + react: + specifier: 18.3.1 + version: 18.3.1 + react-dom: + specifier: 18.3.1 + version: 18.3.1(react@18.3.1) + rehype-autolink-headings: + specifier: ^7.1.0 + version: 7.1.0 + rehype-pretty-code: + specifier: ^0.13.2 + version: 0.13.2(shiki@1.3.0) + rehype-slug: + specifier: ^6.0.0 + version: 6.0.0 + rehype-stringify: + specifier: ^10.0.0 + version: 10.0.0 + remark-gfm: + specifier: ^4.0.0 + version: 4.0.0 + remark-html: + specifier: 16.0.1 + version: 16.0.1 + remark-parse: + specifier: ^11.0.0 + version: 11.0.0 + remark-rehype: + specifier: ^11.1.0 + version: 11.1.0 + unified: + specifier: ^11.0.5 + version: 11.0.5 + devDependencies: + '@eslint/js': + specifier: ^9.8.0 + version: 9.9.0 + '@playwright/test': + specifier: ^1.45.1 + version: 1.46.1 + '@testing-library/dom': + specifier: ^10.4.0 + version: 10.4.0 + '@testing-library/jest-dom': + specifier: ^6.4.8 + version: 6.4.8 + '@testing-library/react': + specifier: ^16.0.0 + version: 16.0.0(@testing-library/dom@10.4.0)(@types/react-dom@18.3.0)(@types/react@18.3.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@testing-library/user-event': + specifier: ^14.5.2 + version: 14.5.2(@testing-library/dom@10.4.0) + '@types/eslint__js': + specifier: ^8.42.3 + version: 8.42.3 + '@types/jest': + specifier: ^29.5.12 + version: 29.5.12 + '@types/node': + specifier: 20.15.0 + version: 20.15.0 + '@types/react': + specifier: 18.3.3 + version: 18.3.3 + '@types/react-dom': + specifier: 18.3.0 + version: 18.3.0 + autoprefixer: + specifier: 10.4.20 + version: 10.4.20(postcss@8.4.41) + eslint: + specifier: ^9.8.0 + version: 9.9.0(jiti@1.21.6) + eslint-config-prettier: + specifier: ^9.1.0 + version: 9.1.0(eslint@9.9.0(jiti@1.21.6)) + husky: + specifier: 9.1.4 + version: 9.1.4 + jest: + specifier: ^29.7.0 + version: 29.7.0(@types/node@20.15.0)(ts-node@10.9.2(@types/node@20.15.0)(typescript@5.5.4)) + jest-environment-jsdom: + specifier: ^29.7.0 + version: 29.7.0 + markdownlint: + specifier: 0.34.0 + version: 0.34.0 + markdownlint-cli: + specifier: 0.41.0 + version: 0.41.0 + netlify-cli: + specifier: ^17.33.3 + version: 17.34.1(@types/node@20.15.0)(picomatch@4.0.2) + postcss: + specifier: ^8.4.39 + version: 8.4.41 + prettier: + specifier: ^3.3.3 + version: 3.3.3 + sass: + specifier: 1.77.8 + version: 1.77.8 + stylelint: + specifier: ^16.7.0 + version: 16.8.2(typescript@5.5.4) + stylelint-config-standard-scss: + specifier: ^13.1.0 + version: 13.1.0(postcss@8.4.41)(stylelint@16.8.2(typescript@5.5.4)) + tailwindcss: + specifier: 3.4.10 + version: 3.4.10(ts-node@10.9.2(@types/node@20.15.0)(typescript@5.5.4)) + typescript: + specifier: 5.5.4 + version: 5.5.4 + typescript-eslint: + specifier: ^8.0.1 + version: 8.1.0(eslint@9.9.0(jiti@1.21.6))(typescript@5.5.4) + +packages: + + '@adobe/css-tools@4.4.0': + resolution: {integrity: sha512-Ff9+ksdQQB3rMncgqDK78uLznstjyfIf2Arnh22pW8kBpLs6rpKDwgnZT46hin5Hl1WzazzK64DOrhSwYpS7bQ==} + + '@alloc/quick-lru@5.2.0': + resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==} + engines: {node: '>=10'} + + '@ampproject/remapping@2.3.0': + resolution: {integrity: sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==} + engines: {node: '>=6.0.0'} + + '@babel/code-frame@7.24.7': + resolution: {integrity: sha512-BcYH1CVJBO9tvyIZ2jVeXgSIMvGZ2FDRvDdOIVQyuklNKSsx+eppDEBq/g47Ayw+RqNFE+URvOShmf+f/qwAlA==} + engines: {node: '>=6.9.0'} + + '@babel/compat-data@7.25.2': + resolution: {integrity: sha512-bYcppcpKBvX4znYaPEeFau03bp89ShqNMLs+rmdptMw+heSZh9+z84d2YG+K7cYLbWwzdjtDoW/uqZmPjulClQ==} + engines: {node: '>=6.9.0'} + + '@babel/core@7.25.2': + resolution: {integrity: sha512-BBt3opiCOxUr9euZ5/ro/Xv8/V7yJ5bjYMqG/C1YAo8MIKAnumZalCN+msbci3Pigy4lIQfPUpfMM27HMGaYEA==} + engines: {node: '>=6.9.0'} + + '@babel/generator@7.25.0': + resolution: {integrity: sha512-3LEEcj3PVW8pW2R1SR1M89g/qrYk/m/mB/tLqn7dn4sbBUQyTqnlod+II2U4dqiGtUmkcnAmkMDralTFZttRiw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-compilation-targets@7.25.2': + resolution: {integrity: sha512-U2U5LsSaZ7TAt3cfaymQ8WHh0pxvdHoEk6HVpaexxixjyEquMh0L0YNJNM6CTGKMXV1iksi0iZkGw4AcFkPaaw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-imports@7.24.7': + resolution: {integrity: sha512-8AyH3C+74cgCVVXow/myrynrAGv+nTVg5vKu2nZph9x7RcRwzmh0VFallJuFTZ9mx6u4eSdXZfcOzSqTUm0HCA==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-transforms@7.25.2': + resolution: {integrity: sha512-BjyRAbix6j/wv83ftcVJmBt72QtHI56C7JXZoG2xATiLpmoC7dpd8WnkikExHDVPpi/3qCmO6WY1EaXOluiecQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/helper-plugin-utils@7.24.8': + resolution: {integrity: sha512-FFWx5142D8h2Mgr/iPVGH5G7w6jDn4jUSpZTyDnQO0Yn7Ks2Kuz6Pci8H6MPCoUJegd/UZQ3tAvfLCxQSnWWwg==} + engines: {node: '>=6.9.0'} + + '@babel/helper-simple-access@7.24.7': + resolution: {integrity: sha512-zBAIvbCMh5Ts+b86r/CjU+4XGYIs+R1j951gxI3KmmxBMhCg4oQMsv6ZXQ64XOm/cvzfU1FmoCyt6+owc5QMYg==} + engines: {node: '>=6.9.0'} + + '@babel/helper-string-parser@7.24.8': + resolution: {integrity: sha512-pO9KhhRcuUyGnJWwyEgnRJTSIZHiT+vMD0kPeD+so0l7mxkMT19g3pjY9GTnHySck/hDzq+dtW/4VgnMkippsQ==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-identifier@7.24.7': + resolution: {integrity: sha512-rR+PBcQ1SMQDDyF6X0wxtG8QyLCgUB0eRAGguqRLfkCA87l7yAP7ehq8SNj96OOGTO8OBV70KhuFYcIkHXOg0w==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-option@7.24.8': + resolution: {integrity: sha512-xb8t9tD1MHLungh/AIoWYN+gVHaB9kwlu8gffXGSt3FFEIT7RjS+xWbc2vUD1UTZdIpKj/ab3rdqJ7ufngyi2Q==} + engines: {node: '>=6.9.0'} + + '@babel/helpers@7.25.0': + resolution: {integrity: sha512-MjgLZ42aCm0oGjJj8CtSM3DB8NOOf8h2l7DCTePJs29u+v7yO/RBX9nShlKMgFnRks/Q4tBAe7Hxnov9VkGwLw==} + engines: {node: '>=6.9.0'} + + '@babel/highlight@7.24.7': + resolution: {integrity: sha512-EStJpq4OuY8xYfhGVXngigBJRWxftKX9ksiGDnmlY3o7B/V7KIAc9X4oiK87uPJSc/vs5L869bem5fhZa8caZw==} + engines: {node: '>=6.9.0'} + + '@babel/parser@7.25.3': + resolution: {integrity: sha512-iLTJKDbJ4hMvFPgQwwsVoxtHyWpKKPBrxkANrSYewDPaPpT5py5yeVkgPIJ7XYXhndxJpaA3PyALSXQ7u8e/Dw==} + engines: {node: '>=6.0.0'} + hasBin: true + + '@babel/plugin-syntax-async-generators@7.8.4': + resolution: {integrity: sha512-tycmZxkGfZaxhMRbXlPXuVFpdWlXpir2W4AMhSJgRKzk/eDlIXOhb2LHWoLpDF7TEHylV5zNhykX6KAgHJmTNw==} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-bigint@7.8.3': + resolution: {integrity: sha512-wnTnFlG+YxQm3vDxpGE57Pj0srRU4sHE/mDkt1qv2YJJSeUAec2ma4WLUnUPeKjyrfntVwe/N6dCXpU+zL3Npg==} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-class-properties@7.12.13': + resolution: {integrity: sha512-fm4idjKla0YahUNgFNLCB0qySdsoPiZP3iQE3rky0mBUtMZ23yDJ9SJdg6dXTSDnulOVqiF3Hgr9nbXvXTQZYA==} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-import-meta@7.10.4': + resolution: {integrity: sha512-Yqfm+XDx0+Prh3VSeEQCPU81yC+JWZ2pDPFSS4ZdpfZhp4MkFMaDC1UqseovEKwSUpnIL7+vK+Clp7bfh0iD7g==} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-json-strings@7.8.3': + resolution: {integrity: sha512-lY6kdGpWHvjoe2vk4WrAapEuBR69EMxZl+RoGRhrFGNYVK8mOPAW8VfbT/ZgrFbXlDNiiaxQnAtgVCZ6jv30EA==} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-jsx@7.24.7': + resolution: {integrity: sha512-6ddciUPe/mpMnOKv/U+RSd2vvVy+Yw/JfBB0ZHYjEZt9NLHmCUylNYlsbqCCS1Bffjlb0fCwC9Vqz+sBz6PsiQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-logical-assignment-operators@7.10.4': + resolution: {integrity: sha512-d8waShlpFDinQ5MtvGU9xDAOzKH47+FFoney2baFIoMr952hKOLp1HR7VszoZvOsV/4+RRszNY7D17ba0te0ig==} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-nullish-coalescing-operator@7.8.3': + resolution: {integrity: sha512-aSff4zPII1u2QD7y+F8oDsz19ew4IGEJg9SVW+bqwpwtfFleiQDMdzA/R+UlWDzfnHFCxxleFT0PMIrR36XLNQ==} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-numeric-separator@7.10.4': + resolution: {integrity: sha512-9H6YdfkcK/uOnY/K7/aA2xpzaAgkQn37yzWUMRK7OaPOqOpGS1+n0H5hxT9AUw9EsSjPW8SVyMJwYRtWs3X3ug==} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-object-rest-spread@7.8.3': + resolution: {integrity: sha512-XoqMijGZb9y3y2XskN+P1wUGiVwWZ5JmoDRwx5+3GmEplNyVM2s2Dg8ILFQm8rWM48orGy5YpI5Bl8U1y7ydlA==} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-optional-catch-binding@7.8.3': + resolution: {integrity: sha512-6VPD0Pc1lpTqw0aKoeRTMiB+kWhAoT24PA+ksWSBrFtl5SIRVpZlwN3NNPQjehA2E/91FV3RjLWoVTglWcSV3Q==} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-optional-chaining@7.8.3': + resolution: {integrity: sha512-KoK9ErH1MBlCPxV0VANkXW2/dw4vlbGDrFgz8bmUsBGYkFRcbRwMh6cIJubdPrkxRwuGdtCk0v/wPTKbQgBjkg==} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-top-level-await@7.14.5': + resolution: {integrity: sha512-hx++upLv5U1rgYfwe1xBQUhRmU41NEvpUvrp8jkrSCdvGSnM5/qdRMtylJ6PG5OFkBaHkbTAKTnd3/YyESRHFw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-typescript@7.24.7': + resolution: {integrity: sha512-c/+fVeJBB0FeKsFvwytYiUD+LBvhHjGSI0g446PRGdSVGZLRNArBUno2PETbAly3tpiNAQR5XaZ+JslxkotsbA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/runtime@7.25.0': + resolution: {integrity: sha512-7dRy4DwXwtzBrPbZflqxnvfxLF8kdZXPkhymtDeFoFqE6ldzjQFgYTtYIFARcLEYDrqfBfYcZt1WqFxRoyC9Rw==} + engines: {node: '>=6.9.0'} + + '@babel/template@7.25.0': + resolution: {integrity: sha512-aOOgh1/5XzKvg1jvVz7AVrx2piJ2XBi227DHmbY6y+bM9H2FlN+IfecYu4Xl0cNiiVejlsCri89LUsbj8vJD9Q==} + engines: {node: '>=6.9.0'} + + '@babel/traverse@7.25.3': + resolution: {integrity: sha512-HefgyP1x754oGCsKmV5reSmtV7IXj/kpaE1XYY+D9G5PvKKoFfSbiS4M77MdjuwlZKDIKFCffq9rPU+H/s3ZdQ==} + engines: {node: '>=6.9.0'} + + '@babel/types@7.25.2': + resolution: {integrity: sha512-YTnYtra7W9e6/oAZEHj0bJehPRUlLH9/fbpT5LfB0NhQXyALCRkRs3zH9v07IYhkgpqX6Z78FnuccZr/l4Fs4Q==} + engines: {node: '>=6.9.0'} + + '@bcoe/v8-coverage@0.2.3': + resolution: {integrity: sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==} + + '@bugsnag/browser@7.25.0': + resolution: {integrity: sha512-PzzWy5d9Ly1CU1KkxTB6ZaOw/dO+CYSfVtqxVJccy832e6+7rW/dvSw5Jy7rsNhgcKSKjZq86LtNkPSvritOLA==} + + '@bugsnag/core@7.25.0': + resolution: {integrity: sha512-JZLak1b5BVzy77CPcklViZrppac/pE07L3uSDmfSvFYSCGReXkik2txOgV05VlF9EDe36dtUAIIV7iAPDfFpQQ==} + + '@bugsnag/cuid@3.1.1': + resolution: {integrity: sha512-d2z4b0rEo3chI07FNN1Xds8v25CNeekecU6FC/2Fs9MxY2EipkZTThVcV2YinMn8dvRUlViKOyC50evoUxg8tw==} + + '@bugsnag/js@7.25.0': + resolution: {integrity: sha512-d8n8SyKdRUz8jMacRW1j/Sj/ckhKbIEp49+Dacp3CS8afRgfMZ//NXhUFFXITsDP5cXouaejR9fx4XVapYXNgg==} + + '@bugsnag/node@7.25.0': + resolution: {integrity: sha512-KlxBaJ8EREEsfKInybAjTO9LmdDXV3cUH5+XNXyqUZrcRVuPOu4j4xvljh+n24ifok/wbFZTKVXUzrN4iKIeIA==} + + '@bugsnag/safe-json-stringify@6.0.0': + resolution: {integrity: sha512-htzFO1Zc57S8kgdRK9mLcPVTW1BY2ijfH7Dk2CeZmspTWKdKqSo1iwmqrq2WtRjFlo8aRZYgLX0wFrDXF/9DLA==} + + '@colors/colors@1.6.0': + resolution: {integrity: sha512-Ir+AOibqzrIsL6ajt3Rz3LskB7OiMVHqltZmspbW/TJuTVuyOMirVqAkjfY6JISiLHgyNqicAC8AyHHGzNd/dA==} + engines: {node: '>=0.1.90'} + + '@cspotcode/source-map-support@0.8.1': + resolution: {integrity: sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==} + engines: {node: '>=12'} + + '@csstools/css-parser-algorithms@3.0.0': + resolution: {integrity: sha512-20hEErXV9GEx15qRbsJVzB91ryayx1F2duHPBrfZXQAHz/dJG0u/611URpr28+sFjm3EI7U17Pj9SVA9NSAGJA==} + engines: {node: '>=18'} + peerDependencies: + '@csstools/css-tokenizer': ^3.0.0 + + '@csstools/css-tokenizer@3.0.0': + resolution: {integrity: sha512-efZvfJyYrqH9hPCKtOBywlTsCXnEzAI9sLHFzUsDpBb+1bQ+bxJnwL9V2bRKv9w4cpIp75yxGeZRaVKoMQnsEg==} + engines: {node: '>=18'} + + '@csstools/media-query-list-parser@3.0.0': + resolution: {integrity: sha512-W0JlkUFwXjo703wt06AcaWuUcS+6x6IEDyxV6W65Sw+vLCYp+uPsrps+PXTiIfN0V1Pqj5snPzN7EYLmbz1zjg==} + engines: {node: '>=18'} + peerDependencies: + '@csstools/css-parser-algorithms': ^3.0.0 + '@csstools/css-tokenizer': ^3.0.0 + + '@csstools/selector-specificity@4.0.0': + resolution: {integrity: sha512-189nelqtPd8++phaHNwYovKZI0FOzH1vQEE3QhHHkNIGrg5fSs9CbYP3RvfEH5geztnIA9Jwq91wyOIwAW5JIQ==} + engines: {node: '>=18'} + peerDependencies: + postcss-selector-parser: ^6.1.0 + + '@dabh/diagnostics@2.0.3': + resolution: {integrity: sha512-hrlQOIi7hAfzsMqlGSFyVucrx38O+j6wiGOf//H2ecvIEqYN4ADBSS2iLMh5UFyDunCNniUIPk/q3riFv45xRA==} + + '@dependents/detective-less@4.1.0': + resolution: {integrity: sha512-KrkT6qO5NxqNfy68sBl6CTSoJ4SNDIS5iQArkibhlbGU4LaDukZ3q2HIkh8aUKDio6o4itU4xDR7t82Y2eP1Bg==} + engines: {node: '>=14'} + + '@dual-bundle/import-meta-resolve@4.1.0': + resolution: {integrity: sha512-+nxncfwHM5SgAtrVzgpzJOI1ol0PkumhVo469KCf9lUi21IGcY90G98VuHm9VRrUypmAzawAHO9bs6hqeADaVg==} + + '@esbuild/aix-ppc64@0.19.11': + resolution: {integrity: sha512-FnzU0LyE3ySQk7UntJO4+qIiQgI7KoODnZg5xzXIrFJlKd2P2gwHsHY4927xj9y5PJmJSzULiUCWmv7iWnNa7g==} + engines: {node: '>=12'} + cpu: [ppc64] + os: [aix] + + '@esbuild/aix-ppc64@0.21.2': + resolution: {integrity: sha512-/c7hocx0pm14bHQlqUVKmxwdT/e5/KkyoY1W8F9lk/8CkE037STDDz8PXUP/LE6faj2HqchvDs9GcShxFhI78Q==} + engines: {node: '>=12'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.19.11': + resolution: {integrity: sha512-aiu7K/5JnLj//KOnOfEZ0D90obUkRzDMyqd/wNAUQ34m4YUPVhRZpnqKV9uqDGxT7cToSDnIHsGooyIczu9T+Q==} + engines: {node: '>=12'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm64@0.21.2': + resolution: {integrity: sha512-SGZKngoTWVUriO5bDjI4WDGsNx2VKZoXcds+ita/kVYB+8IkSCKDRDaK+5yu0b5S0eq6B3S7fpiEvpsa2ammlQ==} + engines: {node: '>=12'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.19.11': + resolution: {integrity: sha512-5OVapq0ClabvKvQ58Bws8+wkLCV+Rxg7tUVbo9xu034Nm536QTII4YzhaFriQ7rMrorfnFKUsArD2lqKbFY4vw==} + engines: {node: '>=12'} + cpu: [arm] + os: [android] + + '@esbuild/android-arm@0.21.2': + resolution: {integrity: sha512-G1ve3b4FeyJeyCjB4MX1CiWyTaIJwT9wAYE+8+IRA53YoN/reC/Bf2GDRXAzDTnh69Fpl+1uIKg76DiB3U6vwQ==} + engines: {node: '>=12'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.19.11': + resolution: {integrity: sha512-eccxjlfGw43WYoY9QgB82SgGgDbibcqyDTlk3l3C0jOVHKxrjdc9CTwDUQd0vkvYg5um0OH+GpxYvp39r+IPOg==} + engines: {node: '>=12'} + cpu: [x64] + os: [android] + + '@esbuild/android-x64@0.21.2': + resolution: {integrity: sha512-1wzzNoj2QtNkAYwIcWJ66UTRA80+RTQ/kuPMtEuP0X6dp5Ar23Dn566q3aV61h4EYrrgGlOgl/HdcqN/2S/2vg==} + engines: {node: '>=12'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.19.11': + resolution: {integrity: sha512-ETp87DRWuSt9KdDVkqSoKoLFHYTrkyz2+65fj9nfXsaV3bMhTCjtQfw3y+um88vGRKRiF7erPrh/ZuIdLUIVxQ==} + engines: {node: '>=12'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-arm64@0.21.2': + resolution: {integrity: sha512-ZyMkPWc5eTROcLOA10lEqdDSTc6ds6nuh3DeHgKip/XJrYjZDfnkCVSty8svWdy+SC1f77ULtVeIqymTzaB6/Q==} + engines: {node: '>=12'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.19.11': + resolution: {integrity: sha512-fkFUiS6IUK9WYUO/+22omwetaSNl5/A8giXvQlcinLIjVkxwTLSktbF5f/kJMftM2MJp9+fXqZ5ezS7+SALp4g==} + engines: {node: '>=12'} + cpu: [x64] + os: [darwin] + + '@esbuild/darwin-x64@0.21.2': + resolution: {integrity: sha512-K4ZdVq1zP9v51h/cKVna7im7G0zGTKKB6bP2yJiSmHjjOykbd8DdhrSi8V978sF69rkwrn8zCyL2t6I3ei6j9A==} + engines: {node: '>=12'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.19.11': + resolution: {integrity: sha512-lhoSp5K6bxKRNdXUtHoNc5HhbXVCS8V0iZmDvyWvYq9S5WSfTIHU2UGjcGt7UeS6iEYp9eeymIl5mJBn0yiuxA==} + engines: {node: '>=12'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-arm64@0.21.2': + resolution: {integrity: sha512-4kbOGdpA61CXqadD+Gb/Pw3YXamQGiz9mal/h93rFVSjr5cgMnmJd/gbfPRm+3BMifvnaOfS1gNWaIDxkE2A3A==} + engines: {node: '>=12'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.19.11': + resolution: {integrity: sha512-JkUqn44AffGXitVI6/AbQdoYAq0TEullFdqcMY/PCUZ36xJ9ZJRtQabzMA+Vi7r78+25ZIBosLTOKnUXBSi1Kw==} + engines: {node: '>=12'} + cpu: [x64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.21.2': + resolution: {integrity: sha512-ShS+R09nuHzDBfPeMUliKZX27Wrmr8UFp93aFf/S8p+++x5BZ+D344CLKXxmY6qzgTL3mILSImPCNJOzD6+RRg==} + engines: {node: '>=12'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.19.11': + resolution: {integrity: sha512-LneLg3ypEeveBSMuoa0kwMpCGmpu8XQUh+mL8XXwoYZ6Be2qBnVtcDI5azSvh7vioMDhoJFZzp9GWp9IWpYoUg==} + engines: {node: '>=12'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm64@0.21.2': + resolution: {integrity: sha512-Hdu8BL+AmO+eCDvvT6kz/fPQhvuHL8YK4ExKZfANWsNe1kFGOHw7VJvS/FKSLFqheXmB3rTF3xFQIgUWPYsGnA==} + engines: {node: '>=12'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.19.11': + resolution: {integrity: sha512-3CRkr9+vCV2XJbjwgzjPtO8T0SZUmRZla+UL1jw+XqHZPkPgZiyWvbDvl9rqAN8Zl7qJF0O/9ycMtjU67HN9/Q==} + engines: {node: '>=12'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-arm@0.21.2': + resolution: {integrity: sha512-nnGXjOAv+7cM3LYRx4tJsYdgy8dGDGkAzF06oIDGppWbUkUKN9SmgQA8H0KukpU0Pjrj9XmgbWqMVSX/U7eeTA==} + engines: {node: '>=12'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.19.11': + resolution: {integrity: sha512-caHy++CsD8Bgq2V5CodbJjFPEiDPq8JJmBdeyZ8GWVQMjRD0sU548nNdwPNvKjVpamYYVL40AORekgfIubwHoA==} + engines: {node: '>=12'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-ia32@0.21.2': + resolution: {integrity: sha512-m73BOCW2V9lcj7RtEMi+gBfHC6n3+VHpwQXP5offtQMPLDkpVolYn1YGXxOZ9hp4h3UPRKuezL7WkBsw+3EB3Q==} + engines: {node: '>=12'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.19.11': + resolution: {integrity: sha512-ppZSSLVpPrwHccvC6nQVZaSHlFsvCQyjnvirnVjbKSHuE5N24Yl8F3UwYUUR1UEPaFObGD2tSvVKbvR+uT1Nrg==} + engines: {node: '>=12'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-loong64@0.21.2': + resolution: {integrity: sha512-84eYHwwWHq3myIY/6ikALMcnwkf6Qo7NIq++xH0x+cJuUNpdwh8mlpUtRY+JiGUc60yu7ElWBbVHGWTABTclGw==} + engines: {node: '>=12'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.19.11': + resolution: {integrity: sha512-B5x9j0OgjG+v1dF2DkH34lr+7Gmv0kzX6/V0afF41FkPMMqaQ77pH7CrhWeR22aEeHKaeZVtZ6yFwlxOKPVFyg==} + engines: {node: '>=12'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-mips64el@0.21.2': + resolution: {integrity: sha512-9siSZngT0/ZKG+AH+/agwKF29LdCxw4ODi/PiE0F52B2rtLozlDP92umf8G2GPoVV611LN4pZ+nSTckebOscUA==} + engines: {node: '>=12'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.19.11': + resolution: {integrity: sha512-MHrZYLeCG8vXblMetWyttkdVRjQlQUb/oMgBNurVEnhj4YWOr4G5lmBfZjHYQHHN0g6yDmCAQRR8MUHldvvRDA==} + engines: {node: '>=12'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-ppc64@0.21.2': + resolution: {integrity: sha512-y0T4aV2CA+ic04ULya1A/8M2RDpDSK2ckgTj6jzHKFJvCq0jQg8afQQIn4EM0G8u2neyOiNHgSF9YKPfuqKOVw==} + engines: {node: '>=12'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.19.11': + resolution: {integrity: sha512-f3DY++t94uVg141dozDu4CCUkYW+09rWtaWfnb3bqe4w5NqmZd6nPVBm+qbz7WaHZCoqXqHz5p6CM6qv3qnSSQ==} + engines: {node: '>=12'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-riscv64@0.21.2': + resolution: {integrity: sha512-x5ssCdXmZC86L2Li1qQPF/VaC4VP20u/Zm8jlAu9IiVOVi79YsSz6cpPDYZl1rfKSHYCJW9XBfFCo66S5gVPSA==} + engines: {node: '>=12'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.19.11': + resolution: {integrity: sha512-A5xdUoyWJHMMlcSMcPGVLzYzpcY8QP1RtYzX5/bS4dvjBGVxdhuiYyFwp7z74ocV7WDc0n1harxmpq2ePOjI0Q==} + engines: {node: '>=12'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-s390x@0.21.2': + resolution: {integrity: sha512-NP7fTpGSFWdXyvp8iAFU04uFh9ARoplFVM/m+8lTRpaYG+2ytHPZWyscSsMM6cvObSIK2KoPHXiZD4l99WaxbQ==} + engines: {node: '>=12'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.19.11': + resolution: {integrity: sha512-grbyMlVCvJSfxFQUndw5mCtWs5LO1gUlwP4CDi4iJBbVpZcqLVT29FxgGuBJGSzyOxotFG4LoO5X+M1350zmPA==} + engines: {node: '>=12'} + cpu: [x64] + os: [linux] + + '@esbuild/linux-x64@0.21.2': + resolution: {integrity: sha512-giZ/uOxWDKda44ZuyfKbykeXznfuVNkTgXOUOPJIjbayJV6FRpQ4zxUy9JMBPLaK9IJcdWtaoeQrYBMh3Rr4vQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-x64@0.19.11': + resolution: {integrity: sha512-13jvrQZJc3P230OhU8xgwUnDeuC/9egsjTkXN49b3GcS5BKvJqZn86aGM8W9pd14Kd+u7HuFBMVtrNGhh6fHEQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.21.2': + resolution: {integrity: sha512-IeFMfGFSQfIj1d4XU+6lkbFzMR+mFELUUVYrZ+jvWzG4NGvs6o53ReEHLHpYkjRbdEjJy2W3lTekTxrFHW7YJg==} + engines: {node: '>=12'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-x64@0.19.11': + resolution: {integrity: sha512-ysyOGZuTp6SNKPE11INDUeFVVQFrhcNDVUgSQVDzqsqX38DjhPEPATpid04LCoUr2WXhQTEZ8ct/EgJCUDpyNw==} + engines: {node: '>=12'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.21.2': + resolution: {integrity: sha512-48QhWD6WxcebNNaE4FCwgvQVUnAycuTd+BdvA/oZu+/MmbpU8pY2dMEYlYzj5uNHWIG5jvdDmFXu0naQeOWUoA==} + engines: {node: '>=12'} + cpu: [x64] + os: [openbsd] + + '@esbuild/sunos-x64@0.19.11': + resolution: {integrity: sha512-Hf+Sad9nVwvtxy4DXCZQqLpgmRTQqyFyhT3bZ4F2XlJCjxGmRFF0Shwn9rzhOYRB61w9VMXUkxlBy56dk9JJiQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [sunos] + + '@esbuild/sunos-x64@0.21.2': + resolution: {integrity: sha512-90r3nTBLgdIgD4FCVV9+cR6Hq2Dzs319icVsln+NTmTVwffWcCqXGml8rAoocHuJ85kZK36DCteii96ba/PX8g==} + engines: {node: '>=12'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.19.11': + resolution: {integrity: sha512-0P58Sbi0LctOMOQbpEOvOL44Ne0sqbS0XWHMvvrg6NE5jQ1xguCSSw9jQeUk2lfrXYsKDdOe6K+oZiwKPilYPQ==} + engines: {node: '>=12'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-arm64@0.21.2': + resolution: {integrity: sha512-sNndlsBT8OeE/MZDSGpRDJlWuhjuUz/dn80nH0EP4ZzDUYvMDVa7G87DVpweBrn4xdJYyXS/y4CQNrf7R2ODXg==} + engines: {node: '>=12'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.19.11': + resolution: {integrity: sha512-6YOrWS+sDJDmshdBIQU+Uoyh7pQKrdykdefC1avn76ss5c+RN6gut3LZA4E2cH5xUEp5/cA0+YxRaVtRAb0xBg==} + engines: {node: '>=12'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-ia32@0.21.2': + resolution: {integrity: sha512-Ti2QChGNFzWhUNNVuU4w21YkYTErsNh3h+CzvlEhzgRbwsJ7TrWQqRzW3bllLKKvTppuF3DJ3XP1GEg11AfrEQ==} + engines: {node: '>=12'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.19.11': + resolution: {integrity: sha512-vfkhltrjCAb603XaFhqhAF4LGDi2M4OrCRrFusyQ+iTLQ/o60QQXxc9cZC/FFpihBI9N1Grn6SMKVJ4KP7Fuiw==} + engines: {node: '>=12'} + cpu: [x64] + os: [win32] + + '@esbuild/win32-x64@0.21.2': + resolution: {integrity: sha512-VEfTCZicoZnZ6sGkjFPGRFFJuL2fZn2bLhsekZl1CJslflp2cJS/VoKs1jMk+3pDfsGW6CfQVUckP707HwbXeQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [win32] + + '@eslint-community/eslint-utils@4.4.0': + resolution: {integrity: sha512-1/sA4dwrzBAyeUoQ6oxahHKmrZvsnLCg4RfxW3ZFGGmQkSNQPFNLV9CUEFQP1x9EYXHTo5p6xdhZM1Ne9p/AfA==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + peerDependencies: + eslint: ^6.0.0 || ^7.0.0 || >=8.0.0 + + '@eslint-community/regexpp@4.11.0': + resolution: {integrity: sha512-G/M/tIiMrTAxEWRfLfQJMmGNX28IxBg4PBz8XqQhqUHLFI6TL2htpIB1iQCj144V5ee/JaKyT9/WZ0MGZWfA7A==} + engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0} + + '@eslint/config-array@0.17.1': + resolution: {integrity: sha512-BlYOpej8AQ8Ev9xVqroV7a02JK3SkBAaN9GfMMH9W6Ch8FlQlkjGw4Ir7+FgYwfirivAf4t+GtzuAxqfukmISA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/eslintrc@3.1.0': + resolution: {integrity: sha512-4Bfj15dVJdoy3RfZmmo86RK1Fwzn6SstsvK9JS+BaVKqC6QQQQyXekNaC+g+LKNgkQ+2VhGAzm6hO40AhMR3zQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/js@9.9.0': + resolution: {integrity: sha512-hhetes6ZHP3BlXLxmd8K2SNgkhNSi+UcecbnwWKwpP7kyi/uC75DJ1lOOBO3xrC4jyojtGE3YxKZPHfk4yrgug==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/object-schema@2.1.4': + resolution: {integrity: sha512-BsWiH1yFGjXXS2yvrf5LyuoSIIbPrGUWob917o+BTKuZ7qJdxX8aJLRxs1fS9n6r7vESrq1OUqb68dANcFXuQQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@fastify/accept-negotiator@1.1.0': + resolution: {integrity: sha512-OIHZrb2ImZ7XG85HXOONLcJWGosv7sIvM2ifAPQVhg9Lv7qdmMBNVaai4QTdyuaqbKM5eO6sLSQOYI7wEQeCJQ==} + engines: {node: '>=14'} + + '@fastify/ajv-compiler@3.6.0': + resolution: {integrity: sha512-LwdXQJjmMD+GwLOkP7TVC68qa+pSSogeWWmznRJ/coyTcfe9qA05AHFSe1eZFwK6q+xVRpChnvFUkf1iYaSZsQ==} + + '@fastify/error@3.4.1': + resolution: {integrity: sha512-wWSvph+29GR783IhmvdwWnN4bUxTD01Vm5Xad4i7i1VuAOItLvbPAb69sb0IQ2N57yprvhNIwAP5B6xfKTmjmQ==} + + '@fastify/fast-json-stringify-compiler@4.3.0': + resolution: {integrity: sha512-aZAXGYo6m22Fk1zZzEUKBvut/CIIQe/BapEORnxiD5Qr0kPHqqI69NtEMCme74h+at72sPhbkb4ZrLd1W3KRLA==} + + '@fastify/merge-json-schemas@0.1.1': + resolution: {integrity: sha512-fERDVz7topgNjtXsJTTW1JKLy0rhuLRcquYqNR9rF7OcVpCa2OVW49ZPDIhaRRCaUuvVxI+N416xUoF76HNSXA==} + + '@fastify/send@2.1.0': + resolution: {integrity: sha512-yNYiY6sDkexoJR0D8IDy3aRP3+L4wdqCpvx5WP+VtEU58sn7USmKynBzDQex5X42Zzvw2gNzzYgP90UfWShLFA==} + + '@fastify/static@7.0.4': + resolution: {integrity: sha512-p2uKtaf8BMOZWLs6wu+Ihg7bWNBdjNgCwDza4MJtTqg+5ovKmcbgbR9Xs5/smZ1YISfzKOCNYmZV8LaCj+eJ1Q==} + + '@humanwhocodes/module-importer@1.0.1': + resolution: {integrity: sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==} + engines: {node: '>=12.22'} + + '@humanwhocodes/momoa@2.0.4': + resolution: {integrity: sha512-RE815I4arJFtt+FVeU1Tgp9/Xvecacji8w/V6XtXsWWH/wz/eNkNbhb+ny/+PlVZjV0rxQpRSQKNKE3lcktHEA==} + engines: {node: '>=10.10.0'} + + '@humanwhocodes/retry@0.3.0': + resolution: {integrity: sha512-d2CGZR2o7fS6sWB7DG/3a95bGKQyHMACZ5aW8qGkkqQpUoZV6C0X7Pc7l4ZNMZkfNBf4VWNe9E1jRsf0G146Ew==} + engines: {node: '>=18.18'} + + '@iarna/toml@2.2.5': + resolution: {integrity: sha512-trnsAYxU3xnS1gPHPyU961coFyLkh4gAD/0zQ5mymY4yOZ+CYvsPqUbOFSw0aDM4y0tV7tiFxL/1XfXPNC6IPg==} + + '@import-maps/resolve@1.0.1': + resolution: {integrity: sha512-tWZNBIS1CoekcwlMuyG2mr0a1Wo5lb5lEHwwWvZo+5GLgr3e9LLDTtmgtCWEwBpXMkxn9D+2W9j2FY6eZQq0tA==} + + '@isaacs/cliui@8.0.2': + resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==} + engines: {node: '>=12'} + + '@istanbuljs/load-nyc-config@1.1.0': + resolution: {integrity: sha512-VjeHSlIzpv/NyD3N0YuHfXOPDIixcA1q2ZV98wsMqcYlPmv2n3Yb2lYP9XMElnaFVXg5A7YLTeLu6V84uQDjmQ==} + engines: {node: '>=8'} + + '@istanbuljs/schema@0.1.3': + resolution: {integrity: sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==} + engines: {node: '>=8'} + + '@jest/console@29.7.0': + resolution: {integrity: sha512-5Ni4CU7XHQi32IJ398EEP4RrB8eV09sXP2ROqD4bksHrnTree52PsxvX8tpL8LvTZ3pFzXyPbNQReSN41CAhOg==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + '@jest/core@29.7.0': + resolution: {integrity: sha512-n7aeXWKMnGtDA48y8TLWJPJmLmmZ642Ceo78cYWEpiD7FzDgmNDV/GCVRorPABdXLJZ/9wzzgZAlHjXjxDHGsg==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + peerDependencies: + node-notifier: ^8.0.1 || ^9.0.0 || ^10.0.0 + peerDependenciesMeta: + node-notifier: + optional: true + + '@jest/environment@29.7.0': + resolution: {integrity: sha512-aQIfHDq33ExsN4jP1NWGXhxgQ/wixs60gDiKO+XVMd8Mn0NWPWgc34ZQDTb2jKaUWQ7MuwoitXAsN2XVXNMpAw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + '@jest/expect-utils@29.7.0': + resolution: {integrity: sha512-GlsNBWiFQFCVi9QVSx7f5AgMeLxe9YCCs5PuP2O2LdjDAA8Jh9eX7lA1Jq/xdXw3Wb3hyvlFNfZIfcRetSzYcA==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + '@jest/expect@29.7.0': + resolution: {integrity: sha512-8uMeAMycttpva3P1lBHB8VciS9V0XAr3GymPpipdyQXbBcuhkLQOSe8E/p92RyAdToS6ZD1tFkX+CkhoECE0dQ==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + '@jest/fake-timers@29.7.0': + resolution: {integrity: sha512-q4DH1Ha4TTFPdxLsqDXK1d3+ioSL7yL5oCMJZgDYm6i+6CygW5E5xVr/D1HdsGxjt1ZWSfUAs9OxSB/BNelWrQ==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + '@jest/globals@29.7.0': + resolution: {integrity: sha512-mpiz3dutLbkW2MNFubUGUEVLkTGiqW6yLVTA+JbP6fI6J5iL9Y0Nlg8k95pcF8ctKwCS7WVxteBs29hhfAotzQ==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + '@jest/reporters@29.7.0': + resolution: {integrity: sha512-DApq0KJbJOEzAFYjHADNNxAE3KbhxQB1y5Kplb5Waqw6zVbuWatSnMjE5gs8FUgEPmNsnZA3NCWl9NG0ia04Pg==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + peerDependencies: + node-notifier: ^8.0.1 || ^9.0.0 || ^10.0.0 + peerDependenciesMeta: + node-notifier: + optional: true + + '@jest/schemas@29.6.3': + resolution: {integrity: sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + '@jest/source-map@29.6.3': + resolution: {integrity: sha512-MHjT95QuipcPrpLM+8JMSzFx6eHp5Bm+4XeFDJlwsvVBjmKNiIAvasGK2fxz2WbGRlnvqehFbh07MMa7n3YJnw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + '@jest/test-result@29.7.0': + resolution: {integrity: sha512-Fdx+tv6x1zlkJPcWXmMDAG2HBnaR9XPSd5aDWQVsfrZmLVT3lU1cwyxLgRmXR9yrq4NBoEm9BMsfgFzTQAbJYA==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + '@jest/test-sequencer@29.7.0': + resolution: {integrity: sha512-GQwJ5WZVrKnOJuiYiAF52UNUJXgTZx1NHjFSEB0qEMmSZKAkdMoIzw/Cj6x6NF4AvV23AUqDpFzQkN/eYCYTxw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + '@jest/transform@29.7.0': + resolution: {integrity: sha512-ok/BTPFzFKVMwO5eOHRrvnBVHdRy9IrsrW1GpMaQ9MCnilNLXQKmAX8s1YXDFaai9xJpac2ySzV0YeRRECr2Vw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + '@jest/types@27.5.1': + resolution: {integrity: sha512-Cx46iJ9QpwQTjIdq5VJu2QTMMs3QlEjI0x1QbBP5W1+nMzyc2XmimiRR/CbX9TO0cPTeUlxWMOu8mslYsJ8DEw==} + engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0} + + '@jest/types@29.6.3': + resolution: {integrity: sha512-u3UPsIilWKOM3F9CXtrG8LEJmNxwoCQC/XVj4IKYXvvpx7QIi/Kg1LI5uDmDpKlac62NUtX7eLjRh+jVZcLOzw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + '@jridgewell/gen-mapping@0.3.5': + resolution: {integrity: sha512-IzL8ZoEDIBRWEzlCcRhOaCupYyN5gdIK+Q6fbFdPDg6HqX6jpkItn7DFIpW9LQzXG6Df9sA7+OKnq0qlz/GaQg==} + engines: {node: '>=6.0.0'} + + '@jridgewell/resolve-uri@3.1.2': + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} + engines: {node: '>=6.0.0'} + + '@jridgewell/set-array@1.2.1': + resolution: {integrity: sha512-R8gLRTZeyp03ymzP/6Lil/28tGeGEzhx1q2k703KGWRAI1VdvPIXdG70VJc2pAMw3NA6JKL5hhFu1sJX0Mnn/A==} + engines: {node: '>=6.0.0'} + + '@jridgewell/sourcemap-codec@1.5.0': + resolution: {integrity: sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ==} + + '@jridgewell/trace-mapping@0.3.25': + resolution: {integrity: sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==} + + '@jridgewell/trace-mapping@0.3.9': + resolution: {integrity: sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==} + + '@lukeed/ms@2.0.2': + resolution: {integrity: sha512-9I2Zn6+NJLfaGoz9jN3lpwDgAYvfGeNYdbAIjJOqzs4Tpc+VU3Jqq4IofSUBKajiDS8k9fZIg18/z13mpk1bsA==} + engines: {node: '>=8'} + + '@mapbox/node-pre-gyp@1.0.11': + resolution: {integrity: sha512-Yhlar6v9WQgUp/He7BdgzOz8lqMQ8sU+jkCq7Wx8Myc5YFJLbEe7lgui/V7G1qB1DJykHSGwreceSaD60Y0PUQ==} + hasBin: true + + '@netlify/binary-info@1.0.0': + resolution: {integrity: sha512-4wMPu9iN3/HL97QblBsBay3E1etIciR84izI3U+4iALY+JHCrI+a2jO0qbAZ/nxKoegypYEaiiqWXylm+/zfrw==} + + '@netlify/blobs@7.4.0': + resolution: {integrity: sha512-7rdPzo8bggt3D2CVO+U1rmEtxxs8X7cLusDbHZRJaMlxqxBD05mXgThj5DUJMFOvmfVjhEH/S/3AyiLUbDQGDg==} + engines: {node: ^14.16.0 || >=16.0.0} + + '@netlify/blobs@8.0.0': + resolution: {integrity: sha512-p9DdRSPvDuFhl9PYODWRo5QYWB4Du/lX5gbZNmwmtw+xfcaIpPD3lWs8I1OwHcpVgbay0Ik4JfCT75ZiPylKgA==} + engines: {node: ^14.16.0 || >=16.0.0} + + '@netlify/build-info@7.14.1': + resolution: {integrity: sha512-0FhHK8+v80pDt0hkN4s5+sFUL5OF8bVU4bqwqDx04NiSQ/jOUSwCZ70F5MHkbvjuqf4RoP0vVKqrvIB3EP0wyA==} + engines: {node: ^14.16.0 || >=16.0.0} + hasBin: true + + '@netlify/build@29.53.0': + resolution: {integrity: sha512-1/IfubZQIfal/HfpvmG9PIhfc+MuGvfMOcZHckOTZ35N8KJ2xmlTN0H/Bqf8OePwA1pn10s2kDdiXh4e9/MlHg==} + engines: {node: ^14.16.0 || >=16.0.0} + hasBin: true + peerDependencies: + '@netlify/opentelemetry-sdk-setup': ^1.1.0 + '@opentelemetry/api': ~1.8.0 + peerDependenciesMeta: + '@netlify/opentelemetry-sdk-setup': + optional: true + + '@netlify/cache-utils@5.1.6': + resolution: {integrity: sha512-0K1+5umxENy9H3CC+v5qGQbeTmKv/PBAhOxPKK6GPykOVa7OxT26KGMU7Jozo6pVNeLPJUvCCMw48ycwtQ1fvw==} + engines: {node: ^14.16.0 || >=16.0.0} + + '@netlify/config@20.18.0': + resolution: {integrity: sha512-sP10JJ73MELqzm4SLGjIDabwy2iRhieDXtgmVRpA/8nkndo5MpN9jqEkBP9ocPezjngvrk+YeUIYjndBCS0Wzg==} + engines: {node: ^14.16.0 || >=16.0.0} + hasBin: true + + '@netlify/edge-bundler@12.2.3': + resolution: {integrity: sha512-o/Od4gvGT2qPSjJ1TSh8KYDJHfzxW4iemA5DiZtXIDgaIvWgvehZKDROp9wJ2FseP2F83y4ZDmt5xFfBSD9IYQ==} + engines: {node: ^14.16.0 || >=16.0.0} + + '@netlify/edge-functions@2.9.0': + resolution: {integrity: sha512-W1kdwLpvUlhfI2FTOe6SEcoobW7Fw+Vm9WN5Gwb5lTCG6QXBE3gpCZk+NVQ4p/XoOcXYwWAS5pfOTMKUoYNQnA==} + + '@netlify/framework-info@9.8.13': + resolution: {integrity: sha512-ZZXCggokY/y5Sz93XYbl/Lig1UAUSWPMBiQRpkVfbrrkjmW2ZPkYS/BgrM2/MxwXRvYhc/TQpZX6y5JPe3quQg==} + engines: {node: ^14.14.0 || >=16.0.0} + + '@netlify/functions-utils@5.2.77': + resolution: {integrity: sha512-JmcfFwWskyQWYNV3aGISkAgRm4ggCWdOpLmc2BxJX+T6tf8i19wC5ZlyX3l16yH0c/dAXWOakEoKZsLJ4MbJZQ==} + engines: {node: ^14.16.0 || >=16.0.0} + + '@netlify/git-utils@5.1.1': + resolution: {integrity: sha512-oyHieuTZH3rKTmg7EKpGEGa28IFxta2oXuVwpPJI/FJAtBje3UE+yko0eDjNufgm3AyGa8G77trUxgBhInAYuw==} + engines: {node: ^14.16.0 || >=16.0.0} + + '@netlify/local-functions-proxy-darwin-arm64@1.1.1': + resolution: {integrity: sha512-lphJ9qqZ3glnKWEqlemU1LMqXxtJ/tKf7VzakqqyjigwLscXSZSb6fupSjQfd4tR1xqxA76ylws/2HDhc/gs+Q==} + cpu: [arm64] + os: [darwin] + hasBin: true + + '@netlify/local-functions-proxy-darwin-x64@1.1.1': + resolution: {integrity: sha512-4CRB0H+dXZzoEklq5Jpmg+chizXlVwCko94d8+UHWCgy/bA3M/rU/BJ8OLZisnJaAktHoeLABKtcLOhtRHpxZQ==} + cpu: [x64] + os: [darwin] + hasBin: true + + '@netlify/local-functions-proxy-freebsd-arm64@1.1.1': + resolution: {integrity: sha512-u13lWTVMJDF0A6jX7V4N3HYGTIHLe5d1Z2wT43fSIHwXkTs6UXi72cGSraisajG+5JFIwHfPr7asw5vxFC0P9w==} + cpu: [arm64] + os: [freebsd] + hasBin: true + + '@netlify/local-functions-proxy-freebsd-x64@1.1.1': + resolution: {integrity: sha512-g5xw4xATK5YDzvXtzJ8S1qSkWBiyF8VVRehXPMOAMzpGjCX86twYhWp8rbAk7yA1zBWmmWrWNA2Odq/MgpKJJg==} + cpu: [x64] + os: [freebsd] + hasBin: true + + '@netlify/local-functions-proxy-linux-arm64@1.1.1': + resolution: {integrity: sha512-dPGu1H5n8na7mBKxiXQ+FNmthDAiA57wqgpm5JMAHtcdcmRvcXwJkwWVGvwfj8ShhYJHQaSaS9oPgO+mpKkgmA==} + cpu: [arm64] + os: [linux] + hasBin: true + + '@netlify/local-functions-proxy-linux-arm@1.1.1': + resolution: {integrity: sha512-YsTpL+AbHwQrfHWXmKnwUrJBjoUON363nr6jUG1ueYnpbbv6wTUA7gI5snMi/gkGpqFusBthAA7C30e6bixfiA==} + cpu: [arm] + os: [linux] + hasBin: true + + '@netlify/local-functions-proxy-linux-ia32@1.1.1': + resolution: {integrity: sha512-Ra0FlXDrmPRaq+rYH3/ttkXSrwk1D5Zx/Na7UPfJZxMY7Qo5iY4bgi/FuzjzWzlp0uuKZOhYOYzYzsIIyrSvmw==} + cpu: [ia32] + os: [linux] + hasBin: true + + '@netlify/local-functions-proxy-linux-ppc64@1.1.1': + resolution: {integrity: sha512-oXf1satwqwUUxz7LHS1BxbRqc4FFEKIDFTls04eXiLReFR3sqv9H/QuYNTCCDMuRcCOd92qKyDfATdnxT4HR8w==} + cpu: [ppc64] + os: [linux] + hasBin: true + + '@netlify/local-functions-proxy-linux-x64@1.1.1': + resolution: {integrity: sha512-bS3u4JuDg/eC0y4Na3i/29JBOxrdUvsK5JSjHfzUeZEbOcuXYf4KavTpHS5uikdvTgyczoSrvbmQJ5m0FLXfLA==} + cpu: [x64] + os: [linux] + hasBin: true + + '@netlify/local-functions-proxy-openbsd-x64@1.1.1': + resolution: {integrity: sha512-1xLef/kLRNkBTXJ+ZGoRFcwsFxd/B2H3oeJZyXaZ3CN5umd9Mv9wZuAD74NuMt/535yRva8jtAJqvEgl9xMSdA==} + cpu: [x64] + os: [openbsd] + hasBin: true + + '@netlify/local-functions-proxy-win32-ia32@1.1.1': + resolution: {integrity: sha512-4IOMDBxp2f8VbIkhZ85zGNDrZR4ey8d68fCMSOIwitjsnKav35YrCf8UmAh3UR6CNIRJdJL4MW1GYePJ7iJ8uA==} + cpu: [ia32] + os: [win32] + hasBin: true + + '@netlify/local-functions-proxy-win32-x64@1.1.1': + resolution: {integrity: sha512-VCBXBJWBujVxyo5f+3r8ovLc9I7wJqpmgDn3ixs1fvdrER5Ac+SzYwYH4mUug9HI08mzTSAKZErzKeuadSez3w==} + cpu: [x64] + os: [win32] + hasBin: true + + '@netlify/local-functions-proxy@1.1.1': + resolution: {integrity: sha512-eXSsayLT6PMvjzFQpjC9nkg2Otc3lZ5GoYele9M6f8PmsvWpaXRhwjNQ0NYhQQ2UZbLMIiO2dH8dbRsT3bMkFw==} + + '@netlify/node-cookies@0.1.0': + resolution: {integrity: sha512-OAs1xG+FfLX0LoRASpqzVntVV/RpYkgpI0VrUnw2u0Q1qiZUzcPffxRK8HF3gc4GjuhG5ahOEMJ9bswBiZPq0g==} + engines: {node: ^14.16.0 || >=16.0.0} + + '@netlify/open-api@2.34.0': + resolution: {integrity: sha512-C4v7Od/vnGgZ1P4JK3Fn9uUi9HkTxeUqUtj4OLnGD+rGyaVrl4JY89xMCoVksijDtO8XylYFU59CSTnQNeNw7g==} + engines: {node: '>=14'} + + '@netlify/opentelemetry-utils@1.2.1': + resolution: {integrity: sha512-A6nQBvUn/avHQopLOOjX8rY2eua//jufbx4NZZODACEHtfXAEmOjCoDe2m+cQPRq+jNa98nvCy/sJh2RwuCQog==} + engines: {node: '>=18.0.0'} + peerDependencies: + '@opentelemetry/api': ~1.8.0 + + '@netlify/plugins-list@6.80.0': + resolution: {integrity: sha512-bCKLI51UZ70ziIWsf2nvgPd4XuG6m8AMCoHiYtl/BSsiaSBfmryZnTTqdRXerH09tBRpbPPwzaEgUJwyU9o8Qw==} + engines: {node: ^14.14.0 || >=16.0.0} + + '@netlify/run-utils@5.1.1': + resolution: {integrity: sha512-V2B8ZB19heVKa715uOeDkztxLH7uaqZ+9U5fV7BRzbQ2514DO5Vxj9hG0irzuRLfZXZZjp/chPUesv4VVsce/A==} + engines: {node: ^14.16.0 || >=16.0.0} + + '@netlify/serverless-functions-api@1.22.0': + resolution: {integrity: sha512-vv8fWCOIadSvdmR+8UYopdyHO/gOysl+8IBOxUUB0B3y7nnLOiBniE1JBeBR3y7gI/q/cnibBF2RhR3W04Wo/A==} + engines: {node: '>=18.0.0'} + + '@netlify/zip-it-and-ship-it@9.37.9': + resolution: {integrity: sha512-pRrxQ8KBxV6qgR2Qg3QVHy39FGg/1u1hsxiXNpZMzq0DF8/XglT4G/4jL0FRlmleV4djWyUsR2V93RsbTlxy8w==} + engines: {node: ^14.18.0 || >=16.0.0} + hasBin: true + + '@next/env@14.2.5': + resolution: {integrity: sha512-/zZGkrTOsraVfYjGP8uM0p6r0BDT6xWpkjdVbcz66PJVSpwXX3yNiRycxAuDfBKGWBrZBXRuK/YVlkNgxHGwmA==} + + '@next/swc-darwin-arm64@14.2.5': + resolution: {integrity: sha512-/9zVxJ+K9lrzSGli1///ujyRfon/ZneeZ+v4ptpiPoOU+GKZnm8Wj8ELWU1Pm7GHltYRBklmXMTUqM/DqQ99FQ==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [darwin] + + '@next/swc-darwin-x64@14.2.5': + resolution: {integrity: sha512-vXHOPCwfDe9qLDuq7U1OYM2wUY+KQ4Ex6ozwsKxp26BlJ6XXbHleOUldenM67JRyBfVjv371oneEvYd3H2gNSA==} + engines: {node: '>= 10'} + cpu: [x64] + os: [darwin] + + '@next/swc-linux-arm64-gnu@14.2.5': + resolution: {integrity: sha512-vlhB8wI+lj8q1ExFW8lbWutA4M2ZazQNvMWuEDqZcuJJc78iUnLdPPunBPX8rC4IgT6lIx/adB+Cwrl99MzNaA==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + + '@next/swc-linux-arm64-musl@14.2.5': + resolution: {integrity: sha512-NpDB9NUR2t0hXzJJwQSGu1IAOYybsfeB+LxpGsXrRIb7QOrYmidJz3shzY8cM6+rO4Aojuef0N/PEaX18pi9OA==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + + '@next/swc-linux-x64-gnu@14.2.5': + resolution: {integrity: sha512-8XFikMSxWleYNryWIjiCX+gU201YS+erTUidKdyOVYi5qUQo/gRxv/3N1oZFCgqpesN6FPeqGM72Zve+nReVXQ==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + + '@next/swc-linux-x64-musl@14.2.5': + resolution: {integrity: sha512-6QLwi7RaYiQDcRDSU/os40r5o06b5ue7Jsk5JgdRBGGp8l37RZEh9JsLSM8QF0YDsgcosSeHjglgqi25+m04IQ==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + + '@next/swc-win32-arm64-msvc@14.2.5': + resolution: {integrity: sha512-1GpG2VhbspO+aYoMOQPQiqc/tG3LzmsdBH0LhnDS3JrtDx2QmzXe0B6mSZZiN3Bq7IOMXxv1nlsjzoS1+9mzZw==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [win32] + + '@next/swc-win32-ia32-msvc@14.2.5': + resolution: {integrity: sha512-Igh9ZlxwvCDsu6438FXlQTHlRno4gFpJzqPjSIBZooD22tKeI4fE/YMRoHVJHmrQ2P5YL1DoZ0qaOKkbeFWeMg==} + engines: {node: '>= 10'} + cpu: [ia32] + os: [win32] + + '@next/swc-win32-x64-msvc@14.2.5': + resolution: {integrity: sha512-tEQ7oinq1/CjSG9uSTerca3v4AZ+dFa+4Yu6ihaG8Ud8ddqLQgFGcnwYls13H5X5CPDPZJdYxyeMui6muOLd4g==} + engines: {node: '>= 10'} + cpu: [x64] + os: [win32] + + '@nodelib/fs.scandir@2.1.5': + resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} + engines: {node: '>= 8'} + + '@nodelib/fs.stat@2.0.5': + resolution: {integrity: sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==} + engines: {node: '>= 8'} + + '@nodelib/fs.walk@1.2.8': + resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} + engines: {node: '>= 8'} + + '@octokit/auth-token@4.0.0': + resolution: {integrity: sha512-tY/msAuJo6ARbK6SPIxZrPBms3xPbfwBrulZe0Wtr/DIY9lje2HeV1uoebShn6mx7SjCHif6EjMvoREj+gZ+SA==} + engines: {node: '>= 18'} + + '@octokit/core@5.2.0': + resolution: {integrity: sha512-1LFfa/qnMQvEOAdzlQymH0ulepxbxnCYAKJZfMci/5XJyIHWgEYnDmgnKakbTh7CH2tFQ5O60oYDvns4i9RAIg==} + engines: {node: '>= 18'} + + '@octokit/endpoint@9.0.5': + resolution: {integrity: sha512-ekqR4/+PCLkEBF6qgj8WqJfvDq65RH85OAgrtnVp1mSxaXF03u2xW/hUdweGS5654IlC0wkNYC18Z50tSYTAFw==} + engines: {node: '>= 18'} + + '@octokit/graphql@7.1.0': + resolution: {integrity: sha512-r+oZUH7aMFui1ypZnAvZmn0KSqAUgE1/tUXIWaqUCa1758ts/Jio84GZuzsvUkme98kv0WFY8//n0J1Z+vsIsQ==} + engines: {node: '>= 18'} + + '@octokit/openapi-types@22.2.0': + resolution: {integrity: sha512-QBhVjcUa9W7Wwhm6DBFu6ZZ+1/t/oYxqc2tp81Pi41YNuJinbFRx8B133qVOrAaBbF7D/m0Et6f9/pZt9Rc+tg==} + + '@octokit/plugin-paginate-rest@11.3.1': + resolution: {integrity: sha512-ryqobs26cLtM1kQxqeZui4v8FeznirUsksiA+RYemMPJ7Micju0WSkv50dBksTuZks9O5cg4wp+t8fZ/cLY56g==} + engines: {node: '>= 18'} + peerDependencies: + '@octokit/core': '5' + + '@octokit/plugin-request-log@4.0.1': + resolution: {integrity: sha512-GihNqNpGHorUrO7Qa9JbAl0dbLnqJVrV8OXe2Zm5/Y4wFkZQDfTreBzVmiRfJVfE4mClXdihHnbpyyO9FSX4HA==} + engines: {node: '>= 18'} + peerDependencies: + '@octokit/core': '5' + + '@octokit/plugin-rest-endpoint-methods@13.2.2': + resolution: {integrity: sha512-EI7kXWidkt3Xlok5uN43suK99VWqc8OaIMktY9d9+RNKl69juoTyxmLoWPIZgJYzi41qj/9zU7G/ljnNOJ5AFA==} + engines: {node: '>= 18'} + peerDependencies: + '@octokit/core': ^5 + + '@octokit/request-error@5.1.0': + resolution: {integrity: sha512-GETXfE05J0+7H2STzekpKObFe765O5dlAKUTLNGeH+x47z7JjXHfsHKo5z21D/o/IOZTUEI6nyWyR+bZVP/n5Q==} + engines: {node: '>= 18'} + + '@octokit/request@8.4.0': + resolution: {integrity: sha512-9Bb014e+m2TgBeEJGEbdplMVWwPmL1FPtggHQRkV+WVsMggPtEkLKPlcVYm/o8xKLkpJ7B+6N8WfQMtDLX2Dpw==} + engines: {node: '>= 18'} + + '@octokit/rest@20.1.1': + resolution: {integrity: sha512-MB4AYDsM5jhIHro/dq4ix1iWTLGToIGk6cWF5L6vanFaMble5jTX/UBQyiv05HsWnwUtY8JrfHy2LWfKwihqMw==} + engines: {node: '>= 18'} + + '@octokit/types@13.5.0': + resolution: {integrity: sha512-HdqWTf5Z3qwDVlzCrP8UJquMwunpDiMPt5er+QjGzL4hqr/vBVY/MauQgS1xWxCDT1oMx1EULyqxncdCY/NVSQ==} + + '@opentelemetry/api@1.8.0': + resolution: {integrity: sha512-I/s6F7yKUDdtMsoBWXJe8Qz40Tui5vsuKCWJEWVL+5q9sSWRzzx6v2KeNsOBEwd94j0eWkpWCH4yB6rZg9Mf0w==} + engines: {node: '>=8.0.0'} + + '@parcel/watcher-android-arm64@2.4.1': + resolution: {integrity: sha512-LOi/WTbbh3aTn2RYddrO8pnapixAziFl6SMxHM69r3tvdSm94JtCenaKgk1GRg5FJ5wpMCpHeW+7yqPlvZv7kg==} + engines: {node: '>= 10.0.0'} + cpu: [arm64] + os: [android] + + '@parcel/watcher-darwin-arm64@2.4.1': + resolution: {integrity: sha512-ln41eihm5YXIY043vBrrHfn94SIBlqOWmoROhsMVTSXGh0QahKGy77tfEywQ7v3NywyxBBkGIfrWRHm0hsKtzA==} + engines: {node: '>= 10.0.0'} + cpu: [arm64] + os: [darwin] + + '@parcel/watcher-darwin-x64@2.4.1': + resolution: {integrity: sha512-yrw81BRLjjtHyDu7J61oPuSoeYWR3lDElcPGJyOvIXmor6DEo7/G2u1o7I38cwlcoBHQFULqF6nesIX3tsEXMg==} + engines: {node: '>= 10.0.0'} + cpu: [x64] + os: [darwin] + + '@parcel/watcher-freebsd-x64@2.4.1': + resolution: {integrity: sha512-TJa3Pex/gX3CWIx/Co8k+ykNdDCLx+TuZj3f3h7eOjgpdKM+Mnix37RYsYU4LHhiYJz3DK5nFCCra81p6g050w==} + engines: {node: '>= 10.0.0'} + cpu: [x64] + os: [freebsd] + + '@parcel/watcher-linux-arm-glibc@2.4.1': + resolution: {integrity: sha512-4rVYDlsMEYfa537BRXxJ5UF4ddNwnr2/1O4MHM5PjI9cvV2qymvhwZSFgXqbS8YoTk5i/JR0L0JDs69BUn45YA==} + engines: {node: '>= 10.0.0'} + cpu: [arm] + os: [linux] + + '@parcel/watcher-linux-arm64-glibc@2.4.1': + resolution: {integrity: sha512-BJ7mH985OADVLpbrzCLgrJ3TOpiZggE9FMblfO65PlOCdG++xJpKUJ0Aol74ZUIYfb8WsRlUdgrZxKkz3zXWYA==} + engines: {node: '>= 10.0.0'} + cpu: [arm64] + os: [linux] + + '@parcel/watcher-linux-arm64-musl@2.4.1': + resolution: {integrity: sha512-p4Xb7JGq3MLgAfYhslU2SjoV9G0kI0Xry0kuxeG/41UfpjHGOhv7UoUDAz/jb1u2elbhazy4rRBL8PegPJFBhA==} + engines: {node: '>= 10.0.0'} + cpu: [arm64] + os: [linux] + + '@parcel/watcher-linux-x64-glibc@2.4.1': + resolution: {integrity: sha512-s9O3fByZ/2pyYDPoLM6zt92yu6P4E39a03zvO0qCHOTjxmt3GHRMLuRZEWhWLASTMSrrnVNWdVI/+pUElJBBBg==} + engines: {node: '>= 10.0.0'} + cpu: [x64] + os: [linux] + + '@parcel/watcher-linux-x64-musl@2.4.1': + resolution: {integrity: sha512-L2nZTYR1myLNST0O632g0Dx9LyMNHrn6TOt76sYxWLdff3cB22/GZX2UPtJnaqQPdCRoszoY5rcOj4oMTtp5fQ==} + engines: {node: '>= 10.0.0'} + cpu: [x64] + os: [linux] + + '@parcel/watcher-wasm@2.4.1': + resolution: {integrity: sha512-/ZR0RxqxU/xxDGzbzosMjh4W6NdYFMqq2nvo2b8SLi7rsl/4jkL8S5stIikorNkdR50oVDvqb/3JT05WM+CRRA==} + engines: {node: '>= 10.0.0'} + bundledDependencies: + - napi-wasm + + '@parcel/watcher-win32-arm64@2.4.1': + resolution: {integrity: sha512-Uq2BPp5GWhrq/lcuItCHoqxjULU1QYEcyjSO5jqqOK8RNFDBQnenMMx4gAl3v8GiWa59E9+uDM7yZ6LxwUIfRg==} + engines: {node: '>= 10.0.0'} + cpu: [arm64] + os: [win32] + + '@parcel/watcher-win32-ia32@2.4.1': + resolution: {integrity: sha512-maNRit5QQV2kgHFSYwftmPBxiuK5u4DXjbXx7q6eKjq5dsLXZ4FJiVvlcw35QXzk0KrUecJmuVFbj4uV9oYrcw==} + engines: {node: '>= 10.0.0'} + cpu: [ia32] + os: [win32] + + '@parcel/watcher-win32-x64@2.4.1': + resolution: {integrity: sha512-+DvS92F9ezicfswqrvIRM2njcYJbd5mb9CUgtrHCHmvn7pPPa+nMDRu1o1bYYz/l5IB2NVGNJWiH7h1E58IF2A==} + engines: {node: '>= 10.0.0'} + cpu: [x64] + os: [win32] + + '@parcel/watcher@2.4.1': + resolution: {integrity: sha512-HNjmfLQEVRZmHRET336f20H/8kOozUGwk7yajvsonjNxbj2wBTK1WsQuHkD5yYh9RxFGL2EyDHryOihOwUoKDA==} + engines: {node: '>= 10.0.0'} + + '@pkgjs/parseargs@0.11.0': + resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} + engines: {node: '>=14'} + + '@playwright/test@1.46.1': + resolution: {integrity: sha512-Fq6SwLujA/DOIvNC2EL/SojJnkKf/rAwJ//APpJJHRyMi1PdKrY3Az+4XNQ51N4RTbItbIByQ0jgd1tayq1aeA==} + engines: {node: '>=18'} + hasBin: true + + '@pnpm/config.env-replace@1.1.0': + resolution: {integrity: sha512-htyl8TWnKL7K/ESFa1oW2UB5lVDxuF5DpM7tBi6Hu2LNL3mWkIzNLG6N4zoCUP1lCKNxWy/3iu8mS8MvToGd6w==} + engines: {node: '>=12.22.0'} + + '@pnpm/network.ca-file@1.0.2': + resolution: {integrity: sha512-YcPQ8a0jwYU9bTdJDpXjMi7Brhkr1mXsXrUJvjqM2mQDgkRiz8jFaQGOdaLxgjtUfQgZhKy/O3cG/YwmgKaxLA==} + engines: {node: '>=12.22.0'} + + '@pnpm/npm-conf@2.3.1': + resolution: {integrity: sha512-c83qWb22rNRuB0UaVCI0uRPNRr8Z0FWnEIvT47jiHAmOIUHbBOg5XvV7pM5x+rKn9HRpjxquDbXYSXr3fAKFcw==} + engines: {node: '>=12'} + + '@rollup/pluginutils@4.2.1': + resolution: {integrity: sha512-iKnFXr7NkdZAIHiIWE+BX5ULi/ucVFYWD6TbAV+rZctiRTY2PL6tsIKhoIOaoskiWAkgu+VsbXgUVDNLHf+InQ==} + engines: {node: '>= 8.0.0'} + + '@shikijs/core@1.3.0': + resolution: {integrity: sha512-7fedsBfuILDTBmrYZNFI8B6ATTxhQAasUHllHmjvSZPnoq4bULWoTpHwmuQvZ8Aq03/tAa2IGo6RXqWtHdWaCA==} + + '@sinclair/typebox@0.27.8': + resolution: {integrity: sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA==} + + '@sindresorhus/is@5.6.0': + resolution: {integrity: sha512-TV7t8GKYaJWsn00tFDqBw8+Uqmr8A0fRU1tvTQhyZzGv0sJCGRQL3JGMI3ucuKo3XIZdUP+Lx7/gh2t3lewy7g==} + engines: {node: '>=14.16'} + + '@sindresorhus/slugify@2.2.1': + resolution: {integrity: sha512-MkngSCRZ8JdSOCHRaYd+D01XhvU3Hjy6MGl06zhOk614hp9EOAp5gIkBeQg7wtmxpitU6eAL4kdiRMcJa2dlrw==} + engines: {node: '>=12'} + + '@sindresorhus/transliterate@1.6.0': + resolution: {integrity: sha512-doH1gimEu3A46VX6aVxpHTeHrytJAG6HgdxntYnCFiIFHEM/ZGpG8KiZGBChchjQmG0XFIBL552kBTjVcMZXwQ==} + engines: {node: '>=12'} + + '@sinonjs/commons@3.0.1': + resolution: {integrity: sha512-K3mCHKQ9sVh8o1C9cxkwxaOmXoAMlDxC1mYyHrjqOWEcBjYr76t96zL2zlj5dUGZ3HSw240X1qgH3Mjf1yJWpQ==} + + '@sinonjs/fake-timers@10.3.0': + resolution: {integrity: sha512-V4BG07kuYSUkTCSBHG8G8TNhM+F19jXFWnQtzj+we8DrkpSBCee9Z3Ms8yiGer/dlmhe35/Xdgyo3/0rQKg7YA==} + + '@swc/counter@0.1.3': + resolution: {integrity: sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==} + + '@swc/helpers@0.5.5': + resolution: {integrity: sha512-KGYxvIOXcceOAbEk4bi/dVLEK9z8sZ0uBB3Il5b1rhfClSpcX0yfRO0KmTkqR2cnQDymwLB+25ZyMzICg/cm/A==} + + '@szmarczak/http-timer@5.0.1': + resolution: {integrity: sha512-+PmQX0PiAYPMeVYe237LJAYvOMYW1j2rH5YROyS3b4CTVJum34HfRvKvAzozHAQG0TnHNdUfY9nCeUyRAs//cw==} + engines: {node: '>=14.16'} + + '@testing-library/dom@10.4.0': + resolution: {integrity: sha512-pemlzrSESWbdAloYml3bAJMEfNh1Z7EduzqPKprCH5S341frlpYnUEW0H72dLxa6IsYr+mPno20GiSm+h9dEdQ==} + engines: {node: '>=18'} + + '@testing-library/jest-dom@6.4.8': + resolution: {integrity: sha512-JD0G+Zc38f5MBHA4NgxQMR5XtO5Jx9g86jqturNTt2WUfRmLDIY7iKkWHDCCTiDuFMre6nxAD5wHw9W5kI4rGw==} + engines: {node: '>=14', npm: '>=6', yarn: '>=1'} + + '@testing-library/react@16.0.0': + resolution: {integrity: sha512-guuxUKRWQ+FgNX0h0NS0FIq3Q3uLtWVpBzcLOggmfMoUpgBnzBzvLLd4fbm6yS8ydJd94cIfY4yP9qUQjM2KwQ==} + engines: {node: '>=18'} + peerDependencies: + '@testing-library/dom': ^10.0.0 + '@types/react': ^18.0.0 + '@types/react-dom': ^18.0.0 + react: ^18.0.0 + react-dom: ^18.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@testing-library/user-event@14.5.2': + resolution: {integrity: sha512-YAh82Wh4TIrxYLmfGcixwD18oIjyC1pFQC2Y01F2lzV2HTMiYrI0nze0FD0ocB//CKS/7jIUgae+adPqxK5yCQ==} + engines: {node: '>=12', npm: '>=6'} + peerDependencies: + '@testing-library/dom': '>=7.21.4' + + '@tokenizer/token@0.3.0': + resolution: {integrity: sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A==} + + '@tootallnate/once@2.0.0': + resolution: {integrity: sha512-XCuKFP5PS55gnMVu3dty8KPatLqUoy/ZYzDzAGCQ8JNFCkLXzmI7vNHCR+XpbZaMWQK/vQubr7PkYq8g470J/A==} + engines: {node: '>= 10'} + + '@trysound/sax@0.2.0': + resolution: {integrity: sha512-L7z9BgrNEcYyUYtF+HaEfiS5ebkh9jXqbszz7pC0hRBPaatV0XjSD3+eHrpqFemQfgwiFF0QPIarnIihIDn7OA==} + engines: {node: '>=10.13.0'} + + '@tsconfig/node10@1.0.11': + resolution: {integrity: sha512-DcRjDCujK/kCk/cUe8Xz8ZSpm8mS3mNNpta+jGCA6USEDfktlNvm1+IuZ9eTcDbNk41BHwpHHeW+N1lKCz4zOw==} + + '@tsconfig/node12@1.0.11': + resolution: {integrity: sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag==} + + '@tsconfig/node14@1.0.3': + resolution: {integrity: sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow==} + + '@tsconfig/node16@1.0.4': + resolution: {integrity: sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==} + + '@types/aria-query@5.0.4': + resolution: {integrity: sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==} + + '@types/babel__core@7.20.5': + resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==} + + '@types/babel__generator@7.6.8': + resolution: {integrity: sha512-ASsj+tpEDsEiFr1arWrlN6V3mdfjRMZt6LtK/Vp/kreFLnr5QH5+DhvD5nINYZXzwJvXeGq+05iUXcAzVrqWtw==} + + '@types/babel__template@7.4.4': + resolution: {integrity: sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==} + + '@types/babel__traverse@7.20.6': + resolution: {integrity: sha512-r1bzfrm0tomOI8g1SzvCaQHo6Lcv6zu0EA+W2kHrt8dyrHQxGzBBL4kdkzIS+jBMV+EYcMAEAqXqYaLJq5rOZg==} + + '@types/debug@4.1.12': + resolution: {integrity: sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==} + + '@types/eslint@9.6.0': + resolution: {integrity: sha512-gi6WQJ7cHRgZxtkQEoyHMppPjq9Kxo5Tjn2prSKDSmZrCz8TZ3jSRCeTJm+WoM+oB0WG37bRqLzaaU3q7JypGg==} + + '@types/eslint__js@8.42.3': + resolution: {integrity: sha512-alfG737uhmPdnvkrLdZLcEKJ/B8s9Y4hrZ+YAdzUeoArBlSUERA2E87ROfOaS4jd/C45fzOoZzidLc1IPwLqOw==} + + '@types/estree@1.0.5': + resolution: {integrity: sha512-/kYRxGDLWzHOB7q+wtSUQlFrtcdUccpfy+X+9iMBpHK8QLLhx2wIPYuS5DYtR9Wa/YlZAbIovy7qVdB1Aq6Lyw==} + + '@types/graceful-fs@4.1.9': + resolution: {integrity: sha512-olP3sd1qOEe5dXTSaFvQG+02VdRXcdytWLAZsAq1PecU8uqQAhkrnbli7DagjtXKW/Bl7YJbUsa8MPcuc8LHEQ==} + + '@types/hast@3.0.4': + resolution: {integrity: sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==} + + '@types/http-cache-semantics@4.0.4': + resolution: {integrity: sha512-1m0bIFVc7eJWyve9S0RnuRgcQqF/Xd5QsUZAZeQFr1Q3/p9JWoQQEqmVy+DPTNpGXwhgIetAoYF8JSc33q29QA==} + + '@types/http-proxy@1.17.15': + resolution: {integrity: sha512-25g5atgiVNTIv0LBDTg1H74Hvayx0ajtJPLLcYE3whFv75J0pWNtOBzaXJQgDTmrX1bx5U9YC2w/n65BN1HwRQ==} + + '@types/istanbul-lib-coverage@2.0.6': + resolution: {integrity: sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w==} + + '@types/istanbul-lib-report@3.0.3': + resolution: {integrity: sha512-NQn7AHQnk/RSLOxrBbGyJM/aVQ+pjj5HCgasFxc0K/KhoATfQ/47AyUl15I2yBUpihjmas+a+VJBOqecrFH+uA==} + + '@types/istanbul-reports@3.0.4': + resolution: {integrity: sha512-pk2B1NWalF9toCRu6gjBzR69syFjP4Od8WRAX+0mmf9lAjCRicLOWc+ZrxZHx/0XRjotgkF9t6iaMJ+aXcOdZQ==} + + '@types/jest@29.5.12': + resolution: {integrity: sha512-eDC8bTvT/QhYdxJAulQikueigY5AsdBRH2yDKW3yveW7svY3+DzN84/2NUgkw10RTiJbWqZrTtoGVdYlvFJdLw==} + + '@types/jsdom@20.0.1': + resolution: {integrity: sha512-d0r18sZPmMQr1eG35u12FZfhIXNrnsPU/g5wvRKCUf/tOGilKKwYMYGqh33BNR6ba+2gkHw1EUiHoN3mn7E5IQ==} + + '@types/json-schema@7.0.15': + resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} + + '@types/mdast@4.0.4': + resolution: {integrity: sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==} + + '@types/ms@0.7.34': + resolution: {integrity: sha512-nG96G3Wp6acyAgJqGasjODb+acrI7KltPiRxzHPXnP3NgI28bpQDRv53olbqGXbfcgF5aiiHmO3xpwEpS5Ld9g==} + + '@types/node@20.15.0': + resolution: {integrity: sha512-eQf4OkH6gA9v1W0iEpht/neozCsZKMTK+C4cU6/fv7wtJCCL8LEQ4hie2Ln8ZP/0YYM2xGj7//f8xyqItkJ6QA==} + + '@types/normalize-package-data@2.4.4': + resolution: {integrity: sha512-37i+OaWTh9qeK4LSHPsyRC7NahnGotNuZvjLSgcPzblpHB3rrCJxAOgI5gCdKm7coonsaX1Of0ILiTcnZjbfxA==} + + '@types/prop-types@15.7.12': + resolution: {integrity: sha512-5zvhXYtRNRluoE/jAp4GVsSduVUzNWKkOZrCDBWYtE7biZywwdC2AcEzg+cSMLFRfVgeAFqpfNabiPjxFddV1Q==} + + '@types/react-dom@18.3.0': + resolution: {integrity: sha512-EhwApuTmMBmXuFOikhQLIBUn6uFg81SwLMOAUgodJF14SOBOCMdU04gDoYi0WOJJHD144TL32z4yDqCW3dnkQg==} + + '@types/react@18.3.3': + resolution: {integrity: sha512-hti/R0pS0q1/xx+TsI73XIqk26eBsISZ2R0wUijXIngRK9R/e7Xw/cXVxQK7R5JjW+SV4zGcn5hXjudkN/pLIw==} + + '@types/retry@0.12.1': + resolution: {integrity: sha512-xoDlM2S4ortawSWORYqsdU+2rxdh4LRW9ytc3zmT37RIKQh6IHyKwwtKhKis9ah8ol07DCkZxPt8BBvPjC6v4g==} + + '@types/stack-utils@2.0.3': + resolution: {integrity: sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw==} + + '@types/tough-cookie@4.0.5': + resolution: {integrity: sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA==} + + '@types/triple-beam@1.3.5': + resolution: {integrity: sha512-6WaYesThRMCl19iryMYP7/x2OVgCtbIVflDGFpWnb9irXI3UjYE4AzmYuiUKY1AJstGijoY+MgUszMgRxIYTYw==} + + '@types/unist@3.0.2': + resolution: {integrity: sha512-dqId9J8K/vGi5Zr7oo212BGii5m3q5Hxlkwy3WpYuKPklmBEvsbMYYyLxAQpSffdLl/gdW0XUpKWFvYmyoWCoQ==} + + '@types/yargs-parser@21.0.3': + resolution: {integrity: sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ==} + + '@types/yargs@16.0.9': + resolution: {integrity: sha512-tHhzvkFXZQeTECenFoRljLBYPZJ7jAVxqqtEI0qTLOmuultnFp4I9yKE17vTuhf7BkhCu7I4XuemPgikDVuYqA==} + + '@types/yargs@17.0.33': + resolution: {integrity: sha512-WpxBCKWPLr4xSsHgz511rFJAM+wS28w2zEO1QDNY5zM/S8ok70NNfztH0xwhqKyaK0OHCbN98LDAZuy1ctxDkA==} + + '@types/yauzl@2.10.3': + resolution: {integrity: sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==} + + '@typescript-eslint/eslint-plugin@8.1.0': + resolution: {integrity: sha512-LlNBaHFCEBPHyD4pZXb35mzjGkuGKXU5eeCA1SxvHfiRES0E82dOounfVpL4DCqYvJEKab0bZIA0gCRpdLKkCw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + '@typescript-eslint/parser': ^8.0.0 || ^8.0.0-alpha.0 + eslint: ^8.57.0 || ^9.0.0 + typescript: '*' + peerDependenciesMeta: + typescript: + optional: true + + '@typescript-eslint/parser@8.1.0': + resolution: {integrity: sha512-U7iTAtGgJk6DPX9wIWPPOlt1gO57097G06gIcl0N0EEnNw8RGD62c+2/DiP/zL7KrkqnnqF7gtFGR7YgzPllTA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 + typescript: '*' + peerDependenciesMeta: + typescript: + optional: true + + '@typescript-eslint/scope-manager@8.1.0': + resolution: {integrity: sha512-DsuOZQji687sQUjm4N6c9xABJa7fjvfIdjqpSIIVOgaENf2jFXiM9hIBZOL3hb6DHK9Nvd2d7zZnoMLf9e0OtQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@typescript-eslint/type-utils@8.1.0': + resolution: {integrity: sha512-oLYvTxljVvsMnldfl6jIKxTaU7ok7km0KDrwOt1RHYu6nxlhN3TIx8k5Q52L6wR33nOwDgM7VwW1fT1qMNfFIA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '*' + peerDependenciesMeta: + typescript: + optional: true + + '@typescript-eslint/types@5.62.0': + resolution: {integrity: sha512-87NVngcbVXUahrRTqIK27gD2t5Cu1yuCXxbLcFtCzZGlfyVWWh8mLHkoxzjsB6DDNnvdL+fW8MiwPEJyGJQDgQ==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + + '@typescript-eslint/types@8.1.0': + resolution: {integrity: sha512-q2/Bxa0gMOu/2/AKALI0tCKbG2zppccnRIRCW6BaaTlRVaPKft4oVYPp7WOPpcnsgbr0qROAVCVKCvIQ0tbWog==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@typescript-eslint/typescript-estree@5.62.0': + resolution: {integrity: sha512-CmcQ6uY7b9y694lKdRB8FEel7JbU/40iSAPomu++SjLMntB+2Leay2LO6i8VnJk58MtE9/nQSFIH6jpyRWyYzA==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + peerDependencies: + typescript: '*' + peerDependenciesMeta: + typescript: + optional: true + + '@typescript-eslint/typescript-estree@8.1.0': + resolution: {integrity: sha512-NTHhmufocEkMiAord/g++gWKb0Fr34e9AExBRdqgWdVBaKoei2dIyYKD9Q0jBnvfbEA5zaf8plUFMUH6kQ0vGg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '*' + peerDependenciesMeta: + typescript: + optional: true + + '@typescript-eslint/utils@8.1.0': + resolution: {integrity: sha512-ypRueFNKTIFwqPeJBfeIpxZ895PQhNyH4YID6js0UoBImWYoSjBsahUn9KMiJXh94uOjVBgHD9AmkyPsPnFwJA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 + + '@typescript-eslint/visitor-keys@5.62.0': + resolution: {integrity: sha512-07ny+LHRzQXepkGg6w0mFY41fVUNBrL2Roj/++7V1txKugfjm/Ci/qSND03r2RhlJhJYMcTn9AhhSSqQp0Ysyw==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + + '@typescript-eslint/visitor-keys@8.1.0': + resolution: {integrity: sha512-ba0lNI19awqZ5ZNKh6wCModMwoZs457StTebQ0q1NP58zSi2F6MOZRXwfKZy+jB78JNJ/WH8GSh2IQNzXX8Nag==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@ungap/structured-clone@1.2.0': + resolution: {integrity: sha512-zuVdFrMJiuCDQUMCzQaD6KL28MjnqqN8XnAqiEq9PNm/hCPTSGfrXCOfwj1ow4LFb/tNymJPwsNbVePc1xFqrQ==} + + '@vercel/nft@0.27.3': + resolution: {integrity: sha512-oySTdDSzUAFDXpsSLk9Q943o+/Yu/+TCFxnehpFQEf/3khi2stMpTHPVNwFdvZq/Z4Ky93lE+MGHpXCRpMkSCA==} + engines: {node: '>=16'} + hasBin: true + + '@xhmikosr/archive-type@6.0.1': + resolution: {integrity: sha512-PB3NeJL8xARZt52yDBupK0dNPn8uIVQDe15qNehUpoeeLWCZyAOam4vGXnoZGz2N9D1VXtjievJuCsXam2TmbQ==} + engines: {node: ^14.14.0 || >=16.0.0} + + '@xhmikosr/decompress-tar@7.0.0': + resolution: {integrity: sha512-kyWf2hybtQVbWtB+FdRyOT+jyR5jxCNZPLqvQGB7djZj75lrpLUPEmRbyo86AtJ5OEtivpYaNWjCkqSJ8xtRWw==} + engines: {node: ^14.14.0 || >=16.0.0} + + '@xhmikosr/decompress-tarbz2@7.0.0': + resolution: {integrity: sha512-3QnjipYkRgh3Dee1MWDgKmANWxOQBVN4e1IwiGNe2fHYfMYTeSkVvWREt87UIoSucKUh3E95v8uGFttgTknZcA==} + engines: {node: ^14.14.0 || >=16.0.0} + + '@xhmikosr/decompress-targz@7.0.0': + resolution: {integrity: sha512-7BNHJl92g9OLhw89zqcFS67V1LAtm4Ex02j6OiQzuE8P7Yy9lQcyBuEL3x6v436grLdL+BcFjgbmhWxnem4GHw==} + engines: {node: ^14.14.0 || >=16.0.0} + + '@xhmikosr/decompress-unzip@6.0.0': + resolution: {integrity: sha512-R1HAkjXLS7RAL74YFLxYY9zYflCcYGssld9KKFDu87PnJ4h4btdhzXfSC8J5i5A2njH3oYIoCzx03RIGTH07Sg==} + engines: {node: ^14.14.0 || >=16.0.0} + + '@xhmikosr/decompress@9.0.1': + resolution: {integrity: sha512-9Lvlt6Qdpo9SaRQyRIXCo3lgU++eMZ68lzgjcTwtuKDrlwT635+5zsHZ1yrSx/Blc5IDuVLlPkBPj5CZkx+2+Q==} + engines: {node: ^14.14.0 || >=16.0.0} + + '@xhmikosr/downloader@13.0.1': + resolution: {integrity: sha512-mBvWew1kZJHfNQVVfVllMjUDwCGN9apPa0t4/z1zaUJ9MzpXjRL3w8fsfJKB8gHN/h4rik9HneKfDbh2fErN+w==} + engines: {node: ^14.14.0 || >=16.0.0} + + abab@2.0.6: + resolution: {integrity: sha512-j2afSsaIENvHZN2B8GOpF566vZ5WVk5opAiMTvWgaQT8DkbOqsTfvNAvHoRGU2zzP8cPoqys+xHTRDWW8L+/BA==} + deprecated: Use your platform's native atob() and btoa() methods instead + + abbrev@1.1.1: + resolution: {integrity: sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==} + + abort-controller@3.0.0: + resolution: {integrity: sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==} + engines: {node: '>=6.5'} + + abstract-logging@2.0.1: + resolution: {integrity: sha512-2BjRTZxTPvheOvGbBslFSYOUkr+SjPtOnrLP33f+VIWLzezQpZcqVg7ja3L4dBXmzzgwT+a029jRx5PCi3JuiA==} + + accepts@1.3.8: + resolution: {integrity: sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==} + engines: {node: '>= 0.6'} + + acorn-globals@7.0.1: + resolution: {integrity: sha512-umOSDSDrfHbTNPuNpC2NSnnA3LUrqpevPb4T9jRx4MagXNS0rs+gwiTcAvqCRmsD6utzsrzNt+ebm00SNWiC3Q==} + + acorn-import-attributes@1.9.5: + resolution: {integrity: sha512-n02Vykv5uA3eHGM/Z2dQrcD56kL8TyDb2p1+0P83PClMnC/nc+anbQRhIOWnSq4Ke/KvDPrY3C9hDtC/A3eHnQ==} + peerDependencies: + acorn: ^8 + + acorn-jsx@5.3.2: + resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} + peerDependencies: + acorn: ^6.0.0 || ^7.0.0 || ^8.0.0 + + acorn-walk@8.3.3: + resolution: {integrity: sha512-MxXdReSRhGO7VlFe1bRG/oI7/mdLV9B9JJT0N8vZOhF7gFRR5l3M8W9G8JxmKV+JC5mGqJ0QvqfSOLsCPa4nUw==} + engines: {node: '>=0.4.0'} + + acorn@8.12.1: + resolution: {integrity: sha512-tcpGyI9zbizT9JbV6oYE477V6mTlXvvi0T0G3SNIYE2apm/G5huBa1+K89VGeovbg+jycCrfhl3ADxErOuO6Jg==} + engines: {node: '>=0.4.0'} + hasBin: true + + agent-base@6.0.2: + resolution: {integrity: sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==} + engines: {node: '>= 6.0.0'} + + agent-base@7.1.1: + resolution: {integrity: sha512-H0TSyFNDMomMNJQBn8wFV5YC/2eJ+VXECwOadZJT554xP6cODZHPX3H9QMQECxvrgiSOP1pHjy1sMWQVYJOUOA==} + engines: {node: '>= 14'} + + aggregate-error@4.0.1: + resolution: {integrity: sha512-0poP0T7el6Vq3rstR8Mn4V/IQrpBLO6POkUSrN7RhyY+GF/InCFShQzsQ39T25gkHhLgSLByyAz+Kjb+c2L98w==} + engines: {node: '>=12'} + + ajv-errors@3.0.0: + resolution: {integrity: sha512-V3wD15YHfHz6y0KdhYFjyy9vWtEVALT9UrxfN3zqlI6dMioHnJrqOYfyPKol3oqrnCM9uwkcdCwkJ0WUcbLMTQ==} + peerDependencies: + ajv: ^8.0.1 + + ajv-formats@2.1.1: + resolution: {integrity: sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==} + peerDependencies: + ajv: ^8.0.0 + peerDependenciesMeta: + ajv: + optional: true + + ajv-formats@3.0.1: + resolution: {integrity: sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==} + peerDependencies: + ajv: ^8.0.0 + peerDependenciesMeta: + ajv: + optional: true + + ajv@6.12.6: + resolution: {integrity: sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==} + + ajv@8.17.1: + resolution: {integrity: sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==} + + all-node-versions@11.3.0: + resolution: {integrity: sha512-psMkc5s3qpr+QMfires9bC4azRYciPWql1wqZKMsYRh1731qefQDH2X4+O19xSBX6u0Ra/8Y5diG6y/fEmqKsw==} + engines: {node: '>=14.18.0'} + + ansi-align@3.0.1: + resolution: {integrity: sha512-IOfwwBF5iczOjp/WeY4YxyjqAFMQoZufdQWDd19SEExbVLNXqvpzSJ/M7Za4/sCPmQ0+GRquoA7bGcINcxew6w==} + + ansi-escapes@3.2.0: + resolution: {integrity: sha512-cBhpre4ma+U0T1oM5fXg7Dy1Jw7zzwv7lt/GoCpr+hDQJoYnKVPLL4dCvSEFMmQurOQvSrwT7SL/DAlhBI97RQ==} + engines: {node: '>=4'} + + ansi-escapes@4.3.2: + resolution: {integrity: sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==} + engines: {node: '>=8'} + + ansi-escapes@5.0.0: + resolution: {integrity: sha512-5GFMVX8HqE/TB+FuBJGuO5XG0WrsA6ptUqoODaT/n9mmUaZFkqnBueB4leqGBCmrUHnCnC4PCZTCd0E7QQ83bA==} + engines: {node: '>=12'} + + ansi-escapes@6.2.1: + resolution: {integrity: sha512-4nJ3yixlEthEJ9Rk4vPcdBRkZvQZlYyu8j4/Mqz5sgIkddmEnH2Yj2ZrnP9S3tQOvSNRUIgVNF/1yPpRAGNRig==} + engines: {node: '>=14.16'} + + ansi-escapes@7.0.0: + resolution: {integrity: sha512-GdYO7a61mR0fOlAsvC9/rIHf7L96sBc6dEWzeOu+KAea5bZyQRPIpojrVoI4AXGJS/ycu/fBTdLrUkA4ODrvjw==} + engines: {node: '>=18'} + + ansi-regex@3.0.1: + resolution: {integrity: sha512-+O9Jct8wf++lXxxFc4hc8LsjaSq0HFzzL7cVsw8pRDIPdjKD2mT4ytDZlLuSBZ4cLKZFXIrMGO7DbQCtMJJMKw==} + engines: {node: '>=4'} + + ansi-regex@4.1.1: + resolution: {integrity: sha512-ILlv4k/3f6vfQ4OoP2AGvirOktlQ98ZEL1k9FaQjxa3L1abBgbuTDAdPOpvbGncC0BTVQrl+OM8xZGK6tWXt7g==} + engines: {node: '>=6'} + + ansi-regex@5.0.1: + resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} + engines: {node: '>=8'} + + ansi-regex@6.0.1: + resolution: {integrity: sha512-n5M855fKb2SsfMIiFFoVrABHJC8QtHwVx+mHWP3QcEqBHYienj5dHSgjbxtC0WEZXYt4wcD6zrQElDPhFuZgfA==} + engines: {node: '>=12'} + + ansi-styles@3.2.1: + resolution: {integrity: sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==} + engines: {node: '>=4'} + + ansi-styles@4.3.0: + resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} + engines: {node: '>=8'} + + ansi-styles@5.2.0: + resolution: {integrity: sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==} + engines: {node: '>=10'} + + ansi-styles@6.2.1: + resolution: {integrity: sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==} + engines: {node: '>=12'} + + ansi-to-html@0.7.2: + resolution: {integrity: sha512-v6MqmEpNlxF+POuyhKkidusCHWWkaLcGRURzivcU3I9tv7k4JVhFcnukrM5Rlk2rUywdZuzYAZ+kbZqWCnfN3g==} + engines: {node: '>=8.0.0'} + hasBin: true + + any-promise@1.3.0: + resolution: {integrity: sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==} + + anymatch@3.1.3: + resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==} + engines: {node: '>= 8'} + + aproba@2.0.0: + resolution: {integrity: sha512-lYe4Gx7QT+MKGbDsA+Z+he/Wtef0BiwDOlK/XkBrdfsh9J/jPPXbX0tE9x9cl27Tmu5gg3QUbUrQYa/y+KOHPQ==} + + archiver-utils@5.0.2: + resolution: {integrity: sha512-wuLJMmIBQYCsGZgYLTy5FIB2pF6Lfb6cXMSF8Qywwk3t20zWnAi7zLcQFdKQmIB8wyZpY5ER38x08GbwtR2cLA==} + engines: {node: '>= 14'} + + archiver@7.0.1: + resolution: {integrity: sha512-ZcbTaIqJOfCc03QwD468Unz/5Ir8ATtvAHsK+FdXbDIbGfihqh9mrvdcYunQzqn4HrvWWaFyaxJhGZagaJJpPQ==} + engines: {node: '>= 14'} + + are-we-there-yet@2.0.0: + resolution: {integrity: sha512-Ci/qENmwHnsYo9xKIcUJN5LeDKdJ6R1Z1j9V/J5wyq8nh/mYPEpIKJbBZXtZjG04HiK7zV/p6Vs9952MrMeUIw==} + engines: {node: '>=10'} + deprecated: This package is no longer supported. + + arg@4.1.3: + resolution: {integrity: sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==} + + arg@5.0.2: + resolution: {integrity: sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==} + + argparse@1.0.10: + resolution: {integrity: sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==} + + argparse@2.0.1: + resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + + aria-query@5.3.0: + resolution: {integrity: sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==} + + array-flatten@1.1.1: + resolution: {integrity: sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==} + + array-timsort@1.0.3: + resolution: {integrity: sha512-/+3GRL7dDAGEfM6TseQk/U+mi18TU2Ms9I3UlLdUMhz2hbvGNTKdj9xniwXfUqgYhHxRx0+8UnKkvlNwVU+cWQ==} + + array-union@2.1.0: + resolution: {integrity: sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==} + engines: {node: '>=8'} + + arrify@3.0.0: + resolution: {integrity: sha512-tLkvA81vQG/XqE2mjDkGQHoOINtMHtysSnemrmoGe6PydDPMRbVugqyk4A6V/WDWEfm3l+0d8anA9r8cv/5Jaw==} + engines: {node: '>=12'} + + ascii-table@0.0.9: + resolution: {integrity: sha512-xpkr6sCDIYTPqzvjG8M3ncw1YOTaloWZOyrUmicoEifBEKzQzt+ooUpRpQ/AbOoJfO/p2ZKiyp79qHThzJDulQ==} + + ast-module-types@5.0.0: + resolution: {integrity: sha512-JvqziE0Wc0rXQfma0HZC/aY7URXHFuZV84fJRtP8u+lhp0JYCNd5wJzVXP45t0PH0Mej3ynlzvdyITYIu0G4LQ==} + engines: {node: '>=14'} + + astral-regex@2.0.0: + resolution: {integrity: sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ==} + engines: {node: '>=8'} + + async-sema@3.1.1: + resolution: {integrity: sha512-tLRNUXati5MFePdAk8dw7Qt7DpxPB60ofAgn8WRhW6a2rcimZnYBP9oxHiv0OHy+Wz7kPMG+t4LGdt31+4EmGg==} + + async@1.5.2: + resolution: {integrity: sha512-nSVgobk4rv61R9PUSDtYt7mPVB2olxNR5RWJcAsH676/ef11bUZwvu7+RGYrYauVdDPcO519v68wRhXQtxsV9w==} + + async@3.2.5: + resolution: {integrity: sha512-baNZyqaaLhyLVKm/DlvdW051MSgO6b8eVfIezl9E5PqWxFgzLm/wQntEW4zOytVburDEr0JlALEpdOFwvErLsg==} + + asynckit@0.4.0: + resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} + + atomic-sleep@1.0.0: + resolution: {integrity: sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==} + engines: {node: '>=8.0.0'} + + autoprefixer@10.4.20: + resolution: {integrity: sha512-XY25y5xSv/wEoqzDyXXME4AFfkZI0P23z6Fs3YgymDnKJkCGOnkL0iTxCa85UTqaSgfcqyf3UA6+c7wUvx/16g==} + engines: {node: ^10 || ^12 || >=14} + hasBin: true + peerDependencies: + postcss: ^8.1.0 + + avvio@8.4.0: + resolution: {integrity: sha512-CDSwaxINFy59iNwhYnkvALBwZiTydGkOecZyPkqBpABYR1KqGEsET0VOOYDwtleZSUIdeY36DC2bSZ24CO1igA==} + + b4a@1.6.6: + resolution: {integrity: sha512-5Tk1HLk6b6ctmjIkAcU/Ujv/1WqiDl0F0JdRCR80VsOcUlHcu7pWeWRlOqQLHfDEsVx9YH/aif5AG4ehoCtTmg==} + + babel-jest@29.7.0: + resolution: {integrity: sha512-BrvGY3xZSwEcCzKvKsCi2GgHqDqsYkOP4/by5xCgIwGXQxIEh+8ew3gmrE1y7XRR6LHZIj6yLYnUi/mm2KXKBg==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + peerDependencies: + '@babel/core': ^7.8.0 + + babel-plugin-istanbul@6.1.1: + resolution: {integrity: sha512-Y1IQok9821cC9onCx5otgFfRm7Lm+I+wwxOx738M/WLPZ9Q42m4IG5W0FNX8WLL2gYMZo3JkuXIH2DOpWM+qwA==} + engines: {node: '>=8'} + + babel-plugin-jest-hoist@29.6.3: + resolution: {integrity: sha512-ESAc/RJvGTFEzRwOTT4+lNDk/GNHMkKbNzsvT0qKRfDyyYTskxB5rnU2njIDYVxXCBHHEI1c0YwHob3WaYujOg==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + babel-preset-current-node-syntax@1.0.1: + resolution: {integrity: sha512-M7LQ0bxarkxQoN+vz5aJPsLBn77n8QgTFmo8WK0/44auK2xlCXrYcUxHFxgU7qW5Yzw/CjmLRK2uJzaCd7LvqQ==} + peerDependencies: + '@babel/core': ^7.0.0 + + babel-preset-jest@29.6.3: + resolution: {integrity: sha512-0B3bhxR6snWXJZtR/RliHTDPRgn1sNHOR0yVtq/IiQFyuOVjFS+wuio/R4gSNkyYmKmJB4wGZv2NZanmKmTnNA==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + peerDependencies: + '@babel/core': ^7.0.0 + + backoff@2.5.0: + resolution: {integrity: sha512-wC5ihrnUXmR2douXmXLCe5O3zg3GKIyvRi/hi58a/XyRxVI+3/yM0PYueQOZXPXQ9pxBislYkw+sF9b7C/RuMA==} + engines: {node: '>= 0.6'} + + bail@2.0.2: + resolution: {integrity: sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==} + + balanced-match@1.0.2: + resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} + + balanced-match@2.0.0: + resolution: {integrity: sha512-1ugUSr8BHXRnK23KfuYS+gVMC3LB8QGH9W1iGtDPsNWoQbgtXSExkBu2aDR4epiGWZOjZsj6lDl/N/AqqTC3UA==} + + bare-events@2.4.2: + resolution: {integrity: sha512-qMKFd2qG/36aA4GwvKq8MxnPgCQAmBWmSyLWsJcbn8v03wvIPQ/hG1Ms8bPzndZxMDoHpxez5VOS+gC9Yi24/Q==} + + bare-fs@2.3.1: + resolution: {integrity: sha512-W/Hfxc/6VehXlsgFtbB5B4xFcsCl+pAh30cYhoFyXErf6oGrwjh8SwiPAdHgpmWonKuYpZgGywN0SXt7dgsADA==} + + bare-os@2.4.0: + resolution: {integrity: sha512-v8DTT08AS/G0F9xrhyLtepoo9EJBJ85FRSMbu1pQUlAf6A8T0tEEQGMVObWeqpjhSPXsE0VGlluFBJu2fdoTNg==} + + bare-path@2.1.3: + resolution: {integrity: sha512-lh/eITfU8hrj9Ru5quUp0Io1kJWIk1bTjzo7JH1P5dWmQ2EL4hFUlfI8FonAhSlgIfhn63p84CDY/x+PisgcXA==} + + bare-stream@2.1.3: + resolution: {integrity: sha512-tiDAH9H/kP+tvNO5sczyn9ZAA7utrSMobyDchsnyyXBuUe2FSQWbxhtuHB8jwpHYYevVo2UJpcmvvjrbHboUUQ==} + + base64-js@1.5.1: + resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} + + before-after-hook@2.2.3: + resolution: {integrity: sha512-NzUnlZexiaH/46WDhANlyR2bXRopNg4F/zuSA3OpZnllCUgRaOF2znDioDWrmbNVsuZk6l9pMquQB38cfBZwkQ==} + + better-ajv-errors@1.2.0: + resolution: {integrity: sha512-UW+IsFycygIo7bclP9h5ugkNH8EjCSgqyFB/yQ4Hqqa1OEYDtb0uFIkYE0b6+CjkgJYVM5UKI/pJPxjYe9EZlA==} + engines: {node: '>= 12.13.0'} + peerDependencies: + ajv: 4.11.8 - 8 + + better-opn@3.0.2: + resolution: {integrity: sha512-aVNobHnJqLiUelTaHat9DZ1qM2w0C0Eym4LPI/3JxOnSokGVdsl1T1kN7TFvsEAD8G47A6VKQ0TVHqbBnYMJlQ==} + engines: {node: '>=12.0.0'} + + binary-extensions@2.3.0: + resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==} + engines: {node: '>=8'} + + bindings@1.5.0: + resolution: {integrity: sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==} + + bl@4.1.0: + resolution: {integrity: sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==} + + blueimp-md5@2.19.0: + resolution: {integrity: sha512-DRQrD6gJyy8FbiE4s+bDoXS9hiW3Vbx5uCdwvcCf3zLHL+Iv7LtGHLpr+GZV8rHG8tK766FGYBwRbu8pELTt+w==} + + body-parser@1.20.2: + resolution: {integrity: sha512-ml9pReCu3M61kGlqoTm2umSXTlRTuGTx0bfYj+uIUKKYycG5NtSbeetV3faSU6R7ajOPw0g/J1PvK4qNy7s5bA==} + engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16} + + boolbase@1.0.0: + resolution: {integrity: sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==} + + boxen@7.1.1: + resolution: {integrity: sha512-2hCgjEmP8YLWQ130n2FerGv7rYpfBmnmp9Uy2Le1vge6X3gZIfSmEzP5QTDElFxcvVcXlEn8Aq6MU/PZygIOog==} + engines: {node: '>=14.16'} + + brace-expansion@1.1.11: + resolution: {integrity: sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==} + + brace-expansion@2.0.1: + resolution: {integrity: sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==} + + braces@3.0.3: + resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} + engines: {node: '>=8'} + + browserslist@4.23.3: + resolution: {integrity: sha512-btwCFJVjI4YWDNfau8RhZ+B1Q/VLoUITrm3RlP6y1tYGWIOa+InuYiRGXUBXo8nA1qKmHMyLB/iVQg5TT4eFoA==} + engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} + hasBin: true + + bser@2.1.1: + resolution: {integrity: sha512-gQxTNE/GAfIIrmHLUE3oJyp5FO6HRBfhjnw4/wMmA63ZGDJnWBmgY/lyQBpnDUkGmAhbSe39tx2d/iTOAfglwQ==} + + buffer-crc32@0.2.13: + resolution: {integrity: sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==} + + buffer-crc32@1.0.0: + resolution: {integrity: sha512-Db1SbgBS/fg/392AblrMJk97KggmvYhr4pB5ZIMTWtaivCPMWLkmb7m21cJvpvgK+J3nsU2CmmixNBZx4vFj/w==} + engines: {node: '>=8.0.0'} + + buffer-equal-constant-time@1.0.1: + resolution: {integrity: sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==} + + buffer-from@1.1.2: + resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} + + buffer@5.7.1: + resolution: {integrity: sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==} + + buffer@6.0.3: + resolution: {integrity: sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==} + + builtin-modules@3.3.0: + resolution: {integrity: sha512-zhaCDicdLuWN5UbN5IMnFqNMhNfo919sH85y2/ea+5Yg9TsTkeZxpL+JLbp6cgYFS4sRLp3YV4S6yDuqVWHYOw==} + engines: {node: '>=6'} + + builtins@5.1.0: + resolution: {integrity: sha512-SW9lzGTLvWTP1AY8xeAMZimqDrIaSdLQUcVr9DMef51niJ022Ri87SwRRKYm4A6iHfkPaiVUu/Duw2Wc4J7kKg==} + + busboy@1.6.0: + resolution: {integrity: sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==} + engines: {node: '>=10.16.0'} + + byline@5.0.0: + resolution: {integrity: sha512-s6webAy+R4SR8XVuJWt2V2rGvhnrhxN+9S15GNuTK3wKPOXFF6RNc+8ug2XhH+2s4f+uudG4kUVYmYOQWL2g0Q==} + engines: {node: '>=0.10.0'} + + bytes@3.1.2: + resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==} + engines: {node: '>= 0.8'} + + cacheable-lookup@7.0.0: + resolution: {integrity: sha512-+qJyx4xiKra8mZrcwhjMRMUhD5NR1R8esPkzIYxX96JiecFoxAXFuz/GpR3+ev4PE1WamHip78wV0vcmPQtp8w==} + engines: {node: '>=14.16'} + + cacheable-request@10.2.14: + resolution: {integrity: sha512-zkDT5WAF4hSSoUgyfg5tFIxz8XQK+25W/TLVojJTMKBaxevLBBtLxgqguAuVQB8PVW79FVjHcU+GJ9tVbDZ9mQ==} + engines: {node: '>=14.16'} + + cachedir@2.4.0: + resolution: {integrity: sha512-9EtFOZR8g22CL7BWjJ9BUx1+A/djkofnyW3aOXZORNW2kxoUpx2h+uN2cOqwPmFhnpVmxg+KW2OjOSgChTEvsQ==} + engines: {node: '>=6'} + + call-bind@1.0.7: + resolution: {integrity: sha512-GHTSNSYICQ7scH7sZ+M2rFopRoLh8t2bLSW6BbgrtLsahOIB5iyAVJf9GjWK3cYTDaMj4XdBpM1cA6pIS0Kv2w==} + engines: {node: '>= 0.4'} + + callsite@1.0.0: + resolution: {integrity: sha512-0vdNRFXn5q+dtOqjfFtmtlI9N2eVZ7LMyEV2iKC5mEEFvSg/69Ml6b/WU2qF8W1nLRa0wiSrDT3Y5jOHZCwKPQ==} + + callsites@3.1.0: + resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} + engines: {node: '>=6'} + + camelcase-css@2.0.1: + resolution: {integrity: sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==} + engines: {node: '>= 6'} + + camelcase@5.3.1: + resolution: {integrity: sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==} + engines: {node: '>=6'} + + camelcase@6.3.0: + resolution: {integrity: sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==} + engines: {node: '>=10'} + + camelcase@7.0.1: + resolution: {integrity: sha512-xlx1yCK2Oc1APsPXDL2LdlNP6+uu8OCDdhOBSVT279M/S+y75O30C2VuD8T2ogdePBBl7PfPF4504tnLgX3zfw==} + engines: {node: '>=14.16'} + + caniuse-lite@1.0.30001642: + resolution: {integrity: sha512-3XQ0DoRgLijXJErLSl+bLnJ+Et4KqV1PY6JJBGAFlsNsz31zeAIncyeZfLCabHK/jtSh+671RM9YMldxjUPZtA==} + + caniuse-lite@1.0.30001647: + resolution: {integrity: sha512-n83xdNiyeNcHpzWY+1aFbqCK7LuLfBricc4+alSQL2Xb6OR3XpnQAmlDG+pQcdTfiHRuLcQ96VOfrPSGiNJYSg==} + + ccount@2.0.1: + resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==} + + chalk@2.4.2: + resolution: {integrity: sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==} + engines: {node: '>=4'} + + chalk@3.0.0: + resolution: {integrity: sha512-4D3B6Wf41KOYRFdszmDqMCGq5VV/uMAB273JILmO+3jAlh8X4qDtdtgCR3fxtbLEMzSx22QdhnDcJvu2u1fVwg==} + engines: {node: '>=8'} + + chalk@4.1.2: + resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} + engines: {node: '>=10'} + + chalk@5.3.0: + resolution: {integrity: sha512-dLitG79d+GV1Nb/VYcCDFivJeK1hiukt9QjRNVOsUtTy1rR1YJsmpGGTZ3qJos+uw7WmWF4wUwBd9jxjocFC2w==} + engines: {node: ^12.17.0 || ^14.13 || >=16.0.0} + + char-regex@1.0.2: + resolution: {integrity: sha512-kWWXztvZ5SBQV+eRgKFeh8q5sLuZY2+8WUIzlxWVTg+oGwY14qylx1KbKzHd8P6ZYkAg0xyIDU9JMHhyJMZ1jw==} + engines: {node: '>=10'} + + character-entities-html4@2.1.0: + resolution: {integrity: sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==} + + character-entities-legacy@3.0.0: + resolution: {integrity: sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==} + + character-entities@2.0.2: + resolution: {integrity: sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==} + + chardet@0.7.0: + resolution: {integrity: sha512-mT8iDcrh03qDGRRmoA2hmBJnxpllMR+0/0qlzjqZES6NdiWDcZkCNAk4rPFZ9Q85r27unkiNNg8ZOiwZXBHwcA==} + + chokidar@3.6.0: + resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==} + engines: {node: '>= 8.10.0'} + + chownr@1.1.4: + resolution: {integrity: sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==} + + chownr@2.0.0: + resolution: {integrity: sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==} + engines: {node: '>=10'} + + ci-info@3.9.0: + resolution: {integrity: sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==} + engines: {node: '>=8'} + + ci-info@4.0.0: + resolution: {integrity: sha512-TdHqgGf9odd8SXNuxtUBVx8Nv+qZOejE6qyqiy5NtbYYQOeFa6zmHkxlPzmaLxWWHsU6nJmB7AETdVPi+2NBUg==} + engines: {node: '>=8'} + + citty@0.1.6: + resolution: {integrity: sha512-tskPPKEs8D2KPafUypv2gxwJP8h/OaJmC82QQGGDQcHvXX43xF2VDACcJVmZ0EuSxkpO9Kc4MlrA3q0+FG58AQ==} + + cjs-module-lexer@1.3.1: + resolution: {integrity: sha512-a3KdPAANPbNE4ZUv9h6LckSl9zLsYOP4MBmhIPkRaeyybt+r4UghLvq+xw/YwUcC1gqylCkL4rdVs3Lwupjm4Q==} + + classnames@2.5.1: + resolution: {integrity: sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow==} + + clean-deep@3.4.0: + resolution: {integrity: sha512-Lo78NV5ItJL/jl+B5w0BycAisaieJGXK1qYi/9m4SjR8zbqmrUtO7Yhro40wEShGmmxs/aJLI/A+jNhdkXK8mw==} + engines: {node: '>=4'} + + clean-stack@4.2.0: + resolution: {integrity: sha512-LYv6XPxoyODi36Dp976riBtSY27VmFo+MKqEU9QCCWyTrdEPDog+RWA7xQWHi6Vbp61j5c4cdzzX1NidnwtUWg==} + engines: {node: '>=12'} + + cli-boxes@3.0.0: + resolution: {integrity: sha512-/lzGpEWL/8PfI0BmBOPRwp0c/wFNX1RdUML3jK/RcSBA9T8mZDdQpqYBKtCFTOfQbwPqWEOpjqW+Fnayc0969g==} + engines: {node: '>=10'} + + cli-cursor@2.1.0: + resolution: {integrity: sha512-8lgKz8LmCRYZZQDpRyT2m5rKJ08TnU4tR9FFFW2rxpxR1FzWi4PQ/NfyODchAatHaUgnSPVcx/R5w6NuTBzFiw==} + engines: {node: '>=4'} + + cli-cursor@4.0.0: + resolution: {integrity: sha512-VGtlMu3x/4DOtIUwEkRezxUZ2lBacNJCHash0N0WeZDBS+7Ux1dm3XWAgWYxLJFMMdOeXMHXorshEFhbMSGelg==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + + cli-cursor@5.0.0: + resolution: {integrity: sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==} + engines: {node: '>=18'} + + cli-progress@3.12.0: + resolution: {integrity: sha512-tRkV3HJ1ASwm19THiiLIXLO7Im7wlTuKnvkYaTkyoAPefqjNg7W7DHKUlGRxy9vxDvbyCYQkQozvptuMkGCg8A==} + engines: {node: '>=4'} + + cli-spinners@2.9.2: + resolution: {integrity: sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg==} + engines: {node: '>=6'} + + cli-truncate@4.0.0: + resolution: {integrity: sha512-nPdaFdQ0h/GEigbPClz11D0v/ZJEwxmeVZGeMo3Z5StPtUTkA9o1lD6QwoirYiSDzbcwn2XcjwmCp68W1IS4TA==} + engines: {node: '>=18'} + + cli-width@2.2.1: + resolution: {integrity: sha512-GRMWDxpOB6Dgk2E5Uo+3eEBvtOOlimMmpbFiKuLFnQzYDavtLFY3K5ona41jgN/WdRZtG7utuVSVTL4HbZHGkw==} + + client-only@0.0.1: + resolution: {integrity: sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==} + + clipboardy@4.0.0: + resolution: {integrity: sha512-5mOlNS0mhX0707P2I0aZ2V/cmHUEO/fL7VFLqszkhUsxt7RwnmrInf/eEQKlf5GzvYeHIjT+Ov1HRfNmymlG0w==} + engines: {node: '>=18'} + + cliui@8.0.1: + resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==} + engines: {node: '>=12'} + + co@4.6.0: + resolution: {integrity: sha512-QVb0dM5HvG+uaxitm8wONl7jltx8dqhfU33DcqtOZcLSVIKSDDLDi7+0LbAKiyI8hD9u42m2YxXSkMGWThaecQ==} + engines: {iojs: '>= 1.0.0', node: '>= 0.12.0'} + + collect-v8-coverage@1.0.2: + resolution: {integrity: sha512-lHl4d5/ONEbLlJvaJNtsF/Lz+WvB07u2ycqTYbdrq7UypDXailES4valYb2eWiJFxZlVmpGekfqoxQhzyFdT4Q==} + + color-convert@1.9.3: + resolution: {integrity: sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==} + + color-convert@2.0.1: + resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} + engines: {node: '>=7.0.0'} + + color-name@1.1.3: + resolution: {integrity: sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==} + + color-name@1.1.4: + resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + + color-string@1.9.1: + resolution: {integrity: sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg==} + + color-support@1.1.3: + resolution: {integrity: sha512-qiBjkpbMLO/HL68y+lh4q0/O1MZFj2RX6X/KmMa3+gJD3z+WwI1ZzDHysvqHGS3mP6mznPckpXmw1nI9cJjyRg==} + hasBin: true + + color@3.2.1: + resolution: {integrity: sha512-aBl7dZI9ENN6fUGC7mWpMTPNHmWUSNan9tuWN6ahh5ZLNk9baLJOnSMlrQkHcrfFgz2/RigjUVAjdx36VcemKA==} + + color@4.2.3: + resolution: {integrity: sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A==} + engines: {node: '>=12.5.0'} + + colord@2.9.3: + resolution: {integrity: sha512-jeC1axXpnb0/2nn/Y1LPuLdgXBLH7aDcHu4KEKfqw3CUhX7ZpfBSlPKyqXE6btIgEzfWtrX3/tyBCaCvXvMkOw==} + + colorette@2.0.20: + resolution: {integrity: sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==} + + colors-option@3.0.0: + resolution: {integrity: sha512-DP3FpjsiDDvnQC1OJBsdOJZPuy7r0o6sepY2T5M3L/d2nrE23O/ErFkEqyY3ngVL1ZhTj/H0pCMNObZGkEOaaQ==} + engines: {node: '>=12.20.0'} + + colors-option@4.5.0: + resolution: {integrity: sha512-Soe5lerRg3erMRgYC0EC696/8dMCGpBzcQchFfi55Yrkja8F+P7cUt0LVTIg7u5ob5BexLZ/F1kO+ejmv+nq8w==} + engines: {node: '>=14.18.0'} + + colors@1.4.0: + resolution: {integrity: sha512-a+UqTh4kgZg/SlGvfbzDHpgRu7AAQOmmqRHJnxhRZICKFUT91brVhNNt58CMWU9PsBbv3PDCZUHbVxuDiH2mtA==} + engines: {node: '>=0.1.90'} + + colorspace@1.1.4: + resolution: {integrity: sha512-BgvKJiuVu1igBUF2kEjRCZXol6wiiGbY5ipL/oVPwm0BL9sIpMIzM8IK7vwuxIIzOXMV3Ey5w+vxhm0rR/TN8w==} + + combined-stream@1.0.8: + resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} + engines: {node: '>= 0.8'} + + comma-separated-tokens@2.0.3: + resolution: {integrity: sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==} + + commander@10.0.1: + resolution: {integrity: sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug==} + engines: {node: '>=14'} + + commander@12.1.0: + resolution: {integrity: sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==} + engines: {node: '>=18'} + + commander@2.20.3: + resolution: {integrity: sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==} + + commander@4.1.1: + resolution: {integrity: sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==} + engines: {node: '>= 6'} + + commander@7.2.0: + resolution: {integrity: sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==} + engines: {node: '>= 10'} + + commander@9.5.0: + resolution: {integrity: sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ==} + engines: {node: ^12.20.0 || >=14} + + comment-json@4.2.5: + resolution: {integrity: sha512-bKw/r35jR3HGt5PEPm1ljsQQGyCrR8sFGNiN5L+ykDHdpO8Smxkrkla9Yi6NkQyUrb8V54PGhfMs6NrIwtxtdw==} + engines: {node: '>= 6'} + + common-path-prefix@3.0.0: + resolution: {integrity: sha512-QE33hToZseCH3jS0qN96O/bSh3kaw/h+Tq7ngyY9eWDUnTlTNUyqfqvCXioLe5Na5jFsL78ra/wuBU4iuEgd4w==} + + compress-commons@6.0.2: + resolution: {integrity: sha512-6FqVXeETqWPoGcfzrXb37E50NP0LXT8kAMu5ooZayhWWdgEY4lBEEcbQNXtkuKQsGduxiIcI4gOTsxTmuq/bSg==} + engines: {node: '>= 14'} + + concat-map@0.0.1: + resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} + + concordance@5.0.4: + resolution: {integrity: sha512-OAcsnTEYu1ARJqWVGwf4zh4JDfHZEaSNlNccFmt8YjB2l/n19/PF2viLINHc57vO4FKIAFl2FWASIGZZWZ2Kxw==} + engines: {node: '>=10.18.0 <11 || >=12.14.0 <13 || >=14'} + + confbox@0.1.7: + resolution: {integrity: sha512-uJcB/FKZtBMCJpK8MQji6bJHgu1tixKPxRLeGkNzBoOZzpnZUJm0jm2/sBDWcuBx1dYgxV4JU+g5hmNxCyAmdA==} + + config-chain@1.1.13: + resolution: {integrity: sha512-qj+f8APARXHrM0hraqXYb2/bOVSV4PvJQlNZ/DVj0QrmNM2q2euizkeuVckQ57J+W0mRH6Hvi+k50M4Jul2VRQ==} + + configstore@6.0.0: + resolution: {integrity: sha512-cD31W1v3GqUlQvbBCGcXmd2Nj9SvLDOP1oQ0YFuLETufzSPaKp11rYBsSOm7rCsW3OnIRAFM3OxRhceaXNYHkA==} + engines: {node: '>=12'} + + consola@3.2.3: + resolution: {integrity: sha512-I5qxpzLv+sJhTVEoLYNcTW+bThDCPsit0vLNKShZx6rLtpilNpmmeTPaeqJb9ZE9dV3DGaeby6Vuhrw38WjeyQ==} + engines: {node: ^14.18.0 || >=16.10.0} + + console-control-strings@1.1.0: + resolution: {integrity: sha512-ty/fTekppD2fIwRvnZAVdeOiGd1c7YXEixbgJTNzqcxJWKQnjJ/V1bNEEE6hygpM3WjwHFUVK6HTjWSzV4a8sQ==} + + content-disposition@0.5.4: + resolution: {integrity: sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==} + engines: {node: '>= 0.6'} + + content-type@1.0.5: + resolution: {integrity: sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==} + engines: {node: '>= 0.6'} + + convert-source-map@2.0.0: + resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + + cookie-es@1.2.2: + resolution: {integrity: sha512-+W7VmiVINB+ywl1HGXJXmrqkOhpKrIiVZV6tQuV54ZyQC7MMuBt81Vc336GMLoHBq5hV/F9eXgt5Mnx0Rha5Fg==} + + cookie-signature@1.0.6: + resolution: {integrity: sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==} + + cookie@0.6.0: + resolution: {integrity: sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==} + engines: {node: '>= 0.6'} + + core-util-is@1.0.3: + resolution: {integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==} + + cosmiconfig@9.0.0: + resolution: {integrity: sha512-itvL5h8RETACmOTFc4UfIyB2RfEHi71Ax6E/PivVxq9NseKbOWpeyHEOIbmAw1rs8Ak0VursQNww7lf7YtUwzg==} + engines: {node: '>=14'} + peerDependencies: + typescript: '>=4.9.5' + peerDependenciesMeta: + typescript: + optional: true + + cp-file@10.0.0: + resolution: {integrity: sha512-vy2Vi1r2epK5WqxOLnskeKeZkdZvTKfFZQCplE3XWsP+SUJyd5XAUFC9lFgTjjXJF2GMne/UML14iEmkAaDfFg==} + engines: {node: '>=14.16'} + + cp-file@9.1.0: + resolution: {integrity: sha512-3scnzFj/94eb7y4wyXRWwvzLFaQp87yyfTnChIjlfYrVqp5lVO3E2hIJMeQIltUT0K2ZAB3An1qXcBmwGyvuwA==} + engines: {node: '>=10'} + + cpy@9.0.1: + resolution: {integrity: sha512-D9U0DR5FjTCN3oMTcFGktanHnAG5l020yvOCR1zKILmAyPP7I/9pl6NFgRbDcmSENtbK1sQLBz1p9HIOlroiNg==} + engines: {node: ^12.20.0 || ^14.17.0 || >=16.0.0} + + crc-32@1.2.2: + resolution: {integrity: sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==} + engines: {node: '>=0.8'} + hasBin: true + + crc32-stream@6.0.0: + resolution: {integrity: sha512-piICUB6ei4IlTv1+653yq5+KoqfBYmj9bw6LqXoOneTMDXk5nM1qt12mFW1caG3LlJXEKW1Bp0WggEmIfQB34g==} + engines: {node: '>= 14'} + + create-jest@29.7.0: + resolution: {integrity: sha512-Adz2bdH0Vq3F53KEMJOoftQFutWCukm6J24wbPWRO4k1kMY7gS7ds/uoJkNuV8wDCtWWnuwGcJwpWcih+zEW1Q==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + hasBin: true + + create-require@1.1.1: + resolution: {integrity: sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==} + + cron-parser@4.9.0: + resolution: {integrity: sha512-p0SaNjrHOnQeR8/VnfGbmg9te2kfyYSQ7Sc/j/6DtPL3JQvKxmjO9TSjNFpujqV3vEYYBvNNvXSxzyksBWAx1Q==} + engines: {node: '>=12.0.0'} + + cross-spawn@7.0.3: + resolution: {integrity: sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==} + engines: {node: '>= 8'} + + crossws@0.2.4: + resolution: {integrity: sha512-DAxroI2uSOgUKLz00NX6A8U/8EE3SZHmIND+10jkVSaypvyt57J5JEOxAQOL6lQxyzi/wZbTIwssU1uy69h5Vg==} + peerDependencies: + uWebSockets.js: '*' + peerDependenciesMeta: + uWebSockets.js: + optional: true + + crypto-random-string@4.0.0: + resolution: {integrity: sha512-x8dy3RnvYdlUcPOjkEHqozhiwzKNSq7GcPuXFbnyMOCHxX8V3OgIg/pYuabl2sbUPfIJaeAQB7PMOK8DFIdoRA==} + engines: {node: '>=12'} + + css-functions-list@3.2.2: + resolution: {integrity: sha512-c+N0v6wbKVxTu5gOBBFkr9BEdBWaqqjQeiJ8QvSRIJOf+UxlJh930m8e6/WNeODIK0mYLFkoONrnj16i2EcvfQ==} + engines: {node: '>=12 || >=16'} + + css-select@5.1.0: + resolution: {integrity: sha512-nwoRF1rvRRnnCqqY7updORDsuqKzqYJ28+oSMaJMMgOauh3fvwHqMS7EZpIPqK8GL+g9mKxF1vP/ZjSeNjEVHg==} + + css-tree@2.2.1: + resolution: {integrity: sha512-OA0mILzGc1kCOCSJerOeqDxDQ4HOh+G8NbOJFOTgOCzpw7fCBubk0fEyxp8AgOL/jvLgYA/uV0cMbe43ElF1JA==} + engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0, npm: '>=7.0.0'} + + css-tree@2.3.1: + resolution: {integrity: sha512-6Fv1DV/TYw//QF5IzQdqsNDjx/wc8TrMBZsqjL9eW01tWb7R7k/mq+/VXfJCl7SoD5emsJop9cOByJZfs8hYIw==} + engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0} + + css-what@6.1.0: + resolution: {integrity: sha512-HTUrgRJ7r4dsZKU6GjmpfRK1O76h97Z8MfS1G0FozR+oF2kG6Vfe8JE6zwrkbxigziPHinCJ+gCPjA9EaBDtRw==} + engines: {node: '>= 6'} + + css.escape@1.5.1: + resolution: {integrity: sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==} + + cssesc@3.0.0: + resolution: {integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==} + engines: {node: '>=4'} + hasBin: true + + cssfilter@0.0.10: + resolution: {integrity: sha512-FAaLDaplstoRsDR8XGYH51znUN0UY7nMc6Z9/fvE8EXGwvJE9hu7W2vHwx1+bd6gCYnln9nLbzxFTrcO9YQDZw==} + + csso@5.0.5: + resolution: {integrity: sha512-0LrrStPOdJj+SPCCrGhzryycLjwcgUSHBtxNA8aIDxf0GLsRh1cKYhB00Gd1lDOS4yGH69+SNn13+TWbVHETFQ==} + engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0, npm: '>=7.0.0'} + + cssom@0.3.8: + resolution: {integrity: sha512-b0tGHbfegbhPJpxpiBPU2sCkigAqtM9O121le6bbOlgyV+NyGyCmVfJ6QW9eRjz8CpNfWEOYBIMIGRYkLwsIYg==} + + cssom@0.5.0: + resolution: {integrity: sha512-iKuQcq+NdHqlAcwUY0o/HL69XQrUaQdMjmStJ8JFmUaiiQErlhrmuigkg/CU4E2J0IyUKUrMAgl36TvN67MqTw==} + + cssstyle@2.3.0: + resolution: {integrity: sha512-AZL67abkUzIuvcHqk7c09cezpGNcxUxU4Ioi/05xHk4DQeTkWmGYftIE6ctU6AEt+Gn4n1lDStOtj7FKycP71A==} + engines: {node: '>=8'} + + csstype@3.1.3: + resolution: {integrity: sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==} + + cyclist@1.0.2: + resolution: {integrity: sha512-0sVXIohTfLqVIW3kb/0n6IiWF3Ifj5nm2XaSrLq2DI6fKIGa2fYAZdk917rUneaeLVpYfFcyXE2ft0fe3remsA==} + + data-uri-to-buffer@4.0.1: + resolution: {integrity: sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==} + engines: {node: '>= 12'} + + data-urls@3.0.2: + resolution: {integrity: sha512-Jy/tj3ldjZJo63sVAvg6LHt2mHvl4V6AgRAmNDtLdm7faqtsx+aJG42rsyCo9JCoRVKwPFzKlIPx3DIibwSIaQ==} + engines: {node: '>=12'} + + date-time@3.1.0: + resolution: {integrity: sha512-uqCUKXE5q1PNBXjPqvwhwJf9SwMoAHBgWJ6DcrnS5o+W2JOiIILl0JEdVD8SGujrNS02GGxgwAg2PN2zONgtjg==} + engines: {node: '>=6'} + + debug@2.6.9: + resolution: {integrity: sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + debug@4.3.6: + resolution: {integrity: sha512-O/09Bd4Z1fBrU4VzkhFqVgpPzaGbw6Sm9FEkBT1A/YBXQFGuuSxa1dN2nxgxS34JmKXqYx8CZAwEVoJFImUXIg==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + decache@4.6.2: + resolution: {integrity: sha512-2LPqkLeu8XWHU8qNCS3kcF6sCcb5zIzvWaAHYSvPfwhdd7mHuah29NssMzrTYyHN4F5oFy2ko9OBYxegtU0FEw==} + + decimal.js@10.4.3: + resolution: {integrity: sha512-VBBaLc1MgL5XpzgIP7ny5Z6Nx3UrRkIViUkPUdtl9aya5amy3De1gsUUSB1g3+3sExYNjCAsAznmukyxCb1GRA==} + + decode-named-character-reference@1.0.2: + resolution: {integrity: sha512-O8x12RzrUF8xyVcY0KJowWsmaJxQbmy0/EtnNtHRpsOcT7dFk5W598coHqBVpmWo1oQQfsCqfCmkZN5DJrZVdg==} + + decompress-response@6.0.0: + resolution: {integrity: sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==} + engines: {node: '>=10'} + + dedent@1.5.3: + resolution: {integrity: sha512-NHQtfOOW68WD8lgypbLA5oT+Bt0xXJhiYvoR6SmmNXZfpzOGXwdKWmcwG8N7PwVVWV3eF/68nmD9BaJSsTBhyQ==} + peerDependencies: + babel-plugin-macros: ^3.1.0 + peerDependenciesMeta: + babel-plugin-macros: + optional: true + + deep-extend@0.6.0: + resolution: {integrity: sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==} + engines: {node: '>=4.0.0'} + + deep-is@0.1.4: + resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} + + deepmerge@4.3.1: + resolution: {integrity: sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==} + engines: {node: '>=0.10.0'} + + defer-to-connect@2.0.1: + resolution: {integrity: sha512-4tvttepXG1VaYGrRibk5EwJd1t4udunSOVMdLSAL6mId1ix438oPwPZMALY41FCijukO1L0twNcGsdzS7dHgDg==} + engines: {node: '>=10'} + + define-data-property@1.1.4: + resolution: {integrity: sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==} + engines: {node: '>= 0.4'} + + define-lazy-prop@2.0.0: + resolution: {integrity: sha512-Ds09qNh8yw3khSjiJjiUInaGX9xlqZDY7JVryGxdxV7NPeuqQfplOpQ66yJFZut3jLa5zOwkXw1g9EI2uKh4Og==} + engines: {node: '>=8'} + + defu@6.1.4: + resolution: {integrity: sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg==} + + delayed-stream@1.0.0: + resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} + engines: {node: '>=0.4.0'} + + delegates@1.0.0: + resolution: {integrity: sha512-bd2L678uiWATM6m5Z1VzNCErI3jiGzt6HGY8OVICs40JQq/HALfbyNJmp0UDakEY4pMMaN0Ly5om/B1VI/+xfQ==} + + depd@1.1.2: + resolution: {integrity: sha512-7emPTl6Dpo6JRXOXjLRxck+FlLRX5847cLKEn00PLAgc3g2hTZZgr+e4c2v6QpSmLeFP3n5yUo7ft6avBK/5jQ==} + engines: {node: '>= 0.6'} + + depd@2.0.0: + resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} + engines: {node: '>= 0.8'} + + deprecation@2.3.1: + resolution: {integrity: sha512-xmHIy4F3scKVwMsQ4WnVaS8bHOx0DmVwRywosKhaILI0ywMDWPtBSku2HNxRvF7jtwDRsoEwYQSfbxj8b7RlJQ==} + + dequal@2.0.3: + resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} + engines: {node: '>=6'} + + destr@2.0.3: + resolution: {integrity: sha512-2N3BOUU4gYMpTP24s5rF5iP7BDr7uNTCs4ozw3kf/eKfvWSIu93GEBi5m427YoyJoeOzQ5smuu4nNAPGb8idSQ==} + + destroy@1.2.0: + resolution: {integrity: sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==} + engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16} + + detect-libc@1.0.3: + resolution: {integrity: sha512-pGjwhsmsp4kL2RTz08wcOlGN83otlqHeD/Z5T8GXZB+/YcpQ/dgo+lbU8ZsGxV0HIvqqxo9l7mqYwyYMD9bKDg==} + engines: {node: '>=0.10'} + hasBin: true + + detect-libc@2.0.3: + resolution: {integrity: sha512-bwy0MGW55bG41VqxxypOsdSdGqLwXPI/focwgTYCFMbdUiBAxLg9CFzG08sz2aqzknwiX7Hkl0bQENjg8iLByw==} + engines: {node: '>=8'} + + detect-newline@3.1.0: + resolution: {integrity: sha512-TLz+x/vEXm/Y7P7wn1EJFNLxYpUD4TgMosxY6fAVJUnJMbupHBOncxyWUG9OpTaH9EBD7uFI5LfEgmMOc54DsA==} + engines: {node: '>=8'} + + detective-amd@5.0.2: + resolution: {integrity: sha512-XFd/VEQ76HSpym80zxM68ieB77unNuoMwopU2TFT/ErUk5n4KvUTwW4beafAVUugrjV48l4BmmR0rh2MglBaiA==} + engines: {node: '>=14'} + hasBin: true + + detective-cjs@5.0.1: + resolution: {integrity: sha512-6nTvAZtpomyz/2pmEmGX1sXNjaqgMplhQkskq2MLrar0ZAIkHMrDhLXkRiK2mvbu9wSWr0V5/IfiTrZqAQMrmQ==} + engines: {node: '>=14'} + + detective-es6@4.0.1: + resolution: {integrity: sha512-k3Z5tB4LQ8UVHkuMrFOlvb3GgFWdJ9NqAa2YLUU/jTaWJIm+JJnEh4PsMc+6dfT223Y8ACKOaC0qcj7diIhBKw==} + engines: {node: '>=14'} + + detective-postcss@6.1.3: + resolution: {integrity: sha512-7BRVvE5pPEvk2ukUWNQ+H2XOq43xENWbH0LcdCE14mwgTBEAMoAx+Fc1rdp76SmyZ4Sp48HlV7VedUnP6GA1Tw==} + engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0} + + detective-sass@5.0.3: + resolution: {integrity: sha512-YsYT2WuA8YIafp2RVF5CEfGhhyIVdPzlwQgxSjK+TUm3JoHP+Tcorbk3SfG0cNZ7D7+cYWa0ZBcvOaR0O8+LlA==} + engines: {node: '>=14'} + + detective-scss@4.0.3: + resolution: {integrity: sha512-VYI6cHcD0fLokwqqPFFtDQhhSnlFWvU614J42eY6G0s8c+MBhi9QAWycLwIOGxlmD8I/XvGSOUV1kIDhJ70ZPg==} + engines: {node: '>=14'} + + detective-stylus@4.0.0: + resolution: {integrity: sha512-TfPotjhszKLgFBzBhTOxNHDsutIxx9GTWjrL5Wh7Qx/ydxKhwUrlSFeLIn+ZaHPF+h0siVBkAQSuy6CADyTxgQ==} + engines: {node: '>=14'} + + detective-typescript@11.2.0: + resolution: {integrity: sha512-ARFxjzizOhPqs1fYC/2NMC3N4jrQ6HvVflnXBTRqNEqJuXwyKLRr9CrJwkRcV/SnZt1sNXgsF6FPm0x57Tq0rw==} + engines: {node: ^14.14.0 || >=16.0.0} + + devlop@1.1.0: + resolution: {integrity: sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==} + + didyoumean@1.2.2: + resolution: {integrity: sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==} + + diff-sequences@29.6.3: + resolution: {integrity: sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + diff@4.0.2: + resolution: {integrity: sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==} + engines: {node: '>=0.3.1'} + + dir-glob@3.0.1: + resolution: {integrity: sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==} + engines: {node: '>=8'} + + dlv@1.1.3: + resolution: {integrity: sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==} + + dom-accessibility-api@0.5.16: + resolution: {integrity: sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==} + + dom-accessibility-api@0.6.3: + resolution: {integrity: sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==} + + dom-serializer@2.0.0: + resolution: {integrity: sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==} + + domelementtype@2.3.0: + resolution: {integrity: sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==} + + domexception@4.0.0: + resolution: {integrity: sha512-A2is4PLG+eeSfoTMA95/s4pvAoSo2mKtiM5jlHkAVewmiO8ISFTFKZjH7UAM1Atli/OT/7JHOrJRJiMKUZKYBw==} + engines: {node: '>=12'} + deprecated: Use your platform's native DOMException instead + + domhandler@5.0.3: + resolution: {integrity: sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==} + engines: {node: '>= 4'} + + domutils@3.1.0: + resolution: {integrity: sha512-H78uMmQtI2AhgDJjWeQmHwJJ2bLPD3GMmO7Zja/ZZh84wkm+4ut+IUnUdRa8uCGX88DiVx1j6FRe1XfxEgjEZA==} + + dot-prop@6.0.1: + resolution: {integrity: sha512-tE7ztYzXHIeyvc7N+hR3oi7FIbf/NIjVP9hmAt3yMXzrQ072/fpjGLx2GxNxGxUl5V73MEqYzioOMoVhGMJ5cA==} + engines: {node: '>=10'} + + dot-prop@7.2.0: + resolution: {integrity: sha512-Ol/IPXUARn9CSbkrdV4VJo7uCy1I3VuSiWCaFSg+8BdUOzF9n3jefIpcgAydvUZbTdEBZs2vEiTiS9m61ssiDA==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + + dot-prop@9.0.0: + resolution: {integrity: sha512-1gxPBJpI/pcjQhKgIU91II6Wkay+dLcN3M6rf2uwP8hRur3HtQXjVrdAK3sjC0piaEuxzMwjXChcETiJl47lAQ==} + engines: {node: '>=18'} + + dotenv@16.4.5: + resolution: {integrity: sha512-ZmdL2rui+eB2YwhsWzjInR8LldtZHGDoQ1ugH85ppHKwpUHL7j7rN0Ti9NCnGiQbhaZ11FpR+7ao1dNsmduNUg==} + engines: {node: '>=12'} + + eastasianwidth@0.2.0: + resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==} + + ecdsa-sig-formatter@1.0.11: + resolution: {integrity: sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==} + + ee-first@1.1.1: + resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} + + electron-to-chromium@1.5.4: + resolution: {integrity: sha512-orzA81VqLyIGUEA77YkVA1D+N+nNfl2isJVjjmOyrlxuooZ19ynb+dOlaDTqd/idKRS9lDCSBmtzM+kyCsMnkA==} + + emittery@0.13.1: + resolution: {integrity: sha512-DeWwawk6r5yR9jFgnDKYt4sLS0LmHJJi3ZOnb5/JdbYwj3nW+FxQnHIjhBKz8YLC7oRNPVM9NQ47I3CVx34eqQ==} + engines: {node: '>=12'} + + emoji-regex@10.3.0: + resolution: {integrity: sha512-QpLs9D9v9kArv4lfDEgg1X/gN5XLnf/A6l9cs8SPZLRZR3ZkY9+kwIQTxm+fsSej5UMYGE8fdoaZVIBlqG0XTw==} + + emoji-regex@8.0.0: + resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} + + emoji-regex@9.2.2: + resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==} + + enabled@2.0.0: + resolution: {integrity: sha512-AKrN98kuwOzMIdAizXGI86UFBoo26CL21UM763y1h/GMSJ4/OHU9k2YlsmBpyScFo/wbLzWQJBMCW4+IO3/+OQ==} + + encodeurl@1.0.2: + resolution: {integrity: sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==} + engines: {node: '>= 0.8'} + + end-of-stream@1.4.4: + resolution: {integrity: sha512-+uw1inIHVPQoaVuHzRyXd21icM+cnt4CzD5rW+NC1wjOUSTOs+Te7FOv7AhN7vS9x/oIyhLP5PR1H+phQAHu5Q==} + + entities@2.2.0: + resolution: {integrity: sha512-p92if5Nz619I0w+akJrLZH0MX0Pb5DX39XOwQTtXSdQQOaYH03S1uIQp4mhOZtAXrxq4ViO67YTiLBo2638o9A==} + + entities@4.5.0: + resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==} + engines: {node: '>=0.12'} + + env-paths@2.2.1: + resolution: {integrity: sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==} + engines: {node: '>=6'} + + env-paths@3.0.0: + resolution: {integrity: sha512-dtJUTepzMW3Lm/NPxRf3wP4642UWhjL2sQxc+ym2YMj1m/H2zDNQOlezafzkHwn6sMstjHTwG6iQQsctDW/b1A==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + + envinfo@7.13.0: + resolution: {integrity: sha512-cvcaMr7KqXVh4nyzGTVqTum+gAiL265x5jUWQIDLq//zOGbW+gSW/C+OWLleY/rs9Qole6AZLMXPbtIFQbqu+Q==} + engines: {node: '>=4'} + hasBin: true + + environment@1.1.0: + resolution: {integrity: sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q==} + engines: {node: '>=18'} + + error-ex@1.3.2: + resolution: {integrity: sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g==} + + error-stack-parser@2.1.4: + resolution: {integrity: sha512-Sk5V6wVazPhq5MhpO+AUxJn5x7XSXGl1R93Vn7i+zS15KDVxQijejNCrz8340/2bgLBjR9GtEG8ZVKONDjcqGQ==} + + es-define-property@1.0.0: + resolution: {integrity: sha512-jxayLKShrEqqzJ0eumQbVhTYQM27CfT1T35+gCgDFoL82JLsXqTJ76zv6A0YLOgEnLUMvLzsDsGIrl8NFpT2gQ==} + engines: {node: '>= 0.4'} + + es-errors@1.3.0: + resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} + engines: {node: '>= 0.4'} + + es-module-lexer@1.5.4: + resolution: {integrity: sha512-MVNK56NiMrOwitFB7cqDwq0CQutbw+0BvLshJSse0MUNU+y1FC3bUS/AQg7oUng+/wKrrki7JfmwtVHkVfPLlw==} + + es6-promisify@6.1.1: + resolution: {integrity: sha512-HBL8I3mIki5C1Cc9QjKUenHtnG0A5/xA8Q/AllRcfiwl2CZFXGK7ddBiCoRwAix4i2KxcQfjtIVcrVbB3vbmwg==} + + esbuild@0.19.11: + resolution: {integrity: sha512-HJ96Hev2hX/6i5cDVwcqiJBBtuo9+FeIJOtZ9W1kA5M6AMJRHUZlpYZ1/SbEwtO0ioNAW8rUooVpC/WehY2SfA==} + engines: {node: '>=12'} + hasBin: true + + esbuild@0.21.2: + resolution: {integrity: sha512-LmHPAa5h4tSxz+g/D8IHY6wCjtIiFx8I7/Q0Aq+NmvtoYvyMnJU0KQJcqB6QH30X9x/W4CemgUtPgQDZFca5SA==} + engines: {node: '>=12'} + hasBin: true + + escalade@3.1.2: + resolution: {integrity: sha512-ErCHMCae19vR8vQGe50xIsVomy19rg6gFu3+r3jkEO46suLMWBksvVyoGgQV+jOfl84ZSOSlmv6Gxa89PmTGmA==} + engines: {node: '>=6'} + + escape-goat@4.0.0: + resolution: {integrity: sha512-2Sd4ShcWxbx6OY1IHyla/CVNwvg7XwZVoXZHcSu9w9SReNP1EzzD5T8NWKIR38fIqEns9kDWKUQTXXAmlDrdPg==} + engines: {node: '>=12'} + + escape-html@1.0.3: + resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==} + + escape-string-regexp@1.0.5: + resolution: {integrity: sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==} + engines: {node: '>=0.8.0'} + + escape-string-regexp@2.0.0: + resolution: {integrity: sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==} + engines: {node: '>=8'} + + escape-string-regexp@4.0.0: + resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} + engines: {node: '>=10'} + + escape-string-regexp@5.0.0: + resolution: {integrity: sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==} + engines: {node: '>=12'} + + escodegen@2.1.0: + resolution: {integrity: sha512-2NlIDTwUWJN0mRPQOdtQBzbUHvdGY2P1VXSyU83Q3xKxM7WHX2Ql8dKq782Q9TgQUNOLEzEYu9bzLNj1q88I5w==} + engines: {node: '>=6.0'} + hasBin: true + + eslint-config-prettier@9.1.0: + resolution: {integrity: sha512-NSWl5BFQWEPi1j4TjVNItzYV7dZXZ+wP6I6ZhrBGpChQhZRUaElihE9uRRkcbRnNb76UMKDF3r+WTmNcGPKsqw==} + hasBin: true + peerDependencies: + eslint: '>=7.0.0' + + eslint-scope@8.0.2: + resolution: {integrity: sha512-6E4xmrTw5wtxnLA5wYL3WDfhZ/1bUBGOXV0zQvVRDOtrR8D0p6W7fs3JweNYhwRYeGvd/1CKX2se0/2s7Q/nJA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + eslint-visitor-keys@3.4.3: + resolution: {integrity: sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + + eslint-visitor-keys@4.0.0: + resolution: {integrity: sha512-OtIRv/2GyiF6o/d8K7MYKKbXrOUBIK6SfkIRM4Z0dY3w+LiQ0vy3F57m0Z71bjbyeiWFiHJ8brqnmE6H6/jEuw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + eslint@9.9.0: + resolution: {integrity: sha512-JfiKJrbx0506OEerjK2Y1QlldtBxkAlLxT5OEcRF8uaQ86noDe2k31Vw9rnSWv+MXZHj7OOUV/dA0AhdLFcyvA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + hasBin: true + peerDependencies: + jiti: '*' + peerDependenciesMeta: + jiti: + optional: true + + espree@10.1.0: + resolution: {integrity: sha512-M1M6CpiE6ffoigIOWYO9UDP8TMUw9kqb21tf+08IgDYjCsOvCuDt4jQcZmoYxx+w7zlKw9/N0KXfto+I8/FrXA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + esprima@4.0.1: + resolution: {integrity: sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==} + engines: {node: '>=4'} + hasBin: true + + esquery@1.6.0: + resolution: {integrity: sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg==} + engines: {node: '>=0.10'} + + esrecurse@4.3.0: + resolution: {integrity: sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==} + engines: {node: '>=4.0'} + + estraverse@5.3.0: + resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==} + engines: {node: '>=4.0'} + + estree-walker@2.0.2: + resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==} + + esutils@2.0.3: + resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} + engines: {node: '>=0.10.0'} + + etag@1.8.1: + resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==} + engines: {node: '>= 0.6'} + + event-target-shim@5.0.1: + resolution: {integrity: sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==} + engines: {node: '>=6'} + + eventemitter3@4.0.7: + resolution: {integrity: sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==} + + eventemitter3@5.0.1: + resolution: {integrity: sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==} + + events@3.3.0: + resolution: {integrity: sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==} + engines: {node: '>=0.8.x'} + + execa@5.1.1: + resolution: {integrity: sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==} + engines: {node: '>=10'} + + execa@6.1.0: + resolution: {integrity: sha512-QVWlX2e50heYJcCPG0iWtf8r0xjEYfz/OYLGDYH+IyjWezzPNxz63qNFOu0l4YftGWuizFVZHHs8PrLU5p2IDA==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + + execa@8.0.1: + resolution: {integrity: sha512-VyhnebXciFV2DESc+p6B+y0LjSm0krU4OgJN44qFAhBY0TJ+1V61tYD2+wHusZ6F9n5K+vl8k0sTy7PEfV4qpg==} + engines: {node: '>=16.17'} + + exit@0.1.2: + resolution: {integrity: sha512-Zk/eNKV2zbjpKzrsQ+n1G6poVbErQxJ0LBOJXaKZ1EViLzH+hrLu9cdXI4zw9dBQJslwBEpbQ2P1oS7nDxs6jQ==} + engines: {node: '>= 0.8.0'} + + expand-template@2.0.3: + resolution: {integrity: sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==} + engines: {node: '>=6'} + + expect@29.7.0: + resolution: {integrity: sha512-2Zks0hf1VLFYI1kbh0I5jP3KHHyCHpkfyHBzsSXRFgl/Bg9mWYfMW8oD+PdMPlEwy5HNsR9JutYy6pMeOh61nw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + express-logging@1.1.1: + resolution: {integrity: sha512-1KboYwxxCG5kwkJHR5LjFDTD1Mgl8n4PIMcCuhhd/1OqaxlC68P3QKbvvAbZVUtVgtlxEdTgSUwf6yxwzRCuuA==} + engines: {node: '>= 0.10.26'} + + express@4.19.2: + resolution: {integrity: sha512-5T6nhjsT+EOMzuck8JjBHARTHfMht0POzlA60WV2pMD3gyXw2LZnZ+ueGdNxG+0calOJcWKbpFcuzLZ91YWq9Q==} + engines: {node: '>= 0.10.0'} + + ext-list@2.2.2: + resolution: {integrity: sha512-u+SQgsubraE6zItfVA0tBuCBhfU9ogSRnsvygI7wht9TS510oLkBRXBsqopeUG/GBOIQyKZO9wjTqIu/sf5zFA==} + engines: {node: '>=0.10.0'} + + ext-name@5.0.0: + resolution: {integrity: sha512-yblEwXAbGv1VQDmow7s38W77hzAgJAO50ztBLMcUyUBfxv1HC+LGwtiEN+Co6LtlqT/5uwVOxsD4TNIilWhwdQ==} + engines: {node: '>=4'} + + extend-shallow@2.0.1: + resolution: {integrity: sha512-zCnTtlxNoAiDc3gqY2aYAWFx7XWWiasuF2K8Me5WbN8otHKTUKBwjPtNpRs/rbUZm7KxWAaNj7P1a/p52GbVug==} + engines: {node: '>=0.10.0'} + + extend@3.0.2: + resolution: {integrity: sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==} + + external-editor@3.1.0: + resolution: {integrity: sha512-hMQ4CX1p1izmuLYyZqLMO/qGNw10wSv9QDCPfzXfyFrOaCSSoRfqE1Kf1s5an66J5JZC62NewG+mK49jOCtQew==} + engines: {node: '>=4'} + + extract-zip@2.0.1: + resolution: {integrity: sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg==} + engines: {node: '>= 10.17.0'} + hasBin: true + + fast-content-type-parse@1.1.0: + resolution: {integrity: sha512-fBHHqSTFLVnR61C+gltJuE5GkVQMV0S2nqUO8TJ+5Z3qAKG8vAx4FKai1s5jq/inV1+sREynIWSuQ6HgoSXpDQ==} + + fast-decode-uri-component@1.0.1: + resolution: {integrity: sha512-WKgKWg5eUxvRZGwW8FvfbaH7AXSh2cL+3j5fMGzUMCxWBJ3dV3a7Wz8y2f/uQ0e3B6WmodD3oS54jTQ9HVTIIg==} + + fast-deep-equal@3.1.3: + resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + + fast-diff@1.3.0: + resolution: {integrity: sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw==} + + fast-equals@3.0.3: + resolution: {integrity: sha512-NCe8qxnZFARSHGztGMZOO/PC1qa5MIFB5Hp66WdzbCRAz8U8US3bx1UTgLS49efBQPcUtO9gf5oVEY8o7y/7Kg==} + + fast-fifo@1.3.2: + resolution: {integrity: sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==} + + fast-glob@3.3.2: + resolution: {integrity: sha512-oX2ruAFQwf/Orj8m737Y5adxDQO0LAB7/S5MnxCdTNDd4p6BsyIVsv9JQsATbTSq8KHRpLwIHbVlUNatxd+1Ow==} + engines: {node: '>=8.6.0'} + + fast-json-stable-stringify@2.1.0: + resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==} + + fast-json-stringify@5.16.1: + resolution: {integrity: sha512-KAdnLvy1yu/XrRtP+LJnxbBGrhN+xXu+gt3EUvZhYGKCr3lFHq/7UFJHHFgmJKoqlh6B40bZLEv7w46B0mqn1g==} + + fast-levenshtein@2.0.6: + resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} + + fast-querystring@1.1.2: + resolution: {integrity: sha512-g6KuKWmFXc0fID8WWH0jit4g0AGBoJhCkJMb1RmbsSEUNvQ+ZC8D6CUZ+GtF8nMzSPXnhiePyyqqipzNNEnHjg==} + + fast-redact@3.5.0: + resolution: {integrity: sha512-dwsoQlS7h9hMeYUq1W++23NDcBLV4KqONnITDV9DjfS3q1SgDGVrBdvvTLUotWtPSD7asWDV9/CmsZPy8Hf70A==} + engines: {node: '>=6'} + + fast-safe-stringify@2.1.1: + resolution: {integrity: sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==} + + fast-uri@2.4.0: + resolution: {integrity: sha512-ypuAmmMKInk5q7XcepxlnUWDLWv4GFtaJqAzWKqn62IpQ3pejtr5dTVbt3vwqVaMKmkNR55sTT+CqUKIaT21BA==} + + fast-uri@3.0.1: + resolution: {integrity: sha512-MWipKbbYiYI0UC7cl8m/i/IWTqfC8YXsqjzybjddLsFjStroQzsHXkc73JutMvBiXmOvapk+axIl79ig5t55Bw==} + + fastest-levenshtein@1.0.16: + resolution: {integrity: sha512-eRnCtTTtGZFpQCwhJiUOuxPQWRXVKYDn0b2PeHfXL6/Zi53SLAzAHfVhVWK2AryC/WH05kGfxhFIPvTF0SXQzg==} + engines: {node: '>= 4.9.1'} + + fastify-plugin@4.5.1: + resolution: {integrity: sha512-stRHYGeuqpEZTL1Ef0Ovr2ltazUT9g844X5z/zEBFLG8RYlpDiOCIG+ATvYEp+/zmc7sN29mcIMp8gvYplYPIQ==} + + fastify@4.28.1: + resolution: {integrity: sha512-kFWUtpNr4i7t5vY2EJPCN2KgMVpuqfU4NjnJNCgiNB900oiDeYqaNDRcAfeBbOF5hGixixxcKnOU4KN9z6QncQ==} + + fastq@1.17.1: + resolution: {integrity: sha512-sRVD3lWVIXWg6By68ZN7vho9a1pQcN/WBFaAAsDDFzlJjvoGx0P8z7V1t72grFJfJhu3YPZBuu25f7Kaw2jN1w==} + + fb-watchman@2.0.2: + resolution: {integrity: sha512-p5161BqbuCaSnB8jIbzQHOlpgsPmK5rJVDfDKO91Axs5NC1uu3HRQm6wt9cd9/+GtQQIO53JdGXXoyDpTAsgYA==} + + fd-slicer@1.1.0: + resolution: {integrity: sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==} + + fdir@6.2.0: + resolution: {integrity: sha512-9XaWcDl0riOX5j2kYfy0kKdg7skw3IY6kA4LFT8Tk2yF9UdrADUy8D6AJuBLtf7ISm/MksumwAHE3WVbMRyCLw==} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + fecha@4.2.3: + resolution: {integrity: sha512-OP2IUU6HeYKJi3i0z4A19kHMQoLVs4Hc+DPqqxI2h/DPZHTm/vjsfC6P0b4jCMy14XizLBqvndQ+UilD7707Jw==} + + fetch-blob@3.2.0: + resolution: {integrity: sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==} + engines: {node: ^12.20 || >= 14.13} + + fetch-node-website@7.3.0: + resolution: {integrity: sha512-/wayUHbdVUWrD72aqRNNrr6+MHnCkumZgNugN0RfiWJpbNJUdAkMk4Z18MGayGZVVqYXR1RWrV+bIFEt5HuBZg==} + engines: {node: '>=14.18.0'} + + figures@2.0.0: + resolution: {integrity: sha512-Oa2M9atig69ZkfwiApY8F2Yy+tzMbazyvqv21R0NsSC8floSOC09BbT1ITWAdoMGQvJ/aZnR1KMwdx9tvHnTNA==} + engines: {node: '>=4'} + + figures@3.2.0: + resolution: {integrity: sha512-yaduQFRKLXYOGgEn6AZau90j3ggSOyiqXU0F9JZfeXYhNa+Jk4X+s45A2zg5jns87GAFa34BBm2kXw4XpNcbdg==} + engines: {node: '>=8'} + + figures@4.0.1: + resolution: {integrity: sha512-rElJwkA/xS04Vfg+CaZodpso7VqBknOYbzi6I76hI4X80RUjkSxO2oAyPmGbuXUppywjqndOrQDl817hDnI++w==} + engines: {node: '>=12'} + + figures@5.0.0: + resolution: {integrity: sha512-ej8ksPF4x6e5wvK9yevct0UCXh8TTFlWGVLlgjZuoBH1HwjIfKE/IdL5mq89sFA7zELi1VhKpmtDnrs7zWyeyg==} + engines: {node: '>=14'} + + file-entry-cache@8.0.0: + resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==} + engines: {node: '>=16.0.0'} + + file-entry-cache@9.0.0: + resolution: {integrity: sha512-6MgEugi8p2tiUhqO7GnPsmbCCzj0YRCwwaTbpGRyKZesjRSzkqkAE9fPp7V2yMs5hwfgbQLgdvSSkGNg1s5Uvw==} + engines: {node: '>=18'} + + file-type@18.7.0: + resolution: {integrity: sha512-ihHtXRzXEziMrQ56VSgU7wkxh55iNchFkosu7Y9/S+tXHdKyrGjVK0ujbqNnsxzea+78MaLhN6PGmfYSAv1ACw==} + engines: {node: '>=14.16'} + + file-uri-to-path@1.0.0: + resolution: {integrity: sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==} + + filename-reserved-regex@3.0.0: + resolution: {integrity: sha512-hn4cQfU6GOT/7cFHXBqeBg2TbrMBgdD0kcjLhvSQYYwm3s4B6cjvBfb7nBALJLAXqmU5xajSa7X2NnUud/VCdw==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + + filenamify@5.1.1: + resolution: {integrity: sha512-M45CbrJLGACfrPOkrTp3j2EcO9OBkKUYME0eiqOCa7i2poaklU0jhlIaMlr8ijLorT0uLAzrn3qXOp5684CkfA==} + engines: {node: '>=12.20'} + + fill-range@7.1.1: + resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} + engines: {node: '>=8'} + + filter-obj@3.0.0: + resolution: {integrity: sha512-oQZM+QmVni8MsYzcq9lgTHD/qeLqaG8XaOPOW7dzuSafVxSUlH1+1ZDefj2OD9f2XsmG5lFl2Euc9NI4jgwFWg==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + + filter-obj@5.1.0: + resolution: {integrity: sha512-qWeTREPoT7I0bifpPUXtxkZJ1XJzxWtfoWWkdVGqa+eCr3SHW/Ocp89o8vLvbUuQnadybJpjOKu4V+RwO6sGng==} + engines: {node: '>=14.16'} + + finalhandler@1.2.0: + resolution: {integrity: sha512-5uXcUVftlQMFnWC9qu/svkWv3GTd2PfUhK/3PLkYNAe7FbqJMt3515HaxE6eRL74GdsriiwujiawdaB1BpEISg==} + engines: {node: '>= 0.8'} + + find-my-way@8.2.0: + resolution: {integrity: sha512-HdWXgFYc6b1BJcOBDBwjqWuHJj1WYiqrxSh25qtU4DabpMFdj/gSunNBQb83t+8Zt67D7CXEzJWTkxaShMTMOA==} + engines: {node: '>=14'} + + find-up-simple@1.0.0: + resolution: {integrity: sha512-q7Us7kcjj2VMePAa02hDAF6d+MzsdsAWEwYyOpwUtlerRBkOEPBCRZrAV4XfcSN8fHAgaD0hP7miwoay6DCprw==} + engines: {node: '>=18'} + + find-up@4.1.0: + resolution: {integrity: sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==} + engines: {node: '>=8'} + + find-up@5.0.0: + resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} + engines: {node: '>=10'} + + find-up@6.3.0: + resolution: {integrity: sha512-v2ZsoEuVHYy8ZIlYqwPe/39Cy+cFDzp4dXPaxNvkEuouymu+2Jbz0PxpKarJHYJTmv2HWT3O382qY8l4jMWthw==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + + find-up@7.0.0: + resolution: {integrity: sha512-YyZM99iHrqLKjmt4LJDj58KI+fYyufRLBSYcqycxf//KpBk9FoewoGX0450m9nB44qrZnovzC2oeP5hUibxc/g==} + engines: {node: '>=18'} + + flat-cache@4.0.1: + resolution: {integrity: sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==} + engines: {node: '>=16'} + + flat-cache@5.0.0: + resolution: {integrity: sha512-JrqFmyUl2PnPi1OvLyTVHnQvwQ0S+e6lGSwu8OkAZlSaNIZciTY2H/cOOROxsBA1m/LZNHDsqAgDZt6akWcjsQ==} + engines: {node: '>=18'} + + flatted@3.3.1: + resolution: {integrity: sha512-X8cqMLLie7KsNUDSdzeN8FYK9rEt4Dt67OsG/DNGnYTSDBG4uFAJFBnUeiV+zCVAvwFy56IjM9sH51jVaEhNxw==} + + flush-write-stream@2.0.0: + resolution: {integrity: sha512-uXClqPxT4xW0lcdSBheb2ObVU+kuqUk3Jk64EwieirEXZx9XUrVwp/JuBfKAWaM4T5Td/VL7QLDWPXp/MvGm/g==} + + fn.name@1.1.0: + resolution: {integrity: sha512-GRnmB5gPyJpAhTQdSZTSp9uaPSvl09KoYcMQtsB9rQoOmzs9dH6ffeccH+Z+cv6P68Hu5bC6JjRh4Ah/mHSNRw==} + + folder-walker@3.2.0: + resolution: {integrity: sha512-VjAQdSLsl6AkpZNyrQJfO7BXLo4chnStqb055bumZMbRUPpVuPN3a4ktsnRCmrFZjtMlYLkyXiR5rAs4WOpC4Q==} + + follow-redirects@1.15.6: + resolution: {integrity: sha512-wWN62YITEaOpSK584EZXJafH1AGpO8RVgElfkuXbTOrPX4fIfOyEpW/CsiNd8JdYrAoOvafRTOEnvsO++qCqFA==} + engines: {node: '>=4.0'} + peerDependencies: + debug: '*' + peerDependenciesMeta: + debug: + optional: true + + foreground-child@3.2.1: + resolution: {integrity: sha512-PXUUyLqrR2XCWICfv6ukppP96sdFwWbNEnfEMt7jNsISjMsvaLNinAHNDYyvkyU+SZG2BTSbT5NjG+vZslfGTA==} + engines: {node: '>=14'} + + form-data-encoder@2.1.4: + resolution: {integrity: sha512-yDYSgNMraqvnxiEXO4hi88+YZxaHC6QKzb5N84iRCTDeRO7ZALpir/lVmf/uXUhnwUr2O4HU8s/n6x+yNjQkHw==} + engines: {node: '>= 14.17'} + + form-data@4.0.0: + resolution: {integrity: sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==} + engines: {node: '>= 6'} + + formdata-polyfill@4.0.10: + resolution: {integrity: sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==} + engines: {node: '>=12.20.0'} + + forwarded@0.2.0: + resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==} + engines: {node: '>= 0.6'} + + fraction.js@4.3.7: + resolution: {integrity: sha512-ZsDfxO51wGAXREY55a7la9LScWpwv9RxIrYABrlvOFBlH/ShPnrtsXeuUIfXKKOVicNxQ+o8JTbJvjS4M89yew==} + + fresh@0.5.2: + resolution: {integrity: sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==} + engines: {node: '>= 0.6'} + + from2-array@0.0.4: + resolution: {integrity: sha512-0G0cAp7sYLobH7ALsr835x98PU/YeVF7wlwxdWbCUaea7wsa7lJfKZUAo6p2YZGZ8F94luCuqHZS3JtFER6uPg==} + + from2@2.3.0: + resolution: {integrity: sha512-OMcX/4IC/uqEPVgGeyfN22LJk6AZrMkRZHxcHBMBvHScDGgwTm2GT2Wkgtocyd3JfZffjj2kYUDXXII0Fk9W0g==} + + fs-constants@1.0.0: + resolution: {integrity: sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==} + + fs-minipass@2.1.0: + resolution: {integrity: sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg==} + engines: {node: '>= 8'} + + fs.realpath@1.0.0: + resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==} + + fsevents@2.3.2: + resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + function-bind@1.1.2: + resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} + + fuzzy@0.1.3: + resolution: {integrity: sha512-/gZffu4ykarLrCiP3Ygsa86UAo1E5vEVlvTrpkKywXSbP9Xhln3oSp9QSV57gEq3JFFpGJ4GZ+5zdEp3FcUh4w==} + engines: {node: '>= 0.6.0'} + + gauge@3.0.2: + resolution: {integrity: sha512-+5J6MS/5XksCuXq++uFRsnUd7Ovu1XenbeuIuNRJxYWjgQbPuFhT14lAvsWfqfAmnwluf1OwMjz39HjfLPci0Q==} + engines: {node: '>=10'} + deprecated: This package is no longer supported. + + gensync@1.0.0-beta.2: + resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} + engines: {node: '>=6.9.0'} + + get-amd-module-type@5.0.1: + resolution: {integrity: sha512-jb65zDeHyDjFR1loOVk0HQGM5WNwoGB8aLWy3LKCieMKol0/ProHkhO2X1JxojuN10vbz1qNn09MJ7tNp7qMzw==} + engines: {node: '>=14'} + + get-caller-file@2.0.5: + resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==} + engines: {node: 6.* || 8.* || >= 10.*} + + get-east-asian-width@1.2.0: + resolution: {integrity: sha512-2nk+7SIVb14QrgXFHcm84tD4bKQz0RxPuMT8Ag5KPOq7J5fEmAg0UbXdTOSHqNuHSU28k55qnceesxXRZGzKWA==} + engines: {node: '>=18'} + + get-intrinsic@1.2.4: + resolution: {integrity: sha512-5uYhsJH8VJBTv7oslg4BznJYhDoRI6waYCxMmCdnTrcCrHA/fCFKoTFz2JKKE0HdDFUF7/oQuhzumXJK7paBRQ==} + engines: {node: '>= 0.4'} + + get-package-name@2.2.0: + resolution: {integrity: sha512-LmCKVxioe63Fy6KDAQ/mmCSOSSRUE/x4zdrMD+7dU8quF3bGpzvP8mOmq4Dgce3nzU9AgkVDotucNOOg7c27BQ==} + engines: {node: '>= 12.0.0'} + + get-package-type@0.1.0: + resolution: {integrity: sha512-pjzuKtY64GYfWizNAJ0fr9VqttZkNiK2iS430LtIHzjBEr6bX8Am2zm4sW4Ro5wjWW5cAlRL1qAMTcXbjNAO2Q==} + engines: {node: '>=8.0.0'} + + get-port-please@3.1.2: + resolution: {integrity: sha512-Gxc29eLs1fbn6LQ4jSU4vXjlwyZhF5HsGuMAa7gqBP4Rw4yxxltyDUuF5MBclFzDTXO+ACchGQoeela4DSfzdQ==} + + get-port@5.1.1: + resolution: {integrity: sha512-g/Q1aTSDOxFpchXC4i8ZWvxA1lnPqx/JHqcpIw0/LX9T8x/GBbi6YnlN5nhaKIFkT8oFsscUKgDJYxfwfS6QsQ==} + engines: {node: '>=8'} + + get-port@6.1.2: + resolution: {integrity: sha512-BrGGraKm2uPqurfGVj/z97/zv8dPleC6x9JBNRTrDNtCkkRF4rPwrQXFgL7+I+q8QSdU4ntLQX2D7KIxSy8nGw==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + + get-stdin@9.0.0: + resolution: {integrity: sha512-dVKBjfWisLAicarI2Sf+JuBE/DghV4UzNAVe9yhEJuzeREd3JhOTE9cUaJTeSa77fsbQUK3pcOpJfM59+VKZaA==} + engines: {node: '>=12'} + + get-stream@5.2.0: + resolution: {integrity: sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==} + engines: {node: '>=8'} + + get-stream@6.0.1: + resolution: {integrity: sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==} + engines: {node: '>=10'} + + get-stream@8.0.1: + resolution: {integrity: sha512-VaUJspBffn/LMCJVoMvSAdmscJyS1auj5Zulnn5UoYcY531UWmdwhRWkcGKnGU93m5HSXP9LP2usOryrBtQowA==} + engines: {node: '>=16'} + + gh-release-fetch@4.0.3: + resolution: {integrity: sha512-TOiP1nwLsH5shG85Yt6v6Kjq5JU/44jXyEpbcfPgmj3C829yeXIlx9nAEwQRaxtRF3SJinn2lz7XUkfG9W/U4g==} + engines: {node: ^14.18.0 || ^16.13.0 || >=18.0.0} + + git-repo-info@2.1.1: + resolution: {integrity: sha512-8aCohiDo4jwjOwma4FmYFd3i97urZulL8XL24nIPxuE+GZnfsAyy/g2Shqx6OjUiFKUXZM+Yy+KHnOmmA3FVcg==} + engines: {node: '>= 4.0'} + + gitconfiglocal@2.1.0: + resolution: {integrity: sha512-qoerOEliJn3z+Zyn1HW2F6eoYJqKwS6MgC9cztTLUB/xLWX8gD/6T60pKn4+t/d6tP7JlybI7Z3z+I572CR/Vg==} + + github-from-package@0.0.0: + resolution: {integrity: sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==} + + github-slugger@2.0.0: + resolution: {integrity: sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw==} + + glob-parent@5.1.2: + resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} + engines: {node: '>= 6'} + + glob-parent@6.0.2: + resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} + engines: {node: '>=10.13.0'} + + glob@10.4.5: + resolution: {integrity: sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==} + hasBin: true + + glob@7.2.3: + resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==} + deprecated: Glob versions prior to v9 are no longer supported + + glob@8.1.0: + resolution: {integrity: sha512-r8hpEjiQEYlF2QU0df3dS+nxxSIreXQS1qRhMJM0Q5NDdR386C7jb7Hwwod8Fgiuex+k0GFjgft18yvxm5XoCQ==} + engines: {node: '>=12'} + deprecated: Glob versions prior to v9 are no longer supported + + global-cache-dir@4.4.0: + resolution: {integrity: sha512-bk0gI6IbbphRjAaCJJn5H+T/CcEck5B3a5KBO2BXSDzjFSV+API17w8GA7YPJ6IXJiasW8M0VsEIig1PCHdfOQ==} + engines: {node: '>=14.18.0'} + + global-dirs@3.0.1: + resolution: {integrity: sha512-NBcGGFbBA9s1VzD41QXDG+3++t9Mn5t1FpLdhESY6oKY4gYTFpX4wO3sqGUa0Srjtbfj3szX0RnemmrVRUdULA==} + engines: {node: '>=10'} + + global-modules@2.0.0: + resolution: {integrity: sha512-NGbfmJBp9x8IxyJSd1P+otYK8vonoJactOogrVfFRIAEY1ukil8RSKDz2Yo7wh1oihl51l/r6W4epkeKJHqL8A==} + engines: {node: '>=6'} + + global-prefix@3.0.0: + resolution: {integrity: sha512-awConJSVCHVGND6x3tmMaKcQvwXLhjdkmomy2W+Goaui8YPgYgXJZewhg3fWC+DlfqqQuWg8AwqjGTD2nAPVWg==} + engines: {node: '>=6'} + + globals@11.12.0: + resolution: {integrity: sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA==} + engines: {node: '>=4'} + + globals@14.0.0: + resolution: {integrity: sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==} + engines: {node: '>=18'} + + globby@11.1.0: + resolution: {integrity: sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==} + engines: {node: '>=10'} + + globby@13.2.2: + resolution: {integrity: sha512-Y1zNGV+pzQdh7H39l9zgB4PJqjRNqydvdYCDG4HFXM4XuvSaQQlEc91IU1yALL8gUTDomgBAfz3XJdmUS+oo0w==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + + globjoin@0.1.4: + resolution: {integrity: sha512-xYfnw62CKG8nLkZBfWbhWwDw02CHty86jfPcc2cr3ZfeuK9ysoVPPEUxf21bAD/rWAgk52SuBrLJlefNy8mvFg==} + + gonzales-pe@4.3.0: + resolution: {integrity: sha512-otgSPpUmdWJ43VXyiNgEYE4luzHCL2pz4wQ0OnDluC6Eg4Ko3Vexy/SrSynglw/eR+OhkzmqFCZa/OFa/RgAOQ==} + engines: {node: '>=0.6.0'} + hasBin: true + + gopd@1.0.1: + resolution: {integrity: sha512-d65bNlIadxvpb/A2abVdlqKqV563juRnZ1Wtk6s1sIR8uNsXR70xqIzVqxVf1eTqDunwT2MkczEeaezCKTZhwA==} + + got@12.6.1: + resolution: {integrity: sha512-mThBblvlAF1d4O5oqyvN+ZxLAYwIJK7bpMxgYqPD9okW0C3qm5FFn7k811QrcuEBwaogR3ngOFoCfs6mRv7teQ==} + engines: {node: '>=14.16'} + + graceful-fs@4.2.10: + resolution: {integrity: sha512-9ByhssR2fPVsNZj478qUUbKfmL0+t5BDVyjShtyZZLiK7ZDAArFFfopyOTj0M05wE2tJPisA4iTnnXl2YoPvOA==} + + graceful-fs@4.2.11: + resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + + graphemer@1.4.0: + resolution: {integrity: sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==} + + gray-matter@4.0.3: + resolution: {integrity: sha512-5v6yZd4JK3eMI3FqqCouswVqwugaA9r4dNZB1wwcmrD02QkV5H0y7XBQW8QwQqEaZY1pM9aqORSORhJRdNK44Q==} + engines: {node: '>=6.0'} + + h3@1.12.0: + resolution: {integrity: sha512-Zi/CcNeWBXDrFNlV0hUBJQR9F7a96RjMeAZweW/ZWkR9fuXrMcvKnSA63f/zZ9l0GgQOZDVHGvXivNN9PWOwhA==} + + has-flag@3.0.0: + resolution: {integrity: sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==} + engines: {node: '>=4'} + + has-flag@4.0.0: + resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} + engines: {node: '>=8'} + + has-own-prop@2.0.0: + resolution: {integrity: sha512-Pq0h+hvsVm6dDEa8x82GnLSYHOzNDt7f0ddFa3FqcQlgzEiptPqL+XrOJNavjOzSYiYWIrgeVYYgGlLmnxwilQ==} + engines: {node: '>=8'} + + has-property-descriptors@1.0.2: + resolution: {integrity: sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==} + + has-proto@1.0.3: + resolution: {integrity: sha512-SJ1amZAJUiZS+PhsVLf5tGydlaVB8EdFpaSO4gmiUKUOxk8qzn5AIy4ZeJUmh22znIdk/uMAUT2pl3FxzVUH+Q==} + engines: {node: '>= 0.4'} + + has-symbols@1.0.3: + resolution: {integrity: sha512-l3LCuF6MgDNwTDKkdYGEihYjt5pRPbEg46rtlmnSPlUbgmB8LOIrKJbYYFBSbnPaJexMKtiPO8hmeRjRz2Td+A==} + engines: {node: '>= 0.4'} + + has-unicode@2.0.1: + resolution: {integrity: sha512-8Rf9Y83NBReMnx0gFzA8JImQACstCYWUplepDa9xprwwtmgEZUF0h/i5xSA625zB/I37EtrswSST6OXxwaaIJQ==} + + hasbin@1.2.3: + resolution: {integrity: sha512-CCd8e/w2w28G8DyZvKgiHnQJ/5XXDz6qiUHnthvtag/6T5acUeN5lqq+HMoBqcmgWueWDhiCplrw0Kb1zDACRg==} + engines: {node: '>=0.10'} + + hasha@5.2.2: + resolution: {integrity: sha512-Hrp5vIK/xr5SkeN2onO32H0MgNZ0f17HRNH39WfL0SYUNOTZ5Lz1TJ8Pajo/87dYGEFlLMm7mIc/k/s6Bvz9HQ==} + engines: {node: '>=8'} + + hasown@2.0.2: + resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} + engines: {node: '>= 0.4'} + + hast-util-from-html@2.0.1: + resolution: {integrity: sha512-RXQBLMl9kjKVNkJTIO6bZyb2n+cUH8LFaSSzo82jiLT6Tfc+Pt7VQCS+/h3YwG4jaNE2TA2sdJisGWR+aJrp0g==} + + hast-util-from-parse5@8.0.1: + resolution: {integrity: sha512-Er/Iixbc7IEa7r/XLtuG52zoqn/b3Xng/w6aZQ0xGVxzhw5xUFxcRqdPzP6yFi/4HBYRaifaI5fQ1RH8n0ZeOQ==} + + hast-util-heading-rank@3.0.0: + resolution: {integrity: sha512-EJKb8oMUXVHcWZTDepnr+WNbfnXKFNf9duMesmr4S8SXTJBJ9M4Yok08pu9vxdJwdlGRhVumk9mEhkEvKGifwA==} + + hast-util-is-element@3.0.0: + resolution: {integrity: sha512-Val9mnv2IWpLbNPqc/pUem+a7Ipj2aHacCwgNfTiK0vJKl0LF+4Ba4+v1oPHFpf3bLYmreq0/l3Gud9S5OH42g==} + + hast-util-parse-selector@4.0.0: + resolution: {integrity: sha512-wkQCkSYoOGCRKERFWcxMVMOcYE2K1AaNLU8DXS9arxnLOUEWbOXKXiJUNzEpqZ3JOKpnha3jkFrumEjVliDe7A==} + + hast-util-raw@9.0.4: + resolution: {integrity: sha512-LHE65TD2YiNsHD3YuXcKPHXPLuYh/gjp12mOfU8jxSrm1f/yJpsb0F/KKljS6U9LJoP0Ux+tCe8iJ2AsPzTdgA==} + + hast-util-sanitize@5.0.1: + resolution: {integrity: sha512-IGrgWLuip4O2nq5CugXy4GI2V8kx4sFVy5Hd4vF7AR2gxS0N9s7nEAVUyeMtZKZvzrxVsHt73XdTsno1tClIkQ==} + + hast-util-to-html@9.0.1: + resolution: {integrity: sha512-hZOofyZANbyWo+9RP75xIDV/gq+OUKx+T46IlwERnKmfpwp81XBFbT9mi26ws+SJchA4RVUQwIBJpqEOBhMzEQ==} + + hast-util-to-parse5@8.0.0: + resolution: {integrity: sha512-3KKrV5ZVI8if87DVSi1vDeByYrkGzg4mEfeu4alwgmmIeARiBLKCZS2uw5Gb6nU9x9Yufyj3iudm6i7nl52PFw==} + + hast-util-to-string@3.0.0: + resolution: {integrity: sha512-OGkAxX1Ua3cbcW6EJ5pT/tslVb90uViVkcJ4ZZIMW/R33DX/AkcJcRrPebPwJkHYwlDHXz4aIwvAAaAdtrACFA==} + + hast-util-whitespace@3.0.0: + resolution: {integrity: sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==} + + hastscript@8.0.0: + resolution: {integrity: sha512-dMOtzCEd3ABUeSIISmrETiKuyydk1w0pa+gE/uormcTpSYuaNJPbX1NU3JLyscSLjwAQM8bWMhhIlnCqnRvDTw==} + + hosted-git-info@4.1.0: + resolution: {integrity: sha512-kyCuEOWjJqZuDbRHzL8V93NzQhwIB71oFWSyzVo+KPZI+pnQPPxucdkrOZvkLRnrf5URsQM+IJ09Dw29cRALIA==} + engines: {node: '>=10'} + + hosted-git-info@7.0.2: + resolution: {integrity: sha512-puUZAUKT5m8Zzvs72XWy3HtvVbTWljRE66cP60bxJzAqf2DgICo7lYTY2IHUmLnNpjYvw5bvmoHvPc0QO2a62w==} + engines: {node: ^16.14.0 || >=18.0.0} + + hot-shots@10.0.0: + resolution: {integrity: sha512-uy/uGpuJk7yuyiKRfZMBNkF1GAOX5O2ifO9rDCaX9jw8fu6eW9QeWC7WRPDI+O98frW1HQgV3+xwjWsZPECIzQ==} + engines: {node: '>=10.0.0'} + + html-encoding-sniffer@3.0.0: + resolution: {integrity: sha512-oWv4T4yJ52iKrufjnyZPkrN0CH3QnrUqdB6In1g5Fe1mia8GmF36gnfNySxoZtxD5+NmYw1EElVXiBk93UeskA==} + engines: {node: '>=12'} + + html-escaper@2.0.2: + resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==} + + html-tags@3.3.1: + resolution: {integrity: sha512-ztqyC3kLto0e9WbNp0aeP+M3kTt+nbaIveGmUxAtZa+8iFgKLUOD4YKM5j+f3QD89bra7UeumolZHKuOXnTmeQ==} + engines: {node: '>=8'} + + html-void-elements@3.0.0: + resolution: {integrity: sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==} + + http-cache-semantics@4.1.1: + resolution: {integrity: sha512-er295DKPVsV82j5kw1Gjt+ADA/XYHsajl82cGNQG2eyoPkvgUhX+nDIyelzhIWbbsXP39EHcI6l5tYs2FYqYXQ==} + + http-errors@1.8.1: + resolution: {integrity: sha512-Kpk9Sm7NmI+RHhnj6OIWDI1d6fIoFAtFt9RLaTMRlg/8w49juAStsrBgp0Dp4OdxdVbRIeKhtCUvoi/RuAhO4g==} + engines: {node: '>= 0.6'} + + http-errors@2.0.0: + resolution: {integrity: sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==} + engines: {node: '>= 0.8'} + + http-proxy-agent@5.0.0: + resolution: {integrity: sha512-n2hY8YdoRE1i7r6M0w9DIw5GgZN0G25P8zLCRQ8rjXtTU3vsNFBI/vWK/UIeE6g5MUUz6avwAPXmL6Fy9D/90w==} + engines: {node: '>= 6'} + + http-proxy-middleware@2.0.6: + resolution: {integrity: sha512-ya/UeJ6HVBYxrgYotAZo1KvPWlgB48kUJLDePFeneHsVujFaW5WNj2NgWCAE//B1Dl02BIfYlpNgBy8Kf8Rjmw==} + engines: {node: '>=12.0.0'} + peerDependencies: + '@types/express': ^4.17.13 + peerDependenciesMeta: + '@types/express': + optional: true + + http-proxy@1.18.1: + resolution: {integrity: sha512-7mz/721AbnJwIVbnaSv1Cz3Am0ZLT/UBwkC92VlxhXv/k/BBQfM2fXElQNC27BVGr0uwUpplYPQM9LnaBMR5NQ==} + engines: {node: '>=8.0.0'} + + http-shutdown@1.2.2: + resolution: {integrity: sha512-S9wWkJ/VSY9/k4qcjG318bqJNruzE4HySUhFYknwmu6LBP97KLLfwNf+n4V1BHurvFNkSKLFnK/RsuUnRTf9Vw==} + engines: {iojs: '>= 1.0.0', node: '>= 0.12.0'} + + http2-wrapper@2.2.1: + resolution: {integrity: sha512-V5nVw1PAOgfI3Lmeaj2Exmeg7fenjhRUgz1lPSezy1CuhPYbgQtbQj4jZfEAEMlaL+vupsvhjqCyjzob0yxsmQ==} + engines: {node: '>=10.19.0'} + + https-proxy-agent@5.0.1: + resolution: {integrity: sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==} + engines: {node: '>= 6'} + + https-proxy-agent@7.0.5: + resolution: {integrity: sha512-1e4Wqeblerz+tMKPIq2EMGiiWW1dIjZOksyHWSUm1rmuvw/how9hBHZ38lAGj5ID4Ik6EdkOw7NmWPy6LAwalw==} + engines: {node: '>= 14'} + + human-signals@2.1.0: + resolution: {integrity: sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==} + engines: {node: '>=10.17.0'} + + human-signals@3.0.1: + resolution: {integrity: sha512-rQLskxnM/5OCldHo+wNXbpVgDn5A17CUoKX+7Sokwaknlq7CdSnphy0W39GU8dw59XiCXmFXDg4fRuckQRKewQ==} + engines: {node: '>=12.20.0'} + + human-signals@5.0.0: + resolution: {integrity: sha512-AXcZb6vzzrFAUE61HnN4mpLqd/cSIwNQjtNWR0euPm6y0iqx3G4gOXaIDdtdDwZmhwe82LA6+zinmW4UBWVePQ==} + engines: {node: '>=16.17.0'} + + husky@9.1.4: + resolution: {integrity: sha512-bho94YyReb4JV7LYWRWxZ/xr6TtOTt8cMfmQ39MQYJ7f/YE268s3GdghGwi+y4zAeqewE5zYLvuhV0M0ijsDEA==} + engines: {node: '>=18'} + hasBin: true + + iconv-lite@0.4.24: + resolution: {integrity: sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==} + engines: {node: '>=0.10.0'} + + iconv-lite@0.6.3: + resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==} + engines: {node: '>=0.10.0'} + + ieee754@1.2.1: + resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==} + + ignore@5.3.1: + resolution: {integrity: sha512-5Fytz/IraMjqpwfd34ke28PTVMjZjJG2MPn5t7OE4eUCUNf8BAa7b5WUS9/Qvr6mwOQS7Mk6vdsMno5he+T8Xw==} + engines: {node: '>= 4'} + + ignore@5.3.2: + resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} + engines: {node: '>= 4'} + + image-meta@0.2.1: + resolution: {integrity: sha512-K6acvFaelNxx8wc2VjbIzXKDVB0Khs0QT35U6NkGfTdCmjLNcO2945m7RFNR9/RPVFm48hq7QPzK8uGH18HCGw==} + + immutable@4.3.6: + resolution: {integrity: sha512-Ju0+lEMyzMVZarkTn/gqRpdqd5dOPaz1mCZ0SH3JV6iFw81PldE/PEB1hWVEA288HPt4WXW8O7AWxB10M+03QQ==} + + import-fresh@3.3.0: + resolution: {integrity: sha512-veYYhQa+D1QBKznvhUHxb8faxlrwUnxseDAbAp457E0wLNio2bOSKnjYDhMj+YiAq61xrMGhQk9iXVk5FzgQMw==} + engines: {node: '>=6'} + + import-lazy@4.0.0: + resolution: {integrity: sha512-rKtvo6a868b5Hu3heneU+L4yEQ4jYKLtjpnPeUdK7h0yzXGmyBTypknlkCvHFBqfX9YlorEiMM6Dnq/5atfHkw==} + engines: {node: '>=8'} + + import-local@3.2.0: + resolution: {integrity: sha512-2SPlun1JUPWoM6t3F0dw0FkCF/jWY8kttcY4f599GLTSjh2OCuuhdTkJQsEcZzBqbXZGKMK2OqW1oZsjtf/gQA==} + engines: {node: '>=8'} + hasBin: true + + imurmurhash@0.1.4: + resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} + engines: {node: '>=0.8.19'} + + indent-string@4.0.0: + resolution: {integrity: sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==} + engines: {node: '>=8'} + + indent-string@5.0.0: + resolution: {integrity: sha512-m6FAo/spmsW2Ab2fU35JTYwtOKa2yAwXSwgjSv1TJzh4Mh7mC3lzAOVLBprb72XsTrgkEIsl7YrFNAiDiRhIGg==} + engines: {node: '>=12'} + + index-to-position@0.1.2: + resolution: {integrity: sha512-MWDKS3AS1bGCHLBA2VLImJz42f7bJh8wQsTGCzI3j519/CASStoDONUBVz2I/VID0MpiX3SGSnbOD2xUalbE5g==} + engines: {node: '>=18'} + + inflight@1.0.6: + resolution: {integrity: sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==} + deprecated: This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful. + + inherits@2.0.4: + resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + + ini@1.3.8: + resolution: {integrity: sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==} + + ini@2.0.0: + resolution: {integrity: sha512-7PnF4oN3CvZF23ADhA5wRaYEQpJ8qygSkbtTXWBeXWXmEVRXK+1ITciHWwHhsjv1TmW0MgacIv6hEi5pX5NQdA==} + engines: {node: '>=10'} + + ini@4.1.3: + resolution: {integrity: sha512-X7rqawQBvfdjS10YU1y1YVreA3SsLrW9dX2CewP2EbBJM4ypVNLDkO5y04gejPwKIY9lR+7r9gn3rFPt/kmWFg==} + engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} + + inquirer-autocomplete-prompt@1.4.0: + resolution: {integrity: sha512-qHgHyJmbULt4hI+kCmwX92MnSxDs/Yhdt4wPA30qnoa01OF6uTXV8yvH4hKXgdaTNmkZ9D01MHjqKYEuJN+ONw==} + engines: {node: '>=10'} + peerDependencies: + inquirer: ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0 + + inquirer@6.5.2: + resolution: {integrity: sha512-cntlB5ghuB0iuO65Ovoi8ogLHiWGs/5yNrtUcKjFhSSiVeAIVpD7koaSU9RM8mpXw5YDi9RdYXGQMaOURB7ycQ==} + engines: {node: '>=6.0.0'} + + inspect-with-kind@1.0.5: + resolution: {integrity: sha512-MAQUJuIo7Xqk8EVNP+6d3CKq9c80hi4tjIbIAT6lmGW9W6WzlHiu9PS8uSuUYU+Do+j1baiFp3H25XEVxDIG2g==} + + ipaddr.js@1.9.1: + resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==} + engines: {node: '>= 0.10'} + + ipx@2.1.0: + resolution: {integrity: sha512-AVnPGXJ8L41vjd11Z4akIF2yd14636Klxul3tBySxHA6PKfCOQPxBDkCFK5zcWh0z/keR6toh1eg8qzdBVUgdA==} + hasBin: true + + iron-webcrypto@1.2.1: + resolution: {integrity: sha512-feOM6FaSr6rEABp/eDfVseKyTMDt+KGpeB35SkVn9Tyn0CqvVsY3EwI0v5i8nMHyJnzCIQf7nsy3p41TPkJZhg==} + + is-arrayish@0.2.1: + resolution: {integrity: sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==} + + is-arrayish@0.3.2: + resolution: {integrity: sha512-eVRqCvVlZbuw3GrM63ovNSNAeA1K16kaR/LRY/92w0zxQ5/1YzwblUX652i4Xs9RwAGjW9d9y6X88t8OaAJfWQ==} + + is-binary-path@2.1.0: + resolution: {integrity: sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==} + engines: {node: '>=8'} + + is-builtin-module@3.2.1: + resolution: {integrity: sha512-BSLE3HnV2syZ0FK0iMA/yUGplUeMmNz4AW5fnTunbCIqZi4vG3WjJT9FHMy5D69xmAYBHXQhJdALdpwVxV501A==} + engines: {node: '>=6'} + + is-core-module@2.15.0: + resolution: {integrity: sha512-Dd+Lb2/zvk9SKy1TGCt1wFJFo/MWBPMX5x7KcvLajWTGuomczdQX61PvY5yK6SVACwpoexWo81IfFyoKY2QnTA==} + engines: {node: '>= 0.4'} + + is-docker@2.2.1: + resolution: {integrity: sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==} + engines: {node: '>=8'} + hasBin: true + + is-docker@3.0.0: + resolution: {integrity: sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + hasBin: true + + is-extendable@0.1.1: + resolution: {integrity: sha512-5BMULNob1vgFX6EjQw5izWDxrecWK9AM72rugNr0TFldMOi0fj6Jk+zeKIt0xGj4cEfQIJth4w3OKWOJ4f+AFw==} + engines: {node: '>=0.10.0'} + + is-extglob@2.1.1: + resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} + engines: {node: '>=0.10.0'} + + is-fullwidth-code-point@2.0.0: + resolution: {integrity: sha512-VHskAKYM8RfSFXwee5t5cbN5PZeq1Wrh6qd5bkyiXIf6UQcN6w/A0eXM9r6t8d+GYOh+o6ZhiEnb88LN/Y8m2w==} + engines: {node: '>=4'} + + is-fullwidth-code-point@3.0.0: + resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} + engines: {node: '>=8'} + + is-fullwidth-code-point@4.0.0: + resolution: {integrity: sha512-O4L094N2/dZ7xqVdrXhh9r1KODPJpFms8B5sGdJLPy664AgvXsreZUyCQQNItZRDlYug4xStLjNp/sz3HvBowQ==} + engines: {node: '>=12'} + + is-fullwidth-code-point@5.0.0: + resolution: {integrity: sha512-OVa3u9kkBbw7b8Xw5F9P+D/T9X+Z4+JruYVNapTjPYZYUznQ5YfWeFkOj606XYYW8yugTfC8Pj0hYqvi4ryAhA==} + engines: {node: '>=18'} + + is-generator-fn@2.1.0: + resolution: {integrity: sha512-cTIB4yPYL/Grw0EaSzASzg6bBy9gqCofvWN8okThAYIxKJZC+udlRAmGbM0XLeniEJSs8uEgHPGuHSe1XsOLSQ==} + engines: {node: '>=6'} + + is-glob@4.0.3: + resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} + engines: {node: '>=0.10.0'} + + is-in-ci@0.1.0: + resolution: {integrity: sha512-d9PXLEY0v1iJ64xLiQMJ51J128EYHAaOR4yZqQi8aHGfw6KgifM3/Viw1oZZ1GCVmb3gBuyhLyHj0HgR2DhSXQ==} + engines: {node: '>=18'} + hasBin: true + + is-inside-container@1.0.0: + resolution: {integrity: sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA==} + engines: {node: '>=14.16'} + hasBin: true + + is-installed-globally@0.4.0: + resolution: {integrity: sha512-iwGqO3J21aaSkC7jWnHP/difazwS7SFeIqxv6wEtLU8Y5KlzFTjyqcSIT0d8s4+dDhKytsk9PJZ2BkS5eZwQRQ==} + engines: {node: '>=10'} + + is-interactive@2.0.0: + resolution: {integrity: sha512-qP1vozQRI+BMOPcjFzrjXuQvdak2pHNUMZoeG2eRbiSqyvbEf/wQtEOTOX1guk6E3t36RkaqiSt8A/6YElNxLQ==} + engines: {node: '>=12'} + + is-npm@6.0.0: + resolution: {integrity: sha512-JEjxbSmtPSt1c8XTkVrlujcXdKV1/tvuQ7GwKcAlyiVLeYFQ2VHat8xfrDJsIkhCdF/tZ7CiIR3sy141c6+gPQ==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + + is-number@7.0.0: + resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} + engines: {node: '>=0.12.0'} + + is-obj@2.0.0: + resolution: {integrity: sha512-drqDG3cbczxxEJRoOXcOjtdp1J/lyp1mNn0xaznRs8+muBhgQcrnbspox5X5fOw0HnMnbfDzvnEMEtqDEJEo8w==} + engines: {node: '>=8'} + + is-path-inside@3.0.3: + resolution: {integrity: sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==} + engines: {node: '>=8'} + + is-path-inside@4.0.0: + resolution: {integrity: sha512-lJJV/5dYS+RcL8uQdBDW9c9uWFLLBNRyFhnAKXw5tVqLlKZ4RMGZKv+YQ/IA3OhD+RpbJa1LLFM1FQPGyIXvOA==} + engines: {node: '>=12'} + + is-plain-obj@1.1.0: + resolution: {integrity: sha512-yvkRyxmFKEOQ4pNXCmJG5AEQNlXJS5LaONXo5/cLdTZdWvsZ1ioJEonLGAosKlMWE8lwUy/bJzMjcw8az73+Fg==} + engines: {node: '>=0.10.0'} + + is-plain-obj@2.1.0: + resolution: {integrity: sha512-YWnfyRwxL/+SsrWYfOpUtz5b3YD+nyfkHvjbcanzk8zgyO4ASD67uVMRt8k5bM4lLMDnXfriRhOpemw+NfT1eA==} + engines: {node: '>=8'} + + is-plain-obj@3.0.0: + resolution: {integrity: sha512-gwsOE28k+23GP1B6vFl1oVh/WOzmawBrKwo5Ev6wMKzPkaXaCDIQKzLnvsA42DRlbVTWorkgTKIviAKCWkfUwA==} + engines: {node: '>=10'} + + is-plain-obj@4.1.0: + resolution: {integrity: sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==} + engines: {node: '>=12'} + + is-plain-object@5.0.0: + resolution: {integrity: sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q==} + engines: {node: '>=0.10.0'} + + is-potential-custom-element-name@1.0.1: + resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==} + + is-stream@2.0.1: + resolution: {integrity: sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==} + engines: {node: '>=8'} + + is-stream@3.0.0: + resolution: {integrity: sha512-LnQR4bZ9IADDRSkvpqMGvt/tEJWclzklNgSw48V5EAaAeDd6qGvN8ei6k5p0tvxSR171VmGyHuTiAOfxAbr8kA==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + + is-stream@4.0.1: + resolution: {integrity: sha512-Dnz92NInDqYckGEUJv689RbRiTSEHCQ7wOVeALbkOz999YpqT46yMRIGtSNl2iCL1waAZSx40+h59NV/EwzV/A==} + engines: {node: '>=18'} + + is-typedarray@1.0.0: + resolution: {integrity: sha512-cyA56iCMHAh5CdzjJIa4aohJyeO1YbwLi3Jc35MmRU6poroFjIGZzUzupGiRPOjgHg9TLu43xbpwXk523fMxKA==} + + is-unicode-supported@1.3.0: + resolution: {integrity: sha512-43r2mRvz+8JRIKnWJ+3j8JtjRKZ6GmjzfaE/qiBJnikNnYv/6bagRJ1kUhNk8R5EX/GkobD+r+sfxCPJsiKBLQ==} + engines: {node: '>=12'} + + is-unicode-supported@2.0.0: + resolution: {integrity: sha512-FRdAyx5lusK1iHG0TWpVtk9+1i+GjrzRffhDg4ovQ7mcidMQ6mj+MhKPmvh7Xwyv5gIS06ns49CA7Sqg7lC22Q==} + engines: {node: '>=18'} + + is-url-superb@4.0.0: + resolution: {integrity: sha512-GI+WjezhPPcbM+tqE9LnmsY5qqjwHzTvjJ36wxYX5ujNXefSUJ/T17r5bqDV8yLhcgB59KTPNOc9O9cmHTPWsA==} + engines: {node: '>=10'} + + is-url@1.2.4: + resolution: {integrity: sha512-ITvGim8FhRiYe4IQ5uHSkj7pVaPDrCTkNd3yq3cV7iZAcJdHTUMPMEHcqSOy9xZ9qFenQCvi+2wjH9a1nXqHww==} + + is-wsl@2.2.0: + resolution: {integrity: sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==} + engines: {node: '>=8'} + + is-wsl@3.1.0: + resolution: {integrity: sha512-UcVfVfaK4Sc4m7X3dUSoHoozQGBEFeDC+zVo06t98xe8CzHSZZBekNXH+tu0NalHolcJ/QAGqS46Hef7QXBIMw==} + engines: {node: '>=16'} + + is64bit@2.0.0: + resolution: {integrity: sha512-jv+8jaWCl0g2lSBkNSVXdzfBA0npK1HGC2KtWM9FumFRoGS94g3NbCCLVnCYHLjp4GrW2KZeeSTMo5ddtznmGw==} + engines: {node: '>=18'} + + isarray@1.0.0: + resolution: {integrity: sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==} + + iserror@0.0.2: + resolution: {integrity: sha512-oKGGrFVaWwETimP3SiWwjDeY27ovZoyZPHtxblC4hCq9fXxed/jasx+ATWFFjCVSRZng8VTMsN1nDnGo6zMBSw==} + + isexe@2.0.0: + resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + + isexe@3.1.1: + resolution: {integrity: sha512-LpB/54B+/2J5hqQ7imZHfdU31OlgQqx7ZicVlkm9kzg9/w8GKLEcFfJl/t7DCEDueOyBAD6zCCwTO6Fzs0NoEQ==} + engines: {node: '>=16'} + + istanbul-lib-coverage@3.2.2: + resolution: {integrity: sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==} + engines: {node: '>=8'} + + istanbul-lib-instrument@5.2.1: + resolution: {integrity: sha512-pzqtp31nLv/XFOzXGuvhCb8qhjmTVo5vjVk19XE4CRlSWz0KoeJ3bw9XsA7nOp9YBf4qHjwBxkDzKcME/J29Yg==} + engines: {node: '>=8'} + + istanbul-lib-instrument@6.0.3: + resolution: {integrity: sha512-Vtgk7L/R2JHyyGW07spoFlB8/lpjiOLTjMdms6AFMraYt3BaJauod/NGrfnVG/y4Ix1JEuMRPDPEj2ua+zz1/Q==} + engines: {node: '>=10'} + + istanbul-lib-report@3.0.1: + resolution: {integrity: sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==} + engines: {node: '>=10'} + + istanbul-lib-source-maps@4.0.1: + resolution: {integrity: sha512-n3s8EwkdFIJCG3BPKBYvskgXGoy88ARzvegkitk60NxRdwltLOTaH7CUiMRXvwYorl0Q712iEjcWB+fK/MrWVw==} + engines: {node: '>=10'} + + istanbul-reports@3.1.7: + resolution: {integrity: sha512-BewmUXImeuRk2YY0PVbxgKAysvhRPUQE0h5QRM++nVWyubKGV0l8qQ5op8+B2DOmwSe63Jivj0BjkPQVf8fP5g==} + engines: {node: '>=8'} + + jackspeak@3.4.3: + resolution: {integrity: sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==} + + jest-changed-files@29.7.0: + resolution: {integrity: sha512-fEArFiwf1BpQ+4bXSprcDc3/x4HSzL4al2tozwVpDFpsxALjLYdyiIK4e5Vz66GQJIbXJ82+35PtysofptNX2w==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest-circus@29.7.0: + resolution: {integrity: sha512-3E1nCMgipcTkCocFwM90XXQab9bS+GMsjdpmPrlelaxwD93Ad8iVEjX/vvHPdLPnFf+L40u+5+iutRdA1N9myw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest-cli@29.7.0: + resolution: {integrity: sha512-OVVobw2IubN/GSYsxETi+gOe7Ka59EFMR/twOU3Jb2GnKKeMGJB5SGUUrEz3SFVmJASUdZUzy83sLNNQ2gZslg==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + hasBin: true + peerDependencies: + node-notifier: ^8.0.1 || ^9.0.0 || ^10.0.0 + peerDependenciesMeta: + node-notifier: + optional: true + + jest-config@29.7.0: + resolution: {integrity: sha512-uXbpfeQ7R6TZBqI3/TxCU4q4ttk3u0PJeC+E0zbfSoSjq6bJ7buBPxzQPL0ifrkY4DNu4JUdk0ImlBUYi840eQ==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + peerDependencies: + '@types/node': '*' + ts-node: '>=9.0.0' + peerDependenciesMeta: + '@types/node': + optional: true + ts-node: + optional: true + + jest-diff@29.7.0: + resolution: {integrity: sha512-LMIgiIrhigmPrs03JHpxUh2yISK3vLFPkAodPeo0+BuF7wA2FoQbkEg1u8gBYBThncu7e1oEDUfIXVuTqLRUjw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest-docblock@29.7.0: + resolution: {integrity: sha512-q617Auw3A612guyaFgsbFeYpNP5t2aoUNLwBUbc/0kD1R4t9ixDbyFTHd1nok4epoVFpr7PmeWHrhvuV3XaJ4g==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest-each@29.7.0: + resolution: {integrity: sha512-gns+Er14+ZrEoC5fhOfYCY1LOHHr0TI+rQUHZS8Ttw2l7gl+80eHc/gFf2Ktkw0+SIACDTeWvpFcv3B04VembQ==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest-environment-jsdom@29.7.0: + resolution: {integrity: sha512-k9iQbsf9OyOfdzWH8HDmrRT0gSIcX+FLNW7IQq94tFX0gynPwqDTW0Ho6iMVNjGz/nb+l/vW3dWM2bbLLpkbXA==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + peerDependencies: + canvas: ^2.5.0 + peerDependenciesMeta: + canvas: + optional: true + + jest-environment-node@29.7.0: + resolution: {integrity: sha512-DOSwCRqXirTOyheM+4d5YZOrWcdu0LNZ87ewUoywbcb2XR4wKgqiG8vNeYwhjFMbEkfju7wx2GYH0P2gevGvFw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest-get-type@27.5.1: + resolution: {integrity: sha512-2KY95ksYSaK7DMBWQn6dQz3kqAf3BB64y2udeG+hv4KfSOb9qwcYQstTJc1KCbsix+wLZWZYN8t7nwX3GOBLRw==} + engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0} + + jest-get-type@29.6.3: + resolution: {integrity: sha512-zrteXnqYxfQh7l5FHyL38jL39di8H8rHoecLH3JNxH3BwOrBsNeabdap5e0I23lD4HHI8W5VFBZqG4Eaq5LNcw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest-haste-map@29.7.0: + resolution: {integrity: sha512-fP8u2pyfqx0K1rGn1R9pyE0/KTn+G7PxktWidOBTqFPLYX0b9ksaMFkhK5vrS3DVun09pckLdlx90QthlW7AmA==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest-leak-detector@29.7.0: + resolution: {integrity: sha512-kYA8IJcSYtST2BY9I+SMC32nDpBT3J2NvWJx8+JCuCdl/CR1I4EKUJROiP8XtCcxqgTTBGJNdbB1A8XRKbTetw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest-matcher-utils@29.7.0: + resolution: {integrity: sha512-sBkD+Xi9DtcChsI3L3u0+N0opgPYnCRPtGcQYrgXmR+hmt/fYfWAL0xRXYU8eWOdfuLgBe0YCW3AFtnRLagq/g==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest-message-util@29.7.0: + resolution: {integrity: sha512-GBEV4GRADeP+qtB2+6u61stea8mGcOT4mCtrYISZwfu9/ISHFJ/5zOMXYbpBE9RsS5+Gb63DW4FgmnKJ79Kf6w==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest-mock@29.7.0: + resolution: {integrity: sha512-ITOMZn+UkYS4ZFh83xYAOzWStloNzJFO2s8DWrE4lhtGD+AorgnbkiKERe4wQVBydIGPx059g6riW5Btp6Llnw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest-pnp-resolver@1.2.3: + resolution: {integrity: sha512-+3NpwQEnRoIBtx4fyhblQDPgJI0H1IEIkX7ShLUjPGA7TtUTvI1oiKi3SR4oBR0hQhQR80l4WAe5RrXBwWMA8w==} + engines: {node: '>=6'} + peerDependencies: + jest-resolve: '*' + peerDependenciesMeta: + jest-resolve: + optional: true + + jest-regex-util@29.6.3: + resolution: {integrity: sha512-KJJBsRCyyLNWCNBOvZyRDnAIfUiRJ8v+hOBQYGn8gDyF3UegwiP4gwRR3/SDa42g1YbVycTidUF3rKjyLFDWbg==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest-resolve-dependencies@29.7.0: + resolution: {integrity: sha512-un0zD/6qxJ+S0et7WxeI3H5XSe9lTBBR7bOHCHXkKR6luG5mwDDlIzVQ0V5cZCuoTgEdcdwzTghYkTWfubi+nA==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest-resolve@29.7.0: + resolution: {integrity: sha512-IOVhZSrg+UvVAshDSDtHyFCCBUl/Q3AAJv8iZ6ZjnZ74xzvwuzLXid9IIIPgTnY62SJjfuupMKZsZQRsCvxEgA==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest-runner@29.7.0: + resolution: {integrity: sha512-fsc4N6cPCAahybGBfTRcq5wFR6fpLznMg47sY5aDpsoejOcVYFb07AHuSnR0liMcPTgBsA3ZJL6kFOjPdoNipQ==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest-runtime@29.7.0: + resolution: {integrity: sha512-gUnLjgwdGqW7B4LvOIkbKs9WGbn+QLqRQQ9juC6HndeDiezIwhDP+mhMwHWCEcfQ5RUXa6OPnFF8BJh5xegwwQ==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest-snapshot@29.7.0: + resolution: {integrity: sha512-Rm0BMWtxBcioHr1/OX5YCP8Uov4riHvKPknOGs804Zg9JGZgmIBkbtlxJC/7Z4msKYVbIJtfU+tKb8xlYNfdkw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest-util@29.7.0: + resolution: {integrity: sha512-z6EbKajIpqGKU56y5KBUgy1dt1ihhQJgWzUlZHArA/+X2ad7Cb5iF+AK1EWVL/Bo7Rz9uurpqw6SiBCefUbCGA==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest-validate@27.5.1: + resolution: {integrity: sha512-thkNli0LYTmOI1tDB3FI1S1RTp/Bqyd9pTarJwL87OIBFuqEb5Apv5EaApEudYg4g86e3CT6kM0RowkhtEnCBQ==} + engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0} + + jest-validate@29.7.0: + resolution: {integrity: sha512-ZB7wHqaRGVw/9hST/OuFUReG7M8vKeq0/J2egIGLdvjHCmYqGARhzXmtgi+gVeZ5uXFF219aOc3Ls2yLg27tkw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest-watcher@29.7.0: + resolution: {integrity: sha512-49Fg7WXkU3Vl2h6LbLtMQ/HyB6rXSIX7SqvBLQmssRBGN9I0PNvPmAmCWSOY6SOvrjhI/F7/bGAv9RtnsPA03g==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest-worker@29.7.0: + resolution: {integrity: sha512-eIz2msL/EzL9UFTFFx7jBTkeZfku0yUAyZZZmJ93H2TYEiroIx2PQjEXcwYtYl8zXCxb+PAmA2hLIt/6ZEkPHw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest@29.7.0: + resolution: {integrity: sha512-NIy3oAFp9shda19hy4HK0HRTWKtPJmGdnvywu01nOqNC2vZg+Z+fvJDxpMQA88eb2I9EcafcdjYgsDthnYTvGw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + hasBin: true + peerDependencies: + node-notifier: ^8.0.1 || ^9.0.0 || ^10.0.0 + peerDependenciesMeta: + node-notifier: + optional: true + + jiti@1.21.6: + resolution: {integrity: sha512-2yTgeWTWzMWkHu6Jp9NKgePDaYHbntiwvYuuJLbbN9vl7DC9DvXKOB2BC3ZZ92D3cvV/aflH0osDfwpHepQ53w==} + hasBin: true + + jotai@2.9.3: + resolution: {integrity: sha512-IqMWKoXuEzWSShjd9UhalNsRGbdju5G2FrqNLQJT+Ih6p41VNYe2sav5hnwQx4HJr25jq9wRqvGSWGviGG6Gjw==} + engines: {node: '>=12.20.0'} + peerDependencies: + '@types/react': '>=17.0.0' + react: '>=17.0.0' + peerDependenciesMeta: + '@types/react': + optional: true + react: + optional: true + + js-string-escape@1.0.1: + resolution: {integrity: sha512-Smw4xcfIQ5LVjAOuJCvN/zIodzA/BBSsluuoSykP+lUvScIi4U6RJLfwHet5cxFnCswUjISV8oAXaqaJDY3chg==} + engines: {node: '>= 0.8'} + + js-tokens@4.0.0: + resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + + js-yaml@3.14.1: + resolution: {integrity: sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g==} + hasBin: true + + js-yaml@4.1.0: + resolution: {integrity: sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==} + hasBin: true + + jsdom@20.0.3: + resolution: {integrity: sha512-SYhBvTh89tTfCD/CRdSOm13mOBa42iTaTyfyEWBdKcGdPxPtLFBXuHR8XHb33YNYaP+lLbmSvBTsnoesCNJEsQ==} + engines: {node: '>=14'} + peerDependencies: + canvas: ^2.5.0 + peerDependenciesMeta: + canvas: + optional: true + + jsesc@2.5.2: + resolution: {integrity: sha512-OYu7XEzjkCQ3C5Ps3QIZsQfNpqoJyZZA99wd9aWd05NCtC5pWOkShK2mkL6HXQR6/Cy2lbNdPlZBpuQHXE63gA==} + engines: {node: '>=4'} + hasBin: true + + json-buffer@3.0.1: + resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==} + + json-parse-even-better-errors@2.3.1: + resolution: {integrity: sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==} + + json-schema-ref-resolver@1.0.1: + resolution: {integrity: sha512-EJAj1pgHc1hxF6vo2Z3s69fMjO1INq6eGHXZ8Z6wCQeldCuwxGK9Sxf4/cScGn3FZubCVUehfWtcDM/PLteCQw==} + + json-schema-traverse@0.4.1: + resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} + + json-schema-traverse@1.0.0: + resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} + + json-stable-stringify-without-jsonify@1.0.1: + resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} + + json5@2.2.3: + resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==} + engines: {node: '>=6'} + hasBin: true + + jsonc-parser@3.2.1: + resolution: {integrity: sha512-AilxAyFOAcK5wA1+LeaySVBrHsGQvUFCDWXKpZjzaL0PqW+xfBOttn8GNtWKFWqneyMZj41MWF9Kl6iPWLwgOA==} + + jsonc-parser@3.3.1: + resolution: {integrity: sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==} + + jsonpointer@5.0.1: + resolution: {integrity: sha512-p/nXbhSEcu3pZRdkW1OfJhpsVtW1gd4Wa1fnQc9YLiTfAjn0312eMKimbdIQzuZl9aa9xUGaRlP9T/CJE/ditQ==} + engines: {node: '>=0.10.0'} + + jsonwebtoken@9.0.2: + resolution: {integrity: sha512-PRp66vJ865SSqOlgqS8hujT5U4AOgMfhrwYIuIhfKaoSCZcirrmASQr8CX7cUg+RMih+hgznrjp99o+W4pJLHQ==} + engines: {node: '>=12', npm: '>=6'} + + junk@4.0.1: + resolution: {integrity: sha512-Qush0uP+G8ZScpGMZvHUiRfI0YBWuB3gVBYlI0v0vvOJt5FLicco+IkP0a50LqTTQhmts/m6tP5SWE+USyIvcQ==} + engines: {node: '>=12.20'} + + jwa@1.4.1: + resolution: {integrity: sha512-qiLX/xhEEFKUAJ6FiBMbes3w9ATzyk5W7Hvzpa/SLYdxNtng+gcurvrI7TbACjIXlsJyr05/S1oUhZrc63evQA==} + + jws@3.2.2: + resolution: {integrity: sha512-YHlZCB6lMTllWDtSPHz/ZXTsi8S00usEV6v1tjq8tOUZzw7DpSDWVXjXDre6ed1w/pd495ODpHZYSdkRTsa0HA==} + + jwt-decode@4.0.0: + resolution: {integrity: sha512-+KJGIyHgkGuIq3IEBNftfhW/LfWhXUIY6OmyVWjliu5KH1y0fw7VQ8YndE2O4qZdMSd9SqbnC8GOcZEy0Om7sA==} + engines: {node: '>=18'} + + keep-func-props@4.0.1: + resolution: {integrity: sha512-87ftOIICfdww3SxR5P1veq3ThBNyRPG0JGL//oaR08v0k2yTicEIHd7s0GqSJfQvlb+ybC3GiDepOweo0LDhvw==} + engines: {node: '>=12.20.0'} + + keyv@4.5.4: + resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} + + kind-of@6.0.3: + resolution: {integrity: sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==} + engines: {node: '>=0.10.0'} + + kleur@3.0.3: + resolution: {integrity: sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==} + engines: {node: '>=6'} + + known-css-properties@0.34.0: + resolution: {integrity: sha512-tBECoUqNFbyAY4RrbqsBQqDFpGXAEbdD5QKr8kACx3+rnArmuuR22nKQWKazvp07N9yjTyDZaw/20UIH8tL9DQ==} + + kuler@2.0.0: + resolution: {integrity: sha512-Xq9nH7KlWZmXAtodXDDRE7vs6DU1gTU8zYDHDiWLSip45Egwq3plLHzPn27NgvzL2r1LMPC1vdqh98sQxtqj4A==} + + lambda-local@2.2.0: + resolution: {integrity: sha512-bPcgpIXbHnVGfI/omZIlgucDqlf4LrsunwoKue5JdZeGybt8L6KyJz2Zu19ffuZwIwLj2NAI2ZyaqNT6/cetcg==} + engines: {node: '>=8'} + hasBin: true + + latest-version@7.0.0: + resolution: {integrity: sha512-KvNT4XqAMzdcL6ka6Tl3i2lYeFDgXNCuIX+xNx6ZMVR1dFq+idXd9FLKNMOIx0t9mJ9/HudyX4oZWXZQ0UJHeg==} + engines: {node: '>=14.16'} + + lazystream@1.0.1: + resolution: {integrity: sha512-b94GiNHQNy6JNTrt5w6zNyffMrNkXZb3KTkCZJb2V1xaEGCk093vkZ2jk3tpaeP33/OiXC+WvK9AxUebnf5nbw==} + engines: {node: '>= 0.6.3'} + + leven@3.1.0: + resolution: {integrity: sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==} + engines: {node: '>=6'} + + levn@0.4.1: + resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} + engines: {node: '>= 0.8.0'} + + light-my-request@5.13.0: + resolution: {integrity: sha512-9IjUN9ZyCS9pTG+KqTDEQo68Sui2lHsYBrfMyVUTTZ3XhH8PMZq7xO94Kr+eP9dhi/kcKsx4N41p2IXEBil1pQ==} + + lilconfig@2.1.0: + resolution: {integrity: sha512-utWOt/GHzuUxnLKxB6dk81RoOeoNeHgbrXiuGk4yyF5qlRz+iIVWu56E2fqGHFrXz0QNUhLB/8nKqvRH66JKGQ==} + engines: {node: '>=10'} + + lilconfig@3.1.2: + resolution: {integrity: sha512-eop+wDAvpItUys0FWkHIKeC9ybYrTGbU41U5K7+bttZZeohvnY7M9dZ5kB21GNWiFT2q1OoPTvncPCgSOVO5ow==} + engines: {node: '>=14'} + + lines-and-columns@1.2.4: + resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==} + + linkify-it@5.0.0: + resolution: {integrity: sha512-5aHCbzQRADcdP+ATqnDuhhJ/MRIqDkZX5pyjFHRRysS8vZ5AbqGEoFIb6pYHPZ+L/OC2Lc+xT8uHVVR5CAK/wQ==} + + listhen@1.7.2: + resolution: {integrity: sha512-7/HamOm5YD9Wb7CFgAZkKgVPA96WwhcTQoqtm2VTZGVbVVn3IWKRBTgrU7cchA3Q8k9iCsG8Osoi9GX4JsGM9g==} + hasBin: true + + listr2@8.2.4: + resolution: {integrity: sha512-opevsywziHd3zHCVQGAj8zu+Z3yHNkkoYhWIGnq54RrCVwLz0MozotJEDnKsIBLvkfLGN6BLOyAeRrYI0pKA4g==} + engines: {node: '>=18.0.0'} + + locate-path@5.0.0: + resolution: {integrity: sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==} + engines: {node: '>=8'} + + locate-path@6.0.0: + resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} + engines: {node: '>=10'} + + locate-path@7.2.0: + resolution: {integrity: sha512-gvVijfZvn7R+2qyPX8mAuKcFGDf6Nc61GdvGafQsHL0sBIxfKzA+usWn4GFC/bk+QdwPUD4kWFJLhElipq+0VA==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + + lodash-es@4.17.21: + resolution: {integrity: sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==} + + lodash.includes@4.3.0: + resolution: {integrity: sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==} + + lodash.isboolean@3.0.3: + resolution: {integrity: sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==} + + lodash.isempty@4.4.0: + resolution: {integrity: sha512-oKMuF3xEeqDltrGMfDxAPGIVMSSRv8tbRSODbrs4KGsRRLEhrW8N8Rd4DRgB2+621hY8A8XwwrTVhXWpxFvMzg==} + + lodash.isinteger@4.0.4: + resolution: {integrity: sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==} + + lodash.isnumber@3.0.3: + resolution: {integrity: sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==} + + lodash.isplainobject@4.0.6: + resolution: {integrity: sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==} + + lodash.isstring@4.0.1: + resolution: {integrity: sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==} + + lodash.merge@4.6.2: + resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} + + lodash.once@4.1.1: + resolution: {integrity: sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==} + + lodash.transform@4.6.0: + resolution: {integrity: sha512-LO37ZnhmBVx0GvOU/caQuipEh4GN82TcWv3yHlebGDgOxbxiwwzW5Pcx2AcvpIv2WmvmSMoC492yQFNhy/l/UQ==} + + lodash.truncate@4.4.2: + resolution: {integrity: sha512-jttmRe7bRse52OsWIMDLaXxWqRAmtIUccAQ3garviCqJjafXOfNMO0yMfNpdD6zbGaTU0P5Nz7e7gAT6cKmJRw==} + + lodash@4.17.21: + resolution: {integrity: sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==} + + log-process-errors@8.0.0: + resolution: {integrity: sha512-+SNGqNC1gCMJfhwYzAHr/YgNT/ZJc+V2nCkvtPnjrENMeCe+B/jgShBW0lmWoh6uVV2edFAPc/IUOkDdsjTbTg==} + engines: {node: '>=12.20.0'} + + log-symbols@6.0.0: + resolution: {integrity: sha512-i24m8rpwhmPIS4zscNzK6MSEhk0DUWa/8iYQWxhffV8jkI4Phvs3F+quL5xvS0gdQR0FyTCMMH33Y78dDTzzIw==} + engines: {node: '>=18'} + + log-update@6.0.0: + resolution: {integrity: sha512-niTvB4gqvtof056rRIrTZvjNYE4rCUzO6X/X+kYjd7WFxXeJ0NwEFnRxX6ehkvv3jTwrXnNdtAak5XYZuIyPFw==} + engines: {node: '>=18'} + + log-update@6.1.0: + resolution: {integrity: sha512-9ie8ItPR6tjY5uYJh8K/Zrv/RMZ5VOlOWvtZdEHYSTFKZfIBPQa9tOAEeAWhd+AnIneLJ22w5fjOYtoutpWq5w==} + engines: {node: '>=18'} + + logform@2.6.1: + resolution: {integrity: sha512-CdaO738xRapbKIMVn2m4F6KTj4j7ooJ8POVnebSgKo3KBz5axNXRAL7ZdRjIV6NOr2Uf4vjtRkxrFETOioCqSA==} + engines: {node: '>= 12.0.0'} + + longest-streak@3.1.0: + resolution: {integrity: sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==} + + loose-envify@1.4.0: + resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==} + hasBin: true + + lowercase-keys@3.0.0: + resolution: {integrity: sha512-ozCC6gdQ+glXOQsveKD0YsDy8DSQFjDTz4zyzEHNV5+JP5D62LmfDZ6o1cycFx9ouG940M5dE8C8CTewdj2YWQ==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + + lru-cache@10.4.3: + resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} + + lru-cache@5.1.1: + resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} + + lru-cache@6.0.0: + resolution: {integrity: sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==} + engines: {node: '>=10'} + + luxon@3.5.0: + resolution: {integrity: sha512-rh+Zjr6DNfUYR3bPwJEnuwDdqMbxZW7LOQfUN4B54+Cl+0o5zaU9RJ6bcidfDtC1cWCZXQ+nvX8bf6bAji37QQ==} + engines: {node: '>=12'} + + lz-string@1.5.0: + resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==} + hasBin: true + + macos-release@3.3.0: + resolution: {integrity: sha512-tPJQ1HeyiU2vRruNGhZ+VleWuMQRro8iFtJxYgnS4NQe+EukKF6aGiIT+7flZhISAt2iaXBCfFGvAyif7/f8nQ==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + + make-dir@3.1.0: + resolution: {integrity: sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==} + engines: {node: '>=8'} + + make-dir@4.0.0: + resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==} + engines: {node: '>=10'} + + make-error@1.3.6: + resolution: {integrity: sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==} + + makeerror@1.0.12: + resolution: {integrity: sha512-JmqCvUhmt43madlpFzG4BQzG2Z3m6tvQDNKdClZnO3VbIudJYmxsT0FNJMeiB2+JTSlTQTSbU8QdesVmwJcmLg==} + + map-obj@5.0.2: + resolution: {integrity: sha512-K6K2NgKnTXimT3779/4KxSvobxOtMmx1LBZ3NwRxT/MDIR3Br/fQ4Q+WCX5QxjyUR8zg5+RV9Tbf2c5pAWTD2A==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + + markdown-it@14.1.0: + resolution: {integrity: sha512-a54IwgWPaeBCAAsv13YgmALOF1elABB08FxO9i+r4VFk5Vl4pKokRPeX8u5TCgSsPi6ec1otfLjdOpVcgbpshg==} + hasBin: true + + markdown-table@3.0.3: + resolution: {integrity: sha512-Z1NL3Tb1M9wH4XESsCDEksWoKTdlUafKc4pt0GRwjUyXaCFZ+dc3g2erqB6zm3szA2IUSi7VnPI+o/9jnxh9hw==} + + markdownlint-cli@0.41.0: + resolution: {integrity: sha512-kp29tKrMKdn+xonfefjp3a/MsNzAd9c5ke0ydMEI9PR98bOjzglYN4nfMSaIs69msUf1DNkgevAIAPtK2SeX0Q==} + engines: {node: '>=18'} + hasBin: true + + markdownlint-micromark@0.1.9: + resolution: {integrity: sha512-5hVs/DzAFa8XqYosbEAEg6ok6MF2smDj89ztn9pKkCtdKHVdPQuGMH7frFfYL9mLkvfFe4pTyAMffLbjf3/EyA==} + engines: {node: '>=18'} + + markdownlint@0.34.0: + resolution: {integrity: sha512-qwGyuyKwjkEMOJ10XN6OTKNOVYvOIi35RNvDLNxTof5s8UmyGHlCdpngRHoRGNvQVGuxO3BJ7uNSgdeX166WXw==} + engines: {node: '>=18'} + + mathml-tag-names@2.1.3: + resolution: {integrity: sha512-APMBEanjybaPzUrfqU0IMU5I0AswKMH7k8OTLs0vvV4KZpExkTkY87nR/zpbuTPj+gARop7aGUbl11pnDfW6xg==} + + maxstache-stream@1.0.4: + resolution: {integrity: sha512-v8qlfPN0pSp7bdSoLo1NTjG43GXGqk5W2NWFnOCq2GlmFFqebGzPCjLKSbShuqIOVorOtZSAy7O/S1OCCRONUw==} + + maxstache@1.0.7: + resolution: {integrity: sha512-53ZBxHrZM+W//5AcRVewiLpDunHnucfdzZUGz54Fnvo4tE+J3p8EL66kBrs2UhBXvYKTWckWYYWBqJqoTcenqg==} + + md5-hex@3.0.1: + resolution: {integrity: sha512-BUiRtTtV39LIJwinWBjqVsU9xhdnz7/i889V859IBFpuqGAj6LuOvHv5XLbgZ2R7ptJoJaEcxkv88/h25T7Ciw==} + engines: {node: '>=8'} + + mdast-util-find-and-replace@3.0.1: + resolution: {integrity: sha512-SG21kZHGC3XRTSUhtofZkBzZTJNM5ecCi0SK2IMKmSXR8vO3peL+kb1O0z7Zl83jKtutG4k5Wv/W7V3/YHvzPA==} + + mdast-util-from-markdown@2.0.1: + resolution: {integrity: sha512-aJEUyzZ6TzlsX2s5B4Of7lN7EQtAxvtradMMglCQDyaTFgse6CmtmdJ15ElnVRlCg1vpNyVtbem0PWzlNieZsA==} + + mdast-util-gfm-autolink-literal@2.0.0: + resolution: {integrity: sha512-FyzMsduZZHSc3i0Px3PQcBT4WJY/X/RCtEJKuybiC6sjPqLv7h1yqAkmILZtuxMSsUyaLUWNp71+vQH2zqp5cg==} + + mdast-util-gfm-footnote@2.0.0: + resolution: {integrity: sha512-5jOT2boTSVkMnQ7LTrd6n/18kqwjmuYqo7JUPe+tRCY6O7dAuTFMtTPauYYrMPpox9hlN0uOx/FL8XvEfG9/mQ==} + + mdast-util-gfm-strikethrough@2.0.0: + resolution: {integrity: sha512-mKKb915TF+OC5ptj5bJ7WFRPdYtuHv0yTRxK2tJvi+BDqbkiG7h7u/9SI89nRAYcmap2xHQL9D+QG/6wSrTtXg==} + + mdast-util-gfm-table@2.0.0: + resolution: {integrity: sha512-78UEvebzz/rJIxLvE7ZtDd/vIQ0RHv+3Mh5DR96p7cS7HsBhYIICDBCu8csTNWNO6tBWfqXPWekRuj2FNOGOZg==} + + mdast-util-gfm-task-list-item@2.0.0: + resolution: {integrity: sha512-IrtvNvjxC1o06taBAVJznEnkiHxLFTzgonUdy8hzFVeDun0uTjxxrRGVaNFqkU1wJR3RBPEfsxmU6jDWPofrTQ==} + + mdast-util-gfm@3.0.0: + resolution: {integrity: sha512-dgQEX5Amaq+DuUqf26jJqSK9qgixgd6rYDHAv4aTBuA92cTknZlKpPfa86Z/s8Dj8xsAQpFfBmPUHWJBWqS4Bw==} + + mdast-util-phrasing@4.1.0: + resolution: {integrity: sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w==} + + mdast-util-to-hast@13.2.0: + resolution: {integrity: sha512-QGYKEuUsYT9ykKBCMOEDLsU5JRObWQusAolFMeko/tYPufNkRffBAQjIE+99jbA87xv6FgmjLtwjh9wBWajwAA==} + + mdast-util-to-markdown@2.1.0: + resolution: {integrity: sha512-SR2VnIEdVNCJbP6y7kVTJgPLifdr8WEU440fQec7qHoHOUz/oJ2jmNRqdDQ3rbiStOXb2mCDGTuwsK5OPUgYlQ==} + + mdast-util-to-string@4.0.0: + resolution: {integrity: sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg==} + + mdn-data@2.0.28: + resolution: {integrity: sha512-aylIc7Z9y4yzHYAJNuESG3hfhC+0Ibp/MAMiaOZgNv4pmEdFyfZhhhny4MNiAfWdBQ1RQ2mfDWmM1x8SvGyp8g==} + + mdn-data@2.0.30: + resolution: {integrity: sha512-GaqWWShW4kv/G9IEucWScBx9G1/vsFZZJUO+tD26M8J8z3Kw5RDQjaoZe03YAClgeS/SWPOcb4nkFBTEi5DUEA==} + + mdurl@2.0.0: + resolution: {integrity: sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w==} + + media-typer@0.3.0: + resolution: {integrity: sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==} + engines: {node: '>= 0.6'} + + memoize-one@6.0.0: + resolution: {integrity: sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw==} + + meow@13.2.0: + resolution: {integrity: sha512-pxQJQzB6djGPXh08dacEloMFopsOqGVRKFPYvPOt9XDZ1HasbgDZA74CJGreSU4G3Ak7EFJGoiH2auq+yXISgA==} + engines: {node: '>=18'} + + merge-descriptors@1.0.1: + resolution: {integrity: sha512-cCi6g3/Zr1iqQi6ySbseM1Xvooa98N0w31jzUYrXPX2xqObmFGHJ0tQ5u74H3mVh7wLouTseZyYIq39g8cNp1w==} + + merge-options@3.0.4: + resolution: {integrity: sha512-2Sug1+knBjkaMsMgf1ctR1Ujx+Ayku4EdJN4Z+C2+JzoeF7A3OZ9KM2GY0CpQS51NR61LTurMJrRKPhSs3ZRTQ==} + engines: {node: '>=10'} + + merge-stream@2.0.0: + resolution: {integrity: sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==} + + merge2@1.4.1: + resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} + engines: {node: '>= 8'} + + methods@1.1.2: + resolution: {integrity: sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==} + engines: {node: '>= 0.6'} + + micro-api-client@3.3.0: + resolution: {integrity: sha512-y0y6CUB9RLVsy3kfgayU28746QrNMpSm9O/AYGNsBgOkJr/X/Jk0VLGoO8Ude7Bpa8adywzF+MzXNZRFRsNPhg==} + + micro-memoize@4.1.2: + resolution: {integrity: sha512-+HzcV2H+rbSJzApgkj0NdTakkC+bnyeiUxgT6/m7mjcz1CmM22KYFKp+EVj1sWe4UYcnriJr5uqHQD/gMHLD+g==} + + micromark-core-commonmark@2.0.1: + resolution: {integrity: sha512-CUQyKr1e///ZODyD1U3xit6zXwy1a8q2a1S1HKtIlmgvurrEpaw/Y9y6KSIbF8P59cn/NjzHyO+Q2fAyYLQrAA==} + + micromark-extension-gfm-autolink-literal@2.1.0: + resolution: {integrity: sha512-oOg7knzhicgQ3t4QCjCWgTmfNhvQbDDnJeVu9v81r7NltNCVmhPy1fJRX27pISafdjL+SVc4d3l48Gb6pbRypw==} + + micromark-extension-gfm-footnote@2.1.0: + resolution: {integrity: sha512-/yPhxI1ntnDNsiHtzLKYnE3vf9JZ6cAisqVDauhp4CEHxlb4uoOTxOCJ+9s51bIB8U1N1FJ1RXOKTIlD5B/gqw==} + + micromark-extension-gfm-strikethrough@2.1.0: + resolution: {integrity: sha512-ADVjpOOkjz1hhkZLlBiYA9cR2Anf8F4HqZUO6e5eDcPQd0Txw5fxLzzxnEkSkfnD0wziSGiv7sYhk/ktvbf1uw==} + + micromark-extension-gfm-table@2.1.0: + resolution: {integrity: sha512-Ub2ncQv+fwD70/l4ou27b4YzfNaCJOvyX4HxXU15m7mpYY+rjuWzsLIPZHJL253Z643RpbcP1oeIJlQ/SKW67g==} + + micromark-extension-gfm-tagfilter@2.0.0: + resolution: {integrity: sha512-xHlTOmuCSotIA8TW1mDIM6X2O1SiX5P9IuDtqGonFhEK0qgRI4yeC6vMxEV2dgyr2TiD+2PQ10o+cOhdVAcwfg==} + + micromark-extension-gfm-task-list-item@2.1.0: + resolution: {integrity: sha512-qIBZhqxqI6fjLDYFTBIa4eivDMnP+OZqsNwmQ3xNLE4Cxwc+zfQEfbs6tzAo2Hjq+bh6q5F+Z8/cksrLFYWQQw==} + + micromark-extension-gfm@3.0.0: + resolution: {integrity: sha512-vsKArQsicm7t0z2GugkCKtZehqUm31oeGBV/KVSorWSy8ZlNAv7ytjFhvaryUiCUJYqs+NoE6AFhpQvBTM6Q4w==} + + micromark-factory-destination@2.0.0: + resolution: {integrity: sha512-j9DGrQLm/Uhl2tCzcbLhy5kXsgkHUrjJHg4fFAeoMRwJmJerT9aw4FEhIbZStWN8A3qMwOp1uzHr4UL8AInxtA==} + + micromark-factory-label@2.0.0: + resolution: {integrity: sha512-RR3i96ohZGde//4WSe/dJsxOX6vxIg9TimLAS3i4EhBAFx8Sm5SmqVfR8E87DPSR31nEAjZfbt91OMZWcNgdZw==} + + micromark-factory-space@2.0.0: + resolution: {integrity: sha512-TKr+LIDX2pkBJXFLzpyPyljzYK3MtmllMUMODTQJIUfDGncESaqB90db9IAUcz4AZAJFdd8U9zOp9ty1458rxg==} + + micromark-factory-title@2.0.0: + resolution: {integrity: sha512-jY8CSxmpWLOxS+t8W+FG3Xigc0RDQA9bKMY/EwILvsesiRniiVMejYTE4wumNc2f4UbAa4WsHqe3J1QS1sli+A==} + + micromark-factory-whitespace@2.0.0: + resolution: {integrity: sha512-28kbwaBjc5yAI1XadbdPYHX/eDnqaUFVikLwrO7FDnKG7lpgxnvk/XGRhX/PN0mOZ+dBSZ+LgunHS+6tYQAzhA==} + + micromark-util-character@2.1.0: + resolution: {integrity: sha512-KvOVV+X1yLBfs9dCBSopq/+G1PcgT3lAK07mC4BzXi5E7ahzMAF8oIupDDJ6mievI6F+lAATkbQQlQixJfT3aQ==} + + micromark-util-chunked@2.0.0: + resolution: {integrity: sha512-anK8SWmNphkXdaKgz5hJvGa7l00qmcaUQoMYsBwDlSKFKjc6gjGXPDw3FNL3Nbwq5L8gE+RCbGqTw49FK5Qyvg==} + + micromark-util-classify-character@2.0.0: + resolution: {integrity: sha512-S0ze2R9GH+fu41FA7pbSqNWObo/kzwf8rN/+IGlW/4tC6oACOs8B++bh+i9bVyNnwCcuksbFwsBme5OCKXCwIw==} + + micromark-util-combine-extensions@2.0.0: + resolution: {integrity: sha512-vZZio48k7ON0fVS3CUgFatWHoKbbLTK/rT7pzpJ4Bjp5JjkZeasRfrS9wsBdDJK2cJLHMckXZdzPSSr1B8a4oQ==} + + micromark-util-decode-numeric-character-reference@2.0.1: + resolution: {integrity: sha512-bmkNc7z8Wn6kgjZmVHOX3SowGmVdhYS7yBpMnuMnPzDq/6xwVA604DuOXMZTO1lvq01g+Adfa0pE2UKGlxL1XQ==} + + micromark-util-decode-string@2.0.0: + resolution: {integrity: sha512-r4Sc6leeUTn3P6gk20aFMj2ntPwn6qpDZqWvYmAG6NgvFTIlj4WtrAudLi65qYoaGdXYViXYw2pkmn7QnIFasA==} + + micromark-util-encode@2.0.0: + resolution: {integrity: sha512-pS+ROfCXAGLWCOc8egcBvT0kf27GoWMqtdarNfDcjb6YLuV5cM3ioG45Ys2qOVqeqSbjaKg72vU+Wby3eddPsA==} + + micromark-util-html-tag-name@2.0.0: + resolution: {integrity: sha512-xNn4Pqkj2puRhKdKTm8t1YHC/BAjx6CEwRFXntTaRf/x16aqka6ouVoutm+QdkISTlT7e2zU7U4ZdlDLJd2Mcw==} + + micromark-util-normalize-identifier@2.0.0: + resolution: {integrity: sha512-2xhYT0sfo85FMrUPtHcPo2rrp1lwbDEEzpx7jiH2xXJLqBuy4H0GgXk5ToU8IEwoROtXuL8ND0ttVa4rNqYK3w==} + + micromark-util-resolve-all@2.0.0: + resolution: {integrity: sha512-6KU6qO7DZ7GJkaCgwBNtplXCvGkJToU86ybBAUdavvgsCiG8lSSvYxr9MhwmQ+udpzywHsl4RpGJsYWG1pDOcA==} + + micromark-util-sanitize-uri@2.0.0: + resolution: {integrity: sha512-WhYv5UEcZrbAtlsnPuChHUAsu/iBPOVaEVsntLBIdpibO0ddy8OzavZz3iL2xVvBZOpolujSliP65Kq0/7KIYw==} + + micromark-util-subtokenize@2.0.1: + resolution: {integrity: sha512-jZNtiFl/1aY73yS3UGQkutD0UbhTt68qnRpw2Pifmz5wV9h8gOVsN70v+Lq/f1rKaU/W8pxRe8y8Q9FX1AOe1Q==} + + micromark-util-symbol@2.0.0: + resolution: {integrity: sha512-8JZt9ElZ5kyTnO94muPxIGS8oyElRJaiJO8EzV6ZSyGQ1Is8xwl4Q45qU5UOg+bGH4AikWziz0iN4sFLWs8PGw==} + + micromark-util-types@2.0.0: + resolution: {integrity: sha512-oNh6S2WMHWRZrmutsRmDDfkzKtxF+bc2VxLC9dvtrDIRFln627VsFP6fLMgTryGDljgLPjkrzQSDcPrjPyDJ5w==} + + micromark@4.0.0: + resolution: {integrity: sha512-o/sd0nMof8kYff+TqcDx3VSrgBTcZpSvYcAHIfHhv5VAuNmisCxjhx6YmxS8PFEpb9z5WKWKPdzf0jM23ro3RQ==} + + micromatch@4.0.7: + resolution: {integrity: sha512-LPP/3KorzCwBxfeUuZmaR6bG2kdeHSbe0P2tY3FLRU4vYrjYz5hI4QZwV0njUx3jeuKe67YukQ1LSPZBKDqO/Q==} + engines: {node: '>=8.6'} + + mime-db@1.52.0: + resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==} + engines: {node: '>= 0.6'} + + mime-db@1.53.0: + resolution: {integrity: sha512-oHlN/w+3MQ3rba9rqFr6V/ypF10LSkdwUysQL7GkXoTgIWeV+tcXGA852TBxH+gsh8UWoyhR1hKcoMJTuWflpg==} + engines: {node: '>= 0.6'} + + mime-types@2.1.35: + resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==} + engines: {node: '>= 0.6'} + + mime@1.6.0: + resolution: {integrity: sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==} + engines: {node: '>=4'} + hasBin: true + + mime@3.0.0: + resolution: {integrity: sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A==} + engines: {node: '>=10.0.0'} + hasBin: true + + mimic-fn@1.2.0: + resolution: {integrity: sha512-jf84uxzwiuiIVKiOLpfYk7N46TSy8ubTonmneY9vrpHNAnp0QBt2BxWV9dO3/j+BoVAb+a5G6YDPW3M5HOdMWQ==} + engines: {node: '>=4'} + + mimic-fn@2.1.0: + resolution: {integrity: sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==} + engines: {node: '>=6'} + + mimic-fn@4.0.0: + resolution: {integrity: sha512-vqiC06CuhBTUdZH+RYl8sFrL096vA45Ok5ISO6sE/Mr1jRbGH4Csnhi8f3wKVl7x8mO4Au7Ir9D3Oyv1VYMFJw==} + engines: {node: '>=12'} + + mimic-function@5.0.1: + resolution: {integrity: sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==} + engines: {node: '>=18'} + + mimic-response@3.1.0: + resolution: {integrity: sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==} + engines: {node: '>=10'} + + mimic-response@4.0.0: + resolution: {integrity: sha512-e5ISH9xMYU0DzrT+jl8q2ze9D6eWBto+I8CNpe+VI+K2J/F/k3PdkdTdz4wvGVH4NTpo+NRYTVIuMQEMMcsLqg==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + + min-indent@1.0.1: + resolution: {integrity: sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==} + engines: {node: '>=4'} + + minimatch@3.1.2: + resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==} + + minimatch@5.1.6: + resolution: {integrity: sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==} + engines: {node: '>=10'} + + minimatch@9.0.5: + resolution: {integrity: sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==} + engines: {node: '>=16 || 14 >=14.17'} + + minimist@1.2.8: + resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} + + minipass@3.3.6: + resolution: {integrity: sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==} + engines: {node: '>=8'} + + minipass@5.0.0: + resolution: {integrity: sha512-3FnjYuehv9k6ovOEbyOswadCDPX1piCfhV8ncmYtHOjuPwylVWsghTLo7rabjC3Rx5xD4HDx8Wm1xnMF7S5qFQ==} + engines: {node: '>=8'} + + minipass@7.1.2: + resolution: {integrity: sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==} + engines: {node: '>=16 || 14 >=14.17'} + + minizlib@2.1.2: + resolution: {integrity: sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg==} + engines: {node: '>= 8'} + + mkdirp-classic@0.5.3: + resolution: {integrity: sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==} + + mkdirp@0.5.6: + resolution: {integrity: sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==} + hasBin: true + + mkdirp@1.0.4: + resolution: {integrity: sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==} + engines: {node: '>=10'} + hasBin: true + + mlly@1.7.1: + resolution: {integrity: sha512-rrVRZRELyQzrIUAVMHxP97kv+G786pHmOKzuFII8zDYahFBS7qnHh2AlYSl1GAHhaMPCz6/oHjVMcfFYgFYHgA==} + + module-definition@5.0.1: + resolution: {integrity: sha512-kvw3B4G19IXk+BOXnYq/D/VeO9qfHaapMeuS7w7sNUqmGaA6hywdFHMi+VWeR9wUScXM7XjoryTffCZ5B0/8IA==} + engines: {node: '>=14'} + hasBin: true + + moize@6.1.6: + resolution: {integrity: sha512-vSKdIUO61iCmTqhdoIDrqyrtp87nWZUmBPniNjO0fX49wEYmyDO4lvlnFXiGcaH1JLE/s/9HbiK4LSHsbiUY6Q==} + + move-file@3.1.0: + resolution: {integrity: sha512-4aE3U7CCBWgrQlQDMq8da4woBWDGHioJFiOZ8Ie6Yq2uwYQ9V2kGhTz4x3u6Wc+OU17nw0yc3rJ/lQ4jIiPe3A==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + + mri@1.2.0: + resolution: {integrity: sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==} + engines: {node: '>=4'} + + ms@2.0.0: + resolution: {integrity: sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==} + + ms@2.1.2: + resolution: {integrity: sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + multiparty@4.2.3: + resolution: {integrity: sha512-Ak6EUJZuhGS8hJ3c2fY6UW5MbkGUPMBEGd13djUzoY/BHqV/gTuFWtC6IuVA7A2+v3yjBS6c4or50xhzTQZImQ==} + engines: {node: '>= 0.10'} + + mute-stream@0.0.7: + resolution: {integrity: sha512-r65nCZhrbXXb6dXOACihYApHw2Q6pV0M3V0PSxd74N0+D8nzAdEAITq2oAjA1jVnKI+tGvEBUpqiMh0+rW6zDQ==} + + mz@2.7.0: + resolution: {integrity: sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==} + + nan@2.20.0: + resolution: {integrity: sha512-bk3gXBZDGILuuo/6sKtr0DQmSThYHLtNCdSdXk9YkxD/jK6X2vmCyyXBBxyqZ4XcnzTyYEAThfX3DCEnLf6igw==} + + nanoid@3.3.7: + resolution: {integrity: sha512-eSRppjcPIatRIMC1U6UngP8XFcz8MQWGQdt1MTBQ7NaAmvXDfvNxbvWV3x2y6CdEUciCSsDHDQZbhYaB8QEo2g==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + napi-build-utils@1.0.2: + resolution: {integrity: sha512-ONmRUqK7zj7DWX0D9ADe03wbwOBZxNAfF20PlGfCWQcD3+/MakShIHrMqx9YwPTfxDdF1zLeL+RGZiR9kGMLdg==} + + natural-compare@1.4.0: + resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} + + negotiator@0.6.3: + resolution: {integrity: sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==} + engines: {node: '>= 0.6'} + + nested-error-stacks@2.1.1: + resolution: {integrity: sha512-9iN1ka/9zmX1ZvLV9ewJYEk9h7RyRRtqdK0woXcqohu8EWIerfPUjYJPg0ULy0UqP7cslmdGc8xKDJcojlKiaw==} + + netlify-cli@17.34.1: + resolution: {integrity: sha512-tTorZ+zyrqou0HguiQJKe2xLZqxhQIckM5X+WmK5p4CMo/9mqA/qhYWKWz7nWbfWTj5iuCQSKKFQgTuJCva+Og==} + engines: {node: '>=18.14.0'} + hasBin: true + + netlify-headers-parser@7.1.4: + resolution: {integrity: sha512-fTVQf8u65vS4YTP2Qt1K6Np01q3yecRKXf6VMONMlWbfl5n3M/on7pZlZISNAXHNOtnVt+6Kpwfl+RIeALC8Kg==} + engines: {node: ^14.16.0 || >=16.0.0} + + netlify-redirect-parser@14.3.0: + resolution: {integrity: sha512-/Oqq+SrTXk8hZqjCBy0AkWf5qAhsgcsdxQA09uYFdSSNG5w9rhh17a7dp77o5Q5XoHCahm8u4Kig/lbXkl4j2g==} + engines: {node: ^14.16.0 || >=16.0.0} + + netlify-redirector@0.5.0: + resolution: {integrity: sha512-4zdzIP+6muqPCuE8avnrgDJ6KW/2+UpHTRcTbMXCIRxiRmyrX+IZ4WSJGZdHPWF3WmQpXpy603XxecZ9iygN7w==} + + netlify@13.1.20: + resolution: {integrity: sha512-pfYUCfaywrzkMzN8If4IVM58DqsAYq2JroAFziuYK7m0LKYPzlbuSNYWhlfQL/zoBmRm8kxzRxEiK6fj1tvOOw==} + engines: {node: ^14.16.0 || >=16.0.0} + + next@14.2.5: + resolution: {integrity: sha512-0f8aRfBVL+mpzfBjYfQuLWh2WyAwtJXCRfkPF4UJ5qd2YwrHczsrSzXU4tRMV0OAxR8ZJZWPFn6uhSC56UTsLA==} + engines: {node: '>=18.17.0'} + hasBin: true + peerDependencies: + '@opentelemetry/api': ^1.1.0 + '@playwright/test': ^1.41.2 + react: ^18.2.0 + react-dom: ^18.2.0 + sass: ^1.3.0 + peerDependenciesMeta: + '@opentelemetry/api': + optional: true + '@playwright/test': + optional: true + sass: + optional: true + + node-abi@3.65.0: + resolution: {integrity: sha512-ThjYBfoDNr08AWx6hGaRbfPwxKV9kVzAzOzlLKbk2CuqXE2xnCh+cbAGnwM3t8Lq4v9rUB7VfondlkBckcJrVA==} + engines: {node: '>=10'} + + node-addon-api@6.1.0: + resolution: {integrity: sha512-+eawOlIgy680F0kBzPUNFhMZGtJ1YmqM6l4+Crf4IkImjYrO/mqPwRMh352g23uIaQKFItcQ64I7KMaJxHgAVA==} + + node-addon-api@7.1.1: + resolution: {integrity: sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==} + + node-domexception@1.0.0: + resolution: {integrity: sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==} + engines: {node: '>=10.5.0'} + + node-fetch-native@1.6.4: + resolution: {integrity: sha512-IhOigYzAKHd244OC0JIMIUrjzctirCmPkaIfhDeGcEETWof5zKYUW7e7MYvChGWh/4CJeXEgsRyGzuF334rOOQ==} + + node-fetch@2.7.0: + resolution: {integrity: sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==} + engines: {node: 4.x || >=6.0.0} + peerDependencies: + encoding: ^0.1.0 + peerDependenciesMeta: + encoding: + optional: true + + node-fetch@3.3.2: + resolution: {integrity: sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + + node-forge@1.3.1: + resolution: {integrity: sha512-dPEtOeMvF9VMcYV/1Wb8CPoVAXtp6MKMlcbAt4ddqmGqUJ6fQZFXkNZNkNlfevtNkGtaSoXf/vNNNSvgrdXwtA==} + engines: {node: '>= 6.13.0'} + + node-gyp-build@4.8.1: + resolution: {integrity: sha512-OSs33Z9yWr148JZcbZd5WiAXhh/n9z8TxQcdMhIOlpN9AhWpLfvVFO73+m77bBABQMaY9XSvIa+qk0jlI7Gcaw==} + hasBin: true + + node-int64@0.4.0: + resolution: {integrity: sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==} + + node-releases@2.0.18: + resolution: {integrity: sha512-d9VeXT4SJ7ZeOqGX6R5EM022wpL+eWPooLI+5UpWn2jCT1aosUQEhQP214x33Wkwx3JQMvIm+tIoVOdodFS40g==} + + node-source-walk@6.0.2: + resolution: {integrity: sha512-jn9vOIK/nfqoFCcpK89/VCVaLg1IHE6UVfDOzvqmANaJ/rWCTEdH8RZ1V278nv2jr36BJdyQXIAavBLXpzdlag==} + engines: {node: '>=14'} + + node-stream-zip@1.15.0: + resolution: {integrity: sha512-LN4fydt9TqhZhThkZIVQnF9cwjU3qmUH9h78Mx/K7d3VvfRqqwthLwJEUOEL0QPZ0XQmNN7be5Ggit5+4dq3Bw==} + engines: {node: '>=0.12.0'} + + node-version-alias@3.4.1: + resolution: {integrity: sha512-Kf3L9spAL6lEHMPyqpwHSTNG3LPkOXBfSUnBMG/YE2TdoC8Qoqf0+qg01nr6K9MFQEcXtWUyTQzLJByRixSBsA==} + engines: {node: '>=14.18.0'} + + nopt@5.0.0: + resolution: {integrity: sha512-Tbj67rffqceeLpcRXrT7vKAN8CwfPeIBgM7E6iBkmKLV7bEMwpGgYLGv0jACUsECaa/vuxP0IjEont6umdMgtQ==} + engines: {node: '>=6'} + hasBin: true + + normalize-node-version@12.4.0: + resolution: {integrity: sha512-0oLZN5xcyKVrSHMk8/9RuNblEe7HEsXAt5Te2xmMiZD9VX7bqWYe0HMyfqSYFD3xv0949lZuXaEwjTqle1uWWQ==} + engines: {node: '>=14.18.0'} + + normalize-package-data@3.0.3: + resolution: {integrity: sha512-p2W1sgqij3zMMyRC067Dg16bfzVH+w7hyegmpIvZ4JNjqtGOVAIvLmjBx3yP7YTe9vKJgkoNOPjwQGogDoMXFA==} + engines: {node: '>=10'} + + normalize-package-data@6.0.2: + resolution: {integrity: sha512-V6gygoYb/5EmNI+MEGrWkC+e6+Rr7mTmfHrxDbLzxQogBkgzo76rkok0Am6thgSF7Mv2nLOajAJj5vDJZEFn7g==} + engines: {node: ^16.14.0 || >=18.0.0} + + normalize-path@2.1.1: + resolution: {integrity: sha512-3pKJwH184Xo/lnH6oyP1q2pMd7HcypqqmRs91/6/i2CGtWwIKGCkOOMTm/zXbgTEWHw1uNpNi/igc3ePOYHb6w==} + engines: {node: '>=0.10.0'} + + normalize-path@3.0.0: + resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} + engines: {node: '>=0.10.0'} + + normalize-range@0.1.2: + resolution: {integrity: sha512-bdok/XvKII3nUpklnV6P2hxtMNrCboOjAcyBuQnWEhO665FwrSNRxU+AqpsyvO6LgGYPspN+lu5CLtw4jPRKNA==} + engines: {node: '>=0.10.0'} + + normalize-url@8.0.1: + resolution: {integrity: sha512-IO9QvjUMWxPQQhs60oOu10CRkWCiZzSUkzbXGGV9pviYl1fXYcvkzQ5jV9z8Y6un8ARoVRl4EtC6v6jNqbaJ/w==} + engines: {node: '>=14.16'} + + npm-run-path@4.0.1: + resolution: {integrity: sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==} + engines: {node: '>=8'} + + npm-run-path@5.3.0: + resolution: {integrity: sha512-ppwTtiJZq0O/ai0z7yfudtBpWIoxM8yE6nHi1X47eFR2EWORqfbu6CnPlNsjeN683eT0qG6H/Pyf9fCcvjnnnQ==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + + npmlog@5.0.1: + resolution: {integrity: sha512-AqZtDUWOMKs1G/8lwylVjrdYgqA4d9nu8hc+0gzRxlDb1I10+FHBGMXs6aiQHFdCUUlqH99MUMuLfzWDNDtfxw==} + deprecated: This package is no longer supported. + + nth-check@2.1.1: + resolution: {integrity: sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==} + + nwsapi@2.2.12: + resolution: {integrity: sha512-qXDmcVlZV4XRtKFzddidpfVP4oMSGhga+xdMc25mv8kaLUHtgzCDhUxkrN8exkGdTlLNaXj7CV3GtON7zuGZ+w==} + + object-assign@4.1.1: + resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} + engines: {node: '>=0.10.0'} + + object-hash@3.0.0: + resolution: {integrity: sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==} + engines: {node: '>= 6'} + + object-inspect@1.13.2: + resolution: {integrity: sha512-IRZSRuzJiynemAXPYtPe5BoI/RESNYR7TYm50MC5Mqbd3Jmw5y790sErYw3V6SryFJD64b74qQQs9wn5Bg/k3g==} + engines: {node: '>= 0.4'} + + ofetch@1.3.4: + resolution: {integrity: sha512-KLIET85ik3vhEfS+3fDlc/BAZiAp+43QEC/yCo5zkNoY2YaKvNkOaFr/6wCFgFH1kuYQM5pMNi0Tg8koiIemtw==} + + ohash@1.1.3: + resolution: {integrity: sha512-zuHHiGTYTA1sYJ/wZN+t5HKZaH23i4yI1HMwbuXm24Nid7Dv0KcuRlKoNKS9UNfAVSBlnGLcuQrnOKWOZoEGaw==} + + omit.js@2.0.2: + resolution: {integrity: sha512-hJmu9D+bNB40YpL9jYebQl4lsTW6yEHRTroJzNLqQJYHm7c+NQnJGfZmIWh8S3q3KoaxV1aLhV6B3+0N0/kyJg==} + + on-exit-leak-free@2.1.2: + resolution: {integrity: sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==} + engines: {node: '>=14.0.0'} + + on-finished@2.4.1: + resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==} + engines: {node: '>= 0.8'} + + on-headers@1.0.2: + resolution: {integrity: sha512-pZAE+FJLoyITytdqK0U5s+FIpjN0JP3OzFi/u8Rx+EV5/W+JTWGXG8xFzevE7AjBfDqHv/8vL8qQsIhHnqRkrA==} + engines: {node: '>= 0.8'} + + once@1.4.0: + resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} + + one-time@1.0.0: + resolution: {integrity: sha512-5DXOiRKwuSEcQ/l0kGCF6Q3jcADFv5tSmRaJck/OqkVFcOzutB134KRSfF0xDrL39MNnqxbHBbUUcjZIhTgb2g==} + + onetime@2.0.1: + resolution: {integrity: sha512-oyyPpiMaKARvvcgip+JV+7zci5L8D1W9RZIz2l1o08AM3pfspitVWnPt3mzHcBPp12oYMTy0pqrFs/C+m3EwsQ==} + engines: {node: '>=4'} + + onetime@5.1.2: + resolution: {integrity: sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==} + engines: {node: '>=6'} + + onetime@6.0.0: + resolution: {integrity: sha512-1FlR+gjXK7X+AsAHso35MnyN5KqGwJRi/31ft6x0M194ht7S+rWAvd7PHss9xSKMzE0asv1pyIHaJYq+BbacAQ==} + engines: {node: '>=12'} + + onetime@7.0.0: + resolution: {integrity: sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==} + engines: {node: '>=18'} + + open@8.4.2: + resolution: {integrity: sha512-7x81NCL719oNbsq/3mh+hVrAWmFuEYUqrq/Iw3kUzH8ReypT9QQ0BLoJS7/G9k6N81XjW4qHWtjWwe/9eLy1EQ==} + engines: {node: '>=12'} + + optionator@0.9.4: + resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} + engines: {node: '>= 0.8.0'} + + ora@8.0.1: + resolution: {integrity: sha512-ANIvzobt1rls2BDny5fWZ3ZVKyD6nscLvfFRpQgfWsythlcsVUC9kL0zq6j2Z5z9wwp1kd7wpsD/T9qNPVLCaQ==} + engines: {node: '>=18'} + + os-name@5.1.0: + resolution: {integrity: sha512-YEIoAnM6zFmzw3PQ201gCVCIWbXNyKObGlVvpAVvraAeOHnlYVKFssbA/riRX5R40WA6kKrZ7Dr7dWzO3nKSeQ==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + + os-tmpdir@1.0.2: + resolution: {integrity: sha512-D2FR03Vir7FIu45XBY20mTb+/ZSWB00sjU9jdQXt83gDrI4Ztz5Fs7/yy74g2N5SVQY4xY1qDr4rNddwYRVX0g==} + engines: {node: '>=0.10.0'} + + p-cancelable@3.0.0: + resolution: {integrity: sha512-mlVgR3PGuzlo0MmTdk4cXqXWlwQDLnONTAg6sm62XkMJEiRxN3GL3SffkYvqwonbkJBcrI7Uvv5Zh9yjvn2iUw==} + engines: {node: '>=12.20'} + + p-event@4.2.0: + resolution: {integrity: sha512-KXatOjCRXXkSePPb1Nbi0p0m+gQAwdlbhi4wQKJPI1HsMQS9g+Sqp2o+QHziPr7eYJyOZet836KoHEVM1mwOrQ==} + engines: {node: '>=8'} + + p-event@5.0.1: + resolution: {integrity: sha512-dd589iCQ7m1L0bmC5NLlVYfy3TbBEsMUfWx9PyAgPeIcFZ/E2yaTZ4Rz4MiBmmJShviiftHVXOqfnfzJ6kyMrQ==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + + p-every@2.0.0: + resolution: {integrity: sha512-MCz9DqD5opPC48Zsd+BHm56O/HfhYIQQtupfDzhXoVgQdg/Ux4F8/JcdRuQ+arq7zD5fB6zP3axbH3d9Nr8dlw==} + engines: {node: '>=8'} + + p-filter@3.0.0: + resolution: {integrity: sha512-QtoWLjXAW++uTX67HZQz1dbTpqBfiidsB6VtQUC9iR85S120+s0T5sO6s+B5MLzFcZkrEd/DGMmCjR+f2Qpxwg==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + + p-filter@4.1.0: + resolution: {integrity: sha512-37/tPdZ3oJwHaS3gNJdenCDB3Tz26i9sjhnguBtvN0vYlRIiDNnvTWkuh+0hETV9rLPdJ3rlL3yVOYPIAnM8rw==} + engines: {node: '>=18'} + + p-finally@1.0.0: + resolution: {integrity: sha512-LICb2p9CB7FS+0eR1oqWnHhp0FljGLZCWBE9aix0Uye9W8LTQPwMTYVGWQWIw9RdQiDg4+epXQODwIYJtSJaow==} + engines: {node: '>=4'} + + p-limit@2.3.0: + resolution: {integrity: sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==} + engines: {node: '>=6'} + + p-limit@3.1.0: + resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} + engines: {node: '>=10'} + + p-limit@4.0.0: + resolution: {integrity: sha512-5b0R4txpzjPWVw/cXXUResoD4hb6U/x9BH08L7nw+GN1sezDzPdxeRvpc9c433fZhBan/wusjbCsqwqm4EIBIQ==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + + p-locate@4.1.0: + resolution: {integrity: sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==} + engines: {node: '>=8'} + + p-locate@5.0.0: + resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} + engines: {node: '>=10'} + + p-locate@6.0.0: + resolution: {integrity: sha512-wPrq66Llhl7/4AGC6I+cqxT07LhXvWL08LNXz1fENOw0Ap4sRZZ/gZpTTJ5jpurzzzfS2W/Ge9BY3LgLjCShcw==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + + p-map@2.1.0: + resolution: {integrity: sha512-y3b8Kpd8OAN444hxfBbFfj1FY/RjtTd8tzYwhUqNYXx0fXx2iX4maP4Qr6qhIKbQXI02wTLAda4fYUbDagTUFw==} + engines: {node: '>=6'} + + p-map@5.5.0: + resolution: {integrity: sha512-VFqfGDHlx87K66yZrNdI4YGtD70IRyd+zSvgks6mzHPRNkoKy+9EKP4SFC77/vTTQYmRmti7dvqC+m5jBrBAcg==} + engines: {node: '>=12'} + + p-map@6.0.0: + resolution: {integrity: sha512-T8BatKGY+k5rU+Q/GTYgrEf2r4xRMevAN5mtXc2aPc4rS1j3s+vWTaO2Wag94neXuCAUAs8cxBL9EeB5EA6diw==} + engines: {node: '>=16'} + + p-map@7.0.2: + resolution: {integrity: sha512-z4cYYMMdKHzw4O5UkWJImbZynVIo0lSGTXc7bzB1e/rrDqkgGUNysK/o4bTr+0+xKvvLoTyGqYC4Fgljy9qe1Q==} + engines: {node: '>=18'} + + p-reduce@3.0.0: + resolution: {integrity: sha512-xsrIUgI0Kn6iyDYm9StOpOeK29XM1aboGji26+QEortiFST1hGZaUQOLhtEbqHErPpGW/aSz6allwK2qcptp0Q==} + engines: {node: '>=12'} + + p-retry@5.1.2: + resolution: {integrity: sha512-couX95waDu98NfNZV+i/iLt+fdVxmI7CbrrdC2uDWfPdUAApyxT4wmDlyOtR5KtTDmkDO0zDScDjDou9YHhd9g==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + + p-timeout@3.2.0: + resolution: {integrity: sha512-rhIwUycgwwKcP9yTOOFK/AKsAopjjCakVqLHePO3CC6Mir1Z99xT+R63jZxAT5lFZLa2inS5h+ZS2GvR99/FBg==} + engines: {node: '>=8'} + + p-timeout@5.1.0: + resolution: {integrity: sha512-auFDyzzzGZZZdHz3BtET9VEz0SE/uMEAx7uWfGPucfzEwwe/xH0iVeZibQmANYE/hp9T2+UUZT5m+BKyrDp3Ew==} + engines: {node: '>=12'} + + p-timeout@6.1.2: + resolution: {integrity: sha512-UbD77BuZ9Bc9aABo74gfXhNvzC9Tx7SxtHSh1fxvx3jTLLYvmVhiQZZrJzqqU0jKbN32kb5VOKiLEQI/3bIjgQ==} + engines: {node: '>=14.16'} + + p-try@2.2.0: + resolution: {integrity: sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==} + engines: {node: '>=6'} + + p-wait-for@4.1.0: + resolution: {integrity: sha512-i8nE5q++9h8oaQHWltS1Tnnv4IoMDOlqN7C0KFG2OdbK0iFJIt6CROZ8wfBM+K4Pxqfnq4C4lkkpXqTEpB5DZw==} + engines: {node: '>=12'} + + p-wait-for@5.0.2: + resolution: {integrity: sha512-lwx6u1CotQYPVju77R+D0vFomni/AqRfqLmqQ8hekklqZ6gAY9rONh7lBQ0uxWMkC2AuX9b2DVAl8To0NyP1JA==} + engines: {node: '>=12'} + + package-json-from-dist@1.0.0: + resolution: {integrity: sha512-dATvCeZN/8wQsGywez1mzHtTlP22H8OEfPrVMLNr4/eGa+ijtLn/6M5f0dY8UKNrC2O9UCU6SSoG3qRKnt7STw==} + + package-json@8.1.1: + resolution: {integrity: sha512-cbH9IAIJHNj9uXi196JVsRlt7cHKak6u/e6AkL/bkRelZ7rlL3X1YKxsZwa36xipOEKAsdtmaG6aAJoM1fx2zA==} + engines: {node: '>=14.16'} + + parallel-transform@1.2.0: + resolution: {integrity: sha512-P2vSmIu38uIlvdcU7fDkyrxj33gTUy/ABO5ZUbGowxNCopBq/OoD42bP4UmMrJoPyk4Uqf0mu3mtWBhHCZD8yg==} + + parent-module@1.0.1: + resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} + engines: {node: '>=6'} + + parse-github-url@1.0.3: + resolution: {integrity: sha512-tfalY5/4SqGaV/GIGzWyHnFjlpTPTNpENR9Ea2lLldSJ8EWXMsvacWucqY3m3I4YPtas15IxTLQVQ5NSYXPrww==} + engines: {node: '>= 0.10'} + hasBin: true + + parse-gitignore@2.0.0: + resolution: {integrity: sha512-RmVuCHWsfu0QPNW+mraxh/xjQVw/lhUCUru8Zni3Ctq3AoMhpDTq0OVdKS6iesd6Kqb7viCV3isAL43dciOSog==} + engines: {node: '>=14'} + + parse-json@5.2.0: + resolution: {integrity: sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==} + engines: {node: '>=8'} + + parse-json@8.1.0: + resolution: {integrity: sha512-rum1bPifK5SSar35Z6EKZuYPJx85pkNaFrxBK3mwdfSJ1/WKbYrjoW/zTPSjRRamfmVX1ACBIdFAO0VRErW/EA==} + engines: {node: '>=18'} + + parse-ms@3.0.0: + resolution: {integrity: sha512-Tpb8Z7r7XbbtBTrM9UhpkzzaMrqA2VXMT3YChzYltwV3P3pM6t8wl7TvpMnSTosz1aQAdVib7kdoys7vYOPerw==} + engines: {node: '>=12'} + + parse-numeric-range@1.3.0: + resolution: {integrity: sha512-twN+njEipszzlMJd4ONUYgSfZPDxgHhT9Ahed5uTigpQn90FggW4SA/AIPq/6a149fTbE9qBEcSwE3FAEp6wQQ==} + + parse5@7.1.2: + resolution: {integrity: sha512-Czj1WaSVpaoj0wbhMzLmWD69anp2WH7FXMB9n1Sy8/ZFF9jolSQVMu1Ij5WIyGmcBmhk7EOndpO4mIpihVqAXw==} + + parseurl@1.3.3: + resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==} + engines: {node: '>= 0.8'} + + path-exists@4.0.0: + resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} + engines: {node: '>=8'} + + path-exists@5.0.0: + resolution: {integrity: sha512-RjhtfwJOxzcFmNOi6ltcbcu4Iu+FL3zEj83dk4kAS+fVpTxXLO1b38RvJgT/0QwvV/L3aY9TAnyv0EOqW4GoMQ==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + + path-is-absolute@1.0.1: + resolution: {integrity: sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==} + engines: {node: '>=0.10.0'} + + path-key@3.1.1: + resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} + engines: {node: '>=8'} + + path-key@4.0.0: + resolution: {integrity: sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==} + engines: {node: '>=12'} + + path-parse@1.0.7: + resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==} + + path-scurry@1.11.1: + resolution: {integrity: sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==} + engines: {node: '>=16 || 14 >=14.18'} + + path-to-regexp@0.1.7: + resolution: {integrity: sha512-5DFkuoqlv1uYQKxy8omFBeJPQcdoE07Kv2sferDCrAq1ohOU+MSDswDIbnx3YAM60qIOnYa53wBhXW0EbMonrQ==} + + path-type@4.0.0: + resolution: {integrity: sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==} + engines: {node: '>=8'} + + path-type@5.0.0: + resolution: {integrity: sha512-5HviZNaZcfqP95rwpv+1HDgUamezbqdSYTyzjTvwtJSnIH+3vnbmWsItli8OFEndS984VT55M3jduxZbX351gg==} + engines: {node: '>=12'} + + pathe@1.1.2: + resolution: {integrity: sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==} + + peek-readable@5.1.4: + resolution: {integrity: sha512-E7mY2VmKqw9jYuXrSWGHFuPCW2SLQenzXLF3amGaY6lXXg4/b3gj5HVM7h8ZjCO/nZS9ICs0Cz285+32FvNd/A==} + engines: {node: '>=14.16'} + + pend@1.2.0: + resolution: {integrity: sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==} + + picocolors@1.0.1: + resolution: {integrity: sha512-anP1Z8qwhkbmu7MFP5iTt+wQKXgwzf7zTyGlcdzabySa9vd0Xt392U0rVmz9poOaBj0uHJKyyo9/upk0HrEQew==} + + picomatch@2.3.1: + resolution: {integrity: sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==} + engines: {node: '>=8.6'} + + picomatch@4.0.2: + resolution: {integrity: sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==} + engines: {node: '>=12'} + + pify@2.3.0: + resolution: {integrity: sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==} + engines: {node: '>=0.10.0'} + + pino-abstract-transport@1.2.0: + resolution: {integrity: sha512-Guhh8EZfPCfH+PMXAb6rKOjGQEoy0xlAIn+irODG5kgfYV+BQ0rGYYWTIel3P5mmyXqkYkPmdIkywsn6QKUR1Q==} + + pino-std-serializers@7.0.0: + resolution: {integrity: sha512-e906FRY0+tV27iq4juKzSYPbUj2do2X2JX4EzSca1631EB2QJQUqGbDuERal7LCtOpxl6x3+nvo9NPZcmjkiFA==} + + pino@9.3.2: + resolution: {integrity: sha512-WtARBjgZ7LNEkrGWxMBN/jvlFiE17LTbBoH0konmBU684Kd0uIiDwBXlcTCW7iJnA6HfIKwUssS/2AC6cDEanw==} + hasBin: true + + pirates@4.0.6: + resolution: {integrity: sha512-saLsH7WeYYPiD25LDuLRRY/i+6HaPYr6G1OUlN39otzkSTxKnubR9RTxS3/Kk50s1g2JTgFwWQDQyplC5/SHZg==} + engines: {node: '>= 6'} + + pkg-dir@4.2.0: + resolution: {integrity: sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==} + engines: {node: '>=8'} + + pkg-dir@7.0.0: + resolution: {integrity: sha512-Ie9z/WINcxxLp27BKOCHGde4ITq9UklYKDzVo1nhk5sqGEXU3FpkwP5GM2voTGJkGd9B3Otl+Q4uwSOeSUtOBA==} + engines: {node: '>=14.16'} + + pkg-types@1.1.3: + resolution: {integrity: sha512-+JrgthZG6m3ckicaOB74TwQ+tBWsFl3qVQg7mN8ulwSOElJ7gBhKzj2VkCPnZ4NlF6kEquYU+RIYNVAvzd54UA==} + + playwright-core@1.46.1: + resolution: {integrity: sha512-h9LqIQaAv+CYvWzsZ+h3RsrqCStkBHlgo6/TJlFst3cOTlLghBQlJwPOZKQJTKNaD3QIB7aAVQ+gfWbN3NXB7A==} + engines: {node: '>=18'} + hasBin: true + + playwright@1.46.1: + resolution: {integrity: sha512-oPcr1yqoXLCkgKtD5eNUPLiN40rYEM39odNpIb6VE6S7/15gJmA1NzVv6zJYusV0e7tzvkU/utBFNa/Kpxmwng==} + engines: {node: '>=18'} + hasBin: true + + postcss-import@15.1.0: + resolution: {integrity: sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==} + engines: {node: '>=14.0.0'} + peerDependencies: + postcss: ^8.0.0 + + postcss-js@4.0.1: + resolution: {integrity: sha512-dDLF8pEO191hJMtlHFPRa8xsizHaM82MLfNkUHdUtVEV3tgTp5oj+8qbEqYM57SLfc74KSbw//4SeJma2LRVIw==} + engines: {node: ^12 || ^14 || >= 16} + peerDependencies: + postcss: ^8.4.21 + + postcss-load-config@4.0.2: + resolution: {integrity: sha512-bSVhyJGL00wMVoPUzAVAnbEoWyqRxkjv64tUl427SKnPrENtq6hJwUojroMz2VB+Q1edmi4IfrAPpami5VVgMQ==} + engines: {node: '>= 14'} + peerDependencies: + postcss: '>=8.0.9' + ts-node: '>=9.0.0' + peerDependenciesMeta: + postcss: + optional: true + ts-node: + optional: true + + postcss-media-query-parser@0.2.3: + resolution: {integrity: sha512-3sOlxmbKcSHMjlUXQZKQ06jOswE7oVkXPxmZdoB1r5l0q6gTFTQSHxNxOrCccElbW7dxNytifNEo8qidX2Vsig==} + + postcss-nested@6.0.1: + resolution: {integrity: sha512-mEp4xPMi5bSWiMbsgoPfcP74lsWLHkQbZc3sY+jWYd65CUwXrUaTp0fmNpa01ZcETKlIgUdFN/MpS2xZtqL9dQ==} + engines: {node: '>=12.0'} + peerDependencies: + postcss: ^8.2.14 + + postcss-resolve-nested-selector@0.1.6: + resolution: {integrity: sha512-0sglIs9Wmkzbr8lQwEyIzlDOOC9bGmfVKcJTaxv3vMmd3uo4o4DerC3En0bnmgceeql9BfC8hRkp7cg0fjdVqw==} + + postcss-safe-parser@7.0.0: + resolution: {integrity: sha512-ovehqRNVCpuFzbXoTb4qLtyzK3xn3t/CUBxOs8LsnQjQrShaB4lKiHoVqY8ANaC0hBMHq5QVWk77rwGklFUDrg==} + engines: {node: '>=18.0'} + peerDependencies: + postcss: ^8.4.31 + + postcss-scss@4.0.9: + resolution: {integrity: sha512-AjKOeiwAitL/MXxQW2DliT28EKukvvbEWx3LBmJIRN8KfBGZbRTxNYW0kSqi1COiTZ57nZ9NW06S6ux//N1c9A==} + engines: {node: '>=12.0'} + peerDependencies: + postcss: ^8.4.29 + + postcss-selector-parser@6.1.1: + resolution: {integrity: sha512-b4dlw/9V8A71rLIDsSwVmak9z2DuBUB7CA1/wSdelNEzqsjoSPeADTWNO09lpH49Diy3/JIZ2bSPB1dI3LJCHg==} + engines: {node: '>=4'} + + postcss-selector-parser@6.1.2: + resolution: {integrity: sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==} + engines: {node: '>=4'} + + postcss-value-parser@4.2.0: + resolution: {integrity: sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==} + + postcss-values-parser@6.0.2: + resolution: {integrity: sha512-YLJpK0N1brcNJrs9WatuJFtHaV9q5aAOj+S4DI5S7jgHlRfm0PIbDCAFRYMQD5SHq7Fy6xsDhyutgS0QOAs0qw==} + engines: {node: '>=10'} + peerDependencies: + postcss: ^8.2.9 + + postcss@8.4.31: + resolution: {integrity: sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==} + engines: {node: ^10 || ^12 || >=14} + + postcss@8.4.41: + resolution: {integrity: sha512-TesUflQ0WKZqAvg52PWL6kHgLKP6xB6heTOdoYM0Wt2UHyxNa4K25EZZMgKns3BH1RLVbZCREPpLY0rhnNoHVQ==} + engines: {node: ^10 || ^12 || >=14} + + prebuild-install@7.1.2: + resolution: {integrity: sha512-UnNke3IQb6sgarcZIDU3gbMeTp/9SSU1DAIkil7PrqG1vZlBtY5msYccSKSHDqa3hNg436IXK+SNImReuA1wEQ==} + engines: {node: '>=10'} + hasBin: true + + precinct@11.0.5: + resolution: {integrity: sha512-oHSWLC8cL/0znFhvln26D14KfCQFFn4KOLSw6hmLhd+LQ2SKt9Ljm89but76Pc7flM9Ty1TnXyrA2u16MfRV3w==} + engines: {node: ^14.14.0 || >=16.0.0} + hasBin: true + + precond@0.2.3: + resolution: {integrity: sha512-QCYG84SgGyGzqJ/vlMsxeXd/pgL/I94ixdNFyh1PusWmTCyVfPJjZ1K1jvHtsbfnXQs2TSkEP2fR7QiMZAnKFQ==} + engines: {node: '>= 0.6'} + + prelude-ls@1.2.1: + resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} + engines: {node: '>= 0.8.0'} + + prettier@3.3.3: + resolution: {integrity: sha512-i2tDNA0O5IrMO757lfrdQZCc2jPNDVntV0m/+4whiDfWaTKfMNgR7Qz0NAeGz/nRqF4m5/6CLzbP4/liHt12Ew==} + engines: {node: '>=14'} + hasBin: true + + pretty-format@27.5.1: + resolution: {integrity: sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==} + engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0} + + pretty-format@29.7.0: + resolution: {integrity: sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + pretty-ms@8.0.0: + resolution: {integrity: sha512-ASJqOugUF1bbzI35STMBUpZqdfYKlJugy6JBziGi2EE+AL5JPJGSzvpeVXojxrr0ViUYoToUjb5kjSEGf7Y83Q==} + engines: {node: '>=14.16'} + + prettyjson@1.2.5: + resolution: {integrity: sha512-rksPWtoZb2ZpT5OVgtmy0KHVM+Dca3iVwWY9ifwhcexfjebtgjg3wmrUt9PvJ59XIYBcknQeYHD8IAnVlh9lAw==} + hasBin: true + + process-nextick-args@2.0.1: + resolution: {integrity: sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==} + + process-warning@3.0.0: + resolution: {integrity: sha512-mqn0kFRl0EoqhnL0GQ0veqFHyIN1yig9RHh/InzORTUiZHFRAur+aMtRkELNwGs9aNwKS6tg/An4NYBPGwvtzQ==} + + process-warning@4.0.0: + resolution: {integrity: sha512-/MyYDxttz7DfGMMHiysAsFE4qF+pQYAA8ziO/3NcRVrQ5fSk+Mns4QZA/oRPFzvcqNoVJXQNWNAsdwBXLUkQKw==} + + process@0.11.10: + resolution: {integrity: sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==} + engines: {node: '>= 0.6.0'} + + prompts@2.4.2: + resolution: {integrity: sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==} + engines: {node: '>= 6'} + + property-information@6.5.0: + resolution: {integrity: sha512-PgTgs/BlvHxOu8QuEN7wi5A0OmXaBcHpmCSTehcs6Uuu9IkDIEo13Hy7n898RHfrQ49vKCoGeWZSaAK01nwVig==} + + proto-list@1.2.4: + resolution: {integrity: sha512-vtK/94akxsTMhe0/cbfpR+syPuszcuwhqVjJq26CuNDgFGj682oRBXOP5MJpv2r7JtE8MsiepGIqvvOTBwn2vA==} + + proxy-addr@2.0.7: + resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} + engines: {node: '>= 0.10'} + + ps-list@8.1.1: + resolution: {integrity: sha512-OPS9kEJYVmiO48u/B9qneqhkMvgCxT+Tm28VCEJpheTpl8cJ0ffZRRNgS5mrQRTrX5yRTpaJ+hRDeefXYmmorQ==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + + psl@1.9.0: + resolution: {integrity: sha512-E/ZsdU4HLs/68gYzgGTkMicWTLPdAftJLfJFlLUAAKZGkStNU72sZjT66SnMDVOfOWY/YAoiD7Jxa9iHvngcag==} + + pump@1.0.3: + resolution: {integrity: sha512-8k0JupWme55+9tCVE+FS5ULT3K6AbgqrGa58lTT49RpyfwwcGedHqaC5LlQNdEAumn/wFsu6aPwkuPMioy8kqw==} + + pump@3.0.0: + resolution: {integrity: sha512-LwZy+p3SFs1Pytd/jYct4wpv49HiYCqd9Rlc5ZVdk0V+8Yzv6jR5Blk3TRmPL1ft69TxP0IMZGJ+WPFU2BFhww==} + + punycode.js@2.3.1: + resolution: {integrity: sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==} + engines: {node: '>=6'} + + punycode@2.3.1: + resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} + engines: {node: '>=6'} + + pupa@3.1.0: + resolution: {integrity: sha512-FLpr4flz5xZTSJxSeaheeMKN/EDzMdK7b8PTOC6a5PYFKTucWbdqjgqaEyH0shFiSJrVB1+Qqi4Tk19ccU6Aug==} + engines: {node: '>=12.20'} + + pure-rand@6.1.0: + resolution: {integrity: sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==} + + qs@6.11.0: + resolution: {integrity: sha512-MvjoMCJwEarSbUYk5O+nmoSzSutSsTwF85zcHPQ9OrlFoZOYIjaqBAJIqIXjptyD5vThxGq52Xu/MaJzRkIk4Q==} + engines: {node: '>=0.6'} + + qs@6.13.0: + resolution: {integrity: sha512-+38qI9SOr8tfZ4QmJNplMUxqjbe7LKvvZgWdExBOmd+egZTtjLB67Gu0HRX3u/XOq7UU2Nx6nsjvS16Z9uwfpg==} + engines: {node: '>=0.6'} + + querystringify@2.2.0: + resolution: {integrity: sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==} + + queue-microtask@1.2.3: + resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} + + queue-tick@1.0.1: + resolution: {integrity: sha512-kJt5qhMxoszgU/62PLP1CJytzd2NKetjSRnyuj31fDd3Rlcz3fzlFdFLD1SItunPwyqEOkca6GbV612BWfaBag==} + + quick-format-unescaped@4.0.4: + resolution: {integrity: sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==} + + quick-lru@5.1.1: + resolution: {integrity: sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA==} + engines: {node: '>=10'} + + quote-unquote@1.0.0: + resolution: {integrity: sha512-twwRO/ilhlG/FIgYeKGFqyHhoEhqgnKVkcmqMKi2r524gz3ZbDTcyFt38E9xjJI2vT+KbRNHVbnJ/e0I25Azwg==} + + radix3@1.1.2: + resolution: {integrity: sha512-b484I/7b8rDEdSDKckSSBA8knMpcdsXudlE/LNL639wFoHKwLbEkQFZHWEYwDC0wa0FKUcCY+GAF73Z7wxNVFA==} + + random-bytes@1.0.0: + resolution: {integrity: sha512-iv7LhNVO047HzYR3InF6pUcUsPQiHTM1Qal51DcGSuZFBil1aBBWG5eHPNek7bvILMaYJ/8RU1e8w1AMdHmLQQ==} + engines: {node: '>= 0.8'} + + range-parser@1.2.1: + resolution: {integrity: sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==} + engines: {node: '>= 0.6'} + + raw-body@2.5.2: + resolution: {integrity: sha512-8zGqypfENjCIqGhgXToC8aB2r7YrBX+AQAfIPs/Mlk+BtPTztOvTS01NRW/3Eh60J+a48lt8qsCzirQ6loCVfA==} + engines: {node: '>= 0.8'} + + rc@1.2.8: + resolution: {integrity: sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==} + hasBin: true + + react-dom@18.3.1: + resolution: {integrity: sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==} + peerDependencies: + react: ^18.3.1 + + react-is@17.0.2: + resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==} + + react-is@18.3.1: + resolution: {integrity: sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==} + + react@18.3.1: + resolution: {integrity: sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==} + engines: {node: '>=0.10.0'} + + read-cache@1.0.0: + resolution: {integrity: sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==} + + read-package-up@11.0.0: + resolution: {integrity: sha512-MbgfoNPANMdb4oRBNg5eqLbB2t2r+o5Ua1pNt8BqGp4I0FJZhuVSOj3PaBPni4azWuSzEdNn2evevzVmEk1ohQ==} + engines: {node: '>=18'} + + read-pkg-up@9.1.0: + resolution: {integrity: sha512-vaMRR1AC1nrd5CQM0PhlRsO5oc2AAigqr7cCrZ/MW/Rsaflz4RlgzkpL4qoU/z1F6wrbd85iFv1OQj/y5RdGvg==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + + read-pkg@7.1.0: + resolution: {integrity: sha512-5iOehe+WF75IccPc30bWTbpdDQLOCc3Uu8bi3Dte3Eueij81yx1Mrufk8qBx/YAbR4uL1FdUr+7BKXDwEtisXg==} + engines: {node: '>=12.20'} + + read-pkg@9.0.1: + resolution: {integrity: sha512-9viLL4/n1BJUCT1NXVTdS1jtm80yDEgR5T4yCelII49Mbj0v1rZdKqj7zCiYdbB0CuCgdrvHcNogAKTFPBocFA==} + engines: {node: '>=18'} + + readable-stream@2.3.8: + resolution: {integrity: sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==} + + readable-stream@3.6.2: + resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==} + engines: {node: '>= 6'} + + readable-stream@4.5.2: + resolution: {integrity: sha512-yjavECdqeZ3GLXNgRXgeQEdz9fvDDkNKyHnbHRFtOr7/LcfgBcmct7t/ET+HaCTqfh06OzoAxrkN/IfjJBVe+g==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + + readable-web-to-node-stream@3.0.2: + resolution: {integrity: sha512-ePeK6cc1EcKLEhJFt/AebMCLL+GgSKhuygrZ/GLaKZYEecIgIECf4UaUuaByiGtzckwR4ain9VzUh95T1exYGw==} + engines: {node: '>=8'} + + readdir-glob@1.1.3: + resolution: {integrity: sha512-v05I2k7xN8zXvPD9N+z/uhXPaj0sUFCe2rcWZIpBsqxfP7xXFQ0tipAd/wjj1YxWyWtUS5IDJpOG82JKt2EAVA==} + + readdirp@3.6.0: + resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==} + engines: {node: '>=8.10.0'} + + real-require@0.2.0: + resolution: {integrity: sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==} + engines: {node: '>= 12.13.0'} + + redent@3.0.0: + resolution: {integrity: sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==} + engines: {node: '>=8'} + + regenerator-runtime@0.14.1: + resolution: {integrity: sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw==} + + registry-auth-token@5.0.2: + resolution: {integrity: sha512-o/3ikDxtXaA59BmZuZrJZDJv8NMDGSj+6j6XaeBmHw8eY1i1qd9+6H+LjVvQXx3HN6aRCGa1cUdJ9RaJZUugnQ==} + engines: {node: '>=14'} + + registry-url@6.0.1: + resolution: {integrity: sha512-+crtS5QjFRqFCoQmvGduwYWEBng99ZvmFvF+cUJkGYF1L1BfU8C6Zp9T7f5vPAwyLkUExpvK+ANVZmGU49qi4Q==} + engines: {node: '>=12'} + + rehype-autolink-headings@7.1.0: + resolution: {integrity: sha512-rItO/pSdvnvsP4QRB1pmPiNHUskikqtPojZKJPPPAVx9Hj8i8TwMBhofrrAYRhYOOBZH9tgmG5lPqDLuIWPWmw==} + + rehype-parse@9.0.0: + resolution: {integrity: sha512-WG7nfvmWWkCR++KEkZevZb/uw41E8TsH4DsY9UxsTbIXCVGbAs4S+r8FrQ+OtH5EEQAs+5UxKC42VinkmpA1Yw==} + + rehype-pretty-code@0.13.2: + resolution: {integrity: sha512-F+PaFMscfJOcSHcR2b//+hk/0jT56hmGDqXcVD6VC9j0CUSGiqv8YxaWUyhR7qEIRRSbzAVxx+0uxzk+akXs+w==} + engines: {node: '>=18'} + peerDependencies: + shiki: ^1.3.0 + + rehype-slug@6.0.0: + resolution: {integrity: sha512-lWyvf/jwu+oS5+hL5eClVd3hNdmwM1kAC0BUvEGD19pajQMIzcNUd/k9GsfQ+FfECvX+JE+e9/btsKH0EjJT6A==} + + rehype-stringify@10.0.0: + resolution: {integrity: sha512-1TX1i048LooI9QoecrXy7nGFFbFSufxVRAfc6Y9YMRAi56l+oB0zP51mLSV312uRuvVLPV1opSlJmslozR1XHQ==} + + remark-gfm@4.0.0: + resolution: {integrity: sha512-U92vJgBPkbw4Zfu/IiW2oTZLSL3Zpv+uI7My2eq8JxKgqraFdU8YUGicEJCEgSbeaG+QDFqIcwwfMTOEelPxuA==} + + remark-html@16.0.1: + resolution: {integrity: sha512-B9JqA5i0qZe0Nsf49q3OXyGvyXuZFDzAP2iOFLEumymuYJITVpiH1IgsTEwTpdptDmZlMDMWeDmSawdaJIGCXQ==} + + remark-parse@11.0.0: + resolution: {integrity: sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA==} + + remark-rehype@11.1.0: + resolution: {integrity: sha512-z3tJrAs2kIs1AqIIy6pzHmAHlF1hWQ+OdY4/hv+Wxe35EhyLKcajL33iUEn3ScxtFox9nUvRufR/Zre8Q08H/g==} + + remark-stringify@11.0.0: + resolution: {integrity: sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw==} + + remove-trailing-separator@1.1.0: + resolution: {integrity: sha512-/hS+Y0u3aOfIETiaiirUFwDBDzmXPvO+jAfKTitUngIPzdKc6Z0LoFjM/CK5PL4C+eKwHohlHAb6H0VFfmmUsw==} + + repeat-string@1.6.1: + resolution: {integrity: sha512-PV0dzCYDNfRi1jCDbJzpW7jNNDRuCOG/jI5ctQcGKt/clZD+YcPS3yIlWuTJMmESC8aevCFmWJy5wjAFgNqN6w==} + engines: {node: '>=0.10'} + + require-directory@2.1.1: + resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} + engines: {node: '>=0.10.0'} + + require-from-string@2.0.2: + resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} + engines: {node: '>=0.10.0'} + + require-package-name@2.0.1: + resolution: {integrity: sha512-uuoJ1hU/k6M0779t3VMVIYpb2VMJk05cehCaABFhXaibcbvfgR8wKiozLjVFSzJPmQMRqIcO0HMyTFqfV09V6Q==} + + requires-port@1.0.0: + resolution: {integrity: sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==} + + resolve-alpn@1.2.1: + resolution: {integrity: sha512-0a1F4l73/ZFZOakJnQ3FvkJ2+gSTQWz/r2KE5OdDY0TxPm5h4GkqkWWfM47T7HsbnOtcJVEF4epCVy6u7Q3K+g==} + + resolve-cwd@3.0.0: + resolution: {integrity: sha512-OrZaX2Mb+rJCpH/6CpSqt9xFVpN++x01XnN2ie9g6P5/3xelLAkXWVADpdz1IHD/KFfEXyE6V0U01OQ3UO2rEg==} + engines: {node: '>=8'} + + resolve-from@4.0.0: + resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} + engines: {node: '>=4'} + + resolve-from@5.0.0: + resolution: {integrity: sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==} + engines: {node: '>=8'} + + resolve.exports@2.0.2: + resolution: {integrity: sha512-X2UW6Nw3n/aMgDVy+0rSqgHlv39WZAlZrXCdnbyEiKm17DSqHX4MmQMaST3FbeWR5FTuRcUwYAziZajji0Y7mg==} + engines: {node: '>=10'} + + resolve@1.22.8: + resolution: {integrity: sha512-oKWePCxqpd6FlLvGV1VU0x7bkPmmCNolxzjMf4NczoDnQcIWrAF+cPtZn5i6n+RfD2d9i0tzpKnG6Yk168yIyw==} + hasBin: true + + resolve@2.0.0-next.5: + resolution: {integrity: sha512-U7WjGVG9sH8tvjW5SmGbQuui75FiyjAX72HX15DwBBwF9dNiQZRQAg9nnPhYy+TUnE0+VcrttuvNI8oSxZcocA==} + hasBin: true + + responselike@3.0.0: + resolution: {integrity: sha512-40yHxbNcl2+rzXvZuVkrYohathsSJlMTXKryG5y8uciHv1+xDLHQpgjG64JUO9nrEq2jGLH6IZ8BcZyw3wrweg==} + engines: {node: '>=14.16'} + + restore-cursor@2.0.0: + resolution: {integrity: sha512-6IzJLuGi4+R14vwagDHX+JrXmPVtPpn4mffDJ1UdR7/Edm87fl6yi8mMBIVvFtJaNTUvjughmW4hwLhRG7gC1Q==} + engines: {node: '>=4'} + + restore-cursor@4.0.0: + resolution: {integrity: sha512-I9fPXU9geO9bHOt9pHHOhOkYerIMsmVaWB0rA2AI9ERh/+x/i7MV5HKBNrg+ljO5eoPVgCcnFuRjJ9uH6I/3eg==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + + restore-cursor@5.1.0: + resolution: {integrity: sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==} + engines: {node: '>=18'} + + ret@0.4.3: + resolution: {integrity: sha512-0f4Memo5QP7WQyUEAYUO3esD/XjOc3Zjjg5CPsAq1p8sIu0XPeMbHJemKA0BO7tV0X7+A0FoEpbmHXWxPyD3wQ==} + engines: {node: '>=10'} + + retry@0.13.1: + resolution: {integrity: sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==} + engines: {node: '>= 4'} + + reusify@1.0.4: + resolution: {integrity: sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw==} + engines: {iojs: '>=1.0.0', node: '>=0.10.0'} + + rfdc@1.4.1: + resolution: {integrity: sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==} + + rimraf@3.0.2: + resolution: {integrity: sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==} + deprecated: Rimraf versions prior to v4 are no longer supported + hasBin: true + + run-async@2.4.1: + resolution: {integrity: sha512-tvVnVv01b8c1RrA6Ep7JkStj85Guv/YrMcwqYQnwjsAS2cTmmPGBBjAjpCW7RrSodNSoE2/qg9O4bceNvUuDgQ==} + engines: {node: '>=0.12.0'} + + run-con@1.3.2: + resolution: {integrity: sha512-CcfE+mYiTcKEzg0IqS08+efdnH0oJ3zV0wSUFBNrMHMuxCtXvBCLzCJHatwuXDcu/RlhjTziTo/a1ruQik6/Yg==} + hasBin: true + + run-parallel@1.2.0: + resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} + + rxjs@6.6.7: + resolution: {integrity: sha512-hTdwr+7yYNIT5n4AMYp85KA6yw2Va0FLa3Rguvbpa4W3I5xynaBZo41cM3XM+4Q6fRMj3sBYIR1VAmZMXYJvRQ==} + engines: {npm: '>=2.0.0'} + + safe-buffer@5.1.2: + resolution: {integrity: sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==} + + safe-buffer@5.2.1: + resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} + + safe-json-stringify@1.2.0: + resolution: {integrity: sha512-gH8eh2nZudPQO6TytOvbxnuhYBOvDBBLW52tz5q6X58lJcd/tkmqFR+5Z9adS8aJtURSXWThWy/xJtJwixErvg==} + + safe-regex2@3.1.0: + resolution: {integrity: sha512-RAAZAGbap2kBfbVhvmnTFv73NWLMvDGOITFYTZBAaY8eR+Ir4ef7Up/e7amo+y1+AH+3PtLkrt9mvcTsG9LXug==} + + safe-stable-stringify@2.4.3: + resolution: {integrity: sha512-e2bDA2WJT0wxseVd4lsDP4+3ONX6HpMXQa1ZhFQ7SU+GjvORCmShbCMltrtIDfkYhVHrOcPtj+KhmDBdPdZD1g==} + engines: {node: '>=10'} + + safer-buffer@2.1.2: + resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + + sass@1.77.8: + resolution: {integrity: sha512-4UHg6prsrycW20fqLGPShtEvo/WyHRVRHwOP4DzkUrObWoWI05QBSfzU71TVB7PFaL104TwNaHpjlWXAZbQiNQ==} + engines: {node: '>=14.0.0'} + hasBin: true + + saxes@6.0.0: + resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==} + engines: {node: '>=v12.22.7'} + + scheduler@0.23.2: + resolution: {integrity: sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==} + + section-matter@1.0.0: + resolution: {integrity: sha512-vfD3pmTzGpufjScBh50YHKzEu2lxBWhVEHsNGoEXmCmn2hKGfeNLYMzCJpe8cD7gqX7TJluOVpBkAequ6dgMmA==} + engines: {node: '>=4'} + + secure-json-parse@2.7.0: + resolution: {integrity: sha512-6aU+Rwsezw7VR8/nyvKTx8QpWH9FrcYiXXlqC4z5d5XQBDRqtbfsRjnwGyqbi3gddNtWHuEk9OANUotL26qKUw==} + + seek-bzip@1.0.6: + resolution: {integrity: sha512-e1QtP3YL5tWww8uKaOCQ18UxIT2laNBXHjV/S2WYCiK4udiv8lkG89KRIoCjUagnAmCBurjF4zEVX2ByBbnCjQ==} + hasBin: true + + semver-diff@4.0.0: + resolution: {integrity: sha512-0Ju4+6A8iOnpL/Thra7dZsSlOHYAHIeMxfhWQRI1/VLcT3WDBZKKtQt/QkBOsiIN9ZpuvHE6cGZ0x4glCMmfiA==} + engines: {node: '>=12'} + + semver@6.3.1: + resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} + hasBin: true + + semver@7.6.3: + resolution: {integrity: sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==} + engines: {node: '>=10'} + hasBin: true + + send@0.18.0: + resolution: {integrity: sha512-qqWzuOjSFOuqPjFe4NOsMLafToQQwBSOEpS+FwEt3A2V3vKubTquT3vmLTQpFgMXp8AlFWFuP1qKaJZOtPpVXg==} + engines: {node: '>= 0.8.0'} + + serve-static@1.15.0: + resolution: {integrity: sha512-XGuRDNjXUijsUL0vl6nSD7cwURuzEgglbOaFuZM9g3kwDXOWVTck0jLzjPzGD+TazWbboZYu52/9/XPdUgne9g==} + engines: {node: '>= 0.8.0'} + + set-blocking@2.0.0: + resolution: {integrity: sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==} + + set-cookie-parser@2.7.0: + resolution: {integrity: sha512-lXLOiqpkUumhRdFF3k1osNXCy9akgx/dyPZ5p8qAg9seJzXr5ZrlqZuWIMuY6ejOsVLE6flJ5/h3lsn57fQ/PQ==} + + set-function-length@1.2.2: + resolution: {integrity: sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==} + engines: {node: '>= 0.4'} + + setprototypeof@1.2.0: + resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==} + + sharp@0.32.6: + resolution: {integrity: sha512-KyLTWwgcR9Oe4d9HwCwNM2l7+J0dUQwn/yf7S0EnTtb0eVS4RxO0eUSvxPtzT4F3SY+C4K6fqdv/DO27sJ/v/w==} + engines: {node: '>=14.15.0'} + + shebang-command@2.0.0: + resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} + engines: {node: '>=8'} + + shebang-regex@3.0.0: + resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} + engines: {node: '>=8'} + + shiki@1.3.0: + resolution: {integrity: sha512-9aNdQy/etMXctnPzsje1h1XIGm9YfRcSksKOGqZWXA/qP9G18/8fpz5Bjpma8bOgz3tqIpjERAd6/lLjFyzoww==} + + side-channel@1.0.6: + resolution: {integrity: sha512-fDW/EZ6Q9RiO8eFG8Hj+7u/oW+XrPTIChwCOM2+th2A6OblDtYYIpve9m+KvI9Z4C9qSEXlaGR6bTEYHReuglA==} + engines: {node: '>= 0.4'} + + signal-exit@3.0.7: + resolution: {integrity: sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==} + + signal-exit@4.1.0: + resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} + engines: {node: '>=14'} + + simple-concat@1.0.1: + resolution: {integrity: sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==} + + simple-get@4.0.1: + resolution: {integrity: sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==} + + simple-swizzle@0.2.2: + resolution: {integrity: sha512-JA//kQgZtbuY83m+xT+tXJkmJncGMTFT+C+g2h2R9uxkYIrE2yy9sgmcLhCnw57/WSD+Eh3J97FPEDFnbXnDUg==} + + sisteransi@1.0.5: + resolution: {integrity: sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==} + + slash@3.0.0: + resolution: {integrity: sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==} + engines: {node: '>=8'} + + slash@4.0.0: + resolution: {integrity: sha512-3dOsAHXXUkQTpOYcoAxLIorMTp4gIQr5IW3iVb7A7lFIp0VHhnynm9izx6TssdrIcVIESAlVjtnO2K8bg+Coew==} + engines: {node: '>=12'} + + slice-ansi@4.0.0: + resolution: {integrity: sha512-qMCMfhY040cVHT43K9BFygqYbUPFZKHOg7K73mtTWJRb8pyP3fzf4Ixd5SzdEJQ6MRUg/WBnOLxghZtKKurENQ==} + engines: {node: '>=10'} + + slice-ansi@5.0.0: + resolution: {integrity: sha512-FC+lgizVPfie0kkhqUScwRu1O/lF6NOgJmlCgK+/LYxDCTk8sGelYaHDhFcDN+Sn3Cv+3VSa4Byeo+IMCzpMgQ==} + engines: {node: '>=12'} + + slice-ansi@7.1.0: + resolution: {integrity: sha512-bSiSngZ/jWeX93BqeIAbImyTbEihizcwNjFoRUIY/T1wWQsfsm2Vw1agPKylXvQTU7iASGdHhyqRlqQzfz+Htg==} + engines: {node: '>=18'} + + smol-toml@1.2.2: + resolution: {integrity: sha512-fVEjX2ybKdJKzFL46VshQbj9PuA4IUKivalgp48/3zwS9vXzyykzQ6AX92UxHSvWJagziMRLeHMgEzoGO7A8hQ==} + engines: {node: '>= 18'} + + sonic-boom@4.0.1: + resolution: {integrity: sha512-hTSD/6JMLyT4r9zeof6UtuBDpjJ9sO08/nmS5djaA9eozT9oOlNdpXSnzcgj4FTqpk3nkLrs61l4gip9r1HCrQ==} + + sort-keys-length@1.0.1: + resolution: {integrity: sha512-GRbEOUqCxemTAk/b32F2xa8wDTs+Z1QHOkbhJDQTvv/6G3ZkbJ+frYWsTcc7cBB3Fu4wy4XlLCuNtJuMn7Gsvw==} + engines: {node: '>=0.10.0'} + + sort-keys@1.1.2: + resolution: {integrity: sha512-vzn8aSqKgytVik0iwdBEi+zevbTYZogewTUM6dtpmGwEcdzbub/TX4bCzRhebDCRC3QzXgJsLRKB2V/Oof7HXg==} + engines: {node: '>=0.10.0'} + + source-map-js@1.2.0: + resolution: {integrity: sha512-itJW8lvSA0TXEphiRoawsCksnlf8SyvmFzIhltqAHluXd88pkCd+cXJVHTDwdCr0IzwptSm035IHQktUu1QUMg==} + engines: {node: '>=0.10.0'} + + source-map-support@0.5.13: + resolution: {integrity: sha512-SHSKFHadjVA5oR4PPqhtAVdcBWwRYVd6g6cAXnIbRiIwc2EhPrTuKUBdSLvlEKyIP3GCf89fltvcZiP9MMFA1w==} + + source-map-support@0.5.21: + resolution: {integrity: sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==} + + source-map@0.6.1: + resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==} + engines: {node: '>=0.10.0'} + + space-separated-tokens@2.0.2: + resolution: {integrity: sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==} + + spdx-correct@3.2.0: + resolution: {integrity: sha512-kN9dJbvnySHULIluDHy32WHRUu3Og7B9sbY7tsFLctQkIqnMh3hErYgdMjTYuqmcXX+lK5T1lnUt3G7zNswmZA==} + + spdx-exceptions@2.5.0: + resolution: {integrity: sha512-PiU42r+xO4UbUS1buo3LPJkjlO7430Xn5SVAhdpzzsPHsjbYVflnnFdATgabnLude+Cqu25p6N+g2lw/PFsa4w==} + + spdx-expression-parse@3.0.1: + resolution: {integrity: sha512-cbqHunsQWnJNE6KhVSMsMeH5H/L9EpymbzqTQ3uLwNCLZ1Q481oWaofqH7nO6V07xlXwY6PhQdQ2IedWx/ZK4Q==} + + spdx-license-ids@3.0.18: + resolution: {integrity: sha512-xxRs31BqRYHwiMzudOrpSiHtZ8i/GeionCBDSilhYRj+9gIcI8wCZTlXZKu9vZIVqViP3dcp9qE5G6AlIaD+TQ==} + + split2@1.1.1: + resolution: {integrity: sha512-cfurE2q8LamExY+lJ9Ex3ZfBwqAPduzOKVscPDXNCLLMvyaeD3DTz1yk7fVIs6Chco+12XeD0BB6HEoYzPYbXA==} + + split2@4.2.0: + resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==} + engines: {node: '>= 10.x'} + + sprintf-js@1.0.3: + resolution: {integrity: sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==} + + stack-generator@2.0.10: + resolution: {integrity: sha512-mwnua/hkqM6pF4k8SnmZ2zfETsRUpWXREfA/goT8SLCV4iOFa4bzOX2nDipWAZFPTjLvQB82f5yaodMVhK0yJQ==} + + stack-trace@0.0.10: + resolution: {integrity: sha512-KGzahc7puUKkzyMt+IqAep+TVNbKP+k2Lmwhub39m1AsTSkaDutx56aDCo+HLDzf/D26BIHTJWNiTG1KAJiQCg==} + + stack-utils@2.0.6: + resolution: {integrity: sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ==} + engines: {node: '>=10'} + + stackframe@1.3.4: + resolution: {integrity: sha512-oeVtt7eWQS+Na6F//S4kJ2K2VbRlS9D43mAlMyVpVWovy9o+jfgH8O9agzANzaiLjclA0oYzUXEM4PurhSUChw==} + + statuses@1.5.0: + resolution: {integrity: sha512-OpZ3zP+jT1PI7I8nemJX4AKmAX070ZkYPVWV/AaKTJl+tXCTGyVdC1a4SL8RUQYEwk/f34ZX8UTykN68FwrqAA==} + engines: {node: '>= 0.6'} + + statuses@2.0.1: + resolution: {integrity: sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==} + engines: {node: '>= 0.8'} + + std-env@3.7.0: + resolution: {integrity: sha512-JPbdCEQLj1w5GilpiHAx3qJvFndqybBysA3qUOnznweH4QbNYUsW/ea8QzSrnh0vNsezMMw5bcVool8lM0gwzg==} + + stdin-discarder@0.2.2: + resolution: {integrity: sha512-UhDfHmA92YAlNnCfhmq0VeNL5bDbiZGg7sZ2IvPsXubGkiNa9EC+tUTsjBRsYUAz87btI6/1wf4XoVvQ3uRnmQ==} + engines: {node: '>=18'} + + streamsearch@1.1.0: + resolution: {integrity: sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==} + engines: {node: '>=10.0.0'} + + streamx@2.18.0: + resolution: {integrity: sha512-LLUC1TWdjVdn1weXGcSxyTR3T4+acB6tVGXT95y0nGbca4t4o/ng1wKAGTljm9VicuCVLvRlqFYXYy5GwgM7sQ==} + + string-length@4.0.2: + resolution: {integrity: sha512-+l6rNN5fYHNhZZy41RXsYptCjA2Igmq4EG7kZAYFQI1E1VTXarr6ZPXBg6eq7Y6eK4FEhY6AJlyuFIb/v/S0VQ==} + engines: {node: '>=10'} + + string-width@2.1.1: + resolution: {integrity: sha512-nOqH59deCq9SRHlxq1Aw85Jnt4w6KvLKqWVik6oA9ZklXLNIOlqg4F2yrT1MVaTjAqvVwdfeZ7w7aCvJD7ugkw==} + engines: {node: '>=4'} + + string-width@4.2.3: + resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} + engines: {node: '>=8'} + + string-width@5.1.2: + resolution: {integrity: sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==} + engines: {node: '>=12'} + + string-width@7.2.0: + resolution: {integrity: sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==} + engines: {node: '>=18'} + + string_decoder@1.1.1: + resolution: {integrity: sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==} + + string_decoder@1.3.0: + resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==} + + stringify-entities@4.0.4: + resolution: {integrity: sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==} + + strip-ansi-control-characters@2.0.0: + resolution: {integrity: sha512-Q0/k5orrVGeaOlIOUn1gybGU0IcAbgHQT1faLo5hik4DqClKVSaka5xOhNNoRgtfztHVxCYxi7j71mrWom0bIw==} + + strip-ansi@4.0.0: + resolution: {integrity: sha512-4XaJ2zQdCzROZDivEVIDPkcQn8LMFSa8kj8Gxb/Lnwzv9A8VctNZ+lfivC/sV3ivW8ElJTERXZoPBRrZKkNKow==} + engines: {node: '>=4'} + + strip-ansi@5.2.0: + resolution: {integrity: sha512-DuRs1gKbBqsMKIZlrffwlug8MHkcnpjs5VPmL1PAh+mA30U0DTotfDZ0d2UUsXpPmPmMMJ6W773MaA3J+lbiWA==} + engines: {node: '>=6'} + + strip-ansi@6.0.1: + resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} + engines: {node: '>=8'} + + strip-ansi@7.1.0: + resolution: {integrity: sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==} + engines: {node: '>=12'} + + strip-bom-string@1.0.0: + resolution: {integrity: sha512-uCC2VHvQRYu+lMh4My/sFNmF2klFymLX1wHJeXnbEJERpV/ZsVuonzerjfrGpIGF7LBVa1O7i9kjiWvJiFck8g==} + engines: {node: '>=0.10.0'} + + strip-bom@4.0.0: + resolution: {integrity: sha512-3xurFv5tEgii33Zi8Jtp55wEIILR9eh34FAW00PZf+JnSsTmV/ioewSgQl97JHvgjoRGwPShsWm+IdrxB35d0w==} + engines: {node: '>=8'} + + strip-dirs@3.0.0: + resolution: {integrity: sha512-I0sdgcFTfKQlUPZyAqPJmSG3HLO9rWDFnxonnIbskYNM3DwFOeTNB5KzVq3dA1GdRAc/25b5Y7UO2TQfKWw4aQ==} + + strip-final-newline@2.0.0: + resolution: {integrity: sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==} + engines: {node: '>=6'} + + strip-final-newline@3.0.0: + resolution: {integrity: sha512-dOESqjYr96iWYylGObzd39EuNTa5VJxyvVAEm5Jnh7KGo75V43Hk1odPQkNDyXNmUR6k+gEiDVXnjB8HJ3crXw==} + engines: {node: '>=12'} + + strip-indent@3.0.0: + resolution: {integrity: sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==} + engines: {node: '>=8'} + + strip-json-comments@2.0.1: + resolution: {integrity: sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==} + engines: {node: '>=0.10.0'} + + strip-json-comments@3.1.1: + resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} + engines: {node: '>=8'} + + strip-outer@2.0.0: + resolution: {integrity: sha512-A21Xsm1XzUkK0qK1ZrytDUvqsQWict2Cykhvi0fBQntGG5JSprESasEyV1EZ/4CiR5WB5KjzLTrP/bO37B0wPg==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + + strtok3@7.1.1: + resolution: {integrity: sha512-mKX8HA/cdBqMKUr0MMZAFssCkIGoZeSCMXgnt79yKxNFguMLVFgRe6wB+fsL0NmoHDbeyZXczy7vEPSoo3rkzg==} + engines: {node: '>=16'} + + styled-jsx@5.1.1: + resolution: {integrity: sha512-pW7uC1l4mBZ8ugbiZrcIsiIvVx1UmTfw7UkC3Um2tmfUq9Bhk8IiyEIPl6F8agHgjzku6j0xQEZbfA5uSgSaCw==} + engines: {node: '>= 12.0.0'} + peerDependencies: + '@babel/core': '*' + babel-plugin-macros: '*' + react: '>= 16.8.0 || 17.x.x || ^18.0.0-0' + peerDependenciesMeta: + '@babel/core': + optional: true + babel-plugin-macros: + optional: true + + stylelint-config-recommended-scss@14.1.0: + resolution: {integrity: sha512-bhaMhh1u5dQqSsf6ri2GVWWQW5iUjBYgcHkh7SgDDn92ijoItC/cfO/W+fpXshgTQWhwFkP1rVcewcv4jaftRg==} + engines: {node: '>=18.12.0'} + peerDependencies: + postcss: ^8.3.3 + stylelint: ^16.6.1 + peerDependenciesMeta: + postcss: + optional: true + + stylelint-config-recommended@14.0.1: + resolution: {integrity: sha512-bLvc1WOz/14aPImu/cufKAZYfXs/A/owZfSMZ4N+16WGXLoX5lOir53M6odBxvhgmgdxCVnNySJmZKx73T93cg==} + engines: {node: '>=18.12.0'} + peerDependencies: + stylelint: ^16.1.0 + + stylelint-config-standard-scss@13.1.0: + resolution: {integrity: sha512-Eo5w7/XvwGHWkeGLtdm2FZLOMYoZl1omP2/jgFCXyl2x5yNz7/8vv4Tj6slHvMSSUNTaGoam/GAZ0ZhukvalfA==} + engines: {node: '>=18.12.0'} + peerDependencies: + postcss: ^8.3.3 + stylelint: ^16.3.1 + peerDependenciesMeta: + postcss: + optional: true + + stylelint-config-standard@36.0.1: + resolution: {integrity: sha512-8aX8mTzJ6cuO8mmD5yon61CWuIM4UD8Q5aBcWKGSf6kg+EC3uhB+iOywpTK4ca6ZL7B49en8yanOFtUW0qNzyw==} + engines: {node: '>=18.12.0'} + peerDependencies: + stylelint: ^16.1.0 + + stylelint-scss@6.4.1: + resolution: {integrity: sha512-+clI2bQC2FPOt06ZwUlXZZ95IO2C5bKTP0GLN1LNQPVvISfSNcgMKv/VTwym1mK9vnqhHbOk8lO4rj4nY7L9pw==} + engines: {node: '>=18.12.0'} + peerDependencies: + stylelint: ^16.0.2 + + stylelint@16.8.2: + resolution: {integrity: sha512-fInKATippQhcSm7AB+T32GpI+626yohrg33GkFT/5jzliUw5qhlwZq2UQQwgl3HsHrf09oeARi0ZwgY/UWEv9A==} + engines: {node: '>=18.12.0'} + hasBin: true + + sucrase@3.35.0: + resolution: {integrity: sha512-8EbVDiu9iN/nESwxeSxDKe0dunta1GOlHufmSSXxMD2z2/tMZpDMpvXQGsc+ajGo8y2uYUmixaSRUc/QPoQ0GA==} + engines: {node: '>=16 || 14 >=14.17'} + hasBin: true + + supports-color@5.5.0: + resolution: {integrity: sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==} + engines: {node: '>=4'} + + supports-color@7.2.0: + resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} + engines: {node: '>=8'} + + supports-color@8.1.1: + resolution: {integrity: sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==} + engines: {node: '>=10'} + + supports-color@9.4.0: + resolution: {integrity: sha512-VL+lNrEoIXww1coLPOmiEmK/0sGigko5COxI09KzHc2VJXJsQ37UaQ+8quuxjDeA7+KnLGTWRyOXSLLR2Wb4jw==} + engines: {node: '>=12'} + + supports-hyperlinks@2.3.0: + resolution: {integrity: sha512-RpsAZlpWcDwOPQA22aCH4J0t7L8JmAvsCxfOSEwm7cQs3LshN36QaTkwd70DnBOXDWGssw2eUoc8CaRWT0XunA==} + engines: {node: '>=8'} + + supports-hyperlinks@3.0.0: + resolution: {integrity: sha512-QBDPHyPQDRTy9ku4URNGY5Lah8PAaXs6tAAwp55sL5WCsSW7GIfdf6W5ixfziW+t7wh3GVvHyHHyQ1ESsoRvaA==} + engines: {node: '>=14.18'} + + supports-preserve-symlinks-flag@1.0.0: + resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} + engines: {node: '>= 0.4'} + + svg-tags@1.0.0: + resolution: {integrity: sha512-ovssysQTa+luh7A5Weu3Rta6FJlFBBbInjOh722LIt6klpU2/HtdUbszju/G4devcvk8PGt7FCLv5wftu3THUA==} + + svgo@3.3.2: + resolution: {integrity: sha512-OoohrmuUlBs8B8o6MB2Aevn+pRIH9zDALSR+6hhqVfa6fRwG/Qw9VUMSMW9VNg2CFc/MTIfabtdOVl9ODIJjpw==} + engines: {node: '>=14.0.0'} + hasBin: true + + symbol-tree@3.2.4: + resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==} + + system-architecture@0.1.0: + resolution: {integrity: sha512-ulAk51I9UVUyJgxlv9M6lFot2WP3e7t8Kz9+IS6D4rVba1tR9kON+Ey69f+1R4Q8cd45Lod6a4IcJIxnzGc/zA==} + engines: {node: '>=18'} + + table@6.8.2: + resolution: {integrity: sha512-w2sfv80nrAh2VCbqR5AK27wswXhqcck2AhfnNW76beQXskGZ1V12GwS//yYVa3d3fcvAip2OUnbDAjW2k3v9fA==} + engines: {node: '>=10.0.0'} + + tabtab@3.0.2: + resolution: {integrity: sha512-jANKmUe0sIQc/zTALTBy186PoM/k6aPrh3A7p6AaAfF6WPSbTx1JYeGIGH162btpH+mmVEXln+UxwViZHO2Jhg==} + + tailwindcss@3.4.10: + resolution: {integrity: sha512-KWZkVPm7yJRhdu4SRSl9d4AK2wM3a50UsvgHZO7xY77NQr2V+fIrEuoDGQcbvswWvFGbS2f6e+jC/6WJm1Dl0w==} + engines: {node: '>=14.0.0'} + hasBin: true + + tar-fs@2.1.1: + resolution: {integrity: sha512-V0r2Y9scmbDRLCNex/+hYzvp/zyYjvFbHPNgVTKfQvVrb6guiE/fxP+XblDNR011utopbkex2nM4dHNV6GDsng==} + + tar-fs@3.0.6: + resolution: {integrity: sha512-iokBDQQkUyeXhgPYaZxmczGPhnhXZ0CmrqI+MOb/WFGS9DW5wnfrLgtjUJBvz50vQ3qfRwJ62QVoCFu8mPVu5w==} + + tar-stream@2.2.0: + resolution: {integrity: sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==} + engines: {node: '>=6'} + + tar-stream@3.1.7: + resolution: {integrity: sha512-qJj60CXt7IU1Ffyc3NJMjh6EkuCFej46zUqJ4J7pqYlThyd9bO0XBTmcOIhSzZJVWfsLks0+nle/j538YAW9RQ==} + + tar@6.2.1: + resolution: {integrity: sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A==} + engines: {node: '>=10'} + + temp-dir@3.0.0: + resolution: {integrity: sha512-nHc6S/bwIilKHNRgK/3jlhDoIHcp45YgyiwcAk46Tr0LfEqGBVpmiAyuiuxeVE44m3mXnEeVhaipLOEWmH+Njw==} + engines: {node: '>=14.16'} + + tempy@3.1.0: + resolution: {integrity: sha512-7jDLIdD2Zp0bDe5r3D2qtkd1QOCacylBuL7oa4udvN6v2pqr4+LcCr67C8DR1zkpaZ8XosF5m1yQSabKAW6f2g==} + engines: {node: '>=14.16'} + + terminal-link@3.0.0: + resolution: {integrity: sha512-flFL3m4wuixmf6IfhFJd1YPiLiMuxEc8uHRM1buzIeZPm22Au2pDqBJQgdo7n1WfPU1ONFGv7YDwpFBmHGF6lg==} + engines: {node: '>=12'} + + test-exclude@6.0.0: + resolution: {integrity: sha512-cAGWPIyOHU6zlmg88jwm7VRyXnMN7iV68OGAbYDk/Mh/xC/pzVPlQtY6ngoIH/5/tciuhGfvESU8GrHrcxD56w==} + engines: {node: '>=8'} + + text-decoder@1.1.1: + resolution: {integrity: sha512-8zll7REEv4GDD3x4/0pW+ppIxSNs7H1J10IKFZsuOMscumCdM2a+toDGLPA3T+1+fLBql4zbt5z83GEQGGV5VA==} + + text-hex@1.0.0: + resolution: {integrity: sha512-uuVGNWzgJ4yhRaNSiubPY7OjISw4sw4E5Uv0wbjp+OzcbmVU/rsT8ujgcXJhn9ypzsgr5vlzpPqP+MBBKcGvbg==} + + text-table@0.2.0: + resolution: {integrity: sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==} + + thenify-all@1.6.0: + resolution: {integrity: sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==} + engines: {node: '>=0.8'} + + thenify@3.3.1: + resolution: {integrity: sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==} + + thread-stream@3.1.0: + resolution: {integrity: sha512-OqyPZ9u96VohAyMfJykzmivOrY2wfMSf3C5TtFJVgN+Hm6aj+voFhlK+kZEIv2FBh1X6Xp3DlnCOfEQ3B2J86A==} + + through2-filter@4.0.0: + resolution: {integrity: sha512-P8IpQL19bSdXqGLvLdbidYRxERXgHEXGcQofPxbLpPkqS1ieOrUrocdYRTNv8YwSukaDJWr71s6F2kZ3bvgEhA==} + engines: {node: '>= 6'} + + through2-map@4.0.0: + resolution: {integrity: sha512-+rpmDB5yckiBGEuqJSsWYWMs9e1zdksypDKvByysEyN+knhsPXV9Z6O2mA9meczIa6AON7bi2G3xWk5T8UG4zQ==} + engines: {node: '>= 6'} + + through2@2.0.5: + resolution: {integrity: sha512-/mrRod8xqpA+IHSLyGCQ2s8SPHiCDEeQJSep1jqLYeEUClOFG2Qsh+4FU6G9VeqpZnGW/Su8LQGc4YKni5rYSQ==} + + through2@4.0.2: + resolution: {integrity: sha512-iOqSav00cVxEEICeD7TjLB1sueEL+81Wpzp2bY17uZjZN0pWZPuo4suZ/61VujxmqSGFfgOcNuTZ85QJwNZQpw==} + + through@2.3.8: + resolution: {integrity: sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==} + + time-zone@1.0.0: + resolution: {integrity: sha512-TIsDdtKo6+XrPtiTm1ssmMngN1sAhyKnTO2kunQWqNPWIVvCm15Wmw4SWInwTVgJ5u/Tr04+8Ei9TNcw4x4ONA==} + engines: {node: '>=4'} + + tmp-promise@3.0.3: + resolution: {integrity: sha512-RwM7MoPojPxsOBYnyd2hy0bxtIlVrihNs9pj5SUvY8Zz1sQcQG2tG1hSr8PDxfgEB8RNKDhqbIlroIarSNDNsQ==} + + tmp@0.0.33: + resolution: {integrity: sha512-jRCJlojKnZ3addtTOjdIqoRuPEKBvNXcGYqzO6zWZX8KfKEpnGY5jfggJQ3EjKuu8D4bJRr0y+cYJFmYbImXGw==} + engines: {node: '>=0.6.0'} + + tmp@0.2.3: + resolution: {integrity: sha512-nZD7m9iCPC5g0pYmcaxogYKggSfLsdxl8of3Q/oIbqCqLLIO9IAF0GWjX1z9NZRHPiXv8Wex4yDCaZsgEw0Y8w==} + engines: {node: '>=14.14'} + + tmpl@1.0.5: + resolution: {integrity: sha512-3f0uOEAQwIqGuWW2MVzYg8fV/QNnc/IpuJNG837rLuczAaLVHslWHZQj4IGiEl5Hs3kkbhwL9Ab7Hrsmuj+Smw==} + + to-fast-properties@2.0.0: + resolution: {integrity: sha512-/OaKK0xYrs3DmxRYqL/yDc+FxFUVYhDlXMhRmv3z915w2HF1tnN1omB354j8VUGO/hbRzyD6Y3sA7v7GS/ceog==} + engines: {node: '>=4'} + + to-regex-range@5.0.1: + resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} + engines: {node: '>=8.0'} + + toad-cache@3.7.0: + resolution: {integrity: sha512-/m8M+2BJUpoJdgAHoG+baCwBT+tf2VraSfkBgl0Y00qIWt41DJ8R5B8nsEw0I58YwF5IZH6z24/2TobDKnqSWw==} + engines: {node: '>=12'} + + toidentifier@1.0.1: + resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==} + engines: {node: '>=0.6'} + + token-types@5.0.1: + resolution: {integrity: sha512-Y2fmSnZjQdDb9W4w4r1tswlMHylzWIeOKpx0aZH9BgGtACHhrk3OkT52AzwcuqTRBZtvvnTjDBh8eynMulu8Vg==} + engines: {node: '>=14.16'} + + toml@3.0.0: + resolution: {integrity: sha512-y/mWCZinnvxjTKYhJ+pYxwD0mRLVvOtdS2Awbgxln6iEnt4rk0yBxeSBHkGJcPucRiG0e55mwWp+g/05rsrd6w==} + + tomlify-j0.4@3.0.0: + resolution: {integrity: sha512-2Ulkc8T7mXJ2l0W476YC/A209PR38Nw8PuaCNtk9uI3t1zzFdGQeWYGQvmj2PZkVvRC/Yoi4xQKMRnWc/N29tQ==} + + tough-cookie@4.1.4: + resolution: {integrity: sha512-Loo5UUvLD9ScZ6jh8beX1T6sO1w2/MpCRpEP7V280GKMVUQ0Jzar2U3UJPsrdbziLEMMhu3Ujnq//rhiFuIeag==} + engines: {node: '>=6'} + + tr46@0.0.3: + resolution: {integrity: sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==} + + tr46@3.0.0: + resolution: {integrity: sha512-l7FvfAHlcmulp8kr+flpQZmVwtu7nfRV7NZujtN0OqES8EL4O4e0qqzL0DC5gAvx/ZC/9lk6rhcUwYvkBnBnYA==} + engines: {node: '>=12'} + + trim-lines@3.0.1: + resolution: {integrity: sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==} + + trim-repeated@2.0.0: + resolution: {integrity: sha512-QUHBFTJGdOwmp0tbOG505xAgOp/YliZP/6UgafFXYZ26WT1bvQmSMJUvkeVSASuJJHbqsFbynTvkd5W8RBTipg==} + engines: {node: '>=12'} + + triple-beam@1.4.1: + resolution: {integrity: sha512-aZbgViZrg1QNcG+LULa7nhZpJTZSLm/mXnHXnbAbjmN5aSa0y7V+wvv6+4WaBtpISJzThKy+PIPxc1Nq1EJ9mg==} + engines: {node: '>= 14.0.0'} + + trough@2.2.0: + resolution: {integrity: sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw==} + + ts-api-utils@1.3.0: + resolution: {integrity: sha512-UQMIo7pb8WRomKR1/+MFVLTroIvDVtMX3K6OUir8ynLyzB8Jeriont2bTAtmNPa1ekAgN7YPDyf6V+ygrdU+eQ==} + engines: {node: '>=16'} + peerDependencies: + typescript: '>=4.2.0' + + ts-interface-checker@0.1.13: + resolution: {integrity: sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==} + + ts-node@10.9.2: + resolution: {integrity: sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==} + hasBin: true + peerDependencies: + '@swc/core': '>=1.2.50' + '@swc/wasm': '>=1.2.50' + '@types/node': '*' + typescript: '>=2.7' + peerDependenciesMeta: + '@swc/core': + optional: true + '@swc/wasm': + optional: true + + tslib@1.14.1: + resolution: {integrity: sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==} + + tslib@2.6.3: + resolution: {integrity: sha512-xNvxJEOUiWPGhUuUdQgAJPKOOJfGnIyKySOc09XkKsgdUV/3E2zvwZYdejjmRgPCgcym1juLH3226yA7sEFJKQ==} + + tsutils@3.21.0: + resolution: {integrity: sha512-mHKK3iUXL+3UF6xL5k0PEhKRUBKPBCv/+RkEOpjRWxxx27KKRBmmA60A9pgOUvMi8GKhRMPEmjBRPzs2W7O1OA==} + engines: {node: '>= 6'} + peerDependencies: + typescript: '>=2.8.0 || >= 3.2.0-dev || >= 3.3.0-dev || >= 3.4.0-dev || >= 3.5.0-dev || >= 3.6.0-dev || >= 3.6.0-beta || >= 3.7.0-dev || >= 3.7.0-beta' + + tunnel-agent@0.6.0: + resolution: {integrity: sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==} + + type-check@0.4.0: + resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} + engines: {node: '>= 0.8.0'} + + type-detect@4.0.8: + resolution: {integrity: sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==} + engines: {node: '>=4'} + + type-fest@0.21.3: + resolution: {integrity: sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==} + engines: {node: '>=10'} + + type-fest@0.8.1: + resolution: {integrity: sha512-4dbzIzqvjtgiM5rw1k5rEHtBANKmdudhGyBEajN01fEyhaAIhsoKNy6y7+IN93IfpFtwY9iqi7kD+xwKhQsNJA==} + engines: {node: '>=8'} + + type-fest@1.4.0: + resolution: {integrity: sha512-yGSza74xk0UG8k+pLh5oeoYirvIiWo5t0/o3zHHAO2tRDiZcxWP7fywNlXhqb6/r6sWvwi+RsyQMWhVLe4BVuA==} + engines: {node: '>=10'} + + type-fest@2.19.0: + resolution: {integrity: sha512-RAH822pAdBgcNMAfWnCBU3CFZcfZ/i1eZjwFU/dsLKumyuuP3niueg2UAukXYF0E2AAoc82ZSSf9J0WQBinzHA==} + engines: {node: '>=12.20'} + + type-fest@4.24.0: + resolution: {integrity: sha512-spAaHzc6qre0TlZQQ2aA/nGMe+2Z/wyGk5Z+Ru2VUfdNwT6kWO6TjevOlpebsATEG1EIQ2sOiDszud3lO5mt/Q==} + engines: {node: '>=16'} + + type-is@1.6.18: + resolution: {integrity: sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==} + engines: {node: '>= 0.6'} + + typedarray-to-buffer@3.1.5: + resolution: {integrity: sha512-zdu8XMNEDepKKR+XYOXAVPtWui0ly0NtohUscw+UmaHiAWT8hrV1rr//H6V+0DvJ3OQ19S979M0laLfX8rm82Q==} + + typescript-eslint@8.1.0: + resolution: {integrity: sha512-prB2U3jXPJLpo1iVLN338Lvolh6OrcCZO+9Yv6AR+tvegPPptYCDBIHiEEUdqRi8gAv2bXNKfMUrgAd2ejn/ow==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '*' + peerDependenciesMeta: + typescript: + optional: true + + typescript@5.5.4: + resolution: {integrity: sha512-Mtq29sKDAEYP7aljRgtPOpTvOfbwRWlS6dPRzwjdE+C0R4brX/GUyhHSecbHMFLNBLcJIPt9nl9yG5TZ1weH+Q==} + engines: {node: '>=14.17'} + hasBin: true + + uc.micro@2.1.0: + resolution: {integrity: sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==} + + ufo@1.5.4: + resolution: {integrity: sha512-UsUk3byDzKd04EyoZ7U4DOlxQaD14JUKQl6/P7wiX4FNvUfm3XL246n9W5AmqwW5RSFJ27NAuM0iLscAOYUiGQ==} + + uid-safe@2.1.5: + resolution: {integrity: sha512-KPHm4VL5dDXKz01UuEd88Df+KzynaohSL9fBh096KWAxSKZQDI2uBrVqtvRM4rwrIrRRKsdLNML/lnaaVSRioA==} + engines: {node: '>= 0.8'} + + ulid@2.3.0: + resolution: {integrity: sha512-keqHubrlpvT6G2wH0OEfSW4mquYRcbe/J8NMmveoQOjUqmo+hXtO+ORCpWhdbZ7k72UtY61BL7haGxW6enBnjw==} + hasBin: true + + unbzip2-stream@1.4.3: + resolution: {integrity: sha512-mlExGW4w71ebDJviH16lQLtZS32VKqsSfk80GCfUlwT/4/hNRFsoscrF/c++9xinkMzECL1uL9DDwXqFWkruPg==} + + uncrypto@0.1.3: + resolution: {integrity: sha512-Ql87qFHB3s/De2ClA9e0gsnS6zXG27SkTiSJwjCc9MebbfapQfuPzumMIUMi38ezPZVNFcHI9sUIepeQfw8J8Q==} + + undici-types@6.13.0: + resolution: {integrity: sha512-xtFJHudx8S2DSoujjMd1WeWvn7KKWFRESZTMeL1RptAYERu29D6jphMjjY+vn96jvN3kVPDNxU/E13VTaXj6jg==} + + unenv@1.10.0: + resolution: {integrity: sha512-wY5bskBQFL9n3Eca5XnhH6KbUo/tfvkwm9OpcdCvLaeA7piBNbavbOKJySEwQ1V0RH6HvNlSAFRTpvTqgKRQXQ==} + + unicorn-magic@0.1.0: + resolution: {integrity: sha512-lRfVq8fE8gz6QMBuDM6a+LO3IAzTi05H6gCVaUpir2E1Rwpo4ZUog45KpNXKC/Mn3Yb9UDuHumeFTo9iV/D9FQ==} + engines: {node: '>=18'} + + unified@11.0.5: + resolution: {integrity: sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==} + + unique-string@3.0.0: + resolution: {integrity: sha512-VGXBUVwxKMBUznyffQweQABPRRW1vHZAbadFZud4pLFAqRGvv/96vafgjWFqzourzr8YonlQiPgH0YCJfawoGQ==} + engines: {node: '>=12'} + + unist-util-is@6.0.0: + resolution: {integrity: sha512-2qCTHimwdxLfz+YzdGfkqNlH0tLi9xjTnHddPmJwtIG9MGsdbutfTc4P+haPD7l7Cjxf/WZj+we5qfVPvvxfYw==} + + unist-util-position@5.0.0: + resolution: {integrity: sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==} + + unist-util-stringify-position@4.0.0: + resolution: {integrity: sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==} + + unist-util-visit-parents@6.0.1: + resolution: {integrity: sha512-L/PqWzfTP9lzzEa6CKs0k2nARxTdZduw3zyh8d2NVBnsyvHjSX4TWse388YrrQKbvI8w20fGjGlhgT96WwKykw==} + + unist-util-visit@5.0.0: + resolution: {integrity: sha512-MR04uvD+07cwl/yhVuVWAtw+3GOR/knlL55Nd/wAdblk27GCVt3lqpTivy/tkJcZoNPzTwS1Y+KMojlLDhoTzg==} + + universal-user-agent@6.0.1: + resolution: {integrity: sha512-yCzhz6FN2wU1NiiQRogkTQszlQSlpWaw8SvVegAc+bDxbzHgh1vX8uIe8OYyMH6DwH+sdTJsgMl36+mSMdRJIQ==} + + universalify@0.2.0: + resolution: {integrity: sha512-CJ1QgKmNg3CwvAv/kOFmtnEN05f0D/cn9QntgNOQlQF9dgvVTHj3t+8JPdjqawCHk7V/KA+fbUqzZ9XWhcqPUg==} + engines: {node: '>= 4.0.0'} + + unix-dgram@2.0.6: + resolution: {integrity: sha512-AURroAsb73BZ6CdAyMrTk/hYKNj3DuYYEuOaB8bYMOHGKupRNScw90Q5C71tWJc3uE7dIeXRyuwN0xLLq3vDTg==} + engines: {node: '>=0.10.48'} + + unixify@1.0.0: + resolution: {integrity: sha512-6bc58dPYhCMHHuwxldQxO3RRNZ4eCogZ/st++0+fcC1nr0jiGUtAdBJ2qzmLQWSxbtz42pWt4QQMiZ9HvZf5cg==} + engines: {node: '>=0.10.0'} + + unpipe@1.0.0: + resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==} + engines: {node: '>= 0.8'} + + unstorage@1.10.2: + resolution: {integrity: sha512-cULBcwDqrS8UhlIysUJs2Dk0Mmt8h7B0E6mtR+relW9nZvsf/u4SkAYyNliPiPW7XtFNb5u3IUMkxGxFTTRTgQ==} + peerDependencies: + '@azure/app-configuration': ^1.5.0 + '@azure/cosmos': ^4.0.0 + '@azure/data-tables': ^13.2.2 + '@azure/identity': ^4.0.1 + '@azure/keyvault-secrets': ^4.8.0 + '@azure/storage-blob': ^12.17.0 + '@capacitor/preferences': ^5.0.7 + '@netlify/blobs': ^6.5.0 || ^7.0.0 + '@planetscale/database': ^1.16.0 + '@upstash/redis': ^1.28.4 + '@vercel/kv': ^1.0.1 + idb-keyval: ^6.2.1 + ioredis: ^5.3.2 + peerDependenciesMeta: + '@azure/app-configuration': + optional: true + '@azure/cosmos': + optional: true + '@azure/data-tables': + optional: true + '@azure/identity': + optional: true + '@azure/keyvault-secrets': + optional: true + '@azure/storage-blob': + optional: true + '@capacitor/preferences': + optional: true + '@netlify/blobs': + optional: true + '@planetscale/database': + optional: true + '@upstash/redis': + optional: true + '@vercel/kv': + optional: true + idb-keyval: + optional: true + ioredis: + optional: true + + untildify@3.0.3: + resolution: {integrity: sha512-iSk/J8efr8uPT/Z4eSUywnqyrQU7DSdMfdqK4iWEaUVVmcP5JcnpRqmVMwcwcnmI1ATFNgC5V90u09tBynNFKA==} + engines: {node: '>=4'} + + untun@0.1.3: + resolution: {integrity: sha512-4luGP9LMYszMRZwsvyUd9MrxgEGZdZuZgpVQHEEX0lCYFESasVRvZd0EYpCkOIbJKHMuv0LskpXc/8Un+MJzEQ==} + hasBin: true + + update-browserslist-db@1.1.0: + resolution: {integrity: sha512-EdRAaAyk2cUE1wOf2DkEhzxqOQvFOoRJFNS6NeyJ01Gp2beMRpBAINjM2iDXE3KCuKhwnvHIQCJm6ThL2Z+HzQ==} + hasBin: true + peerDependencies: + browserslist: '>= 4.21.0' + + update-notifier@7.0.0: + resolution: {integrity: sha512-Hv25Bh+eAbOLlsjJreVPOs4vd51rrtCrmhyOJtbpAojro34jS4KQaEp4/EvlHJX7jSO42VvEFpkastVyXyIsdQ==} + engines: {node: '>=18'} + + uqr@0.1.2: + resolution: {integrity: sha512-MJu7ypHq6QasgF5YRTjqscSzQp/W11zoUk6kvmlH+fmWEs63Y0Eib13hYFwAzagRJcVY8WVnlV+eBDUGMJ5IbA==} + + uri-js@4.4.1: + resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} + + url-parse@1.5.10: + resolution: {integrity: sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==} + + urlpattern-polyfill@8.0.2: + resolution: {integrity: sha512-Qp95D4TPJl1kC9SKigDcqgyM2VDVO4RiJc2d4qe5GrYm+zbIQCWWKAFaJNQ4BhdFeDGwBmAxqJBwWSJDb9T3BQ==} + + util-deprecate@1.0.2: + resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} + + utils-merge@1.0.1: + resolution: {integrity: sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==} + engines: {node: '>= 0.4.0'} + + uuid@9.0.1: + resolution: {integrity: sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==} + hasBin: true + + v8-compile-cache-lib@3.0.1: + resolution: {integrity: sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==} + + v8-to-istanbul@9.3.0: + resolution: {integrity: sha512-kiGUalWN+rgBJ/1OHZsBtU4rXZOfj/7rKQxULKlIzwzQSvMJUUNgPwJEEh7gU6xEVxC0ahoOBvN2YI8GH6FNgA==} + engines: {node: '>=10.12.0'} + + validate-npm-package-license@3.0.4: + resolution: {integrity: sha512-DpKm2Ui/xN7/HQKCtpZxoRWBhZ9Z0kqtygG8XCgNQ8ZlDnxuQmWhj566j8fN4Cu3/JmbhsDo7fcAJq4s9h27Ew==} + + validate-npm-package-name@4.0.0: + resolution: {integrity: sha512-mzR0L8ZDktZjpX4OB46KT+56MAhl4EIazWP/+G/HPGuvfdaqg4YsCdtOm6U9+LOFyYDoh4dpnpxZRB9MQQns5Q==} + engines: {node: ^12.13.0 || ^14.15.0 || >=16.0.0} + + vary@1.1.2: + resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} + engines: {node: '>= 0.8'} + + vfile-location@5.0.2: + resolution: {integrity: sha512-NXPYyxyBSH7zB5U6+3uDdd6Nybz6o6/od9rk8bp9H8GR3L+cm/fC0uUTbqBmUTnMCUDslAGBOIKNfvvb+gGlDg==} + + vfile-message@4.0.2: + resolution: {integrity: sha512-jRDZ1IMLttGj41KcZvlrYAaI3CfqpLpfpf+Mfig13viT6NKvRzWZ+lXz0Y5D60w6uJIBAOGq9mSHf0gktF0duw==} + + vfile@6.0.1: + resolution: {integrity: sha512-1bYqc7pt6NIADBJ98UiG0Bn/CHIVOoZ/IyEkqIruLg0mE1BKzkOXY2D6CSqQIcKqgadppE5lrxgWXJmXd7zZJw==} + + w3c-xmlserializer@4.0.0: + resolution: {integrity: sha512-d+BFHzbiCx6zGfz0HyQ6Rg69w9k19nviJspaj4yNscGjrHu94sVP+aRm75yEbCh+r2/yR+7q6hux9LVtbuTGBw==} + engines: {node: '>=14'} + + wait-port@1.1.0: + resolution: {integrity: sha512-3e04qkoN3LxTMLakdqeWth8nih8usyg+sf1Bgdf9wwUkp05iuK1eSY/QpLvscT/+F/gA89+LpUmmgBtesbqI2Q==} + engines: {node: '>=10'} + hasBin: true + + walker@1.0.8: + resolution: {integrity: sha512-ts/8E8l5b7kY0vlWLewOkDXMmPdLcVV4GmOQLyxuSswIJsweeFZtAsMF7k1Nszz+TYBQrlYRmzOnr398y1JemQ==} + + web-namespaces@2.0.1: + resolution: {integrity: sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ==} + + web-streams-polyfill@3.3.3: + resolution: {integrity: sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==} + engines: {node: '>= 8'} + + webidl-conversions@3.0.1: + resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==} + + webidl-conversions@7.0.0: + resolution: {integrity: sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==} + engines: {node: '>=12'} + + well-known-symbols@2.0.0: + resolution: {integrity: sha512-ZMjC3ho+KXo0BfJb7JgtQ5IBuvnShdlACNkKkdsqBmYw3bPAaJfPeYUo6tLUaT5tG/Gkh7xkpBhKRQ9e7pyg9Q==} + engines: {node: '>=6'} + + whatwg-encoding@2.0.0: + resolution: {integrity: sha512-p41ogyeMUrw3jWclHWTQg1k05DSVXPLcVxRTYsXUk+ZooOCZLcoYgPZ/HL/D/N+uQPOtcp1me1WhBEaX02mhWg==} + engines: {node: '>=12'} + + whatwg-mimetype@3.0.0: + resolution: {integrity: sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==} + engines: {node: '>=12'} + + whatwg-url@11.0.0: + resolution: {integrity: sha512-RKT8HExMpoYx4igMiVMY83lN6UeITKJlBQ+vR/8ZJ8OCdSiN3RwCq+9gH0+Xzj0+5IrM6i4j/6LuvzbZIQgEcQ==} + engines: {node: '>=12'} + + whatwg-url@5.0.0: + resolution: {integrity: sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==} + + which@1.3.1: + resolution: {integrity: sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ==} + hasBin: true + + which@2.0.2: + resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} + engines: {node: '>= 8'} + hasBin: true + + wide-align@1.1.5: + resolution: {integrity: sha512-eDMORYaPNZ4sQIuuYPDHdQvf4gyCF9rEEV/yPxGfwPkRodwEgiMUUXTx/dex+Me0wxx53S+NgUHaP7y3MGlDmg==} + + widest-line@4.0.1: + resolution: {integrity: sha512-o0cyEG0e8GPzT4iGHphIOh0cJOV8fivsXxddQasHPHfoZf1ZexrfeA21w2NaEN1RHE+fXlfISmOE8R9N3u3Qig==} + engines: {node: '>=12'} + + windows-release@5.1.1: + resolution: {integrity: sha512-NMD00arvqcq2nwqc5Q6KtrSRHK+fVD31erE5FEMahAw5PmVCgD7MUXodq3pdZSUkqA9Cda2iWx6s1XYwiJWRmw==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + + winston-transport@4.7.1: + resolution: {integrity: sha512-wQCXXVgfv/wUPOfb2x0ruxzwkcZfxcktz6JIMUaPLmcNhO4bZTwA/WtDWK74xV3F2dKu8YadrFv0qhwYjVEwhA==} + engines: {node: '>= 12.0.0'} + + winston@3.14.1: + resolution: {integrity: sha512-CJi4Il/msz8HkdDfXOMu+r5Au/oyEjFiOZzbX2d23hRLY0narGjqfE5lFlrT5hfYJhPtM8b85/GNFsxIML/RVA==} + engines: {node: '>= 12.0.0'} + + word-wrap@1.2.5: + resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} + engines: {node: '>=0.10.0'} + + wrap-ansi@7.0.0: + resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} + engines: {node: '>=10'} + + wrap-ansi@8.1.0: + resolution: {integrity: sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==} + engines: {node: '>=12'} + + wrap-ansi@9.0.0: + resolution: {integrity: sha512-G8ura3S+3Z2G+mkgNRq8dqaFZAuxfsxpBB8OCTGRTCtp+l/v9nbFNmCUP1BZMts3G1142MsZfn6eeUKrr4PD1Q==} + engines: {node: '>=18'} + + wrappy@1.0.2: + resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + + write-file-atomic@3.0.3: + resolution: {integrity: sha512-AvHcyZ5JnSfq3ioSyjrBkH9yW4m7Ayk8/9My/DD9onKeu/94fwrMocemO2QAJFAlnnDN+ZDS+ZjAR5ua1/PV/Q==} + + write-file-atomic@4.0.2: + resolution: {integrity: sha512-7KxauUdBmSdWnmpaGFg+ppNjKF8uNLry8LyzjauQDOVONfFLNKrKvQOxZ/VuTIcS/gge/YNahf5RIIQWTSarlg==} + engines: {node: ^12.13.0 || ^14.15.0 || >=16.0.0} + + write-file-atomic@5.0.1: + resolution: {integrity: sha512-+QU2zd6OTD8XWIJCbffaiQeH9U73qIqafo1x6V1snCWYGJf6cVE0cDR4D8xRzcEnfI21IFrUPzPGtcPf8AC+Rw==} + engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} + + ws@8.17.1: + resolution: {integrity: sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + + xdg-basedir@5.1.0: + resolution: {integrity: sha512-GCPAHLvrIH13+c0SuacwvRYj2SxJXQ4kaVTT5xgL3kPrz56XxkF21IGhjSE1+W0aw7gpBWRGXLCPnPby6lSpmQ==} + engines: {node: '>=12'} + + xml-name-validator@4.0.0: + resolution: {integrity: sha512-ICP2e+jsHvAj2E2lIHxa5tjXRlKDJo4IdvPvCXbXQGdzSfmSpNVyIKMvoZHjDY9DP0zV17iI85o90vRFXNccRw==} + engines: {node: '>=12'} + + xmlchars@2.2.0: + resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==} + + xss@1.0.15: + resolution: {integrity: sha512-FVdlVVC67WOIPvfOwhoMETV72f6GbW7aOabBC3WxN/oUdoEMDyLz4OgRv5/gck2ZeNqEQu+Tb0kloovXOfpYVg==} + engines: {node: '>= 0.10.0'} + hasBin: true + + xtend@4.0.2: + resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==} + engines: {node: '>=0.4'} + + y18n@5.0.8: + resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} + engines: {node: '>=10'} + + yallist@3.1.1: + resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} + + yallist@4.0.0: + resolution: {integrity: sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==} + + yaml@2.5.0: + resolution: {integrity: sha512-2wWLbGbYDiSqqIKoPjar3MPgB94ErzCtrNE1FdqGuaO0pi2JGjmE8aW8TDZwzU7vuxcGRdL/4gPQwQ7hD5AMSw==} + engines: {node: '>= 14'} + hasBin: true + + yargs-parser@21.1.1: + resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==} + engines: {node: '>=12'} + + yargs@17.7.2: + resolution: {integrity: sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==} + engines: {node: '>=12'} + + yauzl@2.10.0: + resolution: {integrity: sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==} + + yn@3.1.1: + resolution: {integrity: sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==} + engines: {node: '>=6'} + + yocto-queue@0.1.0: + resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} + engines: {node: '>=10'} + + yocto-queue@1.1.1: + resolution: {integrity: sha512-b4JR1PFR10y1mKjhHY9LaGo6tmrgjit7hxVIeAmyMw3jegXR4dhYqLaQF5zMXZxY7tLpMyJeLjr1C4rLmkVe8g==} + engines: {node: '>=12.20'} + + zip-stream@6.0.1: + resolution: {integrity: sha512-zK7YHHz4ZXpW89AHXUPbQVGKI7uvkd3hzusTdotCg1UxyaVtg0zFJSTfW/Dq5f7OBBVnq6cZIaC8Ti4hb6dtCA==} + engines: {node: '>= 14'} + + zod@3.23.8: + resolution: {integrity: sha512-XBx9AXhXktjUqnepgTiE5flcKIYWi/rme0Eaj+5Y0lftuGBq+jyRu/md4WnuxqgP1ubdpNCsYEYPxrzVHD8d6g==} + + zwitch@2.0.4: + resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==} + +snapshots: + + '@adobe/css-tools@4.4.0': {} + + '@alloc/quick-lru@5.2.0': {} + + '@ampproject/remapping@2.3.0': + dependencies: + '@jridgewell/gen-mapping': 0.3.5 + '@jridgewell/trace-mapping': 0.3.25 + + '@babel/code-frame@7.24.7': + dependencies: + '@babel/highlight': 7.24.7 + picocolors: 1.0.1 + + '@babel/compat-data@7.25.2': {} + + '@babel/core@7.25.2': + dependencies: + '@ampproject/remapping': 2.3.0 + '@babel/code-frame': 7.24.7 + '@babel/generator': 7.25.0 + '@babel/helper-compilation-targets': 7.25.2 + '@babel/helper-module-transforms': 7.25.2(@babel/core@7.25.2) + '@babel/helpers': 7.25.0 + '@babel/parser': 7.25.3 + '@babel/template': 7.25.0 + '@babel/traverse': 7.25.3 + '@babel/types': 7.25.2 + convert-source-map: 2.0.0 + debug: 4.3.6(supports-color@9.4.0) + gensync: 1.0.0-beta.2 + json5: 2.2.3 + semver: 6.3.1 + transitivePeerDependencies: + - supports-color + + '@babel/generator@7.25.0': + dependencies: + '@babel/types': 7.25.2 + '@jridgewell/gen-mapping': 0.3.5 + '@jridgewell/trace-mapping': 0.3.25 + jsesc: 2.5.2 + + '@babel/helper-compilation-targets@7.25.2': + dependencies: + '@babel/compat-data': 7.25.2 + '@babel/helper-validator-option': 7.24.8 + browserslist: 4.23.3 + lru-cache: 5.1.1 + semver: 6.3.1 + + '@babel/helper-module-imports@7.24.7': + dependencies: + '@babel/traverse': 7.25.3 + '@babel/types': 7.25.2 + transitivePeerDependencies: + - supports-color + + '@babel/helper-module-transforms@7.25.2(@babel/core@7.25.2)': + dependencies: + '@babel/core': 7.25.2 + '@babel/helper-module-imports': 7.24.7 + '@babel/helper-simple-access': 7.24.7 + '@babel/helper-validator-identifier': 7.24.7 + '@babel/traverse': 7.25.3 + transitivePeerDependencies: + - supports-color + + '@babel/helper-plugin-utils@7.24.8': {} + + '@babel/helper-simple-access@7.24.7': + dependencies: + '@babel/traverse': 7.25.3 + '@babel/types': 7.25.2 + transitivePeerDependencies: + - supports-color + + '@babel/helper-string-parser@7.24.8': {} + + '@babel/helper-validator-identifier@7.24.7': {} + + '@babel/helper-validator-option@7.24.8': {} + + '@babel/helpers@7.25.0': + dependencies: + '@babel/template': 7.25.0 + '@babel/types': 7.25.2 + + '@babel/highlight@7.24.7': + dependencies: + '@babel/helper-validator-identifier': 7.24.7 + chalk: 2.4.2 + js-tokens: 4.0.0 + picocolors: 1.0.1 + + '@babel/parser@7.25.3': + dependencies: + '@babel/types': 7.25.2 + + '@babel/plugin-syntax-async-generators@7.8.4(@babel/core@7.25.2)': + dependencies: + '@babel/core': 7.25.2 + '@babel/helper-plugin-utils': 7.24.8 + + '@babel/plugin-syntax-bigint@7.8.3(@babel/core@7.25.2)': + dependencies: + '@babel/core': 7.25.2 + '@babel/helper-plugin-utils': 7.24.8 + + '@babel/plugin-syntax-class-properties@7.12.13(@babel/core@7.25.2)': + dependencies: + '@babel/core': 7.25.2 + '@babel/helper-plugin-utils': 7.24.8 + + '@babel/plugin-syntax-import-meta@7.10.4(@babel/core@7.25.2)': + dependencies: + '@babel/core': 7.25.2 + '@babel/helper-plugin-utils': 7.24.8 + + '@babel/plugin-syntax-json-strings@7.8.3(@babel/core@7.25.2)': + dependencies: + '@babel/core': 7.25.2 + '@babel/helper-plugin-utils': 7.24.8 + + '@babel/plugin-syntax-jsx@7.24.7(@babel/core@7.25.2)': + dependencies: + '@babel/core': 7.25.2 + '@babel/helper-plugin-utils': 7.24.8 + + '@babel/plugin-syntax-logical-assignment-operators@7.10.4(@babel/core@7.25.2)': + dependencies: + '@babel/core': 7.25.2 + '@babel/helper-plugin-utils': 7.24.8 + + '@babel/plugin-syntax-nullish-coalescing-operator@7.8.3(@babel/core@7.25.2)': + dependencies: + '@babel/core': 7.25.2 + '@babel/helper-plugin-utils': 7.24.8 + + '@babel/plugin-syntax-numeric-separator@7.10.4(@babel/core@7.25.2)': + dependencies: + '@babel/core': 7.25.2 + '@babel/helper-plugin-utils': 7.24.8 + + '@babel/plugin-syntax-object-rest-spread@7.8.3(@babel/core@7.25.2)': + dependencies: + '@babel/core': 7.25.2 + '@babel/helper-plugin-utils': 7.24.8 + + '@babel/plugin-syntax-optional-catch-binding@7.8.3(@babel/core@7.25.2)': + dependencies: + '@babel/core': 7.25.2 + '@babel/helper-plugin-utils': 7.24.8 + + '@babel/plugin-syntax-optional-chaining@7.8.3(@babel/core@7.25.2)': + dependencies: + '@babel/core': 7.25.2 + '@babel/helper-plugin-utils': 7.24.8 + + '@babel/plugin-syntax-top-level-await@7.14.5(@babel/core@7.25.2)': + dependencies: + '@babel/core': 7.25.2 + '@babel/helper-plugin-utils': 7.24.8 + + '@babel/plugin-syntax-typescript@7.24.7(@babel/core@7.25.2)': + dependencies: + '@babel/core': 7.25.2 + '@babel/helper-plugin-utils': 7.24.8 + + '@babel/runtime@7.25.0': + dependencies: + regenerator-runtime: 0.14.1 + + '@babel/template@7.25.0': + dependencies: + '@babel/code-frame': 7.24.7 + '@babel/parser': 7.25.3 + '@babel/types': 7.25.2 + + '@babel/traverse@7.25.3': + dependencies: + '@babel/code-frame': 7.24.7 + '@babel/generator': 7.25.0 + '@babel/parser': 7.25.3 + '@babel/template': 7.25.0 + '@babel/types': 7.25.2 + debug: 4.3.6(supports-color@9.4.0) + globals: 11.12.0 + transitivePeerDependencies: + - supports-color + + '@babel/types@7.25.2': + dependencies: + '@babel/helper-string-parser': 7.24.8 + '@babel/helper-validator-identifier': 7.24.7 + to-fast-properties: 2.0.0 + + '@bcoe/v8-coverage@0.2.3': {} + + '@bugsnag/browser@7.25.0': + dependencies: + '@bugsnag/core': 7.25.0 + + '@bugsnag/core@7.25.0': + dependencies: + '@bugsnag/cuid': 3.1.1 + '@bugsnag/safe-json-stringify': 6.0.0 + error-stack-parser: 2.1.4 + iserror: 0.0.2 + stack-generator: 2.0.10 + + '@bugsnag/cuid@3.1.1': {} + + '@bugsnag/js@7.25.0': + dependencies: + '@bugsnag/browser': 7.25.0 + '@bugsnag/node': 7.25.0 + + '@bugsnag/node@7.25.0': + dependencies: + '@bugsnag/core': 7.25.0 + byline: 5.0.0 + error-stack-parser: 2.1.4 + iserror: 0.0.2 + pump: 3.0.0 + stack-generator: 2.0.10 + + '@bugsnag/safe-json-stringify@6.0.0': {} + + '@colors/colors@1.6.0': {} + + '@cspotcode/source-map-support@0.8.1': + dependencies: + '@jridgewell/trace-mapping': 0.3.9 + + '@csstools/css-parser-algorithms@3.0.0(@csstools/css-tokenizer@3.0.0)': + dependencies: + '@csstools/css-tokenizer': 3.0.0 + + '@csstools/css-tokenizer@3.0.0': {} + + '@csstools/media-query-list-parser@3.0.0(@csstools/css-parser-algorithms@3.0.0(@csstools/css-tokenizer@3.0.0))(@csstools/css-tokenizer@3.0.0)': + dependencies: + '@csstools/css-parser-algorithms': 3.0.0(@csstools/css-tokenizer@3.0.0) + '@csstools/css-tokenizer': 3.0.0 + + '@csstools/selector-specificity@4.0.0(postcss-selector-parser@6.1.2)': + dependencies: + postcss-selector-parser: 6.1.2 + + '@dabh/diagnostics@2.0.3': + dependencies: + colorspace: 1.1.4 + enabled: 2.0.0 + kuler: 2.0.0 + + '@dependents/detective-less@4.1.0': + dependencies: + gonzales-pe: 4.3.0 + node-source-walk: 6.0.2 + + '@dual-bundle/import-meta-resolve@4.1.0': {} + + '@esbuild/aix-ppc64@0.19.11': + optional: true + + '@esbuild/aix-ppc64@0.21.2': + optional: true + + '@esbuild/android-arm64@0.19.11': + optional: true + + '@esbuild/android-arm64@0.21.2': + optional: true + + '@esbuild/android-arm@0.19.11': + optional: true + + '@esbuild/android-arm@0.21.2': + optional: true + + '@esbuild/android-x64@0.19.11': + optional: true + + '@esbuild/android-x64@0.21.2': + optional: true + + '@esbuild/darwin-arm64@0.19.11': + optional: true + + '@esbuild/darwin-arm64@0.21.2': + optional: true + + '@esbuild/darwin-x64@0.19.11': + optional: true + + '@esbuild/darwin-x64@0.21.2': + optional: true + + '@esbuild/freebsd-arm64@0.19.11': + optional: true + + '@esbuild/freebsd-arm64@0.21.2': + optional: true + + '@esbuild/freebsd-x64@0.19.11': + optional: true + + '@esbuild/freebsd-x64@0.21.2': + optional: true + + '@esbuild/linux-arm64@0.19.11': + optional: true + + '@esbuild/linux-arm64@0.21.2': + optional: true + + '@esbuild/linux-arm@0.19.11': + optional: true + + '@esbuild/linux-arm@0.21.2': + optional: true + + '@esbuild/linux-ia32@0.19.11': + optional: true + + '@esbuild/linux-ia32@0.21.2': + optional: true + + '@esbuild/linux-loong64@0.19.11': + optional: true + + '@esbuild/linux-loong64@0.21.2': + optional: true + + '@esbuild/linux-mips64el@0.19.11': + optional: true + + '@esbuild/linux-mips64el@0.21.2': + optional: true + + '@esbuild/linux-ppc64@0.19.11': + optional: true + + '@esbuild/linux-ppc64@0.21.2': + optional: true + + '@esbuild/linux-riscv64@0.19.11': + optional: true + + '@esbuild/linux-riscv64@0.21.2': + optional: true + + '@esbuild/linux-s390x@0.19.11': + optional: true + + '@esbuild/linux-s390x@0.21.2': + optional: true + + '@esbuild/linux-x64@0.19.11': + optional: true + + '@esbuild/linux-x64@0.21.2': + optional: true + + '@esbuild/netbsd-x64@0.19.11': + optional: true + + '@esbuild/netbsd-x64@0.21.2': + optional: true + + '@esbuild/openbsd-x64@0.19.11': + optional: true + + '@esbuild/openbsd-x64@0.21.2': + optional: true + + '@esbuild/sunos-x64@0.19.11': + optional: true + + '@esbuild/sunos-x64@0.21.2': + optional: true + + '@esbuild/win32-arm64@0.19.11': + optional: true + + '@esbuild/win32-arm64@0.21.2': + optional: true + + '@esbuild/win32-ia32@0.19.11': + optional: true + + '@esbuild/win32-ia32@0.21.2': + optional: true + + '@esbuild/win32-x64@0.19.11': + optional: true + + '@esbuild/win32-x64@0.21.2': + optional: true + + '@eslint-community/eslint-utils@4.4.0(eslint@9.9.0(jiti@1.21.6))': + dependencies: + eslint: 9.9.0(jiti@1.21.6) + eslint-visitor-keys: 3.4.3 + + '@eslint-community/regexpp@4.11.0': {} + + '@eslint/config-array@0.17.1': + dependencies: + '@eslint/object-schema': 2.1.4 + debug: 4.3.6(supports-color@9.4.0) + minimatch: 3.1.2 + transitivePeerDependencies: + - supports-color + + '@eslint/eslintrc@3.1.0': + dependencies: + ajv: 6.12.6 + debug: 4.3.6(supports-color@9.4.0) + espree: 10.1.0 + globals: 14.0.0 + ignore: 5.3.1 + import-fresh: 3.3.0 + js-yaml: 4.1.0 + minimatch: 3.1.2 + strip-json-comments: 3.1.1 + transitivePeerDependencies: + - supports-color + + '@eslint/js@9.9.0': {} + + '@eslint/object-schema@2.1.4': {} + + '@fastify/accept-negotiator@1.1.0': {} + + '@fastify/ajv-compiler@3.6.0': + dependencies: + ajv: 8.17.1 + ajv-formats: 2.1.1(ajv@8.17.1) + fast-uri: 2.4.0 + + '@fastify/error@3.4.1': {} + + '@fastify/fast-json-stringify-compiler@4.3.0': + dependencies: + fast-json-stringify: 5.16.1 + + '@fastify/merge-json-schemas@0.1.1': + dependencies: + fast-deep-equal: 3.1.3 + + '@fastify/send@2.1.0': + dependencies: + '@lukeed/ms': 2.0.2 + escape-html: 1.0.3 + fast-decode-uri-component: 1.0.1 + http-errors: 2.0.0 + mime: 3.0.0 + + '@fastify/static@7.0.4': + dependencies: + '@fastify/accept-negotiator': 1.1.0 + '@fastify/send': 2.1.0 + content-disposition: 0.5.4 + fastify-plugin: 4.5.1 + fastq: 1.17.1 + glob: 10.4.5 + + '@humanwhocodes/module-importer@1.0.1': {} + + '@humanwhocodes/momoa@2.0.4': {} + + '@humanwhocodes/retry@0.3.0': {} + + '@iarna/toml@2.2.5': {} + + '@import-maps/resolve@1.0.1': {} + + '@isaacs/cliui@8.0.2': + dependencies: + string-width: 5.1.2 + string-width-cjs: string-width@4.2.3 + strip-ansi: 7.1.0 + strip-ansi-cjs: strip-ansi@6.0.1 + wrap-ansi: 8.1.0 + wrap-ansi-cjs: wrap-ansi@7.0.0 + + '@istanbuljs/load-nyc-config@1.1.0': + dependencies: + camelcase: 5.3.1 + find-up: 4.1.0 + get-package-type: 0.1.0 + js-yaml: 3.14.1 + resolve-from: 5.0.0 + + '@istanbuljs/schema@0.1.3': {} + + '@jest/console@29.7.0': + dependencies: + '@jest/types': 29.6.3 + '@types/node': 20.15.0 + chalk: 4.1.2 + jest-message-util: 29.7.0 + jest-util: 29.7.0 + slash: 3.0.0 + + '@jest/core@29.7.0(ts-node@10.9.2(@types/node@20.15.0)(typescript@5.5.4))': + dependencies: + '@jest/console': 29.7.0 + '@jest/reporters': 29.7.0 + '@jest/test-result': 29.7.0 + '@jest/transform': 29.7.0 + '@jest/types': 29.6.3 + '@types/node': 20.15.0 + ansi-escapes: 4.3.2 + chalk: 4.1.2 + ci-info: 3.9.0 + exit: 0.1.2 + graceful-fs: 4.2.11 + jest-changed-files: 29.7.0 + jest-config: 29.7.0(@types/node@20.15.0)(ts-node@10.9.2(@types/node@20.15.0)(typescript@5.5.4)) + jest-haste-map: 29.7.0 + jest-message-util: 29.7.0 + jest-regex-util: 29.6.3 + jest-resolve: 29.7.0 + jest-resolve-dependencies: 29.7.0 + jest-runner: 29.7.0 + jest-runtime: 29.7.0 + jest-snapshot: 29.7.0 + jest-util: 29.7.0 + jest-validate: 29.7.0 + jest-watcher: 29.7.0 + micromatch: 4.0.7 + pretty-format: 29.7.0 + slash: 3.0.0 + strip-ansi: 6.0.1 + transitivePeerDependencies: + - babel-plugin-macros + - supports-color + - ts-node + + '@jest/environment@29.7.0': + dependencies: + '@jest/fake-timers': 29.7.0 + '@jest/types': 29.6.3 + '@types/node': 20.15.0 + jest-mock: 29.7.0 + + '@jest/expect-utils@29.7.0': + dependencies: + jest-get-type: 29.6.3 + + '@jest/expect@29.7.0': + dependencies: + expect: 29.7.0 + jest-snapshot: 29.7.0 + transitivePeerDependencies: + - supports-color + + '@jest/fake-timers@29.7.0': + dependencies: + '@jest/types': 29.6.3 + '@sinonjs/fake-timers': 10.3.0 + '@types/node': 20.15.0 + jest-message-util: 29.7.0 + jest-mock: 29.7.0 + jest-util: 29.7.0 + + '@jest/globals@29.7.0': + dependencies: + '@jest/environment': 29.7.0 + '@jest/expect': 29.7.0 + '@jest/types': 29.6.3 + jest-mock: 29.7.0 + transitivePeerDependencies: + - supports-color + + '@jest/reporters@29.7.0': + dependencies: + '@bcoe/v8-coverage': 0.2.3 + '@jest/console': 29.7.0 + '@jest/test-result': 29.7.0 + '@jest/transform': 29.7.0 + '@jest/types': 29.6.3 + '@jridgewell/trace-mapping': 0.3.25 + '@types/node': 20.15.0 + chalk: 4.1.2 + collect-v8-coverage: 1.0.2 + exit: 0.1.2 + glob: 7.2.3 + graceful-fs: 4.2.11 + istanbul-lib-coverage: 3.2.2 + istanbul-lib-instrument: 6.0.3 + istanbul-lib-report: 3.0.1 + istanbul-lib-source-maps: 4.0.1 + istanbul-reports: 3.1.7 + jest-message-util: 29.7.0 + jest-util: 29.7.0 + jest-worker: 29.7.0 + slash: 3.0.0 + string-length: 4.0.2 + strip-ansi: 6.0.1 + v8-to-istanbul: 9.3.0 + transitivePeerDependencies: + - supports-color + + '@jest/schemas@29.6.3': + dependencies: + '@sinclair/typebox': 0.27.8 + + '@jest/source-map@29.6.3': + dependencies: + '@jridgewell/trace-mapping': 0.3.25 + callsites: 3.1.0 + graceful-fs: 4.2.11 + + '@jest/test-result@29.7.0': + dependencies: + '@jest/console': 29.7.0 + '@jest/types': 29.6.3 + '@types/istanbul-lib-coverage': 2.0.6 + collect-v8-coverage: 1.0.2 + + '@jest/test-sequencer@29.7.0': + dependencies: + '@jest/test-result': 29.7.0 + graceful-fs: 4.2.11 + jest-haste-map: 29.7.0 + slash: 3.0.0 + + '@jest/transform@29.7.0': + dependencies: + '@babel/core': 7.25.2 + '@jest/types': 29.6.3 + '@jridgewell/trace-mapping': 0.3.25 + babel-plugin-istanbul: 6.1.1 + chalk: 4.1.2 + convert-source-map: 2.0.0 + fast-json-stable-stringify: 2.1.0 + graceful-fs: 4.2.11 + jest-haste-map: 29.7.0 + jest-regex-util: 29.6.3 + jest-util: 29.7.0 + micromatch: 4.0.7 + pirates: 4.0.6 + slash: 3.0.0 + write-file-atomic: 4.0.2 + transitivePeerDependencies: + - supports-color + + '@jest/types@27.5.1': + dependencies: + '@types/istanbul-lib-coverage': 2.0.6 + '@types/istanbul-reports': 3.0.4 + '@types/node': 20.15.0 + '@types/yargs': 16.0.9 + chalk: 4.1.2 + + '@jest/types@29.6.3': + dependencies: + '@jest/schemas': 29.6.3 + '@types/istanbul-lib-coverage': 2.0.6 + '@types/istanbul-reports': 3.0.4 + '@types/node': 20.15.0 + '@types/yargs': 17.0.33 + chalk: 4.1.2 + + '@jridgewell/gen-mapping@0.3.5': + dependencies: + '@jridgewell/set-array': 1.2.1 + '@jridgewell/sourcemap-codec': 1.5.0 + '@jridgewell/trace-mapping': 0.3.25 + + '@jridgewell/resolve-uri@3.1.2': {} + + '@jridgewell/set-array@1.2.1': {} + + '@jridgewell/sourcemap-codec@1.5.0': {} + + '@jridgewell/trace-mapping@0.3.25': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.5.0 + + '@jridgewell/trace-mapping@0.3.9': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.5.0 + + '@lukeed/ms@2.0.2': {} + + '@mapbox/node-pre-gyp@1.0.11(supports-color@9.4.0)': + dependencies: + detect-libc: 2.0.3 + https-proxy-agent: 5.0.1(supports-color@9.4.0) + make-dir: 3.1.0 + node-fetch: 2.7.0 + nopt: 5.0.0 + npmlog: 5.0.1 + rimraf: 3.0.2 + semver: 7.6.3 + tar: 6.2.1 + transitivePeerDependencies: + - encoding + - supports-color + + '@netlify/binary-info@1.0.0': {} + + '@netlify/blobs@7.4.0': {} + + '@netlify/blobs@8.0.0': {} + + '@netlify/build-info@7.14.1': + dependencies: + '@bugsnag/js': 7.25.0 + '@iarna/toml': 2.2.5 + dot-prop: 7.2.0 + find-up: 6.3.0 + minimatch: 9.0.5 + read-pkg: 7.1.0 + semver: 7.6.3 + yaml: 2.5.0 + yargs: 17.7.2 + + '@netlify/build@29.53.0(@opentelemetry/api@1.8.0)(@types/node@20.15.0)(picomatch@4.0.2)': + dependencies: + '@bugsnag/js': 7.25.0 + '@netlify/blobs': 7.4.0 + '@netlify/cache-utils': 5.1.6 + '@netlify/config': 20.18.0 + '@netlify/edge-bundler': 12.2.3(supports-color@9.4.0) + '@netlify/framework-info': 9.8.13 + '@netlify/functions-utils': 5.2.77(supports-color@9.4.0) + '@netlify/git-utils': 5.1.1 + '@netlify/opentelemetry-utils': 1.2.1(@opentelemetry/api@1.8.0) + '@netlify/plugins-list': 6.80.0 + '@netlify/run-utils': 5.1.1 + '@netlify/zip-it-and-ship-it': 9.37.9(supports-color@9.4.0) + '@opentelemetry/api': 1.8.0 + '@sindresorhus/slugify': 2.2.1 + ansi-escapes: 6.2.1 + chalk: 5.3.0 + clean-stack: 4.2.0 + execa: 6.1.0 + fdir: 6.2.0(picomatch@4.0.2) + figures: 5.0.0 + filter-obj: 5.1.0 + got: 12.6.1 + hot-shots: 10.0.0 + indent-string: 5.0.0 + is-plain-obj: 4.1.0 + js-yaml: 4.1.0 + keep-func-props: 4.0.1 + locate-path: 7.2.0 + log-process-errors: 8.0.0 + map-obj: 5.0.2 + memoize-one: 6.0.0 + minimatch: 9.0.5 + node-fetch: 3.3.2 + os-name: 5.1.0 + p-event: 5.0.1 + p-every: 2.0.0 + p-filter: 3.0.0 + p-locate: 6.0.0 + p-map: 6.0.0 + p-reduce: 3.0.0 + path-exists: 5.0.0 + path-type: 5.0.0 + pkg-dir: 7.0.0 + pretty-ms: 8.0.0 + ps-list: 8.1.1 + read-package-up: 11.0.0 + readdirp: 3.6.0 + resolve: 2.0.0-next.5 + rfdc: 1.4.1 + safe-json-stringify: 1.2.0 + semver: 7.6.3 + string-width: 5.1.2 + strip-ansi: 7.1.0 + supports-color: 9.4.0 + terminal-link: 3.0.0 + ts-node: 10.9.2(@types/node@20.15.0)(typescript@5.5.4) + typescript: 5.5.4 + uuid: 9.0.1 + yargs: 17.7.2 + transitivePeerDependencies: + - '@swc/core' + - '@swc/wasm' + - '@types/node' + - encoding + - picomatch + + '@netlify/cache-utils@5.1.6': + dependencies: + cpy: 9.0.1 + get-stream: 6.0.1 + globby: 13.2.2 + junk: 4.0.1 + locate-path: 7.2.0 + move-file: 3.1.0 + path-exists: 5.0.0 + readdirp: 3.6.0 + + '@netlify/config@20.18.0': + dependencies: + '@iarna/toml': 2.2.5 + chalk: 5.3.0 + cron-parser: 4.9.0 + deepmerge: 4.3.1 + dot-prop: 7.2.0 + execa: 6.1.0 + fast-safe-stringify: 2.1.1 + figures: 5.0.0 + filter-obj: 5.1.0 + find-up: 6.3.0 + indent-string: 5.0.0 + is-plain-obj: 4.1.0 + js-yaml: 4.1.0 + map-obj: 5.0.2 + netlify: 13.1.20 + netlify-headers-parser: 7.1.4 + netlify-redirect-parser: 14.3.0 + node-fetch: 3.3.2 + omit.js: 2.0.2 + p-locate: 6.0.0 + path-type: 5.0.0 + tomlify-j0.4: 3.0.0 + validate-npm-package-name: 4.0.0 + yargs: 17.7.2 + + '@netlify/edge-bundler@12.2.3(supports-color@9.4.0)': + dependencies: + '@import-maps/resolve': 1.0.1 + '@vercel/nft': 0.27.3(supports-color@9.4.0) + ajv: 8.17.1 + ajv-errors: 3.0.0(ajv@8.17.1) + better-ajv-errors: 1.2.0(ajv@8.17.1) + common-path-prefix: 3.0.0 + env-paths: 3.0.0 + esbuild: 0.21.2 + execa: 6.1.0 + find-up: 6.3.0 + get-package-name: 2.2.0 + get-port: 6.1.2 + is-path-inside: 4.0.0 + jsonc-parser: 3.3.1 + node-fetch: 3.3.2 + node-stream-zip: 1.15.0 + p-retry: 5.1.2 + p-wait-for: 4.1.0 + path-key: 4.0.0 + semver: 7.6.3 + tmp-promise: 3.0.3 + urlpattern-polyfill: 8.0.2 + uuid: 9.0.1 + transitivePeerDependencies: + - encoding + - supports-color + + '@netlify/edge-functions@2.9.0': {} + + '@netlify/framework-info@9.8.13': + dependencies: + ajv: 8.17.1 + filter-obj: 5.1.0 + find-up: 6.3.0 + is-plain-obj: 4.1.0 + locate-path: 7.2.0 + p-filter: 3.0.0 + p-locate: 6.0.0 + process: 0.11.10 + read-pkg-up: 9.1.0 + semver: 7.6.3 + + '@netlify/functions-utils@5.2.77(supports-color@9.4.0)': + dependencies: + '@netlify/zip-it-and-ship-it': 9.37.9(supports-color@9.4.0) + cpy: 9.0.1 + path-exists: 5.0.0 + transitivePeerDependencies: + - encoding + - supports-color + + '@netlify/git-utils@5.1.1': + dependencies: + execa: 6.1.0 + map-obj: 5.0.2 + micromatch: 4.0.7 + moize: 6.1.6 + path-exists: 5.0.0 + + '@netlify/local-functions-proxy-darwin-arm64@1.1.1': + optional: true + + '@netlify/local-functions-proxy-darwin-x64@1.1.1': + optional: true + + '@netlify/local-functions-proxy-freebsd-arm64@1.1.1': + optional: true + + '@netlify/local-functions-proxy-freebsd-x64@1.1.1': + optional: true + + '@netlify/local-functions-proxy-linux-arm64@1.1.1': + optional: true + + '@netlify/local-functions-proxy-linux-arm@1.1.1': + optional: true + + '@netlify/local-functions-proxy-linux-ia32@1.1.1': + optional: true + + '@netlify/local-functions-proxy-linux-ppc64@1.1.1': + optional: true + + '@netlify/local-functions-proxy-linux-x64@1.1.1': + optional: true + + '@netlify/local-functions-proxy-openbsd-x64@1.1.1': + optional: true + + '@netlify/local-functions-proxy-win32-ia32@1.1.1': + optional: true + + '@netlify/local-functions-proxy-win32-x64@1.1.1': + optional: true + + '@netlify/local-functions-proxy@1.1.1': + optionalDependencies: + '@netlify/local-functions-proxy-darwin-arm64': 1.1.1 + '@netlify/local-functions-proxy-darwin-x64': 1.1.1 + '@netlify/local-functions-proxy-freebsd-arm64': 1.1.1 + '@netlify/local-functions-proxy-freebsd-x64': 1.1.1 + '@netlify/local-functions-proxy-linux-arm': 1.1.1 + '@netlify/local-functions-proxy-linux-arm64': 1.1.1 + '@netlify/local-functions-proxy-linux-ia32': 1.1.1 + '@netlify/local-functions-proxy-linux-ppc64': 1.1.1 + '@netlify/local-functions-proxy-linux-x64': 1.1.1 + '@netlify/local-functions-proxy-openbsd-x64': 1.1.1 + '@netlify/local-functions-proxy-win32-ia32': 1.1.1 + '@netlify/local-functions-proxy-win32-x64': 1.1.1 + + '@netlify/node-cookies@0.1.0': {} + + '@netlify/open-api@2.34.0': {} + + '@netlify/opentelemetry-utils@1.2.1(@opentelemetry/api@1.8.0)': + dependencies: + '@opentelemetry/api': 1.8.0 + + '@netlify/plugins-list@6.80.0': {} + + '@netlify/run-utils@5.1.1': + dependencies: + execa: 6.1.0 + + '@netlify/serverless-functions-api@1.22.0': + dependencies: + '@netlify/node-cookies': 0.1.0 + urlpattern-polyfill: 8.0.2 + + '@netlify/zip-it-and-ship-it@9.37.9(supports-color@9.4.0)': + dependencies: + '@babel/parser': 7.25.3 + '@babel/types': 7.25.2 + '@netlify/binary-info': 1.0.0 + '@netlify/serverless-functions-api': 1.22.0 + '@vercel/nft': 0.27.3(supports-color@9.4.0) + archiver: 7.0.1 + common-path-prefix: 3.0.0 + cp-file: 10.0.0 + es-module-lexer: 1.5.4 + esbuild: 0.19.11 + execa: 6.1.0 + fast-glob: 3.3.2 + filter-obj: 5.1.0 + find-up: 6.3.0 + glob: 8.1.0 + is-builtin-module: 3.2.1 + is-path-inside: 4.0.0 + junk: 4.0.1 + locate-path: 7.2.0 + merge-options: 3.0.4 + minimatch: 9.0.5 + normalize-path: 3.0.0 + p-map: 5.5.0 + path-exists: 5.0.0 + precinct: 11.0.5(supports-color@9.4.0) + require-package-name: 2.0.1 + resolve: 2.0.0-next.5 + semver: 7.6.3 + tmp-promise: 3.0.3 + toml: 3.0.0 + unixify: 1.0.0 + urlpattern-polyfill: 8.0.2 + yargs: 17.7.2 + zod: 3.23.8 + transitivePeerDependencies: + - encoding + - supports-color + + '@next/env@14.2.5': {} + + '@next/swc-darwin-arm64@14.2.5': + optional: true + + '@next/swc-darwin-x64@14.2.5': + optional: true + + '@next/swc-linux-arm64-gnu@14.2.5': + optional: true + + '@next/swc-linux-arm64-musl@14.2.5': + optional: true + + '@next/swc-linux-x64-gnu@14.2.5': + optional: true + + '@next/swc-linux-x64-musl@14.2.5': + optional: true + + '@next/swc-win32-arm64-msvc@14.2.5': + optional: true + + '@next/swc-win32-ia32-msvc@14.2.5': + optional: true + + '@next/swc-win32-x64-msvc@14.2.5': + optional: true + + '@nodelib/fs.scandir@2.1.5': + dependencies: + '@nodelib/fs.stat': 2.0.5 + run-parallel: 1.2.0 + + '@nodelib/fs.stat@2.0.5': {} + + '@nodelib/fs.walk@1.2.8': + dependencies: + '@nodelib/fs.scandir': 2.1.5 + fastq: 1.17.1 + + '@octokit/auth-token@4.0.0': {} + + '@octokit/core@5.2.0': + dependencies: + '@octokit/auth-token': 4.0.0 + '@octokit/graphql': 7.1.0 + '@octokit/request': 8.4.0 + '@octokit/request-error': 5.1.0 + '@octokit/types': 13.5.0 + before-after-hook: 2.2.3 + universal-user-agent: 6.0.1 + + '@octokit/endpoint@9.0.5': + dependencies: + '@octokit/types': 13.5.0 + universal-user-agent: 6.0.1 + + '@octokit/graphql@7.1.0': + dependencies: + '@octokit/request': 8.4.0 + '@octokit/types': 13.5.0 + universal-user-agent: 6.0.1 + + '@octokit/openapi-types@22.2.0': {} + + '@octokit/plugin-paginate-rest@11.3.1(@octokit/core@5.2.0)': + dependencies: + '@octokit/core': 5.2.0 + '@octokit/types': 13.5.0 + + '@octokit/plugin-request-log@4.0.1(@octokit/core@5.2.0)': + dependencies: + '@octokit/core': 5.2.0 + + '@octokit/plugin-rest-endpoint-methods@13.2.2(@octokit/core@5.2.0)': + dependencies: + '@octokit/core': 5.2.0 + '@octokit/types': 13.5.0 + + '@octokit/request-error@5.1.0': + dependencies: + '@octokit/types': 13.5.0 + deprecation: 2.3.1 + once: 1.4.0 + + '@octokit/request@8.4.0': + dependencies: + '@octokit/endpoint': 9.0.5 + '@octokit/request-error': 5.1.0 + '@octokit/types': 13.5.0 + universal-user-agent: 6.0.1 + + '@octokit/rest@20.1.1': + dependencies: + '@octokit/core': 5.2.0 + '@octokit/plugin-paginate-rest': 11.3.1(@octokit/core@5.2.0) + '@octokit/plugin-request-log': 4.0.1(@octokit/core@5.2.0) + '@octokit/plugin-rest-endpoint-methods': 13.2.2(@octokit/core@5.2.0) + + '@octokit/types@13.5.0': + dependencies: + '@octokit/openapi-types': 22.2.0 + + '@opentelemetry/api@1.8.0': {} + + '@parcel/watcher-android-arm64@2.4.1': + optional: true + + '@parcel/watcher-darwin-arm64@2.4.1': + optional: true + + '@parcel/watcher-darwin-x64@2.4.1': + optional: true + + '@parcel/watcher-freebsd-x64@2.4.1': + optional: true + + '@parcel/watcher-linux-arm-glibc@2.4.1': + optional: true + + '@parcel/watcher-linux-arm64-glibc@2.4.1': + optional: true + + '@parcel/watcher-linux-arm64-musl@2.4.1': + optional: true + + '@parcel/watcher-linux-x64-glibc@2.4.1': + optional: true + + '@parcel/watcher-linux-x64-musl@2.4.1': + optional: true + + '@parcel/watcher-wasm@2.4.1': + dependencies: + is-glob: 4.0.3 + micromatch: 4.0.7 + + '@parcel/watcher-win32-arm64@2.4.1': + optional: true + + '@parcel/watcher-win32-ia32@2.4.1': + optional: true + + '@parcel/watcher-win32-x64@2.4.1': + optional: true + + '@parcel/watcher@2.4.1': + dependencies: + detect-libc: 1.0.3 + is-glob: 4.0.3 + micromatch: 4.0.7 + node-addon-api: 7.1.1 + optionalDependencies: + '@parcel/watcher-android-arm64': 2.4.1 + '@parcel/watcher-darwin-arm64': 2.4.1 + '@parcel/watcher-darwin-x64': 2.4.1 + '@parcel/watcher-freebsd-x64': 2.4.1 + '@parcel/watcher-linux-arm-glibc': 2.4.1 + '@parcel/watcher-linux-arm64-glibc': 2.4.1 + '@parcel/watcher-linux-arm64-musl': 2.4.1 + '@parcel/watcher-linux-x64-glibc': 2.4.1 + '@parcel/watcher-linux-x64-musl': 2.4.1 + '@parcel/watcher-win32-arm64': 2.4.1 + '@parcel/watcher-win32-ia32': 2.4.1 + '@parcel/watcher-win32-x64': 2.4.1 + + '@pkgjs/parseargs@0.11.0': + optional: true + + '@playwright/test@1.46.1': + dependencies: + playwright: 1.46.1 + + '@pnpm/config.env-replace@1.1.0': {} + + '@pnpm/network.ca-file@1.0.2': + dependencies: + graceful-fs: 4.2.10 + + '@pnpm/npm-conf@2.3.1': + dependencies: + '@pnpm/config.env-replace': 1.1.0 + '@pnpm/network.ca-file': 1.0.2 + config-chain: 1.1.13 + + '@rollup/pluginutils@4.2.1': + dependencies: + estree-walker: 2.0.2 + picomatch: 2.3.1 + + '@shikijs/core@1.3.0': {} + + '@sinclair/typebox@0.27.8': {} + + '@sindresorhus/is@5.6.0': {} + + '@sindresorhus/slugify@2.2.1': + dependencies: + '@sindresorhus/transliterate': 1.6.0 + escape-string-regexp: 5.0.0 + + '@sindresorhus/transliterate@1.6.0': + dependencies: + escape-string-regexp: 5.0.0 + + '@sinonjs/commons@3.0.1': + dependencies: + type-detect: 4.0.8 + + '@sinonjs/fake-timers@10.3.0': + dependencies: + '@sinonjs/commons': 3.0.1 + + '@swc/counter@0.1.3': {} + + '@swc/helpers@0.5.5': + dependencies: + '@swc/counter': 0.1.3 + tslib: 2.6.3 + + '@szmarczak/http-timer@5.0.1': + dependencies: + defer-to-connect: 2.0.1 + + '@testing-library/dom@10.4.0': + dependencies: + '@babel/code-frame': 7.24.7 + '@babel/runtime': 7.25.0 + '@types/aria-query': 5.0.4 + aria-query: 5.3.0 + chalk: 4.1.2 + dom-accessibility-api: 0.5.16 + lz-string: 1.5.0 + pretty-format: 27.5.1 + + '@testing-library/jest-dom@6.4.8': + dependencies: + '@adobe/css-tools': 4.4.0 + '@babel/runtime': 7.25.0 + aria-query: 5.3.0 + chalk: 3.0.0 + css.escape: 1.5.1 + dom-accessibility-api: 0.6.3 + lodash: 4.17.21 + redent: 3.0.0 + + '@testing-library/react@16.0.0(@testing-library/dom@10.4.0)(@types/react-dom@18.3.0)(@types/react@18.3.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@babel/runtime': 7.25.0 + '@testing-library/dom': 10.4.0 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.3 + '@types/react-dom': 18.3.0 + + '@testing-library/user-event@14.5.2(@testing-library/dom@10.4.0)': + dependencies: + '@testing-library/dom': 10.4.0 + + '@tokenizer/token@0.3.0': {} + + '@tootallnate/once@2.0.0': {} + + '@trysound/sax@0.2.0': {} + + '@tsconfig/node10@1.0.11': {} + + '@tsconfig/node12@1.0.11': {} + + '@tsconfig/node14@1.0.3': {} + + '@tsconfig/node16@1.0.4': {} + + '@types/aria-query@5.0.4': {} + + '@types/babel__core@7.20.5': + dependencies: + '@babel/parser': 7.25.3 + '@babel/types': 7.25.2 + '@types/babel__generator': 7.6.8 + '@types/babel__template': 7.4.4 + '@types/babel__traverse': 7.20.6 + + '@types/babel__generator@7.6.8': + dependencies: + '@babel/types': 7.25.2 + + '@types/babel__template@7.4.4': + dependencies: + '@babel/parser': 7.25.3 + '@babel/types': 7.25.2 + + '@types/babel__traverse@7.20.6': + dependencies: + '@babel/types': 7.25.2 + + '@types/debug@4.1.12': + dependencies: + '@types/ms': 0.7.34 + + '@types/eslint@9.6.0': + dependencies: + '@types/estree': 1.0.5 + '@types/json-schema': 7.0.15 + + '@types/eslint__js@8.42.3': + dependencies: + '@types/eslint': 9.6.0 + + '@types/estree@1.0.5': {} + + '@types/graceful-fs@4.1.9': + dependencies: + '@types/node': 20.15.0 + + '@types/hast@3.0.4': + dependencies: + '@types/unist': 3.0.2 + + '@types/http-cache-semantics@4.0.4': {} + + '@types/http-proxy@1.17.15': + dependencies: + '@types/node': 20.15.0 + + '@types/istanbul-lib-coverage@2.0.6': {} + + '@types/istanbul-lib-report@3.0.3': + dependencies: + '@types/istanbul-lib-coverage': 2.0.6 + + '@types/istanbul-reports@3.0.4': + dependencies: + '@types/istanbul-lib-report': 3.0.3 + + '@types/jest@29.5.12': + dependencies: + expect: 29.7.0 + pretty-format: 29.7.0 + + '@types/jsdom@20.0.1': + dependencies: + '@types/node': 20.15.0 + '@types/tough-cookie': 4.0.5 + parse5: 7.1.2 + + '@types/json-schema@7.0.15': {} + + '@types/mdast@4.0.4': + dependencies: + '@types/unist': 3.0.2 + + '@types/ms@0.7.34': {} + + '@types/node@20.15.0': + dependencies: + undici-types: 6.13.0 + + '@types/normalize-package-data@2.4.4': {} + + '@types/prop-types@15.7.12': {} + + '@types/react-dom@18.3.0': + dependencies: + '@types/react': 18.3.3 + + '@types/react@18.3.3': + dependencies: + '@types/prop-types': 15.7.12 + csstype: 3.1.3 + + '@types/retry@0.12.1': {} + + '@types/stack-utils@2.0.3': {} + + '@types/tough-cookie@4.0.5': {} + + '@types/triple-beam@1.3.5': {} + + '@types/unist@3.0.2': {} + + '@types/yargs-parser@21.0.3': {} + + '@types/yargs@16.0.9': + dependencies: + '@types/yargs-parser': 21.0.3 + + '@types/yargs@17.0.33': + dependencies: + '@types/yargs-parser': 21.0.3 + + '@types/yauzl@2.10.3': + dependencies: + '@types/node': 20.15.0 + optional: true + + '@typescript-eslint/eslint-plugin@8.1.0(@typescript-eslint/parser@8.1.0(eslint@9.9.0(jiti@1.21.6))(typescript@5.5.4))(eslint@9.9.0(jiti@1.21.6))(typescript@5.5.4)': + dependencies: + '@eslint-community/regexpp': 4.11.0 + '@typescript-eslint/parser': 8.1.0(eslint@9.9.0(jiti@1.21.6))(typescript@5.5.4) + '@typescript-eslint/scope-manager': 8.1.0 + '@typescript-eslint/type-utils': 8.1.0(eslint@9.9.0(jiti@1.21.6))(typescript@5.5.4) + '@typescript-eslint/utils': 8.1.0(eslint@9.9.0(jiti@1.21.6))(typescript@5.5.4) + '@typescript-eslint/visitor-keys': 8.1.0 + eslint: 9.9.0(jiti@1.21.6) + graphemer: 1.4.0 + ignore: 5.3.2 + natural-compare: 1.4.0 + ts-api-utils: 1.3.0(typescript@5.5.4) + optionalDependencies: + typescript: 5.5.4 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/parser@8.1.0(eslint@9.9.0(jiti@1.21.6))(typescript@5.5.4)': + dependencies: + '@typescript-eslint/scope-manager': 8.1.0 + '@typescript-eslint/types': 8.1.0 + '@typescript-eslint/typescript-estree': 8.1.0(typescript@5.5.4) + '@typescript-eslint/visitor-keys': 8.1.0 + debug: 4.3.6(supports-color@9.4.0) + eslint: 9.9.0(jiti@1.21.6) + optionalDependencies: + typescript: 5.5.4 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/scope-manager@8.1.0': + dependencies: + '@typescript-eslint/types': 8.1.0 + '@typescript-eslint/visitor-keys': 8.1.0 + + '@typescript-eslint/type-utils@8.1.0(eslint@9.9.0(jiti@1.21.6))(typescript@5.5.4)': + dependencies: + '@typescript-eslint/typescript-estree': 8.1.0(typescript@5.5.4) + '@typescript-eslint/utils': 8.1.0(eslint@9.9.0(jiti@1.21.6))(typescript@5.5.4) + debug: 4.3.6(supports-color@9.4.0) + ts-api-utils: 1.3.0(typescript@5.5.4) + optionalDependencies: + typescript: 5.5.4 + transitivePeerDependencies: + - eslint + - supports-color + + '@typescript-eslint/types@5.62.0': {} + + '@typescript-eslint/types@8.1.0': {} + + '@typescript-eslint/typescript-estree@5.62.0(supports-color@9.4.0)(typescript@5.5.4)': + dependencies: + '@typescript-eslint/types': 5.62.0 + '@typescript-eslint/visitor-keys': 5.62.0 + debug: 4.3.6(supports-color@9.4.0) + globby: 11.1.0 + is-glob: 4.0.3 + semver: 7.6.3 + tsutils: 3.21.0(typescript@5.5.4) + optionalDependencies: + typescript: 5.5.4 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/typescript-estree@8.1.0(typescript@5.5.4)': + dependencies: + '@typescript-eslint/types': 8.1.0 + '@typescript-eslint/visitor-keys': 8.1.0 + debug: 4.3.6(supports-color@9.4.0) + globby: 11.1.0 + is-glob: 4.0.3 + minimatch: 9.0.5 + semver: 7.6.3 + ts-api-utils: 1.3.0(typescript@5.5.4) + optionalDependencies: + typescript: 5.5.4 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/utils@8.1.0(eslint@9.9.0(jiti@1.21.6))(typescript@5.5.4)': + dependencies: + '@eslint-community/eslint-utils': 4.4.0(eslint@9.9.0(jiti@1.21.6)) + '@typescript-eslint/scope-manager': 8.1.0 + '@typescript-eslint/types': 8.1.0 + '@typescript-eslint/typescript-estree': 8.1.0(typescript@5.5.4) + eslint: 9.9.0(jiti@1.21.6) + transitivePeerDependencies: + - supports-color + - typescript + + '@typescript-eslint/visitor-keys@5.62.0': + dependencies: + '@typescript-eslint/types': 5.62.0 + eslint-visitor-keys: 3.4.3 + + '@typescript-eslint/visitor-keys@8.1.0': + dependencies: + '@typescript-eslint/types': 8.1.0 + eslint-visitor-keys: 3.4.3 + + '@ungap/structured-clone@1.2.0': {} + + '@vercel/nft@0.27.3(supports-color@9.4.0)': + dependencies: + '@mapbox/node-pre-gyp': 1.0.11(supports-color@9.4.0) + '@rollup/pluginutils': 4.2.1 + acorn: 8.12.1 + acorn-import-attributes: 1.9.5(acorn@8.12.1) + async-sema: 3.1.1 + bindings: 1.5.0 + estree-walker: 2.0.2 + glob: 7.2.3 + graceful-fs: 4.2.11 + micromatch: 4.0.7 + node-gyp-build: 4.8.1 + resolve-from: 5.0.0 + transitivePeerDependencies: + - encoding + - supports-color + + '@xhmikosr/archive-type@6.0.1': + dependencies: + file-type: 18.7.0 + + '@xhmikosr/decompress-tar@7.0.0': + dependencies: + file-type: 18.7.0 + is-stream: 3.0.0 + tar-stream: 3.1.7 + + '@xhmikosr/decompress-tarbz2@7.0.0': + dependencies: + '@xhmikosr/decompress-tar': 7.0.0 + file-type: 18.7.0 + is-stream: 3.0.0 + seek-bzip: 1.0.6 + unbzip2-stream: 1.4.3 + + '@xhmikosr/decompress-targz@7.0.0': + dependencies: + '@xhmikosr/decompress-tar': 7.0.0 + file-type: 18.7.0 + is-stream: 3.0.0 + + '@xhmikosr/decompress-unzip@6.0.0': + dependencies: + file-type: 18.7.0 + get-stream: 6.0.1 + yauzl: 2.10.0 + + '@xhmikosr/decompress@9.0.1': + dependencies: + '@xhmikosr/decompress-tar': 7.0.0 + '@xhmikosr/decompress-tarbz2': 7.0.0 + '@xhmikosr/decompress-targz': 7.0.0 + '@xhmikosr/decompress-unzip': 6.0.0 + graceful-fs: 4.2.11 + make-dir: 4.0.0 + strip-dirs: 3.0.0 + + '@xhmikosr/downloader@13.0.1': + dependencies: + '@xhmikosr/archive-type': 6.0.1 + '@xhmikosr/decompress': 9.0.1 + content-disposition: 0.5.4 + ext-name: 5.0.0 + file-type: 18.7.0 + filenamify: 5.1.1 + get-stream: 6.0.1 + got: 12.6.1 + merge-options: 3.0.4 + p-event: 5.0.1 + + abab@2.0.6: {} + + abbrev@1.1.1: {} + + abort-controller@3.0.0: + dependencies: + event-target-shim: 5.0.1 + + abstract-logging@2.0.1: {} + + accepts@1.3.8: + dependencies: + mime-types: 2.1.35 + negotiator: 0.6.3 + + acorn-globals@7.0.1: + dependencies: + acorn: 8.12.1 + acorn-walk: 8.3.3 + + acorn-import-attributes@1.9.5(acorn@8.12.1): + dependencies: + acorn: 8.12.1 + + acorn-jsx@5.3.2(acorn@8.12.1): + dependencies: + acorn: 8.12.1 + + acorn-walk@8.3.3: + dependencies: + acorn: 8.12.1 + + acorn@8.12.1: {} + + agent-base@6.0.2(supports-color@9.4.0): + dependencies: + debug: 4.3.6(supports-color@9.4.0) + transitivePeerDependencies: + - supports-color + + agent-base@7.1.1: + dependencies: + debug: 4.3.6(supports-color@9.4.0) + transitivePeerDependencies: + - supports-color + + aggregate-error@4.0.1: + dependencies: + clean-stack: 4.2.0 + indent-string: 5.0.0 + + ajv-errors@3.0.0(ajv@8.17.1): + dependencies: + ajv: 8.17.1 + + ajv-formats@2.1.1(ajv@8.17.1): + optionalDependencies: + ajv: 8.17.1 + + ajv-formats@3.0.1(ajv@8.17.1): + optionalDependencies: + ajv: 8.17.1 + + ajv@6.12.6: + dependencies: + fast-deep-equal: 3.1.3 + fast-json-stable-stringify: 2.1.0 + json-schema-traverse: 0.4.1 + uri-js: 4.4.1 + + ajv@8.17.1: + dependencies: + fast-deep-equal: 3.1.3 + fast-uri: 3.0.1 + json-schema-traverse: 1.0.0 + require-from-string: 2.0.2 + + all-node-versions@11.3.0: + dependencies: + fetch-node-website: 7.3.0 + filter-obj: 5.1.0 + get-stream: 6.0.1 + global-cache-dir: 4.4.0 + is-plain-obj: 4.1.0 + path-exists: 5.0.0 + semver: 7.6.3 + write-file-atomic: 4.0.2 + + ansi-align@3.0.1: + dependencies: + string-width: 4.2.3 + + ansi-escapes@3.2.0: {} + + ansi-escapes@4.3.2: + dependencies: + type-fest: 0.21.3 + + ansi-escapes@5.0.0: + dependencies: + type-fest: 1.4.0 + + ansi-escapes@6.2.1: {} + + ansi-escapes@7.0.0: + dependencies: + environment: 1.1.0 + + ansi-regex@3.0.1: {} + + ansi-regex@4.1.1: {} + + ansi-regex@5.0.1: {} + + ansi-regex@6.0.1: {} + + ansi-styles@3.2.1: + dependencies: + color-convert: 1.9.3 + + ansi-styles@4.3.0: + dependencies: + color-convert: 2.0.1 + + ansi-styles@5.2.0: {} + + ansi-styles@6.2.1: {} + + ansi-to-html@0.7.2: + dependencies: + entities: 2.2.0 + + any-promise@1.3.0: {} + + anymatch@3.1.3: + dependencies: + normalize-path: 3.0.0 + picomatch: 2.3.1 + + aproba@2.0.0: {} + + archiver-utils@5.0.2: + dependencies: + glob: 10.4.5 + graceful-fs: 4.2.11 + is-stream: 2.0.1 + lazystream: 1.0.1 + lodash: 4.17.21 + normalize-path: 3.0.0 + readable-stream: 4.5.2 + + archiver@7.0.1: + dependencies: + archiver-utils: 5.0.2 + async: 3.2.5 + buffer-crc32: 1.0.0 + readable-stream: 4.5.2 + readdir-glob: 1.1.3 + tar-stream: 3.1.7 + zip-stream: 6.0.1 + + are-we-there-yet@2.0.0: + dependencies: + delegates: 1.0.0 + readable-stream: 3.6.2 + + arg@4.1.3: {} + + arg@5.0.2: {} + + argparse@1.0.10: + dependencies: + sprintf-js: 1.0.3 + + argparse@2.0.1: {} + + aria-query@5.3.0: + dependencies: + dequal: 2.0.3 + + array-flatten@1.1.1: {} + + array-timsort@1.0.3: {} + + array-union@2.1.0: {} + + arrify@3.0.0: {} + + ascii-table@0.0.9: {} + + ast-module-types@5.0.0: {} + + astral-regex@2.0.0: {} + + async-sema@3.1.1: {} + + async@1.5.2: {} + + async@3.2.5: {} + + asynckit@0.4.0: {} + + atomic-sleep@1.0.0: {} + + autoprefixer@10.4.20(postcss@8.4.41): + dependencies: + browserslist: 4.23.3 + caniuse-lite: 1.0.30001647 + fraction.js: 4.3.7 + normalize-range: 0.1.2 + picocolors: 1.0.1 + postcss: 8.4.41 + postcss-value-parser: 4.2.0 + + avvio@8.4.0: + dependencies: + '@fastify/error': 3.4.1 + fastq: 1.17.1 + + b4a@1.6.6: {} + + babel-jest@29.7.0(@babel/core@7.25.2): + dependencies: + '@babel/core': 7.25.2 + '@jest/transform': 29.7.0 + '@types/babel__core': 7.20.5 + babel-plugin-istanbul: 6.1.1 + babel-preset-jest: 29.6.3(@babel/core@7.25.2) + chalk: 4.1.2 + graceful-fs: 4.2.11 + slash: 3.0.0 + transitivePeerDependencies: + - supports-color + + babel-plugin-istanbul@6.1.1: + dependencies: + '@babel/helper-plugin-utils': 7.24.8 + '@istanbuljs/load-nyc-config': 1.1.0 + '@istanbuljs/schema': 0.1.3 + istanbul-lib-instrument: 5.2.1 + test-exclude: 6.0.0 + transitivePeerDependencies: + - supports-color + + babel-plugin-jest-hoist@29.6.3: + dependencies: + '@babel/template': 7.25.0 + '@babel/types': 7.25.2 + '@types/babel__core': 7.20.5 + '@types/babel__traverse': 7.20.6 + + babel-preset-current-node-syntax@1.0.1(@babel/core@7.25.2): + dependencies: + '@babel/core': 7.25.2 + '@babel/plugin-syntax-async-generators': 7.8.4(@babel/core@7.25.2) + '@babel/plugin-syntax-bigint': 7.8.3(@babel/core@7.25.2) + '@babel/plugin-syntax-class-properties': 7.12.13(@babel/core@7.25.2) + '@babel/plugin-syntax-import-meta': 7.10.4(@babel/core@7.25.2) + '@babel/plugin-syntax-json-strings': 7.8.3(@babel/core@7.25.2) + '@babel/plugin-syntax-logical-assignment-operators': 7.10.4(@babel/core@7.25.2) + '@babel/plugin-syntax-nullish-coalescing-operator': 7.8.3(@babel/core@7.25.2) + '@babel/plugin-syntax-numeric-separator': 7.10.4(@babel/core@7.25.2) + '@babel/plugin-syntax-object-rest-spread': 7.8.3(@babel/core@7.25.2) + '@babel/plugin-syntax-optional-catch-binding': 7.8.3(@babel/core@7.25.2) + '@babel/plugin-syntax-optional-chaining': 7.8.3(@babel/core@7.25.2) + '@babel/plugin-syntax-top-level-await': 7.14.5(@babel/core@7.25.2) + + babel-preset-jest@29.6.3(@babel/core@7.25.2): + dependencies: + '@babel/core': 7.25.2 + babel-plugin-jest-hoist: 29.6.3 + babel-preset-current-node-syntax: 1.0.1(@babel/core@7.25.2) + + backoff@2.5.0: + dependencies: + precond: 0.2.3 + + bail@2.0.2: {} + + balanced-match@1.0.2: {} + + balanced-match@2.0.0: {} + + bare-events@2.4.2: + optional: true + + bare-fs@2.3.1: + dependencies: + bare-events: 2.4.2 + bare-path: 2.1.3 + bare-stream: 2.1.3 + optional: true + + bare-os@2.4.0: + optional: true + + bare-path@2.1.3: + dependencies: + bare-os: 2.4.0 + optional: true + + bare-stream@2.1.3: + dependencies: + streamx: 2.18.0 + optional: true + + base64-js@1.5.1: {} + + before-after-hook@2.2.3: {} + + better-ajv-errors@1.2.0(ajv@8.17.1): + dependencies: + '@babel/code-frame': 7.24.7 + '@humanwhocodes/momoa': 2.0.4 + ajv: 8.17.1 + chalk: 4.1.2 + jsonpointer: 5.0.1 + leven: 3.1.0 + + better-opn@3.0.2: + dependencies: + open: 8.4.2 + + binary-extensions@2.3.0: {} + + bindings@1.5.0: + dependencies: + file-uri-to-path: 1.0.0 + + bl@4.1.0: + dependencies: + buffer: 5.7.1 + inherits: 2.0.4 + readable-stream: 3.6.2 + + blueimp-md5@2.19.0: {} + + body-parser@1.20.2: + dependencies: + bytes: 3.1.2 + content-type: 1.0.5 + debug: 2.6.9 + depd: 2.0.0 + destroy: 1.2.0 + http-errors: 2.0.0 + iconv-lite: 0.4.24 + on-finished: 2.4.1 + qs: 6.11.0 + raw-body: 2.5.2 + type-is: 1.6.18 + unpipe: 1.0.0 + transitivePeerDependencies: + - supports-color + + boolbase@1.0.0: {} + + boxen@7.1.1: + dependencies: + ansi-align: 3.0.1 + camelcase: 7.0.1 + chalk: 5.3.0 + cli-boxes: 3.0.0 + string-width: 5.1.2 + type-fest: 2.19.0 + widest-line: 4.0.1 + wrap-ansi: 8.1.0 + + brace-expansion@1.1.11: + dependencies: + balanced-match: 1.0.2 + concat-map: 0.0.1 + + brace-expansion@2.0.1: + dependencies: + balanced-match: 1.0.2 + + braces@3.0.3: + dependencies: + fill-range: 7.1.1 + + browserslist@4.23.3: + dependencies: + caniuse-lite: 1.0.30001647 + electron-to-chromium: 1.5.4 + node-releases: 2.0.18 + update-browserslist-db: 1.1.0(browserslist@4.23.3) + + bser@2.1.1: + dependencies: + node-int64: 0.4.0 + + buffer-crc32@0.2.13: {} + + buffer-crc32@1.0.0: {} + + buffer-equal-constant-time@1.0.1: {} + + buffer-from@1.1.2: {} + + buffer@5.7.1: + dependencies: + base64-js: 1.5.1 + ieee754: 1.2.1 + + buffer@6.0.3: + dependencies: + base64-js: 1.5.1 + ieee754: 1.2.1 + + builtin-modules@3.3.0: {} + + builtins@5.1.0: + dependencies: + semver: 7.6.3 + + busboy@1.6.0: + dependencies: + streamsearch: 1.1.0 + + byline@5.0.0: {} + + bytes@3.1.2: {} + + cacheable-lookup@7.0.0: {} + + cacheable-request@10.2.14: + dependencies: + '@types/http-cache-semantics': 4.0.4 + get-stream: 6.0.1 + http-cache-semantics: 4.1.1 + keyv: 4.5.4 + mimic-response: 4.0.0 + normalize-url: 8.0.1 + responselike: 3.0.0 + + cachedir@2.4.0: {} + + call-bind@1.0.7: + dependencies: + es-define-property: 1.0.0 + es-errors: 1.3.0 + function-bind: 1.1.2 + get-intrinsic: 1.2.4 + set-function-length: 1.2.2 + + callsite@1.0.0: {} + + callsites@3.1.0: {} + + camelcase-css@2.0.1: {} + + camelcase@5.3.1: {} + + camelcase@6.3.0: {} + + camelcase@7.0.1: {} + + caniuse-lite@1.0.30001642: {} + + caniuse-lite@1.0.30001647: {} + + ccount@2.0.1: {} + + chalk@2.4.2: + dependencies: + ansi-styles: 3.2.1 + escape-string-regexp: 1.0.5 + supports-color: 5.5.0 + + chalk@3.0.0: + dependencies: + ansi-styles: 4.3.0 + supports-color: 7.2.0 + + chalk@4.1.2: + dependencies: + ansi-styles: 4.3.0 + supports-color: 7.2.0 + + chalk@5.3.0: {} + + char-regex@1.0.2: {} + + character-entities-html4@2.1.0: {} + + character-entities-legacy@3.0.0: {} + + character-entities@2.0.2: {} + + chardet@0.7.0: {} + + chokidar@3.6.0: + dependencies: + anymatch: 3.1.3 + braces: 3.0.3 + glob-parent: 5.1.2 + is-binary-path: 2.1.0 + is-glob: 4.0.3 + normalize-path: 3.0.0 + readdirp: 3.6.0 + optionalDependencies: + fsevents: 2.3.3 + + chownr@1.1.4: {} + + chownr@2.0.0: {} + + ci-info@3.9.0: {} + + ci-info@4.0.0: {} + + citty@0.1.6: + dependencies: + consola: 3.2.3 + + cjs-module-lexer@1.3.1: {} + + classnames@2.5.1: {} + + clean-deep@3.4.0: + dependencies: + lodash.isempty: 4.4.0 + lodash.isplainobject: 4.0.6 + lodash.transform: 4.6.0 + + clean-stack@4.2.0: + dependencies: + escape-string-regexp: 5.0.0 + + cli-boxes@3.0.0: {} + + cli-cursor@2.1.0: + dependencies: + restore-cursor: 2.0.0 + + cli-cursor@4.0.0: + dependencies: + restore-cursor: 4.0.0 + + cli-cursor@5.0.0: + dependencies: + restore-cursor: 5.1.0 + + cli-progress@3.12.0: + dependencies: + string-width: 4.2.3 + + cli-spinners@2.9.2: {} + + cli-truncate@4.0.0: + dependencies: + slice-ansi: 5.0.0 + string-width: 7.2.0 + + cli-width@2.2.1: {} + + client-only@0.0.1: {} + + clipboardy@4.0.0: + dependencies: + execa: 8.0.1 + is-wsl: 3.1.0 + is64bit: 2.0.0 + + cliui@8.0.1: + dependencies: + string-width: 4.2.3 + strip-ansi: 6.0.1 + wrap-ansi: 7.0.0 + + co@4.6.0: {} + + collect-v8-coverage@1.0.2: {} + + color-convert@1.9.3: + dependencies: + color-name: 1.1.3 + + color-convert@2.0.1: + dependencies: + color-name: 1.1.4 + + color-name@1.1.3: {} + + color-name@1.1.4: {} + + color-string@1.9.1: + dependencies: + color-name: 1.1.4 + simple-swizzle: 0.2.2 + + color-support@1.1.3: {} + + color@3.2.1: + dependencies: + color-convert: 1.9.3 + color-string: 1.9.1 + + color@4.2.3: + dependencies: + color-convert: 2.0.1 + color-string: 1.9.1 + + colord@2.9.3: {} + + colorette@2.0.20: {} + + colors-option@3.0.0: + dependencies: + chalk: 5.3.0 + filter-obj: 3.0.0 + is-plain-obj: 4.1.0 + jest-validate: 27.5.1 + + colors-option@4.5.0: + dependencies: + chalk: 5.3.0 + is-plain-obj: 4.1.0 + + colors@1.4.0: {} + + colorspace@1.1.4: + dependencies: + color: 3.2.1 + text-hex: 1.0.0 + + combined-stream@1.0.8: + dependencies: + delayed-stream: 1.0.0 + + comma-separated-tokens@2.0.3: {} + + commander@10.0.1: {} + + commander@12.1.0: {} + + commander@2.20.3: {} + + commander@4.1.1: {} + + commander@7.2.0: {} + + commander@9.5.0: {} + + comment-json@4.2.5: + dependencies: + array-timsort: 1.0.3 + core-util-is: 1.0.3 + esprima: 4.0.1 + has-own-prop: 2.0.0 + repeat-string: 1.6.1 + + common-path-prefix@3.0.0: {} + + compress-commons@6.0.2: + dependencies: + crc-32: 1.2.2 + crc32-stream: 6.0.0 + is-stream: 2.0.1 + normalize-path: 3.0.0 + readable-stream: 4.5.2 + + concat-map@0.0.1: {} + + concordance@5.0.4: + dependencies: + date-time: 3.1.0 + esutils: 2.0.3 + fast-diff: 1.3.0 + js-string-escape: 1.0.1 + lodash: 4.17.21 + md5-hex: 3.0.1 + semver: 7.6.3 + well-known-symbols: 2.0.0 + + confbox@0.1.7: {} + + config-chain@1.1.13: + dependencies: + ini: 1.3.8 + proto-list: 1.2.4 + + configstore@6.0.0: + dependencies: + dot-prop: 6.0.1 + graceful-fs: 4.2.11 + unique-string: 3.0.0 + write-file-atomic: 3.0.3 + xdg-basedir: 5.1.0 + + consola@3.2.3: {} + + console-control-strings@1.1.0: {} + + content-disposition@0.5.4: + dependencies: + safe-buffer: 5.2.1 + + content-type@1.0.5: {} + + convert-source-map@2.0.0: {} + + cookie-es@1.2.2: {} + + cookie-signature@1.0.6: {} + + cookie@0.6.0: {} + + core-util-is@1.0.3: {} + + cosmiconfig@9.0.0(typescript@5.5.4): + dependencies: + env-paths: 2.2.1 + import-fresh: 3.3.0 + js-yaml: 4.1.0 + parse-json: 5.2.0 + optionalDependencies: + typescript: 5.5.4 + + cp-file@10.0.0: + dependencies: + graceful-fs: 4.2.11 + nested-error-stacks: 2.1.1 + p-event: 5.0.1 + + cp-file@9.1.0: + dependencies: + graceful-fs: 4.2.11 + make-dir: 3.1.0 + nested-error-stacks: 2.1.1 + p-event: 4.2.0 + + cpy@9.0.1: + dependencies: + arrify: 3.0.0 + cp-file: 9.1.0 + globby: 13.2.2 + junk: 4.0.1 + micromatch: 4.0.7 + nested-error-stacks: 2.1.1 + p-filter: 3.0.0 + p-map: 5.5.0 + + crc-32@1.2.2: {} + + crc32-stream@6.0.0: + dependencies: + crc-32: 1.2.2 + readable-stream: 4.5.2 + + create-jest@29.7.0(@types/node@20.15.0)(ts-node@10.9.2(@types/node@20.15.0)(typescript@5.5.4)): + dependencies: + '@jest/types': 29.6.3 + chalk: 4.1.2 + exit: 0.1.2 + graceful-fs: 4.2.11 + jest-config: 29.7.0(@types/node@20.15.0)(ts-node@10.9.2(@types/node@20.15.0)(typescript@5.5.4)) + jest-util: 29.7.0 + prompts: 2.4.2 + transitivePeerDependencies: + - '@types/node' + - babel-plugin-macros + - supports-color + - ts-node + + create-require@1.1.1: {} + + cron-parser@4.9.0: + dependencies: + luxon: 3.5.0 + + cross-spawn@7.0.3: + dependencies: + path-key: 3.1.1 + shebang-command: 2.0.0 + which: 2.0.2 + + crossws@0.2.4: {} + + crypto-random-string@4.0.0: + dependencies: + type-fest: 1.4.0 + + css-functions-list@3.2.2: {} + + css-select@5.1.0: + dependencies: + boolbase: 1.0.0 + css-what: 6.1.0 + domhandler: 5.0.3 + domutils: 3.1.0 + nth-check: 2.1.1 + + css-tree@2.2.1: + dependencies: + mdn-data: 2.0.28 + source-map-js: 1.2.0 + + css-tree@2.3.1: + dependencies: + mdn-data: 2.0.30 + source-map-js: 1.2.0 + + css-what@6.1.0: {} + + css.escape@1.5.1: {} + + cssesc@3.0.0: {} + + cssfilter@0.0.10: {} + + csso@5.0.5: + dependencies: + css-tree: 2.2.1 + + cssom@0.3.8: {} + + cssom@0.5.0: {} + + cssstyle@2.3.0: + dependencies: + cssom: 0.3.8 + + csstype@3.1.3: {} + + cyclist@1.0.2: {} + + data-uri-to-buffer@4.0.1: {} + + data-urls@3.0.2: + dependencies: + abab: 2.0.6 + whatwg-mimetype: 3.0.0 + whatwg-url: 11.0.0 + + date-time@3.1.0: + dependencies: + time-zone: 1.0.0 + + debug@2.6.9: + dependencies: + ms: 2.0.0 + + debug@4.3.6(supports-color@9.4.0): + dependencies: + ms: 2.1.2 + optionalDependencies: + supports-color: 9.4.0 + + decache@4.6.2: + dependencies: + callsite: 1.0.0 + + decimal.js@10.4.3: {} + + decode-named-character-reference@1.0.2: + dependencies: + character-entities: 2.0.2 + + decompress-response@6.0.0: + dependencies: + mimic-response: 3.1.0 + + dedent@1.5.3: {} + + deep-extend@0.6.0: {} + + deep-is@0.1.4: {} + + deepmerge@4.3.1: {} + + defer-to-connect@2.0.1: {} + + define-data-property@1.1.4: + dependencies: + es-define-property: 1.0.0 + es-errors: 1.3.0 + gopd: 1.0.1 + + define-lazy-prop@2.0.0: {} + + defu@6.1.4: {} + + delayed-stream@1.0.0: {} + + delegates@1.0.0: {} + + depd@1.1.2: {} + + depd@2.0.0: {} + + deprecation@2.3.1: {} + + dequal@2.0.3: {} + + destr@2.0.3: {} + + destroy@1.2.0: {} + + detect-libc@1.0.3: {} + + detect-libc@2.0.3: {} + + detect-newline@3.1.0: {} + + detective-amd@5.0.2: + dependencies: + ast-module-types: 5.0.0 + escodegen: 2.1.0 + get-amd-module-type: 5.0.1 + node-source-walk: 6.0.2 + + detective-cjs@5.0.1: + dependencies: + ast-module-types: 5.0.0 + node-source-walk: 6.0.2 + + detective-es6@4.0.1: + dependencies: + node-source-walk: 6.0.2 + + detective-postcss@6.1.3: + dependencies: + is-url: 1.2.4 + postcss: 8.4.41 + postcss-values-parser: 6.0.2(postcss@8.4.41) + + detective-sass@5.0.3: + dependencies: + gonzales-pe: 4.3.0 + node-source-walk: 6.0.2 + + detective-scss@4.0.3: + dependencies: + gonzales-pe: 4.3.0 + node-source-walk: 6.0.2 + + detective-stylus@4.0.0: {} + + detective-typescript@11.2.0(supports-color@9.4.0): + dependencies: + '@typescript-eslint/typescript-estree': 5.62.0(supports-color@9.4.0)(typescript@5.5.4) + ast-module-types: 5.0.0 + node-source-walk: 6.0.2 + typescript: 5.5.4 + transitivePeerDependencies: + - supports-color + + devlop@1.1.0: + dependencies: + dequal: 2.0.3 + + didyoumean@1.2.2: {} + + diff-sequences@29.6.3: {} + + diff@4.0.2: {} + + dir-glob@3.0.1: + dependencies: + path-type: 4.0.0 + + dlv@1.1.3: {} + + dom-accessibility-api@0.5.16: {} + + dom-accessibility-api@0.6.3: {} + + dom-serializer@2.0.0: + dependencies: + domelementtype: 2.3.0 + domhandler: 5.0.3 + entities: 4.5.0 + + domelementtype@2.3.0: {} + + domexception@4.0.0: + dependencies: + webidl-conversions: 7.0.0 + + domhandler@5.0.3: + dependencies: + domelementtype: 2.3.0 + + domutils@3.1.0: + dependencies: + dom-serializer: 2.0.0 + domelementtype: 2.3.0 + domhandler: 5.0.3 + + dot-prop@6.0.1: + dependencies: + is-obj: 2.0.0 + + dot-prop@7.2.0: + dependencies: + type-fest: 2.19.0 + + dot-prop@9.0.0: + dependencies: + type-fest: 4.24.0 + + dotenv@16.4.5: {} + + eastasianwidth@0.2.0: {} + + ecdsa-sig-formatter@1.0.11: + dependencies: + safe-buffer: 5.2.1 + + ee-first@1.1.1: {} + + electron-to-chromium@1.5.4: {} + + emittery@0.13.1: {} + + emoji-regex@10.3.0: {} + + emoji-regex@8.0.0: {} + + emoji-regex@9.2.2: {} + + enabled@2.0.0: {} + + encodeurl@1.0.2: {} + + end-of-stream@1.4.4: + dependencies: + once: 1.4.0 + + entities@2.2.0: {} + + entities@4.5.0: {} + + env-paths@2.2.1: {} + + env-paths@3.0.0: {} + + envinfo@7.13.0: {} + + environment@1.1.0: {} + + error-ex@1.3.2: + dependencies: + is-arrayish: 0.2.1 + + error-stack-parser@2.1.4: + dependencies: + stackframe: 1.3.4 + + es-define-property@1.0.0: + dependencies: + get-intrinsic: 1.2.4 + + es-errors@1.3.0: {} + + es-module-lexer@1.5.4: {} + + es6-promisify@6.1.1: {} + + esbuild@0.19.11: + optionalDependencies: + '@esbuild/aix-ppc64': 0.19.11 + '@esbuild/android-arm': 0.19.11 + '@esbuild/android-arm64': 0.19.11 + '@esbuild/android-x64': 0.19.11 + '@esbuild/darwin-arm64': 0.19.11 + '@esbuild/darwin-x64': 0.19.11 + '@esbuild/freebsd-arm64': 0.19.11 + '@esbuild/freebsd-x64': 0.19.11 + '@esbuild/linux-arm': 0.19.11 + '@esbuild/linux-arm64': 0.19.11 + '@esbuild/linux-ia32': 0.19.11 + '@esbuild/linux-loong64': 0.19.11 + '@esbuild/linux-mips64el': 0.19.11 + '@esbuild/linux-ppc64': 0.19.11 + '@esbuild/linux-riscv64': 0.19.11 + '@esbuild/linux-s390x': 0.19.11 + '@esbuild/linux-x64': 0.19.11 + '@esbuild/netbsd-x64': 0.19.11 + '@esbuild/openbsd-x64': 0.19.11 + '@esbuild/sunos-x64': 0.19.11 + '@esbuild/win32-arm64': 0.19.11 + '@esbuild/win32-ia32': 0.19.11 + '@esbuild/win32-x64': 0.19.11 + + esbuild@0.21.2: + optionalDependencies: + '@esbuild/aix-ppc64': 0.21.2 + '@esbuild/android-arm': 0.21.2 + '@esbuild/android-arm64': 0.21.2 + '@esbuild/android-x64': 0.21.2 + '@esbuild/darwin-arm64': 0.21.2 + '@esbuild/darwin-x64': 0.21.2 + '@esbuild/freebsd-arm64': 0.21.2 + '@esbuild/freebsd-x64': 0.21.2 + '@esbuild/linux-arm': 0.21.2 + '@esbuild/linux-arm64': 0.21.2 + '@esbuild/linux-ia32': 0.21.2 + '@esbuild/linux-loong64': 0.21.2 + '@esbuild/linux-mips64el': 0.21.2 + '@esbuild/linux-ppc64': 0.21.2 + '@esbuild/linux-riscv64': 0.21.2 + '@esbuild/linux-s390x': 0.21.2 + '@esbuild/linux-x64': 0.21.2 + '@esbuild/netbsd-x64': 0.21.2 + '@esbuild/openbsd-x64': 0.21.2 + '@esbuild/sunos-x64': 0.21.2 + '@esbuild/win32-arm64': 0.21.2 + '@esbuild/win32-ia32': 0.21.2 + '@esbuild/win32-x64': 0.21.2 + + escalade@3.1.2: {} + + escape-goat@4.0.0: {} + + escape-html@1.0.3: {} + + escape-string-regexp@1.0.5: {} + + escape-string-regexp@2.0.0: {} + + escape-string-regexp@4.0.0: {} + + escape-string-regexp@5.0.0: {} + + escodegen@2.1.0: + dependencies: + esprima: 4.0.1 + estraverse: 5.3.0 + esutils: 2.0.3 + optionalDependencies: + source-map: 0.6.1 + + eslint-config-prettier@9.1.0(eslint@9.9.0(jiti@1.21.6)): + dependencies: + eslint: 9.9.0(jiti@1.21.6) + + eslint-scope@8.0.2: + dependencies: + esrecurse: 4.3.0 + estraverse: 5.3.0 + + eslint-visitor-keys@3.4.3: {} + + eslint-visitor-keys@4.0.0: {} + + eslint@9.9.0(jiti@1.21.6): + dependencies: + '@eslint-community/eslint-utils': 4.4.0(eslint@9.9.0(jiti@1.21.6)) + '@eslint-community/regexpp': 4.11.0 + '@eslint/config-array': 0.17.1 + '@eslint/eslintrc': 3.1.0 + '@eslint/js': 9.9.0 + '@humanwhocodes/module-importer': 1.0.1 + '@humanwhocodes/retry': 0.3.0 + '@nodelib/fs.walk': 1.2.8 + ajv: 6.12.6 + chalk: 4.1.2 + cross-spawn: 7.0.3 + debug: 4.3.6(supports-color@9.4.0) + escape-string-regexp: 4.0.0 + eslint-scope: 8.0.2 + eslint-visitor-keys: 4.0.0 + espree: 10.1.0 + esquery: 1.6.0 + esutils: 2.0.3 + fast-deep-equal: 3.1.3 + file-entry-cache: 8.0.0 + find-up: 5.0.0 + glob-parent: 6.0.2 + ignore: 5.3.1 + imurmurhash: 0.1.4 + is-glob: 4.0.3 + is-path-inside: 3.0.3 + json-stable-stringify-without-jsonify: 1.0.1 + levn: 0.4.1 + lodash.merge: 4.6.2 + minimatch: 3.1.2 + natural-compare: 1.4.0 + optionator: 0.9.4 + strip-ansi: 6.0.1 + text-table: 0.2.0 + optionalDependencies: + jiti: 1.21.6 + transitivePeerDependencies: + - supports-color + + espree@10.1.0: + dependencies: + acorn: 8.12.1 + acorn-jsx: 5.3.2(acorn@8.12.1) + eslint-visitor-keys: 4.0.0 + + esprima@4.0.1: {} + + esquery@1.6.0: + dependencies: + estraverse: 5.3.0 + + esrecurse@4.3.0: + dependencies: + estraverse: 5.3.0 + + estraverse@5.3.0: {} + + estree-walker@2.0.2: {} + + esutils@2.0.3: {} + + etag@1.8.1: {} + + event-target-shim@5.0.1: {} + + eventemitter3@4.0.7: {} + + eventemitter3@5.0.1: {} + + events@3.3.0: {} + + execa@5.1.1: + dependencies: + cross-spawn: 7.0.3 + get-stream: 6.0.1 + human-signals: 2.1.0 + is-stream: 2.0.1 + merge-stream: 2.0.0 + npm-run-path: 4.0.1 + onetime: 5.1.2 + signal-exit: 3.0.7 + strip-final-newline: 2.0.0 + + execa@6.1.0: + dependencies: + cross-spawn: 7.0.3 + get-stream: 6.0.1 + human-signals: 3.0.1 + is-stream: 3.0.0 + merge-stream: 2.0.0 + npm-run-path: 5.3.0 + onetime: 6.0.0 + signal-exit: 3.0.7 + strip-final-newline: 3.0.0 + + execa@8.0.1: + dependencies: + cross-spawn: 7.0.3 + get-stream: 8.0.1 + human-signals: 5.0.0 + is-stream: 3.0.0 + merge-stream: 2.0.0 + npm-run-path: 5.3.0 + onetime: 6.0.0 + signal-exit: 4.1.0 + strip-final-newline: 3.0.0 + + exit@0.1.2: {} + + expand-template@2.0.3: {} + + expect@29.7.0: + dependencies: + '@jest/expect-utils': 29.7.0 + jest-get-type: 29.6.3 + jest-matcher-utils: 29.7.0 + jest-message-util: 29.7.0 + jest-util: 29.7.0 + + express-logging@1.1.1: + dependencies: + on-headers: 1.0.2 + + express@4.19.2: + dependencies: + accepts: 1.3.8 + array-flatten: 1.1.1 + body-parser: 1.20.2 + content-disposition: 0.5.4 + content-type: 1.0.5 + cookie: 0.6.0 + cookie-signature: 1.0.6 + debug: 2.6.9 + depd: 2.0.0 + encodeurl: 1.0.2 + escape-html: 1.0.3 + etag: 1.8.1 + finalhandler: 1.2.0 + fresh: 0.5.2 + http-errors: 2.0.0 + merge-descriptors: 1.0.1 + methods: 1.1.2 + on-finished: 2.4.1 + parseurl: 1.3.3 + path-to-regexp: 0.1.7 + proxy-addr: 2.0.7 + qs: 6.11.0 + range-parser: 1.2.1 + safe-buffer: 5.2.1 + send: 0.18.0 + serve-static: 1.15.0 + setprototypeof: 1.2.0 + statuses: 2.0.1 + type-is: 1.6.18 + utils-merge: 1.0.1 + vary: 1.1.2 + transitivePeerDependencies: + - supports-color + + ext-list@2.2.2: + dependencies: + mime-db: 1.53.0 + + ext-name@5.0.0: + dependencies: + ext-list: 2.2.2 + sort-keys-length: 1.0.1 + + extend-shallow@2.0.1: + dependencies: + is-extendable: 0.1.1 + + extend@3.0.2: {} + + external-editor@3.1.0: + dependencies: + chardet: 0.7.0 + iconv-lite: 0.4.24 + tmp: 0.0.33 + + extract-zip@2.0.1: + dependencies: + debug: 4.3.6(supports-color@9.4.0) + get-stream: 5.2.0 + yauzl: 2.10.0 + optionalDependencies: + '@types/yauzl': 2.10.3 + transitivePeerDependencies: + - supports-color + + fast-content-type-parse@1.1.0: {} + + fast-decode-uri-component@1.0.1: {} + + fast-deep-equal@3.1.3: {} + + fast-diff@1.3.0: {} + + fast-equals@3.0.3: {} + + fast-fifo@1.3.2: {} + + fast-glob@3.3.2: + dependencies: + '@nodelib/fs.stat': 2.0.5 + '@nodelib/fs.walk': 1.2.8 + glob-parent: 5.1.2 + merge2: 1.4.1 + micromatch: 4.0.7 + + fast-json-stable-stringify@2.1.0: {} + + fast-json-stringify@5.16.1: + dependencies: + '@fastify/merge-json-schemas': 0.1.1 + ajv: 8.17.1 + ajv-formats: 3.0.1(ajv@8.17.1) + fast-deep-equal: 3.1.3 + fast-uri: 2.4.0 + json-schema-ref-resolver: 1.0.1 + rfdc: 1.4.1 + + fast-levenshtein@2.0.6: {} + + fast-querystring@1.1.2: + dependencies: + fast-decode-uri-component: 1.0.1 + + fast-redact@3.5.0: {} + + fast-safe-stringify@2.1.1: {} + + fast-uri@2.4.0: {} + + fast-uri@3.0.1: {} + + fastest-levenshtein@1.0.16: {} + + fastify-plugin@4.5.1: {} + + fastify@4.28.1: + dependencies: + '@fastify/ajv-compiler': 3.6.0 + '@fastify/error': 3.4.1 + '@fastify/fast-json-stringify-compiler': 4.3.0 + abstract-logging: 2.0.1 + avvio: 8.4.0 + fast-content-type-parse: 1.1.0 + fast-json-stringify: 5.16.1 + find-my-way: 8.2.0 + light-my-request: 5.13.0 + pino: 9.3.2 + process-warning: 3.0.0 + proxy-addr: 2.0.7 + rfdc: 1.4.1 + secure-json-parse: 2.7.0 + semver: 7.6.3 + toad-cache: 3.7.0 + + fastq@1.17.1: + dependencies: + reusify: 1.0.4 + + fb-watchman@2.0.2: + dependencies: + bser: 2.1.1 + + fd-slicer@1.1.0: + dependencies: + pend: 1.2.0 + + fdir@6.2.0(picomatch@4.0.2): + optionalDependencies: + picomatch: 4.0.2 + + fecha@4.2.3: {} + + fetch-blob@3.2.0: + dependencies: + node-domexception: 1.0.0 + web-streams-polyfill: 3.3.3 + + fetch-node-website@7.3.0: + dependencies: + cli-progress: 3.12.0 + colors-option: 4.5.0 + figures: 5.0.0 + got: 12.6.1 + is-plain-obj: 4.1.0 + + figures@2.0.0: + dependencies: + escape-string-regexp: 1.0.5 + + figures@3.2.0: + dependencies: + escape-string-regexp: 1.0.5 + + figures@4.0.1: + dependencies: + escape-string-regexp: 5.0.0 + is-unicode-supported: 1.3.0 + + figures@5.0.0: + dependencies: + escape-string-regexp: 5.0.0 + is-unicode-supported: 1.3.0 + + file-entry-cache@8.0.0: + dependencies: + flat-cache: 4.0.1 + + file-entry-cache@9.0.0: + dependencies: + flat-cache: 5.0.0 + + file-type@18.7.0: + dependencies: + readable-web-to-node-stream: 3.0.2 + strtok3: 7.1.1 + token-types: 5.0.1 + + file-uri-to-path@1.0.0: {} + + filename-reserved-regex@3.0.0: {} + + filenamify@5.1.1: + dependencies: + filename-reserved-regex: 3.0.0 + strip-outer: 2.0.0 + trim-repeated: 2.0.0 + + fill-range@7.1.1: + dependencies: + to-regex-range: 5.0.1 + + filter-obj@3.0.0: {} + + filter-obj@5.1.0: {} + + finalhandler@1.2.0: + dependencies: + debug: 2.6.9 + encodeurl: 1.0.2 + escape-html: 1.0.3 + on-finished: 2.4.1 + parseurl: 1.3.3 + statuses: 2.0.1 + unpipe: 1.0.0 + transitivePeerDependencies: + - supports-color + + find-my-way@8.2.0: + dependencies: + fast-deep-equal: 3.1.3 + fast-querystring: 1.1.2 + safe-regex2: 3.1.0 + + find-up-simple@1.0.0: {} + + find-up@4.1.0: + dependencies: + locate-path: 5.0.0 + path-exists: 4.0.0 + + find-up@5.0.0: + dependencies: + locate-path: 6.0.0 + path-exists: 4.0.0 + + find-up@6.3.0: + dependencies: + locate-path: 7.2.0 + path-exists: 5.0.0 + + find-up@7.0.0: + dependencies: + locate-path: 7.2.0 + path-exists: 5.0.0 + unicorn-magic: 0.1.0 + + flat-cache@4.0.1: + dependencies: + flatted: 3.3.1 + keyv: 4.5.4 + + flat-cache@5.0.0: + dependencies: + flatted: 3.3.1 + keyv: 4.5.4 + + flatted@3.3.1: {} + + flush-write-stream@2.0.0: + dependencies: + inherits: 2.0.4 + readable-stream: 3.6.2 + + fn.name@1.1.0: {} + + folder-walker@3.2.0: + dependencies: + from2: 2.3.0 + + follow-redirects@1.15.6(debug@4.3.6): + optionalDependencies: + debug: 4.3.6(supports-color@9.4.0) + + foreground-child@3.2.1: + dependencies: + cross-spawn: 7.0.3 + signal-exit: 4.1.0 + + form-data-encoder@2.1.4: {} + + form-data@4.0.0: + dependencies: + asynckit: 0.4.0 + combined-stream: 1.0.8 + mime-types: 2.1.35 + + formdata-polyfill@4.0.10: + dependencies: + fetch-blob: 3.2.0 + + forwarded@0.2.0: {} + + fraction.js@4.3.7: {} + + fresh@0.5.2: {} + + from2-array@0.0.4: + dependencies: + from2: 2.3.0 + + from2@2.3.0: + dependencies: + inherits: 2.0.4 + readable-stream: 2.3.8 + + fs-constants@1.0.0: {} + + fs-minipass@2.1.0: + dependencies: + minipass: 3.3.6 + + fs.realpath@1.0.0: {} + + fsevents@2.3.2: + optional: true + + fsevents@2.3.3: + optional: true + + function-bind@1.1.2: {} + + fuzzy@0.1.3: {} + + gauge@3.0.2: + dependencies: + aproba: 2.0.0 + color-support: 1.1.3 + console-control-strings: 1.1.0 + has-unicode: 2.0.1 + object-assign: 4.1.1 + signal-exit: 3.0.7 + string-width: 4.2.3 + strip-ansi: 6.0.1 + wide-align: 1.1.5 + + gensync@1.0.0-beta.2: {} + + get-amd-module-type@5.0.1: + dependencies: + ast-module-types: 5.0.0 + node-source-walk: 6.0.2 + + get-caller-file@2.0.5: {} + + get-east-asian-width@1.2.0: {} + + get-intrinsic@1.2.4: + dependencies: + es-errors: 1.3.0 + function-bind: 1.1.2 + has-proto: 1.0.3 + has-symbols: 1.0.3 + hasown: 2.0.2 + + get-package-name@2.2.0: {} + + get-package-type@0.1.0: {} + + get-port-please@3.1.2: {} + + get-port@5.1.1: {} + + get-port@6.1.2: {} + + get-stdin@9.0.0: {} + + get-stream@5.2.0: + dependencies: + pump: 3.0.0 + + get-stream@6.0.1: {} + + get-stream@8.0.1: {} + + gh-release-fetch@4.0.3: + dependencies: + '@xhmikosr/downloader': 13.0.1 + node-fetch: 3.3.2 + semver: 7.6.3 + + git-repo-info@2.1.1: {} + + gitconfiglocal@2.1.0: + dependencies: + ini: 1.3.8 + + github-from-package@0.0.0: {} + + github-slugger@2.0.0: {} + + glob-parent@5.1.2: + dependencies: + is-glob: 4.0.3 + + glob-parent@6.0.2: + dependencies: + is-glob: 4.0.3 + + glob@10.4.5: + dependencies: + foreground-child: 3.2.1 + jackspeak: 3.4.3 + minimatch: 9.0.5 + minipass: 7.1.2 + package-json-from-dist: 1.0.0 + path-scurry: 1.11.1 + + glob@7.2.3: + dependencies: + fs.realpath: 1.0.0 + inflight: 1.0.6 + inherits: 2.0.4 + minimatch: 3.1.2 + once: 1.4.0 + path-is-absolute: 1.0.1 + + glob@8.1.0: + dependencies: + fs.realpath: 1.0.0 + inflight: 1.0.6 + inherits: 2.0.4 + minimatch: 5.1.6 + once: 1.4.0 + + global-cache-dir@4.4.0: + dependencies: + cachedir: 2.4.0 + path-exists: 5.0.0 + + global-dirs@3.0.1: + dependencies: + ini: 2.0.0 + + global-modules@2.0.0: + dependencies: + global-prefix: 3.0.0 + + global-prefix@3.0.0: + dependencies: + ini: 1.3.8 + kind-of: 6.0.3 + which: 1.3.1 + + globals@11.12.0: {} + + globals@14.0.0: {} + + globby@11.1.0: + dependencies: + array-union: 2.1.0 + dir-glob: 3.0.1 + fast-glob: 3.3.2 + ignore: 5.3.2 + merge2: 1.4.1 + slash: 3.0.0 + + globby@13.2.2: + dependencies: + dir-glob: 3.0.1 + fast-glob: 3.3.2 + ignore: 5.3.2 + merge2: 1.4.1 + slash: 4.0.0 + + globjoin@0.1.4: {} + + gonzales-pe@4.3.0: + dependencies: + minimist: 1.2.8 + + gopd@1.0.1: + dependencies: + get-intrinsic: 1.2.4 + + got@12.6.1: + dependencies: + '@sindresorhus/is': 5.6.0 + '@szmarczak/http-timer': 5.0.1 + cacheable-lookup: 7.0.0 + cacheable-request: 10.2.14 + decompress-response: 6.0.0 + form-data-encoder: 2.1.4 + get-stream: 6.0.1 + http2-wrapper: 2.2.1 + lowercase-keys: 3.0.0 + p-cancelable: 3.0.0 + responselike: 3.0.0 + + graceful-fs@4.2.10: {} + + graceful-fs@4.2.11: {} + + graphemer@1.4.0: {} + + gray-matter@4.0.3: + dependencies: + js-yaml: 3.14.1 + kind-of: 6.0.3 + section-matter: 1.0.0 + strip-bom-string: 1.0.0 + + h3@1.12.0: + dependencies: + cookie-es: 1.2.2 + crossws: 0.2.4 + defu: 6.1.4 + destr: 2.0.3 + iron-webcrypto: 1.2.1 + ohash: 1.1.3 + radix3: 1.1.2 + ufo: 1.5.4 + uncrypto: 0.1.3 + unenv: 1.10.0 + transitivePeerDependencies: + - uWebSockets.js + + has-flag@3.0.0: {} + + has-flag@4.0.0: {} + + has-own-prop@2.0.0: {} + + has-property-descriptors@1.0.2: + dependencies: + es-define-property: 1.0.0 + + has-proto@1.0.3: {} + + has-symbols@1.0.3: {} + + has-unicode@2.0.1: {} + + hasbin@1.2.3: + dependencies: + async: 1.5.2 + + hasha@5.2.2: + dependencies: + is-stream: 2.0.1 + type-fest: 0.8.1 + + hasown@2.0.2: + dependencies: + function-bind: 1.1.2 + + hast-util-from-html@2.0.1: + dependencies: + '@types/hast': 3.0.4 + devlop: 1.1.0 + hast-util-from-parse5: 8.0.1 + parse5: 7.1.2 + vfile: 6.0.1 + vfile-message: 4.0.2 + + hast-util-from-parse5@8.0.1: + dependencies: + '@types/hast': 3.0.4 + '@types/unist': 3.0.2 + devlop: 1.1.0 + hastscript: 8.0.0 + property-information: 6.5.0 + vfile: 6.0.1 + vfile-location: 5.0.2 + web-namespaces: 2.0.1 + + hast-util-heading-rank@3.0.0: + dependencies: + '@types/hast': 3.0.4 + + hast-util-is-element@3.0.0: + dependencies: + '@types/hast': 3.0.4 + + hast-util-parse-selector@4.0.0: + dependencies: + '@types/hast': 3.0.4 + + hast-util-raw@9.0.4: + dependencies: + '@types/hast': 3.0.4 + '@types/unist': 3.0.2 + '@ungap/structured-clone': 1.2.0 + hast-util-from-parse5: 8.0.1 + hast-util-to-parse5: 8.0.0 + html-void-elements: 3.0.0 + mdast-util-to-hast: 13.2.0 + parse5: 7.1.2 + unist-util-position: 5.0.0 + unist-util-visit: 5.0.0 + vfile: 6.0.1 + web-namespaces: 2.0.1 + zwitch: 2.0.4 + + hast-util-sanitize@5.0.1: + dependencies: + '@types/hast': 3.0.4 + '@ungap/structured-clone': 1.2.0 + unist-util-position: 5.0.0 + + hast-util-to-html@9.0.1: + dependencies: + '@types/hast': 3.0.4 + '@types/unist': 3.0.2 + ccount: 2.0.1 + comma-separated-tokens: 2.0.3 + hast-util-raw: 9.0.4 + hast-util-whitespace: 3.0.0 + html-void-elements: 3.0.0 + mdast-util-to-hast: 13.2.0 + property-information: 6.5.0 + space-separated-tokens: 2.0.2 + stringify-entities: 4.0.4 + zwitch: 2.0.4 + + hast-util-to-parse5@8.0.0: + dependencies: + '@types/hast': 3.0.4 + comma-separated-tokens: 2.0.3 + devlop: 1.1.0 + property-information: 6.5.0 + space-separated-tokens: 2.0.2 + web-namespaces: 2.0.1 + zwitch: 2.0.4 + + hast-util-to-string@3.0.0: + dependencies: + '@types/hast': 3.0.4 + + hast-util-whitespace@3.0.0: + dependencies: + '@types/hast': 3.0.4 + + hastscript@8.0.0: + dependencies: + '@types/hast': 3.0.4 + comma-separated-tokens: 2.0.3 + hast-util-parse-selector: 4.0.0 + property-information: 6.5.0 + space-separated-tokens: 2.0.2 + + hosted-git-info@4.1.0: + dependencies: + lru-cache: 6.0.0 + + hosted-git-info@7.0.2: + dependencies: + lru-cache: 10.4.3 + + hot-shots@10.0.0: + optionalDependencies: + unix-dgram: 2.0.6 + + html-encoding-sniffer@3.0.0: + dependencies: + whatwg-encoding: 2.0.0 + + html-escaper@2.0.2: {} + + html-tags@3.3.1: {} + + html-void-elements@3.0.0: {} + + http-cache-semantics@4.1.1: {} + + http-errors@1.8.1: + dependencies: + depd: 1.1.2 + inherits: 2.0.4 + setprototypeof: 1.2.0 + statuses: 1.5.0 + toidentifier: 1.0.1 + + http-errors@2.0.0: + dependencies: + depd: 2.0.0 + inherits: 2.0.4 + setprototypeof: 1.2.0 + statuses: 2.0.1 + toidentifier: 1.0.1 + + http-proxy-agent@5.0.0: + dependencies: + '@tootallnate/once': 2.0.0 + agent-base: 6.0.2(supports-color@9.4.0) + debug: 4.3.6(supports-color@9.4.0) + transitivePeerDependencies: + - supports-color + + http-proxy-middleware@2.0.6(debug@4.3.6): + dependencies: + '@types/http-proxy': 1.17.15 + http-proxy: 1.18.1(debug@4.3.6) + is-glob: 4.0.3 + is-plain-obj: 3.0.0 + micromatch: 4.0.7 + transitivePeerDependencies: + - debug + + http-proxy@1.18.1(debug@4.3.6): + dependencies: + eventemitter3: 4.0.7 + follow-redirects: 1.15.6(debug@4.3.6) + requires-port: 1.0.0 + transitivePeerDependencies: + - debug + + http-shutdown@1.2.2: {} + + http2-wrapper@2.2.1: + dependencies: + quick-lru: 5.1.1 + resolve-alpn: 1.2.1 + + https-proxy-agent@5.0.1(supports-color@9.4.0): + dependencies: + agent-base: 6.0.2(supports-color@9.4.0) + debug: 4.3.6(supports-color@9.4.0) + transitivePeerDependencies: + - supports-color + + https-proxy-agent@7.0.5: + dependencies: + agent-base: 7.1.1 + debug: 4.3.6(supports-color@9.4.0) + transitivePeerDependencies: + - supports-color + + human-signals@2.1.0: {} + + human-signals@3.0.1: {} + + human-signals@5.0.0: {} + + husky@9.1.4: {} + + iconv-lite@0.4.24: + dependencies: + safer-buffer: 2.1.2 + + iconv-lite@0.6.3: + dependencies: + safer-buffer: 2.1.2 + + ieee754@1.2.1: {} + + ignore@5.3.1: {} + + ignore@5.3.2: {} + + image-meta@0.2.1: {} + + immutable@4.3.6: {} + + import-fresh@3.3.0: + dependencies: + parent-module: 1.0.1 + resolve-from: 4.0.0 + + import-lazy@4.0.0: {} + + import-local@3.2.0: + dependencies: + pkg-dir: 4.2.0 + resolve-cwd: 3.0.0 + + imurmurhash@0.1.4: {} + + indent-string@4.0.0: {} + + indent-string@5.0.0: {} + + index-to-position@0.1.2: {} + + inflight@1.0.6: + dependencies: + once: 1.4.0 + wrappy: 1.0.2 + + inherits@2.0.4: {} + + ini@1.3.8: {} + + ini@2.0.0: {} + + ini@4.1.3: {} + + inquirer-autocomplete-prompt@1.4.0(inquirer@6.5.2): + dependencies: + ansi-escapes: 4.3.2 + chalk: 4.1.2 + figures: 3.2.0 + inquirer: 6.5.2 + run-async: 2.4.1 + rxjs: 6.6.7 + + inquirer@6.5.2: + dependencies: + ansi-escapes: 3.2.0 + chalk: 2.4.2 + cli-cursor: 2.1.0 + cli-width: 2.2.1 + external-editor: 3.1.0 + figures: 2.0.0 + lodash: 4.17.21 + mute-stream: 0.0.7 + run-async: 2.4.1 + rxjs: 6.6.7 + string-width: 2.1.1 + strip-ansi: 5.2.0 + through: 2.3.8 + + inspect-with-kind@1.0.5: + dependencies: + kind-of: 6.0.3 + + ipaddr.js@1.9.1: {} + + ipx@2.1.0(@netlify/blobs@8.0.0): + dependencies: + '@fastify/accept-negotiator': 1.1.0 + citty: 0.1.6 + consola: 3.2.3 + defu: 6.1.4 + destr: 2.0.3 + etag: 1.8.1 + h3: 1.12.0 + image-meta: 0.2.1 + listhen: 1.7.2 + ofetch: 1.3.4 + pathe: 1.1.2 + sharp: 0.32.6 + svgo: 3.3.2 + ufo: 1.5.4 + unstorage: 1.10.2(@netlify/blobs@8.0.0) + xss: 1.0.15 + transitivePeerDependencies: + - '@azure/app-configuration' + - '@azure/cosmos' + - '@azure/data-tables' + - '@azure/identity' + - '@azure/keyvault-secrets' + - '@azure/storage-blob' + - '@capacitor/preferences' + - '@netlify/blobs' + - '@planetscale/database' + - '@upstash/redis' + - '@vercel/kv' + - idb-keyval + - ioredis + - uWebSockets.js + + iron-webcrypto@1.2.1: {} + + is-arrayish@0.2.1: {} + + is-arrayish@0.3.2: {} + + is-binary-path@2.1.0: + dependencies: + binary-extensions: 2.3.0 + + is-builtin-module@3.2.1: + dependencies: + builtin-modules: 3.3.0 + + is-core-module@2.15.0: + dependencies: + hasown: 2.0.2 + + is-docker@2.2.1: {} + + is-docker@3.0.0: {} + + is-extendable@0.1.1: {} + + is-extglob@2.1.1: {} + + is-fullwidth-code-point@2.0.0: {} + + is-fullwidth-code-point@3.0.0: {} + + is-fullwidth-code-point@4.0.0: {} + + is-fullwidth-code-point@5.0.0: + dependencies: + get-east-asian-width: 1.2.0 + + is-generator-fn@2.1.0: {} + + is-glob@4.0.3: + dependencies: + is-extglob: 2.1.1 + + is-in-ci@0.1.0: {} + + is-inside-container@1.0.0: + dependencies: + is-docker: 3.0.0 + + is-installed-globally@0.4.0: + dependencies: + global-dirs: 3.0.1 + is-path-inside: 3.0.3 + + is-interactive@2.0.0: {} + + is-npm@6.0.0: {} + + is-number@7.0.0: {} + + is-obj@2.0.0: {} + + is-path-inside@3.0.3: {} + + is-path-inside@4.0.0: {} + + is-plain-obj@1.1.0: {} + + is-plain-obj@2.1.0: {} + + is-plain-obj@3.0.0: {} + + is-plain-obj@4.1.0: {} + + is-plain-object@5.0.0: {} + + is-potential-custom-element-name@1.0.1: {} + + is-stream@2.0.1: {} + + is-stream@3.0.0: {} + + is-stream@4.0.1: {} + + is-typedarray@1.0.0: {} + + is-unicode-supported@1.3.0: {} + + is-unicode-supported@2.0.0: {} + + is-url-superb@4.0.0: {} + + is-url@1.2.4: {} + + is-wsl@2.2.0: + dependencies: + is-docker: 2.2.1 + + is-wsl@3.1.0: + dependencies: + is-inside-container: 1.0.0 + + is64bit@2.0.0: + dependencies: + system-architecture: 0.1.0 + + isarray@1.0.0: {} + + iserror@0.0.2: {} + + isexe@2.0.0: {} + + isexe@3.1.1: {} + + istanbul-lib-coverage@3.2.2: {} + + istanbul-lib-instrument@5.2.1: + dependencies: + '@babel/core': 7.25.2 + '@babel/parser': 7.25.3 + '@istanbuljs/schema': 0.1.3 + istanbul-lib-coverage: 3.2.2 + semver: 6.3.1 + transitivePeerDependencies: + - supports-color + + istanbul-lib-instrument@6.0.3: + dependencies: + '@babel/core': 7.25.2 + '@babel/parser': 7.25.3 + '@istanbuljs/schema': 0.1.3 + istanbul-lib-coverage: 3.2.2 + semver: 7.6.3 + transitivePeerDependencies: + - supports-color + + istanbul-lib-report@3.0.1: + dependencies: + istanbul-lib-coverage: 3.2.2 + make-dir: 4.0.0 + supports-color: 7.2.0 + + istanbul-lib-source-maps@4.0.1: + dependencies: + debug: 4.3.6(supports-color@9.4.0) + istanbul-lib-coverage: 3.2.2 + source-map: 0.6.1 + transitivePeerDependencies: + - supports-color + + istanbul-reports@3.1.7: + dependencies: + html-escaper: 2.0.2 + istanbul-lib-report: 3.0.1 + + jackspeak@3.4.3: + dependencies: + '@isaacs/cliui': 8.0.2 + optionalDependencies: + '@pkgjs/parseargs': 0.11.0 + + jest-changed-files@29.7.0: + dependencies: + execa: 5.1.1 + jest-util: 29.7.0 + p-limit: 3.1.0 + + jest-circus@29.7.0: + dependencies: + '@jest/environment': 29.7.0 + '@jest/expect': 29.7.0 + '@jest/test-result': 29.7.0 + '@jest/types': 29.6.3 + '@types/node': 20.15.0 + chalk: 4.1.2 + co: 4.6.0 + dedent: 1.5.3 + is-generator-fn: 2.1.0 + jest-each: 29.7.0 + jest-matcher-utils: 29.7.0 + jest-message-util: 29.7.0 + jest-runtime: 29.7.0 + jest-snapshot: 29.7.0 + jest-util: 29.7.0 + p-limit: 3.1.0 + pretty-format: 29.7.0 + pure-rand: 6.1.0 + slash: 3.0.0 + stack-utils: 2.0.6 + transitivePeerDependencies: + - babel-plugin-macros + - supports-color + + jest-cli@29.7.0(@types/node@20.15.0)(ts-node@10.9.2(@types/node@20.15.0)(typescript@5.5.4)): + dependencies: + '@jest/core': 29.7.0(ts-node@10.9.2(@types/node@20.15.0)(typescript@5.5.4)) + '@jest/test-result': 29.7.0 + '@jest/types': 29.6.3 + chalk: 4.1.2 + create-jest: 29.7.0(@types/node@20.15.0)(ts-node@10.9.2(@types/node@20.15.0)(typescript@5.5.4)) + exit: 0.1.2 + import-local: 3.2.0 + jest-config: 29.7.0(@types/node@20.15.0)(ts-node@10.9.2(@types/node@20.15.0)(typescript@5.5.4)) + jest-util: 29.7.0 + jest-validate: 29.7.0 + yargs: 17.7.2 + transitivePeerDependencies: + - '@types/node' + - babel-plugin-macros + - supports-color + - ts-node + + jest-config@29.7.0(@types/node@20.15.0)(ts-node@10.9.2(@types/node@20.15.0)(typescript@5.5.4)): + dependencies: + '@babel/core': 7.25.2 + '@jest/test-sequencer': 29.7.0 + '@jest/types': 29.6.3 + babel-jest: 29.7.0(@babel/core@7.25.2) + chalk: 4.1.2 + ci-info: 3.9.0 + deepmerge: 4.3.1 + glob: 7.2.3 + graceful-fs: 4.2.11 + jest-circus: 29.7.0 + jest-environment-node: 29.7.0 + jest-get-type: 29.6.3 + jest-regex-util: 29.6.3 + jest-resolve: 29.7.0 + jest-runner: 29.7.0 + jest-util: 29.7.0 + jest-validate: 29.7.0 + micromatch: 4.0.7 + parse-json: 5.2.0 + pretty-format: 29.7.0 + slash: 3.0.0 + strip-json-comments: 3.1.1 + optionalDependencies: + '@types/node': 20.15.0 + ts-node: 10.9.2(@types/node@20.15.0)(typescript@5.5.4) + transitivePeerDependencies: + - babel-plugin-macros + - supports-color + + jest-diff@29.7.0: + dependencies: + chalk: 4.1.2 + diff-sequences: 29.6.3 + jest-get-type: 29.6.3 + pretty-format: 29.7.0 + + jest-docblock@29.7.0: + dependencies: + detect-newline: 3.1.0 + + jest-each@29.7.0: + dependencies: + '@jest/types': 29.6.3 + chalk: 4.1.2 + jest-get-type: 29.6.3 + jest-util: 29.7.0 + pretty-format: 29.7.0 + + jest-environment-jsdom@29.7.0: + dependencies: + '@jest/environment': 29.7.0 + '@jest/fake-timers': 29.7.0 + '@jest/types': 29.6.3 + '@types/jsdom': 20.0.1 + '@types/node': 20.15.0 + jest-mock: 29.7.0 + jest-util: 29.7.0 + jsdom: 20.0.3 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + + jest-environment-node@29.7.0: + dependencies: + '@jest/environment': 29.7.0 + '@jest/fake-timers': 29.7.0 + '@jest/types': 29.6.3 + '@types/node': 20.15.0 + jest-mock: 29.7.0 + jest-util: 29.7.0 + + jest-get-type@27.5.1: {} + + jest-get-type@29.6.3: {} + + jest-haste-map@29.7.0: + dependencies: + '@jest/types': 29.6.3 + '@types/graceful-fs': 4.1.9 + '@types/node': 20.15.0 + anymatch: 3.1.3 + fb-watchman: 2.0.2 + graceful-fs: 4.2.11 + jest-regex-util: 29.6.3 + jest-util: 29.7.0 + jest-worker: 29.7.0 + micromatch: 4.0.7 + walker: 1.0.8 + optionalDependencies: + fsevents: 2.3.3 + + jest-leak-detector@29.7.0: + dependencies: + jest-get-type: 29.6.3 + pretty-format: 29.7.0 + + jest-matcher-utils@29.7.0: + dependencies: + chalk: 4.1.2 + jest-diff: 29.7.0 + jest-get-type: 29.6.3 + pretty-format: 29.7.0 + + jest-message-util@29.7.0: + dependencies: + '@babel/code-frame': 7.24.7 + '@jest/types': 29.6.3 + '@types/stack-utils': 2.0.3 + chalk: 4.1.2 + graceful-fs: 4.2.11 + micromatch: 4.0.7 + pretty-format: 29.7.0 + slash: 3.0.0 + stack-utils: 2.0.6 + + jest-mock@29.7.0: + dependencies: + '@jest/types': 29.6.3 + '@types/node': 20.15.0 + jest-util: 29.7.0 + + jest-pnp-resolver@1.2.3(jest-resolve@29.7.0): + optionalDependencies: + jest-resolve: 29.7.0 + + jest-regex-util@29.6.3: {} + + jest-resolve-dependencies@29.7.0: + dependencies: + jest-regex-util: 29.6.3 + jest-snapshot: 29.7.0 + transitivePeerDependencies: + - supports-color + + jest-resolve@29.7.0: + dependencies: + chalk: 4.1.2 + graceful-fs: 4.2.11 + jest-haste-map: 29.7.0 + jest-pnp-resolver: 1.2.3(jest-resolve@29.7.0) + jest-util: 29.7.0 + jest-validate: 29.7.0 + resolve: 1.22.8 + resolve.exports: 2.0.2 + slash: 3.0.0 + + jest-runner@29.7.0: + dependencies: + '@jest/console': 29.7.0 + '@jest/environment': 29.7.0 + '@jest/test-result': 29.7.0 + '@jest/transform': 29.7.0 + '@jest/types': 29.6.3 + '@types/node': 20.15.0 + chalk: 4.1.2 + emittery: 0.13.1 + graceful-fs: 4.2.11 + jest-docblock: 29.7.0 + jest-environment-node: 29.7.0 + jest-haste-map: 29.7.0 + jest-leak-detector: 29.7.0 + jest-message-util: 29.7.0 + jest-resolve: 29.7.0 + jest-runtime: 29.7.0 + jest-util: 29.7.0 + jest-watcher: 29.7.0 + jest-worker: 29.7.0 + p-limit: 3.1.0 + source-map-support: 0.5.13 + transitivePeerDependencies: + - supports-color + + jest-runtime@29.7.0: + dependencies: + '@jest/environment': 29.7.0 + '@jest/fake-timers': 29.7.0 + '@jest/globals': 29.7.0 + '@jest/source-map': 29.6.3 + '@jest/test-result': 29.7.0 + '@jest/transform': 29.7.0 + '@jest/types': 29.6.3 + '@types/node': 20.15.0 + chalk: 4.1.2 + cjs-module-lexer: 1.3.1 + collect-v8-coverage: 1.0.2 + glob: 7.2.3 + graceful-fs: 4.2.11 + jest-haste-map: 29.7.0 + jest-message-util: 29.7.0 + jest-mock: 29.7.0 + jest-regex-util: 29.6.3 + jest-resolve: 29.7.0 + jest-snapshot: 29.7.0 + jest-util: 29.7.0 + slash: 3.0.0 + strip-bom: 4.0.0 + transitivePeerDependencies: + - supports-color + + jest-snapshot@29.7.0: + dependencies: + '@babel/core': 7.25.2 + '@babel/generator': 7.25.0 + '@babel/plugin-syntax-jsx': 7.24.7(@babel/core@7.25.2) + '@babel/plugin-syntax-typescript': 7.24.7(@babel/core@7.25.2) + '@babel/types': 7.25.2 + '@jest/expect-utils': 29.7.0 + '@jest/transform': 29.7.0 + '@jest/types': 29.6.3 + babel-preset-current-node-syntax: 1.0.1(@babel/core@7.25.2) + chalk: 4.1.2 + expect: 29.7.0 + graceful-fs: 4.2.11 + jest-diff: 29.7.0 + jest-get-type: 29.6.3 + jest-matcher-utils: 29.7.0 + jest-message-util: 29.7.0 + jest-util: 29.7.0 + natural-compare: 1.4.0 + pretty-format: 29.7.0 + semver: 7.6.3 + transitivePeerDependencies: + - supports-color + + jest-util@29.7.0: + dependencies: + '@jest/types': 29.6.3 + '@types/node': 20.15.0 + chalk: 4.1.2 + ci-info: 3.9.0 + graceful-fs: 4.2.11 + picomatch: 2.3.1 + + jest-validate@27.5.1: + dependencies: + '@jest/types': 27.5.1 + camelcase: 6.3.0 + chalk: 4.1.2 + jest-get-type: 27.5.1 + leven: 3.1.0 + pretty-format: 27.5.1 + + jest-validate@29.7.0: + dependencies: + '@jest/types': 29.6.3 + camelcase: 6.3.0 + chalk: 4.1.2 + jest-get-type: 29.6.3 + leven: 3.1.0 + pretty-format: 29.7.0 + + jest-watcher@29.7.0: + dependencies: + '@jest/test-result': 29.7.0 + '@jest/types': 29.6.3 + '@types/node': 20.15.0 + ansi-escapes: 4.3.2 + chalk: 4.1.2 + emittery: 0.13.1 + jest-util: 29.7.0 + string-length: 4.0.2 + + jest-worker@29.7.0: + dependencies: + '@types/node': 20.15.0 + jest-util: 29.7.0 + merge-stream: 2.0.0 + supports-color: 8.1.1 + + jest@29.7.0(@types/node@20.15.0)(ts-node@10.9.2(@types/node@20.15.0)(typescript@5.5.4)): + dependencies: + '@jest/core': 29.7.0(ts-node@10.9.2(@types/node@20.15.0)(typescript@5.5.4)) + '@jest/types': 29.6.3 + import-local: 3.2.0 + jest-cli: 29.7.0(@types/node@20.15.0)(ts-node@10.9.2(@types/node@20.15.0)(typescript@5.5.4)) + transitivePeerDependencies: + - '@types/node' + - babel-plugin-macros + - supports-color + - ts-node + + jiti@1.21.6: {} + + jotai@2.9.3(@types/react@18.3.3)(react@18.3.1): + optionalDependencies: + '@types/react': 18.3.3 + react: 18.3.1 + + js-string-escape@1.0.1: {} + + js-tokens@4.0.0: {} + + js-yaml@3.14.1: + dependencies: + argparse: 1.0.10 + esprima: 4.0.1 + + js-yaml@4.1.0: + dependencies: + argparse: 2.0.1 + + jsdom@20.0.3: + dependencies: + abab: 2.0.6 + acorn: 8.12.1 + acorn-globals: 7.0.1 + cssom: 0.5.0 + cssstyle: 2.3.0 + data-urls: 3.0.2 + decimal.js: 10.4.3 + domexception: 4.0.0 + escodegen: 2.1.0 + form-data: 4.0.0 + html-encoding-sniffer: 3.0.0 + http-proxy-agent: 5.0.0 + https-proxy-agent: 5.0.1(supports-color@9.4.0) + is-potential-custom-element-name: 1.0.1 + nwsapi: 2.2.12 + parse5: 7.1.2 + saxes: 6.0.0 + symbol-tree: 3.2.4 + tough-cookie: 4.1.4 + w3c-xmlserializer: 4.0.0 + webidl-conversions: 7.0.0 + whatwg-encoding: 2.0.0 + whatwg-mimetype: 3.0.0 + whatwg-url: 11.0.0 + ws: 8.17.1 + xml-name-validator: 4.0.0 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + + jsesc@2.5.2: {} + + json-buffer@3.0.1: {} + + json-parse-even-better-errors@2.3.1: {} + + json-schema-ref-resolver@1.0.1: + dependencies: + fast-deep-equal: 3.1.3 + + json-schema-traverse@0.4.1: {} + + json-schema-traverse@1.0.0: {} + + json-stable-stringify-without-jsonify@1.0.1: {} + + json5@2.2.3: {} + + jsonc-parser@3.2.1: {} + + jsonc-parser@3.3.1: {} + + jsonpointer@5.0.1: {} + + jsonwebtoken@9.0.2: + dependencies: + jws: 3.2.2 + lodash.includes: 4.3.0 + lodash.isboolean: 3.0.3 + lodash.isinteger: 4.0.4 + lodash.isnumber: 3.0.3 + lodash.isplainobject: 4.0.6 + lodash.isstring: 4.0.1 + lodash.once: 4.1.1 + ms: 2.1.3 + semver: 7.6.3 + + junk@4.0.1: {} + + jwa@1.4.1: + dependencies: + buffer-equal-constant-time: 1.0.1 + ecdsa-sig-formatter: 1.0.11 + safe-buffer: 5.2.1 + + jws@3.2.2: + dependencies: + jwa: 1.4.1 + safe-buffer: 5.2.1 + + jwt-decode@4.0.0: {} + + keep-func-props@4.0.1: + dependencies: + mimic-fn: 4.0.0 + + keyv@4.5.4: + dependencies: + json-buffer: 3.0.1 + + kind-of@6.0.3: {} + + kleur@3.0.3: {} + + known-css-properties@0.34.0: {} + + kuler@2.0.0: {} + + lambda-local@2.2.0: + dependencies: + commander: 10.0.1 + dotenv: 16.4.5 + winston: 3.14.1 + + latest-version@7.0.0: + dependencies: + package-json: 8.1.1 + + lazystream@1.0.1: + dependencies: + readable-stream: 2.3.8 + + leven@3.1.0: {} + + levn@0.4.1: + dependencies: + prelude-ls: 1.2.1 + type-check: 0.4.0 + + light-my-request@5.13.0: + dependencies: + cookie: 0.6.0 + process-warning: 3.0.0 + set-cookie-parser: 2.7.0 + + lilconfig@2.1.0: {} + + lilconfig@3.1.2: {} + + lines-and-columns@1.2.4: {} + + linkify-it@5.0.0: + dependencies: + uc.micro: 2.1.0 + + listhen@1.7.2: + dependencies: + '@parcel/watcher': 2.4.1 + '@parcel/watcher-wasm': 2.4.1 + citty: 0.1.6 + clipboardy: 4.0.0 + consola: 3.2.3 + crossws: 0.2.4 + defu: 6.1.4 + get-port-please: 3.1.2 + h3: 1.12.0 + http-shutdown: 1.2.2 + jiti: 1.21.6 + mlly: 1.7.1 + node-forge: 1.3.1 + pathe: 1.1.2 + std-env: 3.7.0 + ufo: 1.5.4 + untun: 0.1.3 + uqr: 0.1.2 + transitivePeerDependencies: + - uWebSockets.js + + listr2@8.2.4: + dependencies: + cli-truncate: 4.0.0 + colorette: 2.0.20 + eventemitter3: 5.0.1 + log-update: 6.1.0 + rfdc: 1.4.1 + wrap-ansi: 9.0.0 + + locate-path@5.0.0: + dependencies: + p-locate: 4.1.0 + + locate-path@6.0.0: + dependencies: + p-locate: 5.0.0 + + locate-path@7.2.0: + dependencies: + p-locate: 6.0.0 + + lodash-es@4.17.21: {} + + lodash.includes@4.3.0: {} + + lodash.isboolean@3.0.3: {} + + lodash.isempty@4.4.0: {} + + lodash.isinteger@4.0.4: {} + + lodash.isnumber@3.0.3: {} + + lodash.isplainobject@4.0.6: {} + + lodash.isstring@4.0.1: {} + + lodash.merge@4.6.2: {} + + lodash.once@4.1.1: {} + + lodash.transform@4.6.0: {} + + lodash.truncate@4.4.2: {} + + lodash@4.17.21: {} + + log-process-errors@8.0.0: + dependencies: + colors-option: 3.0.0 + figures: 4.0.1 + filter-obj: 3.0.0 + jest-validate: 27.5.1 + map-obj: 5.0.2 + moize: 6.1.6 + semver: 7.6.3 + + log-symbols@6.0.0: + dependencies: + chalk: 5.3.0 + is-unicode-supported: 1.3.0 + + log-update@6.0.0: + dependencies: + ansi-escapes: 6.2.1 + cli-cursor: 4.0.0 + slice-ansi: 7.1.0 + strip-ansi: 7.1.0 + wrap-ansi: 9.0.0 + + log-update@6.1.0: + dependencies: + ansi-escapes: 7.0.0 + cli-cursor: 5.0.0 + slice-ansi: 7.1.0 + strip-ansi: 7.1.0 + wrap-ansi: 9.0.0 + + logform@2.6.1: + dependencies: + '@colors/colors': 1.6.0 + '@types/triple-beam': 1.3.5 + fecha: 4.2.3 + ms: 2.1.3 + safe-stable-stringify: 2.4.3 + triple-beam: 1.4.1 + + longest-streak@3.1.0: {} + + loose-envify@1.4.0: + dependencies: + js-tokens: 4.0.0 + + lowercase-keys@3.0.0: {} + + lru-cache@10.4.3: {} + + lru-cache@5.1.1: + dependencies: + yallist: 3.1.1 + + lru-cache@6.0.0: + dependencies: + yallist: 4.0.0 + + luxon@3.5.0: {} + + lz-string@1.5.0: {} + + macos-release@3.3.0: {} + + make-dir@3.1.0: + dependencies: + semver: 6.3.1 + + make-dir@4.0.0: + dependencies: + semver: 7.6.3 + + make-error@1.3.6: {} + + makeerror@1.0.12: + dependencies: + tmpl: 1.0.5 + + map-obj@5.0.2: {} + + markdown-it@14.1.0: + dependencies: + argparse: 2.0.1 + entities: 4.5.0 + linkify-it: 5.0.0 + mdurl: 2.0.0 + punycode.js: 2.3.1 + uc.micro: 2.1.0 + + markdown-table@3.0.3: {} + + markdownlint-cli@0.41.0: + dependencies: + commander: 12.1.0 + get-stdin: 9.0.0 + glob: 10.4.5 + ignore: 5.3.1 + js-yaml: 4.1.0 + jsonc-parser: 3.2.1 + jsonpointer: 5.0.1 + markdownlint: 0.34.0 + minimatch: 9.0.5 + run-con: 1.3.2 + smol-toml: 1.2.2 + + markdownlint-micromark@0.1.9: {} + + markdownlint@0.34.0: + dependencies: + markdown-it: 14.1.0 + markdownlint-micromark: 0.1.9 + + mathml-tag-names@2.1.3: {} + + maxstache-stream@1.0.4: + dependencies: + maxstache: 1.0.7 + pump: 1.0.3 + split2: 1.1.1 + through2: 2.0.5 + + maxstache@1.0.7: {} + + md5-hex@3.0.1: + dependencies: + blueimp-md5: 2.19.0 + + mdast-util-find-and-replace@3.0.1: + dependencies: + '@types/mdast': 4.0.4 + escape-string-regexp: 5.0.0 + unist-util-is: 6.0.0 + unist-util-visit-parents: 6.0.1 + + mdast-util-from-markdown@2.0.1: + dependencies: + '@types/mdast': 4.0.4 + '@types/unist': 3.0.2 + decode-named-character-reference: 1.0.2 + devlop: 1.1.0 + mdast-util-to-string: 4.0.0 + micromark: 4.0.0 + micromark-util-decode-numeric-character-reference: 2.0.1 + micromark-util-decode-string: 2.0.0 + micromark-util-normalize-identifier: 2.0.0 + micromark-util-symbol: 2.0.0 + micromark-util-types: 2.0.0 + unist-util-stringify-position: 4.0.0 + transitivePeerDependencies: + - supports-color + + mdast-util-gfm-autolink-literal@2.0.0: + dependencies: + '@types/mdast': 4.0.4 + ccount: 2.0.1 + devlop: 1.1.0 + mdast-util-find-and-replace: 3.0.1 + micromark-util-character: 2.1.0 + + mdast-util-gfm-footnote@2.0.0: + dependencies: + '@types/mdast': 4.0.4 + devlop: 1.1.0 + mdast-util-from-markdown: 2.0.1 + mdast-util-to-markdown: 2.1.0 + micromark-util-normalize-identifier: 2.0.0 + transitivePeerDependencies: + - supports-color + + mdast-util-gfm-strikethrough@2.0.0: + dependencies: + '@types/mdast': 4.0.4 + mdast-util-from-markdown: 2.0.1 + mdast-util-to-markdown: 2.1.0 + transitivePeerDependencies: + - supports-color + + mdast-util-gfm-table@2.0.0: + dependencies: + '@types/mdast': 4.0.4 + devlop: 1.1.0 + markdown-table: 3.0.3 + mdast-util-from-markdown: 2.0.1 + mdast-util-to-markdown: 2.1.0 + transitivePeerDependencies: + - supports-color + + mdast-util-gfm-task-list-item@2.0.0: + dependencies: + '@types/mdast': 4.0.4 + devlop: 1.1.0 + mdast-util-from-markdown: 2.0.1 + mdast-util-to-markdown: 2.1.0 + transitivePeerDependencies: + - supports-color + + mdast-util-gfm@3.0.0: + dependencies: + mdast-util-from-markdown: 2.0.1 + mdast-util-gfm-autolink-literal: 2.0.0 + mdast-util-gfm-footnote: 2.0.0 + mdast-util-gfm-strikethrough: 2.0.0 + mdast-util-gfm-table: 2.0.0 + mdast-util-gfm-task-list-item: 2.0.0 + mdast-util-to-markdown: 2.1.0 + transitivePeerDependencies: + - supports-color + + mdast-util-phrasing@4.1.0: + dependencies: + '@types/mdast': 4.0.4 + unist-util-is: 6.0.0 + + mdast-util-to-hast@13.2.0: + dependencies: + '@types/hast': 3.0.4 + '@types/mdast': 4.0.4 + '@ungap/structured-clone': 1.2.0 + devlop: 1.1.0 + micromark-util-sanitize-uri: 2.0.0 + trim-lines: 3.0.1 + unist-util-position: 5.0.0 + unist-util-visit: 5.0.0 + vfile: 6.0.1 + + mdast-util-to-markdown@2.1.0: + dependencies: + '@types/mdast': 4.0.4 + '@types/unist': 3.0.2 + longest-streak: 3.1.0 + mdast-util-phrasing: 4.1.0 + mdast-util-to-string: 4.0.0 + micromark-util-decode-string: 2.0.0 + unist-util-visit: 5.0.0 + zwitch: 2.0.4 + + mdast-util-to-string@4.0.0: + dependencies: + '@types/mdast': 4.0.4 + + mdn-data@2.0.28: {} + + mdn-data@2.0.30: {} + + mdurl@2.0.0: {} + + media-typer@0.3.0: {} + + memoize-one@6.0.0: {} + + meow@13.2.0: {} + + merge-descriptors@1.0.1: {} + + merge-options@3.0.4: + dependencies: + is-plain-obj: 2.1.0 + + merge-stream@2.0.0: {} + + merge2@1.4.1: {} + + methods@1.1.2: {} + + micro-api-client@3.3.0: {} + + micro-memoize@4.1.2: {} + + micromark-core-commonmark@2.0.1: + dependencies: + decode-named-character-reference: 1.0.2 + devlop: 1.1.0 + micromark-factory-destination: 2.0.0 + micromark-factory-label: 2.0.0 + micromark-factory-space: 2.0.0 + micromark-factory-title: 2.0.0 + micromark-factory-whitespace: 2.0.0 + micromark-util-character: 2.1.0 + micromark-util-chunked: 2.0.0 + micromark-util-classify-character: 2.0.0 + micromark-util-html-tag-name: 2.0.0 + micromark-util-normalize-identifier: 2.0.0 + micromark-util-resolve-all: 2.0.0 + micromark-util-subtokenize: 2.0.1 + micromark-util-symbol: 2.0.0 + micromark-util-types: 2.0.0 + + micromark-extension-gfm-autolink-literal@2.1.0: + dependencies: + micromark-util-character: 2.1.0 + micromark-util-sanitize-uri: 2.0.0 + micromark-util-symbol: 2.0.0 + micromark-util-types: 2.0.0 + + micromark-extension-gfm-footnote@2.1.0: + dependencies: + devlop: 1.1.0 + micromark-core-commonmark: 2.0.1 + micromark-factory-space: 2.0.0 + micromark-util-character: 2.1.0 + micromark-util-normalize-identifier: 2.0.0 + micromark-util-sanitize-uri: 2.0.0 + micromark-util-symbol: 2.0.0 + micromark-util-types: 2.0.0 + + micromark-extension-gfm-strikethrough@2.1.0: + dependencies: + devlop: 1.1.0 + micromark-util-chunked: 2.0.0 + micromark-util-classify-character: 2.0.0 + micromark-util-resolve-all: 2.0.0 + micromark-util-symbol: 2.0.0 + micromark-util-types: 2.0.0 + + micromark-extension-gfm-table@2.1.0: + dependencies: + devlop: 1.1.0 + micromark-factory-space: 2.0.0 + micromark-util-character: 2.1.0 + micromark-util-symbol: 2.0.0 + micromark-util-types: 2.0.0 + + micromark-extension-gfm-tagfilter@2.0.0: + dependencies: + micromark-util-types: 2.0.0 + + micromark-extension-gfm-task-list-item@2.1.0: + dependencies: + devlop: 1.1.0 + micromark-factory-space: 2.0.0 + micromark-util-character: 2.1.0 + micromark-util-symbol: 2.0.0 + micromark-util-types: 2.0.0 + + micromark-extension-gfm@3.0.0: + dependencies: + micromark-extension-gfm-autolink-literal: 2.1.0 + micromark-extension-gfm-footnote: 2.1.0 + micromark-extension-gfm-strikethrough: 2.1.0 + micromark-extension-gfm-table: 2.1.0 + micromark-extension-gfm-tagfilter: 2.0.0 + micromark-extension-gfm-task-list-item: 2.1.0 + micromark-util-combine-extensions: 2.0.0 + micromark-util-types: 2.0.0 + + micromark-factory-destination@2.0.0: + dependencies: + micromark-util-character: 2.1.0 + micromark-util-symbol: 2.0.0 + micromark-util-types: 2.0.0 + + micromark-factory-label@2.0.0: + dependencies: + devlop: 1.1.0 + micromark-util-character: 2.1.0 + micromark-util-symbol: 2.0.0 + micromark-util-types: 2.0.0 + + micromark-factory-space@2.0.0: + dependencies: + micromark-util-character: 2.1.0 + micromark-util-types: 2.0.0 + + micromark-factory-title@2.0.0: + dependencies: + micromark-factory-space: 2.0.0 + micromark-util-character: 2.1.0 + micromark-util-symbol: 2.0.0 + micromark-util-types: 2.0.0 + + micromark-factory-whitespace@2.0.0: + dependencies: + micromark-factory-space: 2.0.0 + micromark-util-character: 2.1.0 + micromark-util-symbol: 2.0.0 + micromark-util-types: 2.0.0 + + micromark-util-character@2.1.0: + dependencies: + micromark-util-symbol: 2.0.0 + micromark-util-types: 2.0.0 + + micromark-util-chunked@2.0.0: + dependencies: + micromark-util-symbol: 2.0.0 + + micromark-util-classify-character@2.0.0: + dependencies: + micromark-util-character: 2.1.0 + micromark-util-symbol: 2.0.0 + micromark-util-types: 2.0.0 + + micromark-util-combine-extensions@2.0.0: + dependencies: + micromark-util-chunked: 2.0.0 + micromark-util-types: 2.0.0 + + micromark-util-decode-numeric-character-reference@2.0.1: + dependencies: + micromark-util-symbol: 2.0.0 + + micromark-util-decode-string@2.0.0: + dependencies: + decode-named-character-reference: 1.0.2 + micromark-util-character: 2.1.0 + micromark-util-decode-numeric-character-reference: 2.0.1 + micromark-util-symbol: 2.0.0 + + micromark-util-encode@2.0.0: {} + + micromark-util-html-tag-name@2.0.0: {} + + micromark-util-normalize-identifier@2.0.0: + dependencies: + micromark-util-symbol: 2.0.0 + + micromark-util-resolve-all@2.0.0: + dependencies: + micromark-util-types: 2.0.0 + + micromark-util-sanitize-uri@2.0.0: + dependencies: + micromark-util-character: 2.1.0 + micromark-util-encode: 2.0.0 + micromark-util-symbol: 2.0.0 + + micromark-util-subtokenize@2.0.1: + dependencies: + devlop: 1.1.0 + micromark-util-chunked: 2.0.0 + micromark-util-symbol: 2.0.0 + micromark-util-types: 2.0.0 + + micromark-util-symbol@2.0.0: {} + + micromark-util-types@2.0.0: {} + + micromark@4.0.0: + dependencies: + '@types/debug': 4.1.12 + debug: 4.3.6(supports-color@9.4.0) + decode-named-character-reference: 1.0.2 + devlop: 1.1.0 + micromark-core-commonmark: 2.0.1 + micromark-factory-space: 2.0.0 + micromark-util-character: 2.1.0 + micromark-util-chunked: 2.0.0 + micromark-util-combine-extensions: 2.0.0 + micromark-util-decode-numeric-character-reference: 2.0.1 + micromark-util-encode: 2.0.0 + micromark-util-normalize-identifier: 2.0.0 + micromark-util-resolve-all: 2.0.0 + micromark-util-sanitize-uri: 2.0.0 + micromark-util-subtokenize: 2.0.1 + micromark-util-symbol: 2.0.0 + micromark-util-types: 2.0.0 + transitivePeerDependencies: + - supports-color + + micromatch@4.0.7: + dependencies: + braces: 3.0.3 + picomatch: 2.3.1 + + mime-db@1.52.0: {} + + mime-db@1.53.0: {} + + mime-types@2.1.35: + dependencies: + mime-db: 1.52.0 + + mime@1.6.0: {} + + mime@3.0.0: {} + + mimic-fn@1.2.0: {} + + mimic-fn@2.1.0: {} + + mimic-fn@4.0.0: {} + + mimic-function@5.0.1: {} + + mimic-response@3.1.0: {} + + mimic-response@4.0.0: {} + + min-indent@1.0.1: {} + + minimatch@3.1.2: + dependencies: + brace-expansion: 1.1.11 + + minimatch@5.1.6: + dependencies: + brace-expansion: 2.0.1 + + minimatch@9.0.5: + dependencies: + brace-expansion: 2.0.1 + + minimist@1.2.8: {} + + minipass@3.3.6: + dependencies: + yallist: 4.0.0 + + minipass@5.0.0: {} + + minipass@7.1.2: {} + + minizlib@2.1.2: + dependencies: + minipass: 3.3.6 + yallist: 4.0.0 + + mkdirp-classic@0.5.3: {} + + mkdirp@0.5.6: + dependencies: + minimist: 1.2.8 + + mkdirp@1.0.4: {} + + mlly@1.7.1: + dependencies: + acorn: 8.12.1 + pathe: 1.1.2 + pkg-types: 1.1.3 + ufo: 1.5.4 + + module-definition@5.0.1: + dependencies: + ast-module-types: 5.0.0 + node-source-walk: 6.0.2 + + moize@6.1.6: + dependencies: + fast-equals: 3.0.3 + micro-memoize: 4.1.2 + + move-file@3.1.0: + dependencies: + path-exists: 5.0.0 + + mri@1.2.0: {} + + ms@2.0.0: {} + + ms@2.1.2: {} + + ms@2.1.3: {} + + multiparty@4.2.3: + dependencies: + http-errors: 1.8.1 + safe-buffer: 5.2.1 + uid-safe: 2.1.5 + + mute-stream@0.0.7: {} + + mz@2.7.0: + dependencies: + any-promise: 1.3.0 + object-assign: 4.1.1 + thenify-all: 1.6.0 + + nan@2.20.0: + optional: true + + nanoid@3.3.7: {} + + napi-build-utils@1.0.2: {} + + natural-compare@1.4.0: {} + + negotiator@0.6.3: {} + + nested-error-stacks@2.1.1: {} + + netlify-cli@17.34.1(@types/node@20.15.0)(picomatch@4.0.2): + dependencies: + '@bugsnag/js': 7.25.0 + '@fastify/static': 7.0.4 + '@netlify/blobs': 8.0.0 + '@netlify/build': 29.53.0(@opentelemetry/api@1.8.0)(@types/node@20.15.0)(picomatch@4.0.2) + '@netlify/build-info': 7.14.1 + '@netlify/config': 20.18.0 + '@netlify/edge-bundler': 12.2.3(supports-color@9.4.0) + '@netlify/edge-functions': 2.9.0 + '@netlify/local-functions-proxy': 1.1.1 + '@netlify/zip-it-and-ship-it': 9.37.9(supports-color@9.4.0) + '@octokit/rest': 20.1.1 + '@opentelemetry/api': 1.8.0 + ansi-escapes: 7.0.0 + ansi-styles: 6.2.1 + ansi-to-html: 0.7.2 + ascii-table: 0.0.9 + backoff: 2.5.0 + better-opn: 3.0.2 + boxen: 7.1.1 + chalk: 5.3.0 + chokidar: 3.6.0 + ci-info: 4.0.0 + clean-deep: 3.4.0 + commander: 10.0.1 + comment-json: 4.2.5 + concordance: 5.0.4 + configstore: 6.0.0 + content-type: 1.0.5 + cookie: 0.6.0 + cron-parser: 4.9.0 + debug: 4.3.6(supports-color@9.4.0) + decache: 4.6.2 + dot-prop: 9.0.0 + dotenv: 16.4.5 + env-paths: 3.0.0 + envinfo: 7.13.0 + etag: 1.8.1 + execa: 5.1.1 + express: 4.19.2 + express-logging: 1.1.1 + extract-zip: 2.0.1 + fastest-levenshtein: 1.0.16 + fastify: 4.28.1 + find-up: 7.0.0 + flush-write-stream: 2.0.0 + folder-walker: 3.2.0 + from2-array: 0.0.4 + fuzzy: 0.1.3 + get-port: 5.1.1 + gh-release-fetch: 4.0.3 + git-repo-info: 2.1.1 + gitconfiglocal: 2.1.0 + hasbin: 1.2.3 + hasha: 5.2.2 + http-proxy: 1.18.1(debug@4.3.6) + http-proxy-middleware: 2.0.6(debug@4.3.6) + https-proxy-agent: 7.0.5 + inquirer: 6.5.2 + inquirer-autocomplete-prompt: 1.4.0(inquirer@6.5.2) + ipx: 2.1.0(@netlify/blobs@8.0.0) + is-docker: 3.0.0 + is-stream: 4.0.1 + is-wsl: 3.1.0 + isexe: 3.1.1 + js-yaml: 4.1.0 + jsonwebtoken: 9.0.2 + jwt-decode: 4.0.0 + lambda-local: 2.2.0 + listr2: 8.2.4 + locate-path: 7.2.0 + lodash: 4.17.21 + log-symbols: 6.0.0 + log-update: 6.0.0 + maxstache: 1.0.7 + maxstache-stream: 1.0.4 + multiparty: 4.2.3 + netlify: 13.1.20 + netlify-headers-parser: 7.1.4 + netlify-redirect-parser: 14.3.0 + netlify-redirector: 0.5.0 + node-fetch: 3.3.2 + node-version-alias: 3.4.1 + ora: 8.0.1 + p-filter: 4.1.0 + p-map: 7.0.2 + p-wait-for: 5.0.2 + parallel-transform: 1.2.0 + parse-github-url: 1.0.3 + parse-gitignore: 2.0.0 + path-key: 4.0.0 + prettyjson: 1.2.5 + pump: 3.0.0 + raw-body: 2.5.2 + read-package-up: 11.0.0 + readdirp: 3.6.0 + semver: 7.6.3 + source-map-support: 0.5.21 + strip-ansi-control-characters: 2.0.0 + tabtab: 3.0.2 + tempy: 3.1.0 + terminal-link: 3.0.0 + through2-filter: 4.0.0 + through2-map: 4.0.0 + toml: 3.0.0 + tomlify-j0.4: 3.0.0 + ulid: 2.3.0 + unixify: 1.0.0 + update-notifier: 7.0.0 + uuid: 9.0.1 + wait-port: 1.1.0 + write-file-atomic: 5.0.1 + ws: 8.17.1 + zod: 3.23.8 + transitivePeerDependencies: + - '@azure/app-configuration' + - '@azure/cosmos' + - '@azure/data-tables' + - '@azure/identity' + - '@azure/keyvault-secrets' + - '@azure/storage-blob' + - '@capacitor/preferences' + - '@netlify/opentelemetry-sdk-setup' + - '@planetscale/database' + - '@swc/core' + - '@swc/wasm' + - '@types/express' + - '@types/node' + - '@upstash/redis' + - '@vercel/kv' + - bufferutil + - encoding + - idb-keyval + - ioredis + - picomatch + - supports-color + - uWebSockets.js + - utf-8-validate + + netlify-headers-parser@7.1.4: + dependencies: + '@iarna/toml': 2.2.5 + escape-string-regexp: 5.0.0 + fast-safe-stringify: 2.1.1 + is-plain-obj: 4.1.0 + map-obj: 5.0.2 + path-exists: 5.0.0 + + netlify-redirect-parser@14.3.0: + dependencies: + '@iarna/toml': 2.2.5 + fast-safe-stringify: 2.1.1 + filter-obj: 5.1.0 + is-plain-obj: 4.1.0 + path-exists: 5.0.0 + + netlify-redirector@0.5.0: {} + + netlify@13.1.20: + dependencies: + '@netlify/open-api': 2.34.0 + lodash-es: 4.17.21 + micro-api-client: 3.3.0 + node-fetch: 3.3.2 + omit.js: 2.0.2 + p-wait-for: 4.1.0 + qs: 6.13.0 + + next@14.2.5(@babel/core@7.25.2)(@opentelemetry/api@1.8.0)(@playwright/test@1.46.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.77.8): + dependencies: + '@next/env': 14.2.5 + '@swc/helpers': 0.5.5 + busboy: 1.6.0 + caniuse-lite: 1.0.30001642 + graceful-fs: 4.2.11 + postcss: 8.4.31 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + styled-jsx: 5.1.1(@babel/core@7.25.2)(react@18.3.1) + optionalDependencies: + '@next/swc-darwin-arm64': 14.2.5 + '@next/swc-darwin-x64': 14.2.5 + '@next/swc-linux-arm64-gnu': 14.2.5 + '@next/swc-linux-arm64-musl': 14.2.5 + '@next/swc-linux-x64-gnu': 14.2.5 + '@next/swc-linux-x64-musl': 14.2.5 + '@next/swc-win32-arm64-msvc': 14.2.5 + '@next/swc-win32-ia32-msvc': 14.2.5 + '@next/swc-win32-x64-msvc': 14.2.5 + '@opentelemetry/api': 1.8.0 + '@playwright/test': 1.46.1 + sass: 1.77.8 + transitivePeerDependencies: + - '@babel/core' + - babel-plugin-macros + + node-abi@3.65.0: + dependencies: + semver: 7.6.3 + + node-addon-api@6.1.0: {} + + node-addon-api@7.1.1: {} + + node-domexception@1.0.0: {} + + node-fetch-native@1.6.4: {} + + node-fetch@2.7.0: + dependencies: + whatwg-url: 5.0.0 + + node-fetch@3.3.2: + dependencies: + data-uri-to-buffer: 4.0.1 + fetch-blob: 3.2.0 + formdata-polyfill: 4.0.10 + + node-forge@1.3.1: {} + + node-gyp-build@4.8.1: {} + + node-int64@0.4.0: {} + + node-releases@2.0.18: {} + + node-source-walk@6.0.2: + dependencies: + '@babel/parser': 7.25.3 + + node-stream-zip@1.15.0: {} + + node-version-alias@3.4.1: + dependencies: + all-node-versions: 11.3.0 + filter-obj: 5.1.0 + is-plain-obj: 4.1.0 + normalize-node-version: 12.4.0 + path-exists: 5.0.0 + semver: 7.6.3 + + nopt@5.0.0: + dependencies: + abbrev: 1.1.1 + + normalize-node-version@12.4.0: + dependencies: + all-node-versions: 11.3.0 + filter-obj: 5.1.0 + semver: 7.6.3 + + normalize-package-data@3.0.3: + dependencies: + hosted-git-info: 4.1.0 + is-core-module: 2.15.0 + semver: 7.6.3 + validate-npm-package-license: 3.0.4 + + normalize-package-data@6.0.2: + dependencies: + hosted-git-info: 7.0.2 + semver: 7.6.3 + validate-npm-package-license: 3.0.4 + + normalize-path@2.1.1: + dependencies: + remove-trailing-separator: 1.1.0 + + normalize-path@3.0.0: {} + + normalize-range@0.1.2: {} + + normalize-url@8.0.1: {} + + npm-run-path@4.0.1: + dependencies: + path-key: 3.1.1 + + npm-run-path@5.3.0: + dependencies: + path-key: 4.0.0 + + npmlog@5.0.1: + dependencies: + are-we-there-yet: 2.0.0 + console-control-strings: 1.1.0 + gauge: 3.0.2 + set-blocking: 2.0.0 + + nth-check@2.1.1: + dependencies: + boolbase: 1.0.0 + + nwsapi@2.2.12: {} + + object-assign@4.1.1: {} + + object-hash@3.0.0: {} + + object-inspect@1.13.2: {} + + ofetch@1.3.4: + dependencies: + destr: 2.0.3 + node-fetch-native: 1.6.4 + ufo: 1.5.4 + + ohash@1.1.3: {} + + omit.js@2.0.2: {} + + on-exit-leak-free@2.1.2: {} + + on-finished@2.4.1: + dependencies: + ee-first: 1.1.1 + + on-headers@1.0.2: {} + + once@1.4.0: + dependencies: + wrappy: 1.0.2 + + one-time@1.0.0: + dependencies: + fn.name: 1.1.0 + + onetime@2.0.1: + dependencies: + mimic-fn: 1.2.0 + + onetime@5.1.2: + dependencies: + mimic-fn: 2.1.0 + + onetime@6.0.0: + dependencies: + mimic-fn: 4.0.0 + + onetime@7.0.0: + dependencies: + mimic-function: 5.0.1 + + open@8.4.2: + dependencies: + define-lazy-prop: 2.0.0 + is-docker: 2.2.1 + is-wsl: 2.2.0 + + optionator@0.9.4: + dependencies: + deep-is: 0.1.4 + fast-levenshtein: 2.0.6 + levn: 0.4.1 + prelude-ls: 1.2.1 + type-check: 0.4.0 + word-wrap: 1.2.5 + + ora@8.0.1: + dependencies: + chalk: 5.3.0 + cli-cursor: 4.0.0 + cli-spinners: 2.9.2 + is-interactive: 2.0.0 + is-unicode-supported: 2.0.0 + log-symbols: 6.0.0 + stdin-discarder: 0.2.2 + string-width: 7.2.0 + strip-ansi: 7.1.0 + + os-name@5.1.0: + dependencies: + macos-release: 3.3.0 + windows-release: 5.1.1 + + os-tmpdir@1.0.2: {} + + p-cancelable@3.0.0: {} + + p-event@4.2.0: + dependencies: + p-timeout: 3.2.0 + + p-event@5.0.1: + dependencies: + p-timeout: 5.1.0 + + p-every@2.0.0: + dependencies: + p-map: 2.1.0 + + p-filter@3.0.0: + dependencies: + p-map: 5.5.0 + + p-filter@4.1.0: + dependencies: + p-map: 7.0.2 + + p-finally@1.0.0: {} + + p-limit@2.3.0: + dependencies: + p-try: 2.2.0 + + p-limit@3.1.0: + dependencies: + yocto-queue: 0.1.0 + + p-limit@4.0.0: + dependencies: + yocto-queue: 1.1.1 + + p-locate@4.1.0: + dependencies: + p-limit: 2.3.0 + + p-locate@5.0.0: + dependencies: + p-limit: 3.1.0 + + p-locate@6.0.0: + dependencies: + p-limit: 4.0.0 + + p-map@2.1.0: {} + + p-map@5.5.0: + dependencies: + aggregate-error: 4.0.1 + + p-map@6.0.0: {} + + p-map@7.0.2: {} + + p-reduce@3.0.0: {} + + p-retry@5.1.2: + dependencies: + '@types/retry': 0.12.1 + retry: 0.13.1 + + p-timeout@3.2.0: + dependencies: + p-finally: 1.0.0 + + p-timeout@5.1.0: {} + + p-timeout@6.1.2: {} + + p-try@2.2.0: {} + + p-wait-for@4.1.0: + dependencies: + p-timeout: 5.1.0 + + p-wait-for@5.0.2: + dependencies: + p-timeout: 6.1.2 + + package-json-from-dist@1.0.0: {} + + package-json@8.1.1: + dependencies: + got: 12.6.1 + registry-auth-token: 5.0.2 + registry-url: 6.0.1 + semver: 7.6.3 + + parallel-transform@1.2.0: + dependencies: + cyclist: 1.0.2 + inherits: 2.0.4 + readable-stream: 2.3.8 + + parent-module@1.0.1: + dependencies: + callsites: 3.1.0 + + parse-github-url@1.0.3: {} + + parse-gitignore@2.0.0: {} + + parse-json@5.2.0: + dependencies: + '@babel/code-frame': 7.24.7 + error-ex: 1.3.2 + json-parse-even-better-errors: 2.3.1 + lines-and-columns: 1.2.4 + + parse-json@8.1.0: + dependencies: + '@babel/code-frame': 7.24.7 + index-to-position: 0.1.2 + type-fest: 4.24.0 + + parse-ms@3.0.0: {} + + parse-numeric-range@1.3.0: {} + + parse5@7.1.2: + dependencies: + entities: 4.5.0 + + parseurl@1.3.3: {} + + path-exists@4.0.0: {} + + path-exists@5.0.0: {} + + path-is-absolute@1.0.1: {} + + path-key@3.1.1: {} + + path-key@4.0.0: {} + + path-parse@1.0.7: {} + + path-scurry@1.11.1: + dependencies: + lru-cache: 10.4.3 + minipass: 7.1.2 + + path-to-regexp@0.1.7: {} + + path-type@4.0.0: {} + + path-type@5.0.0: {} + + pathe@1.1.2: {} + + peek-readable@5.1.4: {} + + pend@1.2.0: {} + + picocolors@1.0.1: {} + + picomatch@2.3.1: {} + + picomatch@4.0.2: + optional: true + + pify@2.3.0: {} + + pino-abstract-transport@1.2.0: + dependencies: + readable-stream: 4.5.2 + split2: 4.2.0 + + pino-std-serializers@7.0.0: {} + + pino@9.3.2: + dependencies: + atomic-sleep: 1.0.0 + fast-redact: 3.5.0 + on-exit-leak-free: 2.1.2 + pino-abstract-transport: 1.2.0 + pino-std-serializers: 7.0.0 + process-warning: 4.0.0 + quick-format-unescaped: 4.0.4 + real-require: 0.2.0 + safe-stable-stringify: 2.4.3 + sonic-boom: 4.0.1 + thread-stream: 3.1.0 + + pirates@4.0.6: {} + + pkg-dir@4.2.0: + dependencies: + find-up: 4.1.0 + + pkg-dir@7.0.0: + dependencies: + find-up: 6.3.0 + + pkg-types@1.1.3: + dependencies: + confbox: 0.1.7 + mlly: 1.7.1 + pathe: 1.1.2 + + playwright-core@1.46.1: {} + + playwright@1.46.1: + dependencies: + playwright-core: 1.46.1 + optionalDependencies: + fsevents: 2.3.2 + + postcss-import@15.1.0(postcss@8.4.41): + dependencies: + postcss: 8.4.41 + postcss-value-parser: 4.2.0 + read-cache: 1.0.0 + resolve: 1.22.8 + + postcss-js@4.0.1(postcss@8.4.41): + dependencies: + camelcase-css: 2.0.1 + postcss: 8.4.41 + + postcss-load-config@4.0.2(postcss@8.4.41)(ts-node@10.9.2(@types/node@20.15.0)(typescript@5.5.4)): + dependencies: + lilconfig: 3.1.2 + yaml: 2.5.0 + optionalDependencies: + postcss: 8.4.41 + ts-node: 10.9.2(@types/node@20.15.0)(typescript@5.5.4) + + postcss-media-query-parser@0.2.3: {} + + postcss-nested@6.0.1(postcss@8.4.41): + dependencies: + postcss: 8.4.41 + postcss-selector-parser: 6.1.1 + + postcss-resolve-nested-selector@0.1.6: {} + + postcss-safe-parser@7.0.0(postcss@8.4.41): + dependencies: + postcss: 8.4.41 + + postcss-scss@4.0.9(postcss@8.4.41): + dependencies: + postcss: 8.4.41 + + postcss-selector-parser@6.1.1: + dependencies: + cssesc: 3.0.0 + util-deprecate: 1.0.2 + + postcss-selector-parser@6.1.2: + dependencies: + cssesc: 3.0.0 + util-deprecate: 1.0.2 + + postcss-value-parser@4.2.0: {} + + postcss-values-parser@6.0.2(postcss@8.4.41): + dependencies: + color-name: 1.1.4 + is-url-superb: 4.0.0 + postcss: 8.4.41 + quote-unquote: 1.0.0 + + postcss@8.4.31: + dependencies: + nanoid: 3.3.7 + picocolors: 1.0.1 + source-map-js: 1.2.0 + + postcss@8.4.41: + dependencies: + nanoid: 3.3.7 + picocolors: 1.0.1 + source-map-js: 1.2.0 + + prebuild-install@7.1.2: + dependencies: + detect-libc: 2.0.3 + expand-template: 2.0.3 + github-from-package: 0.0.0 + minimist: 1.2.8 + mkdirp-classic: 0.5.3 + napi-build-utils: 1.0.2 + node-abi: 3.65.0 + pump: 3.0.0 + rc: 1.2.8 + simple-get: 4.0.1 + tar-fs: 2.1.1 + tunnel-agent: 0.6.0 + + precinct@11.0.5(supports-color@9.4.0): + dependencies: + '@dependents/detective-less': 4.1.0 + commander: 10.0.1 + detective-amd: 5.0.2 + detective-cjs: 5.0.1 + detective-es6: 4.0.1 + detective-postcss: 6.1.3 + detective-sass: 5.0.3 + detective-scss: 4.0.3 + detective-stylus: 4.0.0 + detective-typescript: 11.2.0(supports-color@9.4.0) + module-definition: 5.0.1 + node-source-walk: 6.0.2 + transitivePeerDependencies: + - supports-color + + precond@0.2.3: {} + + prelude-ls@1.2.1: {} + + prettier@3.3.3: {} + + pretty-format@27.5.1: + dependencies: + ansi-regex: 5.0.1 + ansi-styles: 5.2.0 + react-is: 17.0.2 + + pretty-format@29.7.0: + dependencies: + '@jest/schemas': 29.6.3 + ansi-styles: 5.2.0 + react-is: 18.3.1 + + pretty-ms@8.0.0: + dependencies: + parse-ms: 3.0.0 + + prettyjson@1.2.5: + dependencies: + colors: 1.4.0 + minimist: 1.2.8 + + process-nextick-args@2.0.1: {} + + process-warning@3.0.0: {} + + process-warning@4.0.0: {} + + process@0.11.10: {} + + prompts@2.4.2: + dependencies: + kleur: 3.0.3 + sisteransi: 1.0.5 + + property-information@6.5.0: {} + + proto-list@1.2.4: {} + + proxy-addr@2.0.7: + dependencies: + forwarded: 0.2.0 + ipaddr.js: 1.9.1 + + ps-list@8.1.1: {} + + psl@1.9.0: {} + + pump@1.0.3: + dependencies: + end-of-stream: 1.4.4 + once: 1.4.0 + + pump@3.0.0: + dependencies: + end-of-stream: 1.4.4 + once: 1.4.0 + + punycode.js@2.3.1: {} + + punycode@2.3.1: {} + + pupa@3.1.0: + dependencies: + escape-goat: 4.0.0 + + pure-rand@6.1.0: {} + + qs@6.11.0: + dependencies: + side-channel: 1.0.6 + + qs@6.13.0: + dependencies: + side-channel: 1.0.6 + + querystringify@2.2.0: {} + + queue-microtask@1.2.3: {} + + queue-tick@1.0.1: {} + + quick-format-unescaped@4.0.4: {} + + quick-lru@5.1.1: {} + + quote-unquote@1.0.0: {} + + radix3@1.1.2: {} + + random-bytes@1.0.0: {} + + range-parser@1.2.1: {} + + raw-body@2.5.2: + dependencies: + bytes: 3.1.2 + http-errors: 2.0.0 + iconv-lite: 0.4.24 + unpipe: 1.0.0 + + rc@1.2.8: + dependencies: + deep-extend: 0.6.0 + ini: 1.3.8 + minimist: 1.2.8 + strip-json-comments: 2.0.1 + + react-dom@18.3.1(react@18.3.1): + dependencies: + loose-envify: 1.4.0 + react: 18.3.1 + scheduler: 0.23.2 + + react-is@17.0.2: {} + + react-is@18.3.1: {} + + react@18.3.1: + dependencies: + loose-envify: 1.4.0 + + read-cache@1.0.0: + dependencies: + pify: 2.3.0 + + read-package-up@11.0.0: + dependencies: + find-up-simple: 1.0.0 + read-pkg: 9.0.1 + type-fest: 4.24.0 + + read-pkg-up@9.1.0: + dependencies: + find-up: 6.3.0 + read-pkg: 7.1.0 + type-fest: 2.19.0 + + read-pkg@7.1.0: + dependencies: + '@types/normalize-package-data': 2.4.4 + normalize-package-data: 3.0.3 + parse-json: 5.2.0 + type-fest: 2.19.0 + + read-pkg@9.0.1: + dependencies: + '@types/normalize-package-data': 2.4.4 + normalize-package-data: 6.0.2 + parse-json: 8.1.0 + type-fest: 4.24.0 + unicorn-magic: 0.1.0 + + readable-stream@2.3.8: + dependencies: + core-util-is: 1.0.3 + inherits: 2.0.4 + isarray: 1.0.0 + process-nextick-args: 2.0.1 + safe-buffer: 5.1.2 + string_decoder: 1.1.1 + util-deprecate: 1.0.2 + + readable-stream@3.6.2: + dependencies: + inherits: 2.0.4 + string_decoder: 1.3.0 + util-deprecate: 1.0.2 + + readable-stream@4.5.2: + dependencies: + abort-controller: 3.0.0 + buffer: 6.0.3 + events: 3.3.0 + process: 0.11.10 + string_decoder: 1.3.0 + + readable-web-to-node-stream@3.0.2: + dependencies: + readable-stream: 3.6.2 + + readdir-glob@1.1.3: + dependencies: + minimatch: 5.1.6 + + readdirp@3.6.0: + dependencies: + picomatch: 2.3.1 + + real-require@0.2.0: {} + + redent@3.0.0: + dependencies: + indent-string: 4.0.0 + strip-indent: 3.0.0 + + regenerator-runtime@0.14.1: {} + + registry-auth-token@5.0.2: + dependencies: + '@pnpm/npm-conf': 2.3.1 + + registry-url@6.0.1: + dependencies: + rc: 1.2.8 + + rehype-autolink-headings@7.1.0: + dependencies: + '@types/hast': 3.0.4 + '@ungap/structured-clone': 1.2.0 + hast-util-heading-rank: 3.0.0 + hast-util-is-element: 3.0.0 + unified: 11.0.5 + unist-util-visit: 5.0.0 + + rehype-parse@9.0.0: + dependencies: + '@types/hast': 3.0.4 + hast-util-from-html: 2.0.1 + unified: 11.0.5 + + rehype-pretty-code@0.13.2(shiki@1.3.0): + dependencies: + '@types/hast': 3.0.4 + hast-util-to-string: 3.0.0 + parse-numeric-range: 1.3.0 + rehype-parse: 9.0.0 + shiki: 1.3.0 + unified: 11.0.5 + unist-util-visit: 5.0.0 + + rehype-slug@6.0.0: + dependencies: + '@types/hast': 3.0.4 + github-slugger: 2.0.0 + hast-util-heading-rank: 3.0.0 + hast-util-to-string: 3.0.0 + unist-util-visit: 5.0.0 + + rehype-stringify@10.0.0: + dependencies: + '@types/hast': 3.0.4 + hast-util-to-html: 9.0.1 + unified: 11.0.5 + + remark-gfm@4.0.0: + dependencies: + '@types/mdast': 4.0.4 + mdast-util-gfm: 3.0.0 + micromark-extension-gfm: 3.0.0 + remark-parse: 11.0.0 + remark-stringify: 11.0.0 + unified: 11.0.5 + transitivePeerDependencies: + - supports-color + + remark-html@16.0.1: + dependencies: + '@types/mdast': 4.0.4 + hast-util-sanitize: 5.0.1 + hast-util-to-html: 9.0.1 + mdast-util-to-hast: 13.2.0 + unified: 11.0.5 + + remark-parse@11.0.0: + dependencies: + '@types/mdast': 4.0.4 + mdast-util-from-markdown: 2.0.1 + micromark-util-types: 2.0.0 + unified: 11.0.5 + transitivePeerDependencies: + - supports-color + + remark-rehype@11.1.0: + dependencies: + '@types/hast': 3.0.4 + '@types/mdast': 4.0.4 + mdast-util-to-hast: 13.2.0 + unified: 11.0.5 + vfile: 6.0.1 + + remark-stringify@11.0.0: + dependencies: + '@types/mdast': 4.0.4 + mdast-util-to-markdown: 2.1.0 + unified: 11.0.5 + + remove-trailing-separator@1.1.0: {} + + repeat-string@1.6.1: {} + + require-directory@2.1.1: {} + + require-from-string@2.0.2: {} + + require-package-name@2.0.1: {} + + requires-port@1.0.0: {} + + resolve-alpn@1.2.1: {} + + resolve-cwd@3.0.0: + dependencies: + resolve-from: 5.0.0 + + resolve-from@4.0.0: {} + + resolve-from@5.0.0: {} + + resolve.exports@2.0.2: {} + + resolve@1.22.8: + dependencies: + is-core-module: 2.15.0 + path-parse: 1.0.7 + supports-preserve-symlinks-flag: 1.0.0 + + resolve@2.0.0-next.5: + dependencies: + is-core-module: 2.15.0 + path-parse: 1.0.7 + supports-preserve-symlinks-flag: 1.0.0 + + responselike@3.0.0: + dependencies: + lowercase-keys: 3.0.0 + + restore-cursor@2.0.0: + dependencies: + onetime: 2.0.1 + signal-exit: 3.0.7 + + restore-cursor@4.0.0: + dependencies: + onetime: 5.1.2 + signal-exit: 3.0.7 + + restore-cursor@5.1.0: + dependencies: + onetime: 7.0.0 + signal-exit: 4.1.0 + + ret@0.4.3: {} + + retry@0.13.1: {} + + reusify@1.0.4: {} + + rfdc@1.4.1: {} + + rimraf@3.0.2: + dependencies: + glob: 7.2.3 + + run-async@2.4.1: {} + + run-con@1.3.2: + dependencies: + deep-extend: 0.6.0 + ini: 4.1.3 + minimist: 1.2.8 + strip-json-comments: 3.1.1 + + run-parallel@1.2.0: + dependencies: + queue-microtask: 1.2.3 + + rxjs@6.6.7: + dependencies: + tslib: 1.14.1 + + safe-buffer@5.1.2: {} + + safe-buffer@5.2.1: {} + + safe-json-stringify@1.2.0: {} + + safe-regex2@3.1.0: + dependencies: + ret: 0.4.3 + + safe-stable-stringify@2.4.3: {} + + safer-buffer@2.1.2: {} + + sass@1.77.8: + dependencies: + chokidar: 3.6.0 + immutable: 4.3.6 + source-map-js: 1.2.0 + + saxes@6.0.0: + dependencies: + xmlchars: 2.2.0 + + scheduler@0.23.2: + dependencies: + loose-envify: 1.4.0 + + section-matter@1.0.0: + dependencies: + extend-shallow: 2.0.1 + kind-of: 6.0.3 + + secure-json-parse@2.7.0: {} + + seek-bzip@1.0.6: + dependencies: + commander: 2.20.3 + + semver-diff@4.0.0: + dependencies: + semver: 7.6.3 + + semver@6.3.1: {} + + semver@7.6.3: {} + + send@0.18.0: + dependencies: + debug: 2.6.9 + depd: 2.0.0 + destroy: 1.2.0 + encodeurl: 1.0.2 + escape-html: 1.0.3 + etag: 1.8.1 + fresh: 0.5.2 + http-errors: 2.0.0 + mime: 1.6.0 + ms: 2.1.3 + on-finished: 2.4.1 + range-parser: 1.2.1 + statuses: 2.0.1 + transitivePeerDependencies: + - supports-color + + serve-static@1.15.0: + dependencies: + encodeurl: 1.0.2 + escape-html: 1.0.3 + parseurl: 1.3.3 + send: 0.18.0 + transitivePeerDependencies: + - supports-color + + set-blocking@2.0.0: {} + + set-cookie-parser@2.7.0: {} + + set-function-length@1.2.2: + dependencies: + define-data-property: 1.1.4 + es-errors: 1.3.0 + function-bind: 1.1.2 + get-intrinsic: 1.2.4 + gopd: 1.0.1 + has-property-descriptors: 1.0.2 + + setprototypeof@1.2.0: {} + + sharp@0.32.6: + dependencies: + color: 4.2.3 + detect-libc: 2.0.3 + node-addon-api: 6.1.0 + prebuild-install: 7.1.2 + semver: 7.6.3 + simple-get: 4.0.1 + tar-fs: 3.0.6 + tunnel-agent: 0.6.0 + + shebang-command@2.0.0: + dependencies: + shebang-regex: 3.0.0 + + shebang-regex@3.0.0: {} + + shiki@1.3.0: + dependencies: + '@shikijs/core': 1.3.0 + + side-channel@1.0.6: + dependencies: + call-bind: 1.0.7 + es-errors: 1.3.0 + get-intrinsic: 1.2.4 + object-inspect: 1.13.2 + + signal-exit@3.0.7: {} + + signal-exit@4.1.0: {} + + simple-concat@1.0.1: {} + + simple-get@4.0.1: + dependencies: + decompress-response: 6.0.0 + once: 1.4.0 + simple-concat: 1.0.1 + + simple-swizzle@0.2.2: + dependencies: + is-arrayish: 0.3.2 + + sisteransi@1.0.5: {} + + slash@3.0.0: {} + + slash@4.0.0: {} + + slice-ansi@4.0.0: + dependencies: + ansi-styles: 4.3.0 + astral-regex: 2.0.0 + is-fullwidth-code-point: 3.0.0 + + slice-ansi@5.0.0: + dependencies: + ansi-styles: 6.2.1 + is-fullwidth-code-point: 4.0.0 + + slice-ansi@7.1.0: + dependencies: + ansi-styles: 6.2.1 + is-fullwidth-code-point: 5.0.0 + + smol-toml@1.2.2: {} + + sonic-boom@4.0.1: + dependencies: + atomic-sleep: 1.0.0 + + sort-keys-length@1.0.1: + dependencies: + sort-keys: 1.1.2 + + sort-keys@1.1.2: + dependencies: + is-plain-obj: 1.1.0 + + source-map-js@1.2.0: {} + + source-map-support@0.5.13: + dependencies: + buffer-from: 1.1.2 + source-map: 0.6.1 + + source-map-support@0.5.21: + dependencies: + buffer-from: 1.1.2 + source-map: 0.6.1 + + source-map@0.6.1: {} + + space-separated-tokens@2.0.2: {} + + spdx-correct@3.2.0: + dependencies: + spdx-expression-parse: 3.0.1 + spdx-license-ids: 3.0.18 + + spdx-exceptions@2.5.0: {} + + spdx-expression-parse@3.0.1: + dependencies: + spdx-exceptions: 2.5.0 + spdx-license-ids: 3.0.18 + + spdx-license-ids@3.0.18: {} + + split2@1.1.1: + dependencies: + through2: 2.0.5 + + split2@4.2.0: {} + + sprintf-js@1.0.3: {} + + stack-generator@2.0.10: + dependencies: + stackframe: 1.3.4 + + stack-trace@0.0.10: {} + + stack-utils@2.0.6: + dependencies: + escape-string-regexp: 2.0.0 + + stackframe@1.3.4: {} + + statuses@1.5.0: {} + + statuses@2.0.1: {} + + std-env@3.7.0: {} + + stdin-discarder@0.2.2: {} + + streamsearch@1.1.0: {} + + streamx@2.18.0: + dependencies: + fast-fifo: 1.3.2 + queue-tick: 1.0.1 + text-decoder: 1.1.1 + optionalDependencies: + bare-events: 2.4.2 + + string-length@4.0.2: + dependencies: + char-regex: 1.0.2 + strip-ansi: 6.0.1 + + string-width@2.1.1: + dependencies: + is-fullwidth-code-point: 2.0.0 + strip-ansi: 4.0.0 + + string-width@4.2.3: + dependencies: + emoji-regex: 8.0.0 + is-fullwidth-code-point: 3.0.0 + strip-ansi: 6.0.1 + + string-width@5.1.2: + dependencies: + eastasianwidth: 0.2.0 + emoji-regex: 9.2.2 + strip-ansi: 7.1.0 + + string-width@7.2.0: + dependencies: + emoji-regex: 10.3.0 + get-east-asian-width: 1.2.0 + strip-ansi: 7.1.0 + + string_decoder@1.1.1: + dependencies: + safe-buffer: 5.1.2 + + string_decoder@1.3.0: + dependencies: + safe-buffer: 5.2.1 + + stringify-entities@4.0.4: + dependencies: + character-entities-html4: 2.1.0 + character-entities-legacy: 3.0.0 + + strip-ansi-control-characters@2.0.0: {} + + strip-ansi@4.0.0: + dependencies: + ansi-regex: 3.0.1 + + strip-ansi@5.2.0: + dependencies: + ansi-regex: 4.1.1 + + strip-ansi@6.0.1: + dependencies: + ansi-regex: 5.0.1 + + strip-ansi@7.1.0: + dependencies: + ansi-regex: 6.0.1 + + strip-bom-string@1.0.0: {} + + strip-bom@4.0.0: {} + + strip-dirs@3.0.0: + dependencies: + inspect-with-kind: 1.0.5 + is-plain-obj: 1.1.0 + + strip-final-newline@2.0.0: {} + + strip-final-newline@3.0.0: {} + + strip-indent@3.0.0: + dependencies: + min-indent: 1.0.1 + + strip-json-comments@2.0.1: {} + + strip-json-comments@3.1.1: {} + + strip-outer@2.0.0: {} + + strtok3@7.1.1: + dependencies: + '@tokenizer/token': 0.3.0 + peek-readable: 5.1.4 + + styled-jsx@5.1.1(@babel/core@7.25.2)(react@18.3.1): + dependencies: + client-only: 0.0.1 + react: 18.3.1 + optionalDependencies: + '@babel/core': 7.25.2 + + stylelint-config-recommended-scss@14.1.0(postcss@8.4.41)(stylelint@16.8.2(typescript@5.5.4)): + dependencies: + postcss-scss: 4.0.9(postcss@8.4.41) + stylelint: 16.8.2(typescript@5.5.4) + stylelint-config-recommended: 14.0.1(stylelint@16.8.2(typescript@5.5.4)) + stylelint-scss: 6.4.1(stylelint@16.8.2(typescript@5.5.4)) + optionalDependencies: + postcss: 8.4.41 + + stylelint-config-recommended@14.0.1(stylelint@16.8.2(typescript@5.5.4)): + dependencies: + stylelint: 16.8.2(typescript@5.5.4) + + stylelint-config-standard-scss@13.1.0(postcss@8.4.41)(stylelint@16.8.2(typescript@5.5.4)): + dependencies: + stylelint: 16.8.2(typescript@5.5.4) + stylelint-config-recommended-scss: 14.1.0(postcss@8.4.41)(stylelint@16.8.2(typescript@5.5.4)) + stylelint-config-standard: 36.0.1(stylelint@16.8.2(typescript@5.5.4)) + optionalDependencies: + postcss: 8.4.41 + + stylelint-config-standard@36.0.1(stylelint@16.8.2(typescript@5.5.4)): + dependencies: + stylelint: 16.8.2(typescript@5.5.4) + stylelint-config-recommended: 14.0.1(stylelint@16.8.2(typescript@5.5.4)) + + stylelint-scss@6.4.1(stylelint@16.8.2(typescript@5.5.4)): + dependencies: + known-css-properties: 0.34.0 + postcss-media-query-parser: 0.2.3 + postcss-resolve-nested-selector: 0.1.6 + postcss-selector-parser: 6.1.2 + postcss-value-parser: 4.2.0 + stylelint: 16.8.2(typescript@5.5.4) + + stylelint@16.8.2(typescript@5.5.4): + dependencies: + '@csstools/css-parser-algorithms': 3.0.0(@csstools/css-tokenizer@3.0.0) + '@csstools/css-tokenizer': 3.0.0 + '@csstools/media-query-list-parser': 3.0.0(@csstools/css-parser-algorithms@3.0.0(@csstools/css-tokenizer@3.0.0))(@csstools/css-tokenizer@3.0.0) + '@csstools/selector-specificity': 4.0.0(postcss-selector-parser@6.1.2) + '@dual-bundle/import-meta-resolve': 4.1.0 + balanced-match: 2.0.0 + colord: 2.9.3 + cosmiconfig: 9.0.0(typescript@5.5.4) + css-functions-list: 3.2.2 + css-tree: 2.3.1 + debug: 4.3.6(supports-color@9.4.0) + fast-glob: 3.3.2 + fastest-levenshtein: 1.0.16 + file-entry-cache: 9.0.0 + global-modules: 2.0.0 + globby: 11.1.0 + globjoin: 0.1.4 + html-tags: 3.3.1 + ignore: 5.3.2 + imurmurhash: 0.1.4 + is-plain-object: 5.0.0 + known-css-properties: 0.34.0 + mathml-tag-names: 2.1.3 + meow: 13.2.0 + micromatch: 4.0.7 + normalize-path: 3.0.0 + picocolors: 1.0.1 + postcss: 8.4.41 + postcss-resolve-nested-selector: 0.1.6 + postcss-safe-parser: 7.0.0(postcss@8.4.41) + postcss-selector-parser: 6.1.2 + postcss-value-parser: 4.2.0 + resolve-from: 5.0.0 + string-width: 4.2.3 + strip-ansi: 7.1.0 + supports-hyperlinks: 3.0.0 + svg-tags: 1.0.0 + table: 6.8.2 + write-file-atomic: 5.0.1 + transitivePeerDependencies: + - supports-color + - typescript + + sucrase@3.35.0: + dependencies: + '@jridgewell/gen-mapping': 0.3.5 + commander: 4.1.1 + glob: 10.4.5 + lines-and-columns: 1.2.4 + mz: 2.7.0 + pirates: 4.0.6 + ts-interface-checker: 0.1.13 + + supports-color@5.5.0: + dependencies: + has-flag: 3.0.0 + + supports-color@7.2.0: + dependencies: + has-flag: 4.0.0 + + supports-color@8.1.1: + dependencies: + has-flag: 4.0.0 + + supports-color@9.4.0: {} + + supports-hyperlinks@2.3.0: + dependencies: + has-flag: 4.0.0 + supports-color: 7.2.0 + + supports-hyperlinks@3.0.0: + dependencies: + has-flag: 4.0.0 + supports-color: 7.2.0 + + supports-preserve-symlinks-flag@1.0.0: {} + + svg-tags@1.0.0: {} + + svgo@3.3.2: + dependencies: + '@trysound/sax': 0.2.0 + commander: 7.2.0 + css-select: 5.1.0 + css-tree: 2.3.1 + css-what: 6.1.0 + csso: 5.0.5 + picocolors: 1.0.1 + + symbol-tree@3.2.4: {} + + system-architecture@0.1.0: {} + + table@6.8.2: + dependencies: + ajv: 8.17.1 + lodash.truncate: 4.4.2 + slice-ansi: 4.0.0 + string-width: 4.2.3 + strip-ansi: 6.0.1 + + tabtab@3.0.2: + dependencies: + debug: 4.3.6(supports-color@9.4.0) + es6-promisify: 6.1.1 + inquirer: 6.5.2 + minimist: 1.2.8 + mkdirp: 0.5.6 + untildify: 3.0.3 + transitivePeerDependencies: + - supports-color + + tailwindcss@3.4.10(ts-node@10.9.2(@types/node@20.15.0)(typescript@5.5.4)): + dependencies: + '@alloc/quick-lru': 5.2.0 + arg: 5.0.2 + chokidar: 3.6.0 + didyoumean: 1.2.2 + dlv: 1.1.3 + fast-glob: 3.3.2 + glob-parent: 6.0.2 + is-glob: 4.0.3 + jiti: 1.21.6 + lilconfig: 2.1.0 + micromatch: 4.0.7 + normalize-path: 3.0.0 + object-hash: 3.0.0 + picocolors: 1.0.1 + postcss: 8.4.41 + postcss-import: 15.1.0(postcss@8.4.41) + postcss-js: 4.0.1(postcss@8.4.41) + postcss-load-config: 4.0.2(postcss@8.4.41)(ts-node@10.9.2(@types/node@20.15.0)(typescript@5.5.4)) + postcss-nested: 6.0.1(postcss@8.4.41) + postcss-selector-parser: 6.1.1 + resolve: 1.22.8 + sucrase: 3.35.0 + transitivePeerDependencies: + - ts-node + + tar-fs@2.1.1: + dependencies: + chownr: 1.1.4 + mkdirp-classic: 0.5.3 + pump: 3.0.0 + tar-stream: 2.2.0 + + tar-fs@3.0.6: + dependencies: + pump: 3.0.0 + tar-stream: 3.1.7 + optionalDependencies: + bare-fs: 2.3.1 + bare-path: 2.1.3 + + tar-stream@2.2.0: + dependencies: + bl: 4.1.0 + end-of-stream: 1.4.4 + fs-constants: 1.0.0 + inherits: 2.0.4 + readable-stream: 3.6.2 + + tar-stream@3.1.7: + dependencies: + b4a: 1.6.6 + fast-fifo: 1.3.2 + streamx: 2.18.0 + + tar@6.2.1: + dependencies: + chownr: 2.0.0 + fs-minipass: 2.1.0 + minipass: 5.0.0 + minizlib: 2.1.2 + mkdirp: 1.0.4 + yallist: 4.0.0 + + temp-dir@3.0.0: {} + + tempy@3.1.0: + dependencies: + is-stream: 3.0.0 + temp-dir: 3.0.0 + type-fest: 2.19.0 + unique-string: 3.0.0 + + terminal-link@3.0.0: + dependencies: + ansi-escapes: 5.0.0 + supports-hyperlinks: 2.3.0 + + test-exclude@6.0.0: + dependencies: + '@istanbuljs/schema': 0.1.3 + glob: 7.2.3 + minimatch: 3.1.2 + + text-decoder@1.1.1: + dependencies: + b4a: 1.6.6 + + text-hex@1.0.0: {} + + text-table@0.2.0: {} + + thenify-all@1.6.0: + dependencies: + thenify: 3.3.1 + + thenify@3.3.1: + dependencies: + any-promise: 1.3.0 + + thread-stream@3.1.0: + dependencies: + real-require: 0.2.0 + + through2-filter@4.0.0: + dependencies: + through2: 4.0.2 + + through2-map@4.0.0: + dependencies: + through2: 4.0.2 + + through2@2.0.5: + dependencies: + readable-stream: 2.3.8 + xtend: 4.0.2 + + through2@4.0.2: + dependencies: + readable-stream: 3.6.2 + + through@2.3.8: {} + + time-zone@1.0.0: {} + + tmp-promise@3.0.3: + dependencies: + tmp: 0.2.3 + + tmp@0.0.33: + dependencies: + os-tmpdir: 1.0.2 + + tmp@0.2.3: {} + + tmpl@1.0.5: {} + + to-fast-properties@2.0.0: {} + + to-regex-range@5.0.1: + dependencies: + is-number: 7.0.0 + + toad-cache@3.7.0: {} + + toidentifier@1.0.1: {} + + token-types@5.0.1: + dependencies: + '@tokenizer/token': 0.3.0 + ieee754: 1.2.1 + + toml@3.0.0: {} + + tomlify-j0.4@3.0.0: {} + + tough-cookie@4.1.4: + dependencies: + psl: 1.9.0 + punycode: 2.3.1 + universalify: 0.2.0 + url-parse: 1.5.10 + + tr46@0.0.3: {} + + tr46@3.0.0: + dependencies: + punycode: 2.3.1 + + trim-lines@3.0.1: {} + + trim-repeated@2.0.0: + dependencies: + escape-string-regexp: 5.0.0 + + triple-beam@1.4.1: {} + + trough@2.2.0: {} + + ts-api-utils@1.3.0(typescript@5.5.4): + dependencies: + typescript: 5.5.4 + + ts-interface-checker@0.1.13: {} + + ts-node@10.9.2(@types/node@20.15.0)(typescript@5.5.4): + dependencies: + '@cspotcode/source-map-support': 0.8.1 + '@tsconfig/node10': 1.0.11 + '@tsconfig/node12': 1.0.11 + '@tsconfig/node14': 1.0.3 + '@tsconfig/node16': 1.0.4 + '@types/node': 20.15.0 + acorn: 8.12.1 + acorn-walk: 8.3.3 + arg: 4.1.3 + create-require: 1.1.1 + diff: 4.0.2 + make-error: 1.3.6 + typescript: 5.5.4 + v8-compile-cache-lib: 3.0.1 + yn: 3.1.1 + + tslib@1.14.1: {} + + tslib@2.6.3: {} + + tsutils@3.21.0(typescript@5.5.4): + dependencies: + tslib: 1.14.1 + typescript: 5.5.4 + + tunnel-agent@0.6.0: + dependencies: + safe-buffer: 5.2.1 + + type-check@0.4.0: + dependencies: + prelude-ls: 1.2.1 + + type-detect@4.0.8: {} + + type-fest@0.21.3: {} + + type-fest@0.8.1: {} + + type-fest@1.4.0: {} + + type-fest@2.19.0: {} + + type-fest@4.24.0: {} + + type-is@1.6.18: + dependencies: + media-typer: 0.3.0 + mime-types: 2.1.35 + + typedarray-to-buffer@3.1.5: + dependencies: + is-typedarray: 1.0.0 + + typescript-eslint@8.1.0(eslint@9.9.0(jiti@1.21.6))(typescript@5.5.4): + dependencies: + '@typescript-eslint/eslint-plugin': 8.1.0(@typescript-eslint/parser@8.1.0(eslint@9.9.0(jiti@1.21.6))(typescript@5.5.4))(eslint@9.9.0(jiti@1.21.6))(typescript@5.5.4) + '@typescript-eslint/parser': 8.1.0(eslint@9.9.0(jiti@1.21.6))(typescript@5.5.4) + '@typescript-eslint/utils': 8.1.0(eslint@9.9.0(jiti@1.21.6))(typescript@5.5.4) + optionalDependencies: + typescript: 5.5.4 + transitivePeerDependencies: + - eslint + - supports-color + + typescript@5.5.4: {} + + uc.micro@2.1.0: {} + + ufo@1.5.4: {} + + uid-safe@2.1.5: + dependencies: + random-bytes: 1.0.0 + + ulid@2.3.0: {} + + unbzip2-stream@1.4.3: + dependencies: + buffer: 5.7.1 + through: 2.3.8 + + uncrypto@0.1.3: {} + + undici-types@6.13.0: {} + + unenv@1.10.0: + dependencies: + consola: 3.2.3 + defu: 6.1.4 + mime: 3.0.0 + node-fetch-native: 1.6.4 + pathe: 1.1.2 + + unicorn-magic@0.1.0: {} + + unified@11.0.5: + dependencies: + '@types/unist': 3.0.2 + bail: 2.0.2 + devlop: 1.1.0 + extend: 3.0.2 + is-plain-obj: 4.1.0 + trough: 2.2.0 + vfile: 6.0.1 + + unique-string@3.0.0: + dependencies: + crypto-random-string: 4.0.0 + + unist-util-is@6.0.0: + dependencies: + '@types/unist': 3.0.2 + + unist-util-position@5.0.0: + dependencies: + '@types/unist': 3.0.2 + + unist-util-stringify-position@4.0.0: + dependencies: + '@types/unist': 3.0.2 + + unist-util-visit-parents@6.0.1: + dependencies: + '@types/unist': 3.0.2 + unist-util-is: 6.0.0 + + unist-util-visit@5.0.0: + dependencies: + '@types/unist': 3.0.2 + unist-util-is: 6.0.0 + unist-util-visit-parents: 6.0.1 + + universal-user-agent@6.0.1: {} + + universalify@0.2.0: {} + + unix-dgram@2.0.6: + dependencies: + bindings: 1.5.0 + nan: 2.20.0 + optional: true + + unixify@1.0.0: + dependencies: + normalize-path: 2.1.1 + + unpipe@1.0.0: {} + + unstorage@1.10.2(@netlify/blobs@8.0.0): + dependencies: + anymatch: 3.1.3 + chokidar: 3.6.0 + destr: 2.0.3 + h3: 1.12.0 + listhen: 1.7.2 + lru-cache: 10.4.3 + mri: 1.2.0 + node-fetch-native: 1.6.4 + ofetch: 1.3.4 + ufo: 1.5.4 + optionalDependencies: + '@netlify/blobs': 8.0.0 + transitivePeerDependencies: + - uWebSockets.js + + untildify@3.0.3: {} + + untun@0.1.3: + dependencies: + citty: 0.1.6 + consola: 3.2.3 + pathe: 1.1.2 + + update-browserslist-db@1.1.0(browserslist@4.23.3): + dependencies: + browserslist: 4.23.3 + escalade: 3.1.2 + picocolors: 1.0.1 + + update-notifier@7.0.0: + dependencies: + boxen: 7.1.1 + chalk: 5.3.0 + configstore: 6.0.0 + import-lazy: 4.0.0 + is-in-ci: 0.1.0 + is-installed-globally: 0.4.0 + is-npm: 6.0.0 + latest-version: 7.0.0 + pupa: 3.1.0 + semver: 7.6.3 + semver-diff: 4.0.0 + xdg-basedir: 5.1.0 + + uqr@0.1.2: {} + + uri-js@4.4.1: + dependencies: + punycode: 2.3.1 + + url-parse@1.5.10: + dependencies: + querystringify: 2.2.0 + requires-port: 1.0.0 + + urlpattern-polyfill@8.0.2: {} + + util-deprecate@1.0.2: {} + + utils-merge@1.0.1: {} + + uuid@9.0.1: {} + + v8-compile-cache-lib@3.0.1: {} + + v8-to-istanbul@9.3.0: + dependencies: + '@jridgewell/trace-mapping': 0.3.25 + '@types/istanbul-lib-coverage': 2.0.6 + convert-source-map: 2.0.0 + + validate-npm-package-license@3.0.4: + dependencies: + spdx-correct: 3.2.0 + spdx-expression-parse: 3.0.1 + + validate-npm-package-name@4.0.0: + dependencies: + builtins: 5.1.0 + + vary@1.1.2: {} + + vfile-location@5.0.2: + dependencies: + '@types/unist': 3.0.2 + vfile: 6.0.1 + + vfile-message@4.0.2: + dependencies: + '@types/unist': 3.0.2 + unist-util-stringify-position: 4.0.0 + + vfile@6.0.1: + dependencies: + '@types/unist': 3.0.2 + unist-util-stringify-position: 4.0.0 + vfile-message: 4.0.2 + + w3c-xmlserializer@4.0.0: + dependencies: + xml-name-validator: 4.0.0 + + wait-port@1.1.0: + dependencies: + chalk: 4.1.2 + commander: 9.5.0 + debug: 4.3.6(supports-color@9.4.0) + transitivePeerDependencies: + - supports-color + + walker@1.0.8: + dependencies: + makeerror: 1.0.12 + + web-namespaces@2.0.1: {} + + web-streams-polyfill@3.3.3: {} + + webidl-conversions@3.0.1: {} + + webidl-conversions@7.0.0: {} + + well-known-symbols@2.0.0: {} + + whatwg-encoding@2.0.0: + dependencies: + iconv-lite: 0.6.3 + + whatwg-mimetype@3.0.0: {} + + whatwg-url@11.0.0: + dependencies: + tr46: 3.0.0 + webidl-conversions: 7.0.0 + + whatwg-url@5.0.0: + dependencies: + tr46: 0.0.3 + webidl-conversions: 3.0.1 + + which@1.3.1: + dependencies: + isexe: 2.0.0 + + which@2.0.2: + dependencies: + isexe: 2.0.0 + + wide-align@1.1.5: + dependencies: + string-width: 4.2.3 + + widest-line@4.0.1: + dependencies: + string-width: 5.1.2 + + windows-release@5.1.1: + dependencies: + execa: 5.1.1 + + winston-transport@4.7.1: + dependencies: + logform: 2.6.1 + readable-stream: 3.6.2 + triple-beam: 1.4.1 + + winston@3.14.1: + dependencies: + '@colors/colors': 1.6.0 + '@dabh/diagnostics': 2.0.3 + async: 3.2.5 + is-stream: 2.0.1 + logform: 2.6.1 + one-time: 1.0.0 + readable-stream: 3.6.2 + safe-stable-stringify: 2.4.3 + stack-trace: 0.0.10 + triple-beam: 1.4.1 + winston-transport: 4.7.1 + + word-wrap@1.2.5: {} + + wrap-ansi@7.0.0: + dependencies: + ansi-styles: 4.3.0 + string-width: 4.2.3 + strip-ansi: 6.0.1 + + wrap-ansi@8.1.0: + dependencies: + ansi-styles: 6.2.1 + string-width: 5.1.2 + strip-ansi: 7.1.0 + + wrap-ansi@9.0.0: + dependencies: + ansi-styles: 6.2.1 + string-width: 7.2.0 + strip-ansi: 7.1.0 + + wrappy@1.0.2: {} + + write-file-atomic@3.0.3: + dependencies: + imurmurhash: 0.1.4 + is-typedarray: 1.0.0 + signal-exit: 3.0.7 + typedarray-to-buffer: 3.1.5 + + write-file-atomic@4.0.2: + dependencies: + imurmurhash: 0.1.4 + signal-exit: 3.0.7 + + write-file-atomic@5.0.1: + dependencies: + imurmurhash: 0.1.4 + signal-exit: 4.1.0 + + ws@8.17.1: {} + + xdg-basedir@5.1.0: {} + + xml-name-validator@4.0.0: {} + + xmlchars@2.2.0: {} + + xss@1.0.15: + dependencies: + commander: 2.20.3 + cssfilter: 0.0.10 + + xtend@4.0.2: {} + + y18n@5.0.8: {} + + yallist@3.1.1: {} + + yallist@4.0.0: {} + + yaml@2.5.0: {} + + yargs-parser@21.1.1: {} + + yargs@17.7.2: + dependencies: + cliui: 8.0.1 + escalade: 3.1.2 + get-caller-file: 2.0.5 + require-directory: 2.1.1 + string-width: 4.2.3 + y18n: 5.0.8 + yargs-parser: 21.1.1 + + yauzl@2.10.0: + dependencies: + buffer-crc32: 0.2.13 + fd-slicer: 1.1.0 + + yn@3.1.1: {} + + yocto-queue@0.1.0: {} + + yocto-queue@1.1.1: {} + + zip-stream@6.0.1: + dependencies: + archiver-utils: 5.0.2 + compress-commons: 6.0.2 + readable-stream: 4.5.2 + + zod@3.23.8: {} + + zwitch@2.0.4: {} diff --git a/postcss.config.cjs b/postcss.config.cjs new file mode 100644 index 00000000..12a703d9 --- /dev/null +++ b/postcss.config.cjs @@ -0,0 +1,6 @@ +module.exports = { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +}; diff --git a/public/favicon/favicon-16x16.png b/public/favicon/favicon-16x16.png new file mode 100644 index 00000000..81a7550c Binary files /dev/null and b/public/favicon/favicon-16x16.png differ diff --git a/public/favicon/favicon-180x180.png b/public/favicon/favicon-180x180.png new file mode 100644 index 00000000..d483a640 Binary files /dev/null and b/public/favicon/favicon-180x180.png differ diff --git a/public/favicon/favicon-32x32.png b/public/favicon/favicon-32x32.png new file mode 100644 index 00000000..19c8f69d Binary files /dev/null and b/public/favicon/favicon-32x32.png differ diff --git a/public/favicon/favicon-46x46.png b/public/favicon/favicon-46x46.png new file mode 100644 index 00000000..af39a8a1 Binary files /dev/null and b/public/favicon/favicon-46x46.png differ diff --git a/public/fonts/D2Coding-subset.woff2 b/public/fonts/D2Coding-subset.woff2 new file mode 100644 index 00000000..3bad0210 Binary files /dev/null and b/public/fonts/D2Coding-subset.woff2 differ diff --git a/public/fonts/RIDIBatang-subset.woff2 b/public/fonts/RIDIBatang-subset.woff2 new file mode 100644 index 00000000..69da46e8 Binary files /dev/null and b/public/fonts/RIDIBatang-subset.woff2 differ diff --git a/public/images/404.webp b/public/images/404.webp new file mode 100644 index 00000000..fdf50e6f Binary files /dev/null and b/public/images/404.webp differ diff --git a/public/images/about/bleet.webp b/public/images/about/bleet.webp new file mode 100644 index 00000000..92cb03df Binary files /dev/null and b/public/images/about/bleet.webp differ diff --git a/public/images/about/blind.webp b/public/images/about/blind.webp new file mode 100644 index 00000000..d246b509 Binary files /dev/null and b/public/images/about/blind.webp differ diff --git a/public/images/about/lds-calendar.webp b/public/images/about/lds-calendar.webp new file mode 100644 index 00000000..5c25880f Binary files /dev/null and b/public/images/about/lds-calendar.webp differ diff --git a/public/images/about/line-account.webp b/public/images/about/line-account.webp new file mode 100644 index 00000000..4d6d0296 Binary files /dev/null and b/public/images/about/line-account.webp differ diff --git a/public/images/about/line-place.webp b/public/images/about/line-place.webp new file mode 100644 index 00000000..57267fd4 Binary files /dev/null and b/public/images/about/line-place.webp differ diff --git a/public/images/about/mybiskit.webp b/public/images/about/mybiskit.webp new file mode 100644 index 00000000..96de7396 Binary files /dev/null and b/public/images/about/mybiskit.webp differ diff --git a/public/images/about/oa-live-cms.webp b/public/images/about/oa-live-cms.webp new file mode 100644 index 00000000..644a2db3 Binary files /dev/null and b/public/images/about/oa-live-cms.webp differ diff --git a/public/images/about/uvp.webp b/public/images/about/uvp.webp new file mode 100644 index 00000000..22006379 Binary files /dev/null and b/public/images/about/uvp.webp differ diff --git a/public/images/about/voom-live-cms.webp b/public/images/about/voom-live-cms.webp new file mode 100644 index 00000000..4ef8fe9b Binary files /dev/null and b/public/images/about/voom-live-cms.webp differ diff --git a/public/images/chul.webp b/public/images/chul.webp new file mode 100644 index 00000000..42f82472 Binary files /dev/null and b/public/images/chul.webp differ diff --git a/public/images/logo/blind.webp b/public/images/logo/blind.webp new file mode 100644 index 00000000..51cae301 Binary files /dev/null and b/public/images/logo/blind.webp differ diff --git a/public/images/logo/catholic.webp b/public/images/logo/catholic.webp new file mode 100644 index 00000000..62b1f81f Binary files /dev/null and b/public/images/logo/catholic.webp differ diff --git a/public/images/logo/github-black.webp b/public/images/logo/github-black.webp new file mode 100644 index 00000000..d4e8522a Binary files /dev/null and b/public/images/logo/github-black.webp differ diff --git a/public/images/logo/github-white.webp b/public/images/logo/github-white.webp new file mode 100644 index 00000000..8689aa82 Binary files /dev/null and b/public/images/logo/github-white.webp differ diff --git a/public/images/logo/gmail.webp b/public/images/logo/gmail.webp new file mode 100644 index 00000000..c2aca3d4 Binary files /dev/null and b/public/images/logo/gmail.webp differ diff --git a/public/images/logo/kakao-pay.webp b/public/images/logo/kakao-pay.webp new file mode 100644 index 00000000..3edf20fb Binary files /dev/null and b/public/images/logo/kakao-pay.webp differ diff --git a/public/images/logo/line.webp b/public/images/logo/line.webp new file mode 100644 index 00000000..c27920a2 Binary files /dev/null and b/public/images/logo/line.webp differ diff --git a/public/images/logo/linkedin.webp b/public/images/logo/linkedin.webp new file mode 100644 index 00000000..1f38d25e Binary files /dev/null and b/public/images/logo/linkedin.webp differ diff --git a/public/images/logo/smilegate.webp b/public/images/logo/smilegate.webp new file mode 100644 index 00000000..5d66cd88 Binary files /dev/null and b/public/images/logo/smilegate.webp differ diff --git a/public/images/logo/toss-pay.webp b/public/images/logo/toss-pay.webp new file mode 100644 index 00000000..8d155e95 Binary files /dev/null and b/public/images/logo/toss-pay.webp differ diff --git a/public/images/logo/woowa-bros.webp b/public/images/logo/woowa-bros.webp new file mode 100644 index 00000000..b4e54ede Binary files /dev/null and b/public/images/logo/woowa-bros.webp differ diff --git a/public/images/posts/google-adsense/ad-bottom-mobile.webp b/public/images/posts/google-adsense/ad-bottom-mobile.webp new file mode 100644 index 00000000..32af0219 Binary files /dev/null and b/public/images/posts/google-adsense/ad-bottom-mobile.webp differ diff --git a/public/images/posts/google-adsense/ad-example.webp b/public/images/posts/google-adsense/ad-example.webp new file mode 100644 index 00000000..0282478a Binary files /dev/null and b/public/images/posts/google-adsense/ad-example.webp differ diff --git a/public/images/posts/google-adsense/ad-exclusive-area.webp b/public/images/posts/google-adsense/ad-exclusive-area.webp new file mode 100644 index 00000000..03b8d102 Binary files /dev/null and b/public/images/posts/google-adsense/ad-exclusive-area.webp differ diff --git a/public/images/posts/google-adsense/ad-left-rail-desktop.webp b/public/images/posts/google-adsense/ad-left-rail-desktop.webp new file mode 100644 index 00000000..4dfbffb5 Binary files /dev/null and b/public/images/posts/google-adsense/ad-left-rail-desktop.webp differ diff --git a/public/images/posts/google-adsense/ad-script-register.webp b/public/images/posts/google-adsense/ad-script-register.webp new file mode 100644 index 00000000..b72cbb29 Binary files /dev/null and b/public/images/posts/google-adsense/ad-script-register.webp differ diff --git a/public/images/posts/google-adsense/ad-target.webp b/public/images/posts/google-adsense/ad-target.webp new file mode 100644 index 00000000..9ada1773 Binary files /dev/null and b/public/images/posts/google-adsense/ad-target.webp differ diff --git a/public/images/posts/google-adsense/ads-text.webp b/public/images/posts/google-adsense/ads-text.webp new file mode 100644 index 00000000..6362d2e4 Binary files /dev/null and b/public/images/posts/google-adsense/ads-text.webp differ diff --git a/public/images/posts/google-adsense/cover.webp b/public/images/posts/google-adsense/cover.webp new file mode 100644 index 00000000..3b354bd0 Binary files /dev/null and b/public/images/posts/google-adsense/cover.webp differ diff --git a/public/images/posts/google-adsense/direct-ad.webp b/public/images/posts/google-adsense/direct-ad.webp new file mode 100644 index 00000000..92489f24 Binary files /dev/null and b/public/images/posts/google-adsense/direct-ad.webp differ diff --git a/public/images/posts/google-adsense/profit-structure.webp b/public/images/posts/google-adsense/profit-structure.webp new file mode 100644 index 00000000..72636cd3 Binary files /dev/null and b/public/images/posts/google-adsense/profit-structure.webp differ diff --git a/public/images/posts/google-adsense/site-register.webp b/public/images/posts/google-adsense/site-register.webp new file mode 100644 index 00000000..b76892c1 Binary files /dev/null and b/public/images/posts/google-adsense/site-register.webp differ diff --git a/public/images/qr/kakao-pay.webp b/public/images/qr/kakao-pay.webp new file mode 100644 index 00000000..74eed8a6 Binary files /dev/null and b/public/images/qr/kakao-pay.webp differ diff --git a/public/images/qr/toss-pay.webp b/public/images/qr/toss-pay.webp new file mode 100644 index 00000000..cce6899e Binary files /dev/null and b/public/images/qr/toss-pay.webp differ diff --git a/public/images/symbol.webp b/public/images/symbol.webp new file mode 100644 index 00000000..243805c7 Binary files /dev/null and b/public/images/symbol.webp differ diff --git a/public/open.mp4 b/public/open.mp4 new file mode 100644 index 00000000..4a9589b8 Binary files /dev/null and b/public/open.mp4 differ diff --git a/renovate.json b/renovate.json new file mode 100644 index 00000000..3120b8ae --- /dev/null +++ b/renovate.json @@ -0,0 +1,6 @@ +{ + "$schema": "https://docs.renovatebot.com/renovate-schema.json", + "extends": ["config:base"], + "branchConcurrentLimit": 3, + "labels": ["renovate"] +} diff --git a/src/app/about/page.tsx b/src/app/about/page.tsx new file mode 100644 index 00000000..422ab919 --- /dev/null +++ b/src/app/about/page.tsx @@ -0,0 +1,22 @@ +import type { NextPage } from 'next'; +import AboutContainer from '~/about/Container'; +import { Footer } from '~/shared/components/Footer'; +import { MainLayout } from '~/shared/components/MainLayout'; +import ImageModal from '~/shared/components/modal/ImageModal'; +import Navbar from '~/shared/components/nav/Navbar'; +import { Portal } from '~/shared/portal/Container'; + +const About: NextPage = () => { + return ( + + + +