first commit

This commit is contained in:
2025-04-14 19:27:23 +08:00
commit c27d535c02
18 changed files with 3336 additions and 0 deletions

19
src/constants.ts Normal file
View File

@@ -0,0 +1,19 @@
export const SVG_DIST_DIR_NAME = '.github-contributors'
export const SVG_STYLESHEETS = `<style>
text {
font-weight: 300;
font-size: 12px;
fill: #777777;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif;
}
image {
border-radius: 50%;
}
.contributor-link {
cursor: pointer;
}
.contributors-title {
font-weight: 500;
font-size: 20px;
}
</style>`

237
src/fetch.ts Normal file
View File

@@ -0,0 +1,237 @@
import { Octokit } from "@octokit/core"
import ora from 'ora'
import type { ContributorsInfo, ContributorsInfoMap } from './types'
/**
* Traverse all pages for data collection, and break when breakOn returns true.
*
* @param requestByPage Request function by page.
* @param breakOn Break condition function.
* @returns Data collection.
*/
async function traversePagesForCount(
requestByPage: (page: number) => Promise<any>,
breakOn?: (resp: any) => boolean,
) {
let page = 1;
const dataCollection: any[] = [];
while (true) {
const resp = await requestByPage(page);
if (resp.data.length === 0) {
break;
}
if (breakOn?.(resp)) {
dataCollection.push(...resp.data)
break;
}
page++
dataCollection.push(...resp.data)
}
return dataCollection
}
/**
* Fetch repo create time.
*
* @param octokit Octokit instance.
* @param owner Owner of the repo.
* @param repo Repo name.
* @returns Repo create time.
* @throws Error when fetching repo create time failed.
* @example
* const octokit = new Octokit({ auth: 'YOUR_TOKEN' })
* const repoCreateTime = await getRepoCreateTime(octokit, 'octokit', 'rest.js')
* console.log(repoCreateTime) // 2021-01-01T00:00:00.000Z
*/
async function getRepoCreateTime(octokit: Octokit, owner: string, repo: string) {
try {
const repoData = await octokit.request(
'GET /repos/{owner}/{repo}',
{
owner,
repo,
}
)
return new Date(repoData.data.created_at)
} catch (e) {
console.error(`Fetch repo create time error: ${e}`)
throw e
}
}
/**
* Fetch repos info from GitHub API.
*
* @param params Fetch repos info params.
* @returns Repos info.
*/
export async function fetchRepos(params: {
token: string,
owner: string,
}) {
const { token, owner } = params
const octokit = new Octokit({ auth: token })
const reposData: any[] = []
const loadingSpin = ora(`Fetching ${owner} repos...`).start()
// fetch repos infos
try {
const reposRespData = await traversePagesForCount(
(page) => octokit.request(
'GET /orgs/{owner}/repos{?type,page,per_page}',
{
owner,
type: 'public',
page,
per_page: '100',
}
)
)
reposData.push(...reposRespData)
loadingSpin.succeed(`Fetching ${owner} repos done`)
// "full_name": "octocat/Hello-World"
return reposData.map(repo => ({ owner, repo: repo.full_name.split('/')[1] }))
} catch (err) {
console.log(`Error: Fetching ${owner} repos failed! ${err}`)
}
}
/**
* Fetch contributors info from GitHub API.
*
* @param params Fetch contributors info params.
* @returns Contributors info map.
*/
export async function fetchContributorsInfo(params: {
token: string,
owner: string,
repo: string,
}) {
const { token, owner, repo } = params
const octokit = new Octokit({ auth: token })
const contributorsData: any[] = []
const loadingSpin = ora(`Fetching ${owner}/${repo} contributors...`).start()
// fetch contributors infos
try {
const contributorsRespData = await traversePagesForCount(
(page) => octokit.request(
'GET /repos/{owner}/{repo}/contributors{?anon,page,per_page}',
{
owner,
repo,
anon: true,
page,
per_page: '100',
}
),
)
contributorsData.push(...contributorsRespData)
loadingSpin.succeed(`Fetching ${owner}/${repo} contributors done`)
} catch (err) {
console.log(`Error: Fetching ${owner}/${repo} contributors failed! ${err}`)
}
// create a map for all contributors infos
const allContributorsInfos = new Map<string, ContributorsInfo>()
contributorsData.forEach(contributor => {
const [userName, avatarURL] = [contributor.login, contributor.avatar_url]
const userInfoByName = allContributorsInfos.get(userName)
if (!userInfoByName) {
allContributorsInfos.set(userName, {
avatarURL,
commitURLs: [],
})
}
})
// count commits for all contributors we got in the map now
await supplementContributorsCommits({
token,
repo,
owner,
contributorsMap: allContributorsInfos
})
return allContributorsInfos
}
/**
* Supplement contributors commits info to contributors map.
*
* @param params Supplement contributors commits info params.
* @returns void.
*/
export async function supplementContributorsCommits(params: {
token: string,
owner: string,
repo: string,
contributorsMap: ContributorsInfoMap,
}) {
const { token, owner, repo, contributorsMap } = params
const octokit = new Octokit({ auth: token })
const commitsData: any[] = []
const repoCreateTime = await getRepoCreateTime(
octokit,
owner,
repo
);
const loadingSpin = ora(`Fetching ${owner}/${repo} commits...`).start()
try {
const commitsRespData = await traversePagesForCount(
(page) => octokit.request(
'GET /repos/{owner}/{repo}/commits{?page,per_page,since}',
{
owner,
repo,
page,
per_page: '100',
}
),
(resp) => {
const commits = resp.data
if (commits.some((commit: any) => {
const { commit: { author: { date } } } = commit
return new Date(date).getTime() < repoCreateTime.getTime()
})) {
return true
}
return false
}
)
commitsData.push(...commitsRespData
.filter((commit: any) => Boolean(commit?.author?.login))
.map((commit: any) => {
const { author: { login: userName }, commit: { author: { date }, url } } = commit
return { userName, url, date }
})
)
loadingSpin.succeed(`Fetching ${owner}/${repo} commits done`)
} catch (err) {
console.log(`Error: Fetching ${owner}/${repo} contributors commits failed! ${err}`)
}
loadingSpin.start('Supplementing commits info to contributors map...')
commitsData
.filter(
commit => new Date(commit.date).getTime() > repoCreateTime.getTime()
)
.forEach(commitInfo => {
const { userName, url } = commitInfo
const foundUserInfoByName = contributorsMap.get(userName)
if (!foundUserInfoByName) {
return
}
const userInfoByName = contributorsMap.get(userName)!
if (!userInfoByName.commitURLs) {
userInfoByName.commitURLs = []
}
userInfoByName.commitURLs.push(url)
})
loadingSpin.succeed('Supplementing commits done')
}

99
src/main.ts Normal file
View File

@@ -0,0 +1,99 @@
import { program } from 'commander'
import { fetchRepos, fetchContributorsInfo } from './fetch'
import { checkContribsPersistence, saveContribsPersistence } from './persistence'
import { saveSVG as saveSVG } from './save-svg'
import { generateContributorsSVGFile } from './svg-codegen'
import { getRepoName } from './utils'
import type { CliOptions, RepoInfo, ContributorsInfo } from './types'
async function main() {
const { Github_token: defaultToken, Github_owner: defaultOwner } = process.env
const defaultRepoName = await getRepoName()
const GITHUBReg = /https:\/\/github.com\/([\w\-_]+)\/([\w\-_]+)/
let urlInfo = null
program
.name('gh-contrib-svg')
.arguments('[url]')
.option('-t, --token <token>', 'Personal GitHub token', defaultToken)
.option('-o, --owner <owner>', 'Repo owner name', defaultOwner)
.option('-r, --repo <repo>', 'GitHub repo path', defaultRepoName)
.option('-s, --size <size>', 'Single avatar block size (pixel)', "120")
.option('-w, --width <width>', 'Output image width (pixel)', "1000")
.option('-c, --count <count>', 'Avatar count in one line', "8")
.action((url) => {
if (!url) return
const match = url.match(GITHUBReg)
if (!match)
throw new Error('Invalid GitHub Repo URL')
const [_, owner, repo] = match
urlInfo = {
owner,
repo
}
})
.parse(process.argv)
const options = Object.assign(program.opts(), urlInfo)
const { token, repo, owner, size: avatarBlockSize, width, count: lineCount } = options as CliOptions
if (token && owner) {
let repos: RepoInfo[] = []
let identifier = 'contributor_'
if (repo) {
// fetch <owner>/<repo> contributors info
identifier += repo;
repos.push({ owner, repo })
} else {
// fetch <owner> contributors info
identifier += owner;
const ownerRepos = await fetchRepos({ token, owner })
if (!ownerRepos || ownerRepos.length === 0) {
throw new Error('No repos found')
}
repos = [...repos, ...ownerRepos]
}
const startTime = performance.now()
const allContributorsInfos = new Map<string, ContributorsInfo>()
for (const { owner, repo } of repos) {
const contributorsInfos = await fetchContributorsInfo({ token, repo, owner });
contributorsInfos.forEach((info, username) => {
allContributorsInfos.set(username, info);
});
}
// sort contributors by commit count and pull request count
const sortedContributors = [...allContributorsInfos.entries()]
.sort(([, userInfoA], [, userInfoB]) => {
const countA = userInfoA.commitURLs.length
const countB = userInfoB.commitURLs.length
return countB - countA
})
const contribUserNames = sortedContributors.map(([userName,]) => userName);
checkContribsPersistence(
contribUserNames,
identifier
)
const svgString = await generateContributorsSVGFile({
imgWidth: Number(width),
blockSize: Number(avatarBlockSize),
lineCount: Number(lineCount),
}, new Map(sortedContributors))
saveSVG(svgString, identifier);
saveContribsPersistence(
contribUserNames,
identifier
)
const endTime = performance.now()
console.log(`Time cost: ${Math.round((endTime - startTime) / 1000)}s`)
} else {
if (!token)
throw new Error('Personal GitHub token is required')
if (!owner)
throw new Error('GitHub repo path is required')
}
}
main()

34
src/persistence.ts Normal file
View File

@@ -0,0 +1,34 @@
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
import path from 'node:path'
import { SVG_DIST_DIR_NAME } from './constants'
const distDir = path.resolve(process.cwd(), SVG_DIST_DIR_NAME)
export function checkContribsPersistence(contribUserNames: string[], identifier: string) {
if (!existsSync(distDir)) {
mkdirSync(distDir)
}
const distDataFilePath = path.join(distDir, `${identifier}.json`)
if (!existsSync(distDataFilePath)) {
return
}
try {
const persistenceDataJsonStr = String(readFileSync(distDataFilePath))
if (JSON.stringify(contribUserNames) === persistenceDataJsonStr) {
console.log('\nThere are no new contributors.\n')
process.exit()
}
} catch (err) {
console.log(`\nError: check contribs persistence failed ! ${err}`)
process.exit()
}
}
export function saveContribsPersistence(contribUserNames: string[], identifier: string) {
const distDataFilePath = path.join(distDir, `${identifier}.json`)
writeFileSync(
distDataFilePath,
JSON.stringify(contribUserNames)
)
}

18
src/save-svg.ts Normal file
View File

@@ -0,0 +1,18 @@
import path from 'node:path';
import { existsSync, mkdirSync, writeFileSync } from 'node:fs';
import { SVG_DIST_DIR_NAME } from './constants';
export function saveSVG(svgString: string, identifier: string) {
const distDir = path.join(process.cwd(), SVG_DIST_DIR_NAME)
if (!existsSync(distDir)) {
mkdirSync(distDir)
}
const optimizedSvgString = svgString.replace('\n', '').replace(/\s+/g, ' ')
const distFilePath = path.join(distDir, `${identifier}.svg`)
console.log(`Write SVG file to ${distFilePath}`)
writeFileSync(
distFilePath,
optimizedSvgString,
{ encoding: 'utf-8' }
)
}

150
src/svg-codegen.ts Normal file
View File

@@ -0,0 +1,150 @@
import ora from 'ora'
import { $fetch } from 'ofetch'
import sharp from 'sharp'
import { SVG_STYLESHEETS } from './constants'
import type { ContributorsInfo } from './types'
// @ts-expect-error missing types
import imageDataURI from 'image-data-uri'
function toBuffer(ab: ArrayBuffer) {
const buf = Buffer.alloc(ab.byteLength)
const view = new Uint8Array(ab)
for (let i = 0; i < buf.length; ++i)
buf[i] = view[i]
return buf
}
async function round(image: string | ArrayBuffer, radius = 0.5, size = 100) {
const rect = Buffer.from(
`<svg><rect x="0" y="0" width="${size}" height="${size}" rx="${size * radius}" ry="${size * radius}"/></svg>`,
)
return await sharp(typeof image === 'string' ? image : toBuffer(image))
.resize(size, size, { fit: sharp.fit.cover })
.composite([{
blend: 'dest-in',
input: rect,
density: 72,
}])
.png({ quality: 80, compressionLevel: 8 })
.toBuffer()
}
async function getAvatarDataURI(avatarURL: string) {
const avatarData = await $fetch(avatarURL, { responseType: 'arrayBuffer' })
const avatarDataURL = await imageDataURI.encode(
await round(avatarData, 0.5, 50), 'PNG'
)
return avatarDataURL
}
async function getImgSVGElement(params: {
imgX: number,
imgY: number,
imgSize: number,
avatarURL: string,
}) {
const { imgX, imgY, imgSize, avatarURL } = params
try {
return `<image x="${imgX}" y="${imgY}" width="${imgSize}" height="${imgSize}" xlink:href="${avatarURL}" clip-path="url(#avatarClipPath)" />`
} catch (e) {
console.error(`Fetch user avatar error: ${e}`)
throw e
}
}
const getContributorSVGTitle = (centerX: number, yStart: number) => {
return `<text class="contributors-title" x="${centerX}" y="${yStart}" text-anchor="middle">Contributors</text>`
}
const getNameTextSVGElement = (params: { textX: number; textY: number; text: string }) => {
const { textX, textY, text } = params
return `<text x="${textX}" y="${textY}" text-anchor="middle" class="contributor-name" fill="currentColor">${text}</text>`
}
const getAnchorWrapSVGElement = (userName: string, innerHTML: string) => {
const githubProfileURL = `https://github.com/${userName}`
return `<a class="contributor-link" xlink:href="${githubProfileURL}" target="_blank" id="${userName}">\n ${innerHTML}\n</a>\n`
}
const getSVGHeader = (imgWidth: number, imgHeight: number) => {
const clipPathDefs = '\n <defs><clipPath id="avatarClipPath" clipPathUnits="objectBoundingBox"><circle cx="0.5" cy=".5" r=".5"/></clipPath></defs>\n'
return `<svg
xmlns="http://www.w3.org/2000/svg"
xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 ${imgWidth} ${imgHeight}" width="${imgWidth}" height="${imgHeight}"
>` + clipPathDefs
}
export async function generateContributorsSVGFile(
params: {
imgWidth: number,
blockSize: number,
lineCount: number,
},
contributorsMap: Map<string, ContributorsInfo>
) {
const { imgWidth, blockSize, lineCount } = params
if (lineCount % 2 !== 0) {
throw Error('[Generating SVG] line count must be even')
}
const generatingSvgSpin = ora('Generating SVG file...').start()
// Hint: These constants may be able to be customized by config params
const Y_START = 40
const TITLE_FONT_SIZE = 20
const TEXT_FONT_SIZE = 14
const Y_CONTENT_START = Y_START + TITLE_FONT_SIZE
const MARGIN = 18
const CENTER = imgWidth / 2
const AVATAR_SIZE = blockSize * 0.625
const SPACE = (blockSize - AVATAR_SIZE) / 2
const startX = CENTER - ((lineCount / 2) * blockSize) + SPACE
const getTextX = (imgX: number) => imgX + (AVATAR_SIZE / 2)
// let svgContent = `
// ${SVG_STYLESHEETS}
// ${getContributorSVGTitle(CENTER, Y_START)}
// `;
let svgContent = `
${SVG_STYLESHEETS}
`;
// Convert all contributors' avatar URL to DataURI
await Promise.all(
Array.from(contributorsMap.entries())
.map(async ([userName, contribInfo]) => {
const avatarDataURI: string = await getAvatarDataURI(contribInfo.avatarURL)
contributorsMap.get(userName)!.avatarURL = avatarDataURI
})
)
const contributorsIterator = contributorsMap.entries()
let contributorEntry = contributorsIterator.next()
let countForLine = 0
let lineIndex = 0
while (!contributorEntry.done) {
const [userName, contributorInfo] = contributorEntry.value
const imgX = startX + (countForLine * blockSize)
const imgY = Y_CONTENT_START + MARGIN + (lineIndex * (AVATAR_SIZE + TEXT_FONT_SIZE + MARGIN))
const imgSVGElement = await getImgSVGElement({
imgX, imgY,
imgSize: AVATAR_SIZE,
avatarURL: contributorInfo.avatarURL,
})
const textX = getTextX(imgX)
const textY = imgY + AVATAR_SIZE + MARGIN
const nameTextSVGElement = getNameTextSVGElement({
textX, textY, text: userName,
})
const anchorWrapSVGElement = getAnchorWrapSVGElement(userName, `${imgSVGElement}\n${nameTextSVGElement}`)
svgContent += anchorWrapSVGElement
countForLine += 1
if (countForLine === lineCount) {
countForLine = 0
lineIndex += 1
}
contributorEntry = contributorsIterator.next()
}
svgContent = `${getSVGHeader(imgWidth, ((lineIndex + 1) * blockSize) + MARGIN + Y_START)}\n${svgContent}\n</svg>`
generatingSvgSpin.succeed('Generated SVG content string.')
return svgContent
}

17
src/types.ts Normal file
View File

@@ -0,0 +1,17 @@
export interface CliOptions {
token: string // Github access token
owner: string
repo: string
size: string
width: string
count: string
}
export interface ContributorsInfo {
avatarURL: string,
commitURLs: string[]
}
export type ContributorsInfoMap = Map<string, ContributorsInfo>
export interface RepoInfo {
owner: string,
repo: string
}

25
src/utils.ts Normal file
View File

@@ -0,0 +1,25 @@
import path from 'path'
import fsp from 'fs/promises'
export function getDefaultValue(name: string) {
return process.env[name]
}
export async function getRepoName() {
try {
const { repository, name } = JSON.parse(await fsp.readFile(path.resolve(process.cwd(), './package.json'), 'utf-8'))
if (!repository)
return name
const url = repository?.url ?? repository
// "git + git@github.com:xx/xx.git"
// "https://github.com/tj/commander.js.git"
const match = url.match(/github.com[:\/]?[\w\-_]+\/([\w\-_]+)/)
if (match) {
return match[1]
}
} catch (e) {
}
}