@dytsou/github-readme-stats 1.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/api/gist.js +98 -0
- package/api/index.js +146 -0
- package/api/pin.js +114 -0
- package/api/status/pat-info.js +193 -0
- package/api/status/up.js +129 -0
- package/api/top-langs.js +163 -0
- package/api/wakatime.js +129 -0
- package/package.json +102 -0
- package/readme.md +412 -0
- package/src/calculateRank.js +87 -0
- package/src/cards/gist.js +151 -0
- package/src/cards/index.js +4 -0
- package/src/cards/repo.js +199 -0
- package/src/cards/stats.js +607 -0
- package/src/cards/top-languages.js +968 -0
- package/src/cards/types.d.ts +67 -0
- package/src/cards/wakatime.js +482 -0
- package/src/common/Card.js +294 -0
- package/src/common/I18n.js +41 -0
- package/src/common/access.js +69 -0
- package/src/common/api-utils.js +221 -0
- package/src/common/blacklist.js +10 -0
- package/src/common/cache.js +153 -0
- package/src/common/color.js +194 -0
- package/src/common/envs.js +15 -0
- package/src/common/error.js +84 -0
- package/src/common/fmt.js +90 -0
- package/src/common/html.js +43 -0
- package/src/common/http.js +24 -0
- package/src/common/icons.js +63 -0
- package/src/common/index.js +13 -0
- package/src/common/languageColors.json +651 -0
- package/src/common/log.js +14 -0
- package/src/common/ops.js +124 -0
- package/src/common/render.js +261 -0
- package/src/common/retryer.js +97 -0
- package/src/common/worker-adapter.js +148 -0
- package/src/common/worker-env.js +48 -0
- package/src/fetchers/gist.js +114 -0
- package/src/fetchers/repo.js +118 -0
- package/src/fetchers/stats.js +350 -0
- package/src/fetchers/top-languages.js +192 -0
- package/src/fetchers/types.d.ts +118 -0
- package/src/fetchers/wakatime.js +109 -0
- package/src/index.js +2 -0
- package/src/translations.js +1105 -0
- package/src/worker.ts +116 -0
- package/themes/README.md +229 -0
- package/themes/index.js +467 -0
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
|
|
3
|
+
import { retryer } from "../common/retryer.js";
|
|
4
|
+
import { logger } from "../common/log.js";
|
|
5
|
+
import { excludeRepositories } from "../common/envs.js";
|
|
6
|
+
import { CustomError, MissingParamError } from "../common/error.js";
|
|
7
|
+
import { wrapTextMultiline } from "../common/fmt.js";
|
|
8
|
+
import { request } from "../common/http.js";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Top languages fetcher object.
|
|
12
|
+
*
|
|
13
|
+
* @param {any} variables Fetcher variables.
|
|
14
|
+
* @param {string} token GitHub token.
|
|
15
|
+
* @returns {Promise<import("axios").AxiosResponse>} Languages fetcher response.
|
|
16
|
+
*/
|
|
17
|
+
const fetcher = (variables, token) => {
|
|
18
|
+
return request(
|
|
19
|
+
{
|
|
20
|
+
query: `
|
|
21
|
+
query userInfo($login: String!) {
|
|
22
|
+
user(login: $login) {
|
|
23
|
+
# fetch only owner repos & not forks
|
|
24
|
+
repositories(ownerAffiliations: OWNER, isFork: false, first: 100) {
|
|
25
|
+
nodes {
|
|
26
|
+
name
|
|
27
|
+
languages(first: 10, orderBy: {field: SIZE, direction: DESC}) {
|
|
28
|
+
edges {
|
|
29
|
+
size
|
|
30
|
+
node {
|
|
31
|
+
color
|
|
32
|
+
name
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
`,
|
|
41
|
+
variables,
|
|
42
|
+
},
|
|
43
|
+
{
|
|
44
|
+
Authorization: `token ${token}`,
|
|
45
|
+
},
|
|
46
|
+
);
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* @typedef {import("./types").TopLangData} TopLangData Top languages data.
|
|
51
|
+
*/
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Fetch top languages for a given username.
|
|
55
|
+
*
|
|
56
|
+
* @param {string} username GitHub username.
|
|
57
|
+
* @param {string[]} exclude_repo List of repositories to exclude.
|
|
58
|
+
* @param {number} size_weight Weightage to be given to size.
|
|
59
|
+
* @param {number} count_weight Weightage to be given to count.
|
|
60
|
+
* @returns {Promise<TopLangData>} Top languages data.
|
|
61
|
+
*/
|
|
62
|
+
const fetchTopLanguages = async (
|
|
63
|
+
username,
|
|
64
|
+
exclude_repo = [],
|
|
65
|
+
size_weight = 1,
|
|
66
|
+
count_weight = 0,
|
|
67
|
+
) => {
|
|
68
|
+
if (!username) {
|
|
69
|
+
throw new MissingParamError(["username"]);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const res = await retryer(fetcher, { login: username });
|
|
73
|
+
|
|
74
|
+
// Check if response has expected structure
|
|
75
|
+
if (!res || !res.data) {
|
|
76
|
+
logger.error("Invalid response structure:", res);
|
|
77
|
+
throw new CustomError(
|
|
78
|
+
"Invalid response from GitHub API.",
|
|
79
|
+
CustomError.GRAPHQL_ERROR,
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
if (res.data.errors) {
|
|
84
|
+
logger.error(res.data.errors);
|
|
85
|
+
if (res.data.errors[0].type === "NOT_FOUND") {
|
|
86
|
+
throw new CustomError(
|
|
87
|
+
res.data.errors[0].message || "Could not fetch user.",
|
|
88
|
+
CustomError.USER_NOT_FOUND,
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
if (res.data.errors[0].message) {
|
|
92
|
+
throw new CustomError(
|
|
93
|
+
wrapTextMultiline(res.data.errors[0].message, 90, 1)[0],
|
|
94
|
+
res.statusText,
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
throw new CustomError(
|
|
98
|
+
"Something went wrong while trying to retrieve the language data using the GraphQL API.",
|
|
99
|
+
CustomError.GRAPHQL_ERROR,
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Check if user data exists
|
|
104
|
+
if (!res.data.data || !res.data.data.user) {
|
|
105
|
+
logger.error("Missing user data in response:", res.data);
|
|
106
|
+
throw new CustomError(
|
|
107
|
+
"Could not fetch user data from GitHub API. The user might not exist or the API token might be invalid.",
|
|
108
|
+
CustomError.USER_NOT_FOUND,
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Check if repositories data exists
|
|
113
|
+
if (
|
|
114
|
+
!res.data.data.user.repositories ||
|
|
115
|
+
!res.data.data.user.repositories.nodes
|
|
116
|
+
) {
|
|
117
|
+
logger.error("Missing repositories data in response:", res.data.data.user);
|
|
118
|
+
throw new CustomError(
|
|
119
|
+
"Could not fetch repositories data from GitHub API.",
|
|
120
|
+
CustomError.GRAPHQL_ERROR,
|
|
121
|
+
);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
let repoNodes = res.data.data.user.repositories.nodes;
|
|
125
|
+
/** @type {Record<string, boolean>} */
|
|
126
|
+
let repoToHide = {};
|
|
127
|
+
const allExcludedRepos = [...exclude_repo, ...excludeRepositories];
|
|
128
|
+
|
|
129
|
+
// populate repoToHide map for quick lookup
|
|
130
|
+
// while filtering out
|
|
131
|
+
if (allExcludedRepos) {
|
|
132
|
+
allExcludedRepos.forEach((repoName) => {
|
|
133
|
+
repoToHide[repoName] = true;
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// filter out repositories to be hidden
|
|
138
|
+
repoNodes = repoNodes
|
|
139
|
+
.sort((a, b) => b.size - a.size)
|
|
140
|
+
.filter((name) => !repoToHide[name.name]);
|
|
141
|
+
|
|
142
|
+
let repoCount = 0;
|
|
143
|
+
|
|
144
|
+
repoNodes = repoNodes
|
|
145
|
+
.filter((node) => node.languages.edges.length > 0)
|
|
146
|
+
// flatten the list of language nodes
|
|
147
|
+
.reduce((acc, curr) => curr.languages.edges.concat(acc), [])
|
|
148
|
+
.reduce((acc, prev) => {
|
|
149
|
+
// get the size of the language (bytes)
|
|
150
|
+
let langSize = prev.size;
|
|
151
|
+
|
|
152
|
+
// if we already have the language in the accumulator
|
|
153
|
+
// & the current language name is same as previous name
|
|
154
|
+
// add the size to the language size and increase repoCount.
|
|
155
|
+
if (acc[prev.node.name] && prev.node.name === acc[prev.node.name].name) {
|
|
156
|
+
langSize = prev.size + acc[prev.node.name].size;
|
|
157
|
+
repoCount += 1;
|
|
158
|
+
} else {
|
|
159
|
+
// reset repoCount to 1
|
|
160
|
+
// language must exist in at least one repo to be detected
|
|
161
|
+
repoCount = 1;
|
|
162
|
+
}
|
|
163
|
+
return {
|
|
164
|
+
...acc,
|
|
165
|
+
[prev.node.name]: {
|
|
166
|
+
name: prev.node.name,
|
|
167
|
+
color: prev.node.color,
|
|
168
|
+
size: langSize,
|
|
169
|
+
count: repoCount,
|
|
170
|
+
},
|
|
171
|
+
};
|
|
172
|
+
}, {});
|
|
173
|
+
|
|
174
|
+
Object.keys(repoNodes).forEach((name) => {
|
|
175
|
+
// comparison index calculation
|
|
176
|
+
repoNodes[name].size =
|
|
177
|
+
Math.pow(repoNodes[name].size, size_weight) *
|
|
178
|
+
Math.pow(repoNodes[name].count, count_weight);
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
const topLangs = Object.keys(repoNodes)
|
|
182
|
+
.sort((a, b) => repoNodes[b].size - repoNodes[a].size)
|
|
183
|
+
.reduce((result, key) => {
|
|
184
|
+
result[key] = repoNodes[key];
|
|
185
|
+
return result;
|
|
186
|
+
}, {});
|
|
187
|
+
|
|
188
|
+
return topLangs;
|
|
189
|
+
};
|
|
190
|
+
|
|
191
|
+
export { fetchTopLanguages };
|
|
192
|
+
export default fetchTopLanguages;
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
export type GistData = {
|
|
2
|
+
name: string;
|
|
3
|
+
nameWithOwner: string;
|
|
4
|
+
description: string | null;
|
|
5
|
+
language: string | null;
|
|
6
|
+
starsCount: number;
|
|
7
|
+
forksCount: number;
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
export type RepositoryData = {
|
|
11
|
+
name: string;
|
|
12
|
+
nameWithOwner: string;
|
|
13
|
+
isPrivate: boolean;
|
|
14
|
+
isArchived: boolean;
|
|
15
|
+
isTemplate: boolean;
|
|
16
|
+
stargazers: { totalCount: number };
|
|
17
|
+
description: string;
|
|
18
|
+
primaryLanguage: {
|
|
19
|
+
color: string;
|
|
20
|
+
id: string;
|
|
21
|
+
name: string;
|
|
22
|
+
};
|
|
23
|
+
forkCount: number;
|
|
24
|
+
starCount: number;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
export type StatsData = {
|
|
28
|
+
name: string;
|
|
29
|
+
totalPRs: number;
|
|
30
|
+
totalPRsMerged: number;
|
|
31
|
+
mergedPRsPercentage: number;
|
|
32
|
+
totalReviews: number;
|
|
33
|
+
totalCommits: number;
|
|
34
|
+
totalIssues: number;
|
|
35
|
+
totalStars: number;
|
|
36
|
+
totalDiscussionsStarted: number;
|
|
37
|
+
totalDiscussionsAnswered: number;
|
|
38
|
+
contributedTo: number;
|
|
39
|
+
rank: { level: string; percentile: number };
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
export type Lang = {
|
|
43
|
+
name: string;
|
|
44
|
+
color: string;
|
|
45
|
+
size: number;
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
export type TopLangData = Record<string, Lang>;
|
|
49
|
+
|
|
50
|
+
export type WakaTimeData = {
|
|
51
|
+
categories: {
|
|
52
|
+
digital: string;
|
|
53
|
+
hours: number;
|
|
54
|
+
minutes: number;
|
|
55
|
+
name: string;
|
|
56
|
+
percent: number;
|
|
57
|
+
text: string;
|
|
58
|
+
total_seconds: number;
|
|
59
|
+
}[];
|
|
60
|
+
daily_average: number;
|
|
61
|
+
daily_average_including_other_language: number;
|
|
62
|
+
days_including_holidays: number;
|
|
63
|
+
days_minus_holidays: number;
|
|
64
|
+
editors: {
|
|
65
|
+
digital: string;
|
|
66
|
+
hours: number;
|
|
67
|
+
minutes: number;
|
|
68
|
+
name: string;
|
|
69
|
+
percent: number;
|
|
70
|
+
text: string;
|
|
71
|
+
total_seconds: number;
|
|
72
|
+
}[];
|
|
73
|
+
holidays: number;
|
|
74
|
+
human_readable_daily_average: string;
|
|
75
|
+
human_readable_daily_average_including_other_language: string;
|
|
76
|
+
human_readable_total: string;
|
|
77
|
+
human_readable_total_including_other_language: string;
|
|
78
|
+
id: string;
|
|
79
|
+
is_already_updating: boolean;
|
|
80
|
+
is_coding_activity_visible: boolean;
|
|
81
|
+
is_including_today: boolean;
|
|
82
|
+
is_other_usage_visible: boolean;
|
|
83
|
+
is_stuck: boolean;
|
|
84
|
+
is_up_to_date: boolean;
|
|
85
|
+
languages: {
|
|
86
|
+
digital: string;
|
|
87
|
+
hours: number;
|
|
88
|
+
minutes: number;
|
|
89
|
+
name: string;
|
|
90
|
+
percent: number;
|
|
91
|
+
text: string;
|
|
92
|
+
total_seconds: number;
|
|
93
|
+
}[];
|
|
94
|
+
operating_systems: {
|
|
95
|
+
digital: string;
|
|
96
|
+
hours: number;
|
|
97
|
+
minutes: number;
|
|
98
|
+
name: string;
|
|
99
|
+
percent: number;
|
|
100
|
+
text: string;
|
|
101
|
+
total_seconds: number;
|
|
102
|
+
}[];
|
|
103
|
+
percent_calculated: number;
|
|
104
|
+
range: string;
|
|
105
|
+
status: string;
|
|
106
|
+
timeout: number;
|
|
107
|
+
total_seconds: number;
|
|
108
|
+
total_seconds_including_other_language: number;
|
|
109
|
+
user_id: string;
|
|
110
|
+
username: string;
|
|
111
|
+
writes_only: boolean;
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
export type WakaTimeLang = {
|
|
115
|
+
name: string;
|
|
116
|
+
text: string;
|
|
117
|
+
percent: number;
|
|
118
|
+
};
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
|
|
3
|
+
import axios from "axios";
|
|
4
|
+
import { CustomError, MissingParamError } from "../common/error.js";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Allowed WakaTime API domains whitelist.
|
|
8
|
+
* Only these domains are permitted to prevent SSRF attacks.
|
|
9
|
+
*/
|
|
10
|
+
const ALLOWED_WAKATIME_DOMAINS = ["wakatime.com", "api.wakatime.com"];
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Validates that the provided domain is in the allowed whitelist.
|
|
14
|
+
* Uses URL parsing to extract only the hostname, preventing SSRF attacks
|
|
15
|
+
* by stripping protocol, port, path, and other components.
|
|
16
|
+
*
|
|
17
|
+
* @param {string} domain The domain to validate.
|
|
18
|
+
* @returns {boolean} True if domain is allowed, false otherwise.
|
|
19
|
+
*/
|
|
20
|
+
const isValidWakatimeDomain = (domain) => {
|
|
21
|
+
if (!domain) {
|
|
22
|
+
return true; // Default to wakatime.com if not provided
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// Parse as a URL and extract hostname to prevent SSRF
|
|
26
|
+
let hostname;
|
|
27
|
+
try {
|
|
28
|
+
// Remove any existing protocol and ensure we have one for parsing
|
|
29
|
+
const domainWithoutProtocol = domain.replace(/^(https?:\/\/)/i, "");
|
|
30
|
+
// Use URL constructor to parse and extract only the hostname
|
|
31
|
+
const urlObj = new URL(`https://${domainWithoutProtocol}`);
|
|
32
|
+
hostname = urlObj.hostname.toLowerCase();
|
|
33
|
+
} catch {
|
|
34
|
+
// Invalid URL format - reject
|
|
35
|
+
return false;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Check against whitelist using only the extracted hostname
|
|
39
|
+
return ALLOWED_WAKATIME_DOMAINS.includes(hostname);
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* WakaTime data fetcher.
|
|
44
|
+
*
|
|
45
|
+
* @param {{username: string, api_domain: string }} props Fetcher props.
|
|
46
|
+
* @returns {Promise<import("./types").WakaTimeData>} WakaTime data response.
|
|
47
|
+
*/
|
|
48
|
+
const fetchWakatimeStats = async ({ username, api_domain }) => {
|
|
49
|
+
if (!username) {
|
|
50
|
+
throw new MissingParamError(["username"]);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Validate api_domain against whitelist to prevent SSRF
|
|
54
|
+
if (api_domain && !isValidWakatimeDomain(api_domain)) {
|
|
55
|
+
throw new CustomError(
|
|
56
|
+
"Invalid API domain. Only whitelisted WakaTime domains are allowed.",
|
|
57
|
+
"WAKATIME_ERROR",
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Extract and sanitize hostname using URL parsing
|
|
62
|
+
// After validation, select domain from whitelist to prevent SSRF
|
|
63
|
+
// This ensures CodeQL recognizes that domain is not user-controlled
|
|
64
|
+
let domain = "wakatime.com"; // Default safe domain
|
|
65
|
+
if (api_domain) {
|
|
66
|
+
try {
|
|
67
|
+
// Remove any existing protocol and ensure we have one for parsing
|
|
68
|
+
const domainWithoutProtocol = api_domain.replace(/^(https?:\/\/)/i, "");
|
|
69
|
+
// Use URL constructor to extract only the hostname (validated by isValidWakatimeDomain)
|
|
70
|
+
const urlObj = new URL(`https://${domainWithoutProtocol}`);
|
|
71
|
+
const extractedHostname = urlObj.hostname.toLowerCase();
|
|
72
|
+
|
|
73
|
+
// Select domain from whitelist using explicit lookup
|
|
74
|
+
// This ensures domain can only be one of the whitelisted constants
|
|
75
|
+
if (extractedHostname === "wakatime.com") {
|
|
76
|
+
domain = "wakatime.com";
|
|
77
|
+
} else if (extractedHostname === "api.wakatime.com") {
|
|
78
|
+
domain = "api.wakatime.com";
|
|
79
|
+
}
|
|
80
|
+
// If extractedHostname doesn't match whitelist, domain remains default "wakatime.com"
|
|
81
|
+
} catch {
|
|
82
|
+
// Should not happen if validation passed, but domain already set to safe default
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// URL-encode username to prevent path injection
|
|
87
|
+
const encodedUsername = encodeURIComponent(username);
|
|
88
|
+
|
|
89
|
+
// Construct URL using only whitelisted domain constants
|
|
90
|
+
// Domain is guaranteed to be from ALLOWED_WAKATIME_DOMAINS, preventing SSRF
|
|
91
|
+
const apiUrl = `https://${domain}/api/v1/users/${encodedUsername}/stats?is_including_today=true`;
|
|
92
|
+
|
|
93
|
+
try {
|
|
94
|
+
const { data } = await axios.get(apiUrl);
|
|
95
|
+
|
|
96
|
+
return data.data;
|
|
97
|
+
} catch (err) {
|
|
98
|
+
if (err.response?.status < 200 || err.response?.status > 299) {
|
|
99
|
+
throw new CustomError(
|
|
100
|
+
`Could not resolve to a User with the login of '${username}'`,
|
|
101
|
+
"WAKATIME_USER_NOT_FOUND",
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
throw err;
|
|
105
|
+
}
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
export { fetchWakatimeStats };
|
|
109
|
+
export default fetchWakatimeStats;
|
package/src/index.js
ADDED