@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.
Files changed (50) hide show
  1. package/LICENSE +21 -0
  2. package/api/gist.js +98 -0
  3. package/api/index.js +146 -0
  4. package/api/pin.js +114 -0
  5. package/api/status/pat-info.js +193 -0
  6. package/api/status/up.js +129 -0
  7. package/api/top-langs.js +163 -0
  8. package/api/wakatime.js +129 -0
  9. package/package.json +102 -0
  10. package/readme.md +412 -0
  11. package/src/calculateRank.js +87 -0
  12. package/src/cards/gist.js +151 -0
  13. package/src/cards/index.js +4 -0
  14. package/src/cards/repo.js +199 -0
  15. package/src/cards/stats.js +607 -0
  16. package/src/cards/top-languages.js +968 -0
  17. package/src/cards/types.d.ts +67 -0
  18. package/src/cards/wakatime.js +482 -0
  19. package/src/common/Card.js +294 -0
  20. package/src/common/I18n.js +41 -0
  21. package/src/common/access.js +69 -0
  22. package/src/common/api-utils.js +221 -0
  23. package/src/common/blacklist.js +10 -0
  24. package/src/common/cache.js +153 -0
  25. package/src/common/color.js +194 -0
  26. package/src/common/envs.js +15 -0
  27. package/src/common/error.js +84 -0
  28. package/src/common/fmt.js +90 -0
  29. package/src/common/html.js +43 -0
  30. package/src/common/http.js +24 -0
  31. package/src/common/icons.js +63 -0
  32. package/src/common/index.js +13 -0
  33. package/src/common/languageColors.json +651 -0
  34. package/src/common/log.js +14 -0
  35. package/src/common/ops.js +124 -0
  36. package/src/common/render.js +261 -0
  37. package/src/common/retryer.js +97 -0
  38. package/src/common/worker-adapter.js +148 -0
  39. package/src/common/worker-env.js +48 -0
  40. package/src/fetchers/gist.js +114 -0
  41. package/src/fetchers/repo.js +118 -0
  42. package/src/fetchers/stats.js +350 -0
  43. package/src/fetchers/top-languages.js +192 -0
  44. package/src/fetchers/types.d.ts +118 -0
  45. package/src/fetchers/wakatime.js +109 -0
  46. package/src/index.js +2 -0
  47. package/src/translations.js +1105 -0
  48. package/src/worker.ts +116 -0
  49. package/themes/README.md +229 -0
  50. 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
@@ -0,0 +1,2 @@
1
+ export * from "./common/index.js";
2
+ export * from "./cards/index.js";