|
| 1 | +#!/usr/bin/env node |
| 2 | +// Builds a README contributors wall from the GitHub contributors API. |
| 3 | +// Avatar bytes are inlined as base64 so GitHub's image proxy does not have to |
| 4 | +// fetch nested images. The scheduled workflow publishes the file on a data |
| 5 | +// branch; this script does not commit. |
| 6 | + |
| 7 | +import fs from 'node:fs'; |
| 8 | +import { pathToFileURL } from 'node:url'; |
| 9 | + |
| 10 | +const WALL_WIDTH = 1000; |
| 11 | +const COLUMNS = 8; |
| 12 | +const AVATAR = 72; |
| 13 | +const CELL_WIDTH = WALL_WIDTH / COLUMNS; |
| 14 | +const AVATAR_X = (CELL_WIDTH - AVATAR) / 2; |
| 15 | +const AVATAR_Y = 8; |
| 16 | +const NAME_LIMIT = 18; |
| 17 | +const ROW_HEIGHT = AVATAR_Y + AVATAR + 36; |
| 18 | +const MAX_AVATAR_BYTES = 1_500_000; |
| 19 | + |
| 20 | +// Exact logins, plus any account GitHub marks as a Bot or names with [bot]. |
| 21 | +// "claude" is the unscoped Claude app login, not a substring of human names. |
| 22 | +const BOT_LOGINS = new Set([ |
| 23 | + 'allcontributors', |
| 24 | + 'claude', |
| 25 | + 'codecov', |
| 26 | + 'copilot', |
| 27 | + 'dependabot', |
| 28 | + 'dependabot-preview', |
| 29 | + 'github-actions', |
| 30 | + 'imgbot', |
| 31 | + 'pre-commit-ci', |
| 32 | + 'renovate', |
| 33 | + 'snyk-bot', |
| 34 | + 'web-flow', |
| 35 | +]); |
| 36 | + |
| 37 | +const LOGIN = /^[A-Za-z0-9](?:[A-Za-z0-9]|-(?=[A-Za-z0-9])){0,38}$/; |
| 38 | + |
| 39 | +export function isBotContributor(user) { |
| 40 | + if (!user || typeof user.login !== 'string') return true; |
| 41 | + if (user.type === 'Bot') return true; |
| 42 | + const login = user.login.trim(); |
| 43 | + if (/\[bot\]$/i.test(login)) return true; |
| 44 | + return BOT_LOGINS.has(login.toLowerCase()); |
| 45 | +} |
| 46 | + |
| 47 | +export function displayLogin(login) { |
| 48 | + if (login.length <= NAME_LIMIT) return login; |
| 49 | + return `${login.slice(0, NAME_LIMIT - 1)}…`; |
| 50 | +} |
| 51 | + |
| 52 | +export function avatarMediaType(bytes, headerType = '') { |
| 53 | + if (bytes.length >= 8 && bytes[0] === 0x89 && bytes[1] === 0x50 && bytes[2] === 0x4e && bytes[3] === 0x47) return 'image/png'; |
| 54 | + if (bytes.length >= 3 && bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff) return 'image/jpeg'; |
| 55 | + const gif = bytes.length >= 6 ? bytes.subarray(0, 6).toString('ascii') : ''; |
| 56 | + if (gif === 'GIF87a' || gif === 'GIF89a') return 'image/gif'; |
| 57 | + if (bytes.length >= 12 && bytes.subarray(0, 4).toString('ascii') === 'RIFF' && bytes.subarray(8, 12).toString('ascii') === 'WEBP') return 'image/webp'; |
| 58 | + const normalized = String(headerType).split(';')[0].trim().toLowerCase(); |
| 59 | + if (['image/png', 'image/jpeg', 'image/gif', 'image/webp'].includes(normalized)) return normalized; |
| 60 | + throw new Error('Avatar is not a PNG, JPEG, GIF, or WebP image'); |
| 61 | +} |
| 62 | + |
| 63 | +function xmlEscape(value) { |
| 64 | + return value.replace(/[&<>"']/g, (char) => ({ |
| 65 | + '&': '&', |
| 66 | + '<': '<', |
| 67 | + '>': '>', |
| 68 | + '"': '"', |
| 69 | + "'": ''', |
| 70 | + }[char])); |
| 71 | +} |
| 72 | + |
| 73 | +function repoParts(repo) { |
| 74 | + const match = /^([^/\s]+)\/([^/\s]+)$/.exec(repo || ''); |
| 75 | + if (!match) throw new Error('Repository must look like owner/name'); |
| 76 | + return { owner: match[1], name: match[2] }; |
| 77 | +} |
| 78 | + |
| 79 | +function githubHeaders(token, accept = 'application/vnd.github+json') { |
| 80 | + const headers = { |
| 81 | + Accept: accept, |
| 82 | + 'User-Agent': 'archify-contributors-svg', |
| 83 | + 'X-GitHub-Api-Version': '2022-11-28', |
| 84 | + }; |
| 85 | + if (token) headers.Authorization = `Bearer ${token}`; |
| 86 | + return headers; |
| 87 | +} |
| 88 | + |
| 89 | +function nextPage(linkHeader) { |
| 90 | + if (!linkHeader) return ''; |
| 91 | + for (const part of linkHeader.split(',')) { |
| 92 | + const match = /<([^>]+)>;\s*rel="next"/.exec(part); |
| 93 | + if (!match) continue; |
| 94 | + const url = new URL(match[1]); |
| 95 | + if (url.protocol !== 'https:' || url.hostname !== 'api.github.com') { |
| 96 | + throw new Error('Refusing contributors pagination outside api.github.com'); |
| 97 | + } |
| 98 | + return url.href; |
| 99 | + } |
| 100 | + return ''; |
| 101 | +} |
| 102 | + |
| 103 | +export async function fetchContributors({ repo = 'tt-a1i/archify', token = '', fetchImpl = fetch } = {}) { |
| 104 | + const { owner, name } = repoParts(repo); |
| 105 | + let url = `https://api.github.com/repos/${encodeURIComponent(owner)}/${encodeURIComponent(name)}/contributors?per_page=100`; |
| 106 | + const people = []; |
| 107 | + const seen = new Set(); |
| 108 | + while (url) { |
| 109 | + const response = await fetchImpl(url, { |
| 110 | + headers: githubHeaders(token), |
| 111 | + signal: AbortSignal.timeout(20_000), |
| 112 | + }); |
| 113 | + if (!response.ok) throw new Error(`GitHub contributors request failed (${response.status})`); |
| 114 | + const page = await response.json(); |
| 115 | + if (!Array.isArray(page)) throw new Error('GitHub contributors response was not a list'); |
| 116 | + for (const user of page) { |
| 117 | + if (isBotContributor(user) || seen.has(user.login)) continue; |
| 118 | + if (!LOGIN.test(user.login)) continue; |
| 119 | + seen.add(user.login); |
| 120 | + people.push({ |
| 121 | + login: user.login, |
| 122 | + contributions: Number(user.contributions) || 0, |
| 123 | + avatarUrl: user.avatar_url, |
| 124 | + }); |
| 125 | + } |
| 126 | + url = nextPage(response.headers.get('link')); |
| 127 | + } |
| 128 | + people.sort((a, b) => b.contributions - a.contributions || a.login.localeCompare(b.login)); |
| 129 | + if (!people.length) throw new Error('No human contributors to render'); |
| 130 | + return people; |
| 131 | +} |
| 132 | + |
| 133 | +function avatarRequestUrl(avatarUrl, login) { |
| 134 | + let url; |
| 135 | + try { |
| 136 | + url = new URL(avatarUrl); |
| 137 | + } catch { |
| 138 | + throw new Error(`Contributor ${login} has no avatar URL`); |
| 139 | + } |
| 140 | + if (url.protocol !== 'https:' || !['avatars.githubusercontent.com', 'github.com'].includes(url.hostname)) { |
| 141 | + throw new Error(`Refusing avatar host for ${login}`); |
| 142 | + } |
| 143 | + url.searchParams.set('s', '144'); |
| 144 | + return url; |
| 145 | +} |
| 146 | + |
| 147 | +export async function fetchAvatarDataUri(person, { fetchImpl = fetch } = {}) { |
| 148 | + let requested = avatarRequestUrl(person.avatarUrl, person.login); |
| 149 | + let response; |
| 150 | + for (let redirects = 0; ; redirects += 1) { |
| 151 | + // Avatars are public. Keep the API token on the API request path only. |
| 152 | + response = await fetchImpl(requested, { |
| 153 | + headers: githubHeaders('', 'application/octet-stream'), |
| 154 | + redirect: 'manual', |
| 155 | + signal: AbortSignal.timeout(20_000), |
| 156 | + }); |
| 157 | + if (![301, 302, 303, 307, 308].includes(response.status)) break; |
| 158 | + await response.body?.cancel(); |
| 159 | + const location = response.headers.get('location'); |
| 160 | + if (!location) throw new Error(`Avatar redirect has no location for ${person.login}`); |
| 161 | + if (redirects >= 5) throw new Error(`Too many avatar redirects for ${person.login}`); |
| 162 | + // Validate each hop before fetching it, including HTTPS and allowed hosts. |
| 163 | + requested = avatarRequestUrl(new URL(location, requested).href, person.login); |
| 164 | + } |
| 165 | + if (!response.ok) throw new Error(`Avatar download failed for ${person.login} (${response.status})`); |
| 166 | + avatarRequestUrl(response.url || requested, person.login); |
| 167 | + const bytes = Buffer.from(await response.arrayBuffer()); |
| 168 | + if (bytes.length === 0 || bytes.length > MAX_AVATAR_BYTES) { |
| 169 | + throw new Error(`Avatar for ${person.login} is empty or larger than ${MAX_AVATAR_BYTES} bytes`); |
| 170 | + } |
| 171 | + const mediaType = avatarMediaType(bytes, response.headers.get('content-type') || ''); |
| 172 | + return `data:${mediaType};base64,${bytes.toString('base64')}`; |
| 173 | +} |
| 174 | + |
| 175 | +export function renderContributorsSvg(contributors) { |
| 176 | + if (!Array.isArray(contributors) || contributors.length === 0) { |
| 177 | + throw new Error('No human contributors to render'); |
| 178 | + } |
| 179 | + const rows = Math.ceil(contributors.length / COLUMNS); |
| 180 | + const cells = contributors.map((person, index) => { |
| 181 | + if (!person || !LOGIN.test(person.login || '')) throw new Error('Contributor login is not a GitHub login'); |
| 182 | + if (!/^data:image\/(?:png|jpeg|gif|webp);base64,[A-Za-z0-9+/=]+$/.test(person.dataUri || '')) { |
| 183 | + throw new Error(`Contributor ${person.login} is missing an inline avatar`); |
| 184 | + } |
| 185 | + const column = index % COLUMNS; |
| 186 | + const row = Math.floor(index / COLUMNS); |
| 187 | + const label = displayLogin(person.login); |
| 188 | + const centerX = AVATAR_X + AVATAR / 2; |
| 189 | + const centerY = AVATAR_Y + AVATAR / 2; |
| 190 | + return ` <g transform="translate(${column * CELL_WIDTH} ${row * ROW_HEIGHT})"> |
| 191 | + <title>${xmlEscape(person.login)}</title> |
| 192 | + <defs><clipPath id="archify-contributor-${index}"><circle cx="${centerX}" cy="${centerY}" r="${AVATAR / 2}"/></clipPath></defs> |
| 193 | + <image href="${person.dataUri}" x="${AVATAR_X}" y="${AVATAR_Y}" width="${AVATAR}" height="${AVATAR}" clip-path="url(#archify-contributor-${index})" preserveAspectRatio="xMidYMid slice"/> |
| 194 | + <circle class="ring" cx="${centerX}" cy="${centerY}" r="${AVATAR / 2}" fill="none" stroke-width="1"/> |
| 195 | + <text class="name" x="${centerX}" y="${AVATAR_Y + AVATAR + 22}" text-anchor="middle">${xmlEscape(label)}</text> |
| 196 | + </g>`; |
| 197 | + }); |
| 198 | + return `<?xml version="1.0" encoding="UTF-8"?> |
| 199 | +<svg xmlns="http://www.w3.org/2000/svg" width="${WALL_WIDTH}" height="${rows * ROW_HEIGHT}" viewBox="0 0 ${WALL_WIDTH} ${rows * ROW_HEIGHT}" role="img"> |
| 200 | + <title>Archify contributors</title> |
| 201 | + <style><![CDATA[ |
| 202 | + .name { fill: #24292f; stroke: #ffffff; stroke-width: 3px; paint-order: stroke fill; font-family: ui-sans-serif, -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif; font-size: 12px; } |
| 203 | + .ring { stroke: #d0d7de; } |
| 204 | + @media (prefers-color-scheme: dark) { |
| 205 | + .name { fill: #f0f6fc; stroke: #010409; } |
| 206 | + .ring { stroke: #30363d; } |
| 207 | + } |
| 208 | + ]]></style> |
| 209 | +${cells.join('\n')} |
| 210 | +</svg> |
| 211 | +`; |
| 212 | +} |
| 213 | + |
| 214 | +export async function buildContributorsSvg(options = {}) { |
| 215 | + const people = await fetchContributors(options); |
| 216 | + const rendered = []; |
| 217 | + for (const person of people) { |
| 218 | + rendered.push({ |
| 219 | + login: person.login, |
| 220 | + dataUri: await fetchAvatarDataUri(person, options), |
| 221 | + }); |
| 222 | + } |
| 223 | + return renderContributorsSvg(rendered); |
| 224 | +} |
| 225 | + |
| 226 | +function parseArgs(argv) { |
| 227 | + const options = { repo: 'tt-a1i/archify', out: '' }; |
| 228 | + for (let index = 0; index < argv.length; index += 1) { |
| 229 | + const arg = argv[index]; |
| 230 | + if (arg === '--repo') options.repo = argv[++index]; |
| 231 | + else if (arg === '--out') options.out = argv[++index]; |
| 232 | + else if (arg === '--help' || arg === '-h') options.help = true; |
| 233 | + else throw new Error(`Unknown argument: ${arg}`); |
| 234 | + if (options.repo === undefined || options.out === undefined) throw new Error(`Missing value for ${arg}`); |
| 235 | + } |
| 236 | + return options; |
| 237 | +} |
| 238 | + |
| 239 | +async function main(argv, env = process.env) { |
| 240 | + const options = parseArgs(argv); |
| 241 | + if (options.help) { |
| 242 | + console.log('Usage: node scripts/generate-contributors-svg.mjs [--repo owner/name] [--out file]'); |
| 243 | + return; |
| 244 | + } |
| 245 | + const svg = await buildContributorsSvg({ |
| 246 | + repo: options.repo, |
| 247 | + token: env.GH_TOKEN || env.GITHUB_TOKEN || '', |
| 248 | + }); |
| 249 | + if (options.out) fs.writeFileSync(options.out, svg); |
| 250 | + else process.stdout.write(svg); |
| 251 | +} |
| 252 | + |
| 253 | +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { |
| 254 | + main(process.argv.slice(2)).catch((error) => { |
| 255 | + console.error(error.message); |
| 256 | + process.exit(1); |
| 257 | + }); |
| 258 | +} |
0 commit comments