mirror of
https://gitee.com/Charles7c/github-contributor-svg-generator.git
synced 2025-09-03 14:57:14 +08:00
first commit
This commit is contained in:
50
.github/workflows/update-continew-admin-contribs.yaml
vendored
Normal file
50
.github/workflows/update-continew-admin-contribs.yaml
vendored
Normal file
@@ -0,0 +1,50 @@
|
||||
name: update-continew-admin-contributors-svg
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
logLevel:
|
||||
description: 'Log level'
|
||||
required: true
|
||||
default: 'warning'
|
||||
type: choice
|
||||
options:
|
||||
- info
|
||||
- warning
|
||||
- debug
|
||||
# Schedule the interval of the checks.
|
||||
schedule:
|
||||
- cron: 00 17 * * *
|
||||
|
||||
jobs:
|
||||
update-svg:
|
||||
name: Update contributors SVG
|
||||
runs-on: macos-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 7
|
||||
|
||||
- name: Set node version to 16.x
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: 16.x
|
||||
registry-url: https://registry.npmjs.org/
|
||||
cache: "pnpm"
|
||||
|
||||
- name: Install deps
|
||||
run: pnpm install
|
||||
|
||||
- name: Run generation script
|
||||
run: pnpm tsx src/main.ts -t ${{ secrets.TOKEN }} -o continew-org -r continew-admin
|
||||
|
||||
- name: Update image
|
||||
run: |
|
||||
git config user.email "charles7c@126.com"
|
||||
git config user.name "Charles7c"
|
||||
git add .
|
||||
git commit -m "workflow: update image" && git push origin main || exit 0
|
62
.github/workflows/update-continew-contribs.yaml
vendored
Normal file
62
.github/workflows/update-continew-contribs.yaml
vendored
Normal file
@@ -0,0 +1,62 @@
|
||||
name: update-continew-contributors-svg
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
logLevel:
|
||||
description: 'Log level'
|
||||
required: true
|
||||
default: 'warning'
|
||||
type: choice
|
||||
options:
|
||||
- info
|
||||
- warning
|
||||
- debug
|
||||
# Schedule the interval of the checks.
|
||||
schedule:
|
||||
- cron: 30 17 * * *
|
||||
|
||||
jobs:
|
||||
update-svg:
|
||||
name: Update contributors SVG
|
||||
runs-on: macos-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 7
|
||||
|
||||
- name: Set node version to 16.x
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: 16.x
|
||||
registry-url: https://registry.npmjs.org/
|
||||
cache: "pnpm"
|
||||
|
||||
- name: Install deps
|
||||
run: pnpm install
|
||||
|
||||
- name: Run generation script
|
||||
run: pnpm tsx src/main.ts -t ${{ secrets.TOKEN }} -o continew-org
|
||||
|
||||
- name: Copy
|
||||
uses: appleboy/scp-action@v0.1.7
|
||||
with:
|
||||
host: ${{ secrets.SERVER_HOST }}
|
||||
port: ${{ secrets.SERVER_PORT }}
|
||||
username: ${{ secrets.SERVER_USERNAME }}
|
||||
password: ${{ secrets.SERVER_PASSWORD }}
|
||||
source: ./.github-contributors/*
|
||||
target: ${{ secrets.SERVER_PATH }}
|
||||
strip_components: 1
|
||||
|
||||
- name: Update image
|
||||
run: |
|
||||
git config user.email "charles7c@126.com"
|
||||
git config user.name "Charles7c"
|
||||
git add .
|
||||
git commit -m "workflow: update image" && git push origin main || exit 0
|
||||
|
50
.github/workflows/update-continew-starter-contribs.yaml
vendored
Normal file
50
.github/workflows/update-continew-starter-contribs.yaml
vendored
Normal file
@@ -0,0 +1,50 @@
|
||||
name: update-continew-starter-contributors-svg
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
logLevel:
|
||||
description: 'Log level'
|
||||
required: true
|
||||
default: 'warning'
|
||||
type: choice
|
||||
options:
|
||||
- info
|
||||
- warning
|
||||
- debug
|
||||
# Schedule the interval of the checks.
|
||||
schedule:
|
||||
- cron: 00 17 * * *
|
||||
|
||||
jobs:
|
||||
update-svg:
|
||||
name: Update contributors SVG
|
||||
runs-on: macos-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 7
|
||||
|
||||
- name: Set node version to 16.x
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: 16.x
|
||||
registry-url: https://registry.npmjs.org/
|
||||
cache: "pnpm"
|
||||
|
||||
- name: Install deps
|
||||
run: pnpm install
|
||||
|
||||
- name: Run generation script
|
||||
run: pnpm tsx src/main.ts -t ${{ secrets.TOKEN }} -o continew-org -r continew-starter
|
||||
|
||||
- name: Update image
|
||||
run: |
|
||||
git config user.email "charles7c@126.com"
|
||||
git config user.name "Charles7c"
|
||||
git add .
|
||||
git commit -m "workflow: update image" && git push origin main || exit 0
|
3
.gitignore
vendored
Normal file
3
.gitignore
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
node_modules
|
||||
.DS_Store
|
||||
lib
|
34
README.md
Normal file
34
README.md
Normal file
@@ -0,0 +1,34 @@
|
||||
# github-contributor-svg-generator
|
||||
|
||||
## 简介
|
||||
|
||||
生成 GitHub 组织或仓库贡献者 SVG 图,基于 GitHub 的 `/repos/{owner}/{repo}/contributors` 接口获取贡献者数据,并生成贡献者 SVG 图。
|
||||
|
||||
本项目 Fork 自 [ShenQingchuan/github-contributor-svg-generator](https://github.com/ShenQingchuan/github-contributor-svg-generator)。
|
||||
|
||||
## 和原项目区别
|
||||
|
||||
原项目是根据仓库 Pull Request 及其相关用户的 Commit 数量制作贡献者 SVG 图,不会统计没有提交过 Pull Request 的用户。
|
||||
|
||||
本项目是基于 GitHub 的 [/repos/{owner}/{repo}/contributors](https://docs.github.com/zh/rest/repos/repos?apiVersion=2022-11-28#list-repository-contributors) 接口获取贡献者数据生成贡献者 SVG 图,并且支持生成组织贡献者 SVG 图(汇总组织下所有仓库贡献者前 100 名)。
|
||||
|
||||
## 使用方法
|
||||
|
||||
```bash
|
||||
# 获取 GitHub Token
|
||||
略
|
||||
|
||||
# 克隆项目
|
||||
git clone https://github.com/charles7c/github-contributor-svg-generator.git
|
||||
|
||||
# 安装依赖
|
||||
pnpm install
|
||||
|
||||
# 运行1:生成 continew-org/continew-admin 仓库贡献者 SVG 图(替换下方 <GitHub Token> 为真实值)
|
||||
pnpm tsx src/main.ts -t <GitHub Token> -o continew-org -r continew-admin
|
||||
|
||||
# 运行2:生成 continew-org 贡献者 SVG 图(替换下方 <GitHub Token> 为真实值)
|
||||
pnpm tsx src/main.ts -t <GitHub Token> -o continew-org
|
||||
```
|
||||
|
||||
**提示:** 执行完成后会在你项目的根目录创建一个 `.github-contributors` 文件夹来存放 SVG 文件。
|
37
package.json
Normal file
37
package.json
Normal file
@@ -0,0 +1,37 @@
|
||||
{
|
||||
"name": "github-contributor-svg-generator",
|
||||
"bin": {
|
||||
"gh-contrib-svg": "./lib/main.js"
|
||||
},
|
||||
"main": "./lib/main.js",
|
||||
"module": "./lib/main.js",
|
||||
"files": [
|
||||
"lib",
|
||||
"README.md"
|
||||
],
|
||||
"version": "1.0.0",
|
||||
"description": "Generate Contributors SVG for github repo.",
|
||||
"type": "module",
|
||||
"author": "Charles7c",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
"build": "tsup",
|
||||
"prepublish": "tsup"
|
||||
},
|
||||
"dependencies": {
|
||||
"@octokit/core": "^4.0.4",
|
||||
"commander": "^9.4.0",
|
||||
"image-data-uri": "^2.0.1",
|
||||
"node-fetch": "^3.2.9",
|
||||
"ofetch": "^1.0.1",
|
||||
"ora": "^6.1.2",
|
||||
"sharp": "^0.31.3",
|
||||
"tsx": "^3.8.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^18.6.2",
|
||||
"bumpp": "^9.1.0",
|
||||
"tsup": "^6.7.0",
|
||||
"typescript": "^5.0.2"
|
||||
}
|
||||
}
|
2474
pnpm-lock.yaml
generated
Normal file
2474
pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
19
src/constants.ts
Normal file
19
src/constants.ts
Normal 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
237
src/fetch.ts
Normal 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
99
src/main.ts
Normal 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
34
src/persistence.ts
Normal 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
18
src/save-svg.ts
Normal 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
150
src/svg-codegen.ts
Normal 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
17
src/types.ts
Normal 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
25
src/utils.ts
Normal 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) {
|
||||
|
||||
}
|
||||
}
|
15
tsconfig.json
Normal file
15
tsconfig.json
Normal file
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "esnext", /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', 'ES2018', 'ES2019', 'ES2020', or 'ESNEXT'. */
|
||||
"module": "esnext", /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', 'es2020', or 'ESNext'. */
|
||||
"strict": true, /* Enable all strict type-checking options. */
|
||||
"esModuleInterop": true, /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */
|
||||
"skipLibCheck": true, /* Skip type checking of declaration files. */
|
||||
"types": [
|
||||
"node", /* Include NodeJS-specific types. */
|
||||
], /* Type declaration files to be included in compilation. */
|
||||
"moduleResolution": "node", /* Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */
|
||||
"outDir": "./lib", /* Output directory for generated declaration files. */
|
||||
"forceConsistentCasingInFileNames": true /* Disallow inconsistently-cased references to the same file. */
|
||||
}
|
||||
}
|
11
tsup.config.ts
Normal file
11
tsup.config.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { defineConfig } from 'tsup'
|
||||
|
||||
export default defineConfig({
|
||||
entry: ['./src/*'],
|
||||
format: ['esm'],
|
||||
target: 'node16',
|
||||
outDir: "./lib",
|
||||
clean: true,
|
||||
dts: false,
|
||||
splitting: false,
|
||||
})
|
Reference in New Issue
Block a user