diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 04b21f65..c7cf2272 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -92,6 +92,7 @@ jobs: -e THEME_IMPORTANT \ -e THEME_WARNING \ -e THEME_CAUTION \ + -e CONTRIBUTORS_PAT \ ghcr.io/pmndrs/docs:2 npm run build env: BASE_PATH: ${{ steps.set-base-path.outputs.BASE_PATH }} @@ -115,6 +116,7 @@ jobs: THEME_IMPORTANT: '${{ inputs.theme_important }}' THEME_WARNING: '${{ inputs.theme_warning }}' THEME_CAUTION: '${{ inputs.theme_caution }}' + CONTRIBUTORS_PAT: ${{ secrets.GITHUB_TOKEN }} - uses: actions/upload-pages-artifact@v3 with: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 6085ec17..1f6754ce 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -70,6 +70,7 @@ jobs: --build-env THEME_IMPORTANT="#8957e5" \ --build-env THEME_WARNING="#d29922" \ --build-env THEME_CAUTION="#da3633" \ + --build-env CONTRIBUTORS_PAT="${{ secrets.GITHUB_TOKEN }}" \ > deployment-url.txt echo "deployment_url=$(cat deployment-url.txt)" >> $GITHUB_OUTPUT diff --git a/docs/getting-started/authoring.mdx b/docs/getting-started/authoring.mdx index b93a7efb..f04598e0 100644 --- a/docs/getting-started/authoring.mdx +++ b/docs/getting-started/authoring.mdx @@ -414,3 +414,32 @@ or better: I'm a deprecated hint. Use `Gha` instead. + +### `Contributors` + +```md + +``` + +> [!WARNING] +> [`CONTRIBUTORS_PAT`](introduction#configuration:~:text=CONTRIBUTORS_PAT) needs to be set. Otherwise, it will display John Doe. + +
+ Result + + + +
+ +### Backers + +```md + +``` + +
+ Result + + + +
\ No newline at end of file diff --git a/docs/getting-started/introduction.mdx b/docs/getting-started/introduction.mdx index 2563f742..4e5241f8 100644 --- a/docs/getting-started/introduction.mdx +++ b/docs/getting-started/introduction.mdx @@ -47,6 +47,7 @@ $ npm ci | `THEME_IMPORTANT` | "important" color | `#8957e5` | | `THEME_WARNING` | "warning" color | `#d29922` | | `THEME_CAUTION` | "caution" color | `#da3633` | +| `CONTRIBUTORS_PAT` | GitHub token for contributors API (see: https://docs.github.com/en/rest/collaborators/collaborators?apiVersion=2022-11-28#list-repository-collaborators) | `ghp_1234567890` | \* Required @@ -116,6 +117,7 @@ $ ( export THEME_IMPORTANT="#8957e5" export THEME_WARNING="#d29922" export THEME_CAUTION="#da3633" + export CONTRIBUTORS_PAT= kill $(lsof -ti:"$_PORT") npx serve $MDX -p $_PORT --no-port-switching --no-clipboard & @@ -163,6 +165,7 @@ $ ( export THEME_IMPORTANT="#8957e5" export THEME_WARNING="#d29922" export THEME_CAUTION="#da3633" + export CONTRIBUTORS_PAT= npm run build @@ -211,6 +214,7 @@ $ ( export THEME_IMPORTANT="#8957e5" export THEME_WARNING="#d29922" export THEME_CAUTION="#da3633" + export CONTRIBUTORS_PAT= rm -rf "$MDX/out" @@ -238,6 +242,7 @@ $ ( -e THEME_IMPORTANT \ -e THEME_WARNING \ -e THEME_CAUTION \ + -e CONTRIBUTORS_PAT \ pmndrs-docs npm run build kill $(lsof -ti:"$_PORT") diff --git a/package-lock.json b/package-lock.json index 4254bb13..59896686 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "version": "0.1.0", "dependencies": { "@codesandbox/sandpack-react": "^2.19.8", + "@octokit/core": "^6.1.2", "@radix-ui/react-collapsible": "^1.1.0", "@radix-ui/react-dialog": "^1.1.1", "@radix-ui/react-visually-hidden": "^1.1.0", @@ -891,7 +892,6 @@ "version": "5.1.1", "resolved": "https://registry.npmjs.org/@octokit/auth-token/-/auth-token-5.1.1.tgz", "integrity": "sha512-rh3G3wDO8J9wSjfI436JUKzHIxq8NaiL0tVeB2aXmG6p/9859aUOAjA9pmSPNGGZxfwmaJ9ozOJImuNVJdpvbA==", - "dev": true, "license": "MIT", "engines": { "node": ">= 18" @@ -901,7 +901,6 @@ "version": "6.1.2", "resolved": "https://registry.npmjs.org/@octokit/core/-/core-6.1.2.tgz", "integrity": "sha512-hEb7Ma4cGJGEUNOAVmyfdB/3WirWMg5hDuNFVejGEDFqupeOysLc2sG6HJxY2etBp5YQu5Wtxwi020jS9xlUwg==", - "dev": true, "license": "MIT", "dependencies": { "@octokit/auth-token": "^5.0.0", @@ -920,7 +919,6 @@ "version": "10.1.1", "resolved": "https://registry.npmjs.org/@octokit/endpoint/-/endpoint-10.1.1.tgz", "integrity": "sha512-JYjh5rMOwXMJyUpj028cu0Gbp7qe/ihxfJMLc8VZBMMqSwLgOxDI1911gV4Enl1QSavAQNJcwmwBF9M0VvLh6Q==", - "dev": true, "license": "MIT", "dependencies": { "@octokit/types": "^13.0.0", @@ -934,7 +932,6 @@ "version": "8.1.1", "resolved": "https://registry.npmjs.org/@octokit/graphql/-/graphql-8.1.1.tgz", "integrity": "sha512-ukiRmuHTi6ebQx/HFRCXKbDlOh/7xEV6QUXaE7MJEKGNAncGI/STSbOkl12qVXZrfZdpXctx5O9X1AIaebiDBg==", - "dev": true, "license": "MIT", "dependencies": { "@octokit/request": "^9.0.0", @@ -949,7 +946,6 @@ "version": "22.2.0", "resolved": "https://registry.npmjs.org/@octokit/openapi-types/-/openapi-types-22.2.0.tgz", "integrity": "sha512-QBhVjcUa9W7Wwhm6DBFu6ZZ+1/t/oYxqc2tp81Pi41YNuJinbFRx8B133qVOrAaBbF7D/m0Et6f9/pZt9Rc+tg==", - "dev": true, "license": "MIT" }, "node_modules/@octokit/plugin-paginate-rest": { @@ -1007,7 +1003,6 @@ "version": "9.1.3", "resolved": "https://registry.npmjs.org/@octokit/request/-/request-9.1.3.tgz", "integrity": "sha512-V+TFhu5fdF3K58rs1pGUJIDH5RZLbZm5BI+MNF+6o/ssFNT4vWlCh/tVpF3NxGtP15HUxTTMUbsG5llAuU2CZA==", - "dev": true, "license": "MIT", "dependencies": { "@octokit/endpoint": "^10.0.0", @@ -1023,7 +1018,6 @@ "version": "6.1.4", "resolved": "https://registry.npmjs.org/@octokit/request-error/-/request-error-6.1.4.tgz", "integrity": "sha512-VpAhIUxwhWZQImo/dWAN/NpPqqojR6PSLgLYAituLM6U+ddx9hCioFGwBr5Mi+oi5CLeJkcAs3gJ0PYYzU6wUg==", - "dev": true, "license": "MIT", "dependencies": { "@octokit/types": "^13.0.0" @@ -1036,7 +1030,6 @@ "version": "13.5.0", "resolved": "https://registry.npmjs.org/@octokit/types/-/types-13.5.0.tgz", "integrity": "sha512-HdqWTf5Z3qwDVlzCrP8UJquMwunpDiMPt5er+QjGzL4hqr/vBVY/MauQgS1xWxCDT1oMx1EULyqxncdCY/NVSQ==", - "dev": true, "license": "MIT", "dependencies": { "@octokit/openapi-types": "^22.2.0" @@ -2732,7 +2725,6 @@ "version": "3.0.2", "resolved": "https://registry.npmjs.org/before-after-hook/-/before-after-hook-3.0.2.tgz", "integrity": "sha512-Nik3Sc0ncrMK4UUdXQmAnRtzmNQTAAXmXIopizwZ1W1t8QmfJj+zL4OA2I7XPTPW5z5TDqv4hRo/JzouDJnX3A==", - "dev": true, "license": "Apache-2.0" }, "node_modules/binary-extensions": { @@ -15284,7 +15276,6 @@ "version": "7.0.2", "resolved": "https://registry.npmjs.org/universal-user-agent/-/universal-user-agent-7.0.2.tgz", "integrity": "sha512-0JCqzSKnStlRRQfCdowvqy3cy0Dvtlb8xecj/H8JFZuCze4rwjPZQOgvFvn0Ws/usCHQFGpyr+pB9adaGwXn4Q==", - "dev": true, "license": "ISC" }, "node_modules/universalify": { diff --git a/package.json b/package.json index 3b338a6d..5e971d97 100644 --- a/package.json +++ b/package.json @@ -31,6 +31,7 @@ }, "dependencies": { "@codesandbox/sandpack-react": "^2.19.8", + "@octokit/core": "^6.1.2", "@radix-ui/react-collapsible": "^1.1.0", "@radix-ui/react-dialog": "^1.1.1", "@radix-ui/react-visually-hidden": "^1.1.0", diff --git a/src/components/mdx/People/People.tsx b/src/components/mdx/People/People.tsx new file mode 100644 index 00000000..d572333b --- /dev/null +++ b/src/components/mdx/People/People.tsx @@ -0,0 +1,151 @@ +import cn from '@/lib/cn' +import { initials } from '@/utils/text' +import { Octokit } from '@octokit/core' +import Image from 'next/image' +import { cache, ComponentProps } from 'react' +import backerBadge from './backer-badge.svg' + +// ██████ ██████ ███ ██ ████████ ██████ ██ ██████ ██ ██ ████████ ██████ ██████ ███████ +// ██ ██ ██ ████ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ +// ██ ██ ██ ██ ██ ██ ██ ██████ ██ ██████ ██ ██ ██ ██ ██ ██████ ███████ +// ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ +// ██████ ██████ ██ ████ ██ ██ ██ ██ ██████ ██████ ██ ██████ ██ ██ ███████ + +const octokit = new Octokit({ + auth: process.env.CONTRIBUTORS_PAT, +}) + +export async function Contributors({ + owner, + repo, + limit = 50, + className, + ...props +}: { owner: string; repo: string; limit: number } & ComponentProps<'ul'>) { + const contributors = ( + await cachedFetchContributors(owner, repo).catch( + (err) => + Array.from({ length: 100 }).map(() => ({ + login: 'jdoe', + html_url: 'https://github.com/jdoe', + })) as Awaited>, + ) + ).slice(0, limit) + + return ( +
+
    + {contributors.map(({ html_url, avatar_url, login }) => ( +
  • + +
  • + ))} +
+
+ ) +} + +async function fetchContributors(owner: string, repo: string) { + const res = await octokit.request(`GET /repos/{owner}/{repo}/collaborators`, { + owner, + repo, + headers: { + 'X-GitHub-Api-Version': '2022-11-28', + }, + }) + return res.data +} +const cachedFetchContributors = cache(fetchContributors) + +// ██████ █████ ██████ ██ ██ ███████ ██████ ███████ +// ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ +// ██████ ███████ ██ █████ █████ ██████ ███████ +// ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ +// ██████ ██ ██ ██████ ██ ██ ███████ ██ ██ ███████ + +export async function Backers({ + repo, + limit = 50, + className, + ...props +}: ComponentProps<'ul'> & { + repo: string + limit: number +}) { + const backers = (await fetchBackers(repo)).slice(0, limit) + + return ( +
+
    + {backers.map((backer) => ( +
  • + +
  • + ))} +
+

+ + Backer + +

+
+ ) +} + +async function fetchBackers(repo: string) { + const res = await fetch(`https://opencollective.com/${repo}/members/users.json`) + const backers: { + profile: string + name: string + image: string + totalAmountDonated: number + }[] = await res.json() + + const backersMap = new Map(backers.map((backer) => [backer.name, backer])) + backersMap.forEach((backer) => { + const existingBacker = backersMap.get(backer.name) + if (existingBacker && backer.totalAmountDonated >= existingBacker.totalAmountDonated) { + backersMap.set(backer.name, backer) // replace with the backer with the highest donation + } + }) + const uniqueBackers = Array.from(backersMap.values()) + + return uniqueBackers.sort((a, b) => b.totalAmountDonated - a.totalAmountDonated) +} + +const cachedFetchBackers = cache(fetchBackers) + +// +// common +// + +function Avatar({ + profileUrl, + imageUrl, + name, + className, + ...props +}: { profileUrl: string; imageUrl: string; name: string } & ComponentProps<'a'>) { + return ( + + {imageUrl ? ( + {name} + ) : ( + initials(name) + )} + + ) +} diff --git a/src/components/mdx/People/backer-badge.svg b/src/components/mdx/People/backer-badge.svg new file mode 100644 index 00000000..8180f020 --- /dev/null +++ b/src/components/mdx/People/backer-badge.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/components/mdx/People/index.ts b/src/components/mdx/People/index.ts new file mode 100644 index 00000000..3c50cbb9 --- /dev/null +++ b/src/components/mdx/People/index.ts @@ -0,0 +1 @@ +export * from './People' diff --git a/src/components/mdx/index.tsx b/src/components/mdx/index.tsx index 89e53f16..4942fc28 100644 --- a/src/components/mdx/index.tsx +++ b/src/components/mdx/index.tsx @@ -7,6 +7,7 @@ export * from './Hint' export * from './Img' export * from './Intro' export * from './Keypoints' +export * from './People' export * from './Summary' export * from './Toc' diff --git a/src/utils/text.ts b/src/utils/text.ts index cfb5b46f..86b399ee 100644 --- a/src/utils/text.ts +++ b/src/utils/text.ts @@ -10,3 +10,17 @@ export const highlight = (text: string, target: string) => target.length > 0 ? text.replace(new RegExp(escape(target), 'gi'), (match: string) => `${match}`) : text + +export function initials(name: string) { + const parts = name.split(' ') + + if (parts.length > 1) { + return parts + .slice(0, 2) + .map(([char]) => char) + .join('') + .toUpperCase() + } + + return name.slice(0, 2).toUpperCase() +}