Skip to content

Commit 1ed35b6

Browse files
authored
Merge pull request #639 from kingsuper195/feat/contributors
feat: add script to build contributors.md
2 parents e549b9e + c721aa0 commit 1ed35b6

File tree

11 files changed

+2196
-0
lines changed

11 files changed

+2196
-0
lines changed

_scripts/.htaccess

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Deny from all

_scripts/contributors/.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
node_modules
2+
keys.txt

_scripts/contributors/README.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
# Contributors
2+
Generate the /about/contributors.md file from contributors on Github and translators on crowdin.
3+
4+
## Usage
5+
```sh
6+
node . --crowdin-key <crowdin-key> --github-key <github-key>
7+
```
8+
Run `node . --help` for more
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
/*
2+
* Keyman is copyright (C) SIL Global. MIT License.
3+
*
4+
* Created by pbdurdin on 2026-01-18
5+
*
6+
* Generate contributors.md
7+
*/
8+
import { Command } from "commander";
9+
import * as fs from "node:fs";
10+
import { getGithub, genGithub } from "./github.mjs";
11+
import { getCrowdin, genCrowdin } from "./crowdin.mjs";
12+
13+
// Parse CLI input
14+
const program = new Command();
15+
program.name("node .")
16+
.description("Script to generate https://keyman.com/about/contributors")
17+
.version("0.1.0")
18+
.option("-o, --output <filename>", "output file", "contributors.md")
19+
.option("-g, --github-key <key>", "github api authentication key (https://github.com/settings/tokens)")
20+
.option("-c, --crowdin-key <key>", "crowdin api authentication key (https://crowdin.com/setting#api-key)")
21+
.action(main);
22+
program.parse(process.argv);
23+
24+
async function main() {
25+
// Header for the markdown file. Can include explanation, title, etc.
26+
let headers = `
27+
---
28+
title: Keyman Contributors
29+
---
30+
<div><style>@import './contributors.css';</style></div>
31+
`;
32+
33+
let githubSeg = null;
34+
// If the user provided a key for github
35+
if (program.opts().githubKey) {
36+
// Fetch a list users that have made contributions to "keymanapp" on github, transform it, then generate markdown.
37+
let githubData = await getGithub(program.opts().githubKey);
38+
let [gMajor, gMinor] = genGithub(githubData);
39+
githubSeg = genMarkdownSegment("Contributors", gMajor, gMinor);
40+
}
41+
42+
let crowdinSeg = null;
43+
if (program.opts().crowdinKey) {
44+
// Fetch a list users that have made contributions to "keyman" on crowdin, transform it, then generate markdown.
45+
let crowdinData = await getCrowdin(program.opts().crowdinKey);
46+
let [cMajor, cMinor] = genCrowdin(crowdinData);
47+
crowdinSeg = genMarkdownSegment("Translators", cMajor, cMinor);
48+
}
49+
50+
// To the users input file for output (default: contributors.md), write a join of the header, the github segment.
51+
fs.writeFileSync(program.opts().output, `${headers}\n${githubSeg != null ? githubSeg : ""}\n${crowdinSeg != null ? crowdinSeg : ""}`);
52+
}
53+
54+
function genMarkdownSegment(name, major, minor) {
55+
// Initiate the markdown variable as a level 2 header of the section's name.
56+
let markDown = `## ${name}\n`;
57+
58+
if (major.length > 0) {
59+
// Add the Major contributors, one at a time
60+
if (minor.length > 0) {
61+
markDown += `### Major ${name}\n`;
62+
}
63+
major.forEach((user) => {
64+
markDown += `[<img class='contributor-major' src="${user.avatar_url}" alt="${user.login}" width="50"/>](${user.html_url} "${user.login}") `;
65+
markDown += `[${user.login}](${user.html_url})\n\n`;
66+
});
67+
}
68+
69+
if (minor.length > 0) {
70+
// Add the "Minor" contributors, one at a time
71+
if (major.length > 0) {
72+
markDown += `### Other ${name}\n`;
73+
}
74+
minor.forEach((user) => {
75+
markDown += `[<img class='contributor-minor' src="${user.avatar_url}" alt="${user.login}" width="50"/>](${user.html_url} "${user.login}") `;
76+
});
77+
}
78+
return markDown;
79+
}

_scripts/contributors/crowdin.mjs

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
/*
2+
* Keyman is copyright (C) SIL Global. MIT License.
3+
*
4+
* Created by pbdurdin on 2026-01-18
5+
*
6+
* Generate crowdin section of contributors.md
7+
*/
8+
import { Client } from "@crowdin/crowdin-api-client";
9+
10+
// Initalize an empty crowdin api variable.
11+
let crowdin = null;
12+
const crowdinId = 386703;
13+
14+
export async function getCrowdin(crowdinKey) {
15+
// Set the aforementioned Crowdin vegtable, with the provided key.
16+
crowdin = new Client({
17+
token: crowdinKey,
18+
});
19+
20+
console.log("Generating Crowdin report");
21+
22+
// Generate a Crowdin report
23+
const crowdata = await crowdin.reportsApi.generateReport(crowdinId, { name: "top-members", schema: { format: "json" } });
24+
if (crowdata.data.status !== "finished") {
25+
// Wait for the report to complete it's generation
26+
console.log("Waiting");
27+
let cdc = await crowdin.reportsApi.checkReportStatus(crowdinId, crowdata.data.identifier);
28+
while (cdc.data.status !== "finished") {
29+
await new Promise(resolve => setTimeout(resolve, 1000));
30+
cdc = await crowdin.reportsApi.checkReportStatus(crowdinId, crowdata.data.identifier);
31+
}
32+
}
33+
34+
// Fetch generated report.
35+
console.log("Downloading Crowdin report");
36+
const crowdmembers = await crowdin.reportsApi.downloadReport(crowdinId, crowdata.data.identifier);
37+
38+
const data = await (await fetch(crowdmembers.data.url)).json();
39+
return data;
40+
}
41+
42+
export function genCrowdin(data) {
43+
// Modify crowdin data to appear like the github data
44+
const crowdinMembers = data.data.filter(member => member.translated > 10).map(member => ({
45+
login: member.user.username,
46+
avatar_url: member.user.avatarUrl,
47+
html_url: `https://crowdin.com/profile/${member.user.username}`,
48+
contributions: member.translated,
49+
}));
50+
51+
// Sort the users by name
52+
crowdinMembers.sort((a, b) => a.login.localeCompare(b.login));
53+
return [[], crowdinMembers];
54+
}
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import js from "@eslint/js";
2+
import globals from "globals";
3+
import { defineConfig } from "eslint/config";
4+
import stylistic from "@stylistic/eslint-plugin";
5+
6+
export default defineConfig([
7+
{ files: ["**/*.{js,mjs,cjs}"], plugins: { js }, extends: ["js/recommended"], languageOptions: { globals: globals.node } },
8+
stylistic.configs.customize({
9+
indent: 2,
10+
quotes: "double",
11+
semi: true,
12+
jsx: false,
13+
}),
14+
]);

_scripts/contributors/github.mjs

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
/*
2+
* Keyman is copyright (C) SIL Global. MIT License.
3+
*
4+
* Created by pbdurdin on 2026-01-18
5+
*
6+
* Generate github section of contributors.md
7+
*/
8+
import { Octokit } from "octokit";
9+
import { paginateRest } from "@octokit/plugin-paginate-rest";
10+
11+
// Initiatlize an empty Octokit variable, to be set later.
12+
const OctokitInit = Octokit.plugin(paginateRest);
13+
let octokit = null;
14+
const githubOrgName = "keymanapp";
15+
16+
export async function getGithub(githubKey) {
17+
// Set the aforementioned Octokit variable, with the provided key.
18+
octokit = new OctokitInit({
19+
auth: githubKey,
20+
});
21+
22+
// Get a list of every repo in the keymanapp github organization
23+
let allRepos = await getReposByOrg(githubOrgName);
24+
25+
// Get the data of each of those repositories
26+
let allRepoData = await getAllRepos(allRepos);
27+
return allRepoData;
28+
}
29+
30+
export function genGithub(repo) {
31+
const MAJOR_CONTRIBUTION_THRESHOLD = 100;
32+
33+
// Initialize empty arrays, to be added to later
34+
let major = [];
35+
let minor = [];
36+
let all = [];
37+
38+
// For each item in the provided data,
39+
repo.forEach((user) => {
40+
// ... check for bots, and skip over if one is found,
41+
if (/^(keyman-server|keyman-status|.+\[bot\])$/.test(user.login)) {
42+
return;
43+
}
44+
45+
// ... if we have not already added this user to the user list, add them,
46+
if (!all.map(user => user.login).includes(user.login)) {
47+
all.push(user);
48+
return;
49+
}
50+
51+
// ... Otherwise, increase their contributions score.
52+
let i = all.findIndex(oldUser => oldUser.login == user.login);
53+
all[i].contributions += user.contributions;
54+
});
55+
56+
// Sort into the major and minor categories
57+
all.forEach((user) => {
58+
if (user.contributions > MAJOR_CONTRIBUTION_THRESHOLD) {
59+
major.push(user);
60+
}
61+
else {
62+
minor.push(user);
63+
};
64+
});
65+
66+
// Sort the major and minor categories by contributions
67+
major.sort((a, b) => a.login.localeCompare(b.login));
68+
minor.sort((a, b) => a.login.localeCompare(b.login));
69+
return [major, minor];
70+
}
71+
72+
async function getReposByOrg(org) {
73+
// Fetch a list of all repositories belonging to this organization.
74+
75+
console.log("Retrieving list of Repositories");
76+
let repos = await octokit.paginate(`GET /orgs/${org}/repos`, {
77+
org,
78+
per_page: 100,
79+
type: "sources",
80+
headers: {
81+
"X-GitHub-Api-Version": "2022-11-28",
82+
},
83+
});
84+
85+
// Map the repository data to make mearly a list of names.
86+
return repos.map(repo => repo.name);
87+
}
88+
89+
async function getAllRepos(allRepos) {
90+
// Fetch the data for the given list of repositories.
91+
92+
let allRepoData = [];
93+
94+
// Using for instead of foreach, as foreach does not support asynchrony.
95+
for (let repo of allRepos) {
96+
// Get data, then concat it to all previously fetched data.
97+
console.log(`Fetching repository contributors: ${repo}`);
98+
let repoData = await getRepo(repo);
99+
allRepoData = allRepoData.concat(repoData);
100+
};
101+
return allRepoData;
102+
}
103+
104+
async function getRepo(repoName) {
105+
// Fetch data for repo of the inputted name.
106+
let repo = await octokit.paginate(`GET /repos/${githubOrgName}/${repoName}/contributors`, {
107+
owner: "keymanapp",
108+
repo: repoName,
109+
per_page: 100,
110+
headers: {
111+
"X-GitHub-Api-Version": "2022-11-28",
112+
},
113+
});
114+
return repo;
115+
}

0 commit comments

Comments
 (0)