diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 0000000..06cfd15 --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1,3 @@ +# 운영진 중 1명의 승인으로 리뷰를 완료합니다. +# CODEOWNERS는 main에 반영된 이후 새 PR부터 리뷰 요청에 사용됩니다. +* @javajigi @boorownie @byjo @jaeyeonling @yesjuhee @2Jin1031 @boyekim diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 0000000..9a446aa --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,25 @@ +## 변경 내용 + + + +## 활동 정보 (아카이브 PR) + + + +- 이름: +- 소속/모임: +- 활동 연도: +- 역할: 리드 / 멘토 / 리뷰어 +- GitHub 프로필: +- 활동 설명 또는 확인할 수 있는 공개 링크: + +### 정보 공개 동의 + +- [ ] 본인의 등록·수정 요청이며 이름·소속·연도·역할·GitHub 프로필과 이미지 공개에 동의합니다. 또는 대리 요청이며 당사자의 동의를 확인했습니다. +- [ ] 같은 계정·연도·소속·역할로 이미 등록된 항목이 없는지 확인했습니다. 기존 기록 수정·삭제라면 해당 항목을 변경했습니다. + + + +## 확인한 내용 + + diff --git a/.github/rulesets/main.json b/.github/rulesets/main.json new file mode 100644 index 0000000..83690a7 --- /dev/null +++ b/.github/rulesets/main.json @@ -0,0 +1,36 @@ +{ + "name": "main contribution rules", + "target": "branch", + "enforcement": "disabled", + "bypass_actors": [], + "conditions": { + "ref_name": { + "include": ["refs/heads/main"], + "exclude": [] + } + }, + "rules": [ + { "type": "deletion" }, + { "type": "non_fast_forward" }, + { + "type": "pull_request", + "parameters": { + "required_approving_review_count": 1, + "dismiss_stale_reviews_on_push": true, + "require_code_owner_review": true, + "require_last_push_approval": false, + "required_review_thread_resolution": true + } + }, + { + "type": "required_status_checks", + "parameters": { + "required_status_checks": [ + { "context": "Archive checks", "integration_id": 15368 } + ], + "strict_required_status_checks_policy": true, + "do_not_enforce_on_create": true + } + } + ] +} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..a62b2b3 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,39 @@ +name: PR checks + +on: + pull_request: + branches: ["main"] + push: + branches: ["main"] + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: pr-checks-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + archive-checks: + name: Archive checks + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + persist-credentials: false + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version-file: ".nvmrc" + cache: npm + - name: Install dependencies + run: npm ci + - name: Test data validation + run: npm run test:data + - name: Lint + run: npm run lint + - name: Validate data and build static site + run: npm run build diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index f911d82..1af125a 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -23,7 +23,7 @@ jobs: - name: Setup Node uses: actions/setup-node@v4 with: - node-version: "20" + node-version-file: ".nvmrc" cache: npm - name: Setup Pages uses: actions/configure-pages@v5 diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 0000000..a45fd52 --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +24 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..5f5ca80 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,95 @@ +# 초록 아카이브 기여 가이드 + +초록과 함께한 사람들의 활동 기록을 PR로 모읍니다. 초록스터디 리드·리뷰어, 초록해듀오 멘토로 활동한 본인이 등록·수정·삭제를 요청할 수 있습니다. + +## 등록 전에 확인하기 + +- [아카이브](https://cho-log.github.io/archive)와 [`src/data/leads.ts`](src/data/leads.ts)에 같은 활동 기록이 있는지 확인해주세요. 이미 등록되어 있다면 해당 항목을 수정합니다. +- 이름·소속·활동 연도·역할·GitHub 프로필과 프로필 이미지가 공개됩니다. 공개해도 되는 정보만 적어주세요. +- 다른 사람의 정보를 대신 추가·수정할 때는 당사자 동의를 받고, PR 본문에 동의를 확인한 사실을 적어주세요. 연락처나 비공개 대화 캡처를 올릴 필요는 없습니다. +- 전화번호·이메일·주소·학번 등 등록 필드에 없는 개인 정보는 올리지 않습니다. + +## 등록할 데이터 + +[`src/data/leads.ts`](src/data/leads.ts)의 `leads` 배열 마지막 `];` 앞에 아래 형식의 객체를 추가합니다. 예시의 계정·이름·소속·연도는 자신의 실제 정보로 바꿔주세요. + +```ts + { + id: "octocat-2026-lead", + name: "홍길동", + community: "그리디", + university: "세종대", + year: 2026, + githubUrl: "https://github.com/octocat", + role: "lead", + }, +``` + +| 필드 | 필수 | 작성 규칙 | +| --- | --- | --- | +| `id` | O | 전체 배열에서 유일한 ID. 새 항목은 `깃허브아이디-활동연도-역할`을 소문자로 작성하는 것을 권장합니다. 영문 소문자·숫자·하이픈을 사용합니다. | +| `name` | O | 사이트에 공개할 이름 | +| `community` | O | 활동 당시 소속/모임. 예: `그리디`, `SCG`, `초록해듀오` | +| `university` | X | 대학 소속이 있을 때만 작성합니다. 해당하지 않으면 필드 자체를 생략합니다. | +| `year` | O | 활동 연도 숫자. 초록이 시작된 2023년부터 현재 연도까지의 실제 활동 연도를 작성합니다. | +| `githubUrl` | O | 개인 GitHub 프로필 URL. `https://github.com/아이디` 형식이며 끝의 `/`, 저장소 경로, 쿼리 문자열은 제외합니다. | +| `role` | O | 스터디 리드는 `lead`, 초록해듀오 멘토는 `mentor`, 스터디 리뷰어는 `reviewer` | + +문자열 앞뒤의 공백은 제거해주세요. `githubUrl`로 카드 링크와 프로필 이미지를 만들기 때문에 개인 프로필 주소를 정확히 적는 것이 중요합니다. + +### 여러 해 또는 여러 역할로 활동했다면 + +- 같은 사람이 여러 해 활동했다면 연도마다 항목을 추가할 수 있습니다. +- 같은 연도에 다른 모임이나 역할로 활동했다면 별도 항목으로 등록할 수 있습니다. ID가 겹치면 `octocat-2026-lead-scg`처럼 소속을 구분하는 영문 접미사를 붙여주세요. +- **같은 GitHub 계정 + 활동 연도 + 소속/모임 + 역할**의 항목은 한 번만 등록합니다. 같은 활동의 오타 수정은 새 항목을 만들지 않고 기존 항목을 고칩니다. +- 기존 `l-01` 같은 ID는 유지합니다. 기존 항목을 수정할 때도 ID는 유지해주세요. +- 새 항목은 배열 끝에 추가하고 전체 배열을 다시 정렬하지 않습니다. 화면에서 최신 연도순·이름 가나다순으로 정렬합니다. + +## GitHub에서 바로 PR 보내기 + +데이터만 등록한다면 로컬 개발 환경 없이 GitHub 웹 편집기로도 기여할 수 있습니다. + +1. 저장소를 **Fork**합니다. +2. 자신의 Fork에서 새 브랜치를 만듭니다. 예: `add-octocat-2026`. +3. `src/data/leads.ts`를 열고 편집 버튼으로 자신의 항목을 추가하거나 수정한 뒤 커밋합니다. +4. **Contribute → Open pull request**를 선택합니다. +5. 대상이 `cho-log/cho-log.github.io`의 `main`, 출발 브랜치가 자신의 Fork 브랜치인지 확인합니다. +6. PR 제목은 `[아카이브] 홍길동 / 그리디 / 2026 / 리드 등록`처럼 적고, 제공되는 템플릿에 활동 정보와 공개 동의를 작성합니다. +7. 자동 검사와 운영진 리뷰를 기다립니다. 수정 요청을 받으면 같은 브랜치에 수정 사항을 커밋하면 됩니다. + +처음 기여하는 경우 운영진이 GitHub Actions 실행을 승인해야 검사가 시작될 수 있습니다. 로컬 검사를 하지 못했다면 PR에 그렇게 적어주세요. 운영진이 CI 결과와 등록 내용을 함께 확인합니다. + +## 로컬에서 확인하기 + +Node.js 24와 npm을 설치하고 자신의 Fork를 클론합니다. 프로젝트 루트에서 실행합니다. + +```bash +git switch -c add-my-archive-entry +npm ci +npm run validate:leads +npm run dev +``` + +에서 이름·소속·연도·역할과 GitHub 링크를 확인해주세요. PR 전에 아래 검사도 실행할 수 있습니다. + +```bash +npm run test:data +npm run lint +npm run build +``` + +데이터 검사는 필수 필드·ID 중복·활동 기록 중복·연도·역할·GitHub URL 형식을 확인합니다. 실제 활동 여부와 정보 공개 동의는 운영진이 리뷰합니다. + +## PR과 머지 기준 + +- 아카이브 등록 PR에는 자신의 활동 기록 변경만 담아주세요. 화면·기능·후기 변경은 별도 PR로 나누면 리뷰가 수월합니다. +- 신규 등록은 사전 이슈를 만들지 않고 바로 PR을 보내도 됩니다. 큰 기능 변경은 이슈에서 방향을 먼저 논의해주세요. +- 운영진은 활동 당시 소속·연도·역할을 확인합니다. 공개 링크로 확인하기 어렵다면 PR에 활동을 간단히 설명하면 됩니다. 추가 확인은 초록 디스코드에서 진행할 수 있습니다. +- `Archive checks`가 통과하고 운영진 1명이 승인하면 운영진이 머지합니다. 7명 전원의 승인은 필요하지 않습니다. +- 머지 후 GitHub Pages 배포가 성공하면 아카이브에 반영됩니다. 머지와 배포 완료는 서로 다른 단계입니다. + +## 수정·삭제와 다른 기여 + +자신의 공개 정보를 수정하거나 사이트에서 내리려면 기존 항목을 수정·삭제하는 PR을 보내주세요. PR 작성이 어렵다면 [초록 디스코드](https://discord.gg/6XkAFe8DwW)에서 운영진에게 요청할 수 있습니다. 사이트에서 삭제해도 공개 저장소의 이전 커밋과 PR에 기록이 남을 수 있습니다. + +후기 추가는 `src/data/testimonials.ts`를 확인하고 본인의 후기 공개 동의를 PR에 적어주세요. 버그 수정·접근성·문서 개선도 환영합니다. 화면 스타일 변경은 [`DESIGN.md`](DESIGN.md)의 토큰과 컴포넌트 가이드를 따릅니다. diff --git a/README.md b/README.md index 0a63524..e03d589 100644 --- a/README.md +++ b/README.md @@ -1 +1,53 @@ -# landing \ No newline at end of file +# 초록 🌱 + +배움에서 나눔으로 이어지는 개발자 커뮤니티, 초록의 소개와 활동 아카이브입니다. + +- [초록 홈페이지](https://cho-log.github.io/) +- [함께한 모임 & 사람들](https://cho-log.github.io/archive) +- [기여 가이드](CONTRIBUTING.md) + +## 아카이브에 나를 추가하기 + +초록스터디 리드·리뷰어, 초록해듀오 멘토로 활동했다면 PR로 자신의 기록을 등록하거나 수정할 수 있습니다. 작은 데이터 기여도 환영합니다. + +1. 먼저 [아카이브](https://cho-log.github.io/archive)에 자신의 활동 기록이 있는지 확인합니다. +2. 이 저장소를 Fork하고 [`src/data/leads.ts`](src/data/leads.ts)의 `leads` 배열에 자신의 항목을 추가합니다. +3. [기여 가이드의 예시와 규칙](CONTRIBUTING.md#등록할-데이터)을 참고해 `main`으로 PR을 보냅니다. +4. 운영진이 활동 기록과 정보 공개 동의를 확인하고 머지합니다. +5. 머지 후 GitHub Pages 배포가 성공하면 사이트에 반영됩니다. + +`leads.ts`는 JSON 파일이 아닌 TypeScript 데이터 파일입니다. 프로필 이미지는 GitHub 계정에서 가져오므로 별도로 업로드하지 않아도 됩니다. + +## 로컬 실행 + +Node.js 24와 npm을 사용합니다. nvm을 사용한다면 `nvm use`로 프로젝트 버전을 선택할 수 있습니다. + +```bash +npm ci +npm run dev +``` + +브라우저에서 를 열어 변경한 기록을 확인합니다. + +```bash +npm run validate:leads +npm run test:data +npm run lint +npm run build +``` + +`npm run build`는 데이터 검증과 이미지 placeholder 생성을 거쳐 정적 사이트를 `out/`에 만듭니다. 정적 결과는 `npm run start` 대신 정적 파일 서버로 확인합니다. + +## 저장소 안내 + +| 경로 | 내용 | +| --- | --- | +| `src/data/leads.ts` | 리드·멘토·리뷰어 활동 기록 | +| `src/data/testimonials.ts` | 활동 후기 | +| `src/data/programs.ts` | 프로그램 타임라인 | +| `src/types/index.ts` | 데이터 필드와 역할 타입 | +| `DESIGN.md` | 디자인 토큰과 컴포넌트 가이드 | +| `.github/workflows/ci.yml` | PR 데이터 검증·테스트·린트·빌드 | +| `.github/workflows/deploy.yml` | `main` 변경 시 GitHub Pages 배포 | + +화면이나 기능 개선도 PR로 제안할 수 있습니다. 스타일을 바꿀 때는 [디자인 시스템](DESIGN.md)을 먼저 확인해주세요. 운영진의 리뷰 및 GitHub 설정 절차는 [운영 가이드](docs/MAINTAINERS.md)에 정리되어 있습니다. diff --git a/docs/MAINTAINERS.md b/docs/MAINTAINERS.md new file mode 100644 index 0000000..57a5929 --- /dev/null +++ b/docs/MAINTAINERS.md @@ -0,0 +1,55 @@ +# 아카이브 운영 가이드 + +## 등록 PR 리뷰 + +1. 본인 요청인지, 대리 요청이라면 당사자 동의를 확인했는지 확인합니다. 삭제 요청은 본인 요청 여부를 확인합니다. +2. 초록스터디 리드·리뷰어 또는 초록해듀오 멘토 활동 당시 소속·연도·역할을 확인합니다. +3. GitHub 링크가 해당 사람의 개인 프로필인지 확인합니다. 자동 검사는 URL 형식만 확인하며 계정의 존재와 소유 여부를 확인하지 않습니다. +4. 같은 계정·연도·소속·역할의 중복 등록인지 확인합니다. 다른 연도·모임·역할의 기록은 허용합니다. +5. 데이터 등록 PR에 불필요한 코드·설정 변경이나 다른 사람의 기록 변경이 섞여 있지 않은지 확인합니다. +6. `Archive checks` 통과와 리뷰 대화 해결을 확인하고 운영진 1명이 승인합니다. 작성자가 운영진이어도 다른 운영진이 승인합니다. +7. 운영진이 머지하고 기존 `Deploy Next.js site to Pages` 워크플로의 배포 결과를 확인합니다. + +`.github/CODEOWNERS`에 운영진 7명을 지정했습니다. 리뷰 담당 계정은 `@javajigi`, `@boorownie`, `@byjo`, `@jaeyeonling`, `@yesjuhee`, `@2Jin1031`, `@boyekim`입니다. 인원이 바뀌면 CODEOWNERS를 수정하고 해당 계정이 저장소의 write 이상 권한을 갖는지 확인합니다. 승인 1명은 7명 중 어느 한 명이면 됩니다. + +처음 기여하는 외부 Fork의 PR은 GitHub Actions 실행 승인 대기 상태가 될 수 있습니다. 변경 내용을 먼저 확인하고 **Approve and run**으로 검사를 시작합니다. 이는 PR 승인과 별도입니다. CI는 `pull_request` 이벤트와 `contents: read` 권한으로 실행하며 배포 권한은 기존 배포 워크플로에만 둡니다. + +## main ruleset 최초 설정 + +권장 설정은 [`.github/rulesets/main.json`](../.github/rulesets/main.json)에 준비했습니다. 이 파일은 **초안이며, 파일을 커밋하는 것만으로 GitHub 규칙이 적용되지는 않습니다**. `enforcement`는 `disabled`로 저장되어 있습니다. + +| 설정 | 값 | +| --- | --- | +| 대상 브랜치 | `main` | +| PR을 통한 변경 | 필수 | +| 승인 | 운영진 CODEOWNER 1명 | +| 변경 커밋 추가 시 기존 승인 | 취소 후 재승인 | +| 미해결 리뷰 대화 | 해결 필수 | +| 필수 검사 | GitHub Actions의 `Archive checks` | +| 최신 `main` 기준 검사 | 필수 | +| 브랜치 삭제·강제 push | 금지 | +| 규칙 우회 | 지정하지 않음 | + +처음 활성화할 때는 다음 순서를 따릅니다. + +1. 기여 가이드·CODEOWNERS·CI를 포함한 PR에서 `Archive checks`가 통과하는지 확인합니다. +2. 운영진 리뷰를 거쳐 이 PR을 `main`에 머지하고, `main`의 CI와 Pages 배포 성공을 확인합니다. +3. 저장소 **Settings → Rules → Rulesets → New ruleset → Import a ruleset**에서 `main.json`을 가져옵니다. +4. 대상이 `main`인지, 필수 검사가 `Archive checks`이고 제공 앱이 GitHub Actions인지, CODEOWNER 승인 1명인지 확인합니다. +5. Enforcement status를 **Active**로 바꾸고 저장합니다. +6. 다음 등록 PR에서 운영진 리뷰 요청·검사·승인 조건이 표시되는지 확인합니다. + +CI가 등록되기 전에 필수 검사 규칙을 켜면 검사 대기 상태로 머지가 막힐 수 있으므로 위 순서를 따릅니다. CODEOWNERS와 PR 템플릿은 기본 브랜치에 반영된 뒤 사용됩니다. 운영진의 PR도 같은 규칙을 적용합니다. + +## 공개 기여와 라이선스 + +이번 구성은 아카이브에 PR로 기여할 수 있는 절차를 엽니다. 저장소에는 아직 라이선스가 없습니다. 코드 재사용까지 허용하는 오픈소스로 배포하려면 코드의 라이선스를 별도로 결정하고 `LICENSE`를 추가합니다. 사진·로고·개인 프로필·후기의 이용 범위도 코드와 함께 일괄 지정하기 전에 확인합니다. + +## 관련 GitHub 문서 + +- [기여 가이드 설정](https://docs.github.com/en/communities/setting-up-your-project-for-healthy-contributions/setting-guidelines-for-repository-contributors) +- [공개 저장소의 라이선스](https://docs.github.com/en/repositories/managing-your-repositorys-settings-and-features/customizing-your-repository/licensing-a-repository) +- [CODEOWNERS와 리뷰 요청](https://docs.github.com/en/repositories/managing-your-repositorys-settings-and-features/customizing-your-repository/about-code-owners) +- [ruleset 생성·가져오기](https://docs.github.com/en/repositories/configuring-branches-and-merges-in-your-repository/managing-rulesets/creating-rulesets-for-a-repository) +- [ruleset의 필수 PR·승인·검사 규칙](https://docs.github.com/en/repositories/configuring-branches-and-merges-in-your-repository/managing-rulesets/available-rules-for-rulesets) +- [Fork PR의 워크플로 실행](https://docs.github.com/en/actions/reference/workflows-and-actions/events-that-trigger-workflows#pull_request) diff --git a/package.json b/package.json index 565f617..a83f791 100644 --- a/package.json +++ b/package.json @@ -4,10 +4,12 @@ "private": true, "scripts": { "dev": "next dev", - "prebuild": "node scripts/optimize-images.mjs", + "prebuild": "npm run validate:leads && node scripts/optimize-images.mjs", "build": "next build", "start": "next start", "lint": "eslint", + "validate:leads": "node scripts/validate-leads.mjs", + "test:data": "node --test scripts/validate-leads.test.mjs", "optimize:images": "node scripts/optimize-images.mjs" }, "dependencies": { diff --git a/scripts/validate-leads.mjs b/scripts/validate-leads.mjs new file mode 100644 index 0000000..cac315f --- /dev/null +++ b/scripts/validate-leads.mjs @@ -0,0 +1,121 @@ +import { readFile } from "node:fs/promises"; +import { resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import ts from "typescript"; + +const DATA_FILE = new URL("../src/data/leads.ts", import.meta.url); +const FIELDS = new Set([ + "id", "name", "community", "university", "year", "githubUrl", "role", +]); +const ROLES = new Set(["lead", "mentor", "reviewer"]); +const PROFILE_URL = /^https:\/\/github\.com\/([a-z\d]+(?:-[a-z\d]+)*)$/i; + +// 데이터 파일을 실행하지 않고 명시적으로 작성한 배열·객체·문자열·숫자만 읽는다. +export function parseLeads(source) { + const file = ts.createSourceFile("leads.ts", source, ts.ScriptTarget.Latest, true); + if (file.parseDiagnostics.length) { + throw new Error(ts.flattenDiagnosticMessageText(file.parseDiagnostics[0].messageText, "\n")); + } + + const declarations = file.statements + .filter((statement) => ts.isVariableStatement(statement) + && statement.modifiers?.some((modifier) => modifier.kind === ts.SyntaxKind.ExportKeyword)) + .flatMap((statement) => statement.declarationList.declarations) + .filter((declaration) => ts.isIdentifier(declaration.name) && declaration.name.text === "leads"); + const array = declarations[0]?.initializer; + if (declarations.length !== 1 || !array || !ts.isArrayLiteralExpression(array)) { + throw new Error("export const leads: Lead[] = [...] 형식으로 작성해주세요."); + } + + return array.elements.map((element, index) => { + if (!ts.isObjectLiteralExpression(element)) { + throw new Error(`${index + 1}번째 항목: 객체를 직접 작성해주세요. 스프레드나 함수는 사용하지 않습니다.`); + } + const entry = Object.create(null); + for (const property of element.properties) { + if (!ts.isPropertyAssignment(property) + || !(ts.isIdentifier(property.name) || ts.isStringLiteral(property.name))) { + throw new Error(`${index + 1}번째 항목: 필드를 '이름: 값' 형식으로 작성해주세요.`); + } + const key = property.name.text; + if (Object.hasOwn(entry, key)) { + throw new Error(`${index + 1}번째 항목: ${key} 필드가 중복되었습니다.`); + } + const value = property.initializer; + if (ts.isStringLiteral(value)) entry[key] = value.text; + else if (ts.isNumericLiteral(value)) entry[key] = Number(value.text); + else throw new Error(`${index + 1}번째 항목: ${key} 값은 문자열 또는 숫자로 직접 작성해주세요.`); + } + return entry; + }); +} + +export function validateLeads(entries, currentYear = Number(new Intl.DateTimeFormat("en", { + timeZone: "Asia/Seoul", year: "numeric", +}).format(new Date()))) { + const errors = []; + const ids = new Map(); + const activities = new Map(); + + if (!Array.isArray(entries) || entries.length === 0) { + return ["leads에는 하나 이상의 활동 기록이 있어야 합니다."]; + } + + entries.forEach((entry, index) => { + const label = `${index + 1}번째 항목`; + if (!entry || typeof entry !== "object" || Array.isArray(entry)) { + errors.push(`${label}: 객체 형식이어야 합니다.`); + return; + } + for (const key of Object.keys(entry)) { + if (!FIELDS.has(key)) errors.push(`${label}: 지원하지 않는 필드 '${key}'입니다.`); + } + for (const key of ["id", "name", "community", "githubUrl", "role", ...(Object.hasOwn(entry, "university") ? ["university"] : [])]) { + const value = entry[key]; + if (typeof value !== "string" || !value.trim() || value !== value.trim() || /[\r\n\t]/.test(value)) { + errors.push(`${label}: ${key}는 앞뒤 공백이나 줄바꿈이 없는 빈 문자열이 아닌 값이어야 합니다.`); + } + } + if (typeof entry.id === "string") { + if (!/^[a-z\d]+(?:-[a-z\d]+)*$/.test(entry.id)) { + errors.push(`${label}: id에는 영문 소문자·숫자·하이픈만 사용해주세요.`); + } + if (ids.has(entry.id)) errors.push(`${label}: id '${entry.id}'가 ${ids.get(entry.id)}번째 항목과 중복되었습니다.`); + else ids.set(entry.id, index + 1); + } + if (!Number.isInteger(entry.year) || entry.year < 2023 || entry.year > currentYear) { + errors.push(`${label}: year는 2023~${currentYear} 사이의 활동 연도 숫자여야 합니다.`); + } + if (!ROLES.has(entry.role)) errors.push(`${label}: role은 lead, mentor, reviewer 중 하나여야 합니다.`); + + const profile = typeof entry.githubUrl === "string" ? entry.githubUrl.match(PROFILE_URL) : null; + if (!profile || profile[1].length > 39) { + errors.push(`${label}: githubUrl은 https://github.com/아이디 형식의 개인 프로필 주소여야 합니다. 끝의 '/'나 저장소 경로를 제거해주세요.`); + } + if (profile && typeof entry.community === "string" && ROLES.has(entry.role) && Number.isInteger(entry.year)) { + const key = JSON.stringify([ + profile[1].toLowerCase(), entry.year, + entry.community.normalize("NFC").trim().toLowerCase(), entry.role, + ]); + if (activities.has(key)) errors.push(`${label}: 같은 계정·연도·소속·역할이 ${activities.get(key)}번째 항목에 이미 등록되어 있습니다.`); + else activities.set(key, index + 1); + } + }); + return errors; +} + +if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + try { + const entries = parseLeads(await readFile(DATA_FILE, "utf8")); + const errors = validateLeads(entries); + if (errors.length) { + console.error(`아카이브 데이터 검증 실패:\n${errors.map((error) => `- ${error}`).join("\n")}`); + process.exitCode = 1; + } else { + console.log(`아카이브 데이터 ${entries.length}개 검증 완료`); + } + } catch (error) { + console.error(`아카이브 데이터 검증 실패: ${error.message}`); + process.exitCode = 1; + } +} diff --git a/scripts/validate-leads.test.mjs b/scripts/validate-leads.test.mjs new file mode 100644 index 0000000..a7aad54 --- /dev/null +++ b/scripts/validate-leads.test.mjs @@ -0,0 +1,84 @@ +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import { test } from "node:test"; +import { parseLeads, validateLeads } from "./validate-leads.mjs"; + +const entry = { + id: "octocat-2026-lead", + name: "홍길동", + community: "그리디", + year: 2026, + githubUrl: "https://github.com/octocat", + role: "lead", +}; +const check = (entries) => validateLeads(entries, 2026); + +test("기존 아카이브 데이터를 직접 읽어 검증한다", async () => { + const source = await readFile(new URL("../src/data/leads.ts", import.meta.url), "utf8"); + assert.deepEqual(validateLeads(parseLeads(source)), []); +}); + +test("대학 생략 및 기존 순번 ID를 허용한다", () => { + assert.deepEqual(check([entry, { + ...entry, id: "l-65", githubUrl: "https://github.com/another-user", university: "세종대", + }]), []); +}); + +test("여러 연도·모임·역할의 활동을 별도로 등록할 수 있다", () => { + assert.deepEqual(check([ + entry, + { ...entry, id: "octocat-2025-lead", year: 2025 }, + { ...entry, id: "octocat-2026-reviewer", role: "reviewer" }, + { ...entry, id: "octocat-2026-lead-scg", community: "SCG" }, + { ...entry, id: "octocat-2026-mentor", role: "mentor", community: "초록해듀오" }, + ]), []); +}); + +test("같은 ID와 대소문자가 다른 동일 계정의 활동 중복을 거부한다", () => { + assert.ok(check([entry, { ...entry, year: 2025 }]).some((error) => error.includes("id '"))); + assert.ok(check([entry, { ...entry, id: "l-65", githubUrl: "https://github.com/Octocat" }]) + .some((error) => error.includes("같은 계정·연도·소속·역할"))); +}); + +test("빈 데이터·누락/빈 필드·공백·불필요한 개인정보 필드를 거부한다", () => { + assert.ok(check([]).length); + for (const key of ["id", "name", "community", "githubUrl", "role"]) { + const missing = { ...entry }; + delete missing[key]; + assert.ok(check([missing]).some((error) => error.includes(key))); + assert.ok(check([{ ...entry, [key]: "" }]).some((error) => error.includes(key))); + } + for (const change of [{ name: " 홍길동" }, { community: "그리디\n" }, { university: "" }, { email: "private@example.com" }]) { + assert.ok(check([{ ...entry, ...change }]).length); + } +}); + +test("미래·소수·문자열 연도와 잘못된 역할·ID를 거부한다", () => { + for (const year of [2022, 2027, 2026.5, "2026", undefined]) { + assert.ok(check([{ ...entry, year }]).some((error) => error.includes("year"))); + } + assert.ok(check([{ ...entry, role: "member" }]).some((error) => error.includes("role"))); + assert.ok(check([{ ...entry, id: "Octocat 2026" }]).some((error) => error.includes("id"))); +}); + +test("프로필 이미지 경로를 깨뜨리는 URL과 외부 URL을 거부한다", () => { + for (const githubUrl of [ + "http://github.com/octocat", "https://github.com/octocat/", + "https://github.com/octocat/repo", "https://github.com/octocat?tab=repositories", + "https://github.com/octocat#profile", "https://github.com.evil.example/octocat", + "https://example.com/octocat", "javascript:alert(1)", + ]) { + assert.ok(check([{ ...entry, githubUrl }]).some((error) => error.includes("githubUrl")), githubUrl); + } +}); + +test("형식 오류·스프레드·함수 호출·중복 필드를 데이터로 실행하지 않는다", () => { + for (const source of [ + "export const leads = [", "const leads = [];", + "export const leads = getLeads();", "export const leads = [...other];", + "export const leads = [{ ...other }];", "export const leads = [{ name: getName() }];", + "export const leads = [{ name: 'A', name: 'B' }];", + ]) { + assert.throws(() => parseLeads(source)); + } +}); diff --git a/src/data/leads.ts b/src/data/leads.ts index 193a89a..c7b707f 100644 --- a/src/data/leads.ts +++ b/src/data/leads.ts @@ -3,6 +3,8 @@ import type { Lead } from "@/src/types"; /** * 역대 스터디 리드 + 초록해듀오 멘토 + 스터디 리뷰어. * community = 소속/모임, university = 대학(해듀오·연합은 없음), role = lead | mentor | reviewer. + * 등록·수정 방법은 CONTRIBUTING.md 참고. 새 항목은 배열 끝에 추가하고 기존 ID는 유지한다. + * 신규 ID 권장: 깃허브아이디-활동연도-역할 (예: octocat-2026-lead). */ export const leads: Lead[] = [ { diff --git a/src/types/index.ts b/src/types/index.ts index 0e6bc36..b99b421 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -17,7 +17,7 @@ export interface Lead { name: string; community: string; // 소속/모임 (예: "그리디", "SCG", "초록해듀오") university?: string; // 대학 (해듀오·연합은 없음) - year: number; // 활동 연도 (2024 | 2025 | 2026) + year: number; // 활동 연도 (2023 ~ 현재 연도) githubUrl: string; role: "lead" | "mentor" | "reviewer"; // 초록해듀오 = mentor, 리뷰어 = reviewer, 나머지 = lead }