diff --git a/.coderabbit.yaml b/.coderabbit.yaml new file mode 100644 index 0000000..370523a --- /dev/null +++ b/.coderabbit.yaml @@ -0,0 +1,26 @@ +# CodeRabbit 설정 — https://docs.coderabbit.ai/guides/configure-coderabbit +# public 저장소에서는 별도 설치/활성화 없이 자동으로 PR 리뷰가 동작합니다. +language: ko-KR + +reviews: + profile: chill + request_changes_workflow: false + high_level_summary: true + poem: false + review_status: true + auto_review: + enabled: true # PR 최초 생성 시 자동 리뷰 + auto_incremental_review: false # 이후 push에는 자동 재리뷰 안 함 (수동 `@coderabbitai review`) + drafts: false + base_branches: + - main + path_filters: + - '!dist/**' + - '!**/*.lock' + - '!package-lock.json' + tools: + eslint: + enabled: true + +chat: + auto_reply: true diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..b87fd96 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,13 @@ +# 모든 텍스트 파일은 저장소에 LF로 저장하고 LF로 체크아웃한다. +# (Prettier 기본값과 일치 → 환경에 상관없이 `prettier --check` 일관성 보장) +* text=auto eol=lf + +# 바이너리 자산은 변환하지 않는다. +*.png binary +*.jpg binary +*.jpeg binary +*.gif binary +*.webp binary +*.ico binary +*.woff binary +*.woff2 binary diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml new file mode 100644 index 0000000..3c132dd --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -0,0 +1,74 @@ +name: 🐛 버그 리포트 +description: 동작하지 않거나 예상과 다르게 동작하는 문제를 알려주세요. +title: '[Bug] ' +labels: ['🐛 bug'] +body: + - type: markdown + attributes: + value: | + 버그 리포트를 작성해주셔서 감사합니다! 🙏 + 작성 전 **이미 같은 이슈가 있는지 [검색](https://github.com/hs-shell/dotbugi/issues?q=is%3Aissue)** 부탁드립니다. + + - type: checkboxes + id: preflight + attributes: + label: 확인 사항 + options: + - label: 동일하거나 유사한 이슈가 없는지 검색했습니다. + required: true + - label: 최신 버전에서 재현되는 문제입니다. + required: true + + - type: textarea + id: what-happened + attributes: + label: 무슨 일이 일어났나요? + description: 어떤 문제가 발생했는지 명확하게 설명해주세요. + placeholder: VOD 목록이 비어있게 표시됩니다... + validations: + required: true + + - type: textarea + id: reproduce + attributes: + label: 재현 방법 + description: 문제를 재현하는 단계를 순서대로 적어주세요. + placeholder: | + 1. LMS 강의 페이지 접속 + 2. 돋부기 대시보드 열기 + 3. ... + validations: + required: true + + - type: textarea + id: expected + attributes: + label: 기대한 동작 + description: 원래 어떻게 동작해야 한다고 생각하셨나요? + validations: + required: true + + - type: input + id: version + attributes: + label: 확장 프로그램 버전 + description: chrome://extensions 에서 확인할 수 있습니다. + placeholder: 'v5.0.5' + validations: + required: true + + - type: input + id: browser + attributes: + label: 브라우저 / OS + placeholder: 'Chrome 130 / macOS 15' + validations: + required: false + + - type: textarea + id: screenshots + attributes: + label: 스크린샷 / 콘솔 로그 + description: 가능하다면 스크린샷이나 개발자 도구 콘솔 로그를 첨부해주세요. + validations: + required: false diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..311d629 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,8 @@ +blank_issues_enabled: false +contact_links: + - name: 💬 질문 / 사용 문의 + url: https://github.com/hs-shell/dotbugi/discussions + about: 버그·기능 제안이 아닌 일반 질문이나 사용법 문의는 Discussions에 남겨주세요. + - name: 🔎 중복 이슈 먼저 검색하기 + url: https://github.com/hs-shell/dotbugi/issues?q=is%3Aissue + about: 이슈를 올리기 전에 같은 내용이 이미 등록되어 있는지 꼭 검색해주세요. diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml new file mode 100644 index 0000000..591ae89 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -0,0 +1,43 @@ +name: ✨ 기능 제안 +description: 새로운 기능이나 개선 아이디어를 제안해주세요. +title: '[Feat] ' +labels: ['✨ feat'] +body: + - type: markdown + attributes: + value: | + 기능 제안 감사합니다! 💡 + 작성 전 **이미 같은 제안이 있는지 [검색](https://github.com/hs-shell/dotbugi/issues?q=is%3Aissue)** 부탁드립니다. + + - type: checkboxes + id: preflight + attributes: + label: 확인 사항 + options: + - label: 동일하거나 유사한 제안이 없는지 검색했습니다. + required: true + + - type: textarea + id: problem + attributes: + label: 어떤 문제를 해결하나요? + description: 이 기능이 필요하게 된 배경이나 불편한 점을 설명해주세요. + placeholder: 과제 마감이 임박했을 때 알기 어렵습니다... + validations: + required: true + + - type: textarea + id: solution + attributes: + label: 제안하는 해결책 + description: 어떤 기능/동작을 원하시는지 구체적으로 적어주세요. + validations: + required: true + + - type: textarea + id: alternatives + attributes: + label: 고려한 대안 + description: 생각해본 다른 방법이 있다면 적어주세요. + validations: + required: false diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..09751bb --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,40 @@ + + +## 관련 이슈 + + + +Closes # + +## 변경 내용 + + + +- + +## 변경 유형 + + + +- [ ] 🐛 버그 수정 (`fix`) +- [ ] ✨ 기능 추가 (`feat`) +- [ ] 📝 문서 (`docs`) +- [ ] ♻️ 리팩터링 (`refactor`) +- [ ] 🎨 스타일/포맷 (`style`) +- [ ] ⚙️ 빌드/CI (`chore`, `ci`) + +## 체크리스트 + +- [ ] 이 PR과 연결된 이슈가 존재합니다. +- [ ] 로컬에서 `npm run lint` 와 `npm run build` 가 통과합니다. +- [ ] 변경 사항을 직접 테스트했습니다. +- [ ] CodeRabbit / 리뷰어 피드백을 반영할 준비가 되어 있습니다. + +## 스크린샷 (선택) + + diff --git a/.github/workflows/deploy-docs.yml b/.github/workflows/deploy-docs.yml index 7634407..e21441a 100644 --- a/.github/workflows/deploy-docs.yml +++ b/.github/workflows/deploy-docs.yml @@ -21,22 +21,22 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 with: fetch-depth: 0 - name: Setup Node - uses: actions/setup-node@v4 + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 with: node-version: 20 cache: npm - name: Setup Pages - uses: actions/configure-pages@v4 + uses: actions/configure-pages@1f0c5cde4bc74cd7e1254d0cb4de8d49e9068c7d # v4 - name: Install dependencies run: npm ci - name: Build with VitePress run: npm run docs:build - name: Upload artifact - uses: actions/upload-pages-artifact@v3 + uses: actions/upload-pages-artifact@56afc609e74202658d3ffba0e8f6dda462b719fa # v3 with: path: docs/.vitepress/dist @@ -46,5 +46,5 @@ jobs: steps: - name: Deploy to GitHub Pages id: deployment - uses: actions/deploy-pages@v4 + uses: actions/deploy-pages@d6db90164ac5ed86f2b6aed7e0febac5b3c0c03e # v4 environment: docs diff --git a/.github/workflows/discord-notify.yml b/.github/workflows/discord-notify.yml new file mode 100644 index 0000000..c1fc606 --- /dev/null +++ b/.github/workflows/discord-notify.yml @@ -0,0 +1,75 @@ +name: Discord 알림 + +# 릴리즈 알림은 release.yaml 안에서 직접 보낸다. +# 이 워크플로는 이슈/PR/CI 실패 이벤트를 담당한다. +on: + issues: + types: [opened] + pull_request: + types: [opened, closed] + workflow_run: + workflows: ['PR 검사'] + types: [completed] + +permissions: + contents: read + +jobs: + notify: + runs-on: ubuntu-latest + if: ${{ vars.DISCORD_ENABLED != 'false' }} + steps: + - name: Discord 전송 + env: + DISCORD_WEBHOOK_URL: ${{ secrets.DISCORD_WEBHOOK_URL }} + uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7 + with: + script: | + const webhook = process.env.DISCORD_WEBHOOK_URL; + if (!webhook) { + core.info('DISCORD_WEBHOOK_URL 미설정 — 건너뜀'); + return; + } + + const event = context.eventName; + let embed; + + if (event === 'issues') { + const i = context.payload.issue; + embed = { + title: `🐛 새 이슈 #${i.number}: ${i.title}`, + url: i.html_url, + description: `작성자: ${i.user.login}`, + color: 0x5865f2, // 파랑 + }; + } else if (event === 'pull_request') { + const pr = context.payload.pull_request; + const merged = pr.merged === true; + const action = context.payload.action; + if (action === 'closed' && !merged) return; // 머지 없이 닫힌 PR은 무시 + embed = { + title: merged + ? `🟣 PR 머지됨 #${pr.number}: ${pr.title}` + : `🟢 PR 열림 #${pr.number}: ${pr.title}`, + url: pr.html_url, + description: `작성자: ${pr.user.login}`, + color: merged ? 0x8957e5 : 0x57f287, // 보라/초록 + }; + } else if (event === 'workflow_run') { + const run = context.payload.workflow_run; + if (run.conclusion !== 'failure') return; // 실패만 알림 + embed = { + title: `❌ CI 실패: ${run.name}`, + url: run.html_url, + description: `브랜치: ${run.head_branch}`, + color: 0xed4245, // 빨강 + }; + } + + if (!embed) return; + const res = await fetch(webhook, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ embeds: [embed] }), + }); + if (!res.ok) core.warning(`Discord 알림 실패: ${res.status}`); diff --git a/.github/workflows/issue-dedup.yml b/.github/workflows/issue-dedup.yml new file mode 100644 index 0000000..b5aacc2 --- /dev/null +++ b/.github/workflows/issue-dedup.yml @@ -0,0 +1,122 @@ +name: 유사 이슈 탐지 + +# 새 이슈가 열리면 기존 open 이슈들과 비교해 중복 가능성이 있으면 댓글을 단다. +# GitHub Models(무료, GITHUB_TOKEN)로 의미 기반 비교 — 별도 API 키 불필요. +on: + issues: + types: [opened] + +permissions: + issues: write + models: read + contents: read + +jobs: + detect: + runs-on: ubuntu-latest + steps: + - name: 유사 이슈 분석 및 댓글 + uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7 + with: + script: | + const current = context.payload.issue; + + // 1) 기존 open 이슈 수집 (현재 이슈/PR 제외, 최근 갱신순 최대 50개) + const { data: issues } = await github.rest.issues.listForRepo({ + owner: context.repo.owner, + repo: context.repo.repo, + state: 'open', + sort: 'updated', + direction: 'desc', + per_page: 50, + }); + const candidates = issues + .filter((i) => i.number !== current.number && !i.pull_request) + .map((i) => ({ + number: i.number, + title: i.title, + body: (i.body || '').slice(0, 500), + })); + + if (candidates.length === 0) { + core.info('비교할 기존 이슈 없음 — 종료'); + return; + } + + // 2) GitHub Models 호출 + const prompt = [ + '너는 GitHub 이슈 트리아지 도우미야.', + '아래 "새 이슈"와 의미상 중복(같은 버그/같은 요청)일 가능성이 높은 "기존 이슈"의 번호만 골라줘.', + '주제가 다르면 절대 고르지 마. 확실하지 않으면 비워둬.', + '반드시 JSON만 출력: {"duplicates":[{"number":123,"reason":"한 줄 근거"}]}', + '', + `## 새 이슈\n제목: ${current.title}\n본문: ${(current.body || '').slice(0, 1500)}`, + '', + '## 기존 이슈 목록', + JSON.stringify(candidates), + ].join('\n'); + + let duplicates = []; + try { + const res = await fetch('https://models.github.ai/inference/chat/completions', { + method: 'POST', + headers: { + Authorization: `Bearer ${process.env.GITHUB_TOKEN}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + model: 'openai/gpt-4o-mini', + temperature: 0, + response_format: { type: 'json_object' }, + messages: [{ role: 'user', content: prompt }], + }), + }); + if (!res.ok) { + core.warning(`GitHub Models 호출 실패: ${res.status} ${await res.text()}`); + return; + } + const data = await res.json(); + const content = data.choices?.[0]?.message?.content || '{}'; + // 모델이 number를 문자열로 줄 수 있으므로 숫자로 정규화 후 비교 + duplicates = (JSON.parse(content).duplicates || []) + .map((d) => ({ ...d, number: Number(d.number) })) + .filter((d) => candidates.some((c) => c.number === d.number)); + } catch (e) { + core.warning(`분석 중 오류: ${e.message}`); + return; + } + + if (duplicates.length === 0) { + core.info('유사 이슈 없음'); + return; + } + + // 3) 댓글 + 라벨 + const lines = duplicates.map((d) => `- #${d.number} — ${d.reason}`).join('\n'); + const body = [ + '🔁 **유사한 이슈가 있는 것 같아요.**', + '아래 이슈와 중복이 아닌지 확인해주세요. 같은 내용이라면 이 이슈를 닫고 기존 이슈에 의견을 남겨주시면 됩니다.', + '', + lines, + '', + '_이 댓글은 자동 분석 결과이며 틀릴 수 있습니다._', + ].join('\n'); + + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: current.number, + body, + }); + try { + await github.rest.issues.addLabels({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: current.number, + labels: ['👀 possible-duplicate'], + }); + } catch (e) { + core.info(`라벨 추가 건너뜀: ${e.message}`); + } + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/pr-autolabel.yml b/.github/workflows/pr-autolabel.yml new file mode 100644 index 0000000..4690041 --- /dev/null +++ b/.github/workflows/pr-autolabel.yml @@ -0,0 +1,70 @@ +name: PR 자동 라벨 + +# PR 제목의 Conventional Commits prefix(feat/fix/...)에 따라 타입 라벨을 자동으로 단다. +# pull_request_target 사용: 포크 PR에도 라벨을 달 수 있다. (PR 제목만 읽고 코드는 실행하지 않아 안전) +on: + pull_request_target: + types: [opened, edited] + +permissions: + pull-requests: write + +jobs: + label: + runs-on: ubuntu-latest + steps: + - name: 타입 라벨 동기화 + uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7 + with: + script: | + // 커밋 타입 → 라벨 매핑 + const MAP = { + feat: '✨ feat', + fix: '🐛 bug', + docs: '📝 docs', + refactor: '♻️ refactor', + perf: '⚡ perf', + test: '🧪 test', + chore: '🧹 chore', + ci: '⚙️ ci', + style: '💄 style', + }; + const MANAGED = new Set(Object.values(MAP)); + + const title = context.payload.pull_request.title || ''; + const m = title.match(/^(\w+)(\([^)]*\))?!?:/); + const target = m ? MAP[m[1].toLowerCase()] : undefined; + + const prNumber = context.payload.pull_request.number; + const { data: current } = await github.rest.issues.listLabelsOnIssue({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: prNumber, + }); + const currentNames = current.map((l) => l.name); + + // 관리 대상(타입) 라벨 중 target이 아닌 것 제거 + for (const name of currentNames) { + if (MANAGED.has(name) && name !== target) { + await github.rest.issues.removeLabel({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: prNumber, + name, + }); + } + } + + if (!target) { + core.info(`제목에서 타입을 못 찾음: "${title}" — 라벨 변경 없음`); + return; + } + if (!currentNames.includes(target)) { + await github.rest.issues.addLabels({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: prNumber, + labels: [target], + }); + core.info(`라벨 추가: ${target}`); + } diff --git a/.github/workflows/pr-checks.yml b/.github/workflows/pr-checks.yml new file mode 100644 index 0000000..eba9e9a --- /dev/null +++ b/.github/workflows/pr-checks.yml @@ -0,0 +1,66 @@ +name: PR 검사 + +on: + pull_request: + types: [opened, synchronize, reopened, edited] + branches: + - main + +permissions: + contents: read + pull-requests: read + +concurrency: + group: pr-checks-${{ github.event.pull_request.number }} + cancel-in-progress: true + +jobs: + # PR 본문에 연결된 이슈 참조가 있는지 검사 (이슈-먼저 정책 강제) + issue-link: + runs-on: ubuntu-latest + steps: + - name: 이슈 참조 확인 + uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7 + with: + script: | + const body = context.payload.pull_request.body || ''; + // "#123", "Closes #123", "Fixes #45", "Resolves: #6" 등 + 전체 URL 형태 모두 허용 + const ref = /(close[sd]?|fix(e[sd])?|resolve[sd]?)?\s*:?\s*#\d+/i.test(body) + || /github\.com\/[^/]+\/[^/]+\/issues\/\d+/i.test(body); + if (!ref) { + core.setFailed( + 'PR 본문에 연결된 이슈가 없습니다. ' + + '`Closes #이슈번호` 형식으로 관련 이슈를 참조해주세요. ' + + '(모든 변경은 이슈가 먼저 등록되어 있어야 합니다)' + ); + } + + verify: + needs: issue-link # 이슈-먼저 정책: 이슈 참조 실패 시 빌드/테스트 낭비 방지 + runs-on: ubuntu-latest + steps: + - name: 리포지토리 체크아웃 + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + with: + persist-credentials: false + + - name: Node.js 설정 + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + with: + node-version: '20' + cache: npm + + - name: 의존성 설치 + run: npm ci + + - name: 포맷 검사 (Prettier) + run: npx prettier --check . + + - name: 린트 (ESLint) + run: npm run lint + + - name: 타입 체크 + 빌드 + run: npm run build + + - name: 테스트 + run: npm run test diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml new file mode 100644 index 0000000..78846d5 --- /dev/null +++ b/.github/workflows/release.yaml @@ -0,0 +1,286 @@ +name: 릴리즈 + +# 트리거: +# 1) main으로 PR 머지 시 (라벨로 버전 범프/채널 결정) +# 2) 수동 실행 (workflow_dispatch) +# 3) repository_dispatch — 향후 Discord 봇이 승인 시 릴리즈를 킥할 진입점 (B안 기반) +on: + pull_request: + types: [closed] + branches: + - main + workflow_dispatch: + inputs: + channel: + description: '릴리즈 채널' + type: choice + default: stable + options: [stable, beta] + bump: + description: '버전 범프' + type: choice + default: patch + options: [patch, minor, major] + repository_dispatch: + types: [discord-release] + +# 최소 권한: 기본은 읽기만. 실제 push/tag/release는 release 잡에만 contents: write 부여. +permissions: + contents: read + +# 릴리즈 run을 직렬화한다. 동시에 머지된 두 PR이 같은 다음 버전을 계산해 +# main/tag에서 충돌하는 것을 막는다 (pending run은 앞 run이 끝난 뒤 prepare를 +# 다시 실행하므로 최신 main 기준으로 버전을 계산하게 된다). +concurrency: + group: release + cancel-in-progress: false + +jobs: + # ── 1) 버전/채널 결정 ───────────────────────────────────────────── + prepare: + runs-on: ubuntu-latest + # PR 트리거일 때는 머지된 경우에만 진행 + if: github.event_name != 'pull_request' || github.event.pull_request.merged == true + outputs: + new_version: ${{ steps.plan.outputs.new_version }} + bump: ${{ steps.plan.outputs.bump }} + channel: ${{ steps.plan.outputs.channel }} + prerelease: ${{ steps.plan.outputs.prerelease }} + steps: + - name: 리포지토리 체크아웃 + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + with: + fetch-depth: 0 + persist-credentials: false + + - name: 버전/채널 계획 수립 + id: plan + uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7 + with: + script: | + const fs = require('fs'); + const event = context.eventName; + + // 채널 / 범프 결정 (트리거별) + let channel = 'stable'; + let bump = 'patch'; + + if (event === 'pull_request') { + const labels = context.payload.pull_request.labels.map((l) => l.name); + if (labels.includes('🔖 major')) bump = 'major'; + else if (labels.includes('🔖 minor')) bump = 'minor'; + if (labels.includes('🚀 beta')) channel = 'beta'; + } else if (event === 'workflow_dispatch') { + channel = context.payload.inputs.channel || 'stable'; + bump = context.payload.inputs.bump || 'patch'; + } else if (event === 'repository_dispatch') { + const p = context.payload.client_payload || {}; + channel = p.channel || 'stable'; + bump = p.bump || 'patch'; + } + + // 화이트리스트 검증: 임의 channel 값으로 보호되지 않은 Environment가 + // 생성되어 승인 게이트를 우회하는 것을 막는다. + const allowedChannels = new Set(['stable', 'beta']); + const allowedBumps = new Set(['patch', 'minor', 'major']); + if (!allowedChannels.has(channel)) { + core.setFailed(`허용되지 않은 채널: ${channel}`); + return; + } + if (!allowedBumps.has(bump)) { + core.setFailed(`허용되지 않은 범프: ${bump}`); + return; + } + + // 현재 안정 버전은 package.json을 기준으로 한다 (베타 태그가 섞여도 안전) + const pkg = JSON.parse(fs.readFileSync('package.json', 'utf-8')); + const base = pkg.version.split('-')[0]; // prerelease 꼬리표 제거 + let [major, minor, patch] = base.split('.').map(Number); + if (bump === 'major') { major += 1; minor = 0; patch = 0; } + else if (bump === 'minor') { minor += 1; patch = 0; } + else { patch += 1; } + + let newVersion = `${major}.${minor}.${patch}`; + const prerelease = channel === 'beta'; + if (prerelease) { + // 베타는 main을 전진시키지 않고 -beta.로 고유 태그만 만든다 + newVersion = `${newVersion}-beta.${context.runNumber}`; + } + + core.setOutput('new_version', newVersion); + core.setOutput('bump', bump); + core.setOutput('channel', channel); + core.setOutput('prerelease', String(prerelease)); + core.summary + .addRaw(`채널: **${channel}** / 범프: **${bump}** / 새 버전: **v${newVersion}**`) + .write(); + + # ── 2) 빌드 & 릴리즈 (승인 게이트) ──────────────────────────────── + release: + needs: prepare + runs-on: ubuntu-latest + permissions: + contents: write # push / tag / release 생성 + # GitHub Environment의 required reviewers가 승인 게이트 역할을 한다. + # Settings → Environments 에서 'beta' / 'stable' 환경에 리뷰어를 등록해두면 + # 머지 후에도 승인 전까지 이 job이 대기 상태로 멈춘다. + environment: ${{ needs.prepare.outputs.channel }} + steps: + # GitHub App으로 실행 시점에만 유효한 단기 설치 토큰을 발급한다. + # 저장되는 secret은 APP_ID/APP_PRIVATE_KEY(환경 secret)뿐이고, + # 발급된 토큰은 휘발성이라 탈취 위험이 낮다. 이 토큰으로 보호된 main에 + # 버전 범프 커밋을 push 한다. (App을 브랜치 보호 bypass 목록에 등록) + - name: GitHub App 토큰 발급 + id: app-token + uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 + with: + app-id: ${{ secrets.APP_ID }} + private-key: ${{ secrets.APP_PRIVATE_KEY }} + + # persist-credentials: false — 토큰을 .git/config에 남기지 않는다. + # (npm ci/build 단계의 공급망 위험으로부터 토큰을 보호; push는 아래에서 just-in-time 인증) + - name: 리포지토리 체크아웃 + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + with: + fetch-depth: 0 + persist-credentials: false + + - name: Node.js 설정 + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + with: + node-version: '20' + cache: npm + + - name: 의존성 설치 + run: npm ci + + - name: package.json 버전 적용 + env: + NEW_VERSION: ${{ needs.prepare.outputs.new_version }} + run: npm version "$NEW_VERSION" --no-git-tag-version --allow-same-version + + - name: 프로덕션 빌드 + run: npm run build + env: + RELEASE_CHANNEL: ${{ needs.prepare.outputs.channel }} + + - name: dist 압축 + env: + NEW_VERSION: ${{ needs.prepare.outputs.new_version }} + run: cd dist && zip -r "../dotbugi-v${NEW_VERSION}.zip" . + + # 스테이블만 main을 전진시킨다 (package.json/README 커밋 + 태그) + - name: 변경사항 커밋 및 태그 (stable) + if: needs.prepare.outputs.channel == 'stable' + env: + GH_TOKEN: ${{ steps.app-token.outputs.token }} + NEW_VERSION: ${{ needs.prepare.outputs.new_version }} + run: | + sed -i "s/Chrome_Web_Store-v[0-9]\+\.[0-9]\+\.[0-9]\+/Chrome_Web_Store-v${NEW_VERSION}/" README.md + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + git add package.json package-lock.json README.md + git commit -m "chore: bump version to v${NEW_VERSION}" + git tag "v${NEW_VERSION}" + REMOTE="https://x-access-token:${GH_TOKEN}@github.com/${GITHUB_REPOSITORY}.git" + git push "$REMOTE" HEAD:main + git push "$REMOTE" "refs/tags/v${NEW_VERSION}" + + # 베타는 main을 건드리지 않고 태그만 만든다 + - name: 태그 생성 (beta) + if: needs.prepare.outputs.channel == 'beta' + env: + GH_TOKEN: ${{ steps.app-token.outputs.token }} + NEW_VERSION: ${{ needs.prepare.outputs.new_version }} + run: | + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + git tag "v${NEW_VERSION}" + REMOTE="https://x-access-token:${GH_TOKEN}@github.com/${GITHUB_REPOSITORY}.git" + git push "$REMOTE" "refs/tags/v${NEW_VERSION}" + + - name: 릴리즈 노트 생성 + id: changelog + uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7 + with: + script: | + const { execSync } = require('child_process'); + let prevTag; + try { + prevTag = execSync('git describe --tags --abbrev=0 HEAD~1', { encoding: 'utf-8' }).trim(); + } catch { + prevTag = execSync('git rev-list --max-parents=0 HEAD', { encoding: 'utf-8' }).trim(); + } + const newVersion = '${{ needs.prepare.outputs.new_version }}'; + const commits = execSync( + `git log ${prevTag}..HEAD --pretty=format:"- %s (%h)" --no-merges`, + { encoding: 'utf-8' } + ).trim(); + const body = [ + `## What's Changed`, + ``, + commits || '- (변경 커밋 없음)', + ``, + `**Full Changelog:** https://github.com/${context.repo.owner}/${context.repo.repo}/compare/${prevTag}...v${newVersion}`, + ].join('\n'); + core.setOutput('body', body); + + - name: GitHub 릴리즈 생성 + uses: softprops/action-gh-release@3bb12739c298aeb8a4eeaf626c5b8d85266b0e65 # v2 + with: + tag_name: v${{ needs.prepare.outputs.new_version }} + name: v${{ needs.prepare.outputs.new_version }} + body: ${{ steps.changelog.outputs.body }} + prerelease: ${{ needs.prepare.outputs.prerelease == 'true' }} + files: dotbugi-v${{ needs.prepare.outputs.new_version }}.zip + + # ── Chrome Web Store 자동 게시 (2단계 기반 — 기본 비활성) ────────── + # 활성화 방법: + # 1) 베타용 별도 스토어 항목을 GUI로 생성하고 각 EXTENSION_ID 확보 + # 2) 아래 secret 등록: + # - CHROME_CLIENT_ID / CHROME_CLIENT_SECRET / CHROME_REFRESH_TOKEN (OAuth, 채널 공유 가능) + # - BETA_EXTENSION_ID / STABLE_EXTENSION_ID + # 3) 저장소 Variable ENABLE_STORE_PUBLISH=true 설정하면 이 step이 동작한다. + - name: Chrome Web Store 게시 + if: vars.ENABLE_STORE_PUBLISH == 'true' + uses: mnao305/chrome-extension-upload@4008e29e13c144d0f6725462cbd49b7c291b4928 # v5.0.0 + with: + file-path: dotbugi-v${{ needs.prepare.outputs.new_version }}.zip + extension-id: ${{ needs.prepare.outputs.channel == 'beta' && secrets.BETA_EXTENSION_ID || secrets.STABLE_EXTENSION_ID }} + client-id: ${{ secrets.CHROME_CLIENT_ID }} + client-secret: ${{ secrets.CHROME_CLIENT_SECRET }} + refresh-token: ${{ secrets.CHROME_REFRESH_TOKEN }} + publish: true + + # ── Discord 릴리즈 알림 (베타=노랑 / 스테이블=초록) ─────────────── + - name: Discord 릴리즈 알림 + if: always() && env.DISCORD_WEBHOOK_URL != '' + env: + DISCORD_WEBHOOK_URL: ${{ secrets.DISCORD_WEBHOOK_URL }} + uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7 + with: + script: | + const webhook = process.env.DISCORD_WEBHOOK_URL; + if (!webhook) return; + const channel = '${{ needs.prepare.outputs.channel }}'; + const version = '${{ needs.prepare.outputs.new_version }}'; + const success = '${{ job.status }}' === 'success'; + const beta = channel === 'beta'; + const color = !success ? 0xed4245 : beta ? 0xfee75c : 0x57f287; // 빨강/노랑/초록 + const title = !success + ? `⚠️ 릴리즈 실패 (${channel})` + : `${beta ? '🚀 베타' : '✅ 스테이블'} 릴리즈 v${version}`; + const url = `https://github.com/${context.repo.owner}/${context.repo.repo}/releases/tag/v${version}`; + const res = await fetch(webhook, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + embeds: [{ + title, + url: success ? url : undefined, + description: success ? `채널: **${channel}**` : '워크플로 로그를 확인해주세요.', + color, + }], + }), + }); + if (!res.ok) core.warning(`Discord 알림 실패: ${res.status}`); diff --git a/.github/workflows/versioning.yaml b/.github/workflows/versioning.yaml deleted file mode 100644 index 1e18da2..0000000 --- a/.github/workflows/versioning.yaml +++ /dev/null @@ -1,125 +0,0 @@ -name: 버전 관리 및 릴리즈 - -on: - pull_request: - types: [closed] - branches: - - main - -permissions: - contents: write - pull-requests: read - -jobs: - release: - runs-on: ubuntu-latest - if: github.event.pull_request.merged == true - - steps: - - name: 리포지토리 체크아웃 - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - - name: Node.js 설정 - uses: actions/setup-node@v4 - with: - node-version: '20' - - - name: 의존성 설치 - run: npm install - - - name: PR 라벨에서 버전 범프 결정 - id: version - uses: actions/github-script@v7 - with: - script: | - const labels = context.payload.pull_request.labels.map(l => l.name); - let bump = 'patch'; - if (labels.includes('🔖 major')) bump = 'major'; - else if (labels.includes('🔖 minor')) bump = 'minor'; - - // 최신 태그에서 현재 버전 추출 - const { execSync } = require('child_process'); - let currentVersion; - try { - const latestTag = execSync('git describe --tags --abbrev=0', { encoding: 'utf-8' }).trim(); - currentVersion = latestTag.replace(/^v/, ''); - } catch { - currentVersion = '0.0.0'; - } - - const [major, minor, patch] = currentVersion.split('.').map(Number); - let newVersion; - if (bump === 'major') newVersion = `${major + 1}.0.0`; - else if (bump === 'minor') newVersion = `${major}.${minor + 1}.0`; - else newVersion = `${major}.${minor}.${patch + 1}`; - - core.setOutput('new_version', newVersion); - core.setOutput('bump', bump); - - - name: package.json 버전 업데이트 - run: npm version ${{ steps.version.outputs.new_version }} --no-git-tag-version - - - name: 프로덕션 빌드 - run: npm run build - - - name: dist 압축 - run: cd dist && zip -r ../dotbugi-v${{ steps.version.outputs.new_version }}.zip . - - - name: README 배지 버전 업데이트 - run: sed -i "s/Chrome_Web_Store-v[0-9]\+\.[0-9]\+\.[0-9]\+/Chrome_Web_Store-v${{ steps.version.outputs.new_version }}/" README.md - - - name: 변경사항 커밋 및 태그 - run: | - git config user.name "github-actions[bot]" - git config user.email "github-actions[bot]@users.noreply.github.com" - git add package.json package-lock.json README.md - git commit -m "chore: bump version to v${{ steps.version.outputs.new_version }}" - git tag v${{ steps.version.outputs.new_version }} - git push origin main --follow-tags - - - name: 릴리즈 노트 생성 - id: changelog - uses: actions/github-script@v7 - with: - script: | - const { execSync } = require('child_process'); - - // 이전 태그 찾기 - let prevTag; - try { - prevTag = execSync('git describe --tags --abbrev=0 HEAD~1', { encoding: 'utf-8' }).trim(); - } catch { - prevTag = execSync('git rev-list --max-parents=0 HEAD', { encoding: 'utf-8' }).trim(); - } - - const newVersion = '${{ steps.version.outputs.new_version }}'; - - // PR에 포함된 커밋 가져오기 - const commits = execSync( - `git log ${prevTag}..HEAD --pretty=format:"- %s (%h)" --no-merges`, - { encoding: 'utf-8' } - ).trim(); - - const pr = context.payload.pull_request; - const body = [ - `## What's Changed`, - ``, - `**PR:** #${pr.number} ${pr.title}`, - ``, - `### Commits`, - commits, - ``, - `**Full Changelog:** https://github.com/${context.repo.owner}/${context.repo.repo}/compare/${prevTag}...v${newVersion}`, - ].join('\n'); - - core.setOutput('body', body); - - - name: GitHub 릴리즈 생성 - uses: softprops/action-gh-release@v2 - with: - tag_name: v${{ steps.version.outputs.new_version }} - name: v${{ steps.version.outputs.new_version }} - body: ${{ steps.changelog.outputs.body }} - files: dotbugi-v${{ steps.version.outputs.new_version }}.zip diff --git a/.gitignore b/.gitignore index 1b3be4b..ee5fd78 100755 --- a/.gitignore +++ b/.gitignore @@ -46,10 +46,6 @@ temp/ *.zip dist -*.md -!README.md -!docs/**/*.md - # VitePress docs/.vitepress/dist/ docs/.vitepress/cache/ \ No newline at end of file diff --git a/.husky/commit-msg b/.husky/commit-msg new file mode 100644 index 0000000..da99483 --- /dev/null +++ b/.husky/commit-msg @@ -0,0 +1 @@ +npx --no -- commitlint --edit "$1" diff --git a/.husky/pre-commit b/.husky/pre-commit new file mode 100644 index 0000000..2312dc5 --- /dev/null +++ b/.husky/pre-commit @@ -0,0 +1 @@ +npx lint-staged diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..970af6c --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,137 @@ +# CLAUDE.md + +Behavioral guidelines to reduce common LLM coding mistakes. Merge with project-specific instructions as needed. + +**Tradeoff:** These guidelines bias toward caution over speed. For trivial tasks, use judgment. + +## 1. Think Before Coding + +**Don't assume. Don't hide confusion. Surface tradeoffs.** + +Before implementing: + +- State your assumptions explicitly. If uncertain, ask. +- If multiple interpretations exist, present them - don't pick silently. +- If a simpler approach exists, say so. Push back when warranted. +- If something is unclear, stop. Name what's confusing. Ask. + +## 2. Simplicity First + +**Minimum code that solves the problem. Nothing speculative.** + +- No features beyond what was asked. +- No abstractions for single-use code. +- No "flexibility" or "configurability" that wasn't requested. +- No error handling for impossible scenarios. +- If you write 200 lines and it could be 50, rewrite it. + +Ask yourself: "Would a senior engineer say this is overcomplicated?" If yes, simplify. + +## 3. Surgical Changes + +**Touch only what you must. Clean up only your own mess.** + +When editing existing code: + +- Don't "improve" adjacent code, comments, or formatting. +- Don't refactor things that aren't broken. +- Match existing style, even if you'd do it differently. +- If you notice unrelated dead code, mention it - don't delete it. + +When your changes create orphans: + +- Remove imports/variables/functions that YOUR changes made unused. +- Don't remove pre-existing dead code unless asked. + +The test: Every changed line should trace directly to the user's request. + +## 4. Goal-Driven Execution + +**Define success criteria. Loop until verified.** + +Transform tasks into verifiable goals: + +- "Add validation" → "Write tests for invalid inputs, then make them pass" +- "Fix the bug" → "Write a test that reproduces it, then make it pass" +- "Refactor X" → "Ensure tests pass before and after" + +For multi-step tasks, state a brief plan: + +``` +1. [Step] → verify: [check] +2. [Step] → verify: [check] +3. [Step] → verify: [check] +``` + +Strong success criteria let you loop independently. Weak criteria ("make it work") require constant clarification. + +--- + +**These guidelines are working if:** fewer unnecessary changes in diffs, fewer rewrites due to overcomplication, and clarifying questions come before implementation rather than after mistakes. + +--- + +# Project-specific guidance: Dotbugi + +## Project Overview + +Dotbugi (돋부기) is a Chrome Extension (Manifest V3) for Hansung University students that aggregates VOD lectures, assignments, and quizzes from the university LMS (`https://learn.hansung.ac.kr/`) into a single dashboard. + +## Build & Development Commands + +```bash +npm run dev # Vite dev server +npm run build # TypeScript check + Vite production build +npm run lint # ESLint +npm run test # Vitest +npm run format # Prettier write +npm run preview # Preview production build +``` + +To test the extension, load `dist/` as an unpacked extension in Chrome (`chrome://extensions`). + +## Code Formatting & Commits + +- Prettier: single quotes, semicolons, 120 char width, 2-space tabs, ES5 trailing commas +- Commit tooling (husky): `pre-commit` runs lint-staged (Prettier + ESLint `--fix` on staged files); `commit-msg` enforces Conventional Commits via commitlint. Hooks install on `npm install`. +- Commit messages follow Conventional Commits (`feat:`, `fix:`, `docs:`, `chore:`, `ci:` …); Korean subjects are allowed. + +## Architecture + +**Three entry points:** + +1. **Content Script** (`src/content/index.tsx`) — Injects a popover dashboard and video player into the LMS pages using Shadow DOM for style isolation. +2. **Background Service Worker** (`src/background.ts`) — Handles Chrome alarm scheduling and notification creation via message passing. +3. **Options Page** (`src/option/index.tsx`, served from `option.html`) — HashRouter SPA with dashboard, VOD/assignment/quiz list pages, and color customization settings. + +**Data flow:** The extension scrapes raw HTML from LMS pages using DOMParser (not an API), orchestrated by `src/lib/fetchCourseData.ts` and individual fetch modules (`fetchVodAttendance.ts`, `fetchIndexPage.ts`, `fetchAssign.ts`, `fetchQuiz.ts`). Results are cached in Chrome storage with a 24-hour TTL. The main data hook is `src/hooks/useCourseData.tsx`. + +**Key patterns:** + +- Shadow DOM isolation for content script UI (context via `src/lib/ShadowRootContext.tsx`) +- Chrome message passing between content scripts and background worker for alarm scheduling +- Deduplication via key generators (`makeVodKey`, `makeAssignKey`, `makeQuizKey`) +- Path alias: `@/` maps to `./src` + +**Stack:** React 18 + TypeScript + Vite + @crxjs/vite-plugin. UI built with shadcn/ui (Radix primitives) + Tailwind CSS. Animations via framer-motion. + +## Contributing flow + +- Every change needs an **issue first**. PRs must reference it (`Closes #123`) — `pr-checks.yml` fails otherwise. +- New issues are scanned for duplicates by `issue-dedup.yml` (GitHub Models) and may get a `👀 possible-duplicate` comment. +- PRs get automated review from **CodeRabbit** (free on public repos) plus `verify` (lint/prettier/build/test). `main` is protected: these checks + 1 approval are required to merge. + +## CI/CD + +Workflows live in `.github/workflows/`: + +- **`pr-checks.yml`** — on PRs: issue-reference guard + Prettier/ESLint/build/test. +- **`release.yaml`** — on PR merge to `main` (also `workflow_dispatch` / `repository_dispatch`). PR labels decide the bump (`🔖 patch` default / `🔖 minor` / `🔖 major`) and channel (`🚀 beta` → prerelease, else stable). A GitHub **Environment** approval gate (`beta` / `stable`, required reviewers) pauses the release until approved. Stable bumps `package.json`/README and pushes to `main`; beta tags only (`-beta.N`, no `main` commit). Both create a GitHub Release. Chrome Web Store auto-publish is stubbed behind `vars.ENABLE_STORE_PUBLISH`. + - The push to protected `main` uses a **GitHub App** short-lived token (`actions/create-github-app-token`, secrets `APP_ID`/`APP_PRIVATE_KEY` scoped to the `beta`/`stable` environments; the App is on the branch-protection bypass list). No long-lived PAT. +- **`discord-notify.yml`** — issue opened / PR opened·merged / CI failure → Discord (`DISCORD_WEBHOOK_URL`). Release notifications are sent inline from `release.yaml` (beta=yellow, stable=green). +- **`issue-dedup.yml`** — similar-issue detection on new issues via GitHub Models (`models: read`, no API key). +- **`pr-autolabel.yml`** — labels PRs from the Conventional Commits title prefix (`feat:`→`✨ feat`, `fix:`→`🐛 bug`, `docs:`→`📝 docs`, `refactor`/`perf`/`test`/`chore`/`ci`/`style` → matching label). + +## Extension Manifest + +Defined in `manifest.config.ts` (not `manifest.json`). Permissions: storage, identity. OAuth2 configured for Google Calendar API integration. Beta builds (`RELEASE_CHANNEL=beta`) suffix the extension name with `(Beta)` — groundwork for a separate beta Web Store listing. diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..61d5161 --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,27 @@ +# 행동 강령 (Code of Conduct) + +## 우리의 약속 + +기여자와 메인테이너로서, 우리는 나이·신체·장애·민족·성 정체성과 표현·경험 수준·국적·외모·인종·종교·성적 지향에 관계없이 모든 사람에게 괴롭힘 없는 참여 환경을 제공할 것을 약속합니다. + +## 우리의 기준 + +긍정적인 환경을 만드는 행동의 예: + +- 친절하고 포용적인 언어 사용 +- 서로 다른 관점과 경험 존중 +- 건설적인 비판을 우아하게 수용 +- 커뮤니티에 가장 이로운 것에 집중 + +용납되지 않는 행동의 예: + +- 성적인 언어나 이미지, 원치 않는 관심 표현 +- 트롤링, 모욕적/경멸적 댓글, 인신공격 +- 공개적 또는 사적인 괴롭힘 +- 명시적 허가 없이 타인의 개인정보 게시 + +## 시행 + +학대·괴롭힘 등 용납될 수 없는 행동은 저장소 메인테이너에게 [이슈](https://github.com/hs-shell/dotbugi/issues) 또는 비공개 연락으로 신고할 수 있습니다. 모든 신고는 검토되어 적절히 대응됩니다. + +이 행동 강령은 [Contributor Covenant](https://www.contributor-covenant.org) v1.4를 기반으로 합니다. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..9d7dbf7 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,61 @@ +# 기여 가이드 + +돋부기(Dotbugi)에 관심 가져주셔서 감사합니다. +이 문서는 기여 절차와 규칙을 정리한 것입니다. + +## 시작하기 전에 + +1. **이슈 먼저 검색하기** — 작업을 시작하기 전에 [이슈 목록](https://github.com/hs-shell/dotbugi/issues)에서 같은 내용이 이미 있는지 확인해주세요. +2. **이슈 먼저 생성하기** — 모든 변경(버그 수정·기능 추가)은 **이슈가 먼저 등록되어 있어야 합니다.** 이슈에서 방향을 합의한 뒤 PR을 올려주세요. +3. 질문이나 사용 문의는 [Discussions](https://github.com/hs-shell/dotbugi/discussions)를 이용해주세요. + +## 개발 환경 + +```bash +npm install # 의존성 설치 (husky git hook 자동 설정) +npm run dev # Vite 개발 서버 +npm run build # 타입 체크 + 프로덕션 빌드 +npm run lint # ESLint +npm run test # 테스트 +``` + +확장 프로그램은 `dist/`를 `chrome://extensions`에서 "압축해제된 확장 프로그램 로드"로 불러와 테스트합니다. + +## 작업 흐름 + +1. 이 저장소를 **Fork**하고 브랜치를 만듭니다. (`feat/짧은-설명`, `fix/짧은-설명`) +2. 변경 후 커밋합니다. 커밋 메시지는 **Conventional Commits** 형식을 따릅니다. (아래 참고) +3. PR을 올립니다. PR 본문에 **연결된 이슈 번호**(`Closes #123`)를 반드시 적습니다. +4. 자동 검사를 통과해야 합니다. + - `lint` / `prettier` / `build` / `test` + - PR 본문에 이슈 참조가 있는지 확인하는 가드 + - **CodeRabbit AI 코드리뷰** (public 저장소에서 자동 실행) +5. 리뷰 피드백을 반영하면 메인테이너가 머지합니다. + +## 커밋 메시지 규칙 (Conventional Commits) + +커밋 메시지는 `commitlint`로 검증됩니다. 형식: + +```text +: <설명> + +예시) +feat: 과제 마감 D-day 알림 추가 +fix: VOD 출석 파싱 오류 수정 +docs: 기여 가이드 추가 +``` + +주요 type: `feat`, `fix`, `docs`, `style`, `refactor`, `perf`, `test`, `chore`, `ci` + +커밋 시 pre-commit 훅이 변경된 파일에 자동으로 `prettier` + `eslint --fix`를 적용합니다. + +## 코드 스타일 + +- Prettier: single quote, 세미콜론, 120자 폭, 2-space, ES5 trailing comma +- 경로 별칭 `@/` → `./src` + +## 릴리즈 / 버전 규칙 (메인테이너용) + +- PR 라벨로 버전 범프가 결정됩니다: `🔖 patch`(기본) / `🔖 minor` / `🔖 major` +- `🚀 beta` 라벨이 있으면 베타(프리릴리즈) 채널로, 없으면 스테이블 채널로 릴리즈됩니다. +- 머지 후 릴리즈는 **승인 게이트**(GitHub Environment)를 통과해야 실제로 발행됩니다. diff --git a/commitlint.config.js b/commitlint.config.js new file mode 100644 index 0000000..926a39f --- /dev/null +++ b/commitlint.config.js @@ -0,0 +1,9 @@ +export default { + extends: ['@commitlint/config-conventional'], + rules: { + // 한글 제목 길이 여유 + 'header-max-length': [2, 'always', 100], + // 제목 끝 마침표 금지만 유지하고, 대소문자 규칙은 한글 친화적으로 완화 + 'subject-case': [0], + }, +}; diff --git a/docs/.vitepress/config.ts b/docs/.vitepress/config.ts index ac79f9f..8bd3c5e 100644 --- a/docs/.vitepress/config.ts +++ b/docs/.vitepress/config.ts @@ -1,13 +1,26 @@ import { defineConfig, type DefaultTheme } from 'vitepress'; -function guideSidebar(basic: string, advanced: string, calendar: string, faq: string, notice: string, label: string, prefix = ''): DefaultTheme.SidebarItem[] { - return [{ text: label, items: [ - { text: notice, link: `${prefix}/guide/notice` }, - { text: basic, link: `${prefix}/guide/basic` }, - { text: advanced, link: `${prefix}/guide/advanced` }, - { text: calendar, link: `${prefix}/guide/calendar` }, - { text: faq, link: `${prefix}/guide/faq` }, - ]}]; +function guideSidebar( + basic: string, + advanced: string, + calendar: string, + faq: string, + notice: string, + label: string, + prefix = '' +): DefaultTheme.SidebarItem[] { + return [ + { + text: label, + items: [ + { text: notice, link: `${prefix}/guide/notice` }, + { text: basic, link: `${prefix}/guide/basic` }, + { text: advanced, link: `${prefix}/guide/advanced` }, + { text: calendar, link: `${prefix}/guide/calendar` }, + { text: faq, link: `${prefix}/guide/faq` }, + ], + }, + ]; } function updatesSidebar(label: string, changelog: string, prefix = ''): DefaultTheme.SidebarItem[] { @@ -21,7 +34,13 @@ export default defineConfig({ head: [ ['link', { rel: 'icon', href: '/dotbugi/favicon.png', type: 'image/png' }], - ['link', { rel: 'stylesheet', href: 'https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.9/dist/web/static/pretendard-jp.min.css' }], + [ + 'link', + { + rel: 'stylesheet', + href: 'https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.9/dist/web/static/pretendard-jp.min.css', + }, + ], ], locales: { @@ -32,10 +51,20 @@ export default defineConfig({ nav: [ { text: '가이드', link: '/guide/basic' }, { text: '업데이트', link: '/updates/changelog' }, - { text: '설치', link: 'https://chromewebstore.google.com/detail/hsu-%EB%8F%8B%EB%B6%80%EA%B8%B0-%F0%9F%94%8E/fbhdnbombekihdhjcfiimiibfmikghch' }, + { + text: '설치', + link: 'https://chromewebstore.google.com/detail/hsu-%EB%8F%8B%EB%B6%80%EA%B8%B0-%F0%9F%94%8E/fbhdnbombekihdhjcfiimiibfmikghch', + }, ], sidebar: { - '/guide/': guideSidebar('간단 사용 설명서', '고급 사용 설명서', 'Google 캘린더 연동', 'FAQ', '공지사항', '가이드'), + '/guide/': guideSidebar( + '간단 사용 설명서', + '고급 사용 설명서', + 'Google 캘린더 연동', + 'FAQ', + '공지사항', + '가이드' + ), '/updates/': updatesSidebar('업데이트', '업데이트 로그'), }, outline: { label: '목차' }, @@ -49,10 +78,21 @@ export default defineConfig({ nav: [ { text: 'Guide', link: '/en/guide/basic' }, { text: 'Updates', link: '/en/updates/changelog' }, - { text: 'Install', link: 'https://chromewebstore.google.com/detail/hsu-%EB%8F%8B%EB%B6%80%EA%B8%B0-%F0%9F%94%8E/fbhdnbombekihdhjcfiimiibfmikghch' }, + { + text: 'Install', + link: 'https://chromewebstore.google.com/detail/hsu-%EB%8F%8B%EB%B6%80%EA%B8%B0-%F0%9F%94%8E/fbhdnbombekihdhjcfiimiibfmikghch', + }, ], sidebar: { - '/en/guide/': guideSidebar('Quick Start', 'Advanced Guide', 'Google Calendar', 'FAQ', 'Notice', 'Guide', '/en'), + '/en/guide/': guideSidebar( + 'Quick Start', + 'Advanced Guide', + 'Google Calendar', + 'FAQ', + 'Notice', + 'Guide', + '/en' + ), '/en/updates/': updatesSidebar('Updates', 'Changelog', '/en'), }, outline: { label: 'On this page' }, @@ -66,10 +106,21 @@ export default defineConfig({ nav: [ { text: 'ガイド', link: '/ja/guide/basic' }, { text: '更新履歴', link: '/ja/updates/changelog' }, - { text: 'インストール', link: 'https://chromewebstore.google.com/detail/hsu-%EB%8F%8B%EB%B6%80%EA%B8%B0-%F0%9F%94%8E/fbhdnbombekihdhjcfiimiibfmikghch' }, + { + text: 'インストール', + link: 'https://chromewebstore.google.com/detail/hsu-%EB%8F%8B%EB%B6%80%EA%B8%B0-%F0%9F%94%8E/fbhdnbombekihdhjcfiimiibfmikghch', + }, ], sidebar: { - '/ja/guide/': guideSidebar('簡単ガイド', '詳細ガイド', 'Google カレンダー連携', 'よくある質問', 'お知らせ', 'ガイド', '/ja'), + '/ja/guide/': guideSidebar( + '簡単ガイド', + '詳細ガイド', + 'Google カレンダー連携', + 'よくある質問', + 'お知らせ', + 'ガイド', + '/ja' + ), '/ja/updates/': updatesSidebar('更新履歴', '更新ログ', '/ja'), }, outline: { label: '目次' }, @@ -83,7 +134,10 @@ export default defineConfig({ nav: [ { text: '指南', link: '/zh/guide/basic' }, { text: '更新日志', link: '/zh/updates/changelog' }, - { text: '安装', link: 'https://chromewebstore.google.com/detail/hsu-%EB%8F%8B%EB%B6%80%EA%B8%B0-%F0%9F%94%8E/fbhdnbombekihdhjcfiimiibfmikghch' }, + { + text: '安装', + link: 'https://chromewebstore.google.com/detail/hsu-%EB%8F%8B%EB%B6%80%EA%B8%B0-%F0%9F%94%8E/fbhdnbombekihdhjcfiimiibfmikghch', + }, ], sidebar: { '/zh/guide/': guideSidebar('快速入门', '高级指南', 'Google 日历', '常见问题', '公告', '指南', '/zh'), diff --git a/docs/.vitepress/theme/AnnouncementBar.vue b/docs/.vitepress/theme/AnnouncementBar.vue index 5745221..982c60f 100644 --- a/docs/.vitepress/theme/AnnouncementBar.vue +++ b/docs/.vitepress/theme/AnnouncementBar.vue @@ -56,10 +56,7 @@ onMounted(() => { setOffset(true); window.addEventListener('resize', updateBarHeight); - observer = new IntersectionObserver( - ([entry]) => setOffset(entry.isIntersecting), - { threshold: 0 }, - ); + observer = new IntersectionObserver(([entry]) => setOffset(entry.isIntersecting), { threshold: 0 }); observer.observe(bar.value); }); diff --git a/docs/en/guide/advanced.md b/docs/en/guide/advanced.md index face18f..6095bc7 100644 --- a/docs/en/guide/advanced.md +++ b/docs/en/guide/advanced.md @@ -9,8 +9,8 @@ A tracking toggle button is displayed on each course card on the LMS main page. By default, all regular courses are tracked, and extracurricular courses are excluded. -| Before Tracking | After Adding Tracking | -| :------------------------------------------: | :---------------------------------------------: | +| Before Tracking | After Adding Tracking | +| :------------------------------------------: | :----------------------------------------: | | ![Before Tracking](/images/track-before.png) | ![After Tracking](/images/track-after.png) | ## Search and Filter @@ -110,9 +110,10 @@ Dotbugi's auto-play does not change video speed or manipulate playback time. It ::: ::: info Smart Playback + - Lectures already marked as attended are automatically skipped. - If there is a previous watch position, playback resumes from that point. -::: + ::: ### Estimated End Time diff --git a/docs/en/guide/basic.md b/docs/en/guide/basic.md index 16d7445..65b4230 100644 --- a/docs/en/guide/basic.md +++ b/docs/en/guide/basic.md @@ -77,6 +77,6 @@ Dotbugi's auto-play works at **normal speed (1x) without any speed manipulation You can choose from Korean, English, Chinese, or Japanese in the Settings tab. -| | | -| -------------------------------- | --------------------------------- | +| | | +| --------------------------------- | ---------------------------------- | | ![Dashboard](/images/setting.png) | ![Dashboard](/images/language.png) | diff --git a/docs/en/guide/calendar.md b/docs/en/guide/calendar.md index ec48c33..398872e 100644 --- a/docs/en/guide/calendar.md +++ b/docs/en/guide/calendar.md @@ -22,11 +22,11 @@ Sync result notifications: ## Calendar Color Coding -| Color | Item | -| ------------ | ---------------- | -| Lavender | Online Lectures | -| Blue | Assignments | -| Orange | Quizzes | +| Color | Item | +| -------- | --------------- | +| Lavender | Online Lectures | +| Blue | Assignments | +| Orange | Quizzes | ## Login Expiration diff --git a/docs/en/guide/notice.md b/docs/en/guide/notice.md index 518bada..8dc51e4 100644 --- a/docs/en/guide/notice.md +++ b/docs/en/guide/notice.md @@ -10,12 +10,12 @@ Some users may be concerned about whether Dotbugi's auto-play feature falls unde Here's how Dotbugi's auto-play works compared to speed-up programs: -| | External Speed-Up Programs | Dotbugi Auto-Play | -|------|-------------------|-----------------| -| Playback Speed | Changed to 2x, 4x, etc. | **Maintains 1x (original speed)** | -| Playback Time Manipulation | Shortens playback time | **No manipulation** | -| LMS Records | Mismatch with actual study time | **Playback time = Study time** | -| Attendance | Risk of rejection | **Normally recognized** | +| | External Speed-Up Programs | Dotbugi Auto-Play | +| -------------------------- | ------------------------------- | --------------------------------- | +| Playback Speed | Changed to 2x, 4x, etc. | **Maintains 1x (original speed)** | +| Playback Time Manipulation | Shortens playback time | **No manipulation** | +| LMS Records | Mismatch with actual study time | **Playback time = Study time** | +| Attendance | Risk of rejection | **Normally recognized** | ### What Dotbugi Does diff --git a/docs/guide/advanced.md b/docs/guide/advanced.md index 1745942..c12e802 100644 --- a/docs/guide/advanced.md +++ b/docs/guide/advanced.md @@ -110,9 +110,10 @@ LMS 메인 페이지의 각 과목 카드에 트래킹 토글 버튼이 표시 ::: ::: info 스마트 재생 + - 이미 출석 처리된 강의는 자동으로 건너뜁니다. - 이전에 시청한 위치가 있으면 해당 지점부터 이어서 재생합니다. -::: + ::: ### 예상 종료 시간 diff --git a/docs/guide/notice.md b/docs/guide/notice.md index a233157..455e761 100644 --- a/docs/guide/notice.md +++ b/docs/guide/notice.md @@ -10,12 +10,12 @@ 돋부기의 자동 재생 기능은 다음과 같이 동작합니다: -| 구분 | 외부 배속 프로그램 | 돋부기 자동 재생 | -|------|-------------------|-----------------| -| 재생 속도 | 2배속, 4배속 등으로 변경 | **1배속 (원래 속도) 유지** | -| 재생 시간 조작 | 재생 시간을 단축시킴 | **조작 없음** | -| LMS 기록 | 실제 학습시간과 불일치 | **재생시간 = 학습시간 동일** | -| 출석 인정 | 불인정 위험 | **정상 인정** | +| 구분 | 외부 배속 프로그램 | 돋부기 자동 재생 | +| -------------- | ------------------------ | ---------------------------- | +| 재생 속도 | 2배속, 4배속 등으로 변경 | **1배속 (원래 속도) 유지** | +| 재생 시간 조작 | 재생 시간을 단축시킴 | **조작 없음** | +| LMS 기록 | 실제 학습시간과 불일치 | **재생시간 = 학습시간 동일** | +| 출석 인정 | 불인정 위험 | **정상 인정** | ### 돋부기가 하는 것 diff --git a/docs/ja/guide/advanced.md b/docs/ja/guide/advanced.md index 44d6a8f..8d59468 100644 --- a/docs/ja/guide/advanced.md +++ b/docs/ja/guide/advanced.md @@ -10,7 +10,7 @@ LMSメインページの各科目カードにトラッキングトグルボタ デフォルトではすべての正規科目がトラッキングされ、課外科目は除外されます。 | トラッキング前 | トラッキング追加 | -| :------------------------------------: | :-----------------------------------: | +| :-----------------------------------------: | :----------------------------------------: | | ![トラッキング前](/images/track-before.png) | ![トラッキング後](/images/track-after.png) | ## 検索とフィルター @@ -36,7 +36,7 @@ LMSメインページの各科目カードにトラッキングトグルボタ 非表示にした項目は設定タブの「非表示のタスク」で確認・復元できます。 | 非表示 | 非表示解除 | -| :-------------------------: | :--------------------------------: | +| :-------------------------: | :-------------------------------: | | ![非表示](/images/hide.png) | ![非表示解除](/images/unhide.png) | ## 講義ページのステータスバッジ @@ -110,9 +110,10 @@ LMSメインページの各科目カードにトラッキングトグルボタ ::: ::: info スマート再生 + - すでに出席処理された講義は自動的にスキップされます。 - 以前視聴した位置がある場合、その地点から再生を再開します。 -::: + ::: ### 予想終了時間 diff --git a/docs/ja/guide/basic.md b/docs/ja/guide/basic.md index d093adb..56f23c6 100644 --- a/docs/ja/guide/basic.md +++ b/docs/ja/guide/basic.md @@ -77,6 +77,6 @@ 設定タブで한국어、English、中文、日本語から選択できます。 -| | | -| -------------------------------- | --------------------------------- | +| | | +| -------------------------------------- | --------------------------------------- | | ![ダッシュボード](/images/setting.png) | ![ダッシュボード](/images/language.png) | diff --git a/docs/ja/guide/calendar.md b/docs/ja/guide/calendar.md index 3082e4f..e01e27c 100644 --- a/docs/ja/guide/calendar.md +++ b/docs/ja/guide/calendar.md @@ -22,11 +22,11 @@ ## カレンダーの色分け -| 色 | 項目 | -| --------- | ------------- | +| 色 | 項目 | +| ------------- | -------------- | | 💜 ラベンダー | オンライン講義 | -| 💙 ブルー | 課題 | -| 🧡 オレンジ | クイズ | +| 💙 ブルー | 課題 | +| 🧡 オレンジ | クイズ | ## ログインの有効期限切れ diff --git a/docs/ja/guide/notice.md b/docs/ja/guide/notice.md index 8a3f4fb..fd5b917 100644 --- a/docs/ja/guide/notice.md +++ b/docs/ja/guide/notice.md @@ -10,12 +10,12 @@ ドットブギの自動再生機能は以下のように動作します: -| 区分 | 外部倍速プログラム | ドットブギ自動再生 | -|------|-------------------|-----------------| -| 再生速度 | 2倍速、4倍速などに変更 | **1倍速(元の速度)を維持** | -| 再生時間の操作 | 再生時間を短縮 | **操作なし** | -| LMS記録 | 実際の学習時間と不一致 | **再生時間 = 学習時間 同一** | -| 出席認定 | 不認定のリスク | **正常に認定** | +| 区分 | 外部倍速プログラム | ドットブギ自動再生 | +| -------------- | ---------------------- | ---------------------------- | +| 再生速度 | 2倍速、4倍速などに変更 | **1倍速(元の速度)を維持** | +| 再生時間の操作 | 再生時間を短縮 | **操作なし** | +| LMS記録 | 実際の学習時間と不一致 | **再生時間 = 学習時間 同一** | +| 出席認定 | 不認定のリスク | **正常に認定** | ### ドットブギがすること diff --git a/docs/public/google50ccec79ebc1bb24.html b/docs/public/google50ccec79ebc1bb24.html index 11719b4..36a024c 100644 --- a/docs/public/google50ccec79ebc1bb24.html +++ b/docs/public/google50ccec79ebc1bb24.html @@ -1 +1 @@ -google-site-verification: google50ccec79ebc1bb24.html \ No newline at end of file +google-site-verification: google50ccec79ebc1bb24.html diff --git a/docs/zh/guide/advanced.md b/docs/zh/guide/advanced.md index 36887ce..82fe39b 100644 --- a/docs/zh/guide/advanced.md +++ b/docs/zh/guide/advanced.md @@ -10,7 +10,7 @@ LMS 主页面的每个科目卡片上会显示跟踪切换按钮。 默认情况下,所有正规科目都会被跟踪,非学分科目除外。 | 跟踪前 | 添加跟踪 | -| :------------------------------------: | :-----------------------------------: | +| :---------------------------------: | :--------------------------------: | | ![跟踪前](/images/track-before.png) | ![跟踪后](/images/track-after.png) | ## 搜索和筛选 @@ -36,7 +36,7 @@ LMS 主页面的每个科目卡片上会显示跟踪切换按钮。 隐藏的项目可以在设置标签页的"已隐藏任务"中查看和恢复。 | 隐藏 | 取消隐藏 | -| :-------------------------: | :--------------------------------: | +| :-----------------------: | :-----------------------------: | | ![隐藏](/images/hide.png) | ![取消隐藏](/images/unhide.png) | ## 课程页面状态徽章 @@ -110,9 +110,10 @@ LMS 主页面的每个科目卡片上会显示跟踪切换按钮。 ::: ::: info 智能播放 + - 已出勤的课程会自动跳过。 - 如果有之前观看的位置,将从该位置继续播放。 -::: + ::: ### 预计结束时间 diff --git a/docs/zh/guide/basic.md b/docs/zh/guide/basic.md index ac1e4c6..9abcaac 100644 --- a/docs/zh/guide/basic.md +++ b/docs/zh/guide/basic.md @@ -77,6 +77,6 @@ 在设置标签页中可以选择한국어、English、中文、日本語。 -| | | -| -------------------------------- | --------------------------------- | +| | | +| ------------------------------ | ------------------------------- | | ![仪表盘](/images/setting.png) | ![仪表盘](/images/language.png) | diff --git a/docs/zh/guide/calendar.md b/docs/zh/guide/calendar.md index 7d46c7e..00ed93b 100644 --- a/docs/zh/guide/calendar.md +++ b/docs/zh/guide/calendar.md @@ -22,11 +22,11 @@ ## 日历颜色区分 -| 颜色 | 项目 | -| --------- | ----------- | +| 颜色 | 项目 | +| --------- | -------- | | 💜 薰衣草 | 在线课程 | -| 💙 蓝色 | 作业 | -| 🧡 橙色 | 测验 | +| 💙 蓝色 | 作业 | +| 🧡 橙色 | 测验 | ## 登录过期 diff --git a/docs/zh/guide/notice.md b/docs/zh/guide/notice.md index b06f23c..79a5db7 100644 --- a/docs/zh/guide/notice.md +++ b/docs/zh/guide/notice.md @@ -10,12 +10,12 @@ 돋부기的自动播放功能与倍速程序的对比如下: -| 区分 | 外部倍速程序 | 돋부기自动播放 | -|------|------------|--------------| -| 播放速度 | 改为2倍速、4倍速等 | **保持1倍速(原始速度)** | -| 播放时间操纵 | 缩短播放时间 | **无任何操纵** | -| LMS记录 | 与实际学习时间不一致 | **播放时间 = 学习时间 完全一致** | -| 出勤认定 | 有不被认定的风险 | **正常认定** | +| 区分 | 外部倍速程序 | 돋부기自动播放 | +| ------------ | -------------------- | -------------------------------- | +| 播放速度 | 改为2倍速、4倍速等 | **保持1倍速(原始速度)** | +| 播放时间操纵 | 缩短播放时间 | **无任何操纵** | +| LMS记录 | 与实际学习时间不一致 | **播放时间 = 学习时间 完全一致** | +| 出勤认定 | 有不被认定的风险 | **正常认定** | ### 돋부기会做的事 diff --git a/eslint.config.js b/eslint.config.js index c973d7f..cefe1e8 100755 --- a/eslint.config.js +++ b/eslint.config.js @@ -5,7 +5,7 @@ import reactRefresh from 'eslint-plugin-react-refresh'; import tseslint from 'typescript-eslint'; export default tseslint.config( - { ignores: ['dist'] }, + { ignores: ['dist', 'docs/.vitepress/cache', 'docs/.vitepress/dist'] }, { extends: [js.configs.recommended, ...tseslint.configs.recommended], files: ['**/*.{ts,tsx}'], diff --git a/manifest.config.ts b/manifest.config.ts index 32a52e5..c46b2e2 100644 --- a/manifest.config.ts +++ b/manifest.config.ts @@ -7,39 +7,44 @@ const DEV_CLIENT_ID = '981860765955-t69elhj8osi7vdp84m5bf8b9hdib44kr.apps.google export function createManifest(mode?: string): ManifestV3Export { const clientId = mode === 'mock' ? DEV_CLIENT_ID : PROD_CLIENT_ID; + // 베타 채널 빌드 기반 (Chrome Web Store 2단계 게시 대비) + // 릴리즈 워크플로의 베타 빌드에서 RELEASE_CHANNEL=beta 를 주입하면 + // 별도 스토어 항목/아이콘과 구분되도록 이름에 (Beta) 가 붙는다. + const isBeta = process.env.RELEASE_CHANNEL === 'beta'; + return { - manifest_version: 3, - name: '__MSG_extName__', - version: packageJson.version, - description: '__MSG_extDescription__', - default_locale: 'ko', - action: {}, - icons: { - '16': 'images/icon/icon-16.png', - '32': 'images/icon/icon-32.png', - '48': 'images/icon/icon-48.png', - '128': 'images/icon/icon-128.png', - }, - background: { - service_worker: 'src/background.ts', - }, - content_scripts: [ - { - matches: ['https://learn.hansung.ac.kr/**'], - js: ['src/popover/index.tsx'], + manifest_version: 3, + name: isBeta ? '__MSG_extName__ (Beta)' : '__MSG_extName__', + version: packageJson.version, + description: '__MSG_extDescription__', + default_locale: 'ko', + action: {}, + icons: { + '16': 'images/icon/icon-16.png', + '32': 'images/icon/icon-32.png', + '48': 'images/icon/icon-48.png', + '128': 'images/icon/icon-128.png', + }, + background: { + service_worker: 'src/background.ts', }, - ], - web_accessible_resources: [ - { - resources: ['assets/*', 'assets/*.css', '*.webp', '*.png', '*.jpg', '*.jpeg', '*.gif'], - matches: ['*://*/*'], + content_scripts: [ + { + matches: ['https://learn.hansung.ac.kr/**'], + js: ['src/popover/index.tsx'], + }, + ], + web_accessible_resources: [ + { + resources: ['assets/*', 'assets/*.css', '*.webp', '*.png', '*.jpg', '*.jpeg', '*.gif'], + matches: ['*://*/*'], + }, + ], + permissions: ['storage', 'identity'], + host_permissions: ['https://*/*', 'http://*/*'], + oauth2: { + client_id: clientId, + scopes: ['https://www.googleapis.com/auth/calendar.events'], }, - ], - permissions: ['storage', 'identity'], - host_permissions: ['https://*/*', 'http://*/*'], - oauth2: { - client_id: clientId, - scopes: ['https://www.googleapis.com/auth/calendar.events'], - }, }; } diff --git a/package-lock.json b/package-lock.json index 237d960..947b877 100644 --- a/package-lock.json +++ b/package-lock.json @@ -36,6 +36,8 @@ "vite-tsconfig-paths": "^5.1.4" }, "devDependencies": { + "@commitlint/cli": "^20.5.3", + "@commitlint/config-conventional": "^20.5.3", "@crxjs/vite-plugin": "^2.0.0-beta.30", "@eslint/js": "^9.17.0", "@testing-library/jest-dom": "^6.9.1", @@ -48,7 +50,9 @@ "eslint-plugin-react-hooks": "^5.0.0", "eslint-plugin-react-refresh": "^0.4.16", "globals": "^15.14.0", + "husky": "^9.1.7", "jsdom": "^25.0.1", + "lint-staged": "^16.4.0", "postcss": "^8.5.1", "prettier": "^3.4.2", "tailwindcss": "^3.4.17", @@ -662,6 +666,345 @@ "node": ">=6.9.0" } }, + "node_modules/@commitlint/cli": { + "version": "20.5.3", + "resolved": "https://registry.npmjs.org/@commitlint/cli/-/cli-20.5.3.tgz", + "integrity": "sha512-OJdL0EXWD5y9LPa0nr/geOwzaS8BsdaybKkcloB0JgsguGxNv2R+hC2FTPqrAcprg35zF33KOQerY0x8W1aesA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@commitlint/format": "^20.5.0", + "@commitlint/lint": "^20.5.3", + "@commitlint/load": "^20.5.3", + "@commitlint/read": "^20.5.0", + "@commitlint/types": "^20.5.0", + "tinyexec": "^1.0.0", + "yargs": "^17.0.0" + }, + "bin": { + "commitlint": "cli.js" + }, + "engines": { + "node": ">=v18" + } + }, + "node_modules/@commitlint/config-conventional": { + "version": "20.5.3", + "resolved": "https://registry.npmjs.org/@commitlint/config-conventional/-/config-conventional-20.5.3.tgz", + "integrity": "sha512-j34Qqeaa152chJgz2ysyk0BCpHenJn1lV0Rx0VXf8k3ccQcED+48EZrzMvo9jLmJUyBrrBwvu89I+2er4gW7QQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@commitlint/types": "^20.5.0", + "conventional-changelog-conventionalcommits": "^9.2.0" + }, + "engines": { + "node": ">=v18" + } + }, + "node_modules/@commitlint/config-validator": { + "version": "20.5.0", + "resolved": "https://registry.npmjs.org/@commitlint/config-validator/-/config-validator-20.5.0.tgz", + "integrity": "sha512-T/Uh6iJUzyx7j35GmHWdIiGRQB+ouZDk0pwAaYq4SXgB54KZhFdJ0vYmxiW6AMYICTIWuyMxDBl1jK74oFp/Gw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@commitlint/types": "^20.5.0", + "ajv": "^8.11.0" + }, + "engines": { + "node": ">=v18" + } + }, + "node_modules/@commitlint/config-validator/node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/@commitlint/config-validator/node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "dev": true, + "license": "MIT" + }, + "node_modules/@commitlint/ensure": { + "version": "20.5.3", + "resolved": "https://registry.npmjs.org/@commitlint/ensure/-/ensure-20.5.3.tgz", + "integrity": "sha512-4i4AgNvH62owG9MwSiWKrle7HGNpBHHdLnWFIp5fTsHUYe5kRuh15t08L/0pdbbrRk8JKXQxxN4hZQcn+szkrw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@commitlint/types": "^20.5.0", + "es-toolkit": "^1.46.0" + }, + "engines": { + "node": ">=v18" + } + }, + "node_modules/@commitlint/execute-rule": { + "version": "20.0.0", + "resolved": "https://registry.npmjs.org/@commitlint/execute-rule/-/execute-rule-20.0.0.tgz", + "integrity": "sha512-xyCoOShoPuPL44gVa+5EdZsBVao/pNzpQhkzq3RdtlFdKZtjWcLlUFQHSWBuhk5utKYykeJPSz2i8ABHQA+ZZw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=v18" + } + }, + "node_modules/@commitlint/format": { + "version": "20.5.0", + "resolved": "https://registry.npmjs.org/@commitlint/format/-/format-20.5.0.tgz", + "integrity": "sha512-TI9EwFU/qZWSK7a5qyXMpKPPv3qta7FO4tKW+Wt2al7sgMbLWTsAcDpX1cU8k16TRdsiiet9aOw0zpvRXNJu7Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@commitlint/types": "^20.5.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=v18" + } + }, + "node_modules/@commitlint/is-ignored": { + "version": "20.5.0", + "resolved": "https://registry.npmjs.org/@commitlint/is-ignored/-/is-ignored-20.5.0.tgz", + "integrity": "sha512-JWLarAsurHJhPozbuAH6GbP4p/hdOCoqS9zJMfqwswne+/GPs5V0+rrsfOkP68Y8PSLphwtFXV0EzJ+GTXTTGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@commitlint/types": "^20.5.0", + "semver": "^7.6.0" + }, + "engines": { + "node": ">=v18" + } + }, + "node_modules/@commitlint/is-ignored/node_modules/semver": { + "version": "7.8.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.4.tgz", + "integrity": "sha512-rUCObTnP32Q08R2uuIrt7r9PlEonuTmtuXYcW6s5kjdlj3xbnwe+21yXptAUYcMAABLkYYTtnmzb3w3EDZfueA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@commitlint/lint": { + "version": "20.5.3", + "resolved": "https://registry.npmjs.org/@commitlint/lint/-/lint-20.5.3.tgz", + "integrity": "sha512-M7JbWBNr2gXKaPc4i/KipsuW1gkDHpj35KPjWtKy3Z+2AQw5wu1gBi1LIO0uoaij67CqY4K8PxPZSGens4evCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@commitlint/is-ignored": "^20.5.0", + "@commitlint/parse": "^20.5.0", + "@commitlint/rules": "^20.5.3", + "@commitlint/types": "^20.5.0" + }, + "engines": { + "node": ">=v18" + } + }, + "node_modules/@commitlint/load": { + "version": "20.5.3", + "resolved": "https://registry.npmjs.org/@commitlint/load/-/load-20.5.3.tgz", + "integrity": "sha512-1FDZWuKyu98Myb8i7Tp31jPU2rZpOwAdYRyJcy2KoGg7Xk2A+bgHN8smhMaaNSNkmE8fwt53BokywZq8Gv/5XQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@commitlint/config-validator": "^20.5.0", + "@commitlint/execute-rule": "^20.0.0", + "@commitlint/resolve-extends": "^20.5.3", + "@commitlint/types": "^20.5.0", + "cosmiconfig": "^9.0.1", + "cosmiconfig-typescript-loader": "^6.1.0", + "es-toolkit": "^1.46.0", + "is-plain-obj": "^4.1.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=v18" + } + }, + "node_modules/@commitlint/message": { + "version": "20.4.3", + "resolved": "https://registry.npmjs.org/@commitlint/message/-/message-20.4.3.tgz", + "integrity": "sha512-6akwCYrzcrFcTYz9GyUaWlhisY4lmQ3KvrnabmhoeAV8nRH4dXJAh4+EUQ3uArtxxKQkvxJS78hNX2EU3USgxQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=v18" + } + }, + "node_modules/@commitlint/parse": { + "version": "20.5.0", + "resolved": "https://registry.npmjs.org/@commitlint/parse/-/parse-20.5.0.tgz", + "integrity": "sha512-SeKWHBMk7YOTnnEWUhx+d1a9vHsjjuo6Uo1xRfPNfeY4bdYFasCH1dDpAv13Lyn+dDPOels+jP6D2GRZqzc5fA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@commitlint/types": "^20.5.0", + "conventional-changelog-angular": "^8.2.0", + "conventional-commits-parser": "^6.3.0" + }, + "engines": { + "node": ">=v18" + } + }, + "node_modules/@commitlint/read": { + "version": "20.5.0", + "resolved": "https://registry.npmjs.org/@commitlint/read/-/read-20.5.0.tgz", + "integrity": "sha512-JDEIJ2+GnWpK8QqwfmW7O42h0aycJEWNqcdkJnyzLD11nf9dW2dWLTVEa8Wtlo4IZFGLPATjR5neA5QlOvIH1w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@commitlint/top-level": "^20.4.3", + "@commitlint/types": "^20.5.0", + "git-raw-commits": "^5.0.0", + "minimist": "^1.2.8", + "tinyexec": "^1.0.0" + }, + "engines": { + "node": ">=v18" + } + }, + "node_modules/@commitlint/resolve-extends": { + "version": "20.5.3", + "resolved": "https://registry.npmjs.org/@commitlint/resolve-extends/-/resolve-extends-20.5.3.tgz", + "integrity": "sha512-+ogW9v/u9JqpvAgTrLra/YTFo0KkjU6iNblF89pPsj4NebNc+DAWctsludwezI8YnsjBmfHpApSwcXprN/f/ew==", + "dev": true, + "license": "MIT", + "dependencies": { + "@commitlint/config-validator": "^20.5.0", + "@commitlint/types": "^20.5.0", + "es-toolkit": "^1.46.0", + "global-directory": "^5.0.0", + "import-meta-resolve": "^4.0.0", + "resolve-from": "^5.0.0" + }, + "engines": { + "node": ">=v18" + } + }, + "node_modules/@commitlint/resolve-extends/node_modules/resolve-from": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", + "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/@commitlint/rules": { + "version": "20.5.3", + "resolved": "https://registry.npmjs.org/@commitlint/rules/-/rules-20.5.3.tgz", + "integrity": "sha512-MPlMnb9D3wbszYMp+1hPtuhtPJndRo6I6yfkZVA4+jR8w7Kqp0u2u/Y+gzbaItx5Lltq5rw7FSZQWJMoXUC4NQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@commitlint/ensure": "^20.5.3", + "@commitlint/message": "^20.4.3", + "@commitlint/to-lines": "^20.0.0", + "@commitlint/types": "^20.5.0" + }, + "engines": { + "node": ">=v18" + } + }, + "node_modules/@commitlint/to-lines": { + "version": "20.0.0", + "resolved": "https://registry.npmjs.org/@commitlint/to-lines/-/to-lines-20.0.0.tgz", + "integrity": "sha512-2l9gmwiCRqZNWgV+pX1X7z4yP0b3ex/86UmUFgoRt672Ez6cAM2lOQeHFRUTuE6sPpi8XBCGnd8Kh3bMoyHwJw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=v18" + } + }, + "node_modules/@commitlint/top-level": { + "version": "20.4.3", + "resolved": "https://registry.npmjs.org/@commitlint/top-level/-/top-level-20.4.3.tgz", + "integrity": "sha512-qD9xfP6dFg5jQ3NMrOhG0/w5y3bBUsVGyJvXxdWEwBm8hyx4WOk3kKXw28T5czBYvyeCVJgJJ6aoJZUWDpaacQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0" + }, + "engines": { + "node": ">=v18" + } + }, + "node_modules/@commitlint/types": { + "version": "20.5.0", + "resolved": "https://registry.npmjs.org/@commitlint/types/-/types-20.5.0.tgz", + "integrity": "sha512-ZJoS8oSq2CAZEpc/YI9SulLrdiIyXeHb/OGqGrkUP6Q7YV+0ouNAa7GjqRdXeQPncHQIDz/jbCTlHScvYvO/gA==", + "dev": true, + "license": "MIT", + "dependencies": { + "conventional-commits-parser": "^6.3.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=v18" + } + }, + "node_modules/@conventional-changelog/git-client": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/@conventional-changelog/git-client/-/git-client-2.7.0.tgz", + "integrity": "sha512-j7A8/LBEQ+3rugMzPXoKYzyUPpw/0CBQCyvtTR7Lmu4olG4yRC/Tfkq79Mr3yuPs0SUitlO2HwGP3gitMJnRFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@simple-libs/child-process-utils": "^1.0.0", + "@simple-libs/stream-utils": "^1.2.0", + "semver": "^7.5.2" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "conventional-commits-filter": "^5.0.0", + "conventional-commits-parser": "^6.4.0" + }, + "peerDependenciesMeta": { + "conventional-commits-filter": { + "optional": true + }, + "conventional-commits-parser": { + "optional": true + } + } + }, + "node_modules/@conventional-changelog/git-client/node_modules/semver": { + "version": "7.8.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.4.tgz", + "integrity": "sha512-rUCObTnP32Q08R2uuIrt7r9PlEonuTmtuXYcW6s5kjdlj3xbnwe+21yXptAUYcMAABLkYYTtnmzb3w3EDZfueA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/@crxjs/vite-plugin": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/@crxjs/vite-plugin/-/vite-plugin-2.3.0.tgz", @@ -4387,6 +4730,35 @@ "dev": true, "license": "MIT" }, + "node_modules/@simple-libs/child-process-utils": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@simple-libs/child-process-utils/-/child-process-utils-1.0.2.tgz", + "integrity": "sha512-/4R8QKnd/8agJynkNdJmNw2MBxuFTRcNFnE5Sg/G+jkSsV8/UBgULMzhizWWW42p8L5H7flImV2ATi79Ove2Tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@simple-libs/stream-utils": "^1.2.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://ko-fi.com/dangreen" + } + }, + "node_modules/@simple-libs/stream-utils": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@simple-libs/stream-utils/-/stream-utils-1.2.0.tgz", + "integrity": "sha512-KxXvfapcixpz6rVEB6HPjOUZT22yN6v0vI0urQSk1L8MlEWPDFCZkhw2xmkyoTGYeFw7tWTZd7e3lVzRZRN/EA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://ko-fi.com/dangreen" + } + }, "node_modules/@standard-schema/spec": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", @@ -4604,6 +4976,17 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/node": { + "version": "25.9.3", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.3.tgz", + "integrity": "sha512-603BddQMv3pUcr4U2dhujk83N2tTDVr/34wII2B6bJy6g+8WD6yUb11jszNs0gdi4PesVWl7ABt8nYMVpnLUcg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "undici-types": ">=7.24.0 <7.24.7" + } + }, "node_modules/@types/prop-types": { "version": "15.7.14", "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.14.tgz", @@ -5393,6 +5776,22 @@ "node": ">= 14.0.0" } }, + "node_modules/ansi-escapes": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-7.3.0.tgz", + "integrity": "sha512-BvU8nYgGQBxcmMuEeUEmNTvrMVjJNSH7RgW24vXexN4Ven6qCvy4TntnvlnwnMLTVlcRQQdbRY8NKnaIoeWDNg==", + "dev": true, + "license": "MIT", + "dependencies": { + "environment": "^1.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/ansi-regex": { "version": "6.1.0", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.1.0.tgz", @@ -5474,6 +5873,13 @@ "node": ">= 0.4" } }, + "node_modules/array-ify": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/array-ify/-/array-ify-1.0.0.tgz", + "integrity": "sha512-c5AMf34bKdvPhQ7tBGhqkgKNUzMr4WUs+WDtC2ZUGOUncbxKMTvqxYctiseW3+L4bA8ec+GcZ6/A/FW4m8ukng==", + "dev": true, + "license": "MIT" + }, "node_modules/assertion-error": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", @@ -5838,42 +6244,199 @@ "url": "https://polar.sh/cva" } }, - "node_modules/clsx": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", - "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "node_modules/cli-cursor": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-5.0.0.tgz", + "integrity": "sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==", + "dev": true, "license": "MIT", + "dependencies": { + "restore-cursor": "^5.0.0" + }, "engines": { - "node": ">=6" + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/color": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/color/-/color-4.2.3.tgz", - "integrity": "sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A==", + "node_modules/cli-truncate": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/cli-truncate/-/cli-truncate-5.2.0.tgz", + "integrity": "sha512-xRwvIOMGrfOAnM1JYtqQImuaNtDEv9v6oIYAs4LIHwTiKee8uwvIi363igssOC0O5U04i4AlENs79LQLu9tEMw==", + "dev": true, "license": "MIT", "dependencies": { - "color-convert": "^2.0.1", - "color-string": "^1.9.0" + "slice-ansi": "^8.0.0", + "string-width": "^8.2.0" }, "engines": { - "node": ">=12.5.0" + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "node_modules/cli-truncate/node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "dev": true, "license": "MIT", - "dependencies": { - "color-name": "~1.1.4" - }, "engines": { - "node": ">=7.0.0" + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" } }, - "node_modules/color-name": { - "version": "1.1.4", + "node_modules/cli-truncate/node_modules/string-width": { + "version": "8.2.1", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-8.2.1.tgz", + "integrity": "sha512-IIaP0g3iy9Cyy18w3M9YcaDudujEAVHKt3a3QJg1+sr/oX96TbaGUubG0hJyCjCBThFH+tFpcIyoUHUn1ogaLA==", + "dev": true, + "license": "MIT", + "dependencies": { + "get-east-asian-width": "^1.5.0", + "strip-ansi": "^7.1.2" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cli-truncate/node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/cliui/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/cliui/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/cliui/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/cliui/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/cliui/node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/color": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/color/-/color-4.2.3.tgz", + "integrity": "sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A==", + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1", + "color-string": "^1.9.0" + }, + "engines": { + "node": ">=12.5.0" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", "license": "MIT" @@ -5894,6 +6457,13 @@ "integrity": "sha512-zW190nQTIoXcGCaU08DvVNFTmQhUpnJfVuAKfWqUQkflXKpaDdpaYoM0iluLS9lgJNHyBF58KKA2FBEwkD7wog==", "license": "MIT" }, + "node_modules/colorette": { + "version": "2.0.20", + "resolved": "https://registry.npmjs.org/colorette/-/colorette-2.0.20.tgz", + "integrity": "sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==", + "dev": true, + "license": "MIT" + }, "node_modules/combined-stream": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", @@ -5927,6 +6497,17 @@ "node": ">= 6" } }, + "node_modules/compare-func": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/compare-func/-/compare-func-2.0.0.tgz", + "integrity": "sha512-zHig5N+tPWARooBnb0Zx1MFcdfpyJrfTJ3Y5L+IFvUm8rM74hHz66z0gw0x4tijh5CorKkKUCnW82R2vmpeCRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-ify": "^1.0.0", + "dot-prop": "^5.1.0" + } + }, "node_modules/concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", @@ -5934,6 +6515,49 @@ "dev": true, "license": "MIT" }, + "node_modules/conventional-changelog-angular": { + "version": "8.3.1", + "resolved": "https://registry.npmjs.org/conventional-changelog-angular/-/conventional-changelog-angular-8.3.1.tgz", + "integrity": "sha512-6gfI3otXK5Ph5DfCOI1dblr+kN3FAm5a97hYoQkqNZxOaYa5WKfXH+AnpsmS+iUH2mgVC2Cg2Qw9m5OKcmNrIg==", + "dev": true, + "license": "ISC", + "dependencies": { + "compare-func": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/conventional-changelog-conventionalcommits": { + "version": "9.3.1", + "resolved": "https://registry.npmjs.org/conventional-changelog-conventionalcommits/-/conventional-changelog-conventionalcommits-9.3.1.tgz", + "integrity": "sha512-dTYtpIacRpcZgrvBYvBfArMmK2xvIpv2TaxM0/ZI5CBtNUzvF2x0t15HsbRABWprS6UPmvj+PzHVjSx4qAVKyw==", + "dev": true, + "license": "ISC", + "dependencies": { + "compare-func": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/conventional-commits-parser": { + "version": "6.4.0", + "resolved": "https://registry.npmjs.org/conventional-commits-parser/-/conventional-commits-parser-6.4.0.tgz", + "integrity": "sha512-tvRg7FIBNlyPzjdG8wWRlPHQJJHI7DylhtRGeU9Lq+JuoPh5BKpPRX83ZdLrvXuOSu5Eo/e7SzOQhU4Hd2Miuw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@simple-libs/stream-utils": "^1.2.0", + "meow": "^13.0.0" + }, + "bin": { + "conventional-commits-parser": "dist/cli/index.js" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/convert-source-map": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", @@ -5970,6 +6594,61 @@ "url": "https://github.com/sponsors/mesqueeb" } }, + "node_modules/cosmiconfig": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-9.0.2.tgz", + "integrity": "sha512-gtTZxTDau1wL7Y7zifc2dd8jHSK/k6BTx/2Xp/BpdlAdnlYWFVt7qhJqgwi7637yRwRQ3qL4ZidbB4I8tA5VOg==", + "dev": true, + "license": "MIT", + "dependencies": { + "env-paths": "^2.2.1", + "import-fresh": "^3.3.0", + "js-yaml": "^4.1.0", + "parse-json": "^5.2.0" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/d-fischer" + }, + "peerDependencies": { + "typescript": ">=4.9.5" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/cosmiconfig-typescript-loader": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/cosmiconfig-typescript-loader/-/cosmiconfig-typescript-loader-6.3.0.tgz", + "integrity": "sha512-Akr82WH1Wfqatyiqpj8HDkO2o2KmJRu1FhKfSNJP3K4IdXwHfEyL7MOb62i1AGQVLtIQM+iCE9CGOtrfhR+mmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "jiti": "2.6.1" + }, + "engines": { + "node": ">=v18" + }, + "peerDependencies": { + "@types/node": "*", + "cosmiconfig": ">=9", + "typescript": ">=5" + } + }, + "node_modules/cosmiconfig-typescript-loader/node_modules/jiti": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", + "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -6242,6 +6921,19 @@ "url": "https://github.com/fb55/domutils?sponsor=1" } }, + "node_modules/dot-prop": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/dot-prop/-/dot-prop-5.3.0.tgz", + "integrity": "sha512-QM8q3zDe58hqUqjraQOmzZ1LIH9SWQJTlEKCH4kJ2oQvLZk7RbQXvtDM2XEq3fwkV9CCvvH4LA0AV+ogFsBM2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-obj": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", @@ -6310,6 +7002,46 @@ "url": "https://github.com/fb55/entities?sponsor=1" } }, + "node_modules/env-paths": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", + "integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/environment": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/environment/-/environment-1.1.0.tgz", + "integrity": "sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/error-ex": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz", + "integrity": "sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-arrayish": "^0.2.1" + } + }, + "node_modules/error-ex/node_modules/is-arrayish": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", + "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==", + "dev": true, + "license": "MIT" + }, "node_modules/es-define-property": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", @@ -6366,6 +7098,17 @@ "node": ">= 0.4" } }, + "node_modules/es-toolkit": { + "version": "1.47.1", + "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.47.1.tgz", + "integrity": "sha512-5RAqEwf4P4E17p+W75KLOWw/nOvKZzSQpxM32IpI2KZLaVonjTrZ0Ai5ghMaVI9eKC2p8eoQgcBdkEDgzFk6+Q==", + "dev": true, + "license": "MIT", + "workspaces": [ + "docs", + "benchmarks" + ] + }, "node_modules/esbuild": { "version": "0.25.0", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.0.tgz", @@ -6638,6 +7381,13 @@ "node": ">=0.10.0" } }, + "node_modules/eventemitter3": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz", + "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==", + "dev": true, + "license": "MIT" + }, "node_modules/expect-type": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", @@ -6697,6 +7447,23 @@ "dev": true, "license": "MIT" }, + "node_modules/fast-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz", + "integrity": "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, "node_modules/fastq": { "version": "1.18.0", "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.18.0.tgz", @@ -6910,6 +7677,29 @@ "node": ">=6.9.0" } }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "dev": true, + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/get-east-asian-width": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.6.0.tgz", + "integrity": "sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/get-intrinsic": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", @@ -6958,6 +7748,23 @@ "node": ">= 0.4" } }, + "node_modules/git-raw-commits": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/git-raw-commits/-/git-raw-commits-5.0.1.tgz", + "integrity": "sha512-Y+csSm2GD/PCSh6Isd/WiMjNAydu0VBiG9J7EdQsNA5P9uXvLayqjmTsNlK5Gs9IhblFZqOU0yid5Il5JPoLiQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@conventional-changelog/git-client": "^2.6.0", + "meow": "^13.0.0" + }, + "bin": { + "git-raw-commits": "src/cli.js" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/glob-parent": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", @@ -6970,6 +7777,22 @@ "node": ">=10.13.0" } }, + "node_modules/global-directory": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/global-directory/-/global-directory-5.0.0.tgz", + "integrity": "sha512-1pgFdhK3J2LeM+dVf2Pd424yHx2ou338lC0ErNP2hPx4j8eW1Sp0XqSjNxtk6Tc4Kr5wlWtSvz8cn2yb7/SG/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "ini": "6.0.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/globals": { "version": "15.14.0", "resolved": "https://registry.npmjs.org/globals/-/globals-15.14.0.tgz", @@ -7193,6 +8016,22 @@ "node": ">= 14" } }, + "node_modules/husky": { + "version": "9.1.7", + "resolved": "https://registry.npmjs.org/husky/-/husky-9.1.7.tgz", + "integrity": "sha512-5gs5ytaNjBrh5Ow3zrvdUUY+0VxIuWVL4i9irt6friV+BqdCfmV11CQTWMiBYWHbXhco+J1kHfTOUkePhCDvMA==", + "dev": true, + "license": "MIT", + "bin": { + "husky": "bin.js" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/typicode" + } + }, "node_modules/i18next": { "version": "25.8.18", "resolved": "https://registry.npmjs.org/i18next/-/i18next-25.8.18.tgz", @@ -7264,6 +8103,17 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/import-meta-resolve": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/import-meta-resolve/-/import-meta-resolve-4.2.0.tgz", + "integrity": "sha512-Iqv2fzaTQN28s/FwZAoFq0ZSs/7hMAHJVX+w8PZl3cY19Pxk6jFFalxQoIfW2826i/fDLXv8IiEZRIT0lDuWcg==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/imurmurhash": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", @@ -7284,6 +8134,16 @@ "node": ">=8" } }, + "node_modules/ini": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/ini/-/ini-6.0.0.tgz", + "integrity": "sha512-IBTdIkzZNOpqm7q3dRqJvMaldXjDHWkEDfrwGEQTs5eaQMWV+djAhR+wahyNNMAa+qpbDUhBMVt4ZKNwpPm7xQ==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, "node_modules/intl-messageformat": { "version": "10.7.14", "resolved": "https://registry.npmjs.org/intl-messageformat/-/intl-messageformat-10.7.14.tgz", @@ -7368,6 +8228,29 @@ "node": ">=0.12.0" } }, + "node_modules/is-obj": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-obj/-/is-obj-2.0.0.tgz", + "integrity": "sha512-drqDG3cbczxxEJRoOXcOjtdp1J/lyp1mNn0xaznRs8+muBhgQcrnbspox5X5fOw0HnMnbfDzvnEMEtqDEJEo8w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-plain-obj": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-4.1.0.tgz", + "integrity": "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/is-potential-custom-element-name": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", @@ -7483,6 +8366,13 @@ "dev": true, "license": "MIT" }, + "node_modules/json-parse-even-better-errors": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", + "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", + "dev": true, + "license": "MIT" + }, "node_modules/json-schema-traverse": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", @@ -7510,83 +8400,313 @@ "node": ">=6" } }, - "node_modules/jsonfile": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.1.0.tgz", - "integrity": "sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ==", + "node_modules/jsonfile": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.1.0.tgz", + "integrity": "sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/lilconfig": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", + "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==", + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antonk52" + } + }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "license": "MIT" + }, + "node_modules/lint-staged": { + "version": "16.4.0", + "resolved": "https://registry.npmjs.org/lint-staged/-/lint-staged-16.4.0.tgz", + "integrity": "sha512-lBWt8hujh/Cjysw5GYVmZpFHXDCgZzhrOm8vbcUdobADZNOK/bRshr2kM3DfgrrtR1DQhfupW9gnIXOfiFi+bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "commander": "^14.0.3", + "listr2": "^9.0.5", + "picomatch": "^4.0.3", + "string-argv": "^0.3.2", + "tinyexec": "^1.0.4", + "yaml": "^2.8.2" + }, + "bin": { + "lint-staged": "bin/lint-staged.js" + }, + "engines": { + "node": ">=20.17" + }, + "funding": { + "url": "https://opencollective.com/lint-staged" + } + }, + "node_modules/lint-staged/node_modules/commander": { + "version": "14.0.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-14.0.3.tgz", + "integrity": "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/lint-staged/node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/listr2": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/listr2/-/listr2-9.0.5.tgz", + "integrity": "sha512-ME4Fb83LgEgwNw96RKNvKV4VTLuXfoKudAmm2lP8Kk87KaMK0/Xrx/aAkMWmT8mDb+3MlFDspfbCs7adjRxA2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "cli-truncate": "^5.0.0", + "colorette": "^2.0.20", + "eventemitter3": "^5.0.1", + "log-update": "^6.1.0", + "rfdc": "^1.4.1", + "wrap-ansi": "^9.0.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/listr2/node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/listr2/node_modules/emoji-regex": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", + "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", + "dev": true, + "license": "MIT" + }, + "node_modules/listr2/node_modules/string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/listr2/node_modules/wrap-ansi": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz", + "integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.2.1", + "string-width": "^7.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/log-update": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/log-update/-/log-update-6.1.0.tgz", + "integrity": "sha512-9ie8ItPR6tjY5uYJh8K/Zrv/RMZ5VOlOWvtZdEHYSTFKZfIBPQa9tOAEeAWhd+AnIneLJ22w5fjOYtoutpWq5w==", + "dev": true, + "license": "MIT", + "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" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/log-update/node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", "dev": true, "license": "MIT", - "dependencies": { - "universalify": "^2.0.0" + "engines": { + "node": ">=12" }, - "optionalDependencies": { - "graceful-fs": "^4.1.6" + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, - "node_modules/keyv": { - "version": "4.5.4", - "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", - "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "node_modules/log-update/node_modules/emoji-regex": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", + "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", "dev": true, - "license": "MIT", - "dependencies": { - "json-buffer": "3.0.1" - } + "license": "MIT" }, - "node_modules/levn": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", - "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "node_modules/log-update/node_modules/is-fullwidth-code-point": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-5.1.0.tgz", + "integrity": "sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ==", "dev": true, "license": "MIT", "dependencies": { - "prelude-ls": "^1.2.1", - "type-check": "~0.4.0" + "get-east-asian-width": "^1.3.1" }, "engines": { - "node": ">= 0.8.0" + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/lilconfig": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", - "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==", + "node_modules/log-update/node_modules/slice-ansi": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-7.1.2.tgz", + "integrity": "sha512-iOBWFgUX7caIZiuutICxVgX1SdxwAVFFKwt1EvMYYec/NWO5meOJ6K5uQxhrYBdQJne4KxiqZc+KptFOWFSI9w==", + "dev": true, "license": "MIT", + "dependencies": { + "ansi-styles": "^6.2.1", + "is-fullwidth-code-point": "^5.0.0" + }, "engines": { - "node": ">=14" + "node": ">=18" }, "funding": { - "url": "https://github.com/sponsors/antonk52" + "url": "https://github.com/chalk/slice-ansi?sponsor=1" } }, - "node_modules/lines-and-columns": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", - "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", - "license": "MIT" - }, - "node_modules/locate-path": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", - "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "node_modules/log-update/node_modules/string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", "dev": true, "license": "MIT", "dependencies": { - "p-locate": "^5.0.0" + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" }, "engines": { - "node": ">=10" + "node": ">=18" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/lodash.merge": { - "version": "4.6.2", - "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", - "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "node_modules/log-update/node_modules/wrap-ansi": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz", + "integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==", "dev": true, - "license": "MIT" + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.2.1", + "string-width": "^7.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } }, "node_modules/loose-envify": { "version": "1.4.0", @@ -7668,6 +8788,19 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/meow": { + "version": "13.2.0", + "resolved": "https://registry.npmjs.org/meow/-/meow-13.2.0.tgz", + "integrity": "sha512-pxQJQzB6djGPXh08dacEloMFopsOqGVRKFPYvPOt9XDZ1HasbgDZA74CJGreSU4G3Ak7EFJGoiH2auq+yXISgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/merge2": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", @@ -7807,6 +8940,19 @@ "node": ">= 0.6" } }, + "node_modules/mimic-function": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/mimic-function/-/mimic-function-5.0.1.tgz", + "integrity": "sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/min-indent": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", @@ -7832,6 +8978,16 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/minipass": { "version": "7.1.2", "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", @@ -8013,6 +9169,22 @@ ], "license": "MIT" }, + "node_modules/onetime": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-7.0.0.tgz", + "integrity": "sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "mimic-function": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/oniguruma-to-es": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/oniguruma-to-es/-/oniguruma-to-es-3.1.1.tgz", @@ -8094,6 +9266,25 @@ "node": ">=6" } }, + "node_modules/parse-json": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", + "integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.0.0", + "error-ex": "^1.3.1", + "json-parse-even-better-errors": "^2.3.0", + "lines-and-columns": "^1.1.6" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/parse5": { "version": "7.2.1", "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.2.1.tgz", @@ -8661,6 +9852,26 @@ "dev": true, "license": "MIT" }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/resolve": { "version": "1.22.10", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.10.tgz", @@ -8691,6 +9902,23 @@ "node": ">=4" } }, + "node_modules/restore-cursor": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-5.1.0.tgz", + "integrity": "sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==", + "dev": true, + "license": "MIT", + "dependencies": { + "onetime": "^7.0.0", + "signal-exit": "^4.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/reusify": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.0.4.tgz", @@ -8912,6 +10140,52 @@ "is-arrayish": "^0.3.1" } }, + "node_modules/slice-ansi": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-8.0.0.tgz", + "integrity": "sha512-stxByr12oeeOyY2BlviTNQlYV5xOj47GirPr4yA1hE9JCtxfQN0+tVbkxwCtYDQWhEKWFHsEK48ORg5jrouCAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.2.3", + "is-fullwidth-code-point": "^5.1.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/chalk/slice-ansi?sponsor=1" + } + }, + "node_modules/slice-ansi/node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/slice-ansi/node_modules/is-fullwidth-code-point": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-5.1.0.tgz", + "integrity": "sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "get-east-asian-width": "^1.3.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -8956,6 +10230,16 @@ "dev": true, "license": "MIT" }, + "node_modules/string-argv": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/string-argv/-/string-argv-0.3.2.tgz", + "integrity": "sha512-aqD2Q0144Z+/RqG52NeHEkZauTAUWJO8c6yTftGJKO3Tja5tUgIfmIl6kExvhtxSDP7fXB6DvzkfMpCd/F3G+Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.6.19" + } + }, "node_modules/string-width": { "version": "5.1.2", "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", @@ -9577,6 +10861,14 @@ "node": ">=18.17" } }, + "node_modules/undici-types": { + "version": "7.24.6", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz", + "integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==", + "dev": true, + "license": "MIT", + "peer": true + }, "node_modules/unist-util-is": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/unist-util-is/-/unist-util-is-6.0.1.tgz", @@ -10811,6 +12103,16 @@ "dev": true, "license": "MIT" }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=10" + } + }, "node_modules/yallist": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", @@ -10819,15 +12121,92 @@ "license": "ISC" }, "node_modules/yaml": { - "version": "2.7.0", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.7.0.tgz", - "integrity": "sha512-+hSoy/QHluxmC9kCIJyL/uyFmLmc+e5CFR5Wa+bpIhIj85LVb9ZH2nVnqrHoSvKogwODv0ClqZkmiSSaIH5LTA==", + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz", + "integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==", "license": "ISC", "bin": { "yaml": "bin.mjs" }, "engines": { - "node": ">= 14" + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } + }, + "node_modules/yargs": { + "version": "17.7.2", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", + "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/yargs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/yargs/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/yargs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" } }, "node_modules/yocto-queue": { diff --git a/package.json b/package.json index 5aa783b..77e3c77 100644 --- a/package.json +++ b/package.json @@ -10,10 +10,21 @@ "test": "vitest run", "test:watch": "vitest", "lint": "eslint .", + "format": "prettier --write .", "preview": "vite preview", "docs:dev": "vitepress dev docs", "docs:build": "vitepress build docs", - "docs:preview": "vitepress preview docs" + "docs:preview": "vitepress preview docs", + "prepare": "husky" + }, + "lint-staged": { + "*.{ts,tsx,js,jsx}": [ + "prettier --write", + "eslint --fix" + ], + "*.{json,css,md,yml,yaml}": [ + "prettier --write" + ] }, "dependencies": { "@dnd-kit/core": "^6.3.1", @@ -44,6 +55,8 @@ "vite-tsconfig-paths": "^5.1.4" }, "devDependencies": { + "@commitlint/cli": "^20.5.3", + "@commitlint/config-conventional": "^20.5.3", "@crxjs/vite-plugin": "^2.0.0-beta.30", "@eslint/js": "^9.17.0", "@testing-library/jest-dom": "^6.9.1", @@ -56,7 +69,9 @@ "eslint-plugin-react-hooks": "^5.0.0", "eslint-plugin-react-refresh": "^0.4.16", "globals": "^15.14.0", + "husky": "^9.1.7", "jsdom": "^25.0.1", + "lint-staged": "^16.4.0", "postcss": "^8.5.1", "prettier": "^3.4.2", "tailwindcss": "^3.4.17", diff --git a/postcss.config.js b/postcss.config.js index 2e7af2b..2aa7205 100644 --- a/postcss.config.js +++ b/postcss.config.js @@ -3,4 +3,4 @@ export default { tailwindcss: {}, autoprefixer: {}, }, -} +}; diff --git a/src/__tests__/badge.test.ts b/src/__tests__/badge.test.ts index 1f229cb..45fe966 100644 --- a/src/__tests__/badge.test.ts +++ b/src/__tests__/badge.test.ts @@ -46,20 +46,16 @@ describe('normalizeUrl (logic)', () => { it('http로 시작하는 URL은 그대로 반환', () => { expect(normalizeUrl('https://learn.hansung.ac.kr/mod/vod/view.php?id=1')).toBe( - 'https://learn.hansung.ac.kr/mod/vod/view.php?id=1', + 'https://learn.hansung.ac.kr/mod/vod/view.php?id=1' ); expect(normalizeUrl('http://example.com')).toBe('http://example.com'); }); it('/로 시작하는 상대 경로에 BASE_LINK 추가', () => { - expect(normalizeUrl('/mod/vod/view.php?id=1')).toBe( - 'https://learn.hansung.ac.kr/mod/vod/view.php?id=1', - ); + expect(normalizeUrl('/mod/vod/view.php?id=1')).toBe('https://learn.hansung.ac.kr/mod/vod/view.php?id=1'); }); it('/가 없는 상대 경로에 BASE_LINK/ 추가', () => { - expect(normalizeUrl('mod/vod/view.php?id=1')).toBe( - 'https://learn.hansung.ac.kr/mod/vod/view.php?id=1', - ); + expect(normalizeUrl('mod/vod/view.php?id=1')).toBe('https://learn.hansung.ac.kr/mod/vod/view.php?id=1'); }); }); diff --git a/src/__tests__/currentWeek.test.ts b/src/__tests__/currentWeek.test.ts index 9223dda..67de562 100644 --- a/src/__tests__/currentWeek.test.ts +++ b/src/__tests__/currentWeek.test.ts @@ -1,8 +1,18 @@ import { describe, it, expect } from 'vitest'; const MONTH_NAMES: Record = { - january: 1, february: 2, march: 3, april: 4, may: 5, june: 6, - july: 7, august: 8, september: 9, october: 10, november: 11, december: 12, + january: 1, + february: 2, + march: 3, + april: 4, + may: 5, + june: 6, + july: 7, + august: 8, + september: 9, + october: 10, + november: 11, + december: 12, }; function parseLocalizedDate(str: string, year: number): Date | null { diff --git a/src/__tests__/filterData.test.ts b/src/__tests__/filterData.test.ts index d108c6e..2f29cce 100644 --- a/src/__tests__/filterData.test.ts +++ b/src/__tests__/filterData.test.ts @@ -51,10 +51,7 @@ describe('filterVods', () => { }); it('과목 필터로 특정 과목만 반환', () => { - const vods = [ - makeVod({ courseTitle: '수학' }), - makeVod({ courseTitle: '영어' }), - ]; + const vods = [makeVod({ courseTitle: '수학' }), makeVod({ courseTitle: '영어' })]; const filters: Filters = { courseTitles: ['수학'] }; const result = filterVods(vods, filters, '', 'range'); expect(result).toHaveLength(1); @@ -62,19 +59,13 @@ describe('filterVods', () => { }); it('검색어로 제목 필터링', () => { - const vods = [ - makeVod({ title: '파이썬 기초' }), - makeVod({ title: '자바 고급' }), - ]; + const vods = [makeVod({ title: '파이썬 기초' }), makeVod({ title: '자바 고급' })]; const result = filterVods(vods, emptyFilters, '파이썬', 'range'); expect(result).toHaveLength(1); }); it('검색어로 교수명 필터링', () => { - const vods = [ - makeVod({ prof: '김교수' }), - makeVod({ prof: '이교수' }), - ]; + const vods = [makeVod({ prof: '김교수' }), makeVod({ prof: '이교수' })]; const result = filterVods(vods, emptyFilters, '이교수', 'range'); expect(result).toHaveLength(1); }); @@ -86,10 +77,7 @@ describe('filterVods', () => { }); it('출석 상태 필터 - 출석만', () => { - const vods = [ - makeVod({ isAttendance: 'O' }), - makeVod({ isAttendance: 'X', title: '강의2' }), - ]; + const vods = [makeVod({ isAttendance: 'O' }), makeVod({ isAttendance: 'X', title: '강의2' })]; // i18n mock: attendance.attended const filters: Filters = { courseTitles: [], attendanceStatuses: ['attendance.attended'] }; const result = filterVods(vods, filters, '', 'range'); @@ -98,10 +86,7 @@ describe('filterVods', () => { }); it('정렬: 미출석이 먼저 (isAttendance 기준)', () => { - const vods = [ - makeVod({ isAttendance: 'O', title: '출석' }), - makeVod({ isAttendance: 'X', title: '미출석' }), - ]; + const vods = [makeVod({ isAttendance: 'O', title: '출석' }), makeVod({ isAttendance: 'X', title: '미출석' })]; const result = filterVods(vods, emptyFilters, '', 'range'); // isAttended('O') = true → 출석이 앞으로? 아니, 코드에서 attendanceA ? -1 : 1 // 실제로는 출석이 먼저 정렬됨 (출석 완료한 것을 상단) @@ -110,10 +95,7 @@ describe('filterVods', () => { }); it('정렬: title 기준', () => { - const vods = [ - makeVod({ isAttendance: 'X', title: 'B강의' }), - makeVod({ isAttendance: 'X', title: 'A강의' }), - ]; + const vods = [makeVod({ isAttendance: 'X', title: 'B강의' }), makeVod({ isAttendance: 'X', title: 'A강의' })]; const result = filterVods(vods, emptyFilters, '', 'title'); expect(result[0].title).toBe('A강의'); expect(result[1].title).toBe('B강의'); @@ -160,10 +142,7 @@ describe('filterVods', () => { }); it('검색어로 과목명 필터링', () => { - const vods = [ - makeVod({ courseTitle: '컴퓨터구조' }), - makeVod({ courseTitle: '운영체제', title: '강의2' }), - ]; + const vods = [makeVod({ courseTitle: '컴퓨터구조' }), makeVod({ courseTitle: '운영체제', title: '강의2' })]; const result = filterVods(vods, emptyFilters, '컴퓨터', 'range'); expect(result).toHaveLength(1); expect(result[0].courseTitle).toBe('컴퓨터구조'); @@ -190,20 +169,14 @@ describe('filterAssigns', () => { }); it('과목 필터', () => { - const assigns = [ - makeAssign({ courseTitle: '수학' }), - makeAssign({ courseTitle: '영어' }), - ]; + const assigns = [makeAssign({ courseTitle: '수학' }), makeAssign({ courseTitle: '영어' })]; const filters: Filters = { courseTitles: ['수학'] }; const result = filterAssigns(assigns, filters, '', 'dueDate'); expect(result).toHaveLength(1); }); it('제출 상태 필터 - 미제출만', () => { - const assigns = [ - makeAssign({ isSubmit: false }), - makeAssign({ isSubmit: true, title: '과제2' }), - ]; + const assigns = [makeAssign({ isSubmit: false }), makeAssign({ isSubmit: true, title: '과제2' })]; const filters: Filters = { courseTitles: [], submitStatuses: [false] }; const result = filterAssigns(assigns, filters, '', 'dueDate'); expect(result).toHaveLength(1); @@ -211,10 +184,7 @@ describe('filterAssigns', () => { }); it('정렬: 미제출이 먼저', () => { - const assigns = [ - makeAssign({ isSubmit: true, title: '제출됨' }), - makeAssign({ isSubmit: false, title: '미제출' }), - ]; + const assigns = [makeAssign({ isSubmit: true, title: '제출됨' }), makeAssign({ isSubmit: false, title: '미제출' })]; const result = filterAssigns(assigns, emptyFilters, '', 'dueDate'); expect(result[0].isSubmit).toBe(false); }); @@ -239,19 +209,13 @@ describe('filterAssigns', () => { }); it('정렬: title 기준', () => { - const assigns = [ - makeAssign({ isSubmit: false, title: 'B과제' }), - makeAssign({ isSubmit: false, title: 'A과제' }), - ]; + const assigns = [makeAssign({ isSubmit: false, title: 'B과제' }), makeAssign({ isSubmit: false, title: 'A과제' })]; const result = filterAssigns(assigns, emptyFilters, '', 'title'); expect(result[0].title).toBe('A과제'); }); it('검색어로 과목명 필터링', () => { - const assigns = [ - makeAssign({ courseTitle: '데이터베이스' }), - makeAssign({ courseTitle: '네트워크' }), - ]; + const assigns = [makeAssign({ courseTitle: '데이터베이스' }), makeAssign({ courseTitle: '네트워크' })]; const result = filterAssigns(assigns, emptyFilters, '데이터', 'dueDate'); expect(result).toHaveLength(1); }); @@ -276,20 +240,14 @@ describe('filterAssigns', () => { }); it('submitStatuses 필터가 빈 배열이면 전체 반환', () => { - const assigns = [ - makeAssign({ isSubmit: true, title: 'A' }), - makeAssign({ isSubmit: false, title: 'B' }), - ]; + const assigns = [makeAssign({ isSubmit: true, title: 'A' }), makeAssign({ isSubmit: false, title: 'B' })]; const filters: Filters = { courseTitles: [], submitStatuses: [] }; const result = filterAssigns(assigns, filters, '', 'dueDate'); expect(result).toHaveLength(2); }); it('제출/미제출 모두 포함하는 submitStatuses 필터', () => { - const assigns = [ - makeAssign({ isSubmit: true, title: 'A' }), - makeAssign({ isSubmit: false, title: 'B' }), - ]; + const assigns = [makeAssign({ isSubmit: true, title: 'A' }), makeAssign({ isSubmit: false, title: 'B' })]; const filters: Filters = { courseTitles: [], submitStatuses: [true, false] }; const result = filterAssigns(assigns, filters, '', 'dueDate'); expect(result).toHaveLength(2); @@ -304,10 +262,7 @@ describe('filterQuizzes', () => { }); it('제출 상태 필터', () => { - const quizzes = [ - makeQuiz({ isSubmit: false }), - makeQuiz({ isSubmit: true, title: '퀴즈2' }), - ]; + const quizzes = [makeQuiz({ isSubmit: false }), makeQuiz({ isSubmit: true, title: '퀴즈2' })]; const filters: Filters = { courseTitles: [], submitStatuses: [true] }; const result = filterQuizzes(quizzes, filters, '', 'dueDate'); expect(result).toHaveLength(1); @@ -315,10 +270,7 @@ describe('filterQuizzes', () => { }); it('정렬: 미제출 우선', () => { - const quizzes = [ - makeQuiz({ isSubmit: true, title: '제출됨' }), - makeQuiz({ isSubmit: false, title: '미제출' }), - ]; + const quizzes = [makeQuiz({ isSubmit: true, title: '제출됨' }), makeQuiz({ isSubmit: false, title: '미제출' })]; const result = filterQuizzes(quizzes, emptyFilters, '', 'dueDate'); expect(result[0].isSubmit).toBe(false); }); @@ -355,10 +307,7 @@ describe('filterQuizzes', () => { }); it('정렬: title 기준 (퀴즈)', () => { - const quizzes = [ - makeQuiz({ isSubmit: false, title: 'B퀴즈' }), - makeQuiz({ isSubmit: false, title: 'A퀴즈' }), - ]; + const quizzes = [makeQuiz({ isSubmit: false, title: 'B퀴즈' }), makeQuiz({ isSubmit: false, title: 'A퀴즈' })]; const result = filterQuizzes(quizzes, emptyFilters, '', 'title'); expect(result[0].title).toBe('A퀴즈'); }); diff --git a/src/__tests__/generateKey.test.ts b/src/__tests__/generateKey.test.ts index b4aee94..7d1d479 100644 --- a/src/__tests__/generateKey.test.ts +++ b/src/__tests__/generateKey.test.ts @@ -27,8 +27,7 @@ describe('makeItemKey', () => { describe('makeVodGroupKey', () => { it('courseId-subject-range 형식의 키 생성', () => { - expect(makeVodGroupKey('123', '1주차', '2024-01-01 ~ 2024-01-07')) - .toBe('123-1주차-2024-01-01 ~ 2024-01-07'); + expect(makeVodGroupKey('123', '1주차', '2024-01-01 ~ 2024-01-07')).toBe('123-1주차-2024-01-01 ~ 2024-01-07'); }); it('range가 null이면 null 포함', () => { @@ -42,8 +41,9 @@ describe('makeVodGroupKey', () => { }); it('한글/특수문자 포함 키 생성', () => { - expect(makeVodGroupKey('C-1', '1주차 (보충)', '2024-01-01 ~ 2024-01-07')) - .toBe('C-1-1주차 (보충)-2024-01-01 ~ 2024-01-07'); + expect(makeVodGroupKey('C-1', '1주차 (보충)', '2024-01-01 ~ 2024-01-07')).toBe( + 'C-1-1주차 (보충)-2024-01-01 ~ 2024-01-07' + ); }); }); diff --git a/src/__tests__/lmsKeywords.test.ts b/src/__tests__/lmsKeywords.test.ts index 0fd7517..f5b71c1 100644 --- a/src/__tests__/lmsKeywords.test.ts +++ b/src/__tests__/lmsKeywords.test.ts @@ -49,18 +49,19 @@ describe('normalizeLmsRange', () => { }); it('ko/en range 정규화', () => { - expect(normalizeLmsRange('2024-03-15 09:00 ~ 2024-03-22 23:59')) - .toBe('2024-03-15 09:00 ~ 2024-03-22 23:59'); + expect(normalizeLmsRange('2024-03-15 09:00 ~ 2024-03-22 23:59')).toBe('2024-03-15 09:00 ~ 2024-03-22 23:59'); }); it('ja range 정규화', () => { - expect(normalizeLmsRange('2023年 04月 24日(月曜日) 10:50 ~ 2023年 05月 01日(月曜日) 23:59')) - .toBe('2023-04-24 10:50 ~ 2023-05-01 23:59'); + expect(normalizeLmsRange('2023年 04月 24日(月曜日) 10:50 ~ 2023年 05月 01日(月曜日) 23:59')).toBe( + '2023-04-24 10:50 ~ 2023-05-01 23:59' + ); }); it('zh range 정규화', () => { - expect(normalizeLmsRange('2023年04月24日 星期一 10:50 ~ 2023年05月01日 星期一 23:59')) - .toBe('2023-04-24 10:50 ~ 2023-05-01 23:59'); + expect(normalizeLmsRange('2023年04月24日 星期一 10:50 ~ 2023年05月01日 星期一 23:59')).toBe( + '2023-04-24 10:50 ~ 2023-05-01 23:59' + ); }); it('빈 문자열은 falsy이므로 null 반환', () => { diff --git a/src/__tests__/stringUtils.test.ts b/src/__tests__/stringUtils.test.ts index 7110c26..9360efd 100644 --- a/src/__tests__/stringUtils.test.ts +++ b/src/__tests__/stringUtils.test.ts @@ -42,8 +42,7 @@ describe('removeSquareBrackets', () => { describe('formatDateString', () => { it('전체 날짜 범위를 축약 형식으로 변환', () => { - expect(formatDateString('2024-03-15 09:00:00 ~ 2024-03-22 23:59:00')) - .toBe('24.03.15 09:00 ~ 24.03.22 23:59'); + expect(formatDateString('2024-03-15 09:00:00 ~ 2024-03-22 23:59:00')).toBe('24.03.15 09:00 ~ 24.03.22 23:59'); }); it('null이면 i18n 키 반환', () => { @@ -60,7 +59,6 @@ describe('formatDateString', () => { }); it('초 부분이 없는 형식은 원본 반환', () => { - expect(formatDateString('2024-03-15 09:00 ~ 2024-03-22 23:59')) - .toBe('2024-03-15 09:00 ~ 2024-03-22 23:59'); + expect(formatDateString('2024-03-15 09:00 ~ 2024-03-22 23:59')).toBe('2024-03-15 09:00 ~ 2024-03-22 23:59'); }); }); diff --git a/src/__tests__/summarizeCourseData.test.ts b/src/__tests__/summarizeCourseData.test.ts index 67ee53a..b705790 100644 --- a/src/__tests__/summarizeCourseData.test.ts +++ b/src/__tests__/summarizeCourseData.test.ts @@ -23,19 +23,13 @@ describe('summarizeVods', () => { }); it('같은 그룹의 VOD들은 하나로 카운트', () => { - const vods = [ - makeVod({ title: '파트1' }), - makeVod({ title: '파트2' }), - ]; // 같은 courseId+subject+range → 1그룹 + const vods = [makeVod({ title: '파트1' }), makeVod({ title: '파트2' })]; // 같은 courseId+subject+range → 1그룹 const result = summarizeVods(vods); expect(result.total).toBe(1); }); it('다른 그룹은 별도 카운트', () => { - const vods = [ - makeVod({ subject: '1주차' }), - makeVod({ subject: '2주차' }), - ]; + const vods = [makeVod({ subject: '1주차' }), makeVod({ subject: '2주차' })]; const result = summarizeVods(vods); expect(result.total).toBe(2); }); @@ -78,19 +72,13 @@ describe('summarizeVods', () => { }); it('다른 courseId인 같은 subject+range는 별도 그룹', () => { - const vods = [ - makeVod({ courseId: 'C1', subject: '1주차' }), - makeVod({ courseId: 'C2', subject: '1주차' }), - ]; + const vods = [makeVod({ courseId: 'C1', subject: '1주차' }), makeVod({ courseId: 'C2', subject: '1주차' })]; const result = summarizeVods(vods); expect(result.total).toBe(2); }); it('같은 courseId+subject이지만 다른 range는 별도 그룹', () => { - const vods = [ - makeVod({ range: '2024-01-01 ~ 2024-01-07' }), - makeVod({ range: '2024-01-08 ~ 2024-01-14' }), - ]; + const vods = [makeVod({ range: '2024-01-01 ~ 2024-01-07' }), makeVod({ range: '2024-01-08 ~ 2024-01-14' })]; const result = summarizeVods(vods); expect(result.total).toBe(2); }); diff --git a/src/__tests__/transformCalendarEvents.test.ts b/src/__tests__/transformCalendarEvents.test.ts index 302b72b..a0f4588 100644 --- a/src/__tests__/transformCalendarEvents.test.ts +++ b/src/__tests__/transformCalendarEvents.test.ts @@ -19,20 +19,14 @@ const makeVod = (overrides: Partial = {}): Vod => ({ describe('vodGroupsToEvents', () => { it('같은 courseId+subject+range는 하나의 이벤트로 그룹핑', () => { - const vods = [ - makeVod({ title: '파트1' }), - makeVod({ title: '파트2' }), - ]; + const vods = [makeVod({ title: '파트1' }), makeVod({ title: '파트2' })]; const events = vodGroupsToEvents(vods); expect(events).toHaveLength(1); expect(events[0].type).toBe('vod'); }); it('다른 subject면 별도 이벤트', () => { - const vods = [ - makeVod({ subject: '1주차' }), - makeVod({ subject: '2주차' }), - ]; + const vods = [makeVod({ subject: '1주차' }), makeVod({ subject: '2주차' })]; const events = vodGroupsToEvents(vods); expect(events).toHaveLength(2); }); @@ -60,10 +54,7 @@ describe('vodGroupsToEvents', () => { }); it('다른 courseId면 같은 subject+range여도 별도 이벤트', () => { - const vods = [ - makeVod({ courseId: 'C1' }), - makeVod({ courseId: 'C2' }), - ]; + const vods = [makeVod({ courseId: 'C1' }), makeVod({ courseId: 'C2' })]; const events = vodGroupsToEvents(vods); expect(events).toHaveLength(2); }); diff --git a/src/__tests__/transformCourseData.test.ts b/src/__tests__/transformCourseData.test.ts index bc64f6a..b1c16a7 100644 --- a/src/__tests__/transformCourseData.test.ts +++ b/src/__tests__/transformCourseData.test.ts @@ -32,39 +32,27 @@ describe('mergeVodWithAttendance', () => { }); it('빈 attendanceList이면 모든 VOD 누락', () => { - const vodList = [ - { week: 1, subject: '1주차', title: '강의1', url: '/v1', range: null, length: '30:00' }, - ]; + const vodList = [{ week: 1, subject: '1주차', title: '강의1', url: '/v1', range: null, length: '30:00' }]; const result = mergeVodWithAttendance(baseCourse, vodList, []); expect(result).toEqual([]); }); it('같은 title이지만 다른 week이면 매칭 안 됨', () => { - const vodList = [ - { week: 1, subject: '1주차', title: '강의1', url: '/v1', range: null, length: '30:00' }, - ]; - const attendanceList = [ - { title: '강의1', isAttendance: 'O', weeklyAttendance: 'O', week: 2 }, - ]; + const vodList = [{ week: 1, subject: '1주차', title: '강의1', url: '/v1', range: null, length: '30:00' }]; + const attendanceList = [{ title: '강의1', isAttendance: 'O', weeklyAttendance: 'O', week: 2 }]; const result = mergeVodWithAttendance(baseCourse, vodList, attendanceList); expect(result).toEqual([]); }); it('같은 week이지만 다른 title이면 매칭 안 됨', () => { - const vodList = [ - { week: 1, subject: '1주차', title: '강의A', url: '/v1', range: null, length: '30:00' }, - ]; - const attendanceList = [ - { title: '강의B', isAttendance: 'O', weeklyAttendance: 'O', week: 1 }, - ]; + const vodList = [{ week: 1, subject: '1주차', title: '강의A', url: '/v1', range: null, length: '30:00' }]; + const attendanceList = [{ title: '강의B', isAttendance: 'O', weeklyAttendance: 'O', week: 1 }]; const result = mergeVodWithAttendance(baseCourse, vodList, attendanceList); expect(result).toEqual([]); }); it('동일 title+week 출석이 여러 개면 마지막이 사용됨 (Map 덮어쓰기)', () => { - const vodList = [ - { week: 1, subject: '1주차', title: '강의1', url: '/v1', range: null, length: '30:00' }, - ]; + const vodList = [{ week: 1, subject: '1주차', title: '강의1', url: '/v1', range: null, length: '30:00' }]; const attendanceList = [ { title: '강의1', isAttendance: 'X', weeklyAttendance: 'X', week: 1 }, { title: '강의1', isAttendance: 'O', weeklyAttendance: 'O', week: 1 }, @@ -74,12 +62,8 @@ describe('mergeVodWithAttendance', () => { }); it('course 정보가 결과에 포함됨', () => { - const vodList = [ - { week: 1, subject: '1주차', title: '강의1', url: '/v1', range: null, length: '30:00' }, - ]; - const attendanceList = [ - { title: '강의1', isAttendance: 'O', weeklyAttendance: 'O', week: 1 }, - ]; + const vodList = [{ week: 1, subject: '1주차', title: '강의1', url: '/v1', range: null, length: '30:00' }]; + const attendanceList = [{ title: '강의1', isAttendance: 'O', weeklyAttendance: 'O', week: 1 }]; const result = mergeVodWithAttendance(baseCourse, vodList, attendanceList); expect(result[0].courseTitle).toBe('프로그래밍'); expect(result[0].prof).toBe('김교수'); @@ -111,7 +95,13 @@ describe('mergeDueDateItems', () => { it('item의 필드가 course 필드와 겹치면 item이 우선', () => { // T에 courseTitle이 있으면 spread 순서에 의해 item 값이 우선 - const items = [{ title: '과제1', dueDate: '2024-06-30', courseTitle: '재정의됨' } as { title: string; dueDate: string; courseTitle: string }]; + const items = [ + { title: '과제1', dueDate: '2024-06-30', courseTitle: '재정의됨' } as { + title: string; + dueDate: string; + courseTitle: string; + }, + ]; const result = mergeDueDateItems(baseCourse, items); expect(result[0].courseTitle).toBe('재정의됨'); }); @@ -120,34 +110,34 @@ describe('mergeDueDateItems', () => { describe('mergeVodWithAttendance - 추가 edge cases', () => { it('대량 데이터 매칭', () => { const vodList = Array.from({ length: 100 }, (_, i) => ({ - week: i, subject: `${i}주차`, title: `강의${i}`, url: `/v${i}`, range: null, length: '30:00', + week: i, + subject: `${i}주차`, + title: `강의${i}`, + url: `/v${i}`, + range: null, + length: '30:00', })); const attendanceList = Array.from({ length: 100 }, (_, i) => ({ - title: `강의${i}`, isAttendance: i % 2 === 0 ? 'O' : 'X', weeklyAttendance: 'O', week: i, + title: `강의${i}`, + isAttendance: i % 2 === 0 ? 'O' : 'X', + weeklyAttendance: 'O', + week: i, })); const result = mergeVodWithAttendance(baseCourse, vodList, attendanceList); expect(result).toHaveLength(100); }); it('title에 특수문자가 포함된 경우 정확한 매칭', () => { - const vodList = [ - { week: 1, subject: '1주차', title: '강의-1(A)', url: '/v1', range: null, length: '30:00' }, - ]; - const attendanceList = [ - { title: '강의-1(A)', isAttendance: 'O', weeklyAttendance: 'O', week: 1 }, - ]; + const vodList = [{ week: 1, subject: '1주차', title: '강의-1(A)', url: '/v1', range: null, length: '30:00' }]; + const attendanceList = [{ title: '강의-1(A)', isAttendance: 'O', weeklyAttendance: 'O', week: 1 }]; const result = mergeVodWithAttendance(baseCourse, vodList, attendanceList); expect(result).toHaveLength(1); }); it('isCommunity 필드가 course에서 전파됨', () => { const communityCourse: CourseBase = { ...baseCourse, isCommunity: true }; - const vodList = [ - { week: 1, subject: '1주차', title: '강의1', url: '/v1', range: null, length: '30:00' }, - ]; - const attendanceList = [ - { title: '강의1', isAttendance: 'O', weeklyAttendance: 'O', week: 1 }, - ]; + const vodList = [{ week: 1, subject: '1주차', title: '강의1', url: '/v1', range: null, length: '30:00' }]; + const attendanceList = [{ title: '강의1', isAttendance: 'O', weeklyAttendance: 'O', week: 1 }]; const result = mergeVodWithAttendance(communityCourse, vodList, attendanceList); expect(result[0].isCommunity).toBe(true); }); diff --git a/src/components/ui/border-trail.tsx b/src/components/ui/border-trail.tsx index 634b3dc..faff00e 100644 --- a/src/components/ui/border-trail.tsx +++ b/src/components/ui/border-trail.tsx @@ -10,13 +10,7 @@ export type BorderTrailProps = { style?: React.CSSProperties; }; -export function BorderTrail({ - className, - size = 60, - transition, - onAnimationComplete, - style, -}: BorderTrailProps) { +export function BorderTrail({ className, size = 60, transition, onAnimationComplete, style }: BorderTrailProps) { const defaultTransition: Transition = { repeat: Infinity, duration: 5, @@ -24,7 +18,7 @@ export function BorderTrail({ }; return ( -
+
->(({ className, ...props }, ref) => ( -
-)) -Card.displayName = "Card" - -const CardHeader = React.forwardRef< - HTMLDivElement, - React.HTMLAttributes ->(({ className, ...props }, ref) => ( -
-)) -CardHeader.displayName = "CardHeader" - -const CardTitle = React.forwardRef< - HTMLDivElement, - React.HTMLAttributes ->(({ className, ...props }, ref) => ( -
-)) -CardTitle.displayName = "CardTitle" - -const CardDescription = React.forwardRef< - HTMLDivElement, - React.HTMLAttributes ->(({ className, ...props }, ref) => ( -
-)) -CardDescription.displayName = "CardDescription" - -const CardContent = React.forwardRef< - HTMLDivElement, - React.HTMLAttributes ->(({ className, ...props }, ref) => ( -
-)) -CardContent.displayName = "CardContent" - -const CardFooter = React.forwardRef< - HTMLDivElement, - React.HTMLAttributes ->(({ className, ...props }, ref) => ( -
-)) -CardFooter.displayName = "CardFooter" - -export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent } +import * as React from 'react'; + +import { cn } from '@/lib/utils'; + +const Card = React.forwardRef>(({ className, ...props }, ref) => ( +
+)); +Card.displayName = 'Card'; + +const CardHeader = React.forwardRef>( + ({ className, ...props }, ref) => ( +
+ ) +); +CardHeader.displayName = 'CardHeader'; + +const CardTitle = React.forwardRef>( + ({ className, ...props }, ref) => ( +
+ ) +); +CardTitle.displayName = 'CardTitle'; + +const CardDescription = React.forwardRef>( + ({ className, ...props }, ref) => ( +
+ ) +); +CardDescription.displayName = 'CardDescription'; + +const CardContent = React.forwardRef>( + ({ className, ...props }, ref) =>
+); +CardContent.displayName = 'CardContent'; + +const CardFooter = React.forwardRef>( + ({ className, ...props }, ref) =>
+); +CardFooter.displayName = 'CardFooter'; + +export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }; diff --git a/src/components/ui/context-menu.tsx b/src/components/ui/context-menu.tsx index ba43c94..735cb00 100644 --- a/src/components/ui/context-menu.tsx +++ b/src/components/ui/context-menu.tsx @@ -1,33 +1,33 @@ -import * as React from "react" -import * as ContextMenuPrimitive from "@radix-ui/react-context-menu" -import { Check, ChevronRight, Circle } from "lucide-react" +import * as React from 'react'; +import * as ContextMenuPrimitive from '@radix-ui/react-context-menu'; +import { Check, ChevronRight, Circle } from 'lucide-react'; -import { cn } from "@/lib/utils" -import { useShadowRoot } from "@/popover/lib/ShadowRootContext" +import { cn } from '@/lib/utils'; +import { useShadowRoot } from '@/popover/lib/ShadowRootContext'; -const ContextMenu = ContextMenuPrimitive.Root +const ContextMenu = ContextMenuPrimitive.Root; -const ContextMenuTrigger = ContextMenuPrimitive.Trigger +const ContextMenuTrigger = ContextMenuPrimitive.Trigger; -const ContextMenuGroup = ContextMenuPrimitive.Group +const ContextMenuGroup = ContextMenuPrimitive.Group; -const ContextMenuPortal = ContextMenuPrimitive.Portal +const ContextMenuPortal = ContextMenuPrimitive.Portal; -const ContextMenuSub = ContextMenuPrimitive.Sub +const ContextMenuSub = ContextMenuPrimitive.Sub; -const ContextMenuRadioGroup = ContextMenuPrimitive.RadioGroup +const ContextMenuRadioGroup = ContextMenuPrimitive.RadioGroup; const ContextMenuSubTrigger = React.forwardRef< React.ElementRef, React.ComponentPropsWithoutRef & { - inset?: boolean + inset?: boolean; } >(({ className, inset, children, ...props }, ref) => ( -)) -ContextMenuSubTrigger.displayName = ContextMenuPrimitive.SubTrigger.displayName +)); +ContextMenuSubTrigger.displayName = ContextMenuPrimitive.SubTrigger.displayName; const ContextMenuSubContent = React.forwardRef< React.ElementRef, @@ -45,13 +45,13 @@ const ContextMenuSubContent = React.forwardRef< -)) -ContextMenuSubContent.displayName = ContextMenuPrimitive.SubContent.displayName +)); +ContextMenuSubContent.displayName = ContextMenuPrimitive.SubContent.displayName; const ContextMenuContent = React.forwardRef< React.ElementRef, @@ -59,37 +59,37 @@ const ContextMenuContent = React.forwardRef< >(({ className, ...props }, ref) => { const shadowRoot = useShadowRoot(); return ( - - - - ) -}) -ContextMenuContent.displayName = ContextMenuPrimitive.Content.displayName + + + + ); +}); +ContextMenuContent.displayName = ContextMenuPrimitive.Content.displayName; const ContextMenuItem = React.forwardRef< React.ElementRef, React.ComponentPropsWithoutRef & { - inset?: boolean + inset?: boolean; } >(({ className, inset, ...props }, ref) => ( -)) -ContextMenuItem.displayName = ContextMenuPrimitive.Item.displayName +)); +ContextMenuItem.displayName = ContextMenuPrimitive.Item.displayName; const ContextMenuCheckboxItem = React.forwardRef< React.ElementRef, @@ -98,7 +98,7 @@ const ContextMenuCheckboxItem = React.forwardRef< {children} -)) -ContextMenuCheckboxItem.displayName = - ContextMenuPrimitive.CheckboxItem.displayName +)); +ContextMenuCheckboxItem.displayName = ContextMenuPrimitive.CheckboxItem.displayName; const ContextMenuRadioItem = React.forwardRef< React.ElementRef, @@ -122,7 +121,7 @@ const ContextMenuRadioItem = React.forwardRef< {children} -)) -ContextMenuRadioItem.displayName = ContextMenuPrimitive.RadioItem.displayName +)); +ContextMenuRadioItem.displayName = ContextMenuPrimitive.RadioItem.displayName; const ContextMenuLabel = React.forwardRef< React.ElementRef, React.ComponentPropsWithoutRef & { - inset?: boolean + inset?: boolean; } >(({ className, inset, ...props }, ref) => ( -)) -ContextMenuLabel.displayName = ContextMenuPrimitive.Label.displayName +)); +ContextMenuLabel.displayName = ContextMenuPrimitive.Label.displayName; const ContextMenuSeparator = React.forwardRef< React.ElementRef, React.ComponentPropsWithoutRef >(({ className, ...props }, ref) => ( - -)) -ContextMenuSeparator.displayName = ContextMenuPrimitive.Separator.displayName + +)); +ContextMenuSeparator.displayName = ContextMenuPrimitive.Separator.displayName; -const ContextMenuShortcut = ({ - className, - ...props -}: React.HTMLAttributes) => { - return ( - - ) -} -ContextMenuShortcut.displayName = "ContextMenuShortcut" +const ContextMenuShortcut = ({ className, ...props }: React.HTMLAttributes) => { + return ; +}; +ContextMenuShortcut.displayName = 'ContextMenuShortcut'; export { ContextMenu, @@ -199,4 +179,4 @@ export { ContextMenuSubContent, ContextMenuSubTrigger, ContextMenuRadioGroup, -} +}; diff --git a/src/components/ui/dropdown-menu.tsx b/src/components/ui/dropdown-menu.tsx index 682aea0..eb464f8 100644 --- a/src/components/ui/dropdown-menu.tsx +++ b/src/components/ui/dropdown-menu.tsx @@ -1,33 +1,33 @@ -import * as React from "react" -import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu" -import { Check, ChevronRight, Circle } from "lucide-react" +import * as React from 'react'; +import * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu'; +import { Check, ChevronRight, Circle } from 'lucide-react'; -import { cn } from "@/lib/utils" -import { useShadowRoot } from "@/popover/lib/ShadowRootContext" +import { cn } from '@/lib/utils'; +import { useShadowRoot } from '@/popover/lib/ShadowRootContext'; -const DropdownMenu = DropdownMenuPrimitive.Root +const DropdownMenu = DropdownMenuPrimitive.Root; -const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger +const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger; -const DropdownMenuGroup = DropdownMenuPrimitive.Group +const DropdownMenuGroup = DropdownMenuPrimitive.Group; -const DropdownMenuPortal = DropdownMenuPrimitive.Portal +const DropdownMenuPortal = DropdownMenuPrimitive.Portal; -const DropdownMenuSub = DropdownMenuPrimitive.Sub +const DropdownMenuSub = DropdownMenuPrimitive.Sub; -const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup +const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup; const DropdownMenuSubTrigger = React.forwardRef< React.ElementRef, React.ComponentPropsWithoutRef & { - inset?: boolean + inset?: boolean; } >(({ className, inset, children, ...props }, ref) => ( -)) -DropdownMenuSubTrigger.displayName = - DropdownMenuPrimitive.SubTrigger.displayName +)); +DropdownMenuSubTrigger.displayName = DropdownMenuPrimitive.SubTrigger.displayName; const DropdownMenuSubContent = React.forwardRef< React.ElementRef, @@ -46,14 +45,13 @@ const DropdownMenuSubContent = React.forwardRef< -)) -DropdownMenuSubContent.displayName = - DropdownMenuPrimitive.SubContent.displayName +)); +DropdownMenuSubContent.displayName = DropdownMenuPrimitive.SubContent.displayName; const DropdownMenuContent = React.forwardRef< React.ElementRef, @@ -61,39 +59,39 @@ const DropdownMenuContent = React.forwardRef< >(({ className, sideOffset = 4, ...props }, ref) => { const shadowRoot = useShadowRoot(); return ( - - - - ) -}) -DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName + + + + ); +}); +DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName; const DropdownMenuItem = React.forwardRef< React.ElementRef, React.ComponentPropsWithoutRef & { - inset?: boolean + inset?: boolean; } >(({ className, inset, ...props }, ref) => ( svg]:size-4 [&>svg]:shrink-0", - inset && "pl-8", + 'relative flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&>svg]:size-4 [&>svg]:shrink-0', + inset && 'pl-8', className )} {...props} /> -)) -DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName +)); +DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName; const DropdownMenuCheckboxItem = React.forwardRef< React.ElementRef, @@ -102,7 +100,7 @@ const DropdownMenuCheckboxItem = React.forwardRef< {children} -)) -DropdownMenuCheckboxItem.displayName = - DropdownMenuPrimitive.CheckboxItem.displayName +)); +DropdownMenuCheckboxItem.displayName = DropdownMenuPrimitive.CheckboxItem.displayName; const DropdownMenuRadioItem = React.forwardRef< React.ElementRef, @@ -126,7 +123,7 @@ const DropdownMenuRadioItem = React.forwardRef< {children} -)) -DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName +)); +DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName; const DropdownMenuLabel = React.forwardRef< React.ElementRef, React.ComponentPropsWithoutRef & { - inset?: boolean + inset?: boolean; } >(({ className, inset, ...props }, ref) => ( -)) -DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName +)); +DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName; const DropdownMenuSeparator = React.forwardRef< React.ElementRef, React.ComponentPropsWithoutRef >(({ className, ...props }, ref) => ( - -)) -DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName + +)); +DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName; -const DropdownMenuShortcut = ({ - className, - ...props -}: React.HTMLAttributes) => { - return ( - - ) -} -DropdownMenuShortcut.displayName = "DropdownMenuShortcut" +const DropdownMenuShortcut = ({ className, ...props }: React.HTMLAttributes) => { + return ; +}; +DropdownMenuShortcut.displayName = 'DropdownMenuShortcut'; export { DropdownMenu, @@ -200,4 +181,4 @@ export { DropdownMenuSubContent, DropdownMenuSubTrigger, DropdownMenuRadioGroup, -} +}; diff --git a/src/components/ui/label.tsx b/src/components/ui/label.tsx index 683faa7..8f87a22 100644 --- a/src/components/ui/label.tsx +++ b/src/components/ui/label.tsx @@ -1,24 +1,17 @@ -import * as React from "react" -import * as LabelPrimitive from "@radix-ui/react-label" -import { cva, type VariantProps } from "class-variance-authority" +import * as React from 'react'; +import * as LabelPrimitive from '@radix-ui/react-label'; +import { cva, type VariantProps } from 'class-variance-authority'; -import { cn } from "@/lib/utils" +import { cn } from '@/lib/utils'; -const labelVariants = cva( - "text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70" -) +const labelVariants = cva('text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70'); const Label = React.forwardRef< React.ElementRef, - React.ComponentPropsWithoutRef & - VariantProps + React.ComponentPropsWithoutRef & VariantProps >(({ className, ...props }, ref) => ( - -)) -Label.displayName = LabelPrimitive.Root.displayName + +)); +Label.displayName = LabelPrimitive.Root.displayName; -export { Label } +export { Label }; diff --git a/src/hooks/use-mobile.tsx b/src/hooks/use-mobile.tsx index 2b0fe1d..ba553c6 100644 --- a/src/hooks/use-mobile.tsx +++ b/src/hooks/use-mobile.tsx @@ -1,19 +1,19 @@ -import * as React from "react" +import * as React from 'react'; -const MOBILE_BREAKPOINT = 768 +const MOBILE_BREAKPOINT = 768; export function useIsMobile() { - const [isMobile, setIsMobile] = React.useState(undefined) + const [isMobile, setIsMobile] = React.useState(undefined); React.useEffect(() => { - const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`) + const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`); const onChange = () => { - setIsMobile(window.innerWidth < MOBILE_BREAKPOINT) - } - mql.addEventListener("change", onChange) - setIsMobile(window.innerWidth < MOBILE_BREAKPOINT) - return () => mql.removeEventListener("change", onChange) - }, []) + setIsMobile(window.innerWidth < MOBILE_BREAKPOINT); + }; + mql.addEventListener('change', onChange); + setIsMobile(window.innerWidth < MOBILE_BREAKPOINT); + return () => mql.removeEventListener('change', onChange); + }, []); - return !!isMobile + return !!isMobile; } diff --git a/src/hooks/useCalendarEvents.ts b/src/hooks/useCalendarEvents.ts index 60ad11f..11ab884 100644 --- a/src/hooks/useCalendarEvents.ts +++ b/src/hooks/useCalendarEvents.ts @@ -18,13 +18,13 @@ function useCalendarEvents() { loadAndTransform( 'assign', (assigns) => assigns.map((a) => dueDateItemToEvent(a, 'assign')), - appendEvents, + appendEvents ); loadAndTransform( 'quiz', (quizzes) => quizzes.map((q) => dueDateItemToEvent(q, 'quiz')), - appendEvents, + appendEvents ); }, []); diff --git a/src/hooks/useCardData.ts b/src/hooks/useCardData.ts index 693dc22..13fd415 100644 --- a/src/hooks/useCardData.ts +++ b/src/hooks/useCardData.ts @@ -18,16 +18,24 @@ function useCardData() { useEffect(() => { loadAndTransform('vod', summarizeVods, updateKey('vod')); - loadAndTransform('assign', (assigns) => ({ - done: assigns.filter((a) => a.isSubmit).length, - total: assigns.length, - }), updateKey('assign')); + loadAndTransform( + 'assign', + (assigns) => ({ + done: assigns.filter((a) => a.isSubmit).length, + total: assigns.length, + }), + updateKey('assign') + ); // QuizData에 완료 여부 필드가 없어 done은 항상 0 - loadAndTransform('quiz', (quizzes) => ({ - done: 0, - total: quizzes.length, - }), updateKey('quiz')); + loadAndTransform( + 'quiz', + (quizzes) => ({ + done: 0, + total: quizzes.length, + }), + updateKey('quiz') + ); }, []); return summaries; diff --git a/src/hooks/useCourseData.tsx b/src/hooks/useCourseData.tsx index 0c7e729..fc12db6 100644 --- a/src/hooks/useCourseData.tsx +++ b/src/hooks/useCourseData.tsx @@ -92,25 +92,23 @@ export function useCourseData(courses: CourseBase[]) { await Promise.all( courses.map(async (course) => { - const scraped = await scrapeCourseData(course.courseId, cachedQuizMap.get(course.courseId), course.isCommunity); + const scraped = await scrapeCourseData( + course.courseId, + cachedQuizMap.get(course.courseId), + course.isCommunity + ); deduplicateInto( vods, mergeVodWithAttendance(course, scraped.vodDataArray, scraped.vodAttendanceArray), seenVods, - (v) => makeVodKey(v.courseId, v.title, v.week), + (v) => makeVodKey(v.courseId, v.title, v.week) ); - deduplicateInto( - assigns, - mergeDueDateItems(course, scraped.assignDataArray), - seenAssigns, - (a) => makeItemKey(a.courseId, a.title, a.dueDate ?? ''), + deduplicateInto(assigns, mergeDueDateItems(course, scraped.assignDataArray), seenAssigns, (a) => + makeItemKey(a.courseId, a.title, a.dueDate ?? '') ); - deduplicateInto( - quizzes, - mergeDueDateItems(course, scraped.quizDataArray), - seenQuizzes, - (q) => makeItemKey(q.courseId, q.title, q.dueDate ?? ''), + deduplicateInto(quizzes, mergeDueDateItems(course, scraped.quizDataArray), seenQuizzes, (q) => + makeItemKey(q.courseId, q.title, q.dueDate ?? '') ); }) ); @@ -217,13 +215,25 @@ export function useCourseData(courses: CourseBase[]) { const removeCourseData = useCallback((courseId: string) => { // Storage: 전체 데이터에서 해당 강의 제거 loadDataFromStorage('vod', (stored) => { - if (stored) saveDataToStorage('vod', stored.filter((v) => v.courseId !== courseId)); + if (stored) + saveDataToStorage( + 'vod', + stored.filter((v) => v.courseId !== courseId) + ); }); loadDataFromStorage('assign', (stored) => { - if (stored) saveDataToStorage('assign', stored.filter((a) => a.courseId !== courseId)); + if (stored) + saveDataToStorage( + 'assign', + stored.filter((a) => a.courseId !== courseId) + ); }); loadDataFromStorage('quiz', (stored) => { - if (stored) saveDataToStorage('quiz', stored.filter((q) => q.courseId !== courseId)); + if (stored) + saveDataToStorage( + 'quiz', + stored.filter((q) => q.courseId !== courseId) + ); }); // State: 대시보드에서 제거 @@ -292,7 +302,7 @@ export function useCourseData(courses: CourseBase[]) { if (data) setQuizzes(filterItemsForDisplay(byTracked(data))); }); } - // eslint-disable-next-line react-hooks/exhaustive-deps + // eslint-disable-next-line react-hooks/exhaustive-deps }, [courses]); return { diff --git a/src/hooks/useGetCourses.ts b/src/hooks/useGetCourses.ts index fec2477..8938e63 100644 --- a/src/hooks/useGetCourses.ts +++ b/src/hooks/useGetCourses.ts @@ -50,9 +50,7 @@ export const useGetCourses = () => { } } else if (courses.length > 0) { // 최초 사용: 비교과(커뮤니티)와 이전 학기 과목 제외하고 전부 트래킹 - const defaultIds = courses - .filter((c) => !c.isCommunity) - .map((c) => c.courseId); + const defaultIds = courses.filter((c) => !c.isCommunity).map((c) => c.courseId); setTrackedCourseIdsState(defaultIds); saveDataToStorage('trackedCourseIds', defaultIds); } diff --git a/src/hooks/useHiddenTasks.ts b/src/hooks/useHiddenTasks.ts index 3642442..36391ac 100644 --- a/src/hooks/useHiddenTasks.ts +++ b/src/hooks/useHiddenTasks.ts @@ -16,32 +16,41 @@ export function useHiddenTasks() { saveDataToStorage(STORAGE_KEY, Array.from(urls)); }, []); - const hideTask = useCallback((url: string) => { - setHiddenUrls((prev) => { - const next = new Set(prev); - next.add(url); - persist(next); - return next; - }); - }, [persist]); - - const hideTasks = useCallback((urls: string[]) => { - setHiddenUrls((prev) => { - const next = new Set(prev); - for (const url of urls) next.add(url); - persist(next); - return next; - }); - }, [persist]); - - const unhideTask = useCallback((url: string) => { - setHiddenUrls((prev) => { - const next = new Set(prev); - next.delete(url); - persist(next); - return next; - }); - }, [persist]); + const hideTask = useCallback( + (url: string) => { + setHiddenUrls((prev) => { + const next = new Set(prev); + next.add(url); + persist(next); + return next; + }); + }, + [persist] + ); + + const hideTasks = useCallback( + (urls: string[]) => { + setHiddenUrls((prev) => { + const next = new Set(prev); + for (const url of urls) next.add(url); + persist(next); + return next; + }); + }, + [persist] + ); + + const unhideTask = useCallback( + (url: string) => { + setHiddenUrls((prev) => { + const next = new Set(prev); + next.delete(url); + persist(next); + return next; + }); + }, + [persist] + ); const isHidden = useCallback((url: string) => hiddenUrls.has(url), [hiddenUrls]); diff --git a/src/lib/calendarUtils.ts b/src/lib/calendarUtils.ts index 6a88fe5..6f4e277 100644 --- a/src/lib/calendarUtils.ts +++ b/src/lib/calendarUtils.ts @@ -59,7 +59,7 @@ export const removeCachedAuthToken = async (token: string): Promise => { */ export async function addCalendarEvent( event: GoogleCalendarEvent, - token: string, + token: string ): Promise<{ ok: boolean; tokenExpired: boolean }> { try { const response = await fetch('https://www.googleapis.com/calendar/v3/calendars/primary/events', { @@ -88,17 +88,15 @@ export async function addCalendarEvent( const BATCH_CONCURRENCY = 2; const BATCH_DELAY_MS = 500; -async function runBatch( - items: T[], - fn: (item: T) => Promise, - concurrency: number, -): Promise { +async function runBatch(items: T[], fn: (item: T) => Promise, concurrency: number): Promise { const results: R[] = new Array(items.length); for (let i = 0; i < items.length; i += concurrency) { const chunk = items.slice(i, i + concurrency); const chunkResults = await Promise.all(chunk.map((item) => fn(item))); - chunkResults.forEach((r, j) => { results[i + j] = r; }); + chunkResults.forEach((r, j) => { + results[i + j] = r; + }); if (i + concurrency < items.length) { await new Promise((resolve) => setTimeout(resolve, BATCH_DELAY_MS)); } @@ -112,7 +110,7 @@ async function runBatch( */ export async function addCalendarEventsBatch( events: GoogleCalendarEvent[], - token: string, + token: string ): Promise { const results = await runBatch(events, (event) => addCalendarEvent(event, token), BATCH_CONCURRENCY); @@ -158,7 +156,7 @@ export async function addCalendarEventsBatch( * 구글 캘린더 API를 사용해 현재 이벤트 목록을 가져옵니다. */ export async function getCalendarEvents( - token: string, + token: string ): Promise<{ events: GoogleCalendarEvent[]; tokenExpired: boolean }> { try { const pastDate = new Date(); diff --git a/src/lib/courseStatus/badge.ts b/src/lib/courseStatus/badge.ts index 50c3951..7995cc7 100644 --- a/src/lib/courseStatus/badge.ts +++ b/src/lib/courseStatus/badge.ts @@ -60,9 +60,7 @@ function getVodStatus(vod: Vod): StatusInfo { const endDate = extractEndDate(vod.range); const remaining = endDate ? calculateDueDate(endDate) : null; const suffix = - remaining && remaining.status !== 'expired' && remaining.status !== 'noInfo' - ? ` · ${remaining.message}` - : ''; + remaining && remaining.status !== 'expired' && remaining.status !== 'noInfo' ? ` · ${remaining.message}` : ''; const isUrgent = remaining?.status === 'urgent'; return { label: `${t('courseStatus.vod.inProgress')}${suffix}`, @@ -134,7 +132,7 @@ function injectBadgesForSelector( dataMap: Map, getStatus: (item: T) => StatusInfo, hiddenUrls: Set, - hiddenStatus: StatusInfo, + hiddenStatus: StatusInfo ) { document.querySelectorAll(selector).forEach((activity) => { const link = activity.querySelector('a') as HTMLAnchorElement | null; @@ -163,7 +161,7 @@ export function injectBadgesIntoDOM(vods: Vod[], assigns: Assign[], quizzes: Qui new Map(vods.map((v) => [v.url, v])), getVodStatus, hiddenUrls, - hidden, + hidden ); injectBadgesForSelector( @@ -171,7 +169,7 @@ export function injectBadgesIntoDOM(vods: Vod[], assigns: Assign[], quizzes: Qui new Map(assigns.map((a) => [a.url, a])), (a) => getDueDateStatus(a.isSubmit, a.dueDate, 'assign'), hiddenUrls, - hidden, + hidden ); injectBadgesForSelector( @@ -179,7 +177,7 @@ export function injectBadgesIntoDOM(vods: Vod[], assigns: Assign[], quizzes: Qui new Map(quizzes.map((q) => [q.url, q])), (q) => getDueDateStatus(q.isSubmit, q.dueDate, 'quiz'), hiddenUrls, - hidden, + hidden ); if (import.meta.env.VITE_MOCK) { diff --git a/src/lib/courseStatus/currentWeek.ts b/src/lib/courseStatus/currentWeek.ts index 095c73b..b07c414 100644 --- a/src/lib/courseStatus/currentWeek.ts +++ b/src/lib/courseStatus/currentWeek.ts @@ -4,8 +4,18 @@ const CURRENT_WEEK_ATTR = 'data-dotbugi-current-week'; const SCROLL_BTN_ID = 'dotbugi-scroll-current-week'; const MONTH_NAMES: Record = { - january: 1, february: 2, march: 3, april: 4, may: 5, june: 6, - july: 7, august: 8, september: 9, october: 10, november: 11, december: 12, + january: 1, + february: 2, + march: 3, + april: 4, + may: 5, + june: 6, + july: 7, + august: 8, + september: 9, + october: 10, + november: 11, + december: 12, }; const t = (key: string) => i18n.t(key, { ns: 'common' }); @@ -13,7 +23,10 @@ const t = (key: string) => i18n.t(key, { ns: 'common' }); // ── Date parsing ──────────────────────────────────────────────────── function inferYearFromSection(section: Element): number | null { - const m = section.querySelector('.text-ubstrap')?.textContent?.trim()?.match(/(\d{4})-/); + const m = section + .querySelector('.text-ubstrap') + ?.textContent?.trim() + ?.match(/(\d{4})-/); return m ? parseInt(m[1]) : null; } diff --git a/src/lib/courseStatus/storage.ts b/src/lib/courseStatus/storage.ts index bff8eab..c76f013 100644 --- a/src/lib/courseStatus/storage.ts +++ b/src/lib/courseStatus/storage.ts @@ -44,10 +44,9 @@ export function saveCourseData( newVods: Vod[], newAssigns: Assign[], newQuizzes: Quiz[], - all: { vods: Vod[]; assigns: Assign[]; quizzes: Quiz[] }, + all: { vods: Vod[]; assigns: Assign[]; quizzes: Quiz[] } ) { - const exclude = (items: T[]) => - items.filter((item) => item.courseId !== courseId); + const exclude = (items: T[]) => items.filter((item) => item.courseId !== courseId); saveDataToStorage('vod', [...exclude(all.vods), ...newVods]); saveDataToStorage('assign', [...exclude(all.assigns), ...newAssigns]); diff --git a/src/lib/dateUtils.ts b/src/lib/dateUtils.ts index 02d8753..04f8ad7 100644 --- a/src/lib/dateUtils.ts +++ b/src/lib/dateUtils.ts @@ -55,7 +55,13 @@ export function calculateDueDate(dueDate: string | null): TimeDifferenceResult { const endDate = new Date(dueDate); if (isNaN(endDate.getTime())) { - return { message: 'Invalid date format', status: 'invalid', borderColor: 'gray', borderLeftColor: 'gray', textColor: 'black' }; + return { + message: 'Invalid date format', + status: 'invalid', + borderColor: 'gray', + borderLeftColor: 'gray', + textColor: 'black', + }; } if (now >= endDate) { diff --git a/src/lib/deduplicateInto.ts b/src/lib/deduplicateInto.ts index 8678e4a..6dfb5e9 100644 --- a/src/lib/deduplicateInto.ts +++ b/src/lib/deduplicateInto.ts @@ -1,12 +1,7 @@ /** * 키 생성 함수 기반으로 중복 제거하면서 배열에 추가 */ -export function deduplicateInto( - target: T[], - source: T[], - seen: Set, - getKey: (item: T) => string, -): void { +export function deduplicateInto(target: T[], source: T[], seen: Set, getKey: (item: T) => string): void { for (const item of source) { const key = getKey(item); if (!seen.has(key)) { diff --git a/src/lib/fetchAssign.ts b/src/lib/fetchAssign.ts index 57f81c7..1ccb22f 100644 --- a/src/lib/fetchAssign.ts +++ b/src/lib/fetchAssign.ts @@ -26,7 +26,7 @@ export const fetchAssign = async (link: string) => { const url = getHref(row, COL.TITLE_LINK); const rawDueDate = getText(row, COL.DUE_DATE)?.trim(); if (!title || !url) return []; - const dueDate = rawDueDate && rawDueDate !== '-' ? normalizeLmsDate(rawDueDate) ?? null : null; + const dueDate = rawDueDate && rawDueDate !== '-' ? (normalizeLmsDate(rawDueDate) ?? null) : null; const submitText = getText(row, COL.SUBMIT_STATUS) ?? ''; const isSubmit = !NOT_SUBMITTED.some((keyword) => submitText.includes(keyword)); diff --git a/src/lib/fetchCourseData.ts b/src/lib/fetchCourseData.ts index 41132fc..ffcf95d 100644 --- a/src/lib/fetchCourseData.ts +++ b/src/lib/fetchCourseData.ts @@ -1,7 +1,13 @@ import { fetchVodAttendance } from './fetchVodAttendance'; import { fetchVodProgress } from './fetchVodProgress'; import { fetchVodList } from './fetchVodList'; -import { getAssignPageLink, getVodProgressPageLink, getIndexPageLink, getQuizPageLink, getVodPageLink } from '@/constants/links'; +import { + getAssignPageLink, + getVodProgressPageLink, + getIndexPageLink, + getQuizPageLink, + getVodPageLink, +} from '@/constants/links'; import { fetchAssign } from './fetchAssign'; import { fetchQuiz } from './fetchQuiz'; import { Quiz } from '@/types'; @@ -13,15 +19,9 @@ function settled(result: PromiseSettledResult, fallback: T): T { return fallback; } -export const scrapeCourseData = async ( - courseId: string, - cachedQuizzes?: Quiz[], - isCommunity?: boolean, -) => { +export const scrapeCourseData = async (courseId: string, cachedQuizzes?: Quiz[], isCommunity?: boolean) => { // 캐시된 퀴즈의 제출 상태 맵 생성 (url → isSubmit) - const cachedSubmitMap = cachedQuizzes - ? new Map(cachedQuizzes.map((q) => [q.url, q.isSubmit])) - : undefined; + const cachedSubmitMap = cachedQuizzes ? new Map(cachedQuizzes.map((q) => [q.url, q.isSubmit])) : undefined; // 일반 강좌: _a.php 우선, 실패 시 .php 폴백 // 커뮤니티: .php만 사용 (_a.php 접근 불가) @@ -49,9 +49,7 @@ export const scrapeCourseData = async ( // 일반 강좌: _a.php 데이터 사용 (출석 + 시간 데이터 포함) // _a.php 실패 또는 커뮤니티: .php 폴백 (시간 비교 기반 출석 판정) - const vodAttendance = isCommunity || !vodAttendanceRaw - ? vodProgress - : vodAttendanceRaw; + const vodAttendance = isCommunity || !vodAttendanceRaw ? vodProgress : vodAttendanceRaw; return { vodAttendanceArray: vodAttendance, diff --git a/src/lib/fetchQuiz.ts b/src/lib/fetchQuiz.ts index ab27d9e..fbe979f 100644 --- a/src/lib/fetchQuiz.ts +++ b/src/lib/fetchQuiz.ts @@ -47,32 +47,28 @@ type QuizItem = { * @param cachedSubmitMap 이전에 저장된 퀴즈별 제출 상태 (key: url) * 마감된 퀴즈는 상세 페이지를 다시 fetch하지 않고 캐시된 값을 사용 */ -export const fetchQuiz = async ( - link: string, - cachedSubmitMap?: Map, -) => { +export const fetchQuiz = async (link: string, cachedSubmitMap?: Map) => { try { const doc = await fetchHtml(link); const rows = doc.querySelectorAll('table.generaltable tbody tr'); let lastWeekLabel = ''; - const quizItems: Omit[] = Array.from(rows) - .flatMap((row) => { - const weekLabel = getText(row, COL.WEEK); - if (weekLabel) lastWeekLabel = weekLabel; + const quizItems: Omit[] = Array.from(rows).flatMap((row) => { + const weekLabel = getText(row, COL.WEEK); + if (weekLabel) lastWeekLabel = weekLabel; - const titleLink = row.querySelector(COL.TITLE_LINK); - const rawDueDate = getText(row, COL.DUE_DATE)?.trim(); - if (!titleLink) return []; - const dueDate = rawDueDate && rawDueDate !== '-' ? normalizeLmsDate(rawDueDate) ?? null : null; + const titleLink = row.querySelector(COL.TITLE_LINK); + const rawDueDate = getText(row, COL.DUE_DATE)?.trim(); + if (!titleLink) return []; + const dueDate = rawDueDate && rawDueDate !== '-' ? (normalizeLmsDate(rawDueDate) ?? null) : null; - const title = titleLink.textContent?.trim(); - const rawHref = titleLink.getAttribute('href'); - if (!title || !rawHref) return []; + const title = titleLink.textContent?.trim(); + const rawHref = titleLink.getAttribute('href'); + if (!title || !rawHref) return []; - return { title, subject: lastWeekLabel, url: toQuizUrl(rawHref), dueDate }; - }); + return { title, subject: lastWeekLabel, url: toQuizUrl(rawHref), dueDate }; + }); // 마감 전 퀴즈만 상세 페이지 fetch, 마감된 퀴즈는 캐시 사용 const results: QuizItem[] = await Promise.all( @@ -85,7 +81,7 @@ export const fetchQuiz = async ( const isSubmit = await fetchQuizSubmitStatus(item.url); return { ...item, isSubmit }; - }), + }) ); return results; diff --git a/src/lib/fetchVodAttendance.ts b/src/lib/fetchVodAttendance.ts index 3990afd..c189969 100644 --- a/src/lib/fetchVodAttendance.ts +++ b/src/lib/fetchVodAttendance.ts @@ -83,7 +83,7 @@ function extractTime(raw: string | null): string { function flattenRow( row: Element, pendingSpans: (number | undefined)[], - spanValues: (string | null)[], + spanValues: (string | null)[] ): (string | null)[] { const cells = Array.from(row.querySelectorAll('td')); const rowData: (string | null)[] = []; @@ -169,7 +169,7 @@ export function parseAttendanceTable(doc: Document): VodAttendanceData[] { if (!title || !isAttendance) return; - const requiredTime = col.REQUIRED_TIME !== null ? (cells[col.REQUIRED_TIME] || '') : ''; + const requiredTime = col.REQUIRED_TIME !== null ? cells[col.REQUIRED_TIME] || '' : ''; const watchedTime = extractTime(cells[col.WATCHED_TIME]); results.push({ @@ -180,7 +180,7 @@ export function parseAttendanceTable(doc: Document): VodAttendanceData[] { ...(requiredTime && { requiredTime }), ...(watchedTime && { watchedTime }), }); - } catch (error) { + } catch { // 개별 행 파싱 실패는 무시하고 계속 진행 } }); diff --git a/src/lib/fetchVodProgress.ts b/src/lib/fetchVodProgress.ts index ff3d0c2..c0b5fac 100644 --- a/src/lib/fetchVodProgress.ts +++ b/src/lib/fetchVodProgress.ts @@ -88,7 +88,7 @@ export const fetchVodProgress = async (link: string) => { requiredTime, watchedTime, }); - } catch (error) { + } catch { // 개별 행 파싱 실패는 무시하고 계속 진행 } }); diff --git a/src/lib/injectCourseToggles.ts b/src/lib/injectCourseToggles.ts index ae19239..0997cb7 100644 --- a/src/lib/injectCourseToggles.ts +++ b/src/lib/injectCourseToggles.ts @@ -13,64 +13,62 @@ export function injectCourseToggles() { loadDataFromStorage('trackedCourseIds', (savedIds) => { loadDataFromStorage('knownCourseIds', (knownIds) => { - const knownSet = new Set(knownIds ?? []); - - const currentCourseIds = new Set(allCourses.map((c) => c.courseId)); - - let trackedIds: string[]; - let changed = false; - if (savedIds) { - // 현재 강의 목록에 없는 과목은 추적에서 제거 - const filtered = savedIds.filter((id) => currentCourseIds.has(id)); - // 새로 추가된 과목 자동 추가 - const newTrackableIds = allCourses - .filter((c) => !knownSet.has(c.courseId) && !c.isCommunity) - .map((c) => c.courseId); - trackedIds = [...filtered, ...newTrackableIds]; - changed = filtered.length !== savedIds.length || newTrackableIds.length > 0; - } else { - trackedIds = allCourses - .filter((c) => !c.isCommunity) - .map((c) => c.courseId); - changed = true; - } + const knownSet = new Set(knownIds ?? []); + + const currentCourseIds = new Set(allCourses.map((c) => c.courseId)); + + let trackedIds: string[]; + let changed = false; + if (savedIds) { + // 현재 강의 목록에 없는 과목은 추적에서 제거 + const filtered = savedIds.filter((id) => currentCourseIds.has(id)); + // 새로 추가된 과목 자동 추가 + const newTrackableIds = allCourses + .filter((c) => !knownSet.has(c.courseId) && !c.isCommunity) + .map((c) => c.courseId); + trackedIds = [...filtered, ...newTrackableIds]; + changed = filtered.length !== savedIds.length || newTrackableIds.length > 0; + } else { + trackedIds = allCourses.filter((c) => !c.isCommunity).map((c) => c.courseId); + changed = true; + } - if (changed) { - saveDataToStorage('trackedCourseIds', trackedIds); - } + if (changed) { + saveDataToStorage('trackedCourseIds', trackedIds); + } - // knownCourseIds 업데이트 - const updatedKnown = [...new Set([...knownSet, ...currentCourseIds])]; - saveDataToStorage('knownCourseIds', updatedKnown); + // knownCourseIds 업데이트 + const updatedKnown = [...new Set([...knownSet, ...currentCourseIds])]; + saveDataToStorage('knownCourseIds', updatedKnown); - const trackedSet = new Set(trackedIds); - const listItems = document.querySelectorAll('.my-course-lists > li'); + const trackedSet = new Set(trackedIds); + const listItems = document.querySelectorAll('.my-course-lists > li'); - listItems.forEach((li) => { - if (li.querySelector(`[${TOGGLE_ATTR}]`)) return; + listItems.forEach((li) => { + if (li.querySelector(`[${TOGGLE_ATTR}]`)) return; - const link = li.querySelector('a.course_link') as HTMLAnchorElement | null; - if (!link) return; + const link = li.querySelector('a.course_link') as HTMLAnchorElement | null; + if (!link) return; - const courseId = new URL(link.href).searchParams.get('id'); - if (!courseId) return; + const courseId = new URL(link.href).searchParams.get('id'); + if (!courseId) return; - const isTracked = trackedSet.has(courseId); - const toggle = createToggleElement(isTracked); - toggle.setAttribute(TOGGLE_ATTR, courseId); + const isTracked = trackedSet.has(courseId); + const toggle = createToggleElement(isTracked); + toggle.setAttribute(TOGGLE_ATTR, courseId); - toggle.addEventListener('click', (e) => { - e.preventDefault(); - e.stopPropagation(); - handleToggle(toggle, courseId); - }); + toggle.addEventListener('click', (e) => { + e.preventDefault(); + e.stopPropagation(); + handleToggle(toggle, courseId); + }); - const courseBox = li.querySelector('.course_box'); - if (courseBox) { - (courseBox as HTMLElement).style.position = 'relative'; - courseBox.appendChild(toggle); - } - }); + const courseBox = li.querySelector('.course_box'); + if (courseBox) { + (courseBox as HTMLElement).style.position = 'relative'; + courseBox.appendChild(toggle); + } + }); }); }); } diff --git a/src/lib/logger.ts b/src/lib/logger.ts index 7a1c24d..890964e 100644 --- a/src/lib/logger.ts +++ b/src/lib/logger.ts @@ -117,9 +117,15 @@ export async function clearLogs(): Promise { /** 디버깅용 스토리지 스냅샷 (민감 정보 없음, 트래킹 강의만 포함) */ const STORAGE_DUMP_KEYS = [ - 'vod', 'assign', 'quiz', - 'trackedCourseIds', 'communityIds', 'knownCourseIds', - 'courses', 'language', 'hiddenTaskUrls', + 'vod', + 'assign', + 'quiz', + 'trackedCourseIds', + 'communityIds', + 'knownCourseIds', + 'courses', + 'language', + 'hiddenTaskUrls', ] as const; async function getStorageDump(): Promise { @@ -150,9 +156,7 @@ export async function downloadLogs(): Promise { for (const category of LOG_CATEGORIES) { const logs = allLogs[category]; if (logs.length === 0) continue; - const text = logs - .map((l) => `[${l.timestamp}] [${l.level.toUpperCase()}] ${l.message}`) - .join('\n'); + const text = logs.map((l) => `[${l.timestamp}] [${l.level.toUpperCase()}] ${l.message}`).join('\n'); files.push({ name: `${category}.txt`, data: encoder.encode(text) }); } diff --git a/src/lib/parseCourses.ts b/src/lib/parseCourses.ts index 8f2ced0..c36bb56 100644 --- a/src/lib/parseCourses.ts +++ b/src/lib/parseCourses.ts @@ -21,6 +21,7 @@ function parseCourseFromElement(li: Element): CourseBase | null { } export function parseCoursesFromDOM(): CourseBase[] { - return Array.from(document.querySelectorAll('.my-course-lists > li')) - .flatMap((li) => parseCourseFromElement(li) ?? []); + return Array.from(document.querySelectorAll('.my-course-lists > li')).flatMap( + (li) => parseCourseFromElement(li) ?? [] + ); } diff --git a/src/lib/stringUtils.ts b/src/lib/stringUtils.ts index 5dff7a5..fe14dca 100644 --- a/src/lib/stringUtils.ts +++ b/src/lib/stringUtils.ts @@ -10,7 +10,6 @@ export function formatDateString(input: string | null) { return input.replace( regex, - (_, y1, m1, d1, t1, y2, m2, d2, t2) => - `${y1.slice(2)}.${m1}.${d1} ${t1} ~ ${y2.slice(2)}.${m2}.${d2} ${t2}` + (_, y1, m1, d1, t1, y2, m2, d2, t2) => `${y1.slice(2)}.${m1}.${d1} ${t1} ~ ${y2.slice(2)}.${m2}.${d2} ${t2}` ); } diff --git a/src/lib/transformCalendarEvents.ts b/src/lib/transformCalendarEvents.ts index 5f43205..415c12a 100644 --- a/src/lib/transformCalendarEvents.ts +++ b/src/lib/transformCalendarEvents.ts @@ -36,7 +36,7 @@ export function vodGroupsToEvents(vods: Vod[]): CalendarEvent[] { export function dueDateItemToEvent( item: { courseId: string; courseTitle: string; title: string; dueDate: string | null }, - type: 'assign' | 'quiz', + type: 'assign' | 'quiz' ): CalendarEvent { const normalizedDate = item.dueDate ? startOfDay(new Date(item.dueDate)) : null; return { diff --git a/src/lib/transformCourseData.ts b/src/lib/transformCourseData.ts index d25cbe9..4022ca7 100644 --- a/src/lib/transformCourseData.ts +++ b/src/lib/transformCourseData.ts @@ -6,7 +6,14 @@ import { Vod, CourseBase } from '@/types'; export function mergeVodWithAttendance( course: CourseBase, vodList: { week: number; subject: string; title: string; url: string; range: string | null; length: string }[], - attendanceList: { title: string; isAttendance: string; weeklyAttendance: string; week: number; requiredTime?: string; watchedTime?: string }[] + attendanceList: { + title: string; + isAttendance: string; + weeklyAttendance: string; + week: number; + requiredTime?: string; + watchedTime?: string; + }[] ): Vod[] { const attendanceByKey = new Map(); for (const att of attendanceList) { @@ -34,8 +41,7 @@ export function mergeVodWithAttendance( */ export function mergeDueDateItems( course: CourseBase, - items: T[], + items: T[] ): (CourseBase & T)[] { return items.map((item) => ({ ...course, ...item })); } - diff --git a/src/mocks/loadMockData.ts b/src/mocks/loadMockData.ts index a9a0880..f05ac61 100644 --- a/src/mocks/loadMockData.ts +++ b/src/mocks/loadMockData.ts @@ -10,4 +10,3 @@ export async function loadMockData(): Promise { const { mockVods, mockAssigns, mockQuizes } = await import('./mockData'); return { vods: mockVods, assigns: mockAssigns, quizzes: mockQuizes }; } - diff --git a/src/popover/dashboard/Dashboard.tsx b/src/popover/dashboard/Dashboard.tsx index ff7bdd1..6ce59a5 100644 --- a/src/popover/dashboard/Dashboard.tsx +++ b/src/popover/dashboard/Dashboard.tsx @@ -487,7 +487,9 @@ export default function Dashboard() {
) : ( <> - {activeTab === 'VIDEO' && } + {activeTab === 'VIDEO' && ( + + )} {activeTab === 'ASSIGN' && } {activeTab === 'QUIZ' && } {activeTab === 'SETTING' && ( diff --git a/src/popover/dashboard/components/DashboardHeader.tsx b/src/popover/dashboard/components/DashboardHeader.tsx index 19863d1..8f1b356 100644 --- a/src/popover/dashboard/components/DashboardHeader.tsx +++ b/src/popover/dashboard/components/DashboardHeader.tsx @@ -110,7 +110,10 @@ export default function DashboardHeader({ activeTab, filter: f, actions: a }: Da {a.isCalendarConnected && ( <> - + {t('common:calendar.syncShort')} diff --git a/src/popover/dashboard/components/DueDateList.tsx b/src/popover/dashboard/components/DueDateList.tsx index 77a177e..813bb72 100644 --- a/src/popover/dashboard/components/DueDateList.tsx +++ b/src/popover/dashboard/components/DueDateList.tsx @@ -69,10 +69,7 @@ export default function DueDateList({ - onHideTask?.(course.url)} - className="text-lg gap-2 cursor-pointer" - > + onHideTask?.(course.url)} className="text-lg gap-2 cursor-pointer"> {t('hide.task')} diff --git a/src/popover/dashboard/components/VodList.tsx b/src/popover/dashboard/components/VodList.tsx index d9b807f..f01a042 100644 --- a/src/popover/dashboard/components/VodList.tsx +++ b/src/popover/dashboard/components/VodList.tsx @@ -1,7 +1,14 @@ import { useState } from 'react'; import { Card, CardContent, CardFooter, CardHeader } from '@/components/ui/card'; import { Vod } from '@/types'; -import { calculateDueDate, calculateRemainingTime, extractEndDate, formatDateString, isAbsent, isAttended } from '@/lib/utils'; +import { + calculateDueDate, + calculateRemainingTime, + extractEndDate, + formatDateString, + isAbsent, + isAttended, +} from '@/lib/utils'; import { makeVodGroupKey } from '@/lib/generateKey'; import { ChevronDown, ChevronUp, EyeOff } from 'lucide-react'; import EmptyState from './EmptyState'; @@ -47,7 +54,7 @@ export default function VodList({ courseData, onHideTask, onHideTasks }: VideoPr }; const sortedVodGroups = Object.values(groupedData).sort((groupA, groupB) => - compareByAbsentThenEndTime(groupA[0], groupB[0], 'weeklyAttendance'), + compareByAbsentThenEndTime(groupA[0], groupB[0], 'weeklyAttendance') ); return ( @@ -91,9 +98,14 @@ export default function VodList({ courseData, onHideTask, onHideTasks }: VideoPr
{vod.title}
-
+
{formatDateString(vod.range)},{' '} - + {vod.length}
diff --git a/src/popover/player/components/PlayerIframe.tsx b/src/popover/player/components/PlayerIframe.tsx index 100b23c..49e8d12 100644 --- a/src/popover/player/components/PlayerIframe.tsx +++ b/src/popover/player/components/PlayerIframe.tsx @@ -3,7 +3,9 @@ import { useTranslation } from 'react-i18next'; import { logger } from '@/lib/logger'; const formatMMSS = (seconds: number) => - `${Math.floor(seconds / 60)}:${Math.floor(seconds % 60).toString().padStart(2, '0')}`; + `${Math.floor(seconds / 60)}:${Math.floor(seconds % 60) + .toString() + .padStart(2, '0')}`; interface PlayerIframeProps { videoSrc: string; @@ -138,7 +140,7 @@ export default function PlayerIframe({ videoSrc, onNextVideo, isPlaying }: Playe node.addEventListener('load', handleLoad); } }, - [handleLoad], + [handleLoad] ); // 시간 표시 diff --git a/src/popover/player/components/PlayerPopoverContent.tsx b/src/popover/player/components/PlayerPopoverContent.tsx index ff20bb1..62e330b 100644 --- a/src/popover/player/components/PlayerPopoverContent.tsx +++ b/src/popover/player/components/PlayerPopoverContent.tsx @@ -79,7 +79,7 @@ export default function PlayerPopoverContent({ isPopoverOpen, isPlaying, setIsPl length: '00:00', isAttendance: 'X', weeklyAttendance: 'X', - })), + })) ); return; } @@ -90,7 +90,11 @@ export default function PlayerPopoverContent({ isPopoverOpen, isPlaying, setIsPl if (skipPlayerFilter) { setVods(data.filter((vod) => !hidden.has(vod.url))); } else { - setVods(data.filter((vod) => !hidden.has(vod.url) && isCurrentDateInRange(vod.range) && !isAttended(vod.isAttendance))); + setVods( + data.filter( + (vod) => !hidden.has(vod.url) && isCurrentDateInRange(vod.range) && !isAttended(vod.isAttendance) + ) + ); } }); }); @@ -230,9 +234,7 @@ export default function PlayerPopoverContent({ isPopoverOpen, isPlaying, setIsPl
{t('noVideos')}
-
- {t('refreshPrompt')} -
+
{t('refreshPrompt')}
)}
diff --git a/src/popover/player/components/SortableItem.tsx b/src/popover/player/components/SortableItem.tsx index 91e82a0..bd97f93 100644 --- a/src/popover/player/components/SortableItem.tsx +++ b/src/popover/player/components/SortableItem.tsx @@ -53,9 +53,7 @@ export default function SortableItem({ vod, idx, currentVideoIndex, setCurrentVi

{vod.title}

-

- {vod.courseTitle} -

+

{vod.courseTitle}

· {vod.length.trim()}
diff --git a/src/styles/shadow.css b/src/styles/shadow.css index 9322047..bda2641 100644 --- a/src/styles/shadow.css +++ b/src/styles/shadow.css @@ -4,7 +4,8 @@ @layer base { :host { - font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Malgun Gothic", "Apple SD Gothic Neo", "Yu Gothic", system-ui, sans-serif; + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Malgun Gothic', 'Apple SD Gothic Neo', 'Yu Gothic', + system-ui, sans-serif; line-height: 1.5; font-weight: 400;