├── .github └── workflows │ ├── ci.yml │ └── nightly.yml ├── .gitignore ├── .prettierrc ├── .vscode ├── extensions.json └── launch.json ├── README.md ├── astro.config.mjs ├── package.json ├── patches └── @astrojs__web-vitals@0.0.0-vitals-fix-20240503214211.patch ├── pnpm-lock.yaml ├── public ├── _astro │ └── noise.webp ├── favicon.ico ├── favicon.svg ├── ogimg.jpg ├── v1 │ └── built-with-astro.svg └── v2 │ ├── built-with-astro │ ├── large.svg │ ├── medium.svg │ ├── previews.svg │ ├── small.svg │ └── tiny.svg │ ├── built-with-starlight │ ├── large.svg │ ├── medium.svg │ ├── small.svg │ └── tiny.svg │ └── contributor-placeholder.svg ├── scripts └── collect-stats.ts ├── src ├── achievements.config.ts ├── components │ ├── BadgePicker.astro │ ├── CTA.astro │ ├── Copy.astro │ ├── Footer.astro │ ├── Frame.astro │ ├── Preloads.astro │ └── achievements │ │ └── Stat.astro ├── data │ └── contributors.json ├── env.d.ts ├── fonts │ ├── ibm-plex-mono-latin-400-normal.ttf │ ├── inter-tight-latin-500-normal.ttf │ └── inter-tight-latin-800-normal.ttf ├── layouts │ └── Layout.astro ├── pages │ ├── 404.astro │ ├── _og.jpg │ ├── achievements │ │ ├── [groupID] │ │ │ └── [class].astro │ │ ├── _og.jpg │ │ └── index.astro │ ├── api │ │ └── v1 │ │ │ └── top-contributors.json.ts │ ├── badges │ │ └── index.astro │ ├── contributor │ │ └── [username].astro │ ├── contributors │ │ ├── _og.jpg │ │ └── index.astro │ ├── index.astro │ ├── v1 │ │ ├── built-with-astro │ │ │ └── [size].svg.ts │ │ └── contributor │ │ │ └── [username].svg.ts │ └── v2 │ │ └── contributor │ │ ├── [username].png.ts │ │ └── [username].svg.ts ├── styles │ └── fonts.css ├── types.ts └── util │ ├── achievementClasses.ts │ ├── achievementsHelpers.ts │ ├── getAchievements.ts │ ├── getContributors.ts │ ├── getGlobalStats.ts │ ├── getStats.ts │ ├── objSum.ts │ └── resizedGitHubAvatarURL.ts ├── tailwind.config.mjs ├── tsconfig.json └── vercel.json /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: [latest] 6 | pull_request: 7 | branches: [latest] 8 | 9 | # Automatically cancel in-progress actions on the same branch 10 | concurrency: 11 | group: ${{ github.workflow }}-${{ github.event_name == 'pull_request_target' && github.head_ref || github.ref }} 12 | cancel-in-progress: true 13 | 14 | defaults: 15 | run: 16 | shell: bash 17 | 18 | env: 19 | NODE_VERSION: 18 20 | 21 | jobs: 22 | check: 23 | name: Check for type issues with astro check 24 | runs-on: ubuntu-latest 25 | steps: 26 | - uses: actions/checkout@v3 27 | - uses: pnpm/action-setup@v2 28 | - uses: actions/setup-node@v3 29 | with: 30 | node-version: ${{ env.NODE_VERSION }} 31 | cache: pnpm 32 | - run: pnpm i 33 | - run: pnpm check 34 | 35 | tsc: 36 | name: Check for type issues with tsc 37 | runs-on: ubuntu-latest 38 | steps: 39 | - uses: actions/checkout@v3 40 | - uses: pnpm/action-setup@v2 41 | - uses: actions/setup-node@v3 42 | with: 43 | node-version: ${{ env.NODE_VERSION }} 44 | cache: pnpm 45 | - run: pnpm i 46 | - run: pnpm tsc 47 | -------------------------------------------------------------------------------- /.github/workflows/nightly.yml: -------------------------------------------------------------------------------- 1 | name: Nightly Build 2 | 3 | on: 4 | schedule: 5 | - cron: '0 0 * * *' 6 | workflow_dispatch: 7 | 8 | jobs: 9 | build: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - name: Check out repository 13 | uses: actions/checkout@v3 14 | 15 | - name: Setup PNPM 16 | uses: pnpm/action-setup@v2 17 | 18 | - name: Setup Node 19 | uses: actions/setup-node@v3 20 | with: 21 | node-version: 20 22 | cache: pnpm 23 | 24 | - name: Install dependencies 25 | run: pnpm i 26 | 27 | - name: Update data 28 | run: pnpm collect-stats 29 | env: 30 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 31 | 32 | - uses: stefanzweifel/git-auto-commit-action@v4 33 | with: 34 | commit_message: 'ci: Collect stats' 35 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # build output 2 | dist/ 3 | .vercel/ 4 | 5 | # Astro generated files 6 | .astro/ 7 | 8 | # dependencies 9 | node_modules/ 10 | 11 | # logs 12 | npm-debug.log* 13 | yarn-debug.log* 14 | yarn-error.log* 15 | pnpm-debug.log* 16 | 17 | 18 | # environment variables 19 | .env 20 | .env.production 21 | 22 | # macOS-specific files 23 | .DS_Store 24 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "tabWidth": 2, 3 | "useTabs": false, 4 | "printWidth": 100, 5 | "singleQuote": true 6 | } 7 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": ["astro-build.astro-vscode"], 3 | "unwantedRecommendations": [] 4 | } 5 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.2.0", 3 | "configurations": [ 4 | { 5 | "command": "./node_modules/.bin/astro dev", 6 | "name": "Development server", 7 | "request": "launch", 8 | "type": "node-terminal" 9 | } 10 | ] 11 | } 12 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Astro Badges 2 | 3 | [![Built with Astro](./public/v2/built-with-astro/small.svg)](https://astro.build) 4 | 5 | This project aims to celebrate the contributions of the [Astro](https://astro.build/) community. 6 | 7 | Get badges to show off on your sites and READMEs! 8 | 👉 **** 👈 9 | 10 | ## 🧞 Commands 11 | 12 | All commands are run from the root of the project, from a terminal: 13 | 14 | | Command | Action | 15 | | :------------- | :------------------------------------------- | 16 | | `pnpm i` | Installs dependencies | 17 | | `pnpm start` | Starts local dev server at `localhost:4321` | 18 | | `pnpm build` | Build your production site to `./dist/` | 19 | | `pnpm preview` | Preview your build locally, before deploying | 20 | 21 | ### Data collection 22 | 23 | This project uses the GitHub REST API to gather public data about contributions to [the `withastro` org](https://github.com/withastro/). Data collection is run once per day in a GitHub action and automatically committed to this repository. 24 | 25 | You can run `pnpm collect-stats` to run data collection locally (for example to test changes to the script), but should first ensure a `GITHUB_TOKEN` [environment variable](https://docs.astro.build/en/guides/environment-variables/) is set up, containing a GitHub personal access token. Create a `.env` file in the repo root and add your token there for it to be detected automatically for local runs: 26 | 27 | ```dotenv 28 | GITHUB_TOKEN=github_pat_...... 29 | ``` 30 | -------------------------------------------------------------------------------- /astro.config.mjs: -------------------------------------------------------------------------------- 1 | import tailwind from '@astrojs/tailwind'; 2 | import vercel from '@astrojs/vercel/serverless'; 3 | import { defineConfig } from 'astro/config'; 4 | 5 | // https://astro.build/config 6 | export default defineConfig({ 7 | site: process.env.VERCEL_ENV === 'production' ? 'https://astro.badg.es/' : process.env.VERCEL_URL ? `https://${process.env.VERCEL_URL}/` : 'http://localhost:4321/', 8 | output: 'hybrid', 9 | adapter: vercel(), 10 | integrations: [ 11 | tailwind({ applyBaseStyles: false }), 12 | ], 13 | vite: { 14 | ssr: { external: ['@resvg/resvg-js'] }, 15 | optimizeDeps: { exclude: ['@resvg/resvg-js'] }, 16 | build: { rollupOptions: { external: ["@resvg/resvg-js"] } }, 17 | // Expose Vercel’s analytics ID to client-side scripts. 18 | define: { 19 | 'import.meta.env.PUBLIC_VERCEL_ANALYTICS_ID': 20 | JSON.stringify(process.env.VERCEL_ANALYTICS_ID), 21 | }, 22 | } 23 | }); -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@example/minimal", 3 | "version": "0.0.1", 4 | "private": true, 5 | "scripts": { 6 | "collect-stats": "tsm -r dotenv/config ./scripts/collect-stats.ts", 7 | "check": "astro check", 8 | "dev": "astro dev", 9 | "start": "astro dev", 10 | "build": "astro build", 11 | "preview": "astro preview" 12 | }, 13 | "devDependencies": { 14 | "@astrojs/check": "^0.7.0", 15 | "@astrojs/site-kit": "github:withastro/site-kit", 16 | "@astrojs/tailwind": "^5.1.0", 17 | "@astrojs/vercel": "^7.6.0", 18 | "@fontsource-variable/inter": "^5.0.18", 19 | "@octokit/core": "^5.0.1", 20 | "@octokit/plugin-paginate-graphql": "^5.2.4", 21 | "@octokit/plugin-paginate-rest": "^11.3.5", 22 | "@octokit/plugin-retry": "^7.1.2", 23 | "@octokit/types": "^12.0.0", 24 | "@resvg/resvg-js": "^2.6.2", 25 | "@types/node": "^20.12.12", 26 | "@vercel/analytics": "^1.2.2", 27 | "astro": "^4.16.18", 28 | "dotenv": "^16.4.5", 29 | "minimatch": "^9.0.4", 30 | "sharp": "^0.33.4", 31 | "tailwindcss": "^3.4.3", 32 | "tsm": "^2.3.0", 33 | "typescript": "^5.4.5" 34 | }, 35 | "packageManager": "pnpm@8.9.2" 36 | } 37 | -------------------------------------------------------------------------------- /patches/@astrojs__web-vitals@0.0.0-vitals-fix-20240503214211.patch: -------------------------------------------------------------------------------- 1 | diff --git a/dist/index.js b/dist/index.js 2 | index 760acbe4435ecb701a04da02860413112853eac8..8114d66d04b039faeffa34f4790f10f3f825d8a5 100644 3 | --- a/dist/index.js 4 | +++ b/dist/index.js 5 | @@ -24,7 +24,7 @@ function webVitals() { 6 | addMiddleware({ entrypoint: "@astrojs/web-vitals/middleware", order: "post" }); 7 | injectRoute({ 8 | entrypoint: "@astrojs/web-vitals/endpoint", 9 | - pattern: WEB_VITALS_ENDPOINT_PATH, 10 | + pattern: WEB_VITALS_ENDPOINT_PATH + '/[...any]', 11 | prerender: false 12 | }); 13 | injectScript("page", `import '@astrojs/web-vitals/client-script';`); 14 | -------------------------------------------------------------------------------- /public/_astro/noise.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/delucis/astro-badge/ca90cfc8b87f9380de186eb9aafef9ecef3cacb0/public/_astro/noise.webp -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/delucis/astro-badge/ca90cfc8b87f9380de186eb9aafef9ecef3cacb0/public/favicon.ico -------------------------------------------------------------------------------- /public/favicon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 16 | 17 | -------------------------------------------------------------------------------- /public/ogimg.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/delucis/astro-badge/ca90cfc8b87f9380de186eb9aafef9ecef3cacb0/public/ogimg.jpg -------------------------------------------------------------------------------- /public/v1/built-with-astro.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | -------------------------------------------------------------------------------- /public/v2/built-with-astro/large.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /public/v2/built-with-astro/medium.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /public/v2/built-with-astro/previews.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | -------------------------------------------------------------------------------- /public/v2/built-with-astro/small.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /public/v2/built-with-astro/tiny.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /public/v2/built-with-starlight/large.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | -------------------------------------------------------------------------------- /public/v2/built-with-starlight/medium.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | -------------------------------------------------------------------------------- /public/v2/built-with-starlight/small.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | -------------------------------------------------------------------------------- /public/v2/built-with-starlight/tiny.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | -------------------------------------------------------------------------------- /public/v2/contributor-placeholder.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | -------------------------------------------------------------------------------- /scripts/collect-stats.ts: -------------------------------------------------------------------------------- 1 | import { Octokit } from '@octokit/core'; 2 | import { paginateGraphQL, type PageInfoForward } from '@octokit/plugin-paginate-graphql'; 3 | import { paginateRest } from '@octokit/plugin-paginate-rest'; 4 | import { retry } from '@octokit/plugin-retry'; 5 | import type { Endpoints } from '@octokit/types'; 6 | import { minimatch } from 'minimatch'; 7 | import { writeFile } from 'node:fs/promises'; 8 | import type { Contributor } from '../src/types'; 9 | 10 | type APIData = Endpoints[T]['response']['data']; 11 | type Repo = APIData<'GET /orgs/{org}/repos'>[number]; 12 | type CustomCategories = { 13 | [key: string]: { 14 | [key: string]: string[]; 15 | }; 16 | }; 17 | interface Review { 18 | login: string | undefined; 19 | avatarUrl: string | undefined; 20 | prNumber: number; 21 | labels: string[]; 22 | } 23 | interface AugmentedRepo extends Repo { 24 | reviewComments: APIData<'GET /repos/{owner}/{repo}/pulls/comments'>; 25 | issues: APIData<'GET /repos/{owner}/{repo}/issues'>; 26 | reviews: Review[]; 27 | } 28 | 29 | const OctokitWithPlugins = Octokit.plugin(paginateRest, paginateGraphQL, retry); 30 | 31 | class StatsCollector { 32 | #org: string; 33 | #app: InstanceType; 34 | #customCategories: CustomCategories; 35 | 36 | constructor(opts: { 37 | org: string; 38 | token: string | undefined; 39 | customCategories: CustomCategories; 40 | }) { 41 | this.#org = opts.org; 42 | this.#app = new OctokitWithPlugins({ auth: opts.token }); 43 | this.#customCategories = opts.customCategories; 44 | } 45 | 46 | async run() { 47 | const repos = await this.#getReposWithExtraStats(); 48 | 49 | const contributors: Record = {}; 50 | 51 | console.log('Processing data...'); 52 | for (const repo of repos) { 53 | for (const issue of repo.issues) { 54 | const { user, pull_request, labels } = issue; 55 | if (!user) { 56 | console.warn(`No user found for ${repo.full_name}#${issue.number}`); 57 | continue; 58 | } 59 | const { avatar_url, login } = user; 60 | const contributor = (contributors[login] = 61 | contributors[login] || this.#newContributor({ avatar_url })); 62 | if (pull_request) { 63 | contributor.pulls[repo.name] = (contributor.pulls[repo.name] || 0) + 1; 64 | if (pull_request.merged_at) { 65 | contributor.merged_pulls[repo.name] = (contributor.merged_pulls[repo.name] || 0) + 1; 66 | if (labels.length) { 67 | if (!contributor.merged_pulls_by_label[repo.name]) { 68 | contributor.merged_pulls_by_label[repo.name] = {}; 69 | } 70 | for (const labelOrObject of labels) { 71 | const label = 72 | typeof labelOrObject === 'string' ? labelOrObject : labelOrObject.name; 73 | if (!label) continue; 74 | contributor.merged_pulls_by_label[repo.name]![label] = 75 | (contributor.merged_pulls_by_label[repo.name]![label] || 0) + 1; 76 | } 77 | } 78 | } 79 | } else { 80 | contributor.issues[repo.name] = (contributor.issues[repo.name] || 0) + 1; 81 | } 82 | } 83 | 84 | /** Temporary store for deduplicating multiple reviews on the same PR. */ 85 | const reviewedPRs: Record> = {}; 86 | 87 | const customCategories = this.#customCategories; 88 | 89 | for (const review of repo.reviewComments) { 90 | const { user, pull_request_url, path } = review; 91 | const prNumber = parseInt(pull_request_url.split('/').pop()!); 92 | if (!user) { 93 | console.warn(`No user found for PR review: ${review.url}`); 94 | continue; 95 | } 96 | const { avatar_url, login } = user; 97 | const contributor = (contributors[login] = 98 | contributors[login] || this.#newContributor({ avatar_url })); 99 | const contributorReviews = (reviewedPRs[login] = reviewedPRs[login] || new Set()); 100 | if (!contributorReviews.has(prNumber)) { 101 | contributor.reviews[repo.name] = (contributor.reviews[repo.name] || 0) + 1; 102 | 103 | if (!contributor.reviews_by_category[repo.name]) { 104 | contributor.reviews_by_category[repo.name] = {}; 105 | } 106 | 107 | for (const categoryName in customCategories) { 108 | for (const repoName in customCategories[categoryName]) { 109 | if (repoName !== repo.name) continue; 110 | for (const glob of customCategories[categoryName]![repoName]!) { 111 | if (minimatch(path, glob)) { 112 | contributor.reviews_by_category[repo.name]![categoryName] = 113 | (contributor.reviews_by_category[repo.name]![categoryName] || 0) + 1; 114 | } 115 | } 116 | } 117 | } 118 | 119 | const associatedIssue = repo.issues.find((issue) => issue.number === prNumber); 120 | if (!associatedIssue) { 121 | console.warn(`No associated PR ${repo.full_name}#${prNumber} found`); 122 | continue; 123 | } 124 | if (associatedIssue.labels.length) { 125 | if (!contributor.reviews_by_label[repo.name]) { 126 | contributor.reviews_by_label[repo.name] = {}; 127 | } 128 | for (const labelOrObject of associatedIssue.labels) { 129 | const label = typeof labelOrObject === 'string' ? labelOrObject : labelOrObject.name; 130 | if (!label) continue; 131 | contributor.reviews_by_label[repo.name]![label] = 132 | (contributor.reviews_by_label[repo.name]![label] || 0) + 1; 133 | } 134 | contributorReviews.add(prNumber); 135 | } 136 | } 137 | } 138 | 139 | for (const review of repo.reviews) { 140 | const { login, avatarUrl, prNumber, labels } = review; 141 | if (!login || !avatarUrl) { 142 | console.warn(`No user found for PR review on ${repo.full_name}#${prNumber}`); 143 | continue; 144 | } 145 | const contributor = (contributors[login] = 146 | contributors[login] || this.#newContributor({ avatar_url: avatarUrl })); 147 | const contributorReviews = (reviewedPRs[login] = reviewedPRs[login] || new Set()); 148 | if (!contributorReviews.has(prNumber)) { 149 | contributor.reviews[repo.name] = (contributor.reviews[repo.name] || 0) + 1; 150 | if (!contributor.reviews_by_label[repo.name]) { 151 | contributor.reviews_by_label[repo.name] = {}; 152 | } 153 | for (const label of labels) { 154 | contributor.reviews_by_label[repo.name]![label] = 155 | (contributor.reviews_by_label[repo.name]![label] || 0) + 1; 156 | } 157 | contributorReviews.add(prNumber); 158 | } 159 | } 160 | } 161 | console.log('Done processing data!'); 162 | 163 | console.log('Writing to disk...'); 164 | await this.#writeData(contributors); 165 | console.log('Mission complete!'); 166 | } 167 | 168 | #newContributor({ avatar_url }: { avatar_url: string }): Contributor { 169 | return { 170 | avatar_url, 171 | issues: {}, 172 | pulls: {}, 173 | merged_pulls: {}, 174 | merged_pulls_by_label: {}, 175 | reviews: {}, 176 | reviews_by_category: {}, 177 | reviews_by_label: {}, 178 | }; 179 | } 180 | 181 | async #getRepos() { 182 | return ( 183 | await this.#app.request(`GET /orgs/{org}/repos`, { 184 | org: this.#org, 185 | type: 'sources', 186 | }) 187 | ).data.filter((repo) => !repo.private); 188 | } 189 | 190 | async #getAllIssuesAndPRs(repo: string) { 191 | console.log(`Fetching issues and PRs for ${this.#org}/${repo}...`); 192 | const issues = await this.#app.paginate('GET /repos/{owner}/{repo}/issues', { 193 | owner: this.#org, 194 | repo, 195 | per_page: 100, 196 | state: 'all', 197 | }); 198 | console.log(`Done fetching ${issues.length} issues and PRs for ${this.#org}/${repo}`); 199 | return issues; 200 | } 201 | 202 | async #getAllReviewComments(repo: string) { 203 | console.log(`Fetching PR review comments for ${this.#org}/${repo}...`); 204 | const reviews = await this.#app.paginate('GET /repos/{owner}/{repo}/pulls/comments', { 205 | owner: this.#org, 206 | repo, 207 | per_page: 100, 208 | }); 209 | console.log(`Done fetching ${reviews.length} PR review comments for ${this.#org}/${repo}`); 210 | return reviews; 211 | } 212 | 213 | async #getAllReviews(repo: string) { 214 | console.log(`Fetching PR reviews for ${this.#org}/${repo}...`); 215 | const { 216 | repository: { 217 | pullRequests: { nodes: pullRequests }, 218 | }, 219 | } = await this.#app.graphql.paginate<{ 220 | repository: { 221 | pullRequests: { 222 | pageInfo: PageInfoForward; 223 | nodes: Array<{ 224 | number: number; 225 | labels: { nodes: Array<{ name: string }> }; 226 | latestReviews: { 227 | nodes: Array<{ author: null | { login: string; avatarUrl: string } }>; 228 | }; 229 | }>; 230 | }; 231 | }; 232 | }>( 233 | ` 234 | query ($org: String!, $repo: String!, $cursor: String) { 235 | repository(owner: $org, name: $repo) { 236 | pullRequests(first: 100, after: $cursor) { 237 | nodes { 238 | number 239 | labels(first: 10) { 240 | nodes { 241 | name 242 | } 243 | } 244 | latestReviews(first: 15) { 245 | nodes { 246 | author { 247 | login 248 | avatarUrl 249 | } 250 | } 251 | } 252 | } 253 | pageInfo { 254 | hasNextPage 255 | endCursor 256 | } 257 | } 258 | } 259 | } 260 | `, 261 | { org: this.#org, repo }, 262 | ); 263 | const reviews: Review[] = []; 264 | for (const { number, labels, latestReviews } of pullRequests) { 265 | for (const { author } of latestReviews.nodes) { 266 | reviews.push({ 267 | prNumber: number, 268 | labels: labels.nodes.map(({ name }) => name), 269 | login: author?.login, 270 | avatarUrl: author?.avatarUrl, 271 | }); 272 | } 273 | } 274 | console.log(`Done fetching ${reviews.length} PR reviews for ${this.#org}/${repo}`); 275 | return reviews; 276 | } 277 | 278 | async #getReposWithExtraStats() { 279 | console.log('Fetching repos...'); 280 | const repos = await this.#getRepos(); 281 | console.log(`Done fetching ${repos.length} repos!`); 282 | const reposWithStats: AugmentedRepo[] = []; 283 | for (const repo of repos) { 284 | reposWithStats.push({ 285 | ...repo, 286 | issues: await this.#getAllIssuesAndPRs(repo.name), 287 | reviewComments: await this.#getAllReviewComments(repo.name), 288 | reviews: await this.#getAllReviews(repo.name), 289 | }); 290 | } 291 | return reposWithStats; 292 | } 293 | 294 | async #writeData(data: any) { 295 | return await writeFile('src/data/contributors.json', JSON.stringify(data), 'utf8'); 296 | } 297 | } 298 | 299 | const collector = new StatsCollector({ 300 | org: 'withastro', 301 | token: process.env.GITHUB_TOKEN, 302 | customCategories: { 303 | i18n: { 304 | docs: [ 305 | // Astro Docs content translations 306 | 'src/content/docs/!(en)/**/*', 307 | // Astro Docs labels translations 308 | 'src/i18n/!(en)/**/*', 309 | // Astro Docs translations before migrating to Content Collections 310 | 'src/pages/+(ar|de|es|fr|ja|pl|pt-br|ru|zh-cn|zh-tw)/**/*', 311 | ], 312 | starlight: [ 313 | // Starlight Docs content translations 314 | 'docs/src/content/docs/!(en)/**/*', 315 | // Starlight package labels translations 316 | 'packages/starlight/translations/!(en.json)', 317 | ], 318 | }, 319 | }, 320 | }); 321 | await collector.run(); 322 | -------------------------------------------------------------------------------- /src/achievements.config.ts: -------------------------------------------------------------------------------- 1 | import { AchievementSpec } from './util/achievementsHelpers'; 2 | 3 | export default AchievementSpec({ 4 | 'repos-with-merges': { 5 | getCount: ({ merged_pulls }) => Object.keys(merged_pulls).length, 6 | achievements: [ 7 | { count: 2, title: 'Gemini', details: 'PRs in 2 Astro repos' }, 8 | { count: 3, title: 'Astronomer', details: 'PRs in 3 Astro repos' }, 9 | { 10 | count: 6, 11 | title: 'Constellation Crafter', 12 | details: 'PRs in 6 Astro repos', 13 | }, 14 | ], 15 | }, 16 | 'i18n-reviews': { 17 | getCount: ({ reviews_by_category, reviews_by_label }) => 18 | Math.max(reviews_by_category?.docs?.i18n || 0, reviews_by_label?.docs?.i18n || 0) + 19 | Math.max(reviews_by_category?.starlight?.i18n || 0, reviews_by_label?.starlight?.i18n || 0), 20 | achievements: [ 21 | { count: 1, title: 'Proofreader', details: 'Reviewed an i18n PR' }, 22 | { count: 15, title: 'Polyglot', details: 'Reviewed 15 i18n PRs' }, 23 | { count: 40, title: 'Rosetta Stone', details: 'Reviewed 40 i18n PRs' }, 24 | ], 25 | }, 26 | 'i18n-merges': { 27 | stat: 'merged_pulls_by_label', 28 | label: 'i18n', 29 | achievements: [ 30 | { count: 1, title: 'Decoder', details: 'First i18n PR' }, 31 | { count: 15, title: 'Babel Fish', details: '15 i18n PRs' }, 32 | { count: 40, title: 'Universal Translator', details: '40 i18n PRs' }, 33 | ], 34 | }, 35 | 'total-reviews': { 36 | stat: 'reviews', 37 | achievements: [ 38 | { count: 1, title: 'Spot Check', details: 'Reviewed a PR' }, 39 | { count: 10, title: 'Copilot', details: 'Reviewed 10 PRs' }, 40 | { count: 30, title: 'PR Perfectionist', details: 'Reviewed 30 PRs' }, 41 | ], 42 | }, 43 | 'astro-merges': { 44 | repo: 'astro', 45 | stat: 'merges', 46 | achievements: [ 47 | { count: 1, title: 'Space Cadet', details: 'First astro PR' }, 48 | { count: 10, title: 'Technician', details: '10 astro PRs' }, 49 | { count: 30, title: 'Rocket Scientist', details: '30 astro PRs' }, 50 | ], 51 | }, 52 | 'docs-merges': { 53 | repo: 'docs', 54 | stat: 'merges', 55 | achievements: [ 56 | { count: 1, title: 'Docs Padawan', details: 'First docs PR' }, 57 | { count: 10, title: 'Scholar', details: '10 docs PRs' }, 58 | { count: 30, title: 'Galactic Librarian', details: '30 docs PRs' }, 59 | ], 60 | }, 61 | 'rfc-merges': { 62 | repo: 'roadmap', 63 | stat: 'merges', 64 | achievements: [ 65 | { count: 1, title: 'Visionary', details: 'First roadmap PR' }, 66 | { count: 5, title: 'Mission Control', details: '5 roadmap PRs' }, 67 | { count: 15, title: 'Feature Creature', details: '15 roadmap PRs' }, 68 | ], 69 | }, 70 | 'prettier-merges': { 71 | repo: 'prettier-plugin-astro', 72 | stat: 'merges', 73 | achievements: [ 74 | { count: 1, title: 'Aesthete', details: 'First prettier-plugin PR' }, 75 | { count: 10, title: 'Format Focused', details: '10 prettier-plugin PRs' }, 76 | { 77 | count: 30, 78 | title: 'Prettiest of Them All', 79 | details: '30 prettier-plugin PRs', 80 | }, 81 | ], 82 | }, 83 | 'compiler-merges': { 84 | repo: 'compiler', 85 | stat: 'merges', 86 | achievements: [ 87 | { count: 1, title: 'Mechanic', details: 'First compiler PR' }, 88 | { count: 10, title: 'Go Pro', details: '10 compiler PRs' }, 89 | { count: 30, title: 'Spline Reticulator', details: '30 compiler PRs' }, 90 | ], 91 | }, 92 | 'language-tools-merges': { 93 | repo: 'language-tools', 94 | stat: 'merges', 95 | achievements: [ 96 | { count: 1, title: 'Subeditor', details: 'First language-tools PR' }, 97 | { count: 10, title: 'Linguist', details: '10 language-tools PRs' }, 98 | { count: 30, title: 'Token Wrangler', details: '30 language-tools PRs' }, 99 | ], 100 | }, 101 | 'astro.new-merges': { 102 | repo: 'astro.new', 103 | stat: 'merges', 104 | achievements: [ 105 | { count: 1, title: 'Launch Pad', details: 'First astro.new PR' }, 106 | { count: 5, title: 'Browser IDEator', details: '5 astro.new PRs' }, 107 | { count: 15, title: 'New Code, Who Dis?', details: '15 astro.new PRs' }, 108 | ], 109 | }, 110 | 'action-merges': { 111 | repo: 'action', 112 | stat: 'merges', 113 | achievements: [ 114 | { count: 1, title: 'Deploy Buddy', details: 'First withastro/action PR' }, 115 | { count: 5, title: 'Action Packed', details: '5 withastro/action PRs' }, 116 | { count: 15, title: 'Action Hero', details: '15 withastro/action PRs' }, 117 | ], 118 | }, 119 | 'houston-ai-merges': { 120 | repo: 'houston.astro.build', 121 | stat: 'merges', 122 | achievements: [ 123 | { 124 | count: 1, 125 | title: 'HAL-lo World', 126 | details: 'First houston.astro.build PR', 127 | }, 128 | { count: 5, title: 'Droid Dev', details: '5 houston.astro.build PRs' }, 129 | { 130 | count: 15, 131 | title: 'Modern Prometheus', 132 | details: '15 houston.astro.build PRs', 133 | }, 134 | ], 135 | }, 136 | 'starlight-merges': { 137 | repo: 'starlight', 138 | stat: 'merges', 139 | achievements: [ 140 | { count: 1, title: 'Twinkle, twinkle', details: 'First starlight PR' }, 141 | { count: 10, title: 'Stargazer', details: '10 starlight PRs' }, 142 | { count: 30, title: 'Superstar', details: '30 starlight PRs' }, 143 | ], 144 | }, 145 | 'adapters-merges': { 146 | repo: 'adapters', 147 | stat: 'merges', 148 | achievements: [ 149 | { count: 1, title: 'Ahead of the Serve', details: 'First adapters PR' }, 150 | { count: 10, title: 'SSRsly', details: '10 adapters PRs' }, 151 | { count: 30, title: 'Adapter in Chief', details: '30 adapters PRs' }, 152 | ], 153 | }, 154 | 'cli-kit-merges': { 155 | repo: 'cli-kit', 156 | stat: 'merges', 157 | achievements: [ 158 | { count: 1, title: 'Click, clack', details: 'First cli-kit PR' }, 159 | { count: 5, title: 'Niftty', details: '5 cli-kit PRs' }, 160 | { count: 15, title: 'Shellebrity', details: '15 cli-kit PRs' }, 161 | ], 162 | }, 163 | 'houston-bot-merges': { 164 | repo: 'houston-discord', 165 | stat: 'merges', 166 | achievements: [ 167 | { count: 1, title: 'Assistant', details: 'First houston-discord PR' }, 168 | { count: 5, title: 'Botanist', details: '5 houston-discord PRs' }, 169 | { count: 15, title: 'Chatterbox', details: '15 houston-discord PRs' }, 170 | ], 171 | }, 172 | 'hacktoberfest-merges': { 173 | stat: 'merged_pulls_by_label', 174 | label: 'hacktoberfest-accepted', 175 | achievements: [ 176 | { count: 1, title: 'Hacker', details: '1 Hacktoberfest contribution' }, 177 | { count: 5, title: 'Commit or Treat', details: '5 Hacktoberfest contributions' }, 178 | { count: 15, title: 'Hack-o-Lantern', details: '15 Hacktoberfest contributions' }, 179 | ], 180 | }, 181 | 'total-issues': { 182 | stat: 'issues', 183 | achievements: [ 184 | { count: 1, title: 'Little Green Bug', details: 'Opened an issue' }, 185 | { count: 10, title: 'Pest Control', details: 'Opened 10 issues' }, 186 | { count: 30, title: 'Entomologist', details: 'Opened 30 issues' }, 187 | ], 188 | }, 189 | }); 190 | -------------------------------------------------------------------------------- /src/components/BadgePicker.astro: -------------------------------------------------------------------------------- 1 | --- 2 | import { Code } from "astro/components"; 3 | import Copy from "./Copy.astro"; 4 | 5 | export interface Badge { 6 | label: 'Tiny' | 'Small' | 'Medium' | 'Large'; 7 | shortLabel: 'XS' | 'S' | 'M' | 'L'; 8 | slug: 'tiny' | 'small' | 'medium' | 'large'; 9 | width: number; 10 | height: number; 11 | } 12 | 13 | interface Props { 14 | aspectRatio: number; 15 | alt: string; 16 | link: string; 17 | getImagePath: (badge: Badge) => string; 18 | } 19 | 20 | const { aspectRatio, alt, link, getImagePath } = Astro.props as Props; 21 | 22 | const heights = { tiny: 20, small: 32, medium: 40, large: 48 } as const; 23 | const heightsKeys = Object.keys(heights) as Array; 24 | 25 | const badgeSizes: Badge[] = heightsKeys.map((slug) => ({ 26 | slug, 27 | label: (slug[0]!.toUpperCase() + slug.slice(1)) as Badge['label'], 28 | shortLabel: (slug === 'tiny' ? 'XS' : slug[0]!.toUpperCase()) as Badge['shortLabel'], 29 | width: heights[slug] * aspectRatio, 30 | height: heights[slug], 31 | })); 32 | 33 | const mdSnippet = (badge: Badge) => 34 | `[![${alt}](${Astro.site}${getImagePath(badge)})](${link})`; 35 | const htmlSnippet = (badge: Badge) => `` 36 | +`${alt}` 37 | +``; 38 | --- 39 | 40 | 41 | 42 | 52 | {badgeSizes.map((badge, idx) => ( 53 | 54 | 83 | ))} 84 | 85 | 86 | -------------------------------------------------------------------------------- /src/components/CTA.astro: -------------------------------------------------------------------------------- 1 | --- 2 | export interface Props { 3 | href: string; 4 | } 5 | const { href } = Astro.props; 6 | --- 7 | 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /src/components/Copy.astro: -------------------------------------------------------------------------------- 1 | --- 2 | export interface Props { 3 | text: string; 4 | label?: string; 5 | } 6 | 7 | const { text, label = 'Copy' } = Astro.props as Props; 8 | --- 9 | 10 | 11 | 12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /src/components/Footer.astro: -------------------------------------------------------------------------------- 1 | 16 | -------------------------------------------------------------------------------- /src/components/Frame.astro: -------------------------------------------------------------------------------- 1 |
2 | 3 |
4 | -------------------------------------------------------------------------------- /src/components/Preloads.astro: -------------------------------------------------------------------------------- 1 | --- 2 | import Inter from '@fontsource-variable/inter/files/inter-latin-wght-normal.woff2'; 3 | --- 4 | 5 | 6 | 13 | 20 | 21 | -------------------------------------------------------------------------------- /src/components/achievements/Stat.astro: -------------------------------------------------------------------------------- 1 | --- 2 | import type { EnhancedContributor } from '../../util/getContributors'; 3 | type HeadingLevels = "1" | "2" | "3" | "4" | "5" | "6"; 4 | 5 | export interface Props { 6 | contributor: EnhancedContributor; 7 | count: string; 8 | type: "pulls" | "issues" | "reviews"; 9 | hLevel: HeadingLevels; 10 | } 11 | 12 | const { contributor, count, hLevel, type } = Astro.props; 13 | const H: `h${HeadingLevels}` = `h${hLevel}`; 14 | const { issues, reviews, merged_pulls } = contributor; 15 | const allCounts = [issues, reviews, merged_pulls].flatMap(type => Object.values(type)); 16 | const maxCount = Math.max(...allCounts); 17 | const stat = contributor[type === "pulls" ? "merged_pulls" : type]; 18 | const repoCounts = Object.entries(stat).sort(([, a], [, b]) => b - a); 19 | 20 | const subPath = type === 'issues' ? 'issues' : 'pulls' 21 | const isType = `is:${type === 'issues' ? 'issue' : 'pr'}` 22 | const userFilter = `${type === 'reviews' ? 'reviewed-by' : 'author'}:${contributor.username}` 23 | const query = `${subPath}?q=${isType}+${userFilter}` 24 | --- 25 | 26 |
  • 27 |
    28 | {type[0]!.toUpperCase() + type.slice(1)} 29 |

    {count}

    30 |
    31 |
      32 | { 33 | repoCounts.map(([repo, repoCount]) => { 34 | const percentage = (repoCount / maxCount) * 100; 35 | return ( 36 |
    • 37 | 41 | {repo} 42 | 43 | 46 | 50 | {repoCount} {type} 51 | 52 | 53 |
    • 54 | ); 55 | }) 56 | } 57 |
    58 |
  • 59 | -------------------------------------------------------------------------------- /src/env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | /// 4 | -------------------------------------------------------------------------------- /src/fonts/ibm-plex-mono-latin-400-normal.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/delucis/astro-badge/ca90cfc8b87f9380de186eb9aafef9ecef3cacb0/src/fonts/ibm-plex-mono-latin-400-normal.ttf -------------------------------------------------------------------------------- /src/fonts/inter-tight-latin-500-normal.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/delucis/astro-badge/ca90cfc8b87f9380de186eb9aafef9ecef3cacb0/src/fonts/inter-tight-latin-500-normal.ttf -------------------------------------------------------------------------------- /src/fonts/inter-tight-latin-800-normal.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/delucis/astro-badge/ca90cfc8b87f9380de186eb9aafef9ecef3cacb0/src/fonts/inter-tight-latin-800-normal.ttf -------------------------------------------------------------------------------- /src/layouts/Layout.astro: -------------------------------------------------------------------------------- 1 | --- 2 | import '@astrojs/site-kit/tailwind.css'; 3 | import '@fontsource-variable/inter'; 4 | import '../styles/fonts.css'; 5 | import Footer from '../components/Footer.astro'; 6 | import Preloads from '../components/Preloads.astro'; 7 | export interface Props { 8 | title?: string; 9 | description?: string; 10 | ogImg?: { 11 | src: string; 12 | alt: string; 13 | }; 14 | wide?: boolean; 15 | } 16 | const { 17 | title = 'Astro Badges', 18 | description = 'Badges to show off your Astro pride', 19 | ogImg = { 20 | src: '/ogimg.jpg', 21 | alt: 'The text “Astro Badges” over a soft green-gray gradient.', 22 | }, 23 | } = Astro.props; 24 | const ogImgURL = new URL(ogImg.src, Astro.site).href; 25 | const canonical = new URL(Astro.url.pathname, Astro.site).href; 26 | 27 | const navItems = [ 28 | { text: 'Contributors', href: '/contributors/' }, 29 | { text: 'Badges', href: '/badges/' }, 30 | ]; 31 | --- 32 | 33 | 34 | 35 | 36 | 37 | {title} 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 73 | 74 | 83 | 84 | 85 | 86 |
    89 |
    90 |
    93 | 94 | Astro 95 | 101 | 104 | Badges 105 | 106 | 107 | 120 |
    121 |
    122 |
    123 | 124 |
    125 |
    126 |
    127 |
    128 |
    129 | 140 | 141 | 142 | -------------------------------------------------------------------------------- /src/pages/404.astro: -------------------------------------------------------------------------------- 1 | --- 2 | import CTA from '../components/CTA.astro'; 3 | import Layout from '../layouts/Layout.astro'; 4 | --- 5 | 6 | 7 |
    8 |
    9 |

    Page Not Found

    10 |

    Sorry, we couldn’t find the page you were looking for!

    11 |
    12 | Go back home → 13 |
    14 |
    15 | -------------------------------------------------------------------------------- /src/pages/_og.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/delucis/astro-badge/ca90cfc8b87f9380de186eb9aafef9ecef3cacb0/src/pages/_og.jpg -------------------------------------------------------------------------------- /src/pages/achievements/[groupID]/[class].astro: -------------------------------------------------------------------------------- 1 | --- 2 | import Layout from '../../../layouts/Layout.astro'; 3 | import { globalAchievements } from '../../../util/getGlobalStats'; 4 | import { 5 | achievementClassEmoji, 6 | achievementClassGradient, 7 | achievementClassGradientText, 8 | achievementClassSlug, 9 | } from '../../../util/achievementClasses'; 10 | import { resizedGitHubAvatarURL } from '../../../util/resizedGitHubAvatarURL'; 11 | import src from '../_og.jpg?url'; 12 | 13 | export function getStaticPaths() { 14 | return globalAchievements.map((achievement) => ({ 15 | params: { 16 | groupID: achievement.groupID, 17 | class: achievementClassSlug(achievement.class), 18 | }, 19 | props: achievement, 20 | })); 21 | } 22 | 23 | type Props = (typeof globalAchievements)[number]; 24 | 25 | const { title, details, contributors, class: cls, repo } = Astro.props; 26 | --- 27 | 28 | 36 |
    37 | ← Back to Achievements 38 |
    39 |

    40 | {' '} 45 | 46 | {title} 47 | 48 |

    49 |
    50 |

    {details}

    51 | { 52 | repo && ( 53 |

    54 | 58 | 65 | 69 | 70 | See repo on GitHub 71 | 72 |

    73 | ) 74 | } 75 |
    76 |
    77 | 78 |
    79 | 80 |
    81 |

    82 | Contributors with this achievement{' '} 83 | 86 | {contributors.length} 87 | 88 |

    89 | 90 | 113 |
    114 |
    115 |
    116 | -------------------------------------------------------------------------------- /src/pages/achievements/_og.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/delucis/astro-badge/ca90cfc8b87f9380de186eb9aafef9ecef3cacb0/src/pages/achievements/_og.jpg -------------------------------------------------------------------------------- /src/pages/achievements/index.astro: -------------------------------------------------------------------------------- 1 | --- 2 | import Layout from '../../layouts/Layout.astro'; 3 | import { globalAchievements } from '../../util/getGlobalStats'; 4 | import { 5 | achievementClassLabel, 6 | achievementClassSlug, 7 | achievementClassGradient, 8 | achievementClassGradientText, 9 | } from '../../util/achievementClasses'; 10 | import src from './_og.jpg?url'; 11 | 12 | /** Achievements sorted alphabetically by their title. */ 13 | const achievements = globalAchievements.sort((a, b) => (a.title > b.title ? 1 : -1)); 14 | --- 15 | 16 | 24 |
    25 | ← Back to Contributors 26 |
    27 |

    All Achievements

    28 |
    29 |

    Discover all the achievements Astro contributors have collected so far.

    30 |
    31 |
    32 |
    33 | 34 | { 35 | [2, 1, 0].map((cls) => ( 36 |
    37 |

    38 | 41 | 42 | {achievementClassLabel(cls)} 43 | 44 |

    45 | 46 |
      47 | {achievements 48 | .filter((a) => a.class === cls) 49 | .map((achievement) => ( 50 |
    • 58 | 64 | {achievement.title} 65 | 66 | 67 | {achievement.numAchieved} 68 | 69 |
    • 70 | ))} 71 |
    72 |
    73 | )) 74 | } 75 |
    76 |
    77 | -------------------------------------------------------------------------------- /src/pages/api/v1/top-contributors.json.ts: -------------------------------------------------------------------------------- 1 | import type { APIRoute } from 'astro'; 2 | import { contributors } from '../../../util/getContributors'; 3 | 4 | /** 5 | * Generate a JSON file with a list of the top 50 contributors. 6 | * 7 | * Returns a response like: 8 | * ```json 9 | * { 10 | * data: [ 11 | * { 12 | * "username": "matthewp", 13 | * "avatar_url":"https://avatars.githubusercontent.com/u/361671?v=4" 14 | * }, 15 | * { 16 | * "username": "natemoo-re", 17 | * "avatar_url":"https://avatars.githubusercontent.com/u/7118177?v=4" 18 | * } 19 | * ] 20 | * } 21 | * ``` 22 | */ 23 | export const GET: APIRoute = () => 24 | new Response( 25 | JSON.stringify({ 26 | data: contributors.slice(0, 50).map(({ username, avatar_url }) => ({ username, avatar_url })), 27 | }), 28 | { headers: { 'Content-Type': 'application/json' } } 29 | ); 30 | -------------------------------------------------------------------------------- /src/pages/badges/index.astro: -------------------------------------------------------------------------------- 1 | --- 2 | import Layout from '../../layouts/Layout.astro'; 3 | import src from '../_og.jpg?url'; 4 | import BadgePicker, { type Badge } from '../../components/BadgePicker.astro'; 5 | import Frame from '../../components/Frame.astro'; 6 | 7 | const title = 'Badges'; 8 | const description = 9 | 'SVG badges to add to your sites and GitHub READMEs and show off your Astro or Starlight pride!'; 10 | --- 11 | 12 | 17 |
    18 |
    19 |

    Built with…

    20 |

    Add a badge to your repository or website and show off your Astro or Starlight pride!

    21 |
    22 | 23 |
    24 | 25 |
    26 |

    Astro

    27 | 28 | `v2/built-with-astro/${slug}.svg`} 33 | /> 34 | 35 |
    36 | 37 |
    38 |

    Starlight

    39 | 40 | `v2/built-with-starlight/${slug}.svg`} 45 | /> 46 | 47 |
    48 |
    49 |
    50 | -------------------------------------------------------------------------------- /src/pages/contributor/[username].astro: -------------------------------------------------------------------------------- 1 | --- 2 | import { Code } from "astro/components"; 3 | import Copy from "../../components/Copy.astro"; 4 | import { contributors } from "../../util/getContributors"; 5 | import Layout from '../../layouts/Layout.astro'; 6 | import Stat from "../../components/achievements/Stat.astro"; 7 | import { achievementClassFill, achievementClassGradient, achievementClassGradientText, achievementClassSlug, achievementClassStroke } from '../../util/achievementClasses' 8 | 9 | export function getStaticPaths() { 10 | return contributors.map((contributor) => ({ 11 | params: { username: contributor.username }, 12 | props: { contributor }, 13 | })); 14 | } 15 | 16 | export interface Props { 17 | contributor: typeof contributors[number]; 18 | } 19 | 20 | const { username } = Astro.params; 21 | const { contributor } = Astro.props; 22 | const { achievements, stats } = contributor; 23 | 24 | const mdSnippet = `[![@${username} Astro contributions](${Astro.site}v2/contributor/${username}.svg)](${Astro.url.href})`; 25 | const htmlSnippet = `\n @${username} Astro contributions\n`; 26 | --- 27 | 28 | 36 |
    37 | ← Back to Contributors 38 | 39 | 48 | 49 |
    50 | 51 |
    52 |

    Shareable Badge

    53 | 54 |
    55 | 56 |
    57 | {`@${username} 58 |

    59 | Add this badge to your GitHub profile README. 60 |

    61 |
    62 | 63 | 64 |
    65 |
    66 |

    Markdown

    67 | 68 |
    69 | 70 |
    71 |

    HTML

    72 | 73 |
    74 | 75 |
    76 |
    77 |
    78 | 79 |
    80 |

    @{username}’s Achievements

    81 | 82 |
      83 | {achievements.map((group) => { 84 | const { title, details, numAchieved } = group.achievements[0]!; 85 | const percentage = numAchieved / contributors.length * 100; 86 | return ( 87 |
    1. 88 |
      89 |
      90 |

      91 | 92 | {' '} 93 | 94 | {title} 95 | 96 | 97 |

      98 |

      {details}

      99 |
      100 |

      101 | {percentage < 1 ? '<1' : Math.round(percentage)}% of contributors have this 102 |

      103 |
      104 | {group.achievements.length > 1 && ( 105 |
      106 |

      107 | Previously 108 |

      109 |
        110 | {group.achievements.map((previous, i) => i > 0 && ( 111 |
      1. 112 |
        113 | 114 | 115 |
        116 | {previous.title} 117 |
        118 |

        {previous.details}

        119 |
        120 |
        121 |
      2. 122 | ))} 123 |
      124 |
      125 | )} 126 | 127 | {group.next && ( 128 |
      129 |
      130 |

      Next

      131 | 132 | 133 | {Math.round(group.next.progress * 100)}% 134 | 135 | 140 | 141 |

      143 | {(group.next.achievement.numAchieved > 0) ? ( 144 | 148 | {group.next.achievement.title} 149 | 150 | ) : ( 151 | 152 | Locked 153 | 154 | 155 | 156 | ??? 157 | 158 | )} 159 |

      160 |
      161 | {Math.round(group.next.progress * group.next.achievement.count)}/{group.next.achievement.count} 162 |
      163 |
      164 |
      165 |
      166 | )} 167 |
    2. 168 | ); 169 | })} 170 |
    171 |
    172 | 173 |
    174 |

    Stats

    175 |
      176 | {stats.map((stat) => )} 177 |
    178 |
    179 |
    180 |
    181 | -------------------------------------------------------------------------------- /src/pages/contributors/_og.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/delucis/astro-badge/ca90cfc8b87f9380de186eb9aafef9ecef3cacb0/src/pages/contributors/_og.jpg -------------------------------------------------------------------------------- /src/pages/contributors/index.astro: -------------------------------------------------------------------------------- 1 | --- 2 | import CTA from '../../components/CTA.astro'; 3 | import Frame from '../../components/Frame.astro'; 4 | import Layout from '../../layouts/Layout.astro'; 5 | import { contributors } from '../../util/getContributors'; 6 | import src from './_og.jpg?url'; 7 | 8 | const sortedUsernames = contributors.map((c) => c.username); 9 | 10 | const title = 'Astro Contributors'; 11 | const description = 'Find an Astro contributor and check out their awesome work!'; 12 | const dataID = 'contributor-usernames'; 13 | --- 14 | 15 | 23 |
    24 |
    25 |

    {title}

    26 |
    27 |

    Show off your own or celebrate an Astro contributor’s achievements!

    28 |

    29 | Enter a GitHub username to show that user’s contributions to repositories in the withastro org. 33 |

    34 |
    35 |
    36 | 37 | 38 |
    39 | 42 |
    43 | 49 | 53 | 54 | 62 |
    63 |
    64 | 65 |
    68 |
    69 |
    70 | 71 |
    72 | 78 |
    79 |
    80 | 81 |
    82 |

    Astro has {Intl.NumberFormat().format(contributors.length)} contributors! 🧑‍🚀💜

    83 |

    Data is updated once per day at midnight GMT.

    84 |
    85 | 86 | 87 |
    88 | Browse achievements → 89 |
    90 |
    91 | 92 | 93 | {sortedUsernames.map((username) => 95 |
    96 | 97 | 164 | -------------------------------------------------------------------------------- /src/pages/index.astro: -------------------------------------------------------------------------------- 1 | --- 2 | import Layout from "../layouts/Layout.astro"; 3 | import src from './_og.jpg?url'; 4 | 5 | const ctas = [ 6 | { 7 | title: "Contributors", 8 | description: "Celebrate your GitHub contributions 🥳", 9 | href: "/contributors/", 10 | img: { 11 | src: "/v2/contributor/FredKSchott.svg", 12 | alt: "@FredKSchott Astro contributions", 13 | width: "320", 14 | height: "192", 15 | }, 16 | }, 17 | { 18 | title: "Badges", 19 | description: "Add a badge to your repository or website", 20 | href: "/badges/", 21 | img: { 22 | src: "/v2/built-with-astro/previews.svg", 23 | alt: "Built with Astro", 24 | width: "395", 25 | height: "213", 26 | }, 27 | }, 28 | ]; 29 | --- 30 | 31 | 35 |
    36 |
    37 |

    38 | 39 | 40 | 41 | Astro 42 | 47 | Badges 48 | 49 |

    50 | 51 |
      52 | { 53 | ctas.map(({ href, title, description, img }) => ( 54 |
    • 55 |
      56 |

      57 | 58 | {title} 59 | 60 |

      61 |

      {description}

      62 |
      63 |
      64 | 65 |
      66 |
    • 67 | )) 68 | } 69 |
    70 |
    71 | 72 | -------------------------------------------------------------------------------- /src/pages/v1/built-with-astro/[size].svg.ts: -------------------------------------------------------------------------------- 1 | import type { InferStaticAPIRoute } from "../../../types"; 2 | 3 | const sizes = { 4 | tiny: 109, 5 | small: 150, 6 | medium: 200, 7 | large: 300, 8 | }; 9 | const sizeKeys = Object.keys(sizes) as Array; 10 | 11 | const aspectRatio = 1200 / 220; 12 | 13 | export const badgeSizes = sizeKeys.map((slug) => ({ 14 | slug, 15 | label: slug[0]!.toUpperCase() + slug.slice(1), 16 | width: sizes[slug], 17 | height: Math.round(sizes[slug] / aspectRatio), 18 | })); 19 | 20 | const gridStroke = { tiny: 8, small: 7, medium: 6, large: 4 }; 21 | const textStroke = { tiny: 3, small: 2, medium: 1, large: 0 }; 22 | 23 | export function getStaticPaths() { 24 | return sizeKeys.map((size) => ({ params: { size } })); 25 | } 26 | 27 | export const GET: InferStaticAPIRoute = ({ params }) => { 28 | const body = ` 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | `; 69 | return new Response(body, { headers: { 'Content-Type': 'image/svg+xml' } }); 70 | } 71 | -------------------------------------------------------------------------------- /src/pages/v1/contributor/[username].svg.ts: -------------------------------------------------------------------------------- 1 | import type { InferStaticAPIRoute } from '../../../types'; 2 | import { contributors, type EnhancedContributor } from "../../../util/getContributors"; 3 | 4 | export function getStaticPaths() { 5 | return contributors.map(({ username }) => ({ params: { username } })); 6 | } 7 | 8 | const icons = { 9 | commits: 10 | '', 11 | issues: 12 | '', 13 | pulls: 14 | '', 15 | reviews: 16 | '' 17 | }; 18 | 19 | const SidebarBG = ``; 20 | 21 | const Stat = ({ count, type }: { count: string; type: keyof typeof icons }, i: number) => ` 22 | ${icons[type]} 23 | 24 | ${count}` 25 | 26 | const Achievement = ({ achievements }: EnhancedContributor['achievements'][number], i: number) => 27 | `${achievements[0].title} ${achievements[0].details}` 28 | 29 | const AstroLogo = ` 30 | 31 | 32 | `; 33 | 34 | export const GET: InferStaticAPIRoute = async ({ params }) => { 35 | const { username } = params; 36 | const { achievements, stats, getBase64Avatar } = contributors.find((c) => c.username === username)!; 37 | const b64 = await getBase64Avatar(); 38 | 39 | const body = ` 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | ${SidebarBG} 50 | 51 | 52 | ${stats.map(Stat).join('')} 53 | 54 | @${username} 55 | ${achievements.slice(0, 7).map(Achievement)} 56 | 57 | 58 | ${AstroLogo} 59 | 60 | 61 | `; 62 | 63 | return new Response(body, { headers: { 'Content-Type': 'image/svg+xml' } }); 64 | } 65 | 66 | export const HEAD = GET; 67 | -------------------------------------------------------------------------------- /src/pages/v2/contributor/[username].png.ts: -------------------------------------------------------------------------------- 1 | import { Resvg } from '@resvg/resvg-js'; 2 | import { getSvg, getStaticPaths } from './[username].svg'; 3 | import type { InferStaticAPIRoute } from '../../../types'; 4 | export { getStaticPaths } from './[username].svg'; 5 | 6 | export const GET: InferStaticAPIRoute = async function GET(ctx) { 7 | const svg = await getSvg(ctx); 8 | const resvg = new Resvg(svg, { 9 | fitTo: { mode: 'zoom', value: 1200 / 260 }, 10 | font: { 11 | loadSystemFonts: false, 12 | fontDirs: ['./src/fonts'], 13 | defaultFontFamily: 'Inter Tight', 14 | monospaceFamily: 'IBM Plex Mono', 15 | }, 16 | }); 17 | return new Response(resvg.render().asPng(), { headers: { 'Content-Type': 'image/png' } }); 18 | }; 19 | -------------------------------------------------------------------------------- /src/pages/v2/contributor/[username].svg.ts: -------------------------------------------------------------------------------- 1 | import type { InferStaticAPIRoute, InferStaticContext } from '../../../types'; 2 | import { contributors, type EnhancedContributor } from "../../../util/getContributors"; 3 | 4 | export function getStaticPaths() { 5 | return contributors.map(({ username }) => ({ params: { username } })); 6 | } 7 | export type Context = InferStaticContext; 8 | 9 | const icons = { 10 | commits: 11 | '', 12 | issues: 13 | ` 14 | `, 15 | pulls: 16 | '', 17 | reviews: 18 | '' 19 | }; 20 | 21 | const Stat = ({ count, type }: { count: string; type: keyof typeof icons }, i: number) => ` 22 | ${icons[type]} 23 | 24 | ${count}` 25 | 26 | const Achievement = ({ achievements }: EnhancedContributor['achievements'][number], i: number) => 27 | `${achievements[0].title} ${achievements[0].details}` 28 | 29 | export async function getSvg(ctx: Context): Promise { 30 | const { username } = ctx.params; 31 | const { achievements, stats, getBase64Avatar } = contributors.find((c) => c.username === username)!; 32 | const b64 = await getBase64Avatar(); 33 | 34 | return ` 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | ${stats.map(Stat).join('')} 53 | 54 | @${username} 55 | ${achievements.slice(0, 7).map(Achievement)} 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | `; 66 | } 67 | 68 | export const GET: InferStaticAPIRoute = async (ctx) => { 69 | const body = await getSvg(ctx); 70 | return new Response(body, { headers: { 'Content-Type': 'image/svg+xml' }}); 71 | } 72 | 73 | export const HEAD = GET; 74 | -------------------------------------------------------------------------------- /src/styles/fonts.css: -------------------------------------------------------------------------------- 1 | @font-face { 2 | font-family: 'MD IO 0.5'; 3 | src: url('https://fonts-cdn.astro.build/MDIO/Web/MDIO0.5-Regular.woff2') 4 | format('woff2'); 5 | font-weight: 400; 6 | font-style: normal; 7 | font-display: swap; 8 | } 9 | 10 | @font-face { 11 | font-family: 'Obviously'; 12 | src: url('https://fonts-cdn.astro.build/Obviously/Obviously Normal/Web/Obviously-Light.woff2') 13 | format('woff2'); 14 | font-weight: 300; 15 | font-style: normal; 16 | font-display: swap; 17 | } 18 | 19 | @font-face { 20 | font-family: 'Obviously'; 21 | src: url('https://fonts-cdn.astro.build/Obviously/Obviously Normal/Web/Obviously-Regular.woff2') 22 | format('woff2'); 23 | font-weight: 400; 24 | font-style: normal; 25 | font-display: swap; 26 | } 27 | 28 | @font-face { 29 | font-family: 'Obviously Wide'; 30 | src: url('https://fonts-cdn.astro.build/Obviously/Obviously Wide/Web/Obviously-Wide_Semibold.woff2') 31 | format('woff2'); 32 | font-weight: 600; 33 | font-style: normal; 34 | font-stretch: wider; 35 | font-display: swap; 36 | } 37 | 38 | /* == FALLBACK FONT CONFIG == */ 39 | @font-face { 40 | font-family: md-io-fallback; 41 | src: local('Courier New'); 42 | line-gap-override: 7%; 43 | } 44 | 45 | @font-face { 46 | font-family: obviously-regular-fallback; 47 | src: local('Verdana'); 48 | size-adjust: 112%; 49 | line-gap-override: 19%; 50 | } 51 | 52 | @font-face { 53 | font-family: obviously-wide-fallback; 54 | src: local('Arial Black'); 55 | size-adjust: 134%; 56 | ascent-override: 96%; 57 | descent-override: 27%; 58 | } 59 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | import type { APIContext, InferGetStaticParamsType, InferGetStaticPropsType } from 'astro'; 2 | 3 | export interface Contributor { 4 | avatar_url: string; 5 | issues: Record; 6 | pulls: Record; 7 | merged_pulls: Record; 8 | merged_pulls_by_label: Record>; 9 | reviews: Record; 10 | reviews_by_category: Record>; 11 | reviews_by_label: Record>; 12 | } 13 | 14 | export type InferStaticContext = APIContext< 15 | InferGetStaticPropsType, 16 | InferGetStaticParamsType 17 | >; 18 | 19 | export type InferStaticAPIRoute = ( 20 | context: InferStaticContext, 21 | ) => Response | Promise; 22 | -------------------------------------------------------------------------------- /src/util/achievementClasses.ts: -------------------------------------------------------------------------------- 1 | import type { AchievementClass } from './achievementsHelpers'; 2 | 3 | export const achievementClassGradientFrom = (cls: AchievementClass) => 4 | (['from-bronze', 'from-silver', 'from-gold'] as const)[cls]; 5 | export const achievementClassGradientTo = (cls: AchievementClass) => 6 | (['to-bronze/50', 'to-silver/50', 'to-gold/50'] as const)[cls]; 7 | export const achievementClassGradient = (cls: AchievementClass, dir = 'bg-gradient-to-br') => 8 | [dir, 'bg-white', achievementClassGradientFrom(cls), achievementClassGradientTo(cls)].join(' '); 9 | export const achievementClassGradientText = (cls: AchievementClass, dir = 'bg-gradient-to-br') => 10 | [ 11 | dir, 12 | 'text-transparent bg-clip-text bg-white', 13 | achievementClassGradientFrom(cls), 14 | achievementClassGradientTo(cls), 15 | ].join(' '); 16 | 17 | export const achievementClassStroke = (cls: AchievementClass) => 18 | (['stroke-bronze', 'stroke-silver', 'stroke-gold'] as const)[cls]; 19 | export const achievementClassFill = (cls: AchievementClass) => 20 | (['fill-bronze', 'fill-silver', 'fill-gold'] as const)[cls]; 21 | 22 | export const achievementClassSlug = (cls: AchievementClass) => 23 | (['bronze', 'silver', 'gold'] as const)[cls]; 24 | 25 | export const achievementClassLabel = (cls: AchievementClass) => 26 | (['Bronze', 'Silver', 'Gold'] as const)[cls]; 27 | 28 | export const achievementClassEmoji = (cls: AchievementClass) => (['🥉', '🥈', '🥇'] as const)[cls]; 29 | -------------------------------------------------------------------------------- /src/util/achievementsHelpers.ts: -------------------------------------------------------------------------------- 1 | import type { Contributor } from '../types'; 2 | 3 | export enum AchievementClass { 4 | Bronze, 5 | Silver, 6 | Gold, 7 | } 8 | 9 | export interface Achievement { 10 | title: string; 11 | details: string; 12 | class: AchievementClass; 13 | numAchieved: number; 14 | } 15 | 16 | interface AchievementDef { 17 | /** The title for this achievement. Short, sweet and entertaining. */ 18 | title: string; 19 | /** Description of how this was achieved, e.g. `'First docs PR'`. */ 20 | details: string; 21 | /** The stat count required to receive this achievement. */ 22 | count: number; 23 | } 24 | 25 | interface AchievementGroupBase { 26 | /** 27 | * The repository this achievement is for. 28 | * If omitted, built-in achievements are calculated for all repos. 29 | */ 30 | repo?: string; 31 | /** Tuple of achievements in ascending order: `[bronze, silver, gold]`. */ 32 | achievements: [bronze: AchievementDef, silver: AchievementDef, gold: AchievementDef]; 33 | } 34 | 35 | interface BuiltinAchievementGroup extends AchievementGroupBase { 36 | /** Specify a built-in stat count type. */ 37 | stat: 'merges' | 'issues' | 'reviews'; 38 | } 39 | 40 | interface LabelAchievementGroup extends AchievementGroupBase { 41 | stat: 'merged_pulls_by_label'; 42 | label: string; 43 | } 44 | 45 | interface CategoryAchievementGroup extends AchievementGroupBase { 46 | stat: 'reviews_by_category'; 47 | category: string; 48 | } 49 | 50 | interface CustomAchievementGroup extends AchievementGroupBase { 51 | /** A custom function to calculate the stat count for this achievement. */ 52 | getCount: (contributor: Contributor) => number; 53 | } 54 | 55 | type AchievementGroup = 56 | | BuiltinAchievementGroup 57 | | LabelAchievementGroup 58 | | CustomAchievementGroup 59 | | CategoryAchievementGroup; 60 | 61 | export function AchievementSpec(spec: Record) { 62 | return Object.fromEntries( 63 | Object.entries(spec).map(([groupID, group]) => { 64 | return [ 65 | groupID, 66 | { 67 | ...group, 68 | achievements: group.achievements.map((achievement, index) => ({ 69 | ...achievement, 70 | class: index as AchievementClass, 71 | numAchieved: 0, 72 | })), 73 | }, 74 | ]; 75 | }) 76 | ); 77 | } 78 | -------------------------------------------------------------------------------- /src/util/getAchievements.ts: -------------------------------------------------------------------------------- 1 | import type { Contributor } from '../types'; 2 | import { objSum } from './objSum'; 3 | import type { Achievement, AchievementClass } from './achievementsHelpers'; 4 | import spec from '../achievements.config'; 5 | 6 | interface NextAchievement { 7 | achievement: Achievement & { count: number }; 8 | progress: number; 9 | } 10 | 11 | function getAchievementsFromSpec(contributor: Contributor) { 12 | const achieved: { 13 | groupID: string; 14 | repo?: string | undefined; 15 | class: AchievementClass; 16 | achievements: [Achievement, ...Achievement[]]; 17 | next?: NextAchievement | undefined; 18 | }[] = []; 19 | for (const groupID in spec) { 20 | const group = spec[groupID]!; 21 | const groupAchieved: Achievement[] = []; 22 | const { repo, achievements } = group; 23 | let count: number; 24 | if ('getCount' in group) { 25 | count = group.getCount(contributor); 26 | } else if (group.stat === 'merged_pulls_by_label' || group.stat === 'reviews_by_category') { 27 | const key = group.stat === 'merged_pulls_by_label' ? group.label : group.category; 28 | count = repo 29 | ? contributor[group.stat][repo]?.[key] || 0 30 | : Object.values(contributor[group.stat]).reduce((sum, repo) => sum + (repo[key] || 0), 0); 31 | } else { 32 | const stat = group.stat === 'merges' ? 'merged_pulls' : group.stat; 33 | count = repo ? contributor[stat][repo] || 0 : objSum(contributor[stat]); 34 | } 35 | let next: NextAchievement | undefined; 36 | for (const achievement of Object.values(achievements)) { 37 | if (count >= achievement.count) { 38 | groupAchieved.push(achievement); 39 | achievement.numAchieved++; 40 | } else { 41 | next = { progress: count / achievement.count, achievement }; 42 | break; 43 | } 44 | } 45 | groupAchieved.sort((a, b) => b.class - a.class); 46 | const [best, ...rest] = groupAchieved; 47 | if (!best) continue; 48 | 49 | achieved.push({ 50 | groupID, 51 | repo, 52 | class: best.class, 53 | achievements: [best, ...rest], 54 | next, 55 | }); 56 | } 57 | achieved.sort((a, b) => b.class - a.class); 58 | return achieved; 59 | } 60 | 61 | export function getAchievements(contributor: Contributor) { 62 | return getAchievementsFromSpec(contributor); 63 | } 64 | -------------------------------------------------------------------------------- /src/util/getContributors.ts: -------------------------------------------------------------------------------- 1 | import sharp from 'sharp'; 2 | import data from '../data/contributors.json'; 3 | import type { Contributor } from '../types'; 4 | import { getAchievements } from './getAchievements'; 5 | import { getStats } from './getStats'; 6 | import { objSum } from './objSum'; 7 | import { resizedGitHubAvatarURL } from './resizedGitHubAvatarURL'; 8 | 9 | export interface EnhancedContributor extends Contributor { 10 | username: string; 11 | achievements: ReturnType; 12 | stats: ReturnType; 13 | getBase64Avatar: () => Promise; 14 | } 15 | 16 | const avatarCache = new Map(); 17 | async function getBase64Avatar(avatar_url: string) { 18 | const cached = avatarCache.get(avatar_url); 19 | if (cached) { 20 | return cached; 21 | } 22 | const avatarRes = await fetch(resizedGitHubAvatarURL(avatar_url, 60)); 23 | let avatarBuffer = Buffer.from(await (await avatarRes.blob()).arrayBuffer()); 24 | if (avatarRes.headers.get('content-type') !== 'image/jpeg') { 25 | // resvg doesn’t like PNG avatars, so force to JPEG: 26 | avatarBuffer = await sharp(avatarBuffer).flatten().jpeg().toBuffer(); 27 | } 28 | const b64 = avatarBuffer.toString('base64'); 29 | avatarCache.set(avatar_url, b64); 30 | return b64; 31 | } 32 | 33 | function enhanceContributor( 34 | username: string, 35 | contributor: Contributor 36 | ): EnhancedContributor { 37 | return { 38 | username, 39 | ...contributor, 40 | achievements: getAchievements(contributor), 41 | stats: getStats(contributor), 42 | getBase64Avatar: () => getBase64Avatar(contributor.avatar_url), 43 | }; 44 | } 45 | 46 | function contributorSum({ issues, merged_pulls, reviews }: Contributor) { 47 | return objSum(issues) + objSum(merged_pulls) + objSum(reviews); 48 | } 49 | 50 | function sortContributors(contributors: EnhancedContributor[]) { 51 | return contributors.sort((a, b) => contributorSum(b) - contributorSum(a)); 52 | } 53 | 54 | function loadContributors(contributors: typeof data) { 55 | const enhancedContributors: EnhancedContributor[] = []; 56 | for (const username in contributors) { 57 | const contributor = contributors[username as keyof typeof contributors]; 58 | enhancedContributors.push(enhanceContributor(username, contributor)); 59 | } 60 | return sortContributors(enhancedContributors); 61 | } 62 | 63 | export const contributors = loadContributors(data); 64 | -------------------------------------------------------------------------------- /src/util/getGlobalStats.ts: -------------------------------------------------------------------------------- 1 | import type { Achievement, AchievementClass } from './achievementsHelpers'; 2 | import { contributors } from './getContributors'; 3 | 4 | interface Stat extends Achievement { 5 | groupID: string; 6 | repo?: string | undefined; 7 | contributors: typeof contributors; 8 | } 9 | 10 | export const globalAchievements: Stat[] = Object.values( 11 | contributors.reduce((stats, contributor) => { 12 | contributor.achievements.forEach((group) => { 13 | if (!stats[group.groupID]) { 14 | stats[group.groupID] = {}; 15 | } 16 | for (const achievement of group.achievements) { 17 | if (!stats[group.groupID]![achievement.class]) { 18 | stats[group.groupID]![achievement.class] = { 19 | ...achievement, 20 | groupID: group.groupID, 21 | repo: group.repo, 22 | contributors: [], 23 | }; 24 | } 25 | stats[group.groupID]![achievement.class]!.contributors.push(contributor); 26 | } 27 | }); 28 | return stats; 29 | }, {} as Record>>) 30 | ).flatMap((group) => Object.values(group)); 31 | -------------------------------------------------------------------------------- /src/util/getStats.ts: -------------------------------------------------------------------------------- 1 | import type { Contributor } from '../types'; 2 | import { objSum } from './objSum'; 3 | 4 | export function getStats({ 5 | issues, 6 | merged_pulls, 7 | reviews, 8 | }: Contributor): { type: 'issues' | 'pulls' | 'reviews'; count: string }[] { 9 | const stats = [ 10 | { type: 'issues', count: formatInt(objSum(issues)) }, 11 | { type: 'pulls', count: formatInt(objSum(merged_pulls)) }, 12 | { type: 'reviews', count: formatInt(objSum(reviews)) }, 13 | ] as const; 14 | return stats.filter(({ count }) => count !== '0'); 15 | } 16 | 17 | const formatInt = (int: number) => 18 | int < 1000 ? int.toString() : (int / 1000).toFixed(1) + 'k'; 19 | -------------------------------------------------------------------------------- /src/util/objSum.ts: -------------------------------------------------------------------------------- 1 | export function objSum(obj: Record): number { 2 | return Object.values(obj).reduce((sum, i) => sum + i, 0); 3 | } 4 | -------------------------------------------------------------------------------- /src/util/resizedGitHubAvatarURL.ts: -------------------------------------------------------------------------------- 1 | export const resizedGitHubAvatarURL = (avatarURL: string, size: number) => { 2 | const url = new URL(avatarURL); 3 | url.searchParams.set('s', String(size)); 4 | return url.href; 5 | }; 6 | -------------------------------------------------------------------------------- /tailwind.config.mjs: -------------------------------------------------------------------------------- 1 | import config from '@astrojs/site-kit/tailwind-preset'; 2 | import colors from 'tailwindcss/colors'; 3 | import defaultTheme from 'tailwindcss/defaultTheme'; 4 | 5 | /** @type {import('tailwindcss').Config} */ 6 | module.exports = { 7 | presets: [config], 8 | theme: { 9 | extend: { 10 | screens: { 11 | xs: '320px', 12 | }, 13 | colors: { 14 | neutral: config.theme.extend.colors['astro-gray'], 15 | accent: colors.fuchsia, 16 | bronze: '#FF9E58', 17 | silver: '#BFC1C9', 18 | gold: '#FFCA58', 19 | }, 20 | fontFamily: { 21 | sans: ['InterVariable', ...defaultTheme.fontFamily.sans], 22 | }, 23 | typography: ({ theme }) => ({ 24 | DEFAULT: { 25 | css: { 26 | h1: { 27 | fontFamily: theme('fontFamily.obviously'), 28 | }, 29 | }, 30 | }, 31 | }), 32 | }, 33 | }, 34 | }; 35 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "astro/tsconfigs/strictest", 3 | "exclude": ["dist"] 4 | } 5 | -------------------------------------------------------------------------------- /vercel.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://openapi.vercel.sh/vercel.json", 3 | "cleanUrls": true, 4 | "trailingSlash": true, 5 | "redirects": [ 6 | { 7 | "source": "/bwa/", 8 | "destination": "/badges/" 9 | }, 10 | { 11 | "source": "/v1/achievements/:path*/", 12 | "destination": "/achievements/:path*/" 13 | }, 14 | { 15 | "source": "/v1/contributor/:path*/", 16 | "destination": "/contributor/:path*/" 17 | } 18 | ] 19 | } 20 | --------------------------------------------------------------------------------