unmodeled-tyler commited on
Commit
4e63b61
·
verified ·
1 Parent(s): afaf88a

Upload 34 files

Browse files
src/calculateRank.js ADDED
@@ -0,0 +1,87 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Calculates the exponential cdf.
3
+ *
4
+ * @param {number} x The value.
5
+ * @returns {number} The exponential cdf.
6
+ */
7
+ function exponential_cdf(x) {
8
+ return 1 - 2 ** -x;
9
+ }
10
+
11
+ /**
12
+ * Calculates the log normal cdf.
13
+ *
14
+ * @param {number} x The value.
15
+ * @returns {number} The log normal cdf.
16
+ */
17
+ function log_normal_cdf(x) {
18
+ // approximation
19
+ return x / (1 + x);
20
+ }
21
+
22
+ /**
23
+ * Calculates the users rank.
24
+ *
25
+ * @param {object} params Parameters on which the user's rank depends.
26
+ * @param {boolean} params.all_commits Whether `include_all_commits` was used.
27
+ * @param {number} params.commits Number of commits.
28
+ * @param {number} params.prs The number of pull requests.
29
+ * @param {number} params.issues The number of issues.
30
+ * @param {number} params.reviews The number of reviews.
31
+ * @param {number} params.repos Total number of repos.
32
+ * @param {number} params.stars The number of stars.
33
+ * @param {number} params.followers The number of followers.
34
+ * @returns {{ level: string, percentile: number }} The users rank.
35
+ */
36
+ function calculateRank({
37
+ all_commits,
38
+ commits,
39
+ prs,
40
+ issues,
41
+ reviews,
42
+ // eslint-disable-next-line no-unused-vars
43
+ repos, // unused
44
+ stars,
45
+ followers,
46
+ }) {
47
+ const COMMITS_MEDIAN = all_commits ? 1000 : 250,
48
+ COMMITS_WEIGHT = 2;
49
+ const PRS_MEDIAN = 50,
50
+ PRS_WEIGHT = 3;
51
+ const ISSUES_MEDIAN = 25,
52
+ ISSUES_WEIGHT = 1;
53
+ const REVIEWS_MEDIAN = 2,
54
+ REVIEWS_WEIGHT = 1;
55
+ const STARS_MEDIAN = 50,
56
+ STARS_WEIGHT = 4;
57
+ const FOLLOWERS_MEDIAN = 10,
58
+ FOLLOWERS_WEIGHT = 1;
59
+
60
+ const TOTAL_WEIGHT =
61
+ COMMITS_WEIGHT +
62
+ PRS_WEIGHT +
63
+ ISSUES_WEIGHT +
64
+ REVIEWS_WEIGHT +
65
+ STARS_WEIGHT +
66
+ FOLLOWERS_WEIGHT;
67
+
68
+ const THRESHOLDS = [1, 12.5, 25, 37.5, 50, 62.5, 75, 87.5, 100];
69
+ const LEVELS = ["S", "A+", "A", "A-", "B+", "B", "B-", "C+", "C"];
70
+
71
+ const rank =
72
+ 1 -
73
+ (COMMITS_WEIGHT * exponential_cdf(commits / COMMITS_MEDIAN) +
74
+ PRS_WEIGHT * exponential_cdf(prs / PRS_MEDIAN) +
75
+ ISSUES_WEIGHT * exponential_cdf(issues / ISSUES_MEDIAN) +
76
+ REVIEWS_WEIGHT * exponential_cdf(reviews / REVIEWS_MEDIAN) +
77
+ STARS_WEIGHT * log_normal_cdf(stars / STARS_MEDIAN) +
78
+ FOLLOWERS_WEIGHT * log_normal_cdf(followers / FOLLOWERS_MEDIAN)) /
79
+ TOTAL_WEIGHT;
80
+
81
+ const level = LEVELS[THRESHOLDS.findIndex((t) => rank * 100 <= t)];
82
+
83
+ return { level, percentile: rank * 100 };
84
+ }
85
+
86
+ export { calculateRank };
87
+ export default calculateRank;
src/cards/gist.js ADDED
@@ -0,0 +1,152 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // @ts-check
2
+
3
+ import {
4
+ measureText,
5
+ flexLayout,
6
+ iconWithLabel,
7
+ createLanguageNode,
8
+ } from "../common/render.js";
9
+ import Card from "../common/Card.js";
10
+ import { getCardColors } from "../common/color.js";
11
+ import { kFormatter, wrapTextMultiline } from "../common/fmt.js";
12
+ import { encodeHTML } from "../common/html.js";
13
+ import { icons } from "../common/icons.js";
14
+ import { parseEmojis } from "../common/ops.js";
15
+
16
+ /** Import language colors.
17
+ *
18
+ * @description Here we use the workaround found in
19
+ * https://stackoverflow.com/questions/66726365/how-should-i-import-json-in-node
20
+ * since vercel is using v16.14.0 which does not yet support json imports without the
21
+ * --experimental-json-modules flag.
22
+ */
23
+ import { createRequire } from "module";
24
+ const require = createRequire(import.meta.url);
25
+ const languageColors = require("../common/languageColors.json"); // now works
26
+
27
+ const ICON_SIZE = 16;
28
+ const CARD_DEFAULT_WIDTH = 400;
29
+ const HEADER_MAX_LENGTH = 35;
30
+
31
+ /**
32
+ * @typedef {import('./types').GistCardOptions} GistCardOptions Gist card options.
33
+ * @typedef {import('../fetchers/types').GistData} GistData Gist data.
34
+ */
35
+
36
+ /**
37
+ * Render gist card.
38
+ *
39
+ * @param {GistData} gistData Gist data.
40
+ * @param {Partial<GistCardOptions>} options Gist card options.
41
+ * @returns {string} Gist card.
42
+ */
43
+ const renderGistCard = (gistData, options = {}) => {
44
+ const { name, nameWithOwner, description, language, starsCount, forksCount } =
45
+ gistData;
46
+ const {
47
+ title_color,
48
+ icon_color,
49
+ text_color,
50
+ bg_color,
51
+ theme,
52
+ border_radius,
53
+ border_color,
54
+ show_owner = false,
55
+ hide_border = false,
56
+ } = options;
57
+
58
+ // returns theme based colors with proper overrides and defaults
59
+ const { titleColor, textColor, iconColor, bgColor, borderColor } =
60
+ getCardColors({
61
+ title_color,
62
+ icon_color,
63
+ text_color,
64
+ bg_color,
65
+ border_color,
66
+ theme,
67
+ });
68
+
69
+ const lineWidth = 59;
70
+ const linesLimit = 10;
71
+ const desc = parseEmojis(description || "No description provided");
72
+ const multiLineDescription = wrapTextMultiline(desc, lineWidth, linesLimit);
73
+ const descriptionLines = multiLineDescription.length;
74
+ const descriptionSvg = multiLineDescription
75
+ .map((line) => `<tspan dy="1.2em" x="25">${encodeHTML(line)}</tspan>`)
76
+ .join("");
77
+
78
+ const lineHeight = descriptionLines > 3 ? 12 : 10;
79
+ const height =
80
+ (descriptionLines > 1 ? 120 : 110) + descriptionLines * lineHeight;
81
+
82
+ const totalStars = kFormatter(starsCount);
83
+ const totalForks = kFormatter(forksCount);
84
+ const svgStars = iconWithLabel(
85
+ icons.star,
86
+ totalStars,
87
+ "starsCount",
88
+ ICON_SIZE,
89
+ );
90
+ const svgForks = iconWithLabel(
91
+ icons.fork,
92
+ totalForks,
93
+ "forksCount",
94
+ ICON_SIZE,
95
+ );
96
+
97
+ const languageName = language || "Unspecified";
98
+ // @ts-ignore
99
+ const languageColor = languageColors[languageName] || "#858585";
100
+
101
+ const svgLanguage = createLanguageNode(languageName, languageColor);
102
+
103
+ const starAndForkCount = flexLayout({
104
+ items: [svgLanguage, svgStars, svgForks],
105
+ sizes: [
106
+ measureText(languageName, 12),
107
+ ICON_SIZE + measureText(`${totalStars}`, 12),
108
+ ICON_SIZE + measureText(`${totalForks}`, 12),
109
+ ],
110
+ gap: 25,
111
+ }).join("");
112
+
113
+ const header = show_owner ? nameWithOwner : name;
114
+
115
+ const card = new Card({
116
+ defaultTitle:
117
+ header.length > HEADER_MAX_LENGTH
118
+ ? `${header.slice(0, HEADER_MAX_LENGTH)}...`
119
+ : header,
120
+ titlePrefixIcon: icons.gist,
121
+ width: CARD_DEFAULT_WIDTH,
122
+ height,
123
+ border_radius,
124
+ colors: {
125
+ titleColor,
126
+ textColor,
127
+ iconColor,
128
+ bgColor,
129
+ borderColor,
130
+ },
131
+ });
132
+
133
+ card.setCSS(`
134
+ .description { font: 400 13px 'Segoe UI', Ubuntu, Sans-Serif; fill: ${textColor} }
135
+ .gray { font: 400 12px 'Segoe UI', Ubuntu, Sans-Serif; fill: ${textColor} }
136
+ .icon { fill: ${iconColor} }
137
+ `);
138
+ card.setHideBorder(hide_border);
139
+
140
+ return card.render(`
141
+ <text class="description" x="25" y="-5">
142
+ ${descriptionSvg}
143
+ </text>
144
+
145
+ <g transform="translate(30, ${height - 75})">
146
+ ${starAndForkCount}
147
+ </g>
148
+ `);
149
+ };
150
+
151
+ export { renderGistCard, HEADER_MAX_LENGTH };
152
+ export default renderGistCard;
src/cards/index.js ADDED
@@ -0,0 +1,4 @@
 
 
 
 
 
1
+ export { renderRepoCard } from "./repo.js";
2
+ export { renderStatsCard } from "./stats.js";
3
+ export { renderTopLanguages } from "./top-languages.js";
4
+ export { renderWakatimeCard } from "./wakatime.js";
src/cards/repo.js ADDED
@@ -0,0 +1,193 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // @ts-check
2
+
3
+ import { Card } from "../common/Card.js";
4
+ import { getCardColors } from "../common/color.js";
5
+ import { kFormatter, wrapTextMultiline } from "../common/fmt.js";
6
+ import { encodeHTML } from "../common/html.js";
7
+ import { I18n } from "../common/I18n.js";
8
+ import { icons } from "../common/icons.js";
9
+ import { clampValue, parseEmojis } from "../common/ops.js";
10
+ import {
11
+ flexLayout,
12
+ measureText,
13
+ iconWithLabel,
14
+ createLanguageNode,
15
+ } from "../common/render.js";
16
+ import { repoCardLocales } from "../translations.js";
17
+
18
+ const ICON_SIZE = 16;
19
+ const DESCRIPTION_LINE_WIDTH = 59;
20
+ const DESCRIPTION_MAX_LINES = 3;
21
+
22
+ /**
23
+ * Retrieves the repository description and wraps it to fit the card width.
24
+ *
25
+ * @param {string} label The repository description.
26
+ * @param {string} textColor The color of the text.
27
+ * @returns {string} Wrapped repo description SVG object.
28
+ */
29
+ const getBadgeSVG = (label, textColor) => `
30
+ <g data-testid="badge" class="badge" transform="translate(320, -18)">
31
+ <rect stroke="${textColor}" stroke-width="1" width="70" height="20" x="-12" y="-14" ry="10" rx="10"></rect>
32
+ <text
33
+ x="23" y="-5"
34
+ alignment-baseline="central"
35
+ dominant-baseline="central"
36
+ text-anchor="middle"
37
+ fill="${textColor}"
38
+ >
39
+ ${label}
40
+ </text>
41
+ </g>
42
+ `;
43
+
44
+ /**
45
+ * @typedef {import("../fetchers/types").RepositoryData} RepositoryData Repository data.
46
+ * @typedef {import("./types").RepoCardOptions} RepoCardOptions Repo card options.
47
+ */
48
+
49
+ /**
50
+ * Renders repository card details.
51
+ *
52
+ * @param {RepositoryData} repo Repository data.
53
+ * @param {Partial<RepoCardOptions>} options Card options.
54
+ * @returns {string} Repository card SVG object.
55
+ */
56
+ const renderRepoCard = (repo, options = {}) => {
57
+ const {
58
+ name,
59
+ nameWithOwner,
60
+ description,
61
+ primaryLanguage,
62
+ isArchived,
63
+ isTemplate,
64
+ starCount,
65
+ forkCount,
66
+ } = repo;
67
+ const {
68
+ hide_border = false,
69
+ title_color,
70
+ icon_color,
71
+ text_color,
72
+ bg_color,
73
+ show_owner = false,
74
+ theme = "default_repocard",
75
+ border_radius,
76
+ border_color,
77
+ locale,
78
+ description_lines_count,
79
+ } = options;
80
+
81
+ const lineHeight = 10;
82
+ const header = show_owner ? nameWithOwner : name;
83
+ const langName = (primaryLanguage && primaryLanguage.name) || "Unspecified";
84
+ const langColor = (primaryLanguage && primaryLanguage.color) || "#333";
85
+ const descriptionMaxLines = description_lines_count
86
+ ? clampValue(description_lines_count, 1, DESCRIPTION_MAX_LINES)
87
+ : DESCRIPTION_MAX_LINES;
88
+
89
+ const desc = parseEmojis(description || "No description provided");
90
+ const multiLineDescription = wrapTextMultiline(
91
+ desc,
92
+ DESCRIPTION_LINE_WIDTH,
93
+ descriptionMaxLines,
94
+ );
95
+ const descriptionLinesCount = description_lines_count
96
+ ? clampValue(description_lines_count, 1, DESCRIPTION_MAX_LINES)
97
+ : multiLineDescription.length;
98
+
99
+ const descriptionSvg = multiLineDescription
100
+ .map((line) => `<tspan dy="1.2em" x="25">${encodeHTML(line)}</tspan>`)
101
+ .join("");
102
+
103
+ const height =
104
+ (descriptionLinesCount > 1 ? 120 : 110) +
105
+ descriptionLinesCount * lineHeight;
106
+
107
+ const i18n = new I18n({
108
+ locale,
109
+ translations: repoCardLocales,
110
+ });
111
+
112
+ // returns theme based colors with proper overrides and defaults
113
+ const colors = getCardColors({
114
+ title_color,
115
+ icon_color,
116
+ text_color,
117
+ bg_color,
118
+ border_color,
119
+ theme,
120
+ });
121
+
122
+ const svgLanguage = primaryLanguage
123
+ ? createLanguageNode(langName, langColor)
124
+ : "";
125
+
126
+ const totalStars = kFormatter(starCount);
127
+ const totalForks = kFormatter(forkCount);
128
+ const svgStars = iconWithLabel(
129
+ icons.star,
130
+ totalStars,
131
+ "stargazers",
132
+ ICON_SIZE,
133
+ );
134
+ const svgForks = iconWithLabel(
135
+ icons.fork,
136
+ totalForks,
137
+ "forkcount",
138
+ ICON_SIZE,
139
+ );
140
+
141
+ const starAndForkCount = flexLayout({
142
+ items: [svgLanguage, svgStars, svgForks],
143
+ sizes: [
144
+ measureText(langName, 12),
145
+ ICON_SIZE + measureText(`${totalStars}`, 12),
146
+ ICON_SIZE + measureText(`${totalForks}`, 12),
147
+ ],
148
+ gap: 25,
149
+ }).join("");
150
+
151
+ const card = new Card({
152
+ defaultTitle: header.length > 35 ? `${header.slice(0, 35)}...` : header,
153
+ titlePrefixIcon: icons.contribs,
154
+ width: 400,
155
+ height,
156
+ border_radius,
157
+ colors,
158
+ });
159
+
160
+ card.disableAnimations();
161
+ card.setHideBorder(hide_border);
162
+ card.setHideTitle(false);
163
+ card.setCSS(`
164
+ .description { font: 400 13px 'Segoe UI', Ubuntu, Sans-Serif; fill: ${colors.textColor} }
165
+ .gray { font: 400 12px 'Segoe UI', Ubuntu, Sans-Serif; fill: ${colors.textColor} }
166
+ .icon { fill: ${colors.iconColor} }
167
+ .badge { font: 600 11px 'Segoe UI', Ubuntu, Sans-Serif; }
168
+ .badge rect { opacity: 0.2 }
169
+ `);
170
+
171
+ return card.render(`
172
+ ${
173
+ isTemplate
174
+ ? // @ts-ignore
175
+ getBadgeSVG(i18n.t("repocard.template"), colors.textColor)
176
+ : isArchived
177
+ ? // @ts-ignore
178
+ getBadgeSVG(i18n.t("repocard.archived"), colors.textColor)
179
+ : ""
180
+ }
181
+
182
+ <text class="description" x="25" y="-5">
183
+ ${descriptionSvg}
184
+ </text>
185
+
186
+ <g transform="translate(30, ${height - 75})">
187
+ ${starAndForkCount}
188
+ </g>
189
+ `);
190
+ };
191
+
192
+ export { renderRepoCard };
193
+ export default renderRepoCard;
src/cards/stats.js ADDED
@@ -0,0 +1,602 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // @ts-check
2
+
3
+ import { Card } from "../common/Card.js";
4
+ import { getCardColors } from "../common/color.js";
5
+ import { CustomError } from "../common/error.js";
6
+ import { kFormatter } from "../common/fmt.js";
7
+ import { I18n } from "../common/I18n.js";
8
+ import { icons, rankIcon } from "../common/icons.js";
9
+ import { clampValue } from "../common/ops.js";
10
+ import { flexLayout, measureText } from "../common/render.js";
11
+ import { statCardLocales, wakatimeCardLocales } from "../translations.js";
12
+
13
+ const CARD_MIN_WIDTH = 287;
14
+ const CARD_DEFAULT_WIDTH = 287;
15
+ const RANK_CARD_MIN_WIDTH = 420;
16
+ const RANK_CARD_DEFAULT_WIDTH = 450;
17
+ const RANK_ONLY_CARD_MIN_WIDTH = 290;
18
+ const RANK_ONLY_CARD_DEFAULT_WIDTH = 290;
19
+
20
+ /**
21
+ * Long locales that need more space for text. Keep sorted alphabetically.
22
+ *
23
+ * @type {(keyof typeof wakatimeCardLocales["wakatimecard.title"])[]}
24
+ */
25
+ const LONG_LOCALES = [
26
+ "az",
27
+ "bg",
28
+ "cs",
29
+ "de",
30
+ "el",
31
+ "es",
32
+ "fil",
33
+ "fi",
34
+ "fr",
35
+ "hu",
36
+ "id",
37
+ "ja",
38
+ "ml",
39
+ "my",
40
+ "nl",
41
+ "pl",
42
+ "pt-br",
43
+ "pt-pt",
44
+ "ru",
45
+ "sr",
46
+ "sr-latn",
47
+ "sw",
48
+ "ta",
49
+ "uk-ua",
50
+ "uz",
51
+ "zh-tw",
52
+ ];
53
+
54
+ /**
55
+ * Create a stats card text item.
56
+ *
57
+ * @param {object} params Object that contains the createTextNode parameters.
58
+ * @param {string} params.icon The icon to display.
59
+ * @param {string} params.label The label to display.
60
+ * @param {number} params.value The value to display.
61
+ * @param {string} params.id The id of the stat.
62
+ * @param {string=} params.unitSymbol The unit symbol of the stat.
63
+ * @param {number} params.index The index of the stat.
64
+ * @param {boolean} params.showIcons Whether to show icons.
65
+ * @param {number} params.shiftValuePos Number of pixels the value has to be shifted to the right.
66
+ * @param {boolean} params.bold Whether to bold the label.
67
+ * @param {string} params.numberFormat The format of numbers on card.
68
+ * @param {number=} params.numberPrecision The precision of numbers on card.
69
+ * @returns {string} The stats card text item SVG object.
70
+ */
71
+ const createTextNode = ({
72
+ icon,
73
+ label,
74
+ value,
75
+ id,
76
+ unitSymbol,
77
+ index,
78
+ showIcons,
79
+ shiftValuePos,
80
+ bold,
81
+ numberFormat,
82
+ numberPrecision,
83
+ }) => {
84
+ const precision =
85
+ typeof numberPrecision === "number" && !isNaN(numberPrecision)
86
+ ? clampValue(numberPrecision, 0, 2)
87
+ : undefined;
88
+ const kValue =
89
+ numberFormat.toLowerCase() === "long" || id === "prs_merged_percentage"
90
+ ? value
91
+ : kFormatter(value, precision);
92
+ const staggerDelay = (index + 3) * 150;
93
+
94
+ const labelOffset = showIcons ? `x="25"` : "";
95
+ const iconSvg = showIcons
96
+ ? `
97
+ <svg data-testid="icon" class="icon" viewBox="0 0 16 16" version="1.1" width="16" height="16">
98
+ ${icon}
99
+ </svg>
100
+ `
101
+ : "";
102
+ return `
103
+ <g class="stagger" style="animation-delay: ${staggerDelay}ms" transform="translate(25, 0)">
104
+ ${iconSvg}
105
+ <text class="stat ${
106
+ bold ? " bold" : "not_bold"
107
+ }" ${labelOffset} y="12.5">${label}:</text>
108
+ <text
109
+ class="stat ${bold ? " bold" : "not_bold"}"
110
+ x="${(showIcons ? 140 : 120) + shiftValuePos}"
111
+ y="12.5"
112
+ data-testid="${id}"
113
+ >${kValue}${unitSymbol ? ` ${unitSymbol}` : ""}</text>
114
+ </g>
115
+ `;
116
+ };
117
+
118
+ /**
119
+ * Calculates progress along the boundary of the circle, i.e. its circumference.
120
+ *
121
+ * @param {number} value The rank value to calculate progress for.
122
+ * @returns {number} Progress value.
123
+ */
124
+ const calculateCircleProgress = (value) => {
125
+ const radius = 40;
126
+ const c = Math.PI * (radius * 2);
127
+
128
+ if (value < 0) {
129
+ value = 0;
130
+ }
131
+ if (value > 100) {
132
+ value = 100;
133
+ }
134
+
135
+ return ((100 - value) / 100) * c;
136
+ };
137
+
138
+ /**
139
+ * Retrieves the animation to display progress along the circumference of circle
140
+ * from the beginning to the given value in a clockwise direction.
141
+ *
142
+ * @param {{progress: number}} progress The progress value to animate to.
143
+ * @returns {string} Progress animation css.
144
+ */
145
+ const getProgressAnimation = ({ progress }) => {
146
+ return `
147
+ @keyframes rankAnimation {
148
+ from {
149
+ stroke-dashoffset: ${calculateCircleProgress(0)};
150
+ }
151
+ to {
152
+ stroke-dashoffset: ${calculateCircleProgress(progress)};
153
+ }
154
+ }
155
+ `;
156
+ };
157
+
158
+ /**
159
+ * Retrieves CSS styles for a card.
160
+ *
161
+ * @param {Object} colors The colors to use for the card.
162
+ * @param {string} colors.titleColor The title color.
163
+ * @param {string} colors.textColor The text color.
164
+ * @param {string} colors.iconColor The icon color.
165
+ * @param {string} colors.ringColor The ring color.
166
+ * @param {boolean} colors.show_icons Whether to show icons.
167
+ * @param {number} colors.progress The progress value to animate to.
168
+ * @returns {string} Card CSS styles.
169
+ */
170
+ const getStyles = ({
171
+ // eslint-disable-next-line no-unused-vars
172
+ titleColor,
173
+ textColor,
174
+ iconColor,
175
+ ringColor,
176
+ show_icons,
177
+ progress,
178
+ }) => {
179
+ return `
180
+ .stat {
181
+ font: 600 14px 'Segoe UI', Ubuntu, "Helvetica Neue", Sans-Serif; fill: ${textColor};
182
+ }
183
+ @supports(-moz-appearance: auto) {
184
+ /* Selector detects Firefox */
185
+ .stat { font-size:12px; }
186
+ }
187
+ .stagger {
188
+ opacity: 0;
189
+ animation: fadeInAnimation 0.3s ease-in-out forwards;
190
+ }
191
+ .rank-text {
192
+ font: 800 24px 'Segoe UI', Ubuntu, Sans-Serif; fill: ${textColor};
193
+ animation: scaleInAnimation 0.3s ease-in-out forwards;
194
+ }
195
+ .rank-percentile-header {
196
+ font-size: 14px;
197
+ }
198
+ .rank-percentile-text {
199
+ font-size: 16px;
200
+ }
201
+
202
+ .not_bold { font-weight: 400 }
203
+ .bold { font-weight: 700 }
204
+ .icon {
205
+ fill: ${iconColor};
206
+ display: ${show_icons ? "block" : "none"};
207
+ }
208
+
209
+ .rank-circle-rim {
210
+ stroke: ${ringColor};
211
+ fill: none;
212
+ stroke-width: 6;
213
+ opacity: 0.2;
214
+ }
215
+ .rank-circle {
216
+ stroke: ${ringColor};
217
+ stroke-dasharray: 250;
218
+ fill: none;
219
+ stroke-width: 6;
220
+ stroke-linecap: round;
221
+ opacity: 0.8;
222
+ transform-origin: -10px 8px;
223
+ transform: rotate(-90deg);
224
+ animation: rankAnimation 1s forwards ease-in-out;
225
+ }
226
+ ${process.env.NODE_ENV === "test" ? "" : getProgressAnimation({ progress })}
227
+ `;
228
+ };
229
+
230
+ /**
231
+ * Return the label for commits according to the selected options
232
+ *
233
+ * @param {boolean} include_all_commits Option to include all years
234
+ * @param {number|undefined} commits_year Option to include only selected year
235
+ * @param {I18n} i18n The I18n instance.
236
+ * @returns {string} The label corresponding to the options.
237
+ */
238
+ const getTotalCommitsYearLabel = (include_all_commits, commits_year, i18n) =>
239
+ include_all_commits
240
+ ? ""
241
+ : commits_year
242
+ ? ` (${commits_year})`
243
+ : ` (${i18n.t("wakatimecard.lastyear")})`;
244
+
245
+ /**
246
+ * @typedef {import('../fetchers/types').StatsData} StatsData
247
+ * @typedef {import('./types').StatCardOptions} StatCardOptions
248
+ */
249
+
250
+ /**
251
+ * Renders the stats card.
252
+ *
253
+ * @param {StatsData} stats The stats data.
254
+ * @param {Partial<StatCardOptions>} options The card options.
255
+ * @returns {string} The stats card SVG object.
256
+ */
257
+ const renderStatsCard = (stats, options = {}) => {
258
+ const {
259
+ name,
260
+ totalStars,
261
+ totalCommits,
262
+ totalIssues,
263
+ totalPRs,
264
+ totalPRsMerged,
265
+ mergedPRsPercentage,
266
+ totalReviews,
267
+ totalDiscussionsStarted,
268
+ totalDiscussionsAnswered,
269
+ contributedTo,
270
+ rank,
271
+ } = stats;
272
+ const {
273
+ hide = [],
274
+ show_icons = false,
275
+ hide_title = false,
276
+ hide_border = false,
277
+ card_width,
278
+ hide_rank = false,
279
+ include_all_commits = false,
280
+ commits_year,
281
+ line_height = 25,
282
+ title_color,
283
+ ring_color,
284
+ icon_color,
285
+ text_color,
286
+ text_bold = true,
287
+ bg_color,
288
+ theme = "default",
289
+ custom_title,
290
+ border_radius,
291
+ border_color,
292
+ number_format = "short",
293
+ number_precision,
294
+ locale,
295
+ disable_animations = false,
296
+ rank_icon = "default",
297
+ show = [],
298
+ } = options;
299
+
300
+ const lheight = parseInt(String(line_height), 10);
301
+
302
+ // returns theme based colors with proper overrides and defaults
303
+ const { titleColor, iconColor, textColor, bgColor, borderColor, ringColor } =
304
+ getCardColors({
305
+ title_color,
306
+ text_color,
307
+ icon_color,
308
+ bg_color,
309
+ border_color,
310
+ ring_color,
311
+ theme,
312
+ });
313
+
314
+ const apostrophe = /s$/i.test(name.trim()) ? "" : "s";
315
+ const i18n = new I18n({
316
+ locale,
317
+ translations: {
318
+ ...statCardLocales({ name, apostrophe }),
319
+ ...wakatimeCardLocales,
320
+ },
321
+ });
322
+
323
+ // Meta data for creating text nodes with createTextNode function
324
+ const STATS = {};
325
+
326
+ STATS.stars = {
327
+ icon: icons.star,
328
+ label: i18n.t("statcard.totalstars"),
329
+ value: totalStars,
330
+ id: "stars",
331
+ };
332
+ STATS.commits = {
333
+ icon: icons.commits,
334
+ label: `${i18n.t("statcard.commits")}${getTotalCommitsYearLabel(
335
+ include_all_commits,
336
+ commits_year,
337
+ i18n,
338
+ )}`,
339
+ value: totalCommits,
340
+ id: "commits",
341
+ };
342
+ STATS.prs = {
343
+ icon: icons.prs,
344
+ label: i18n.t("statcard.prs"),
345
+ value: totalPRs,
346
+ id: "prs",
347
+ };
348
+
349
+ if (show.includes("prs_merged")) {
350
+ STATS.prs_merged = {
351
+ icon: icons.prs_merged,
352
+ label: i18n.t("statcard.prs-merged"),
353
+ value: totalPRsMerged,
354
+ id: "prs_merged",
355
+ };
356
+ }
357
+
358
+ if (show.includes("prs_merged_percentage")) {
359
+ STATS.prs_merged_percentage = {
360
+ icon: icons.prs_merged_percentage,
361
+ label: i18n.t("statcard.prs-merged-percentage"),
362
+ value: mergedPRsPercentage.toFixed(
363
+ typeof number_precision === "number" && !isNaN(number_precision)
364
+ ? clampValue(number_precision, 0, 2)
365
+ : 2,
366
+ ),
367
+ id: "prs_merged_percentage",
368
+ unitSymbol: "%",
369
+ };
370
+ }
371
+
372
+ if (show.includes("reviews")) {
373
+ STATS.reviews = {
374
+ icon: icons.reviews,
375
+ label: i18n.t("statcard.reviews"),
376
+ value: totalReviews,
377
+ id: "reviews",
378
+ };
379
+ }
380
+
381
+ STATS.issues = {
382
+ icon: icons.issues,
383
+ label: i18n.t("statcard.issues"),
384
+ value: totalIssues,
385
+ id: "issues",
386
+ };
387
+
388
+ if (show.includes("discussions_started")) {
389
+ STATS.discussions_started = {
390
+ icon: icons.discussions_started,
391
+ label: i18n.t("statcard.discussions-started"),
392
+ value: totalDiscussionsStarted,
393
+ id: "discussions_started",
394
+ };
395
+ }
396
+ if (show.includes("discussions_answered")) {
397
+ STATS.discussions_answered = {
398
+ icon: icons.discussions_answered,
399
+ label: i18n.t("statcard.discussions-answered"),
400
+ value: totalDiscussionsAnswered,
401
+ id: "discussions_answered",
402
+ };
403
+ }
404
+
405
+ STATS.contribs = {
406
+ icon: icons.contribs,
407
+ label: i18n.t("statcard.contribs"),
408
+ value: contributedTo,
409
+ id: "contribs",
410
+ };
411
+
412
+ // @ts-ignore
413
+ const isLongLocale = locale ? LONG_LOCALES.includes(locale) : false;
414
+
415
+ // filter out hidden stats defined by user & create the text nodes
416
+ const statItems = Object.keys(STATS)
417
+ .filter((key) => !hide.includes(key))
418
+ .map((key, index) => {
419
+ // @ts-ignore
420
+ const stats = STATS[key];
421
+
422
+ // create the text nodes, and pass index so that we can calculate the line spacing
423
+ return createTextNode({
424
+ icon: stats.icon,
425
+ label: stats.label,
426
+ value: stats.value,
427
+ id: stats.id,
428
+ unitSymbol: stats.unitSymbol,
429
+ index,
430
+ showIcons: show_icons,
431
+ shiftValuePos: 79.01 + (isLongLocale ? 50 : 0),
432
+ bold: text_bold,
433
+ numberFormat: number_format,
434
+ numberPrecision: number_precision,
435
+ });
436
+ });
437
+
438
+ if (statItems.length === 0 && hide_rank) {
439
+ throw new CustomError(
440
+ "Could not render stats card.",
441
+ "Either stats or rank are required.",
442
+ );
443
+ }
444
+
445
+ // Calculate the card height depending on how many items there are
446
+ // but if rank circle is visible clamp the minimum height to `150`
447
+ let height = Math.max(
448
+ 45 + (statItems.length + 1) * lheight,
449
+ hide_rank ? 0 : statItems.length ? 150 : 180,
450
+ );
451
+
452
+ // the lower the user's percentile the better
453
+ const progress = 100 - rank.percentile;
454
+ const cssStyles = getStyles({
455
+ titleColor,
456
+ ringColor,
457
+ textColor,
458
+ iconColor,
459
+ show_icons,
460
+ progress,
461
+ });
462
+
463
+ const calculateTextWidth = () => {
464
+ return measureText(
465
+ custom_title
466
+ ? custom_title
467
+ : statItems.length
468
+ ? i18n.t("statcard.title")
469
+ : i18n.t("statcard.ranktitle"),
470
+ );
471
+ };
472
+
473
+ /*
474
+ When hide_rank=true, the minimum card width is 270 px + the title length and padding.
475
+ When hide_rank=false, the minimum card_width is 340 px + the icon width (if show_icons=true).
476
+ Numbers are picked by looking at existing dimensions on production.
477
+ */
478
+ const iconWidth = show_icons && statItems.length ? 16 + /* padding */ 1 : 0;
479
+ const minCardWidth =
480
+ (hide_rank
481
+ ? clampValue(
482
+ 50 /* padding */ + calculateTextWidth() * 2,
483
+ CARD_MIN_WIDTH,
484
+ Infinity,
485
+ )
486
+ : statItems.length
487
+ ? RANK_CARD_MIN_WIDTH
488
+ : RANK_ONLY_CARD_MIN_WIDTH) + iconWidth;
489
+ const defaultCardWidth =
490
+ (hide_rank
491
+ ? CARD_DEFAULT_WIDTH
492
+ : statItems.length
493
+ ? RANK_CARD_DEFAULT_WIDTH
494
+ : RANK_ONLY_CARD_DEFAULT_WIDTH) + iconWidth;
495
+ let width = card_width
496
+ ? isNaN(card_width)
497
+ ? defaultCardWidth
498
+ : card_width
499
+ : defaultCardWidth;
500
+ if (width < minCardWidth) {
501
+ width = minCardWidth;
502
+ }
503
+
504
+ const card = new Card({
505
+ customTitle: custom_title,
506
+ defaultTitle: statItems.length
507
+ ? i18n.t("statcard.title")
508
+ : i18n.t("statcard.ranktitle"),
509
+ width,
510
+ height,
511
+ border_radius,
512
+ colors: {
513
+ titleColor,
514
+ textColor,
515
+ iconColor,
516
+ bgColor,
517
+ borderColor,
518
+ },
519
+ });
520
+
521
+ card.setHideBorder(hide_border);
522
+ card.setHideTitle(hide_title);
523
+ card.setCSS(cssStyles);
524
+
525
+ if (disable_animations) {
526
+ card.disableAnimations();
527
+ }
528
+
529
+ /**
530
+ * Calculates the right rank circle translation values such that the rank circle
531
+ * keeps respecting the following padding:
532
+ *
533
+ * width > RANK_CARD_DEFAULT_WIDTH: The default right padding of 70 px will be used.
534
+ * width < RANK_CARD_DEFAULT_WIDTH: The left and right padding will be enlarged
535
+ * equally from a certain minimum at RANK_CARD_MIN_WIDTH.
536
+ *
537
+ * @returns {number} - Rank circle translation value.
538
+ */
539
+ const calculateRankXTranslation = () => {
540
+ if (statItems.length) {
541
+ const minXTranslation = RANK_CARD_MIN_WIDTH + iconWidth - 70;
542
+ if (width > RANK_CARD_DEFAULT_WIDTH) {
543
+ const xMaxExpansion = minXTranslation + (450 - minCardWidth) / 2;
544
+ return xMaxExpansion + width - RANK_CARD_DEFAULT_WIDTH;
545
+ } else {
546
+ return minXTranslation + (width - minCardWidth) / 2;
547
+ }
548
+ } else {
549
+ return width / 2 + 20 - 10;
550
+ }
551
+ };
552
+
553
+ // Conditionally rendered elements
554
+ const rankCircle = hide_rank
555
+ ? ""
556
+ : `<g data-testid="rank-circle"
557
+ transform="translate(${calculateRankXTranslation()}, ${
558
+ height / 2 - 50
559
+ })">
560
+ <circle class="rank-circle-rim" cx="-10" cy="8" r="40" />
561
+ <circle class="rank-circle" cx="-10" cy="8" r="40" />
562
+ <g class="rank-text">
563
+ ${rankIcon(rank_icon, rank?.level, rank?.percentile)}
564
+ </g>
565
+ </g>`;
566
+
567
+ // Accessibility Labels
568
+ const labels = Object.keys(STATS)
569
+ .filter((key) => !hide.includes(key))
570
+ .map((key) => {
571
+ // @ts-ignore
572
+ const stats = STATS[key];
573
+ if (key === "commits") {
574
+ return `${i18n.t("statcard.commits")} ${getTotalCommitsYearLabel(
575
+ include_all_commits,
576
+ commits_year,
577
+ i18n,
578
+ )} : ${stats.value}`;
579
+ }
580
+ return `${stats.label}: ${stats.value}`;
581
+ })
582
+ .join(", ");
583
+
584
+ card.setAccessibilityLabel({
585
+ title: `${card.title}, Rank: ${rank.level}`,
586
+ desc: labels,
587
+ });
588
+
589
+ return card.render(`
590
+ ${rankCircle}
591
+ <svg x="0" y="0">
592
+ ${flexLayout({
593
+ items: statItems,
594
+ gap: lheight,
595
+ direction: "column",
596
+ }).join("")}
597
+ </svg>
598
+ `);
599
+ };
600
+
601
+ export { renderStatsCard };
602
+ export default renderStatsCard;
src/cards/top-languages.js ADDED
@@ -0,0 +1,965 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // @ts-check
2
+
3
+ import { Card } from "../common/Card.js";
4
+ import { getCardColors } from "../common/color.js";
5
+ import { formatBytes } from "../common/fmt.js";
6
+ import { I18n } from "../common/I18n.js";
7
+ import { chunkArray, clampValue, lowercaseTrim } from "../common/ops.js";
8
+ import {
9
+ createProgressNode,
10
+ flexLayout,
11
+ measureText,
12
+ } from "../common/render.js";
13
+ import { langCardLocales } from "../translations.js";
14
+
15
+ const DEFAULT_CARD_WIDTH = 300;
16
+ const MIN_CARD_WIDTH = 280;
17
+ const DEFAULT_LANG_COLOR = "#858585";
18
+ const CARD_PADDING = 25;
19
+ const COMPACT_LAYOUT_BASE_HEIGHT = 90;
20
+ const MAXIMUM_LANGS_COUNT = 20;
21
+
22
+ const NORMAL_LAYOUT_DEFAULT_LANGS_COUNT = 5;
23
+ const COMPACT_LAYOUT_DEFAULT_LANGS_COUNT = 6;
24
+ const DONUT_LAYOUT_DEFAULT_LANGS_COUNT = 5;
25
+ const PIE_LAYOUT_DEFAULT_LANGS_COUNT = 6;
26
+ const DONUT_VERTICAL_LAYOUT_DEFAULT_LANGS_COUNT = 6;
27
+
28
+ /**
29
+ * @typedef {import("../fetchers/types").Lang} Lang
30
+ */
31
+
32
+ /**
33
+ * Retrieves the programming language whose name is the longest.
34
+ *
35
+ * @param {Lang[]} arr Array of programming languages.
36
+ * @returns {{ name: string, size: number, color: string }} Longest programming language object.
37
+ */
38
+ const getLongestLang = (arr) =>
39
+ arr.reduce(
40
+ (savedLang, lang) =>
41
+ lang.name.length > savedLang.name.length ? lang : savedLang,
42
+ { name: "", size: 0, color: "" },
43
+ );
44
+
45
+ /**
46
+ * Convert degrees to radians.
47
+ *
48
+ * @param {number} angleInDegrees Angle in degrees.
49
+ * @returns {number} Angle in radians.
50
+ */
51
+ const degreesToRadians = (angleInDegrees) => angleInDegrees * (Math.PI / 180.0);
52
+
53
+ /**
54
+ * Convert radians to degrees.
55
+ *
56
+ * @param {number} angleInRadians Angle in radians.
57
+ * @returns {number} Angle in degrees.
58
+ */
59
+ const radiansToDegrees = (angleInRadians) => angleInRadians / (Math.PI / 180.0);
60
+
61
+ /**
62
+ * Convert polar coordinates to cartesian coordinates.
63
+ *
64
+ * @param {number} centerX Center x coordinate.
65
+ * @param {number} centerY Center y coordinate.
66
+ * @param {number} radius Radius of the circle.
67
+ * @param {number} angleInDegrees Angle in degrees.
68
+ * @returns {{x: number, y: number}} Cartesian coordinates.
69
+ */
70
+ const polarToCartesian = (centerX, centerY, radius, angleInDegrees) => {
71
+ const rads = degreesToRadians(angleInDegrees);
72
+ return {
73
+ x: centerX + radius * Math.cos(rads),
74
+ y: centerY + radius * Math.sin(rads),
75
+ };
76
+ };
77
+
78
+ /**
79
+ * Convert cartesian coordinates to polar coordinates.
80
+ *
81
+ * @param {number} centerX Center x coordinate.
82
+ * @param {number} centerY Center y coordinate.
83
+ * @param {number} x Point x coordinate.
84
+ * @param {number} y Point y coordinate.
85
+ * @returns {{radius: number, angleInDegrees: number}} Polar coordinates.
86
+ */
87
+ const cartesianToPolar = (centerX, centerY, x, y) => {
88
+ const radius = Math.sqrt(Math.pow(x - centerX, 2) + Math.pow(y - centerY, 2));
89
+ let angleInDegrees = radiansToDegrees(Math.atan2(y - centerY, x - centerX));
90
+ if (angleInDegrees < 0) {
91
+ angleInDegrees += 360;
92
+ }
93
+ return { radius, angleInDegrees };
94
+ };
95
+
96
+ /**
97
+ * Calculates length of circle.
98
+ *
99
+ * @param {number} radius Radius of the circle.
100
+ * @returns {number} The length of the circle.
101
+ */
102
+ const getCircleLength = (radius) => {
103
+ return 2 * Math.PI * radius;
104
+ };
105
+
106
+ /**
107
+ * Calculates height for the compact layout.
108
+ *
109
+ * @param {number} totalLangs Total number of languages.
110
+ * @returns {number} Card height.
111
+ */
112
+ const calculateCompactLayoutHeight = (totalLangs) => {
113
+ return COMPACT_LAYOUT_BASE_HEIGHT + Math.round(totalLangs / 2) * 25;
114
+ };
115
+
116
+ /**
117
+ * Calculates height for the normal layout.
118
+ *
119
+ * @param {number} totalLangs Total number of languages.
120
+ * @returns {number} Card height.
121
+ */
122
+ const calculateNormalLayoutHeight = (totalLangs) => {
123
+ return 45 + (totalLangs + 1) * 40;
124
+ };
125
+
126
+ /**
127
+ * Calculates height for the donut layout.
128
+ *
129
+ * @param {number} totalLangs Total number of languages.
130
+ * @returns {number} Card height.
131
+ */
132
+ const calculateDonutLayoutHeight = (totalLangs) => {
133
+ return 215 + Math.max(totalLangs - 5, 0) * 32;
134
+ };
135
+
136
+ /**
137
+ * Calculates height for the donut vertical layout.
138
+ *
139
+ * @param {number} totalLangs Total number of languages.
140
+ * @returns {number} Card height.
141
+ */
142
+ const calculateDonutVerticalLayoutHeight = (totalLangs) => {
143
+ return 300 + Math.round(totalLangs / 2) * 25;
144
+ };
145
+
146
+ /**
147
+ * Calculates height for the pie layout.
148
+ *
149
+ * @param {number} totalLangs Total number of languages.
150
+ * @returns {number} Card height.
151
+ */
152
+ const calculatePieLayoutHeight = (totalLangs) => {
153
+ return 300 + Math.round(totalLangs / 2) * 25;
154
+ };
155
+
156
+ /**
157
+ * Calculates the center translation needed to keep the donut chart centred.
158
+ * @param {number} totalLangs Total number of languages.
159
+ * @returns {number} Donut center translation.
160
+ */
161
+ const donutCenterTranslation = (totalLangs) => {
162
+ return -45 + Math.max(totalLangs - 5, 0) * 16;
163
+ };
164
+
165
+ /**
166
+ * Trim top languages to lang_count while also hiding certain languages.
167
+ *
168
+ * @param {Record<string, Lang>} topLangs Top languages.
169
+ * @param {number} langs_count Number of languages to show.
170
+ * @param {string[]=} hide Languages to hide.
171
+ * @returns {{ langs: Lang[], totalLanguageSize: number }} Trimmed top languages and total size.
172
+ */
173
+ const trimTopLanguages = (topLangs, langs_count, hide) => {
174
+ let langs = Object.values(topLangs);
175
+ let langsToHide = {};
176
+ let langsCount = clampValue(langs_count, 1, MAXIMUM_LANGS_COUNT);
177
+
178
+ // populate langsToHide map for quick lookup
179
+ // while filtering out
180
+ if (hide) {
181
+ hide.forEach((langName) => {
182
+ // @ts-ignore
183
+ langsToHide[lowercaseTrim(langName)] = true;
184
+ });
185
+ }
186
+
187
+ // filter out languages to be hidden
188
+ langs = langs
189
+ .sort((a, b) => b.size - a.size)
190
+ .filter((lang) => {
191
+ // @ts-ignore
192
+ return !langsToHide[lowercaseTrim(lang.name)];
193
+ })
194
+ .slice(0, langsCount);
195
+
196
+ const totalLanguageSize = langs.reduce((acc, curr) => acc + curr.size, 0);
197
+
198
+ return { langs, totalLanguageSize };
199
+ };
200
+
201
+ /**
202
+ * Get display value corresponding to the format.
203
+ *
204
+ * @param {number} size Bytes size.
205
+ * @param {number} percentages Percentage value.
206
+ * @param {string} format Format of the stats.
207
+ * @returns {string} Display value.
208
+ */
209
+ const getDisplayValue = (size, percentages, format) => {
210
+ return format === "bytes" ? formatBytes(size) : `${percentages.toFixed(2)}%`;
211
+ };
212
+
213
+ /**
214
+ * Create progress bar text item for a programming language.
215
+ *
216
+ * @param {object} props Function properties.
217
+ * @param {number} props.width The card width
218
+ * @param {string} props.color Color of the programming language.
219
+ * @param {string} props.name Name of the programming language.
220
+ * @param {number} props.size Size of the programming language.
221
+ * @param {number} props.totalSize Total size of all languages.
222
+ * @param {string} props.statsFormat Stats format.
223
+ * @param {number} props.index Index of the programming language.
224
+ * @returns {string} Programming language SVG node.
225
+ */
226
+ const createProgressTextNode = ({
227
+ width,
228
+ color,
229
+ name,
230
+ size,
231
+ totalSize,
232
+ statsFormat,
233
+ index,
234
+ }) => {
235
+ const staggerDelay = (index + 3) * 150;
236
+ const paddingRight = 95;
237
+ const progressTextX = width - paddingRight + 10;
238
+ const progressWidth = width - paddingRight;
239
+
240
+ const progress = (size / totalSize) * 100;
241
+ const displayValue = getDisplayValue(size, progress, statsFormat);
242
+
243
+ return `
244
+ <g class="stagger" style="animation-delay: ${staggerDelay}ms">
245
+ <text data-testid="lang-name" x="2" y="15" class="lang-name">${name}</text>
246
+ <text x="${progressTextX}" y="34" class="lang-name">${displayValue}</text>
247
+ ${createProgressNode({
248
+ x: 0,
249
+ y: 25,
250
+ color,
251
+ width: progressWidth,
252
+ progress,
253
+ progressBarBackgroundColor: "#ddd",
254
+ delay: staggerDelay + 300,
255
+ })}
256
+ </g>
257
+ `;
258
+ };
259
+
260
+ /**
261
+ * Creates compact text item for a programming language.
262
+ *
263
+ * @param {object} props Function properties.
264
+ * @param {Lang} props.lang Programming language object.
265
+ * @param {number} props.totalSize Total size of all languages.
266
+ * @param {boolean=} props.hideProgress Whether to hide percentage.
267
+ * @param {string=} props.statsFormat Stats format
268
+ * @param {number} props.index Index of the programming language.
269
+ * @returns {string} Compact layout programming language SVG node.
270
+ */
271
+ const createCompactLangNode = ({
272
+ lang,
273
+ totalSize,
274
+ hideProgress,
275
+ statsFormat = "percentages",
276
+ index,
277
+ }) => {
278
+ const percentages = (lang.size / totalSize) * 100;
279
+ const displayValue = getDisplayValue(lang.size, percentages, statsFormat);
280
+
281
+ const staggerDelay = (index + 3) * 150;
282
+ const color = lang.color || "#858585";
283
+
284
+ return `
285
+ <g class="stagger" style="animation-delay: ${staggerDelay}ms">
286
+ <circle cx="5" cy="6" r="5" fill="${color}" />
287
+ <text data-testid="lang-name" x="15" y="10" class='lang-name'>
288
+ ${lang.name} ${hideProgress ? "" : displayValue}
289
+ </text>
290
+ </g>
291
+ `;
292
+ };
293
+
294
+ /**
295
+ * Create compact languages text items for all programming languages.
296
+ *
297
+ * @param {object} props Function properties.
298
+ * @param {Lang[]} props.langs Array of programming languages.
299
+ * @param {number} props.totalSize Total size of all languages.
300
+ * @param {boolean=} props.hideProgress Whether to hide percentage.
301
+ * @param {string=} props.statsFormat Stats format
302
+ * @returns {string} Programming languages SVG node.
303
+ */
304
+ const createLanguageTextNode = ({
305
+ langs,
306
+ totalSize,
307
+ hideProgress,
308
+ statsFormat,
309
+ }) => {
310
+ const longestLang = getLongestLang(langs);
311
+ const chunked = chunkArray(langs, langs.length / 2);
312
+ const layouts = chunked.map((array) => {
313
+ // @ts-ignore
314
+ const items = array.map((lang, index) =>
315
+ createCompactLangNode({
316
+ lang,
317
+ totalSize,
318
+ hideProgress,
319
+ statsFormat,
320
+ index,
321
+ }),
322
+ );
323
+ return flexLayout({
324
+ items,
325
+ gap: 25,
326
+ direction: "column",
327
+ }).join("");
328
+ });
329
+
330
+ const percent = ((longestLang.size / totalSize) * 100).toFixed(2);
331
+ const minGap = 150;
332
+ const maxGap = 20 + measureText(`${longestLang.name} ${percent}%`, 11);
333
+ return flexLayout({
334
+ items: layouts,
335
+ gap: maxGap < minGap ? minGap : maxGap,
336
+ }).join("");
337
+ };
338
+
339
+ /**
340
+ * Create donut languages text items for all programming languages.
341
+ *
342
+ * @param {object} props Function properties.
343
+ * @param {Lang[]} props.langs Array of programming languages.
344
+ * @param {number} props.totalSize Total size of all languages.
345
+ * @param {string} props.statsFormat Stats format
346
+ * @returns {string} Donut layout programming language SVG node.
347
+ */
348
+ const createDonutLanguagesNode = ({ langs, totalSize, statsFormat }) => {
349
+ return flexLayout({
350
+ items: langs.map((lang, index) => {
351
+ return createCompactLangNode({
352
+ lang,
353
+ totalSize,
354
+ hideProgress: false,
355
+ statsFormat,
356
+ index,
357
+ });
358
+ }),
359
+ gap: 32,
360
+ direction: "column",
361
+ }).join("");
362
+ };
363
+
364
+ /**
365
+ * Renders the default language card layout.
366
+ *
367
+ * @param {Lang[]} langs Array of programming languages.
368
+ * @param {number} width Card width.
369
+ * @param {number} totalLanguageSize Total size of all languages.
370
+ * @param {string} statsFormat Stats format.
371
+ * @returns {string} Normal layout card SVG object.
372
+ */
373
+ const renderNormalLayout = (langs, width, totalLanguageSize, statsFormat) => {
374
+ return flexLayout({
375
+ items: langs.map((lang, index) => {
376
+ return createProgressTextNode({
377
+ width,
378
+ name: lang.name,
379
+ color: lang.color || DEFAULT_LANG_COLOR,
380
+ size: lang.size,
381
+ totalSize: totalLanguageSize,
382
+ statsFormat,
383
+ index,
384
+ });
385
+ }),
386
+ gap: 40,
387
+ direction: "column",
388
+ }).join("");
389
+ };
390
+
391
+ /**
392
+ * Renders the compact language card layout.
393
+ *
394
+ * @param {Lang[]} langs Array of programming languages.
395
+ * @param {number} width Card width.
396
+ * @param {number} totalLanguageSize Total size of all languages.
397
+ * @param {boolean=} hideProgress Whether to hide progress bar.
398
+ * @param {string} statsFormat Stats format.
399
+ * @returns {string} Compact layout card SVG object.
400
+ */
401
+ const renderCompactLayout = (
402
+ langs,
403
+ width,
404
+ totalLanguageSize,
405
+ hideProgress,
406
+ statsFormat = "percentages",
407
+ ) => {
408
+ const paddingRight = 50;
409
+ const offsetWidth = width - paddingRight;
410
+ // progressOffset holds the previous language's width and used to offset the next language
411
+ // so that we can stack them one after another, like this: [--][----][---]
412
+ let progressOffset = 0;
413
+ const compactProgressBar = langs
414
+ .map((lang) => {
415
+ const percentage = parseFloat(
416
+ ((lang.size / totalLanguageSize) * offsetWidth).toFixed(2),
417
+ );
418
+
419
+ const progress = percentage < 10 ? percentage + 10 : percentage;
420
+
421
+ const output = `
422
+ <rect
423
+ mask="url(#rect-mask)"
424
+ data-testid="lang-progress"
425
+ x="${progressOffset}"
426
+ y="0"
427
+ width="${progress}"
428
+ height="8"
429
+ fill="${lang.color || "#858585"}"
430
+ />
431
+ `;
432
+ progressOffset += percentage;
433
+ return output;
434
+ })
435
+ .join("");
436
+
437
+ return `
438
+ ${
439
+ hideProgress
440
+ ? ""
441
+ : `
442
+ <mask id="rect-mask">
443
+ <rect x="0" y="0" width="${offsetWidth}" height="8" fill="white" rx="5"/>
444
+ </mask>
445
+ ${compactProgressBar}
446
+ `
447
+ }
448
+ <g transform="translate(0, ${hideProgress ? "0" : "25"})">
449
+ ${createLanguageTextNode({
450
+ langs,
451
+ totalSize: totalLanguageSize,
452
+ hideProgress,
453
+ statsFormat,
454
+ })}
455
+ </g>
456
+ `;
457
+ };
458
+
459
+ /**
460
+ * Renders donut vertical layout to display user's most frequently used programming languages.
461
+ *
462
+ * @param {Lang[]} langs Array of programming languages.
463
+ * @param {number} totalLanguageSize Total size of all languages.
464
+ * @param {string} statsFormat Stats format.
465
+ * @returns {string} Compact layout card SVG object.
466
+ */
467
+ const renderDonutVerticalLayout = (langs, totalLanguageSize, statsFormat) => {
468
+ // Donut vertical chart radius and total length
469
+ const radius = 80;
470
+ const totalCircleLength = getCircleLength(radius);
471
+
472
+ // SVG circles
473
+ let circles = [];
474
+
475
+ // Start indent for donut vertical chart parts
476
+ let indent = 0;
477
+
478
+ // Start delay coefficient for donut vertical chart parts
479
+ let startDelayCoefficient = 1;
480
+
481
+ // Generate each donut vertical chart part
482
+ for (const lang of langs) {
483
+ const percentage = (lang.size / totalLanguageSize) * 100;
484
+ const circleLength = totalCircleLength * (percentage / 100);
485
+ const delay = startDelayCoefficient * 100;
486
+
487
+ circles.push(`
488
+ <g class="stagger" style="animation-delay: ${delay}ms">
489
+ <circle
490
+ cx="150"
491
+ cy="100"
492
+ r="${radius}"
493
+ fill="transparent"
494
+ stroke="${lang.color}"
495
+ stroke-width="25"
496
+ stroke-dasharray="${totalCircleLength}"
497
+ stroke-dashoffset="${indent}"
498
+ size="${percentage}"
499
+ data-testid="lang-donut"
500
+ />
501
+ </g>
502
+ `);
503
+
504
+ // Update the indent for the next part
505
+ indent += circleLength;
506
+ // Update the start delay coefficient for the next part
507
+ startDelayCoefficient += 1;
508
+ }
509
+
510
+ return `
511
+ <svg data-testid="lang-items">
512
+ <g transform="translate(0, 0)">
513
+ <svg data-testid="donut">
514
+ ${circles.join("")}
515
+ </svg>
516
+ </g>
517
+ <g transform="translate(0, 220)">
518
+ <svg data-testid="lang-names" x="${CARD_PADDING}">
519
+ ${createLanguageTextNode({
520
+ langs,
521
+ totalSize: totalLanguageSize,
522
+ hideProgress: false,
523
+ statsFormat,
524
+ })}
525
+ </svg>
526
+ </g>
527
+ </svg>
528
+ `;
529
+ };
530
+
531
+ /**
532
+ * Renders pie layout to display user's most frequently used programming languages.
533
+ *
534
+ * @param {Lang[]} langs Array of programming languages.
535
+ * @param {number} totalLanguageSize Total size of all languages.
536
+ * @param {string} statsFormat Stats format.
537
+ * @returns {string} Compact layout card SVG object.
538
+ */
539
+ const renderPieLayout = (langs, totalLanguageSize, statsFormat) => {
540
+ // Pie chart radius and center coordinates
541
+ const radius = 90;
542
+ const centerX = 150;
543
+ const centerY = 100;
544
+
545
+ // Start angle for the pie chart parts
546
+ let startAngle = 0;
547
+
548
+ // Start delay coefficient for the pie chart parts
549
+ let startDelayCoefficient = 1;
550
+
551
+ // SVG paths
552
+ const paths = [];
553
+
554
+ // Generate each pie chart part
555
+ for (const lang of langs) {
556
+ if (langs.length === 1) {
557
+ paths.push(`
558
+ <circle
559
+ cx="${centerX}"
560
+ cy="${centerY}"
561
+ r="${radius}"
562
+ stroke="none"
563
+ fill="${lang.color}"
564
+ data-testid="lang-pie"
565
+ size="100"
566
+ />
567
+ `);
568
+ break;
569
+ }
570
+
571
+ const langSizePart = lang.size / totalLanguageSize;
572
+ const percentage = langSizePart * 100;
573
+ // Calculate the angle for the current part
574
+ const angle = langSizePart * 360;
575
+
576
+ // Calculate the end angle
577
+ const endAngle = startAngle + angle;
578
+
579
+ // Calculate the coordinates of the start and end points of the arc
580
+ const startPoint = polarToCartesian(centerX, centerY, radius, startAngle);
581
+ const endPoint = polarToCartesian(centerX, centerY, radius, endAngle);
582
+
583
+ // Determine the large arc flag based on the angle
584
+ const largeArcFlag = angle > 180 ? 1 : 0;
585
+
586
+ // Calculate delay
587
+ const delay = startDelayCoefficient * 100;
588
+
589
+ // SVG arc markup
590
+ paths.push(`
591
+ <g class="stagger" style="animation-delay: ${delay}ms">
592
+ <path
593
+ data-testid="lang-pie"
594
+ size="${percentage}"
595
+ d="M ${centerX} ${centerY} L ${startPoint.x} ${startPoint.y} A ${radius} ${radius} 0 ${largeArcFlag} 1 ${endPoint.x} ${endPoint.y} Z"
596
+ fill="${lang.color}"
597
+ />
598
+ </g>
599
+ `);
600
+
601
+ // Update the start angle for the next part
602
+ startAngle = endAngle;
603
+ // Update the start delay coefficient for the next part
604
+ startDelayCoefficient += 1;
605
+ }
606
+
607
+ return `
608
+ <svg data-testid="lang-items">
609
+ <g transform="translate(0, 0)">
610
+ <svg data-testid="pie">
611
+ ${paths.join("")}
612
+ </svg>
613
+ </g>
614
+ <g transform="translate(0, 220)">
615
+ <svg data-testid="lang-names" x="${CARD_PADDING}">
616
+ ${createLanguageTextNode({
617
+ langs,
618
+ totalSize: totalLanguageSize,
619
+ hideProgress: false,
620
+ statsFormat,
621
+ })}
622
+ </svg>
623
+ </g>
624
+ </svg>
625
+ `;
626
+ };
627
+
628
+ /**
629
+ * Creates the SVG paths for the language donut chart.
630
+ *
631
+ * @param {number} cx Donut center x-position.
632
+ * @param {number} cy Donut center y-position.
633
+ * @param {number} radius Donut arc Radius.
634
+ * @param {number[]} percentages Array with donut section percentages.
635
+ * @returns {{d: string, percent: number}[]} Array of svg path elements
636
+ */
637
+ const createDonutPaths = (cx, cy, radius, percentages) => {
638
+ const paths = [];
639
+ let startAngle = 0;
640
+ let endAngle = 0;
641
+
642
+ const totalPercent = percentages.reduce((acc, curr) => acc + curr, 0);
643
+ for (let i = 0; i < percentages.length; i++) {
644
+ const tmpPath = {};
645
+
646
+ let percent = parseFloat(
647
+ ((percentages[i] / totalPercent) * 100).toFixed(2),
648
+ );
649
+
650
+ endAngle = 3.6 * percent + startAngle;
651
+ const startPoint = polarToCartesian(cx, cy, radius, endAngle - 90); // rotate donut 90 degrees counter-clockwise.
652
+ const endPoint = polarToCartesian(cx, cy, radius, startAngle - 90); // rotate donut 90 degrees counter-clockwise.
653
+ const largeArc = endAngle - startAngle <= 180 ? 0 : 1;
654
+
655
+ tmpPath.percent = percent;
656
+ tmpPath.d = `M ${startPoint.x} ${startPoint.y} A ${radius} ${radius} 0 ${largeArc} 0 ${endPoint.x} ${endPoint.y}`;
657
+
658
+ paths.push(tmpPath);
659
+ startAngle = endAngle;
660
+ }
661
+
662
+ return paths;
663
+ };
664
+
665
+ /**
666
+ * Renders the donut language card layout.
667
+ *
668
+ * @param {Lang[]} langs Array of programming languages.
669
+ * @param {number} width Card width.
670
+ * @param {number} totalLanguageSize Total size of all languages.
671
+ * @param {string} statsFormat Stats format.
672
+ * @returns {string} Donut layout card SVG object.
673
+ */
674
+ const renderDonutLayout = (langs, width, totalLanguageSize, statsFormat) => {
675
+ const centerX = width / 3;
676
+ const centerY = width / 3;
677
+ const radius = centerX - 60;
678
+ const strokeWidth = 12;
679
+
680
+ const colors = langs.map((lang) => lang.color);
681
+ const langsPercents = langs.map((lang) =>
682
+ parseFloat(((lang.size / totalLanguageSize) * 100).toFixed(2)),
683
+ );
684
+
685
+ const langPaths = createDonutPaths(centerX, centerY, radius, langsPercents);
686
+
687
+ const donutPaths =
688
+ langs.length === 1
689
+ ? `<circle cx="${centerX}" cy="${centerY}" r="${radius}" stroke="${colors[0]}" fill="none" stroke-width="${strokeWidth}" data-testid="lang-donut" size="100"/>`
690
+ : langPaths
691
+ .map((section, index) => {
692
+ const staggerDelay = (index + 3) * 100;
693
+ const delay = staggerDelay + 300;
694
+
695
+ const output = `
696
+ <g class="stagger" style="animation-delay: ${delay}ms">
697
+ <path
698
+ data-testid="lang-donut"
699
+ size="${section.percent}"
700
+ d="${section.d}"
701
+ stroke="${colors[index]}"
702
+ fill="none"
703
+ stroke-width="${strokeWidth}">
704
+ </path>
705
+ </g>
706
+ `;
707
+
708
+ return output;
709
+ })
710
+ .join("");
711
+
712
+ const donut = `<svg width="${width}" height="${width}">${donutPaths}</svg>`;
713
+
714
+ return `
715
+ <g transform="translate(0, 0)">
716
+ <g transform="translate(0, 0)">
717
+ ${createDonutLanguagesNode({ langs, totalSize: totalLanguageSize, statsFormat })}
718
+ </g>
719
+
720
+ <g transform="translate(125, ${donutCenterTranslation(langs.length)})">
721
+ ${donut}
722
+ </g>
723
+ </g>
724
+ `;
725
+ };
726
+
727
+ /**
728
+ * @typedef {import("./types").TopLangOptions} TopLangOptions
729
+ * @typedef {TopLangOptions["layout"]} Layout
730
+ */
731
+
732
+ /**
733
+ * Creates the no languages data SVG node.
734
+ *
735
+ * @param {object} props Object with function properties.
736
+ * @param {string} props.color No languages data text color.
737
+ * @param {string} props.text No languages data translated text.
738
+ * @param {Layout | undefined} props.layout Card layout.
739
+ * @returns {string} No languages data SVG node string.
740
+ */
741
+ const noLanguagesDataNode = ({ color, text, layout }) => {
742
+ return `
743
+ <text x="${
744
+ layout === "pie" || layout === "donut-vertical" ? CARD_PADDING : 0
745
+ }" y="11" class="stat bold" fill="${color}">${text}</text>
746
+ `;
747
+ };
748
+
749
+ /**
750
+ * Get default languages count for provided card layout.
751
+ *
752
+ * @param {object} props Function properties.
753
+ * @param {Layout=} props.layout Input layout string.
754
+ * @param {boolean=} props.hide_progress Input hide_progress parameter value.
755
+ * @returns {number} Default languages count for input layout.
756
+ */
757
+ const getDefaultLanguagesCountByLayout = ({ layout, hide_progress }) => {
758
+ if (layout === "compact" || hide_progress === true) {
759
+ return COMPACT_LAYOUT_DEFAULT_LANGS_COUNT;
760
+ } else if (layout === "donut") {
761
+ return DONUT_LAYOUT_DEFAULT_LANGS_COUNT;
762
+ } else if (layout === "donut-vertical") {
763
+ return DONUT_VERTICAL_LAYOUT_DEFAULT_LANGS_COUNT;
764
+ } else if (layout === "pie") {
765
+ return PIE_LAYOUT_DEFAULT_LANGS_COUNT;
766
+ } else {
767
+ return NORMAL_LAYOUT_DEFAULT_LANGS_COUNT;
768
+ }
769
+ };
770
+
771
+ /**
772
+ * @typedef {import('../fetchers/types').TopLangData} TopLangData
773
+ */
774
+
775
+ /**
776
+ * Renders card that display user's most frequently used programming languages.
777
+ *
778
+ * @param {TopLangData} topLangs User's most frequently used programming languages.
779
+ * @param {Partial<TopLangOptions>} options Card options.
780
+ * @returns {string} Language card SVG object.
781
+ */
782
+ const renderTopLanguages = (topLangs, options = {}) => {
783
+ const {
784
+ hide_title = false,
785
+ hide_border = false,
786
+ card_width,
787
+ title_color,
788
+ text_color,
789
+ bg_color,
790
+ hide,
791
+ hide_progress,
792
+ theme,
793
+ layout,
794
+ custom_title,
795
+ locale,
796
+ langs_count = getDefaultLanguagesCountByLayout({ layout, hide_progress }),
797
+ border_radius,
798
+ border_color,
799
+ disable_animations,
800
+ stats_format = "percentages",
801
+ } = options;
802
+
803
+ const i18n = new I18n({
804
+ locale,
805
+ translations: langCardLocales,
806
+ });
807
+
808
+ const { langs, totalLanguageSize } = trimTopLanguages(
809
+ topLangs,
810
+ langs_count,
811
+ hide,
812
+ );
813
+
814
+ let width = card_width
815
+ ? isNaN(card_width)
816
+ ? DEFAULT_CARD_WIDTH
817
+ : card_width < MIN_CARD_WIDTH
818
+ ? MIN_CARD_WIDTH
819
+ : card_width
820
+ : DEFAULT_CARD_WIDTH;
821
+ let height = calculateNormalLayoutHeight(langs.length);
822
+
823
+ // returns theme based colors with proper overrides and defaults
824
+ const colors = getCardColors({
825
+ title_color,
826
+ text_color,
827
+ bg_color,
828
+ border_color,
829
+ theme,
830
+ });
831
+
832
+ let finalLayout = "";
833
+ if (langs.length === 0) {
834
+ height = COMPACT_LAYOUT_BASE_HEIGHT;
835
+ finalLayout = noLanguagesDataNode({
836
+ color: colors.textColor,
837
+ text: i18n.t("langcard.nodata"),
838
+ layout,
839
+ });
840
+ } else if (layout === "pie") {
841
+ height = calculatePieLayoutHeight(langs.length);
842
+ finalLayout = renderPieLayout(langs, totalLanguageSize, stats_format);
843
+ } else if (layout === "donut-vertical") {
844
+ height = calculateDonutVerticalLayoutHeight(langs.length);
845
+ finalLayout = renderDonutVerticalLayout(
846
+ langs,
847
+ totalLanguageSize,
848
+ stats_format,
849
+ );
850
+ } else if (layout === "compact" || hide_progress == true) {
851
+ height =
852
+ calculateCompactLayoutHeight(langs.length) + (hide_progress ? -25 : 0);
853
+
854
+ finalLayout = renderCompactLayout(
855
+ langs,
856
+ width,
857
+ totalLanguageSize,
858
+ hide_progress,
859
+ stats_format,
860
+ );
861
+ } else if (layout === "donut") {
862
+ height = calculateDonutLayoutHeight(langs.length);
863
+ width = width + 50; // padding
864
+ finalLayout = renderDonutLayout(
865
+ langs,
866
+ width,
867
+ totalLanguageSize,
868
+ stats_format,
869
+ );
870
+ } else {
871
+ finalLayout = renderNormalLayout(
872
+ langs,
873
+ width,
874
+ totalLanguageSize,
875
+ stats_format,
876
+ );
877
+ }
878
+
879
+ const card = new Card({
880
+ customTitle: custom_title,
881
+ defaultTitle: i18n.t("langcard.title"),
882
+ width,
883
+ height,
884
+ border_radius,
885
+ colors,
886
+ });
887
+
888
+ if (disable_animations) {
889
+ card.disableAnimations();
890
+ }
891
+
892
+ card.setHideBorder(hide_border);
893
+ card.setHideTitle(hide_title);
894
+ card.setCSS(
895
+ `
896
+ @keyframes slideInAnimation {
897
+ from {
898
+ width: 0;
899
+ }
900
+ to {
901
+ width: calc(100%-100px);
902
+ }
903
+ }
904
+ @keyframes growWidthAnimation {
905
+ from {
906
+ width: 0;
907
+ }
908
+ to {
909
+ width: 100%;
910
+ }
911
+ }
912
+ .stat {
913
+ font: 600 14px 'Segoe UI', Ubuntu, "Helvetica Neue", Sans-Serif; fill: ${colors.textColor};
914
+ }
915
+ @supports(-moz-appearance: auto) {
916
+ /* Selector detects Firefox */
917
+ .stat { font-size:12px; }
918
+ }
919
+ .bold { font-weight: 700 }
920
+ .lang-name {
921
+ font: 400 11px "Segoe UI", Ubuntu, Sans-Serif;
922
+ fill: ${colors.textColor};
923
+ }
924
+ .stagger {
925
+ opacity: 0;
926
+ animation: fadeInAnimation 0.3s ease-in-out forwards;
927
+ }
928
+ #rect-mask rect{
929
+ animation: slideInAnimation 1s ease-in-out forwards;
930
+ }
931
+ .lang-progress{
932
+ animation: growWidthAnimation 0.6s ease-in-out forwards;
933
+ }
934
+ `,
935
+ );
936
+
937
+ if (layout === "pie" || layout === "donut-vertical") {
938
+ return card.render(finalLayout);
939
+ }
940
+
941
+ return card.render(`
942
+ <svg data-testid="lang-items" x="${CARD_PADDING}">
943
+ ${finalLayout}
944
+ </svg>
945
+ `);
946
+ };
947
+
948
+ export {
949
+ getLongestLang,
950
+ degreesToRadians,
951
+ radiansToDegrees,
952
+ polarToCartesian,
953
+ cartesianToPolar,
954
+ getCircleLength,
955
+ calculateCompactLayoutHeight,
956
+ calculateNormalLayoutHeight,
957
+ calculateDonutLayoutHeight,
958
+ calculateDonutVerticalLayoutHeight,
959
+ calculatePieLayoutHeight,
960
+ donutCenterTranslation,
961
+ trimTopLanguages,
962
+ renderTopLanguages,
963
+ MIN_CARD_WIDTH,
964
+ getDefaultLanguagesCountByLayout,
965
+ };
src/cards/types.d.ts ADDED
@@ -0,0 +1,67 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ type ThemeNames = keyof typeof import("../../themes/index.js");
2
+ type RankIcon = "default" | "github" | "percentile";
3
+
4
+ export type CommonOptions = {
5
+ title_color: string;
6
+ icon_color: string;
7
+ text_color: string;
8
+ bg_color: string;
9
+ theme: ThemeNames;
10
+ border_radius: number;
11
+ border_color: string;
12
+ locale: string;
13
+ hide_border: boolean;
14
+ };
15
+
16
+ export type StatCardOptions = CommonOptions & {
17
+ hide: string[];
18
+ show_icons: boolean;
19
+ hide_title: boolean;
20
+ card_width: number;
21
+ hide_rank: boolean;
22
+ include_all_commits: boolean;
23
+ commits_year: number;
24
+ line_height: number | string;
25
+ custom_title: string;
26
+ disable_animations: boolean;
27
+ number_format: string;
28
+ number_precision: number;
29
+ ring_color: string;
30
+ text_bold: boolean;
31
+ rank_icon: RankIcon;
32
+ show: string[];
33
+ };
34
+
35
+ export type RepoCardOptions = CommonOptions & {
36
+ show_owner: boolean;
37
+ description_lines_count: number;
38
+ };
39
+
40
+ export type TopLangOptions = CommonOptions & {
41
+ hide_title: boolean;
42
+ card_width: number;
43
+ hide: string[];
44
+ layout: "compact" | "normal" | "donut" | "donut-vertical" | "pie";
45
+ custom_title: string;
46
+ langs_count: number;
47
+ disable_animations: boolean;
48
+ hide_progress: boolean;
49
+ stats_format: "percentages" | "bytes";
50
+ };
51
+
52
+ export type WakaTimeOptions = CommonOptions & {
53
+ hide_title: boolean;
54
+ hide: string[];
55
+ card_width: number;
56
+ line_height: string;
57
+ hide_progress: boolean;
58
+ custom_title: string;
59
+ layout: "compact" | "normal";
60
+ langs_count: number;
61
+ display_format: "time" | "percent";
62
+ disable_animations: boolean;
63
+ };
64
+
65
+ export type GistCardOptions = CommonOptions & {
66
+ show_owner: boolean;
67
+ };
src/cards/wakatime.js ADDED
@@ -0,0 +1,481 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // @ts-check
2
+
3
+ import { Card } from "../common/Card.js";
4
+ import { getCardColors } from "../common/color.js";
5
+ import { I18n } from "../common/I18n.js";
6
+ import { clampValue, lowercaseTrim } from "../common/ops.js";
7
+ import { createProgressNode, flexLayout } from "../common/render.js";
8
+ import { wakatimeCardLocales } from "../translations.js";
9
+
10
+ /** Import language colors.
11
+ *
12
+ * @description Here we use the workaround found in
13
+ * https://stackoverflow.com/questions/66726365/how-should-i-import-json-in-node
14
+ * since vercel is using v16.14.0 which does not yet support json imports without the
15
+ * --experimental-json-modules flag.
16
+ */
17
+ import { createRequire } from "module";
18
+ const require = createRequire(import.meta.url);
19
+ const languageColors = require("../common/languageColors.json"); // now works
20
+
21
+ const DEFAULT_CARD_WIDTH = 495;
22
+ const MIN_CARD_WIDTH = 250;
23
+ const COMPACT_LAYOUT_MIN_WIDTH = 400;
24
+ const DEFAULT_LINE_HEIGHT = 25;
25
+ const PROGRESSBAR_PADDING = 130;
26
+ const HIDDEN_PROGRESSBAR_PADDING = 170;
27
+ const COMPACT_LAYOUT_PROGRESSBAR_PADDING = 25;
28
+ const TOTAL_TEXT_WIDTH = 275;
29
+
30
+ /**
31
+ * Creates the no coding activity SVG node.
32
+ *
33
+ * @param {object} props The function properties.
34
+ * @param {string} props.color No coding activity text color.
35
+ * @param {string} props.text No coding activity translated text.
36
+ * @returns {string} No coding activity SVG node string.
37
+ */
38
+ const noCodingActivityNode = ({ color, text }) => {
39
+ return `
40
+ <text x="25" y="11" class="stat bold" fill="${color}">${text}</text>
41
+ `;
42
+ };
43
+
44
+ /**
45
+ * @typedef {import('../fetchers/types').WakaTimeLang} WakaTimeLang
46
+ */
47
+
48
+ /**
49
+ * Format language value.
50
+ *
51
+ * @param {Object} args The function arguments.
52
+ * @param {WakaTimeLang} args.lang The language object.
53
+ * @param {"time" | "percent"} args.display_format The display format of the language node.
54
+ * @returns {string} The formatted language value.
55
+ */
56
+ const formatLanguageValue = ({ display_format, lang }) => {
57
+ return display_format === "percent"
58
+ ? `${lang.percent.toFixed(2).toString()} %`
59
+ : lang.text;
60
+ };
61
+
62
+ /**
63
+ * Create compact WakaTime layout.
64
+ *
65
+ * @param {Object} args The function arguments.
66
+ * @param {WakaTimeLang} args.lang The languages array.
67
+ * @param {number} args.x The x position of the language node.
68
+ * @param {number} args.y The y position of the language node.
69
+ * @param {"time" | "percent"} args.display_format The display format of the language node.
70
+ * @returns {string} The compact layout language SVG node.
71
+ */
72
+ const createCompactLangNode = ({ lang, x, y, display_format }) => {
73
+ // @ts-ignore
74
+ const color = languageColors[lang.name] || "#858585";
75
+ const value = formatLanguageValue({ display_format, lang });
76
+
77
+ return `
78
+ <g transform="translate(${x}, ${y})">
79
+ <circle cx="5" cy="6" r="5" fill="${color}" />
80
+ <text data-testid="lang-name" x="15" y="10" class='lang-name'>
81
+ ${lang.name} - ${value}
82
+ </text>
83
+ </g>
84
+ `;
85
+ };
86
+
87
+ /**
88
+ * Create WakaTime language text node item.
89
+ *
90
+ * @param {Object} args The function arguments.
91
+ * @param {WakaTimeLang[]} args.langs The language objects.
92
+ * @param {number} args.y The y position of the language node.
93
+ * @param {"time" | "percent"} args.display_format The display format of the language node.
94
+ * @param {number} args.card_width Width in px of the card.
95
+ * @returns {string[]} The language text node items.
96
+ */
97
+ const createLanguageTextNode = ({ langs, y, display_format, card_width }) => {
98
+ const LEFT_X = 25;
99
+ const RIGHT_X_BASE = 230;
100
+ const rightOffset = (card_width - DEFAULT_CARD_WIDTH) / 2;
101
+ const RIGHT_X = RIGHT_X_BASE + rightOffset;
102
+
103
+ return langs.map((lang, index) => {
104
+ const isLeft = index % 2 === 0;
105
+ return createCompactLangNode({
106
+ lang,
107
+ x: isLeft ? LEFT_X : RIGHT_X,
108
+ y: y + DEFAULT_LINE_HEIGHT * Math.floor(index / 2),
109
+ display_format,
110
+ });
111
+ });
112
+ };
113
+
114
+ /**
115
+ * Create WakaTime text item.
116
+ *
117
+ * @param {Object} args The function arguments.
118
+ * @param {string} args.id The id of the text node item.
119
+ * @param {string} args.label The label of the text node item.
120
+ * @param {string} args.value The value of the text node item.
121
+ * @param {number} args.index The index of the text node item.
122
+ * @param {number} args.percent Percentage of the text node item.
123
+ * @param {boolean=} args.hideProgress Whether to hide the progress bar.
124
+ * @param {string} args.progressBarColor The color of the progress bar.
125
+ * @param {string} args.progressBarBackgroundColor The color of the progress bar background.
126
+ * @param {number} args.progressBarWidth The width of the progress bar.
127
+ * @returns {string} The text SVG node.
128
+ */
129
+ const createTextNode = ({
130
+ id,
131
+ label,
132
+ value,
133
+ index,
134
+ percent,
135
+ hideProgress,
136
+ progressBarColor,
137
+ progressBarBackgroundColor,
138
+ progressBarWidth,
139
+ }) => {
140
+ const staggerDelay = (index + 3) * 150;
141
+ const cardProgress = hideProgress
142
+ ? null
143
+ : createProgressNode({
144
+ x: 110,
145
+ y: 4,
146
+ progress: percent,
147
+ color: progressBarColor,
148
+ width: progressBarWidth,
149
+ // @ts-ignore
150
+ name: label,
151
+ progressBarBackgroundColor,
152
+ delay: staggerDelay + 300,
153
+ });
154
+
155
+ return `
156
+ <g class="stagger" style="animation-delay: ${staggerDelay}ms" transform="translate(25, 0)">
157
+ <text class="stat bold" y="12.5" data-testid="${id}">${label}:</text>
158
+ <text
159
+ class="stat"
160
+ x="${hideProgress ? HIDDEN_PROGRESSBAR_PADDING : PROGRESSBAR_PADDING + progressBarWidth}"
161
+ y="12.5"
162
+ >${value}</text>
163
+ ${cardProgress}
164
+ </g>
165
+ `;
166
+ };
167
+
168
+ /**
169
+ * Recalculating percentages so that, compact layout's progress bar does not break when
170
+ * hiding languages.
171
+ *
172
+ * @param {WakaTimeLang[]} languages The languages array.
173
+ * @returns {void} The recalculated languages array.
174
+ */
175
+ const recalculatePercentages = (languages) => {
176
+ const totalSum = languages.reduce(
177
+ (totalSum, language) => totalSum + language.percent,
178
+ 0,
179
+ );
180
+ const weight = +(100 / totalSum).toFixed(2);
181
+ languages.forEach((language) => {
182
+ language.percent = +(language.percent * weight).toFixed(2);
183
+ });
184
+ };
185
+
186
+ /**
187
+ * Retrieves CSS styles for a card.
188
+ *
189
+ * @param {Object} colors The colors to use for the card.
190
+ * @param {string} colors.titleColor The title color.
191
+ * @param {string} colors.textColor The text color.
192
+ * @returns {string} Card CSS styles.
193
+ */
194
+ const getStyles = ({
195
+ // eslint-disable-next-line no-unused-vars
196
+ titleColor,
197
+ textColor,
198
+ }) => {
199
+ return `
200
+ .stat {
201
+ font: 600 14px 'Segoe UI', Ubuntu, "Helvetica Neue", Sans-Serif; fill: ${textColor};
202
+ }
203
+ @supports(-moz-appearance: auto) {
204
+ /* Selector detects Firefox */
205
+ .stat { font-size:12px; }
206
+ }
207
+ .stagger {
208
+ opacity: 0;
209
+ animation: fadeInAnimation 0.3s ease-in-out forwards;
210
+ }
211
+ .not_bold { font-weight: 400 }
212
+ .bold { font-weight: 700 }
213
+ `;
214
+ };
215
+
216
+ /**
217
+ * Normalize incoming width (string or number) and clamp to minimum.
218
+ *
219
+ * @param {Object} args The function arguments.
220
+ * @param {WakaTimeOptions["layout"] | undefined} args.layout The incoming layout value.
221
+ * @param {number|undefined} args.value The incoming width value.
222
+ * @returns {number} The normalized width value.
223
+ */
224
+ const normalizeCardWidth = ({ value, layout }) => {
225
+ if (value === undefined || value === null || isNaN(value)) {
226
+ return DEFAULT_CARD_WIDTH;
227
+ }
228
+ return Math.max(
229
+ layout === "compact" ? COMPACT_LAYOUT_MIN_WIDTH : MIN_CARD_WIDTH,
230
+ value,
231
+ );
232
+ };
233
+
234
+ /**
235
+ * @typedef {import('../fetchers/types').WakaTimeData} WakaTimeData
236
+ * @typedef {import('./types').WakaTimeOptions} WakaTimeOptions
237
+ */
238
+
239
+ /**
240
+ * Renders WakaTime card.
241
+ *
242
+ * @param {Partial<WakaTimeData>} stats WakaTime stats.
243
+ * @param {Partial<WakaTimeOptions>} options Card options.
244
+ * @returns {string} WakaTime card SVG.
245
+ */
246
+ const renderWakatimeCard = (stats = {}, options = { hide: [] }) => {
247
+ let { languages = [] } = stats;
248
+ const {
249
+ hide_title = false,
250
+ hide_border = false,
251
+ card_width,
252
+ hide,
253
+ line_height = DEFAULT_LINE_HEIGHT,
254
+ title_color,
255
+ icon_color,
256
+ text_color,
257
+ bg_color,
258
+ theme = "default",
259
+ hide_progress,
260
+ custom_title,
261
+ locale,
262
+ layout,
263
+ langs_count = languages.length,
264
+ border_radius,
265
+ border_color,
266
+ display_format = "time",
267
+ disable_animations,
268
+ } = options;
269
+
270
+ const normalizedWidth = normalizeCardWidth({ value: card_width, layout });
271
+
272
+ const shouldHideLangs = Array.isArray(hide) && hide.length > 0;
273
+ if (shouldHideLangs) {
274
+ const languagesToHide = new Set(hide.map((lang) => lowercaseTrim(lang)));
275
+ languages = languages.filter(
276
+ (lang) => !languagesToHide.has(lowercaseTrim(lang.name)),
277
+ );
278
+ }
279
+
280
+ // Since the percentages are sorted in descending order, we can just
281
+ // slice from the beginning without sorting.
282
+ languages = languages.slice(0, langs_count);
283
+ recalculatePercentages(languages);
284
+
285
+ const i18n = new I18n({
286
+ locale,
287
+ translations: wakatimeCardLocales,
288
+ });
289
+
290
+ const lheight = parseInt(String(line_height), 10);
291
+
292
+ const langsCount = clampValue(langs_count, 1, langs_count);
293
+
294
+ // returns theme based colors with proper overrides and defaults
295
+ const { titleColor, textColor, iconColor, bgColor, borderColor } =
296
+ getCardColors({
297
+ title_color,
298
+ icon_color,
299
+ text_color,
300
+ bg_color,
301
+ border_color,
302
+ theme,
303
+ });
304
+
305
+ const filteredLanguages = languages
306
+ .filter((language) => language.hours || language.minutes)
307
+ .slice(0, langsCount);
308
+
309
+ // Calculate the card height depending on how many items there are
310
+ // but if rank circle is visible clamp the minimum height to `150`
311
+ let height = Math.max(45 + (filteredLanguages.length + 1) * lheight, 150);
312
+
313
+ const cssStyles = getStyles({
314
+ titleColor,
315
+ textColor,
316
+ });
317
+
318
+ let finalLayout = "";
319
+
320
+ // RENDER COMPACT LAYOUT
321
+ if (layout === "compact") {
322
+ const width = normalizedWidth - 5;
323
+ height =
324
+ 90 + Math.round(filteredLanguages.length / 2) * DEFAULT_LINE_HEIGHT;
325
+
326
+ // progressOffset holds the previous language's width and used to offset the next language
327
+ // so that we can stack them one after another, like this: [--][----][---]
328
+ let progressOffset = 0;
329
+ const compactProgressBar = filteredLanguages
330
+ .map((language) => {
331
+ const progress =
332
+ ((width - COMPACT_LAYOUT_PROGRESSBAR_PADDING) * language.percent) /
333
+ 100;
334
+
335
+ // @ts-ignore
336
+ const languageColor = languageColors[language.name] || "#858585";
337
+
338
+ const output = `
339
+ <rect
340
+ mask="url(#rect-mask)"
341
+ data-testid="lang-progress"
342
+ x="${progressOffset}"
343
+ y="0"
344
+ width="${progress}"
345
+ height="8"
346
+ fill="${languageColor}"
347
+ />
348
+ `;
349
+ progressOffset += progress;
350
+ return output;
351
+ })
352
+ .join("");
353
+
354
+ finalLayout = `
355
+ <mask id="rect-mask">
356
+ <rect x="${COMPACT_LAYOUT_PROGRESSBAR_PADDING}" y="0" width="${width - 2 * COMPACT_LAYOUT_PROGRESSBAR_PADDING}" height="8" fill="white" rx="5" />
357
+ </mask>
358
+ ${compactProgressBar}
359
+ ${
360
+ filteredLanguages.length
361
+ ? createLanguageTextNode({
362
+ y: 25,
363
+ langs: filteredLanguages,
364
+ display_format,
365
+ card_width: normalizedWidth,
366
+ }).join("")
367
+ : noCodingActivityNode({
368
+ // @ts-ignore
369
+ color: textColor,
370
+ text: stats.is_coding_activity_visible
371
+ ? stats.is_other_usage_visible
372
+ ? i18n.t("wakatimecard.nocodingactivity")
373
+ : i18n.t("wakatimecard.nocodedetails")
374
+ : i18n.t("wakatimecard.notpublic"),
375
+ })
376
+ }
377
+ `;
378
+ } else {
379
+ finalLayout = flexLayout({
380
+ items: filteredLanguages.length
381
+ ? filteredLanguages.map((language, index) => {
382
+ return createTextNode({
383
+ id: language.name,
384
+ label: language.name,
385
+ value: formatLanguageValue({ display_format, lang: language }),
386
+ index,
387
+ percent: language.percent,
388
+ // @ts-ignore
389
+ progressBarColor: titleColor,
390
+ // @ts-ignore
391
+ progressBarBackgroundColor: textColor,
392
+ hideProgress: hide_progress,
393
+ progressBarWidth: normalizedWidth - TOTAL_TEXT_WIDTH,
394
+ });
395
+ })
396
+ : [
397
+ noCodingActivityNode({
398
+ // @ts-ignore
399
+ color: textColor,
400
+ text: stats.is_coding_activity_visible
401
+ ? stats.is_other_usage_visible
402
+ ? i18n.t("wakatimecard.nocodingactivity")
403
+ : i18n.t("wakatimecard.nocodedetails")
404
+ : i18n.t("wakatimecard.notpublic"),
405
+ }),
406
+ ],
407
+ gap: lheight,
408
+ direction: "column",
409
+ }).join("");
410
+ }
411
+
412
+ // Get title range text
413
+ let titleText = i18n.t("wakatimecard.title");
414
+ switch (stats.range) {
415
+ case "last_7_days":
416
+ titleText += ` (${i18n.t("wakatimecard.last7days")})`;
417
+ break;
418
+ case "last_year":
419
+ titleText += ` (${i18n.t("wakatimecard.lastyear")})`;
420
+ break;
421
+ }
422
+
423
+ const card = new Card({
424
+ customTitle: custom_title,
425
+ defaultTitle: titleText,
426
+ width: normalizedWidth,
427
+ height,
428
+ border_radius,
429
+ colors: {
430
+ titleColor,
431
+ textColor,
432
+ iconColor,
433
+ bgColor,
434
+ borderColor,
435
+ },
436
+ });
437
+
438
+ if (disable_animations) {
439
+ card.disableAnimations();
440
+ }
441
+
442
+ card.setHideBorder(hide_border);
443
+ card.setHideTitle(hide_title);
444
+ card.setCSS(
445
+ `
446
+ ${cssStyles}
447
+ @keyframes slideInAnimation {
448
+ from {
449
+ width: 0;
450
+ }
451
+ to {
452
+ width: calc(100%-100px);
453
+ }
454
+ }
455
+ @keyframes growWidthAnimation {
456
+ from {
457
+ width: 0;
458
+ }
459
+ to {
460
+ width: 100%;
461
+ }
462
+ }
463
+ .lang-name { font: 400 11px 'Segoe UI', Ubuntu, Sans-Serif; fill: ${textColor} }
464
+ #rect-mask rect{
465
+ animation: slideInAnimation 1s ease-in-out forwards;
466
+ }
467
+ .lang-progress{
468
+ animation: growWidthAnimation 0.6s ease-in-out forwards;
469
+ }
470
+ `,
471
+ );
472
+
473
+ return card.render(`
474
+ <svg x="0" y="0" width="100%">
475
+ ${finalLayout}
476
+ </svg>
477
+ `);
478
+ };
479
+
480
+ export { renderWakatimeCard };
481
+ export default renderWakatimeCard;
src/common/Card.js ADDED
@@ -0,0 +1,275 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // @ts-check
2
+
3
+ import { encodeHTML } from "./html.js";
4
+ import { flexLayout } from "./render.js";
5
+
6
+ class Card {
7
+ /**
8
+ * Creates a new card instance.
9
+ *
10
+ * @param {object} args Card arguments.
11
+ * @param {number=} args.width Card width.
12
+ * @param {number=} args.height Card height.
13
+ * @param {number=} args.border_radius Card border radius.
14
+ * @param {string=} args.customTitle Card custom title.
15
+ * @param {string=} args.defaultTitle Card default title.
16
+ * @param {string=} args.titlePrefixIcon Card title prefix icon.
17
+ * @param {object} [args.colors={}] Card colors arguments.
18
+ * @param {string=} args.colors.titleColor Card title color.
19
+ * @param {string=} args.colors.textColor Card text color.
20
+ * @param {string=} args.colors.iconColor Card icon color.
21
+ * @param {string|string[]=} args.colors.bgColor Card background color.
22
+ * @param {string=} args.colors.borderColor Card border color.
23
+ */
24
+ constructor({
25
+ width = 100,
26
+ height = 100,
27
+ border_radius = 4.5,
28
+ colors = {},
29
+ customTitle,
30
+ defaultTitle = "",
31
+ titlePrefixIcon,
32
+ }) {
33
+ this.width = width;
34
+ this.height = height;
35
+
36
+ this.hideBorder = false;
37
+ this.hideTitle = false;
38
+
39
+ this.border_radius = border_radius;
40
+
41
+ // returns theme based colors with proper overrides and defaults
42
+ this.colors = colors;
43
+ this.title =
44
+ customTitle === undefined
45
+ ? encodeHTML(defaultTitle)
46
+ : encodeHTML(customTitle);
47
+
48
+ this.css = "";
49
+
50
+ this.paddingX = 25;
51
+ this.paddingY = 35;
52
+ this.titlePrefixIcon = titlePrefixIcon;
53
+ this.animations = true;
54
+ this.a11yTitle = "";
55
+ this.a11yDesc = "";
56
+ }
57
+
58
+ /**
59
+ * @returns {void}
60
+ */
61
+ disableAnimations() {
62
+ this.animations = false;
63
+ }
64
+
65
+ /**
66
+ * @param {Object} props The props object.
67
+ * @param {string} props.title Accessibility title.
68
+ * @param {string} props.desc Accessibility description.
69
+ * @returns {void}
70
+ */
71
+ setAccessibilityLabel({ title, desc }) {
72
+ this.a11yTitle = title;
73
+ this.a11yDesc = desc;
74
+ }
75
+
76
+ /**
77
+ * @param {string} value The CSS to add to the card.
78
+ * @returns {void}
79
+ */
80
+ setCSS(value) {
81
+ this.css = value;
82
+ }
83
+
84
+ /**
85
+ * @param {boolean} value Whether to hide the border or not.
86
+ * @returns {void}
87
+ */
88
+ setHideBorder(value) {
89
+ this.hideBorder = value;
90
+ }
91
+
92
+ /**
93
+ * @param {boolean} value Whether to hide the title or not.
94
+ * @returns {void}
95
+ */
96
+ setHideTitle(value) {
97
+ this.hideTitle = value;
98
+ if (value) {
99
+ this.height -= 30;
100
+ }
101
+ }
102
+
103
+ /**
104
+ * @param {string} text The title to set.
105
+ * @returns {void}
106
+ */
107
+ setTitle(text) {
108
+ this.title = text;
109
+ }
110
+
111
+ /**
112
+ * @returns {string} The rendered card title.
113
+ */
114
+ renderTitle() {
115
+ const titleText = `
116
+ <text
117
+ x="0"
118
+ y="0"
119
+ class="header"
120
+ data-testid="header"
121
+ >${this.title}</text>
122
+ `;
123
+
124
+ const prefixIcon = `
125
+ <svg
126
+ class="icon"
127
+ x="0"
128
+ y="-13"
129
+ viewBox="0 0 16 16"
130
+ version="1.1"
131
+ width="16"
132
+ height="16"
133
+ >
134
+ ${this.titlePrefixIcon}
135
+ </svg>
136
+ `;
137
+ return `
138
+ <g
139
+ data-testid="card-title"
140
+ transform="translate(${this.paddingX}, ${this.paddingY})"
141
+ >
142
+ ${flexLayout({
143
+ items: [this.titlePrefixIcon ? prefixIcon : "", titleText],
144
+ gap: 25,
145
+ }).join("")}
146
+ </g>
147
+ `;
148
+ }
149
+
150
+ /**
151
+ * @returns {string} The rendered card gradient.
152
+ */
153
+ renderGradient() {
154
+ if (typeof this.colors.bgColor !== "object") {
155
+ return "";
156
+ }
157
+
158
+ const gradients = this.colors.bgColor.slice(1);
159
+ return typeof this.colors.bgColor === "object"
160
+ ? `
161
+ <defs>
162
+ <linearGradient
163
+ id="gradient"
164
+ gradientTransform="rotate(${this.colors.bgColor[0]})"
165
+ gradientUnits="userSpaceOnUse"
166
+ >
167
+ ${gradients.map((grad, index) => {
168
+ let offset = (index * 100) / (gradients.length - 1);
169
+ return `<stop offset="${offset}%" stop-color="#${grad}" />`;
170
+ })}
171
+ </linearGradient>
172
+ </defs>
173
+ `
174
+ : "";
175
+ }
176
+
177
+ /**
178
+ * Retrieves css animations for a card.
179
+ *
180
+ * @returns {string} Animation css.
181
+ */
182
+ getAnimations = () => {
183
+ return `
184
+ /* Animations */
185
+ @keyframes scaleInAnimation {
186
+ from {
187
+ transform: translate(-5px, 5px) scale(0);
188
+ }
189
+ to {
190
+ transform: translate(-5px, 5px) scale(1);
191
+ }
192
+ }
193
+ @keyframes fadeInAnimation {
194
+ from {
195
+ opacity: 0;
196
+ }
197
+ to {
198
+ opacity: 1;
199
+ }
200
+ }
201
+ `;
202
+ };
203
+
204
+ /**
205
+ * @param {string} body The inner body of the card.
206
+ * @returns {string} The rendered card.
207
+ */
208
+ render(body) {
209
+ return `
210
+ <svg
211
+ width="${this.width}"
212
+ height="${this.height}"
213
+ viewBox="0 0 ${this.width} ${this.height}"
214
+ fill="none"
215
+ xmlns="http://www.w3.org/2000/svg"
216
+ role="img"
217
+ aria-labelledby="descId"
218
+ >
219
+ <title id="titleId">${this.a11yTitle}</title>
220
+ <desc id="descId">${this.a11yDesc}</desc>
221
+ <style>
222
+ .header {
223
+ font: 600 18px 'Segoe UI', Ubuntu, Sans-Serif;
224
+ fill: ${this.colors.titleColor};
225
+ animation: fadeInAnimation 0.8s ease-in-out forwards;
226
+ }
227
+ @supports(-moz-appearance: auto) {
228
+ /* Selector detects Firefox */
229
+ .header { font-size: 15.5px; }
230
+ }
231
+ ${this.css}
232
+
233
+ ${process.env.NODE_ENV === "test" ? "" : this.getAnimations()}
234
+ ${
235
+ this.animations === false
236
+ ? `* { animation-duration: 0s !important; animation-delay: 0s !important; }`
237
+ : ""
238
+ }
239
+ </style>
240
+
241
+ ${this.renderGradient()}
242
+
243
+ <rect
244
+ data-testid="card-bg"
245
+ x="0.5"
246
+ y="0.5"
247
+ rx="${this.border_radius}"
248
+ height="99%"
249
+ stroke="${this.colors.borderColor}"
250
+ width="${this.width - 1}"
251
+ fill="${
252
+ typeof this.colors.bgColor === "object"
253
+ ? "url(#gradient)"
254
+ : this.colors.bgColor
255
+ }"
256
+ stroke-opacity="${this.hideBorder ? 0 : 1}"
257
+ />
258
+
259
+ ${this.hideTitle ? "" : this.renderTitle()}
260
+
261
+ <g
262
+ data-testid="main-card-body"
263
+ transform="translate(0, ${
264
+ this.hideTitle ? this.paddingX : this.paddingY + 20
265
+ })"
266
+ >
267
+ ${body}
268
+ </g>
269
+ </svg>
270
+ `;
271
+ }
272
+ }
273
+
274
+ export { Card };
275
+ export default Card;
src/common/I18n.js ADDED
@@ -0,0 +1,43 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // @ts-check
2
+
3
+ const FALLBACK_LOCALE = "en";
4
+
5
+ /**
6
+ * I18n translation class.
7
+ */
8
+ class I18n {
9
+ /**
10
+ * Constructor.
11
+ *
12
+ * @param {Object} options Options.
13
+ * @param {string=} options.locale Locale.
14
+ * @param {any} options.translations Translations.
15
+ */
16
+ constructor({ locale, translations }) {
17
+ this.locale = locale || FALLBACK_LOCALE;
18
+ this.translations = translations;
19
+ }
20
+
21
+ /**
22
+ * Get translation.
23
+ *
24
+ * @param {string} str String to translate.
25
+ * @returns {string} Translated string.
26
+ */
27
+ t(str) {
28
+ if (!this.translations[str]) {
29
+ throw new Error(`${str} Translation string not found`);
30
+ }
31
+
32
+ if (!this.translations[str][this.locale]) {
33
+ throw new Error(
34
+ `'${str}' translation not found for locale '${this.locale}'`,
35
+ );
36
+ }
37
+
38
+ return this.translations[str][this.locale];
39
+ }
40
+ }
41
+
42
+ export { I18n };
43
+ export default I18n;
src/common/access.js ADDED
@@ -0,0 +1,69 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // @ts-check
2
+
3
+ import { renderError } from "./render.js";
4
+ import { blacklist } from "./blacklist.js";
5
+ import { whitelist, gistWhitelist } from "./envs.js";
6
+
7
+ const NOT_WHITELISTED_USERNAME_MESSAGE = "This username is not whitelisted";
8
+ const NOT_WHITELISTED_GIST_MESSAGE = "This gist ID is not whitelisted";
9
+ const BLACKLISTED_MESSAGE = "This username is blacklisted";
10
+
11
+ /**
12
+ * Guards access using whitelist/blacklist.
13
+ *
14
+ * @param {Object} args The parameters object.
15
+ * @param {any} args.res The response object.
16
+ * @param {string} args.id Resource identifier (username or gist id).
17
+ * @param {"username"|"gist"|"wakatime"} args.type The type of identifier.
18
+ * @param {{ title_color?: string, text_color?: string, bg_color?: string, border_color?: string, theme?: string }} args.colors Color options for the error card.
19
+ * @returns {{ isPassed: boolean, result?: any }} The result object indicating success or failure.
20
+ */
21
+ const guardAccess = ({ res, id, type, colors }) => {
22
+ if (!["username", "gist", "wakatime"].includes(type)) {
23
+ throw new Error(
24
+ 'Invalid type. Expected "username", "gist", or "wakatime".',
25
+ );
26
+ }
27
+
28
+ const currentWhitelist = type === "gist" ? gistWhitelist : whitelist;
29
+ const notWhitelistedMsg =
30
+ type === "gist"
31
+ ? NOT_WHITELISTED_GIST_MESSAGE
32
+ : NOT_WHITELISTED_USERNAME_MESSAGE;
33
+
34
+ if (Array.isArray(currentWhitelist) && !currentWhitelist.includes(id)) {
35
+ const result = res.send(
36
+ renderError({
37
+ message: notWhitelistedMsg,
38
+ secondaryMessage: "Please deploy your own instance",
39
+ renderOptions: {
40
+ ...colors,
41
+ show_repo_link: false,
42
+ },
43
+ }),
44
+ );
45
+ return { isPassed: false, result };
46
+ }
47
+
48
+ if (
49
+ type === "username" &&
50
+ currentWhitelist === undefined &&
51
+ blacklist.includes(id)
52
+ ) {
53
+ const result = res.send(
54
+ renderError({
55
+ message: BLACKLISTED_MESSAGE,
56
+ secondaryMessage: "Please deploy your own instance",
57
+ renderOptions: {
58
+ ...colors,
59
+ show_repo_link: false,
60
+ },
61
+ }),
62
+ );
63
+ return { isPassed: false, result };
64
+ }
65
+
66
+ return { isPassed: true };
67
+ };
68
+
69
+ export { guardAccess };
src/common/blacklist.js ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ const blacklist = [
2
+ "renovate-bot",
3
+ "technote-space",
4
+ "sw-yx",
5
+ "YourUsername",
6
+ "[YourUsername]",
7
+ ];
8
+
9
+ export { blacklist };
10
+ export default blacklist;
src/common/cache.js ADDED
@@ -0,0 +1,153 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // @ts-check
2
+
3
+ import { clampValue } from "./ops.js";
4
+
5
+ const MIN = 60;
6
+ const HOUR = 60 * MIN;
7
+ const DAY = 24 * HOUR;
8
+
9
+ /**
10
+ * Common durations in seconds.
11
+ */
12
+ const DURATIONS = {
13
+ ONE_MINUTE: MIN,
14
+ FIVE_MINUTES: 5 * MIN,
15
+ TEN_MINUTES: 10 * MIN,
16
+ FIFTEEN_MINUTES: 15 * MIN,
17
+ THIRTY_MINUTES: 30 * MIN,
18
+
19
+ TWO_HOURS: 2 * HOUR,
20
+ FOUR_HOURS: 4 * HOUR,
21
+ SIX_HOURS: 6 * HOUR,
22
+ EIGHT_HOURS: 8 * HOUR,
23
+ TWELVE_HOURS: 12 * HOUR,
24
+
25
+ ONE_DAY: DAY,
26
+ TWO_DAY: 2 * DAY,
27
+ SIX_DAY: 6 * DAY,
28
+ TEN_DAY: 10 * DAY,
29
+ };
30
+
31
+ /**
32
+ * Common cache TTL values in seconds.
33
+ */
34
+ const CACHE_TTL = {
35
+ STATS_CARD: {
36
+ DEFAULT: DURATIONS.ONE_DAY,
37
+ MIN: DURATIONS.TWELVE_HOURS,
38
+ MAX: DURATIONS.TWO_DAY,
39
+ },
40
+ TOP_LANGS_CARD: {
41
+ DEFAULT: DURATIONS.SIX_DAY,
42
+ MIN: DURATIONS.TWO_DAY,
43
+ MAX: DURATIONS.TEN_DAY,
44
+ },
45
+ PIN_CARD: {
46
+ DEFAULT: DURATIONS.TEN_DAY,
47
+ MIN: DURATIONS.ONE_DAY,
48
+ MAX: DURATIONS.TEN_DAY,
49
+ },
50
+ GIST_CARD: {
51
+ DEFAULT: DURATIONS.TWO_DAY,
52
+ MIN: DURATIONS.ONE_DAY,
53
+ MAX: DURATIONS.TEN_DAY,
54
+ },
55
+ WAKATIME_CARD: {
56
+ DEFAULT: DURATIONS.ONE_DAY,
57
+ MIN: DURATIONS.TWELVE_HOURS,
58
+ MAX: DURATIONS.TWO_DAY,
59
+ },
60
+ ERROR: DURATIONS.TEN_MINUTES,
61
+ };
62
+
63
+ /**
64
+ * Resolves the cache seconds based on the requested, default, min, and max values.
65
+ *
66
+ * @param {Object} args The parameters object.
67
+ * @param {number} args.requested The requested cache seconds.
68
+ * @param {number} args.def The default cache seconds.
69
+ * @param {number} args.min The minimum cache seconds.
70
+ * @param {number} args.max The maximum cache seconds.
71
+ * @returns {number} The resolved cache seconds.
72
+ */
73
+ const resolveCacheSeconds = ({ requested, def, min, max }) => {
74
+ let cacheSeconds = clampValue(isNaN(requested) ? def : requested, min, max);
75
+
76
+ if (process.env.CACHE_SECONDS) {
77
+ const envCacheSeconds = parseInt(process.env.CACHE_SECONDS, 10);
78
+ if (!isNaN(envCacheSeconds)) {
79
+ cacheSeconds = envCacheSeconds;
80
+ }
81
+ }
82
+
83
+ return cacheSeconds;
84
+ };
85
+
86
+ /**
87
+ * Disables caching by setting appropriate headers on the response object.
88
+ *
89
+ * @param {any} res The response object.
90
+ */
91
+ const disableCaching = (res) => {
92
+ // Disable caching for browsers, shared caches/CDNs, and GitHub Camo.
93
+ res.setHeader(
94
+ "Cache-Control",
95
+ "no-cache, no-store, must-revalidate, max-age=0, s-maxage=0",
96
+ );
97
+ res.setHeader("Pragma", "no-cache");
98
+ res.setHeader("Expires", "0");
99
+ };
100
+
101
+ /**
102
+ * Sets the Cache-Control headers on the response object.
103
+ *
104
+ * @param {any} res The response object.
105
+ * @param {number} cacheSeconds The cache seconds to set in the headers.
106
+ */
107
+ const setCacheHeaders = (res, cacheSeconds) => {
108
+ if (cacheSeconds < 1 || process.env.NODE_ENV === "development") {
109
+ disableCaching(res);
110
+ return;
111
+ }
112
+
113
+ res.setHeader(
114
+ "Cache-Control",
115
+ `max-age=${cacheSeconds}, ` +
116
+ `s-maxage=${cacheSeconds}, ` +
117
+ `stale-while-revalidate=${DURATIONS.ONE_DAY}`,
118
+ );
119
+ };
120
+
121
+ /**
122
+ * Sets the Cache-Control headers for error responses on the response object.
123
+ *
124
+ * @param {any} res The response object.
125
+ */
126
+ const setErrorCacheHeaders = (res) => {
127
+ const envCacheSeconds = process.env.CACHE_SECONDS
128
+ ? parseInt(process.env.CACHE_SECONDS, 10)
129
+ : NaN;
130
+ if (
131
+ (!isNaN(envCacheSeconds) && envCacheSeconds < 1) ||
132
+ process.env.NODE_ENV === "development"
133
+ ) {
134
+ disableCaching(res);
135
+ return;
136
+ }
137
+
138
+ // Use lower cache period for errors.
139
+ res.setHeader(
140
+ "Cache-Control",
141
+ `max-age=${CACHE_TTL.ERROR}, ` +
142
+ `s-maxage=${CACHE_TTL.ERROR}, ` +
143
+ `stale-while-revalidate=${DURATIONS.ONE_DAY}`,
144
+ );
145
+ };
146
+
147
+ export {
148
+ resolveCacheSeconds,
149
+ setCacheHeaders,
150
+ setErrorCacheHeaders,
151
+ DURATIONS,
152
+ CACHE_TTL,
153
+ };
src/common/color.js ADDED
@@ -0,0 +1,144 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // @ts-check
2
+
3
+ import { themes } from "../../themes/index.js";
4
+
5
+ /**
6
+ * Checks if a string is a valid hex color.
7
+ *
8
+ * @param {string} hexColor String to check.
9
+ * @returns {boolean} True if the given string is a valid hex color.
10
+ */
11
+ const isValidHexColor = (hexColor) => {
12
+ return new RegExp(
13
+ /^([A-Fa-f0-9]{8}|[A-Fa-f0-9]{6}|[A-Fa-f0-9]{3}|[A-Fa-f0-9]{4})$/,
14
+ ).test(hexColor);
15
+ };
16
+
17
+ /**
18
+ * Check if the given string is a valid gradient.
19
+ *
20
+ * @param {string[]} colors Array of colors.
21
+ * @returns {boolean} True if the given string is a valid gradient.
22
+ */
23
+ const isValidGradient = (colors) => {
24
+ return (
25
+ colors.length > 2 &&
26
+ colors.slice(1).every((color) => isValidHexColor(color))
27
+ );
28
+ };
29
+
30
+ /**
31
+ * Retrieves a gradient if color has more than one valid hex codes else a single color.
32
+ *
33
+ * @param {string} color The color to parse.
34
+ * @param {string | string[]} fallbackColor The fallback color.
35
+ * @returns {string | string[]} The gradient or color.
36
+ */
37
+ const fallbackColor = (color, fallbackColor) => {
38
+ let gradient = null;
39
+
40
+ let colors = color ? color.split(",") : [];
41
+ if (colors.length > 1 && isValidGradient(colors)) {
42
+ gradient = colors;
43
+ }
44
+
45
+ return (
46
+ (gradient ? gradient : isValidHexColor(color) && `#${color}`) ||
47
+ fallbackColor
48
+ );
49
+ };
50
+
51
+ /**
52
+ * Object containing card colors.
53
+ * @typedef {{
54
+ * titleColor: string;
55
+ * iconColor: string;
56
+ * textColor: string;
57
+ * bgColor: string | string[];
58
+ * borderColor: string;
59
+ * ringColor: string;
60
+ * }} CardColors
61
+ */
62
+
63
+ /**
64
+ * Returns theme based colors with proper overrides and defaults.
65
+ *
66
+ * @param {Object} args Function arguments.
67
+ * @param {string=} args.title_color Card title color.
68
+ * @param {string=} args.text_color Card text color.
69
+ * @param {string=} args.icon_color Card icon color.
70
+ * @param {string=} args.bg_color Card background color.
71
+ * @param {string=} args.border_color Card border color.
72
+ * @param {string=} args.ring_color Card ring color.
73
+ * @param {string=} args.theme Card theme.
74
+ * @returns {CardColors} Card colors.
75
+ */
76
+ const getCardColors = ({
77
+ title_color,
78
+ text_color,
79
+ icon_color,
80
+ bg_color,
81
+ border_color,
82
+ ring_color,
83
+ theme,
84
+ }) => {
85
+ const defaultTheme = themes["default"];
86
+ const isThemeProvided = theme !== null && theme !== undefined;
87
+
88
+ // @ts-ignore
89
+ const selectedTheme = isThemeProvided ? themes[theme] : defaultTheme;
90
+
91
+ const defaultBorderColor =
92
+ "border_color" in selectedTheme
93
+ ? selectedTheme.border_color
94
+ : // @ts-ignore
95
+ defaultTheme.border_color;
96
+
97
+ // get the color provided by the user else the theme color
98
+ // finally if both colors are invalid fallback to default theme
99
+ const titleColor = fallbackColor(
100
+ title_color || selectedTheme.title_color,
101
+ "#" + defaultTheme.title_color,
102
+ );
103
+
104
+ // get the color provided by the user else the theme color
105
+ // finally if both colors are invalid we use the titleColor
106
+ const ringColor = fallbackColor(
107
+ // @ts-ignore
108
+ ring_color || selectedTheme.ring_color,
109
+ titleColor,
110
+ );
111
+ const iconColor = fallbackColor(
112
+ icon_color || selectedTheme.icon_color,
113
+ "#" + defaultTheme.icon_color,
114
+ );
115
+ const textColor = fallbackColor(
116
+ text_color || selectedTheme.text_color,
117
+ "#" + defaultTheme.text_color,
118
+ );
119
+ const bgColor = fallbackColor(
120
+ bg_color || selectedTheme.bg_color,
121
+ "#" + defaultTheme.bg_color,
122
+ );
123
+
124
+ const borderColor = fallbackColor(
125
+ border_color || defaultBorderColor,
126
+ "#" + defaultBorderColor,
127
+ );
128
+
129
+ if (
130
+ typeof titleColor !== "string" ||
131
+ typeof textColor !== "string" ||
132
+ typeof ringColor !== "string" ||
133
+ typeof iconColor !== "string" ||
134
+ typeof borderColor !== "string"
135
+ ) {
136
+ throw new Error(
137
+ "Unexpected behavior, all colors except background should be string.",
138
+ );
139
+ }
140
+
141
+ return { titleColor, iconColor, textColor, bgColor, borderColor, ringColor };
142
+ };
143
+
144
+ export { isValidHexColor, isValidGradient, getCardColors };
src/common/envs.js ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // @ts-check
2
+
3
+ const whitelist = process.env.WHITELIST
4
+ ? process.env.WHITELIST.split(",")
5
+ : undefined;
6
+
7
+ const gistWhitelist = process.env.GIST_WHITELIST
8
+ ? process.env.GIST_WHITELIST.split(",")
9
+ : undefined;
10
+
11
+ const excludeRepositories = process.env.EXCLUDE_REPO
12
+ ? process.env.EXCLUDE_REPO.split(",")
13
+ : [];
14
+
15
+ export { whitelist, gistWhitelist, excludeRepositories };
src/common/error.js ADDED
@@ -0,0 +1,84 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // @ts-check
2
+
3
+ /**
4
+ * @type {string} A general message to ask user to try again later.
5
+ */
6
+ const TRY_AGAIN_LATER = "Please try again later";
7
+
8
+ /**
9
+ * @type {Object<string, string>} A map of error types to secondary error messages.
10
+ */
11
+ const SECONDARY_ERROR_MESSAGES = {
12
+ MAX_RETRY:
13
+ "You can deploy own instance or wait until public will be no longer limited",
14
+ NO_TOKENS:
15
+ "Please add an env variable called PAT_1 with your GitHub API token in vercel",
16
+ USER_NOT_FOUND: "Make sure the provided username is not an organization",
17
+ GRAPHQL_ERROR: TRY_AGAIN_LATER,
18
+ GITHUB_REST_API_ERROR: TRY_AGAIN_LATER,
19
+ WAKATIME_USER_NOT_FOUND: "Make sure you have a public WakaTime profile",
20
+ };
21
+
22
+ /**
23
+ * Custom error class to handle custom GRS errors.
24
+ */
25
+ class CustomError extends Error {
26
+ /**
27
+ * Custom error constructor.
28
+ *
29
+ * @param {string} message Error message.
30
+ * @param {string} type Error type.
31
+ */
32
+ constructor(message, type) {
33
+ super(message);
34
+ this.type = type;
35
+ this.secondaryMessage = SECONDARY_ERROR_MESSAGES[type] || type;
36
+ }
37
+
38
+ static MAX_RETRY = "MAX_RETRY";
39
+ static NO_TOKENS = "NO_TOKENS";
40
+ static USER_NOT_FOUND = "USER_NOT_FOUND";
41
+ static GRAPHQL_ERROR = "GRAPHQL_ERROR";
42
+ static GITHUB_REST_API_ERROR = "GITHUB_REST_API_ERROR";
43
+ static WAKATIME_ERROR = "WAKATIME_ERROR";
44
+ }
45
+
46
+ /**
47
+ * Missing query parameter class.
48
+ */
49
+ class MissingParamError extends Error {
50
+ /**
51
+ * Missing query parameter error constructor.
52
+ *
53
+ * @param {string[]} missedParams An array of missing parameters names.
54
+ * @param {string=} secondaryMessage Optional secondary message to display.
55
+ */
56
+ constructor(missedParams, secondaryMessage) {
57
+ const msg = `Missing params ${missedParams
58
+ .map((p) => `"${p}"`)
59
+ .join(", ")} make sure you pass the parameters in URL`;
60
+ super(msg);
61
+ this.missedParams = missedParams;
62
+ this.secondaryMessage = secondaryMessage;
63
+ }
64
+ }
65
+
66
+ /**
67
+ * Retrieve secondary message from an error object.
68
+ *
69
+ * @param {Error} err The error object.
70
+ * @returns {string|undefined} The secondary message if available, otherwise undefined.
71
+ */
72
+ const retrieveSecondaryMessage = (err) => {
73
+ return "secondaryMessage" in err && typeof err.secondaryMessage === "string"
74
+ ? err.secondaryMessage
75
+ : undefined;
76
+ };
77
+
78
+ export {
79
+ CustomError,
80
+ MissingParamError,
81
+ SECONDARY_ERROR_MESSAGES,
82
+ TRY_AGAIN_LATER,
83
+ retrieveSecondaryMessage,
84
+ };
src/common/fmt.js ADDED
@@ -0,0 +1,90 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // @ts-check
2
+
3
+ import wrap from "word-wrap";
4
+ import { encodeHTML } from "./html.js";
5
+
6
+ /**
7
+ * Retrieves num with suffix k(thousands) precise to given decimal places.
8
+ *
9
+ * @param {number} num The number to format.
10
+ * @param {number=} precision The number of decimal places to include.
11
+ * @returns {string|number} The formatted number.
12
+ */
13
+ const kFormatter = (num, precision) => {
14
+ const abs = Math.abs(num);
15
+ const sign = Math.sign(num);
16
+
17
+ if (typeof precision === "number" && !isNaN(precision)) {
18
+ return (sign * (abs / 1000)).toFixed(precision) + "k";
19
+ }
20
+
21
+ if (abs < 1000) {
22
+ return sign * abs;
23
+ }
24
+
25
+ return sign * parseFloat((abs / 1000).toFixed(1)) + "k";
26
+ };
27
+
28
+ /**
29
+ * Convert bytes to a human-readable string representation.
30
+ *
31
+ * @param {number} bytes The number of bytes to convert.
32
+ * @returns {string} The human-readable representation of bytes.
33
+ * @throws {Error} If bytes is negative or too large.
34
+ */
35
+ const formatBytes = (bytes) => {
36
+ if (bytes < 0) {
37
+ throw new Error("Bytes must be a non-negative number");
38
+ }
39
+
40
+ if (bytes === 0) {
41
+ return "0 B";
42
+ }
43
+
44
+ const sizes = ["B", "KB", "MB", "GB", "TB", "PB", "EB"];
45
+ const base = 1024;
46
+ const i = Math.floor(Math.log(bytes) / Math.log(base));
47
+
48
+ if (i >= sizes.length) {
49
+ throw new Error("Bytes is too large to convert to a human-readable string");
50
+ }
51
+
52
+ return `${(bytes / Math.pow(base, i)).toFixed(1)} ${sizes[i]}`;
53
+ };
54
+
55
+ /**
56
+ * Split text over multiple lines based on the card width.
57
+ *
58
+ * @param {string} text Text to split.
59
+ * @param {number} width Line width in number of characters.
60
+ * @param {number} maxLines Maximum number of lines.
61
+ * @returns {string[]} Array of lines.
62
+ */
63
+ const wrapTextMultiline = (text, width = 59, maxLines = 3) => {
64
+ const fullWidthComma = ",";
65
+ const encoded = encodeHTML(text);
66
+ const isChinese = encoded.includes(fullWidthComma);
67
+
68
+ let wrapped = [];
69
+
70
+ if (isChinese) {
71
+ wrapped = encoded.split(fullWidthComma); // Chinese full punctuation
72
+ } else {
73
+ wrapped = wrap(encoded, {
74
+ width,
75
+ }).split("\n"); // Split wrapped lines to get an array of lines
76
+ }
77
+
78
+ const lines = wrapped.map((line) => line.trim()).slice(0, maxLines); // Only consider maxLines lines
79
+
80
+ // Add "..." to the last line if the text exceeds maxLines
81
+ if (wrapped.length > maxLines) {
82
+ lines[maxLines - 1] += "...";
83
+ }
84
+
85
+ // Remove empty lines if text fits in less than maxLines lines
86
+ const multiLineText = lines.filter(Boolean);
87
+ return multiLineText;
88
+ };
89
+
90
+ export { kFormatter, formatBytes, wrapTextMultiline };
src/common/html.js ADDED
@@ -0,0 +1,19 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // @ts-check
2
+
3
+ /**
4
+ * Encode string as HTML.
5
+ *
6
+ * @see https://stackoverflow.com/a/48073476/10629172
7
+ *
8
+ * @param {string} str String to encode.
9
+ * @returns {string} Encoded string.
10
+ */
11
+ const encodeHTML = (str) => {
12
+ return str
13
+ .replace(/[\u00A0-\u9999<>&](?!#)/gim, (i) => {
14
+ return "&#" + i.charCodeAt(0) + ";";
15
+ })
16
+ .replace(/\u0008/gim, "");
17
+ };
18
+
19
+ export { encodeHTML };
src/common/http.js ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // @ts-check
2
+
3
+ import axios from "axios";
4
+
5
+ /**
6
+ * Send GraphQL request to GitHub API.
7
+ *
8
+ * @param {import('axios').AxiosRequestConfig['data']} data Request data.
9
+ * @param {import('axios').AxiosRequestConfig['headers']} headers Request headers.
10
+ * @returns {Promise<any>} Request response.
11
+ */
12
+ const request = (data, headers) => {
13
+ return axios({
14
+ url: "https://api.github.com/graphql",
15
+ method: "post",
16
+ headers,
17
+ data,
18
+ });
19
+ };
20
+
21
+ export { request };
src/common/icons.js ADDED
@@ -0,0 +1,55 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // @ts-check
2
+
3
+ const icons = {
4
+ star: `<path fill-rule="evenodd" d="M8 .25a.75.75 0 01.673.418l1.882 3.815 4.21.612a.75.75 0 01.416 1.279l-3.046 2.97.719 4.192a.75.75 0 01-1.088.791L8 12.347l-3.766 1.98a.75.75 0 01-1.088-.79l.72-4.194L.818 6.374a.75.75 0 01.416-1.28l4.21-.611L7.327.668A.75.75 0 018 .25zm0 2.445L6.615 5.5a.75.75 0 01-.564.41l-3.097.45 2.24 2.184a.75.75 0 01.216.664l-.528 3.084 2.769-1.456a.75.75 0 01.698 0l2.77 1.456-.53-3.084a.75.75 0 01.216-.664l2.24-2.183-3.096-.45a.75.75 0 01-.564-.41L8 2.694v.001z"/>`,
5
+ commits: `<path fill-rule="evenodd" d="M1.643 3.143L.427 1.927A.25.25 0 000 2.104V5.75c0 .138.112.25.25.25h3.646a.25.25 0 00.177-.427L2.715 4.215a6.5 6.5 0 11-1.18 4.458.75.75 0 10-1.493.154 8.001 8.001 0 101.6-5.684zM7.75 4a.75.75 0 01.75.75v2.992l2.028.812a.75.75 0 01-.557 1.392l-2.5-1A.75.75 0 017 8.25v-3.5A.75.75 0 017.75 4z"/>`,
6
+ prs: `<path fill-rule="evenodd" d="M7.177 3.073L9.573.677A.25.25 0 0110 .854v4.792a.25.25 0 01-.427.177L7.177 3.427a.25.25 0 010-.354zM3.75 2.5a.75.75 0 100 1.5.75.75 0 000-1.5zm-2.25.75a2.25 2.25 0 113 2.122v5.256a2.251 2.251 0 11-1.5 0V5.372A2.25 2.25 0 011.5 3.25zM11 2.5h-1V4h1a1 1 0 011 1v5.628a2.251 2.251 0 101.5 0V5A2.5 2.5 0 0011 2.5zm1 10.25a.75.75 0 111.5 0 .75.75 0 01-1.5 0zM3.75 12a.75.75 0 100 1.5.75.75 0 000-1.5z"/>`,
7
+ prs_merged: `<path fill-rule="evenodd" d="M5.45 5.154A4.25 4.25 0 0 0 9.25 7.5h1.378a2.251 2.251 0 1 1 0 1.5H9.25A5.734 5.734 0 0 1 5 7.123v3.505a2.25 2.25 0 1 1-1.5 0V5.372a2.25 2.25 0 1 1 1.95-.218ZM4.25 13.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5Zm8.5-4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5ZM5 3.25a.75.75 0 1 0 0 .005V3.25Z" />`,
8
+ prs_merged_percentage: `<path fill-rule="evenodd" d="M13.442 2.558a.625.625 0 0 1 0 .884l-10 10a.625.625 0 1 1-.884-.884l10-10a.625.625 0 0 1 .884 0zM4.5 6a1.5 1.5 0 1 1 0-3 1.5 1.5 0 0 1 0 3zm0 1a2.5 2.5 0 1 0 0-5 2.5 2.5 0 0 0 0 5zm7 6a1.5 1.5 0 1 1 0-3 1.5 1.5 0 0 1 0 3zm0 1a2.5 2.5 0 1 0 0-5 2.5 2.5 0 0 0 0 5z" />`,
9
+ issues: `<path fill-rule="evenodd" d="M8 1.5a6.5 6.5 0 100 13 6.5 6.5 0 000-13zM0 8a8 8 0 1116 0A8 8 0 010 8zm9 3a1 1 0 11-2 0 1 1 0 012 0zm-.25-6.25a.75.75 0 00-1.5 0v3.5a.75.75 0 001.5 0v-3.5z"/>`,
10
+ icon: `<path fill-rule="evenodd" d="M2 2.5A2.5 2.5 0 014.5 0h8.75a.75.75 0 01.75.75v12.5a.75.75 0 01-.75.75h-2.5a.75.75 0 110-1.5h1.75v-2h-8a1 1 0 00-.714 1.7.75.75 0 01-1.072 1.05A2.495 2.495 0 012 11.5v-9zm10.5-1V9h-8c-.356 0-.694.074-1 .208V2.5a1 1 0 011-1h8zM5 12.25v3.25a.25.25 0 00.4.2l1.45-1.087a.25.25 0 01.3 0L8.6 15.7a.25.25 0 00.4-.2v-3.25a.25.25 0 00-.25-.25h-3.5a.25.25 0 00-.25.25z"/>`,
11
+ contribs: `<path fill-rule="evenodd" d="M2 2.5A2.5 2.5 0 014.5 0h8.75a.75.75 0 01.75.75v12.5a.75.75 0 01-.75.75h-2.5a.75.75 0 110-1.5h1.75v-2h-8a1 1 0 00-.714 1.7.75.75 0 01-1.072 1.05A2.495 2.495 0 012 11.5v-9zm10.5-1V9h-8c-.356 0-.694.074-1 .208V2.5a1 1 0 011-1h8zM5 12.25v3.25a.25.25 0 00.4.2l1.45-1.087a.25.25 0 01.3 0L8.6 15.7a.25.25 0 00.4-.2v-3.25a.25.25 0 00-.25-.25h-3.5a.25.25 0 00-.25.25z"/>`,
12
+ fork: `<path fill-rule="evenodd" d="M5 3.25a.75.75 0 11-1.5 0 .75.75 0 011.5 0zm0 2.122a2.25 2.25 0 10-1.5 0v.878A2.25 2.25 0 005.75 8.5h1.5v2.128a2.251 2.251 0 101.5 0V8.5h1.5a2.25 2.25 0 002.25-2.25v-.878a2.25 2.25 0 10-1.5 0v.878a.75.75 0 01-.75.75h-4.5A.75.75 0 015 6.25v-.878zm3.75 7.378a.75.75 0 11-1.5 0 .75.75 0 011.5 0zm3-8.75a.75.75 0 100-1.5.75.75 0 000 1.5z"></path>`,
13
+ reviews: `<path fill-rule="evenodd" d="M8 2c1.981 0 3.671.992 4.933 2.078 1.27 1.091 2.187 2.345 2.637 3.023a1.62 1.62 0 0 1 0 1.798c-.45.678-1.367 1.932-2.637 3.023C11.67 13.008 9.981 14 8 14c-1.981 0-3.671-.992-4.933-2.078C1.797 10.83.88 9.576.43 8.898a1.62 1.62 0 0 1 0-1.798c.45-.677 1.367-1.931 2.637-3.022C4.33 2.992 6.019 2 8 2ZM1.679 7.932a.12.12 0 0 0 0 .136c.411.622 1.241 1.75 2.366 2.717C5.176 11.758 6.527 12.5 8 12.5c1.473 0 2.825-.742 3.955-1.715 1.124-.967 1.954-2.096 2.366-2.717a.12.12 0 0 0 0-.136c-.412-.621-1.242-1.75-2.366-2.717C10.824 4.242 9.473 3.5 8 3.5c-1.473 0-2.825.742-3.955 1.715-1.124.967-1.954 2.096-2.366 2.717ZM8 10a2 2 0 1 1-.001-3.999A2 2 0 0 1 8 10Z"/>`,
14
+ discussions_started: `<path fill-rule="evenodd" d="M1.75 1h8.5c.966 0 1.75.784 1.75 1.75v5.5A1.75 1.75 0 0 1 10.25 10H7.061l-2.574 2.573A1.458 1.458 0 0 1 2 11.543V10h-.25A1.75 1.75 0 0 1 0 8.25v-5.5C0 1.784.784 1 1.75 1ZM1.5 2.75v5.5c0 .138.112.25.25.25h1a.75.75 0 0 1 .75.75v2.19l2.72-2.72a.749.749 0 0 1 .53-.22h3.5a.25.25 0 0 0 .25-.25v-5.5a.25.25 0 0 0-.25-.25h-8.5a.25.25 0 0 0-.25.25Zm13 2a.25.25 0 0 0-.25-.25h-.5a.75.75 0 0 1 0-1.5h.5c.966 0 1.75.784 1.75 1.75v5.5A1.75 1.75 0 0 1 14.25 12H14v1.543a1.458 1.458 0 0 1-2.487 1.03L9.22 12.28a.749.749 0 0 1 .326-1.275.749.749 0 0 1 .734.215l2.22 2.22v-2.19a.75.75 0 0 1 .75-.75h1a.25.25 0 0 0 .25-.25Z" />`,
15
+ discussions_answered: `<path fill-rule="evenodd" d="M13.78 4.22a.75.75 0 0 1 0 1.06l-7.25 7.25a.75.75 0 0 1-1.06 0L2.22 9.28a.751.751 0 0 1 .018-1.042.751.751 0 0 1 1.042-.018L6 10.94l6.72-6.72a.75.75 0 0 1 1.06 0Z" />`,
16
+ gist: `<path fill-rule="evenodd" d="M0 1.75C0 .784.784 0 1.75 0h12.5C15.216 0 16 .784 16 1.75v12.5A1.75 1.75 0 0 1 14.25 16H1.75A1.75 1.75 0 0 1 0 14.25Zm1.75-.25a.25.25 0 0 0-.25.25v12.5c0 .138.112.25.25.25h12.5a.25.25 0 0 0 .25-.25V1.75a.25.25 0 0 0-.25-.25Zm7.47 3.97a.75.75 0 0 1 1.06 0l2 2a.75.75 0 0 1 0 1.06l-2 2a.749.749 0 0 1-1.275-.326.749.749 0 0 1 .215-.734L10.69 8 9.22 6.53a.75.75 0 0 1 0-1.06ZM6.78 6.53 5.31 8l1.47 1.47a.749.749 0 0 1-.326 1.275.749.749 0 0 1-.734-.215l-2-2a.75.75 0 0 1 0-1.06l2-2a.751.751 0 0 1 1.042.018.751.751 0 0 1 .018 1.042Z" />`,
17
+ };
18
+
19
+ /**
20
+ * Get rank icon
21
+ *
22
+ * @param {string} rankIcon - The rank icon type.
23
+ * @param {string} rankLevel - The rank level.
24
+ * @param {number} percentile - The rank percentile.
25
+ * @returns {string} - The SVG code of the rank icon
26
+ */
27
+ const rankIcon = (rankIcon, rankLevel, percentile) => {
28
+ switch (rankIcon) {
29
+ case "github":
30
+ return `
31
+ <svg x="-38" y="-30" height="66" width="66" aria-hidden="true" viewBox="0 0 16 16" version="1.1" data-view-component="true" data-testid="github-rank-icon">
32
+ <path d="M8 0c4.42 0 8 3.58 8 8a8.013 8.013 0 0 1-5.45 7.59c-.4.08-.55-.17-.55-.38 0-.27.01-1.13.01-2.2 0-.75-.25-1.23-.54-1.48 1.78-.2 3.65-.88 3.65-3.95 0-.88-.31-1.59-.82-2.15.08-.2.36-1.02-.08-2.12 0 0-.67-.22-2.2.82-.64-.18-1.32-.27-2-.27-.68 0-1.36.09-2 .27-1.53-1.03-2.2-.82-2.2-.82-.44 1.1-.16 1.92-.08 2.12-.51.56-.82 1.28-.82 2.15 0 3.06 1.86 3.75 3.64 3.95-.23.2-.44.55-.51 1.07-.46.21-1.61.55-2.33-.66-.15-.24-.6-.83-1.23-.82-.67.01-.27.38.01.53.34.19.73.9.82 1.13.16.45.68 1.31 2.69.94 0 .67.01 1.3.01 1.49 0 .21-.15.45-.55.38A7.995 7.995 0 0 1 0 8c0-4.42 3.58-8 8-8Z"></path>
33
+ </svg>
34
+ `;
35
+ case "percentile":
36
+ return `
37
+ <text x="-5" y="-12" alignment-baseline="central" dominant-baseline="central" text-anchor="middle" data-testid="percentile-top-header" class="rank-percentile-header">
38
+ Top
39
+ </text>
40
+ <text x="-5" y="12" alignment-baseline="central" dominant-baseline="central" text-anchor="middle" data-testid="percentile-rank-value" class="rank-percentile-text">
41
+ ${percentile.toFixed(1)}%
42
+ </text>
43
+ `;
44
+ case "default":
45
+ default:
46
+ return `
47
+ <text x="-5" y="3" alignment-baseline="central" dominant-baseline="central" text-anchor="middle" data-testid="level-rank-icon">
48
+ ${rankLevel}
49
+ </text>
50
+ `;
51
+ }
52
+ };
53
+
54
+ export { icons, rankIcon };
55
+ export default icons;
src/common/index.js ADDED
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // @ts-check
2
+
3
+ export { blacklist } from "./blacklist.js";
4
+ export { Card } from "./Card.js";
5
+ export { I18n } from "./I18n.js";
6
+ export { icons } from "./icons.js";
7
+ export { retryer } from "./retryer.js";
8
+ export {
9
+ ERROR_CARD_LENGTH,
10
+ renderError,
11
+ flexLayout,
12
+ measureText,
13
+ } from "./render.js";
src/common/languageColors.json ADDED
@@ -0,0 +1,651 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "1C Enterprise": "#814CCC",
3
+ "2-Dimensional Array": "#38761D",
4
+ "4D": "#004289",
5
+ "ABAP": "#E8274B",
6
+ "ABAP CDS": "#555e25",
7
+ "AGS Script": "#B9D9FF",
8
+ "AIDL": "#34EB6B",
9
+ "AL": "#3AA2B5",
10
+ "AMPL": "#E6EFBB",
11
+ "ANTLR": "#9DC3FF",
12
+ "API Blueprint": "#2ACCA8",
13
+ "APL": "#5A8164",
14
+ "ASP.NET": "#9400ff",
15
+ "ATS": "#1ac620",
16
+ "ActionScript": "#882B0F",
17
+ "Ada": "#02f88c",
18
+ "Adblock Filter List": "#800000",
19
+ "Adobe Font Metrics": "#fa0f00",
20
+ "Agda": "#315665",
21
+ "Aiken": "#640ff8",
22
+ "Alloy": "#64C800",
23
+ "Alpine Abuild": "#0D597F",
24
+ "Altium Designer": "#A89663",
25
+ "AngelScript": "#C7D7DC",
26
+ "Answer Set Programming": "#A9CC29",
27
+ "Ant Build System": "#A9157E",
28
+ "Antlers": "#ff269e",
29
+ "ApacheConf": "#d12127",
30
+ "Apex": "#1797c0",
31
+ "Apollo Guidance Computer": "#0B3D91",
32
+ "AppleScript": "#101F1F",
33
+ "Arc": "#aa2afe",
34
+ "ArkTS": "#0080ff",
35
+ "AsciiDoc": "#73a0c5",
36
+ "AspectJ": "#a957b0",
37
+ "Assembly": "#6E4C13",
38
+ "Astro": "#ff5a03",
39
+ "Asymptote": "#ff0000",
40
+ "Augeas": "#9CC134",
41
+ "AutoHotkey": "#6594b9",
42
+ "AutoIt": "#1C3552",
43
+ "Avro IDL": "#0040FF",
44
+ "Awk": "#c30e9b",
45
+ "B4X": "#00e4ff",
46
+ "BASIC": "#ff0000",
47
+ "BQN": "#2b7067",
48
+ "Ballerina": "#FF5000",
49
+ "Batchfile": "#C1F12E",
50
+ "Beef": "#a52f4e",
51
+ "Berry": "#15A13C",
52
+ "BibTeX": "#778899",
53
+ "Bicep": "#519aba",
54
+ "Bikeshed": "#5562ac",
55
+ "Bison": "#6A463F",
56
+ "BitBake": "#00bce4",
57
+ "Blade": "#f7523f",
58
+ "BlitzBasic": "#00FFAE",
59
+ "BlitzMax": "#cd6400",
60
+ "Bluespec": "#12223c",
61
+ "Bluespec BH": "#12223c",
62
+ "Boo": "#d4bec1",
63
+ "Boogie": "#c80fa0",
64
+ "Brainfuck": "#2F2530",
65
+ "BrighterScript": "#66AABB",
66
+ "Brightscript": "#662D91",
67
+ "Browserslist": "#ffd539",
68
+ "Bru": "#F4AA41",
69
+ "BuildStream": "#006bff",
70
+ "C": "#555555",
71
+ "C#": "#178600",
72
+ "C++": "#f34b7d",
73
+ "C3": "#2563eb",
74
+ "CAP CDS": "#0092d1",
75
+ "CLIPS": "#00A300",
76
+ "CMake": "#DA3434",
77
+ "COLLADA": "#F1A42B",
78
+ "CSON": "#244776",
79
+ "CSS": "#663399",
80
+ "CSV": "#237346",
81
+ "CUE": "#5886E1",
82
+ "CWeb": "#00007a",
83
+ "Cabal Config": "#483465",
84
+ "Caddyfile": "#22b638",
85
+ "Cadence": "#00ef8b",
86
+ "Cairo": "#ff4a48",
87
+ "Cairo Zero": "#ff4a48",
88
+ "CameLIGO": "#3be133",
89
+ "Cangjie": "#00868B",
90
+ "Cap'n Proto": "#c42727",
91
+ "Carbon": "#222222",
92
+ "Ceylon": "#dfa535",
93
+ "Chapel": "#8dc63f",
94
+ "ChucK": "#3f8000",
95
+ "Circom": "#707575",
96
+ "Cirru": "#ccccff",
97
+ "Clarion": "#db901e",
98
+ "Clarity": "#5546ff",
99
+ "Classic ASP": "#6a40fd",
100
+ "Clean": "#3F85AF",
101
+ "Click": "#E4E6F3",
102
+ "Clojure": "#db5855",
103
+ "Closure Templates": "#0d948f",
104
+ "Cloud Firestore Security Rules": "#FFA000",
105
+ "Clue": "#0009b5",
106
+ "CodeQL": "#140f46",
107
+ "CoffeeScript": "#244776",
108
+ "ColdFusion": "#ed2cd6",
109
+ "ColdFusion CFC": "#ed2cd6",
110
+ "Common Lisp": "#3fb68b",
111
+ "Common Workflow Language": "#B5314C",
112
+ "Component Pascal": "#B0CE4E",
113
+ "Cooklang": "#E15A29",
114
+ "Crystal": "#000100",
115
+ "Csound": "#1a1a1a",
116
+ "Csound Document": "#1a1a1a",
117
+ "Csound Score": "#1a1a1a",
118
+ "Cuda": "#3A4E3A",
119
+ "Curry": "#531242",
120
+ "Cylc": "#00b3fd",
121
+ "Cypher": "#34c0eb",
122
+ "Cython": "#fedf5b",
123
+ "D": "#ba595e",
124
+ "D2": "#526ee8",
125
+ "DM": "#447265",
126
+ "Dafny": "#FFEC25",
127
+ "Darcs Patch": "#8eff23",
128
+ "Dart": "#00B4AB",
129
+ "Daslang": "#d3d3d3",
130
+ "DataWeave": "#003a52",
131
+ "Debian Package Control File": "#D70751",
132
+ "DenizenScript": "#FBEE96",
133
+ "Dhall": "#dfafff",
134
+ "DirectX 3D File": "#aace60",
135
+ "Dockerfile": "#384d54",
136
+ "Dogescript": "#cca760",
137
+ "Dotenv": "#e5d559",
138
+ "Dune": "#89421e",
139
+ "Dylan": "#6c616e",
140
+ "E": "#ccce35",
141
+ "ECL": "#8a1267",
142
+ "ECLiPSe": "#001d9d",
143
+ "EJS": "#a91e50",
144
+ "EQ": "#a78649",
145
+ "Earthly": "#2af0ff",
146
+ "Easybuild": "#069406",
147
+ "Ecere Projects": "#913960",
148
+ "Ecmarkup": "#eb8131",
149
+ "Edge": "#0dffe0",
150
+ "EdgeQL": "#31A7FF",
151
+ "EditorConfig": "#fff1f2",
152
+ "Eiffel": "#4d6977",
153
+ "Elixir": "#6e4a7e",
154
+ "Elm": "#60B5CC",
155
+ "Elvish": "#55BB55",
156
+ "Elvish Transcript": "#55BB55",
157
+ "Emacs Lisp": "#c065db",
158
+ "EmberScript": "#FFF4F3",
159
+ "Erlang": "#B83998",
160
+ "Euphoria": "#FF790B",
161
+ "F#": "#b845fc",
162
+ "F*": "#572e30",
163
+ "FIGlet Font": "#FFDDBB",
164
+ "FIRRTL": "#2f632f",
165
+ "FLUX": "#88ccff",
166
+ "Factor": "#636746",
167
+ "Fancy": "#7b9db4",
168
+ "Fantom": "#14253c",
169
+ "Faust": "#c37240",
170
+ "Fennel": "#fff3d7",
171
+ "Filebench WML": "#F6B900",
172
+ "Flix": "#d44a45",
173
+ "Fluent": "#ffcc33",
174
+ "Forth": "#341708",
175
+ "Fortran": "#4d41b1",
176
+ "Fortran Free Form": "#4d41b1",
177
+ "FreeBASIC": "#141AC9",
178
+ "FreeMarker": "#0050b2",
179
+ "Frege": "#00cafe",
180
+ "Futhark": "#5f021f",
181
+ "G-code": "#D08CF2",
182
+ "GAML": "#FFC766",
183
+ "GAMS": "#f49a22",
184
+ "GAP": "#0000cc",
185
+ "GCC Machine Description": "#FFCFAB",
186
+ "GDScript": "#355570",
187
+ "GDShader": "#478CBF",
188
+ "GEDCOM": "#003058",
189
+ "GLSL": "#5686a5",
190
+ "GSC": "#FF6800",
191
+ "Game Maker Language": "#71b417",
192
+ "Gemfile.lock": "#701516",
193
+ "Gemini": "#ff6900",
194
+ "Genero 4gl": "#63408e",
195
+ "Genero per": "#d8df39",
196
+ "Genie": "#fb855d",
197
+ "Genshi": "#951531",
198
+ "Gentoo Ebuild": "#9400ff",
199
+ "Gentoo Eclass": "#9400ff",
200
+ "Gerber Image": "#d20b00",
201
+ "Gherkin": "#5B2063",
202
+ "Git Attributes": "#F44D27",
203
+ "Git Commit": "#F44D27",
204
+ "Git Config": "#F44D27",
205
+ "Git Revision List": "#F44D27",
206
+ "Gleam": "#ffaff3",
207
+ "Glimmer JS": "#F5835F",
208
+ "Glimmer TS": "#3178c6",
209
+ "Glyph": "#c1ac7f",
210
+ "Gnuplot": "#f0a9f0",
211
+ "Go": "#00ADD8",
212
+ "Go Checksums": "#00ADD8",
213
+ "Go Module": "#00ADD8",
214
+ "Go Workspace": "#00ADD8",
215
+ "Godot Resource": "#355570",
216
+ "Golo": "#88562A",
217
+ "Gosu": "#82937f",
218
+ "Grace": "#615f8b",
219
+ "Gradle": "#02303a",
220
+ "Gradle Kotlin DSL": "#02303a",
221
+ "Grammatical Framework": "#ff0000",
222
+ "GraphQL": "#e10098",
223
+ "Graphviz (DOT)": "#2596be",
224
+ "Groovy": "#4298b8",
225
+ "Groovy Server Pages": "#4298b8",
226
+ "HAProxy": "#106da9",
227
+ "HCL": "#844FBA",
228
+ "HIP": "#4F3A4F",
229
+ "HLSL": "#aace60",
230
+ "HOCON": "#9ff8ee",
231
+ "HTML": "#e34c26",
232
+ "HTML+ECR": "#2e1052",
233
+ "HTML+EEX": "#6e4a7e",
234
+ "HTML+ERB": "#701516",
235
+ "HTML+PHP": "#4f5d95",
236
+ "HTML+Razor": "#512be4",
237
+ "HTTP": "#005C9C",
238
+ "HXML": "#f68712",
239
+ "Hack": "#878787",
240
+ "Haml": "#ece2a9",
241
+ "Handlebars": "#f7931e",
242
+ "Harbour": "#0e60e3",
243
+ "Hare": "#9d7424",
244
+ "Haskell": "#5e5086",
245
+ "Haxe": "#df7900",
246
+ "HiveQL": "#dce200",
247
+ "HolyC": "#ffefaf",
248
+ "Hosts File": "#308888",
249
+ "Hurl": "#FF0288",
250
+ "Hy": "#7790B2",
251
+ "IDL": "#a3522f",
252
+ "IGOR Pro": "#0000cc",
253
+ "INI": "#d1dbe0",
254
+ "ISPC": "#2D68B1",
255
+ "Idris": "#b30000",
256
+ "Ignore List": "#000000",
257
+ "ImageJ Macro": "#99AAFF",
258
+ "Imba": "#16cec6",
259
+ "Inno Setup": "#264b99",
260
+ "Io": "#a9188d",
261
+ "Ioke": "#078193",
262
+ "Isabelle": "#FEFE00",
263
+ "Isabelle ROOT": "#FEFE00",
264
+ "J": "#9EEDFF",
265
+ "JAR Manifest": "#b07219",
266
+ "JCL": "#d90e09",
267
+ "JFlex": "#DBCA00",
268
+ "JSON": "#292929",
269
+ "JSON with Comments": "#292929",
270
+ "JSON5": "#267CB9",
271
+ "JSONLD": "#0c479c",
272
+ "JSONiq": "#40d47e",
273
+ "Jai": "#ab8b4b",
274
+ "Janet": "#0886a5",
275
+ "Jasmin": "#d03600",
276
+ "Java": "#b07219",
277
+ "Java Properties": "#2A6277",
278
+ "Java Server Pages": "#2A6277",
279
+ "Java Template Engine": "#2A6277",
280
+ "JavaScript": "#f1e05a",
281
+ "JavaScript+ERB": "#f1e05a",
282
+ "Jest Snapshot": "#15c213",
283
+ "JetBrains MPS": "#21D789",
284
+ "Jinja": "#a52a22",
285
+ "Jison": "#56b3cb",
286
+ "Jison Lex": "#56b3cb",
287
+ "Jolie": "#843179",
288
+ "Jsonnet": "#0064bd",
289
+ "Julia": "#a270ba",
290
+ "Julia REPL": "#a270ba",
291
+ "Jupyter Notebook": "#DA5B0B",
292
+ "Just": "#384d54",
293
+ "KDL": "#ffb3b3",
294
+ "KRL": "#28430A",
295
+ "Kaitai Struct": "#773b37",
296
+ "KakouneScript": "#6f8042",
297
+ "KerboScript": "#41adf0",
298
+ "KiCad Layout": "#2f4aab",
299
+ "KiCad Legacy Layout": "#2f4aab",
300
+ "KiCad Schematic": "#2f4aab",
301
+ "KoLmafia ASH": "#B9D9B9",
302
+ "Koka": "#215166",
303
+ "Kotlin": "#A97BFF",
304
+ "LFE": "#4C3023",
305
+ "LLVM": "#185619",
306
+ "LOLCODE": "#cc9900",
307
+ "LSL": "#3d9970",
308
+ "LabVIEW": "#fede06",
309
+ "Lark": "#2980B9",
310
+ "Lasso": "#999999",
311
+ "Latte": "#f2a542",
312
+ "Leo": "#C4FFC2",
313
+ "Less": "#1d365d",
314
+ "Lex": "#DBCA00",
315
+ "LigoLANG": "#0e74ff",
316
+ "LilyPond": "#9ccc7c",
317
+ "Liquid": "#67b8de",
318
+ "Literate Agda": "#315665",
319
+ "Literate CoffeeScript": "#244776",
320
+ "Literate Haskell": "#5e5086",
321
+ "LiveCode Script": "#0c5ba5",
322
+ "LiveScript": "#499886",
323
+ "Logtalk": "#295b9a",
324
+ "LookML": "#652B81",
325
+ "Lua": "#000080",
326
+ "Luau": "#00A2FF",
327
+ "M3U": "#179C7D",
328
+ "MATLAB": "#e16737",
329
+ "MAXScript": "#00a6a6",
330
+ "MDX": "#fcb32c",
331
+ "MLIR": "#5EC8DB",
332
+ "MQL4": "#62A8D6",
333
+ "MQL5": "#4A76B8",
334
+ "MTML": "#b7e1f4",
335
+ "Macaulay2": "#d8ffff",
336
+ "Makefile": "#427819",
337
+ "Mako": "#7e858d",
338
+ "Markdown": "#083fa1",
339
+ "Marko": "#42bff2",
340
+ "Mask": "#f97732",
341
+ "Max": "#c4a79c",
342
+ "Mercury": "#ff2b2b",
343
+ "Mermaid": "#ff3670",
344
+ "Meson": "#007800",
345
+ "Metal": "#8f14e9",
346
+ "MiniYAML": "#ff1111",
347
+ "MiniZinc": "#06a9e6",
348
+ "Mint": "#02b046",
349
+ "Mirah": "#c7a938",
350
+ "Modelica": "#de1d31",
351
+ "Modula-2": "#10253f",
352
+ "Modula-3": "#223388",
353
+ "Mojo": "#ff4c1f",
354
+ "Monkey C": "#8D6747",
355
+ "MoonBit": "#b92381",
356
+ "MoonScript": "#ff4585",
357
+ "Motoko": "#fbb03b",
358
+ "Motorola 68K Assembly": "#005daa",
359
+ "Move": "#4a137a",
360
+ "Mustache": "#724b3b",
361
+ "NCL": "#28431f",
362
+ "NMODL": "#00356B",
363
+ "NPM Config": "#cb3837",
364
+ "NWScript": "#111522",
365
+ "Nasal": "#1d2c4e",
366
+ "Nearley": "#990000",
367
+ "Nemerle": "#3d3c6e",
368
+ "NetLinx": "#0aa0ff",
369
+ "NetLinx+ERB": "#747faa",
370
+ "NetLogo": "#ff6375",
371
+ "NewLisp": "#87AED7",
372
+ "Nextflow": "#3ac486",
373
+ "Nginx": "#009639",
374
+ "Nickel": "#E0C3FC",
375
+ "Nim": "#ffc200",
376
+ "Nit": "#009917",
377
+ "Nix": "#7e7eff",
378
+ "Noir": "#2f1f49",
379
+ "Nu": "#c9df40",
380
+ "NumPy": "#9C8AF9",
381
+ "Nunjucks": "#3d8137",
382
+ "Nushell": "#4E9906",
383
+ "OASv2-json": "#85ea2d",
384
+ "OASv2-yaml": "#85ea2d",
385
+ "OASv3-json": "#85ea2d",
386
+ "OASv3-yaml": "#85ea2d",
387
+ "OCaml": "#ef7a08",
388
+ "OMNeT++ MSG": "#a0e0a0",
389
+ "OMNeT++ NED": "#08607c",
390
+ "ObjectScript": "#424893",
391
+ "Objective-C": "#438eff",
392
+ "Objective-C++": "#6866fb",
393
+ "Objective-J": "#ff0c5a",
394
+ "Odin": "#60AFFE",
395
+ "Omgrofl": "#cabbff",
396
+ "Opal": "#f7ede0",
397
+ "Open Policy Agent": "#7d9199",
398
+ "OpenAPI Specification v2": "#85ea2d",
399
+ "OpenAPI Specification v3": "#85ea2d",
400
+ "OpenCL": "#ed2e2d",
401
+ "OpenEdge ABL": "#5ce600",
402
+ "OpenQASM": "#AA70FF",
403
+ "OpenSCAD": "#e5cd45",
404
+ "Option List": "#476732",
405
+ "Org": "#77aa99",
406
+ "OverpassQL": "#cce2aa",
407
+ "Oxygene": "#cdd0e3",
408
+ "Oz": "#fab738",
409
+ "P4": "#7055b5",
410
+ "PDDL": "#0d00ff",
411
+ "PEG.js": "#234d6b",
412
+ "PHP": "#4F5D95",
413
+ "PLSQL": "#dad8d8",
414
+ "PLpgSQL": "#336790",
415
+ "POV-Ray SDL": "#6bac65",
416
+ "Pact": "#F7A8B8",
417
+ "Pan": "#cc0000",
418
+ "Papyrus": "#6600cc",
419
+ "Parrot": "#f3ca0a",
420
+ "Pascal": "#E3F171",
421
+ "Pawn": "#dbb284",
422
+ "Pep8": "#C76F5B",
423
+ "Perl": "#0298c3",
424
+ "PicoLisp": "#6067af",
425
+ "PigLatin": "#fcd7de",
426
+ "Pike": "#005390",
427
+ "Pip Requirements": "#FFD343",
428
+ "Pkl": "#6b9543",
429
+ "PlantUML": "#fbbd16",
430
+ "PogoScript": "#d80074",
431
+ "Polar": "#ae81ff",
432
+ "Portugol": "#f8bd00",
433
+ "PostCSS": "#dc3a0c",
434
+ "PostScript": "#da291c",
435
+ "PowerBuilder": "#8f0f8d",
436
+ "PowerShell": "#012456",
437
+ "Praat": "#c8506d",
438
+ "Prisma": "#0c344b",
439
+ "Processing": "#0096D8",
440
+ "Procfile": "#3B2F63",
441
+ "Prolog": "#74283c",
442
+ "Promela": "#de0000",
443
+ "Propeller Spin": "#7fa2a7",
444
+ "Pug": "#a86454",
445
+ "Puppet": "#302B6D",
446
+ "PureBasic": "#5a6986",
447
+ "PureScript": "#1D222D",
448
+ "Pyret": "#ee1e10",
449
+ "Python": "#3572A5",
450
+ "Python console": "#3572A5",
451
+ "Python traceback": "#3572A5",
452
+ "Q#": "#fed659",
453
+ "QML": "#44a51c",
454
+ "Qt Script": "#00b841",
455
+ "Quake": "#882233",
456
+ "QuakeC": "#975777",
457
+ "QuickBASIC": "#008080",
458
+ "R": "#198CE7",
459
+ "RAML": "#77d9fb",
460
+ "RBS": "#701516",
461
+ "RDoc": "#701516",
462
+ "REXX": "#d90e09",
463
+ "RMarkdown": "#198ce7",
464
+ "RON": "#a62c00",
465
+ "ROS Interface": "#22314e",
466
+ "RPGLE": "#2BDE21",
467
+ "RUNOFF": "#665a4e",
468
+ "Racket": "#3c5caa",
469
+ "Ragel": "#9d5200",
470
+ "Raku": "#0000fb",
471
+ "Rascal": "#fffaa0",
472
+ "ReScript": "#ed5051",
473
+ "Reason": "#ff5847",
474
+ "ReasonLIGO": "#ff5847",
475
+ "Rebol": "#358a5b",
476
+ "Record Jar": "#0673ba",
477
+ "Red": "#f50000",
478
+ "Regular Expression": "#009a00",
479
+ "Ren'Py": "#ff7f7f",
480
+ "Rez": "#FFDAB3",
481
+ "Ring": "#2D54CB",
482
+ "Riot": "#A71E49",
483
+ "RobotFramework": "#00c0b5",
484
+ "Roc": "#7c38f5",
485
+ "Rocq Prover": "#d0b68c",
486
+ "Roff": "#ecdebe",
487
+ "Roff Manpage": "#ecdebe",
488
+ "Rouge": "#cc0088",
489
+ "RouterOS Script": "#DE3941",
490
+ "Ruby": "#701516",
491
+ "Rust": "#dea584",
492
+ "SAS": "#B34936",
493
+ "SCSS": "#c6538c",
494
+ "SPARQL": "#0C4597",
495
+ "SQF": "#3F3F3F",
496
+ "SQL": "#e38c00",
497
+ "SQLPL": "#e38c00",
498
+ "SRecode Template": "#348a34",
499
+ "STL": "#373b5e",
500
+ "SVG": "#ff9900",
501
+ "Sail": "#259dd5",
502
+ "SaltStack": "#646464",
503
+ "Sass": "#a53b70",
504
+ "Scala": "#c22d40",
505
+ "Scaml": "#bd181a",
506
+ "Scenic": "#fdc700",
507
+ "Scheme": "#1e4aec",
508
+ "Scilab": "#ca0f21",
509
+ "Self": "#0579aa",
510
+ "ShaderLab": "#222c37",
511
+ "Shell": "#89e051",
512
+ "ShellCheck Config": "#cecfcb",
513
+ "Shen": "#120F14",
514
+ "Simple File Verification": "#C9BFED",
515
+ "Singularity": "#64E6AD",
516
+ "Slang": "#1fbec9",
517
+ "Slash": "#007eff",
518
+ "Slice": "#003fa2",
519
+ "Slim": "#2b2b2b",
520
+ "Slint": "#2379F4",
521
+ "SmPL": "#c94949",
522
+ "Smalltalk": "#596706",
523
+ "Smarty": "#f0c040",
524
+ "Smithy": "#c44536",
525
+ "Snakemake": "#419179",
526
+ "Solidity": "#AA6746",
527
+ "SourcePawn": "#f69e1d",
528
+ "Squirrel": "#800000",
529
+ "Stan": "#b2011d",
530
+ "Standard ML": "#dc566d",
531
+ "Starlark": "#76d275",
532
+ "Stata": "#1a5f91",
533
+ "StringTemplate": "#3fb34f",
534
+ "Stylus": "#ff6347",
535
+ "SubRip Text": "#9e0101",
536
+ "SugarSS": "#2fcc9f",
537
+ "SuperCollider": "#46390b",
538
+ "Survex data": "#ffcc99",
539
+ "Svelte": "#ff3e00",
540
+ "Sway": "#00F58C",
541
+ "Sweave": "#198ce7",
542
+ "Swift": "#F05138",
543
+ "SystemVerilog": "#DAE1C2",
544
+ "TI Program": "#A0AA87",
545
+ "TL-Verilog": "#C40023",
546
+ "TLA": "#4b0079",
547
+ "TOML": "#9c4221",
548
+ "TSQL": "#e38c00",
549
+ "TSV": "#237346",
550
+ "TSX": "#3178c6",
551
+ "TXL": "#0178b8",
552
+ "Tact": "#48b5ff",
553
+ "Talon": "#333333",
554
+ "Tcl": "#e4cc98",
555
+ "TeX": "#3D6117",
556
+ "Teal": "#00B1BC",
557
+ "Terra": "#00004c",
558
+ "Terraform Template": "#7b42bb",
559
+ "TextGrid": "#c8506d",
560
+ "TextMate Properties": "#df66e4",
561
+ "Textile": "#ffe7ac",
562
+ "Thrift": "#D12127",
563
+ "Toit": "#c2c9fb",
564
+ "Tor Config": "#59316b",
565
+ "Tree-sitter Query": "#8ea64c",
566
+ "Turing": "#cf142b",
567
+ "Twig": "#c1d026",
568
+ "TypeScript": "#3178c6",
569
+ "TypeSpec": "#4A3665",
570
+ "Typst": "#239dad",
571
+ "Unified Parallel C": "#4e3617",
572
+ "Unity3D Asset": "#222c37",
573
+ "Uno": "#9933cc",
574
+ "UnrealScript": "#a54c4d",
575
+ "Untyped Plutus Core": "#36adbd",
576
+ "UrWeb": "#ccccee",
577
+ "V": "#4f87c4",
578
+ "VBA": "#867db1",
579
+ "VBScript": "#15dcdc",
580
+ "VCL": "#148AA8",
581
+ "VHDL": "#adb2cb",
582
+ "Vala": "#a56de2",
583
+ "Valve Data Format": "#f26025",
584
+ "Velocity Template Language": "#507cff",
585
+ "Vento": "#ff0080",
586
+ "Verilog": "#b2b7f8",
587
+ "Vim Help File": "#199f4b",
588
+ "Vim Script": "#199f4b",
589
+ "Vim Snippet": "#199f4b",
590
+ "Visual Basic .NET": "#945db7",
591
+ "Visual Basic 6.0": "#2c6353",
592
+ "Volt": "#1F1F1F",
593
+ "Vue": "#41b883",
594
+ "Vyper": "#9F4CF2",
595
+ "WDL": "#42f1f4",
596
+ "WGSL": "#1a5e9a",
597
+ "Web Ontology Language": "#5b70bd",
598
+ "WebAssembly": "#04133b",
599
+ "WebAssembly Interface Type": "#6250e7",
600
+ "Whiley": "#d5c397",
601
+ "Wikitext": "#fc5757",
602
+ "Windows Registry Entries": "#52d5ff",
603
+ "Witcher Script": "#ff0000",
604
+ "Wolfram Language": "#dd1100",
605
+ "Wollok": "#a23738",
606
+ "World of Warcraft Addon Data": "#f7e43f",
607
+ "Wren": "#383838",
608
+ "X10": "#4B6BEF",
609
+ "XC": "#99DA07",
610
+ "XML": "#0060ac",
611
+ "XML Property List": "#0060ac",
612
+ "XQuery": "#5232e7",
613
+ "XSLT": "#EB8CEB",
614
+ "Xmake": "#22a079",
615
+ "Xojo": "#81bd41",
616
+ "Xonsh": "#285EEF",
617
+ "Xtend": "#24255d",
618
+ "YAML": "#cb171e",
619
+ "YARA": "#220000",
620
+ "YASnippet": "#32AB90",
621
+ "Yacc": "#4B6C4B",
622
+ "Yul": "#794932",
623
+ "ZAP": "#0d665e",
624
+ "ZIL": "#dc75e5",
625
+ "ZenScript": "#00BCD1",
626
+ "Zephir": "#118f9e",
627
+ "Zig": "#ec915c",
628
+ "Zimpl": "#d67711",
629
+ "Zmodel": "#ff7100",
630
+ "crontab": "#ead7ac",
631
+ "eC": "#913960",
632
+ "fish": "#4aae47",
633
+ "hoon": "#00b171",
634
+ "iCalendar": "#ec564c",
635
+ "jq": "#c7254e",
636
+ "kvlang": "#1da6e0",
637
+ "mIRC Script": "#3d57c3",
638
+ "mcfunction": "#E22837",
639
+ "mdsvex": "#5f9ea0",
640
+ "mupad": "#244963",
641
+ "nanorc": "#2d004d",
642
+ "nesC": "#94B0C7",
643
+ "ooc": "#b0b77e",
644
+ "q": "#0040cd",
645
+ "reStructuredText": "#141414",
646
+ "sed": "#64b970",
647
+ "templ": "#66D0DD",
648
+ "vCard": "#ee2647",
649
+ "wisp": "#7582D1",
650
+ "xBase": "#403a40"
651
+ }
src/common/log.js ADDED
@@ -0,0 +1,14 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // @ts-check
2
+
3
+ const noop = () => {};
4
+
5
+ /**
6
+ * Return console instance based on the environment.
7
+ *
8
+ * @type {Console | {log: () => void, error: () => void}}
9
+ */
10
+ const logger =
11
+ process.env.NODE_ENV === "test" ? { log: noop, error: noop } : console;
12
+
13
+ export { logger };
14
+ export default logger;
src/common/ops.js ADDED
@@ -0,0 +1,124 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // @ts-check
2
+
3
+ import toEmoji from "emoji-name-map";
4
+
5
+ /**
6
+ * Returns boolean if value is either "true" or "false" else the value as it is.
7
+ *
8
+ * @param {string | boolean} value The value to parse.
9
+ * @returns {boolean | undefined } The parsed value.
10
+ */
11
+ const parseBoolean = (value) => {
12
+ if (typeof value === "boolean") {
13
+ return value;
14
+ }
15
+
16
+ if (typeof value === "string") {
17
+ if (value.toLowerCase() === "true") {
18
+ return true;
19
+ } else if (value.toLowerCase() === "false") {
20
+ return false;
21
+ }
22
+ }
23
+ return undefined;
24
+ };
25
+
26
+ /**
27
+ * Parse string to array of strings.
28
+ *
29
+ * @param {string} str The string to parse.
30
+ * @returns {string[]} The array of strings.
31
+ */
32
+ const parseArray = (str) => {
33
+ if (!str) {
34
+ return [];
35
+ }
36
+ return str.split(",");
37
+ };
38
+
39
+ /**
40
+ * Clamp the given number between the given range.
41
+ *
42
+ * @param {number} number The number to clamp.
43
+ * @param {number} min The minimum value.
44
+ * @param {number} max The maximum value.
45
+ * @returns {number} The clamped number.
46
+ */
47
+ const clampValue = (number, min, max) => {
48
+ // @ts-ignore
49
+ if (Number.isNaN(parseInt(number, 10))) {
50
+ return min;
51
+ }
52
+ return Math.max(min, Math.min(number, max));
53
+ };
54
+
55
+ /**
56
+ * Lowercase and trim string.
57
+ *
58
+ * @param {string} name String to lowercase and trim.
59
+ * @returns {string} Lowercased and trimmed string.
60
+ */
61
+ const lowercaseTrim = (name) => name.toLowerCase().trim();
62
+
63
+ /**
64
+ * Split array of languages in two columns.
65
+ *
66
+ * @template T Language object.
67
+ * @param {Array<T>} arr Array of languages.
68
+ * @param {number} perChunk Number of languages per column.
69
+ * @returns {Array<T>} Array of languages split in two columns.
70
+ */
71
+ const chunkArray = (arr, perChunk) => {
72
+ return arr.reduce((resultArray, item, index) => {
73
+ const chunkIndex = Math.floor(index / perChunk);
74
+
75
+ if (!resultArray[chunkIndex]) {
76
+ // @ts-ignore
77
+ resultArray[chunkIndex] = []; // start a new chunk
78
+ }
79
+
80
+ // @ts-ignore
81
+ resultArray[chunkIndex].push(item);
82
+
83
+ return resultArray;
84
+ }, []);
85
+ };
86
+
87
+ /**
88
+ * Parse emoji from string.
89
+ *
90
+ * @param {string} str String to parse emoji from.
91
+ * @returns {string} String with emoji parsed.
92
+ */
93
+ const parseEmojis = (str) => {
94
+ if (!str) {
95
+ throw new Error("[parseEmoji]: str argument not provided");
96
+ }
97
+ return str.replace(/:\w+:/gm, (emoji) => {
98
+ return toEmoji.get(emoji) || "";
99
+ });
100
+ };
101
+
102
+ /**
103
+ * Get diff in minutes between two dates.
104
+ *
105
+ * @param {Date} d1 First date.
106
+ * @param {Date} d2 Second date.
107
+ * @returns {number} Number of minutes between the two dates.
108
+ */
109
+ const dateDiff = (d1, d2) => {
110
+ const date1 = new Date(d1);
111
+ const date2 = new Date(d2);
112
+ const diff = date1.getTime() - date2.getTime();
113
+ return Math.round(diff / (1000 * 60));
114
+ };
115
+
116
+ export {
117
+ parseBoolean,
118
+ parseArray,
119
+ clampValue,
120
+ lowercaseTrim,
121
+ chunkArray,
122
+ parseEmojis,
123
+ dateDiff,
124
+ };
src/common/render.js ADDED
@@ -0,0 +1,239 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // @ts-check
2
+
3
+ import { SECONDARY_ERROR_MESSAGES, TRY_AGAIN_LATER } from "./error.js";
4
+ import { getCardColors } from "./color.js";
5
+ import { encodeHTML } from "./html.js";
6
+ import { clampValue } from "./ops.js";
7
+
8
+ /**
9
+ * Auto layout utility, allows us to layout things vertically or horizontally with
10
+ * proper gaping.
11
+ *
12
+ * @param {object} props Function properties.
13
+ * @param {string[]} props.items Array of items to layout.
14
+ * @param {number} props.gap Gap between items.
15
+ * @param {"column" | "row"=} props.direction Direction to layout items.
16
+ * @param {number[]=} props.sizes Array of sizes for each item.
17
+ * @returns {string[]} Array of items with proper layout.
18
+ */
19
+ const flexLayout = ({ items, gap, direction, sizes = [] }) => {
20
+ let lastSize = 0;
21
+ // filter() for filtering out empty strings
22
+ return items.filter(Boolean).map((item, i) => {
23
+ const size = sizes[i] || 0;
24
+ let transform = `translate(${lastSize}, 0)`;
25
+ if (direction === "column") {
26
+ transform = `translate(0, ${lastSize})`;
27
+ }
28
+ lastSize += size + gap;
29
+ return `<g transform="${transform}">${item}</g>`;
30
+ });
31
+ };
32
+
33
+ /**
34
+ * Creates a node to display the primary programming language of the repository/gist.
35
+ *
36
+ * @param {string} langName Language name.
37
+ * @param {string} langColor Language color.
38
+ * @returns {string} Language display SVG object.
39
+ */
40
+ const createLanguageNode = (langName, langColor) => {
41
+ return `
42
+ <g data-testid="primary-lang">
43
+ <circle data-testid="lang-color" cx="0" cy="-5" r="6" fill="${langColor}" />
44
+ <text data-testid="lang-name" class="gray" x="15">${langName}</text>
45
+ </g>
46
+ `;
47
+ };
48
+
49
+ /**
50
+ * Create a node to indicate progress in percentage along a horizontal line.
51
+ *
52
+ * @param {Object} params Object that contains the createProgressNode parameters.
53
+ * @param {number} params.x X-axis position.
54
+ * @param {number} params.y Y-axis position.
55
+ * @param {number} params.width Width of progress bar.
56
+ * @param {string} params.color Progress color.
57
+ * @param {number} params.progress Progress value.
58
+ * @param {string} params.progressBarBackgroundColor Progress bar bg color.
59
+ * @param {number} params.delay Delay before animation starts.
60
+ * @returns {string} Progress node.
61
+ */
62
+ const createProgressNode = ({
63
+ x,
64
+ y,
65
+ width,
66
+ color,
67
+ progress,
68
+ progressBarBackgroundColor,
69
+ delay,
70
+ }) => {
71
+ const progressPercentage = clampValue(progress, 2, 100);
72
+
73
+ return `
74
+ <svg width="${width}" x="${x}" y="${y}">
75
+ <rect rx="5" ry="5" x="0" y="0" width="${width}" height="8" fill="${progressBarBackgroundColor}"></rect>
76
+ <svg data-testid="lang-progress" width="${progressPercentage}%">
77
+ <rect
78
+ height="8"
79
+ fill="${color}"
80
+ rx="5" ry="5" x="0" y="0"
81
+ class="lang-progress"
82
+ style="animation-delay: ${delay}ms;"
83
+ />
84
+ </svg>
85
+ </svg>
86
+ `;
87
+ };
88
+
89
+ /**
90
+ * Creates an icon with label to display repository/gist stats like forks, stars, etc.
91
+ *
92
+ * @param {string} icon The icon to display.
93
+ * @param {number|string} label The label to display.
94
+ * @param {string} testid The testid to assign to the label.
95
+ * @param {number} iconSize The size of the icon.
96
+ * @returns {string} Icon with label SVG object.
97
+ */
98
+ const iconWithLabel = (icon, label, testid, iconSize) => {
99
+ if (typeof label === "number" && label <= 0) {
100
+ return "";
101
+ }
102
+ const iconSvg = `
103
+ <svg
104
+ class="icon"
105
+ y="-12"
106
+ viewBox="0 0 16 16"
107
+ version="1.1"
108
+ width="${iconSize}"
109
+ height="${iconSize}"
110
+ >
111
+ ${icon}
112
+ </svg>
113
+ `;
114
+ const text = `<text data-testid="${testid}" class="gray">${label}</text>`;
115
+ return flexLayout({ items: [iconSvg, text], gap: 20 }).join("");
116
+ };
117
+
118
+ // Script parameters.
119
+ const ERROR_CARD_LENGTH = 576.5;
120
+
121
+ const UPSTREAM_API_ERRORS = [
122
+ TRY_AGAIN_LATER,
123
+ SECONDARY_ERROR_MESSAGES.MAX_RETRY,
124
+ ];
125
+
126
+ /**
127
+ * Renders error message on the card.
128
+ *
129
+ * @param {object} args Function arguments.
130
+ * @param {string} args.message Main error message.
131
+ * @param {string} [args.secondaryMessage=""] The secondary error message.
132
+ * @param {object} [args.renderOptions={}] Render options.
133
+ * @param {string=} args.renderOptions.title_color Card title color.
134
+ * @param {string=} args.renderOptions.text_color Card text color.
135
+ * @param {string=} args.renderOptions.bg_color Card background color.
136
+ * @param {string=} args.renderOptions.border_color Card border color.
137
+ * @param {Parameters<typeof getCardColors>[0]["theme"]=} args.renderOptions.theme Card theme.
138
+ * @param {boolean=} args.renderOptions.show_repo_link Whether to show repo link or not.
139
+ * @returns {string} The SVG markup.
140
+ */
141
+ const renderError = ({
142
+ message,
143
+ secondaryMessage = "",
144
+ renderOptions = {},
145
+ }) => {
146
+ const {
147
+ title_color,
148
+ text_color,
149
+ bg_color,
150
+ border_color,
151
+ theme = "default",
152
+ show_repo_link = true,
153
+ } = renderOptions;
154
+
155
+ // returns theme based colors with proper overrides and defaults
156
+ const { titleColor, textColor, bgColor, borderColor } = getCardColors({
157
+ title_color,
158
+ text_color,
159
+ icon_color: "",
160
+ bg_color,
161
+ border_color,
162
+ ring_color: "",
163
+ theme,
164
+ });
165
+
166
+ return `
167
+ <svg width="${ERROR_CARD_LENGTH}" height="120" viewBox="0 0 ${ERROR_CARD_LENGTH} 120" fill="${bgColor}" xmlns="http://www.w3.org/2000/svg">
168
+ <style>
169
+ .text { font: 600 16px 'Segoe UI', Ubuntu, Sans-Serif; fill: ${titleColor} }
170
+ .small { font: 600 12px 'Segoe UI', Ubuntu, Sans-Serif; fill: ${textColor} }
171
+ .gray { fill: #858585 }
172
+ </style>
173
+ <rect x="0.5" y="0.5" width="${
174
+ ERROR_CARD_LENGTH - 1
175
+ }" height="99%" rx="4.5" fill="${bgColor}" stroke="${borderColor}"/>
176
+ <text x="25" y="45" class="text">Something went wrong!${
177
+ UPSTREAM_API_ERRORS.includes(secondaryMessage) || !show_repo_link
178
+ ? ""
179
+ : " file an issue at https://tiny.one/readme-stats"
180
+ }</text>
181
+ <text data-testid="message" x="25" y="55" class="text small">
182
+ <tspan x="25" dy="18">${encodeHTML(message)}</tspan>
183
+ <tspan x="25" dy="18" class="gray">${secondaryMessage}</tspan>
184
+ </text>
185
+ </svg>
186
+ `;
187
+ };
188
+
189
+ /**
190
+ * Retrieve text length.
191
+ *
192
+ * @see https://stackoverflow.com/a/48172630/10629172
193
+ * @param {string} str String to measure.
194
+ * @param {number} fontSize Font size.
195
+ * @returns {number} Text length.
196
+ */
197
+ const measureText = (str, fontSize = 10) => {
198
+ // prettier-ignore
199
+ const widths = [
200
+ 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
201
+ 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
202
+ 0, 0, 0, 0, 0.2796875, 0.2765625,
203
+ 0.3546875, 0.5546875, 0.5546875, 0.8890625, 0.665625, 0.190625,
204
+ 0.3328125, 0.3328125, 0.3890625, 0.5828125, 0.2765625, 0.3328125,
205
+ 0.2765625, 0.3015625, 0.5546875, 0.5546875, 0.5546875, 0.5546875,
206
+ 0.5546875, 0.5546875, 0.5546875, 0.5546875, 0.5546875, 0.5546875,
207
+ 0.2765625, 0.2765625, 0.584375, 0.5828125, 0.584375, 0.5546875,
208
+ 1.0140625, 0.665625, 0.665625, 0.721875, 0.721875, 0.665625,
209
+ 0.609375, 0.7765625, 0.721875, 0.2765625, 0.5, 0.665625,
210
+ 0.5546875, 0.8328125, 0.721875, 0.7765625, 0.665625, 0.7765625,
211
+ 0.721875, 0.665625, 0.609375, 0.721875, 0.665625, 0.94375,
212
+ 0.665625, 0.665625, 0.609375, 0.2765625, 0.3546875, 0.2765625,
213
+ 0.4765625, 0.5546875, 0.3328125, 0.5546875, 0.5546875, 0.5,
214
+ 0.5546875, 0.5546875, 0.2765625, 0.5546875, 0.5546875, 0.221875,
215
+ 0.240625, 0.5, 0.221875, 0.8328125, 0.5546875, 0.5546875,
216
+ 0.5546875, 0.5546875, 0.3328125, 0.5, 0.2765625, 0.5546875,
217
+ 0.5, 0.721875, 0.5, 0.5, 0.5, 0.3546875, 0.259375, 0.353125, 0.5890625,
218
+ ];
219
+
220
+ const avg = 0.5279276315789471;
221
+ return (
222
+ str
223
+ .split("")
224
+ .map((c) =>
225
+ c.charCodeAt(0) < widths.length ? widths[c.charCodeAt(0)] : avg,
226
+ )
227
+ .reduce((cur, acc) => acc + cur) * fontSize
228
+ );
229
+ };
230
+
231
+ export {
232
+ ERROR_CARD_LENGTH,
233
+ renderError,
234
+ createLanguageNode,
235
+ createProgressNode,
236
+ iconWithLabel,
237
+ flexLayout,
238
+ measureText,
239
+ };
src/common/retryer.js ADDED
@@ -0,0 +1,97 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // @ts-check
2
+
3
+ import { CustomError } from "./error.js";
4
+ import { logger } from "./log.js";
5
+
6
+ // Script variables.
7
+
8
+ // Count the number of GitHub API tokens available.
9
+ const PATs = Object.keys(process.env).filter((key) =>
10
+ /PAT_\d*$/.exec(key),
11
+ ).length;
12
+ const RETRIES = process.env.NODE_ENV === "test" ? 7 : PATs;
13
+
14
+ /**
15
+ * @typedef {import("axios").AxiosResponse} AxiosResponse Axios response.
16
+ * @typedef {(variables: any, token: string, retriesForTests?: number) => Promise<AxiosResponse>} FetcherFunction Fetcher function.
17
+ */
18
+
19
+ /**
20
+ * Try to execute the fetcher function until it succeeds or the max number of retries is reached.
21
+ *
22
+ * @param {FetcherFunction} fetcher The fetcher function.
23
+ * @param {any} variables Object with arguments to pass to the fetcher function.
24
+ * @param {number} retries How many times to retry.
25
+ * @returns {Promise<any>} The response from the fetcher function.
26
+ */
27
+ const retryer = async (fetcher, variables, retries = 0) => {
28
+ if (!RETRIES) {
29
+ throw new CustomError("No GitHub API tokens found", CustomError.NO_TOKENS);
30
+ }
31
+
32
+ if (retries > RETRIES) {
33
+ throw new CustomError(
34
+ "Downtime due to GitHub API rate limiting",
35
+ CustomError.MAX_RETRY,
36
+ );
37
+ }
38
+
39
+ try {
40
+ // try to fetch with the first token since RETRIES is 0 index i'm adding +1
41
+ let response = await fetcher(
42
+ variables,
43
+ // @ts-ignore
44
+ process.env[`PAT_${retries + 1}`],
45
+ // used in tests for faking rate limit
46
+ retries,
47
+ );
48
+
49
+ // react on both type and message-based rate-limit signals.
50
+ // https://github.com/anuraghazra/github-readme-stats/issues/4425
51
+ const errors = response?.data?.errors;
52
+ const errorType = errors?.[0]?.type;
53
+ const errorMsg = errors?.[0]?.message || "";
54
+ const isRateLimited =
55
+ (errors && errorType === "RATE_LIMITED") || /rate limit/i.test(errorMsg);
56
+
57
+ // if rate limit is hit increase the RETRIES and recursively call the retryer
58
+ // with username, and current RETRIES
59
+ if (isRateLimited) {
60
+ logger.log(`PAT_${retries + 1} Failed`);
61
+ retries++;
62
+ // directly return from the function
63
+ return retryer(fetcher, variables, retries);
64
+ }
65
+
66
+ // finally return the response
67
+ return response;
68
+ } catch (err) {
69
+ /** @type {any} */
70
+ const e = err;
71
+
72
+ // network/unexpected error → let caller treat as failure
73
+ if (!e?.response) {
74
+ throw e;
75
+ }
76
+
77
+ // prettier-ignore
78
+ // also checking for bad credentials if any tokens gets invalidated
79
+ const isBadCredential =
80
+ e?.response?.data?.message === "Bad credentials";
81
+ const isAccountSuspended =
82
+ e?.response?.data?.message === "Sorry. Your account was suspended.";
83
+
84
+ if (isBadCredential || isAccountSuspended) {
85
+ logger.log(`PAT_${retries + 1} Failed`);
86
+ retries++;
87
+ // directly return from the function
88
+ return retryer(fetcher, variables, retries);
89
+ }
90
+
91
+ // HTTP error with a response → return it for caller-side handling
92
+ return e.response;
93
+ }
94
+ };
95
+
96
+ export { retryer, RETRIES };
97
+ export default retryer;
src/fetchers/gist.js ADDED
@@ -0,0 +1,114 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // @ts-check
2
+
3
+ import { retryer } from "../common/retryer.js";
4
+ import { MissingParamError } from "../common/error.js";
5
+ import { request } from "../common/http.js";
6
+
7
+ const QUERY = `
8
+ query gistInfo($gistName: String!) {
9
+ viewer {
10
+ gist(name: $gistName) {
11
+ description
12
+ owner {
13
+ login
14
+ }
15
+ stargazerCount
16
+ forks {
17
+ totalCount
18
+ }
19
+ files {
20
+ name
21
+ language {
22
+ name
23
+ }
24
+ size
25
+ }
26
+ }
27
+ }
28
+ }
29
+ `;
30
+
31
+ /**
32
+ * Gist data fetcher.
33
+ *
34
+ * @param {object} variables Fetcher variables.
35
+ * @param {string} token GitHub token.
36
+ * @returns {Promise<import('axios').AxiosResponse>} The response.
37
+ */
38
+ const fetcher = async (variables, token) => {
39
+ return await request(
40
+ { query: QUERY, variables },
41
+ { Authorization: `token ${token}` },
42
+ );
43
+ };
44
+
45
+ /**
46
+ * @typedef {{ name: string; language: { name: string; }, size: number }} GistFile Gist file.
47
+ */
48
+
49
+ /**
50
+ * This function calculates the primary language of a gist by files size.
51
+ *
52
+ * @param {GistFile[]} files Files.
53
+ * @returns {string} Primary language.
54
+ */
55
+ const calculatePrimaryLanguage = (files) => {
56
+ /** @type {Record<string, number>} */
57
+ const languages = {};
58
+
59
+ for (const file of files) {
60
+ if (file.language) {
61
+ if (languages[file.language.name]) {
62
+ languages[file.language.name] += file.size;
63
+ } else {
64
+ languages[file.language.name] = file.size;
65
+ }
66
+ }
67
+ }
68
+
69
+ let primaryLanguage = Object.keys(languages)[0];
70
+ for (const language in languages) {
71
+ if (languages[language] > languages[primaryLanguage]) {
72
+ primaryLanguage = language;
73
+ }
74
+ }
75
+
76
+ return primaryLanguage;
77
+ };
78
+
79
+ /**
80
+ * @typedef {import('./types').GistData} GistData Gist data.
81
+ */
82
+
83
+ /**
84
+ * Fetch GitHub gist information by given username and ID.
85
+ *
86
+ * @param {string} id GitHub gist ID.
87
+ * @returns {Promise<GistData>} Gist data.
88
+ */
89
+ const fetchGist = async (id) => {
90
+ if (!id) {
91
+ throw new MissingParamError(["id"], "/api/gist?id=GIST_ID");
92
+ }
93
+ const res = await retryer(fetcher, { gistName: id });
94
+ if (res.data.errors) {
95
+ throw new Error(res.data.errors[0].message);
96
+ }
97
+ if (!res.data.data.viewer.gist) {
98
+ throw new Error("Gist not found");
99
+ }
100
+ const data = res.data.data.viewer.gist;
101
+ return {
102
+ name: data.files[Object.keys(data.files)[0]].name,
103
+ nameWithOwner: `${data.owner.login}/${
104
+ data.files[Object.keys(data.files)[0]].name
105
+ }`,
106
+ description: data.description,
107
+ language: calculatePrimaryLanguage(data.files),
108
+ starsCount: data.stargazerCount,
109
+ forksCount: data.forks.totalCount,
110
+ };
111
+ };
112
+
113
+ export { fetchGist };
114
+ export default fetchGist;
src/fetchers/repo.js ADDED
@@ -0,0 +1,118 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // @ts-check
2
+
3
+ import { MissingParamError } from "../common/error.js";
4
+ import { request } from "../common/http.js";
5
+ import { retryer } from "../common/retryer.js";
6
+
7
+ /**
8
+ * Repo data fetcher.
9
+ *
10
+ * @param {object} variables Fetcher variables.
11
+ * @param {string} token GitHub token.
12
+ * @returns {Promise<import('axios').AxiosResponse>} The response.
13
+ */
14
+ const fetcher = (variables, token) => {
15
+ return request(
16
+ {
17
+ query: `
18
+ fragment RepoInfo on Repository {
19
+ name
20
+ nameWithOwner
21
+ isPrivate
22
+ isArchived
23
+ isTemplate
24
+ stargazers {
25
+ totalCount
26
+ }
27
+ description
28
+ primaryLanguage {
29
+ color
30
+ id
31
+ name
32
+ }
33
+ forkCount
34
+ }
35
+ query getRepo($login: String!, $repo: String!) {
36
+ user(login: $login) {
37
+ repository(name: $repo) {
38
+ ...RepoInfo
39
+ }
40
+ }
41
+ organization(login: $login) {
42
+ repository(name: $repo) {
43
+ ...RepoInfo
44
+ }
45
+ }
46
+ }
47
+ `,
48
+ variables,
49
+ },
50
+ {
51
+ Authorization: `token ${token}`,
52
+ },
53
+ );
54
+ };
55
+
56
+ const urlExample = "/api/pin?username=USERNAME&amp;repo=REPO_NAME";
57
+
58
+ /**
59
+ * @typedef {import("./types").RepositoryData} RepositoryData Repository data.
60
+ */
61
+
62
+ /**
63
+ * Fetch repository data.
64
+ *
65
+ * @param {string} username GitHub username.
66
+ * @param {string} reponame GitHub repository name.
67
+ * @returns {Promise<RepositoryData>} Repository data.
68
+ */
69
+ const fetchRepo = async (username, reponame) => {
70
+ if (!username && !reponame) {
71
+ throw new MissingParamError(["username", "repo"], urlExample);
72
+ }
73
+ if (!username) {
74
+ throw new MissingParamError(["username"], urlExample);
75
+ }
76
+ if (!reponame) {
77
+ throw new MissingParamError(["repo"], urlExample);
78
+ }
79
+
80
+ let res = await retryer(fetcher, { login: username, repo: reponame });
81
+
82
+ const data = res.data.data;
83
+
84
+ if (!data.user && !data.organization) {
85
+ throw new Error("Not found");
86
+ }
87
+
88
+ const isUser = data.organization === null && data.user;
89
+ const isOrg = data.user === null && data.organization;
90
+
91
+ if (isUser) {
92
+ if (!data.user.repository || data.user.repository.isPrivate) {
93
+ throw new Error("User Repository Not found");
94
+ }
95
+ return {
96
+ ...data.user.repository,
97
+ starCount: data.user.repository.stargazers.totalCount,
98
+ };
99
+ }
100
+
101
+ if (isOrg) {
102
+ if (
103
+ !data.organization.repository ||
104
+ data.organization.repository.isPrivate
105
+ ) {
106
+ throw new Error("Organization Repository Not found");
107
+ }
108
+ return {
109
+ ...data.organization.repository,
110
+ starCount: data.organization.repository.stargazers.totalCount,
111
+ };
112
+ }
113
+
114
+ throw new Error("Unexpected behavior");
115
+ };
116
+
117
+ export { fetchRepo };
118
+ export default fetchRepo;
src/fetchers/stats.js ADDED
@@ -0,0 +1,340 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // @ts-check
2
+
3
+ import axios from "axios";
4
+ import * as dotenv from "dotenv";
5
+ import githubUsernameRegex from "github-username-regex";
6
+ import { calculateRank } from "../calculateRank.js";
7
+ import { retryer } from "../common/retryer.js";
8
+ import { logger } from "../common/log.js";
9
+ import { excludeRepositories } from "../common/envs.js";
10
+ import { CustomError, MissingParamError } from "../common/error.js";
11
+ import { wrapTextMultiline } from "../common/fmt.js";
12
+ import { request } from "../common/http.js";
13
+
14
+ dotenv.config();
15
+
16
+ // GraphQL queries.
17
+ const GRAPHQL_REPOS_FIELD = `
18
+ repositories(first: 100, ownerAffiliations: OWNER, orderBy: {direction: DESC, field: STARGAZERS}, after: $after) {
19
+ totalCount
20
+ nodes {
21
+ name
22
+ stargazers {
23
+ totalCount
24
+ }
25
+ }
26
+ pageInfo {
27
+ hasNextPage
28
+ endCursor
29
+ }
30
+ }
31
+ `;
32
+
33
+ const GRAPHQL_REPOS_QUERY = `
34
+ query userInfo($login: String!, $after: String) {
35
+ user(login: $login) {
36
+ ${GRAPHQL_REPOS_FIELD}
37
+ }
38
+ }
39
+ `;
40
+
41
+ const GRAPHQL_STATS_QUERY = `
42
+ query userInfo($login: String!, $after: String, $includeMergedPullRequests: Boolean!, $includeDiscussions: Boolean!, $includeDiscussionsAnswers: Boolean!, $startTime: DateTime = null) {
43
+ user(login: $login) {
44
+ name
45
+ login
46
+ commits: contributionsCollection (from: $startTime) {
47
+ totalCommitContributions,
48
+ }
49
+ reviews: contributionsCollection {
50
+ totalPullRequestReviewContributions
51
+ }
52
+ repositoriesContributedTo(first: 1, contributionTypes: [COMMIT, ISSUE, PULL_REQUEST, REPOSITORY]) {
53
+ totalCount
54
+ }
55
+ pullRequests(first: 1) {
56
+ totalCount
57
+ }
58
+ mergedPullRequests: pullRequests(states: MERGED) @include(if: $includeMergedPullRequests) {
59
+ totalCount
60
+ }
61
+ openIssues: issues(states: OPEN) {
62
+ totalCount
63
+ }
64
+ closedIssues: issues(states: CLOSED) {
65
+ totalCount
66
+ }
67
+ followers {
68
+ totalCount
69
+ }
70
+ repositoryDiscussions @include(if: $includeDiscussions) {
71
+ totalCount
72
+ }
73
+ repositoryDiscussionComments(onlyAnswers: true) @include(if: $includeDiscussionsAnswers) {
74
+ totalCount
75
+ }
76
+ ${GRAPHQL_REPOS_FIELD}
77
+ }
78
+ }
79
+ `;
80
+
81
+ /**
82
+ * Stats fetcher object.
83
+ *
84
+ * @param {object & { after: string | null }} variables Fetcher variables.
85
+ * @param {string} token GitHub token.
86
+ * @returns {Promise<import('axios').AxiosResponse>} Axios response.
87
+ */
88
+ const fetcher = (variables, token) => {
89
+ const query = variables.after ? GRAPHQL_REPOS_QUERY : GRAPHQL_STATS_QUERY;
90
+ return request(
91
+ {
92
+ query,
93
+ variables,
94
+ },
95
+ {
96
+ Authorization: `bearer ${token}`,
97
+ },
98
+ );
99
+ };
100
+
101
+ /**
102
+ * Fetch stats information for a given username.
103
+ *
104
+ * @param {object} variables Fetcher variables.
105
+ * @param {string} variables.username GitHub username.
106
+ * @param {boolean} variables.includeMergedPullRequests Include merged pull requests.
107
+ * @param {boolean} variables.includeDiscussions Include discussions.
108
+ * @param {boolean} variables.includeDiscussionsAnswers Include discussions answers.
109
+ * @param {string|undefined} variables.startTime Time to start the count of total commits.
110
+ * @returns {Promise<import('axios').AxiosResponse>} Axios response.
111
+ *
112
+ * @description This function supports multi-page fetching if the 'FETCH_MULTI_PAGE_STARS' environment variable is set to true.
113
+ */
114
+ const statsFetcher = async ({
115
+ username,
116
+ includeMergedPullRequests,
117
+ includeDiscussions,
118
+ includeDiscussionsAnswers,
119
+ startTime,
120
+ }) => {
121
+ let stats;
122
+ let hasNextPage = true;
123
+ let endCursor = null;
124
+ while (hasNextPage) {
125
+ const variables = {
126
+ login: username,
127
+ first: 100,
128
+ after: endCursor,
129
+ includeMergedPullRequests,
130
+ includeDiscussions,
131
+ includeDiscussionsAnswers,
132
+ startTime,
133
+ };
134
+ let res = await retryer(fetcher, variables);
135
+ if (res.data.errors) {
136
+ return res;
137
+ }
138
+
139
+ // Store stats data.
140
+ const repoNodes = res.data.data.user.repositories.nodes;
141
+ if (stats) {
142
+ stats.data.data.user.repositories.nodes.push(...repoNodes);
143
+ } else {
144
+ stats = res;
145
+ }
146
+
147
+ // Disable multi page fetching on public Vercel instance due to rate limits.
148
+ const repoNodesWithStars = repoNodes.filter(
149
+ (node) => node.stargazers.totalCount !== 0,
150
+ );
151
+ hasNextPage =
152
+ process.env.FETCH_MULTI_PAGE_STARS === "true" &&
153
+ repoNodes.length === repoNodesWithStars.length &&
154
+ res.data.data.user.repositories.pageInfo.hasNextPage;
155
+ endCursor = res.data.data.user.repositories.pageInfo.endCursor;
156
+ }
157
+
158
+ return stats;
159
+ };
160
+
161
+ /**
162
+ * Fetch total commits using the REST API.
163
+ *
164
+ * @param {object} variables Fetcher variables.
165
+ * @param {string} token GitHub token.
166
+ * @returns {Promise<import('axios').AxiosResponse>} Axios response.
167
+ *
168
+ * @see https://developer.github.com/v3/search/#search-commits
169
+ */
170
+ const fetchTotalCommits = (variables, token) => {
171
+ return axios({
172
+ method: "get",
173
+ url: `https://api.github.com/search/commits?q=author:${variables.login}`,
174
+ headers: {
175
+ "Content-Type": "application/json",
176
+ Accept: "application/vnd.github.cloak-preview",
177
+ Authorization: `token ${token}`,
178
+ },
179
+ });
180
+ };
181
+
182
+ /**
183
+ * Fetch all the commits for all the repositories of a given username.
184
+ *
185
+ * @param {string} username GitHub username.
186
+ * @returns {Promise<number>} Total commits.
187
+ *
188
+ * @description Done like this because the GitHub API does not provide a way to fetch all the commits. See
189
+ * #92#issuecomment-661026467 and #211 for more information.
190
+ */
191
+ const totalCommitsFetcher = async (username) => {
192
+ if (!githubUsernameRegex.test(username)) {
193
+ logger.log("Invalid username provided.");
194
+ throw new Error("Invalid username provided.");
195
+ }
196
+
197
+ let res;
198
+ try {
199
+ res = await retryer(fetchTotalCommits, { login: username });
200
+ } catch (err) {
201
+ logger.log(err);
202
+ throw new Error(err);
203
+ }
204
+
205
+ const totalCount = res.data.total_count;
206
+ if (!totalCount || isNaN(totalCount)) {
207
+ throw new CustomError(
208
+ "Could not fetch total commits.",
209
+ CustomError.GITHUB_REST_API_ERROR,
210
+ );
211
+ }
212
+ return totalCount;
213
+ };
214
+
215
+ /**
216
+ * Fetch stats for a given username.
217
+ *
218
+ * @param {string} username GitHub username.
219
+ * @param {boolean} include_all_commits Include all commits.
220
+ * @param {string[]} exclude_repo Repositories to exclude.
221
+ * @param {boolean} include_merged_pull_requests Include merged pull requests.
222
+ * @param {boolean} include_discussions Include discussions.
223
+ * @param {boolean} include_discussions_answers Include discussions answers.
224
+ * @param {number|undefined} commits_year Year to count total commits
225
+ * @returns {Promise<import("./types").StatsData>} Stats data.
226
+ */
227
+ const fetchStats = async (
228
+ username,
229
+ include_all_commits = false,
230
+ exclude_repo = [],
231
+ include_merged_pull_requests = false,
232
+ include_discussions = false,
233
+ include_discussions_answers = false,
234
+ commits_year,
235
+ ) => {
236
+ if (!username) {
237
+ throw new MissingParamError(["username"]);
238
+ }
239
+
240
+ const stats = {
241
+ name: "",
242
+ totalPRs: 0,
243
+ totalPRsMerged: 0,
244
+ mergedPRsPercentage: 0,
245
+ totalReviews: 0,
246
+ totalCommits: 0,
247
+ totalIssues: 0,
248
+ totalStars: 0,
249
+ totalDiscussionsStarted: 0,
250
+ totalDiscussionsAnswered: 0,
251
+ contributedTo: 0,
252
+ rank: { level: "C", percentile: 100 },
253
+ };
254
+
255
+ let res = await statsFetcher({
256
+ username,
257
+ includeMergedPullRequests: include_merged_pull_requests,
258
+ includeDiscussions: include_discussions,
259
+ includeDiscussionsAnswers: include_discussions_answers,
260
+ startTime: commits_year ? `${commits_year}-01-01T00:00:00Z` : undefined,
261
+ });
262
+
263
+ // Catch GraphQL errors.
264
+ if (res.data.errors) {
265
+ logger.error(res.data.errors);
266
+ if (res.data.errors[0].type === "NOT_FOUND") {
267
+ throw new CustomError(
268
+ res.data.errors[0].message || "Could not fetch user.",
269
+ CustomError.USER_NOT_FOUND,
270
+ );
271
+ }
272
+ if (res.data.errors[0].message) {
273
+ throw new CustomError(
274
+ wrapTextMultiline(res.data.errors[0].message, 90, 1)[0],
275
+ res.statusText,
276
+ );
277
+ }
278
+ throw new CustomError(
279
+ "Something went wrong while trying to retrieve the stats data using the GraphQL API.",
280
+ CustomError.GRAPHQL_ERROR,
281
+ );
282
+ }
283
+
284
+ const user = res.data.data.user;
285
+
286
+ stats.name = user.name || user.login;
287
+
288
+ // if include_all_commits, fetch all commits using the REST API.
289
+ if (include_all_commits) {
290
+ stats.totalCommits = await totalCommitsFetcher(username);
291
+ } else {
292
+ stats.totalCommits = user.commits.totalCommitContributions;
293
+ }
294
+
295
+ stats.totalPRs = user.pullRequests.totalCount;
296
+ if (include_merged_pull_requests) {
297
+ stats.totalPRsMerged = user.mergedPullRequests.totalCount;
298
+ stats.mergedPRsPercentage =
299
+ (user.mergedPullRequests.totalCount / user.pullRequests.totalCount) *
300
+ 100 || 0;
301
+ }
302
+ stats.totalReviews = user.reviews.totalPullRequestReviewContributions;
303
+ stats.totalIssues = user.openIssues.totalCount + user.closedIssues.totalCount;
304
+ if (include_discussions) {
305
+ stats.totalDiscussionsStarted = user.repositoryDiscussions.totalCount;
306
+ }
307
+ if (include_discussions_answers) {
308
+ stats.totalDiscussionsAnswered =
309
+ user.repositoryDiscussionComments.totalCount;
310
+ }
311
+ stats.contributedTo = user.repositoriesContributedTo.totalCount;
312
+
313
+ // Retrieve stars while filtering out repositories to be hidden.
314
+ const allExcludedRepos = [...exclude_repo, ...excludeRepositories];
315
+ let repoToHide = new Set(allExcludedRepos);
316
+
317
+ stats.totalStars = user.repositories.nodes
318
+ .filter((data) => {
319
+ return !repoToHide.has(data.name);
320
+ })
321
+ .reduce((prev, curr) => {
322
+ return prev + curr.stargazers.totalCount;
323
+ }, 0);
324
+
325
+ stats.rank = calculateRank({
326
+ all_commits: include_all_commits,
327
+ commits: stats.totalCommits,
328
+ prs: stats.totalPRs,
329
+ reviews: stats.totalReviews,
330
+ issues: stats.totalIssues,
331
+ repos: user.repositories.totalCount,
332
+ stars: stats.totalStars,
333
+ followers: user.followers.totalCount,
334
+ });
335
+
336
+ return stats;
337
+ };
338
+
339
+ export { fetchStats };
340
+ export default fetchStats;
src/fetchers/top-languages.js ADDED
@@ -0,0 +1,162 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+ if (res.data.errors) {
75
+ logger.error(res.data.errors);
76
+ if (res.data.errors[0].type === "NOT_FOUND") {
77
+ throw new CustomError(
78
+ res.data.errors[0].message || "Could not fetch user.",
79
+ CustomError.USER_NOT_FOUND,
80
+ );
81
+ }
82
+ if (res.data.errors[0].message) {
83
+ throw new CustomError(
84
+ wrapTextMultiline(res.data.errors[0].message, 90, 1)[0],
85
+ res.statusText,
86
+ );
87
+ }
88
+ throw new CustomError(
89
+ "Something went wrong while trying to retrieve the language data using the GraphQL API.",
90
+ CustomError.GRAPHQL_ERROR,
91
+ );
92
+ }
93
+
94
+ let repoNodes = res.data.data.user.repositories.nodes;
95
+ /** @type {Record<string, boolean>} */
96
+ let repoToHide = {};
97
+ const allExcludedRepos = [...exclude_repo, ...excludeRepositories];
98
+
99
+ // populate repoToHide map for quick lookup
100
+ // while filtering out
101
+ if (allExcludedRepos) {
102
+ allExcludedRepos.forEach((repoName) => {
103
+ repoToHide[repoName] = true;
104
+ });
105
+ }
106
+
107
+ // filter out repositories to be hidden
108
+ repoNodes = repoNodes
109
+ .sort((a, b) => b.size - a.size)
110
+ .filter((name) => !repoToHide[name.name]);
111
+
112
+ let repoCount = 0;
113
+
114
+ repoNodes = repoNodes
115
+ .filter((node) => node.languages.edges.length > 0)
116
+ // flatten the list of language nodes
117
+ .reduce((acc, curr) => curr.languages.edges.concat(acc), [])
118
+ .reduce((acc, prev) => {
119
+ // get the size of the language (bytes)
120
+ let langSize = prev.size;
121
+
122
+ // if we already have the language in the accumulator
123
+ // & the current language name is same as previous name
124
+ // add the size to the language size and increase repoCount.
125
+ if (acc[prev.node.name] && prev.node.name === acc[prev.node.name].name) {
126
+ langSize = prev.size + acc[prev.node.name].size;
127
+ repoCount += 1;
128
+ } else {
129
+ // reset repoCount to 1
130
+ // language must exist in at least one repo to be detected
131
+ repoCount = 1;
132
+ }
133
+ return {
134
+ ...acc,
135
+ [prev.node.name]: {
136
+ name: prev.node.name,
137
+ color: prev.node.color,
138
+ size: langSize,
139
+ count: repoCount,
140
+ },
141
+ };
142
+ }, {});
143
+
144
+ Object.keys(repoNodes).forEach((name) => {
145
+ // comparison index calculation
146
+ repoNodes[name].size =
147
+ Math.pow(repoNodes[name].size, size_weight) *
148
+ Math.pow(repoNodes[name].count, count_weight);
149
+ });
150
+
151
+ const topLangs = Object.keys(repoNodes)
152
+ .sort((a, b) => repoNodes[b].size - repoNodes[a].size)
153
+ .reduce((result, key) => {
154
+ result[key] = repoNodes[key];
155
+ return result;
156
+ }, {});
157
+
158
+ return topLangs;
159
+ };
160
+
161
+ export { fetchTopLanguages };
162
+ export default fetchTopLanguages;
src/fetchers/types.d.ts ADDED
@@ -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
+ };
src/fetchers/wakatime.js ADDED
@@ -0,0 +1,37 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // @ts-check
2
+
3
+ import axios from "axios";
4
+ import { CustomError, MissingParamError } from "../common/error.js";
5
+
6
+ /**
7
+ * WakaTime data fetcher.
8
+ *
9
+ * @param {{username: string, api_domain: string }} props Fetcher props.
10
+ * @returns {Promise<import("./types").WakaTimeData>} WakaTime data response.
11
+ */
12
+ const fetchWakatimeStats = async ({ username, api_domain }) => {
13
+ if (!username) {
14
+ throw new MissingParamError(["username"]);
15
+ }
16
+
17
+ try {
18
+ const { data } = await axios.get(
19
+ `https://${
20
+ api_domain ? api_domain.replace(/\/$/gi, "") : "wakatime.com"
21
+ }/api/v1/users/${username}/stats?is_including_today=true`,
22
+ );
23
+
24
+ return data.data;
25
+ } catch (err) {
26
+ if (err.response.status < 200 || err.response.status > 299) {
27
+ throw new CustomError(
28
+ `Could not resolve to a User with the login of '${username}'`,
29
+ "WAKATIME_USER_NOT_FOUND",
30
+ );
31
+ }
32
+ throw err;
33
+ }
34
+ };
35
+
36
+ export { fetchWakatimeStats };
37
+ export default fetchWakatimeStats;
src/index.js ADDED
@@ -0,0 +1,2 @@
 
 
 
1
+ export * from "./common/index.js";
2
+ export * from "./cards/index.js";
src/translations.js ADDED
@@ -0,0 +1,1105 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // @ts-check
2
+
3
+ import { encodeHTML } from "./common/html.js";
4
+
5
+ /**
6
+ * Retrieves stat card labels in the available locales.
7
+ *
8
+ * @param {object} props Function arguments.
9
+ * @param {string} props.name The name of the locale.
10
+ * @param {string} props.apostrophe Whether to use apostrophe or not.
11
+ * @returns {object} The locales object.
12
+ *
13
+ * @see https://www.andiamo.co.uk/resources/iso-language-codes/ for language codes.
14
+ */
15
+ const statCardLocales = ({ name, apostrophe }) => {
16
+ const encodedName = encodeHTML(name);
17
+ return {
18
+ "statcard.title": {
19
+ en: `${encodedName}'${apostrophe} GitHub Stats`,
20
+ ar: `${encodedName} إحصائيات جيت هاب`,
21
+ az: `${encodedName}'${apostrophe} Hesabının GitHub Statistikası`,
22
+ ca: `Estadístiques de GitHub de ${encodedName}`,
23
+ cn: `${encodedName} 的 GitHub 统计数据`,
24
+ "zh-tw": `${encodedName} 的 GitHub 統計資料`,
25
+ cs: `GitHub statistiky uživatele ${encodedName}`,
26
+ de: `${encodedName + apostrophe} GitHub-Statistiken`,
27
+ sw: `GitHub Stats za ${encodedName}`,
28
+ ur: `${encodedName} کے گٹ ہب کے اعداد و شمار`,
29
+ bg: `GitHub статистика на потребител ${encodedName}`,
30
+ bn: `${encodedName} এর GitHub পরিসংখ্যান`,
31
+ es: `Estadísticas de GitHub de ${encodedName}`,
32
+ fa: `آمار گیت‌هاب ${encodedName}`,
33
+ fi: `${encodedName}:n GitHub-tilastot`,
34
+ fr: `Statistiques GitHub de ${encodedName}`,
35
+ hi: `${encodedName} के GitHub आँकड़े`,
36
+ sa: `${encodedName} इत्यस्य GitHub सांख्यिकी`,
37
+ hu: `${encodedName} GitHub statisztika`,
38
+ it: `Statistiche GitHub di ${encodedName}`,
39
+ ja: `${encodedName}の GitHub 統計`,
40
+ kr: `${encodedName}의 GitHub 통계`,
41
+ nl: `${encodedName}'${apostrophe} GitHub-statistieken`,
42
+ "pt-pt": `Estatísticas do GitHub de ${encodedName}`,
43
+ "pt-br": `Estatísticas do GitHub de ${encodedName}`,
44
+ np: `${encodedName}'${apostrophe} गिटहब तथ्याङ्क`,
45
+ el: `Στατιστικά GitHub του ${encodedName}`,
46
+ ro: `Statisticile GitHub ale lui ${encodedName}`,
47
+ ru: `Статистика GitHub пользователя ${encodedName}`,
48
+ "uk-ua": `Статистика GitHub користувача ${encodedName}`,
49
+ id: `Statistik GitHub ${encodedName}`,
50
+ ml: `${encodedName}'${apostrophe} ഗിറ്റ്ഹബ് സ്ഥിതിവിവരക്കണക്കുകൾ`,
51
+ my: `${encodedName} ရဲ့ GitHub အခြေအနေများ`,
52
+ ta: `${encodedName} கிட்ஹப் புள்ளிவிவரங்கள்`,
53
+ sk: `GitHub štatistiky používateľa ${encodedName}`,
54
+ tr: `${encodedName} Hesabının GitHub İstatistikleri`,
55
+ pl: `Statystyki GitHub użytkownika ${encodedName}`,
56
+ uz: `${encodedName}ning GitHub'dagi statistikasi`,
57
+ vi: `Thống Kê GitHub ${encodedName}`,
58
+ se: `GitHubstatistik för ${encodedName}`,
59
+ he: `סטטיסטיקות הגיטהאב של ${encodedName}`,
60
+ fil: `Mga Stats ng GitHub ni ${encodedName}`,
61
+ th: `สถิติ GitHub ของ ${encodedName}`,
62
+ sr: `GitHub статистика корисника ${encodedName}`,
63
+ "sr-latn": `GitHub statistika korisnika ${encodedName}`,
64
+ no: `GitHub-statistikk for ${encodedName}`,
65
+ },
66
+ "statcard.ranktitle": {
67
+ en: `${encodedName}'${apostrophe} GitHub Rank`,
68
+ ar: `${encodedName} إحصائيات جيت هاب`,
69
+ az: `${encodedName}'${apostrophe} Hesabının GitHub Statistikası`,
70
+ ca: `Estadístiques de GitHub de ${encodedName}`,
71
+ cn: `${encodedName} 的 GitHub 统计数据`,
72
+ "zh-tw": `${encodedName} 的 GitHub 統計資料`,
73
+ cs: `GitHub statistiky uživatele ${encodedName}`,
74
+ de: `${encodedName + apostrophe} GitHub-Statistiken`,
75
+ sw: `GitHub Rank ya ${encodedName}`,
76
+ ur: `${encodedName} کی گٹ ہب رینک`,
77
+ bg: `GitHub ранг на ${encodedName}`,
78
+ bn: `${encodedName} এর GitHub পরিসংখ্যান`,
79
+ es: `Estadísticas de GitHub de ${encodedName}`,
80
+ fa: `رتبه گیت‌هاب ${encodedName}`,
81
+ fi: `${encodedName}:n GitHub-sijoitus`,
82
+ fr: `Statistiques GitHub de ${encodedName}`,
83
+ hi: `${encodedName} का GitHub स्थान`,
84
+ sa: `${encodedName} इत्यस्य GitHub स्थानम्`,
85
+ hu: `${encodedName} GitHub statisztika`,
86
+ it: `Statistiche GitHub di ${encodedName}`,
87
+ ja: `${encodedName} の GitHub ランク`,
88
+ kr: `${encodedName}의 GitHub 통계`,
89
+ nl: `${encodedName}'${apostrophe} GitHub-statistieken`,
90
+ "pt-pt": `Estatísticas do GitHub de ${encodedName}`,
91
+ "pt-br": `Estatísticas do GitHub de ${encodedName}`,
92
+ np: `${encodedName}'${apostrophe} गिटहब तथ्याङ्क`,
93
+ el: `Στατιστικά GitHub του ${encodedName}`,
94
+ ro: `Rankul GitHub al lui ${encodedName}`,
95
+ ru: `Рейтинг GitHub пользователя ${encodedName}`,
96
+ "uk-ua": `Рейтинг GitHub користувача ${encodedName}`,
97
+ id: `Statistik GitHub ${encodedName}`,
98
+ ml: `${encodedName}'${apostrophe} ഗിറ്റ്ഹബ് സ്ഥിതിവിവരക്കണക്കുകൾ`,
99
+ my: `${encodedName} ရဲ့ GitHub အဆင့်`,
100
+ ta: `${encodedName} கிட்ஹப் தரவரிசை`,
101
+ sk: `GitHub štatistiky používateľa ${encodedName}`,
102
+ tr: `${encodedName} Hesabının GitHub Yıldızları`,
103
+ pl: `Statystyki GitHub użytkownika ${encodedName}`,
104
+ uz: `${encodedName}ning GitHub'dagi statistikasi`,
105
+ vi: `Thống Kê GitHub ${encodedName}`,
106
+ se: `GitHubstatistik för ${encodedName}`,
107
+ he: `דרגת הגיטהאב של ${encodedName}`,
108
+ fil: `Ranggo ng GitHub ni ${encodedName}`,
109
+ th: `อันดับ GitHub ของ ${encodedName}`,
110
+ sr: `Ранк корисника ${encodedName}`,
111
+ "sr-latn": `Rank korisnika ${encodedName}`,
112
+ no: `GitHub-statistikk for ${encodedName}`,
113
+ },
114
+ "statcard.totalstars": {
115
+ en: "Total Stars Earned",
116
+ ar: "مجموع النجوم",
117
+ az: "Ümumi Ulduz",
118
+ ca: "Total d'estrelles",
119
+ cn: "获标星数",
120
+ "zh-tw": "得標星星數量(Star)",
121
+ cs: "Celkem hvězd",
122
+ de: "Insgesamt erhaltene Sterne",
123
+ sw: "Medali(stars) ulizojishindia",
124
+ ur: "کل ستارے حاصل کیے",
125
+ bg: "Получени звезди",
126
+ bn: "সর্বমোট Star",
127
+ es: "Estrellas totales",
128
+ fa: "مجموع ستاره‌های دریافت‌شده",
129
+ fi: "Ansaitut tähdet yhteensä",
130
+ fr: "Total d'étoiles",
131
+ hi: "कुल अर्जित सितारे",
132
+ sa: "अर्जिताः कुल-तारकाः",
133
+ hu: "Csillagok",
134
+ it: "Stelle totali",
135
+ ja: "スターされた数",
136
+ kr: "받은 스타 수",
137
+ nl: "Totaal Sterren Ontvangen",
138
+ "pt-pt": "Total de estrelas",
139
+ "pt-br": "Total de estrelas",
140
+ np: "कुल ताराहरू",
141
+ el: "Σύνολο Αστεριών",
142
+ ro: "Total de stele câștigate",
143
+ ru: "Всего звёзд",
144
+ "uk-ua": "Всього зірок",
145
+ id: "Total Bintang",
146
+ ml: "ആകെ നക്ഷത്രങ്ങൾ",
147
+ my: "စုစုပေါင်းကြယ်များ",
148
+ ta: "சம்பாதித்த மொத்த நட்சத்திரங்கள்",
149
+ sk: "Hviezdy",
150
+ tr: "Toplam Yıldız",
151
+ pl: "Liczba otrzymanych gwiazdek",
152
+ uz: "Yulduzchalar",
153
+ vi: "Tổng Số Sao",
154
+ se: "Antal intjänade stjärnor",
155
+ he: "סך כל הכוכבים שהושגו",
156
+ fil: "Kabuuang Nakuhang Bituin",
157
+ th: "ดาวทั้งหมดที่ได้รับ",
158
+ sr: "Број освојених звездица",
159
+ "sr-latn": "Broj osvojenih zvezdica",
160
+ no: "Totalt antall stjerner",
161
+ },
162
+ "statcard.commits": {
163
+ en: "Total Commits",
164
+ ar: "مجموع المساهمات",
165
+ az: "Ümumi Commit",
166
+ ca: "Commits totals",
167
+ cn: "累计提交总数",
168
+ "zh-tw": "累計提交數量(Commit)",
169
+ cs: "Celkem commitů",
170
+ de: "Anzahl Commits",
171
+ sw: "Matendo yako yote",
172
+ ur: "کل کمٹ",
173
+ bg: "Общо ангажименти",
174
+ bn: "সর্বমোট Commit",
175
+ es: "Commits totales",
176
+ fa: "مجموع کامیت‌ها",
177
+ fi: "Yhteensä committeja",
178
+ fr: "Total des Commits",
179
+ hi: "कुल commits",
180
+ sa: "कुल-समिन्चयः",
181
+ hu: "Összes commit",
182
+ it: "Commit totali",
183
+ ja: "合計コミット数",
184
+ kr: "전체 커밋 수",
185
+ nl: "Aantal commits",
186
+ "pt-pt": "Total de Commits",
187
+ "pt-br": "Total de Commits",
188
+ np: "कुल Commits",
189
+ el: "Σύνολο Commits",
190
+ ro: "Total Commit-uri",
191
+ ru: "Всего коммитов",
192
+ "uk-ua": "Всього комітів",
193
+ id: "Total Komitmen",
194
+ ml: "ആകെ കമ്മിറ്റുകൾ",
195
+ my: "စုစုပေါင်း Commit များ",
196
+ ta: `மொத்த கமிட்கள்`,
197
+ sk: "Všetky commity",
198
+ tr: "Toplam Commit",
199
+ pl: "Wszystkie commity",
200
+ uz: "'Commit'lar",
201
+ vi: "Tổng Số Cam Kết",
202
+ se: "Totalt antal commits",
203
+ he: "סך כל ה־commits",
204
+ fil: "Kabuuang Commits",
205
+ th: "Commit ทั้งหมด",
206
+ sr: "Укупно commit-ова",
207
+ "sr-latn": "Ukupno commit-ova",
208
+ no: "Totalt antall commits",
209
+ },
210
+ "statcard.prs": {
211
+ en: "Total PRs",
212
+ ar: "مجموع طلبات السحب",
213
+ az: "Ümumi PR",
214
+ ca: "PRs totals",
215
+ cn: "发起的 PR 总数",
216
+ "zh-tw": "拉取請求數量���PR)",
217
+ cs: "Celkem PRs",
218
+ de: "PRs Insgesamt",
219
+ sw: "PRs Zote",
220
+ ur: "کل پی آرز",
221
+ bg: "Заявки за изтегляния",
222
+ bn: "সর্বমোট PR",
223
+ es: "PRs totales",
224
+ fa: "مجموع Pull Request",
225
+ fi: "Yhteensä PR:t",
226
+ fr: "Total des PRs",
227
+ hi: "कुल PR",
228
+ sa: "कुल-पीआर",
229
+ hu: "Összes PR",
230
+ it: "PR totali",
231
+ ja: "合計 PR",
232
+ kr: "PR 횟수",
233
+ nl: "Aantal PR's",
234
+ "pt-pt": "Total de PRs",
235
+ "pt-br": "Total de PRs",
236
+ np: "कुल PRs",
237
+ el: "Σύνολο PRs",
238
+ ro: "Total PR-uri",
239
+ ru: "Всего запросов изменений",
240
+ "uk-ua": "Всього запитів на злиття",
241
+ id: "Total Permintaan Tarik",
242
+ ml: "ആകെ പുൾ അഭ്യർത്ഥനകൾ",
243
+ my: "စုစုပေါင်း PR များ",
244
+ ta: `மொத்த இழுக்கும் கோரிக்கைகள்`,
245
+ sk: "Všetky PR",
246
+ tr: "Toplam PR",
247
+ pl: "Wszystkie PR-y",
248
+ uz: "'Pull Request'lar",
249
+ vi: "Tổng Số PR",
250
+ se: "Totalt antal PR",
251
+ he: "סך כל ה־PRs",
252
+ fil: "Kabuuang PRs",
253
+ th: "PR ทั้งหมด",
254
+ sr: "Укупно PR-ова",
255
+ "sr-latn": "Ukupno PR-ova",
256
+ no: "Totalt antall PR",
257
+ },
258
+ "statcard.issues": {
259
+ en: "Total Issues",
260
+ ar: "مجموع التحسينات",
261
+ az: "Ümumi Problem",
262
+ ca: "Issues totals",
263
+ cn: "提出的 issue 总数",
264
+ "zh-tw": "提出問題數量(Issue)",
265
+ cs: "Celkem problémů",
266
+ de: "Anzahl Issues",
267
+ sw: "Masuala Ibuka",
268
+ ur: "کل مسائل",
269
+ bg: "Брой въпроси",
270
+ bn: "সর্বমোট Issue",
271
+ es: "Issues totales",
272
+ fa: "مجموع مسائل",
273
+ fi: "Yhteensä ongelmat",
274
+ fr: "Nombre total d'incidents",
275
+ hi: "कुल मुद्दे(Issues)",
276
+ sa: "कुल-समस्याः",
277
+ hu: "Összes hibajegy",
278
+ it: "Segnalazioni totali",
279
+ ja: "合計 issue",
280
+ kr: "이슈 개수",
281
+ nl: "Aantal kwesties",
282
+ "pt-pt": "Total de Issues",
283
+ "pt-br": "Total de Issues",
284
+ np: "कुल मुद्दाहरू",
285
+ el: "Σύνολο Ζητημάτων",
286
+ ro: "Total Issue-uri",
287
+ ru: "Всего вопросов",
288
+ "uk-ua": "Всього питань",
289
+ id: "Total Masalah Dilaporkan",
290
+ ml: "ആകെ പ്രശ്നങ്ങൾ",
291
+ my: "စုစုပေါင်းပြဿနာများ",
292
+ ta: `மொத்த சிக்கல்கள்`,
293
+ sk: "Všetky problémy",
294
+ tr: "Toplam Hata",
295
+ pl: "Wszystkie problemy",
296
+ uz: "'Issue'lar",
297
+ vi: "Tổng Số Vấn Đề",
298
+ se: "Total antal issues",
299
+ he: "סך כל ה־issues",
300
+ fil: "Kabuuang mga Isyu",
301
+ th: "Issue ทั้งหมด",
302
+ sr: "Укупно пријављених проблема",
303
+ "sr-latn": "Ukupno prijavljenih problema",
304
+ no: "Totalt antall issues",
305
+ },
306
+ "statcard.contribs": {
307
+ en: "Contributed to (last year)",
308
+ ar: "ساهم في (العام الماضي)",
309
+ az: "Töhfə verdi (ötən il)",
310
+ ca: "Contribucions (l'any passat)",
311
+ cn: "贡献的项目数(去年)",
312
+ "zh-tw": "參與項目數量(去年)",
313
+ cs: "Přispěl k (minulý rok)",
314
+ de: "Beigetragen zu (letztes Jahr)",
315
+ sw: "Idadi ya michango (mwaka mzima)",
316
+ ur: "پچھلے سال میں تعاون کیا",
317
+ bg: "Приноси (за изминалата година)",
318
+ bn: "অবদান (গত বছর)",
319
+ es: "Contribuciones en (el año pasado)",
320
+ fa: "مشارکت در (سال گذشته)",
321
+ fi: "Osallistunut (viime vuonna)",
322
+ fr: "Contribué à (l'année dernière)",
323
+ hi: "(पिछले वर्ष) में योगदान दिया",
324
+ sa: "(गते वर्षे) योगदानम् कृतम्",
325
+ hu: "Hozzájárulások (tavaly)",
326
+ it: "Ha contribuito a (l'anno scorso)",
327
+ ja: "貢献したリポジトリ (昨年)",
328
+ kr: "(작년) 기여",
329
+ nl: "Bijgedragen aan (vorig jaar)",
330
+ "pt-pt": "Contribuiu em (ano passado)",
331
+ "pt-br": "Contribuiu para (ano passado)",
332
+ np: "कुल योगदानहरू (गत वर्ष)",
333
+ el: "Συνεισφέρθηκε σε (πέρυσι)",
334
+ ro: "Total Contribuiri",
335
+ ru: "Внесено вклада (за прошлый год)",
336
+ "uk-ua": "Зроблено внесок (за минулий рік)",
337
+ id: "Berkontribusi ke (tahun lalu)",
338
+ ml: "(കഴിഞ്ഞ വർഷത്തെ)ആകെ സംഭാവനകൾ ",
339
+ my: "အကူအညီပေးခဲ့သည် (ပြီးခဲ့သည့်နှစ်)",
340
+ ta: "(கடந்த ஆண்டு) பங்களித்தது",
341
+ sk: "Účasti (minulý rok)",
342
+ tr: "Katkı Verildi (geçen yıl)",
343
+ pl: "Kontrybucje (w zeszłym roku)",
344
+ uz: "Hissa qoʻshgan (o'tgan yili)",
345
+ vi: "Đã Đóng Góp (năm ngoái)",
346
+ se: "Bidragit till (förra året)",
347
+ he: "תרם ל... (שנה שעברה)",
348
+ fil: "Nag-ambag sa (nakaraang taon)",
349
+ th: "มีส่วนร่วมใน (ปีที่แล้ว)",
350
+ sr: "Доприноси (прошла година)",
351
+ "sr-latn": "Doprinosi (prošla godina)",
352
+ no: "Bidro til (i fjor)",
353
+ },
354
+ "statcard.reviews": {
355
+ en: "Total PRs Reviewed",
356
+ ar: "طلبات السحب التي تم مراجعتها",
357
+ az: "Nəzərdən Keçirilən Ümumi PR",
358
+ ca: "Total de PRs revisats",
359
+ cn: "审查的 PR 总数",
360
+ "zh-tw": "審核的 PR 總計",
361
+ cs: "Celkový počet PR",
362
+ de: "Insgesamt überprüfte PRs",
363
+ sw: "Idadi ya PRs zilizopitiliwa upya",
364
+ ur: "کل پی آرز کا جائزہ لیا",
365
+ bg: "Разгледани заявки за изтегляне",
366
+ bn: "সর্বমোট পুনরালোচনা করা PR",
367
+ es: "PR totales revisados",
368
+ fa: "مجموع درخواست‌های ادغام بررسی‌شده",
369
+ fi: "Yhteensä tarkastettuja PR:itä",
370
+ fr: "Nombre total de PR examinés",
371
+ hi: "कुल PRs की समीक्षा की गई",
372
+ sa: "समीक्षिताः कुल-पीआर",
373
+ hu: "Összes ellenőrzött PR",
374
+ it: "PR totali esaminati",
375
+ ja: "レビューされた PR の総数",
376
+ kr: "검토된 총 PR",
377
+ nl: "Totaal beoordeelde PR's",
378
+ "pt-pt": "Total de PRs revistos",
379
+ "pt-br": "Total de PRs revisados",
380
+ np: "कुल पीआर समीक्षित",
381
+ el: "Σύνολο Αναθεωρημένων PR",
382
+ ro: "Total PR-uri Revizuite",
383
+ ru: "Всего запросов проверено",
384
+ "uk-ua": "Всього запитів перевірено",
385
+ id: "Total PR yang Direview",
386
+ ml: "ആകെ പുൾ അവലോകനങ്ങൾ",
387
+ my: "စုစုပေါင်း PR များကို ပြန်လည်သုံးသပ်ခဲ့မှု",
388
+ ta: "மதிப்பாய்வு செய்யப்பட்ட மொத்த இழுத்தல் கோரிக்கைகள்",
389
+ sk: "Celkový počet PR",
390
+ tr: "İncelenen toplam PR",
391
+ pl: "Łącznie sprawdzonych PR",
392
+ uz: "Koʻrib chiqilgan PR-lar soni",
393
+ vi: "Tổng Số PR Đã Xem Xét",
394
+ se: "Totalt antal granskade PR",
395
+ he: "סך כל ה־PRs שנסרקו",
396
+ fil: "Kabuuang PR na Na-review",
397
+ th: "รีวิว PR แล้วทั้งหมด",
398
+ sr: "Укупно прегледаних PR-ова",
399
+ "sr-latn": "Ukupno pregledanih PR-ova",
400
+ no: "Totalt antall vurderte PR",
401
+ },
402
+ "statcard.discussions-started": {
403
+ en: "Total Discussions Started",
404
+ ar: "مجموع المناقشات التي بدأها",
405
+ az: "Başladılan Ümumi Müzakirə",
406
+ ca: "Discussions totals iniciades",
407
+ cn: "发起的讨论总数",
408
+ "zh-tw": "發起的討論總數",
409
+ cs: "Celkem zahájených diskusí",
410
+ de: "Gesamt gestartete Diskussionen",
411
+ sw: "Idadi ya majadiliano yaliyoanzishwa",
412
+ ur: "کل مباحثے شروع کیے",
413
+ bg: "Започнати дискусии",
414
+ bn: "সর্বমোট আলোচনা শুরু",
415
+ es: "Discusiones totales iniciadas",
416
+ fa: "مجموع بحث‌های آغازشده",
417
+ fi: "Aloitetut keskustelut yhteensä",
418
+ fr: "Nombre total de discussions lancées",
419
+ hi: "कुल चर्चाएँ शुरू हुईं",
420
+ sa: "प्रारब्धाः कुल-चर्चाः",
421
+ hu: "Összes megkezdett megbeszélés",
422
+ it: "Discussioni totali avviate",
423
+ ja: "開始されたディスカッションの総数",
424
+ kr: "시작된 토론 총 수",
425
+ nl: "Totaal gestarte discussies",
426
+ "pt-pt": "Total de Discussões Iniciadas",
427
+ "pt-br": "Total de Discussões Iniciadas",
428
+ np: "कुल चर्चा सुरु",
429
+ el: "Σύνολο Συζητήσεων που Ξεκίνησαν",
430
+ ro: "Total Discuții Începute",
431
+ ru: "Всего начатых обсуждений",
432
+ "uk-ua": "Всього розпочатих дискусій",
433
+ id: "Total Diskusi Dimulai",
434
+ ml: "ആരംഭിച്ച ആലോചനകൾ",
435
+ my: "စုစုပေါင်း စတင်ခဲ့သော ဆွေးနွေးမှုများ",
436
+ ta: "மொத்த விவாதங்கள் தொடங்கின",
437
+ sk: "Celkový počet začatých diskusií",
438
+ tr: "Başlatılan Toplam Tartışma",
439
+ pl: "Łącznie rozpoczętych dyskusji",
440
+ uz: "Boshlangan muzokaralar soni",
441
+ vi: "Tổng Số Thảo Luận Bắt Đầu",
442
+ se: "Totalt antal diskussioner startade",
443
+ he: "סך כל הדיונים שהותחלו",
444
+ fil: "Kabuuang mga Diskusyon na Sinimulan",
445
+ th: "เริ่มหัวข้อสนทนาทั้งหมด",
446
+ sr: "Укупно покренутих дискусија",
447
+ "sr-latn": "Ukupno pokrenutih diskusija",
448
+ no: "Totalt antall startede diskusjoner",
449
+ },
450
+ "statcard.discussions-answered": {
451
+ en: "Total Discussions Answered",
452
+ ar: "مجموع المناقشات المُجابة",
453
+ az: "Cavablandırılan Ümumi Müzakirə",
454
+ ca: "Discussions totals respostes",
455
+ cn: "回复的讨论总数",
456
+ "zh-tw": "回覆討論總計",
457
+ cs: "Celkem zodpovězených diskusí",
458
+ de: "Gesamt beantwortete Diskussionen",
459
+ sw: "Idadi ya majadiliano yaliyojibiwa",
460
+ ur: "کل مباحثے جواب دیے",
461
+ bg: "Отговорени дискусии",
462
+ bn: "সর্বমোট আলোচনা উত্তর",
463
+ es: "Discusiones totales respondidas",
464
+ fa: "مجموع بحث‌های پاسخ‌داده‌شده",
465
+ fi: "Vastatut keskustelut yhteensä",
466
+ fr: "Nombre total de discussions répondues",
467
+ hi: "कुल चर्चाओं के उत्तर",
468
+ sa: "उत्तरिताः कुल-चर्चाः",
469
+ hu: "Összes megválaszolt megbeszélés",
470
+ it: "Discussioni totali risposte",
471
+ ja: "回答されたディスカッションの総数",
472
+ kr: "답변된 토론 총 수",
473
+ nl: "Totaal beantwoorde discussies",
474
+ "pt-pt": "Total de Discussões Respondidas",
475
+ "pt-br": "Total de Discussões Respondidas",
476
+ np: "कुल चर्चा उत्तर",
477
+ el: "Σύνολο Συζητήσεων που Απαντήθηκαν",
478
+ ro: "Total Răspunsuri La Discuții",
479
+ ru: "Всего отвеченных обсуждений",
480
+ "uk-ua": "Всього відповідей на дискусії",
481
+ id: "Total Diskusi Dibalas",
482
+ ml: "ഉത്തരം നൽകിയ ആലോചനകൾ",
483
+ my: "စုစုပေါင်း ပြန်လည်ဖြေကြားခဲ့သော ဆွေးနွေးမှုများ",
484
+ ta: "பதிலளிக்கப்பட்ட மொத்த விவாதங்கள்",
485
+ sk: "Celkový počet zodpovedaných diskusií",
486
+ tr: "Toplam Cevaplanan Tartışma",
487
+ pl: "Łącznie odpowiedzianych dyskusji",
488
+ uz: "Javob berilgan muzokaralar soni",
489
+ vi: "Tổng Số Thảo Luận Đã Trả Lời",
490
+ se: "Totalt antal diskussioner besvarade",
491
+ he: "סך כל הדיונים שנענו",
492
+ fil: "Kabuuang mga Diskusyon na Sinagot",
493
+ th: "ตอบกลับหัวข้อสนทนาทั้งหมด",
494
+ sr: "Укупно одговорених дискусија",
495
+ "sr-latn": "Ukupno odgovorenih diskusija",
496
+ no: "Totalt antall besvarte diskusjoner",
497
+ },
498
+ "statcard.prs-merged": {
499
+ en: "Total PRs Merged",
500
+ ar: "مجموع طلبات السحب المُدمجة",
501
+ az: "Birləşdirilmiş Ümumi PR",
502
+ ca: "PRs totals fusionats",
503
+ cn: "合并的 PR 总数",
504
+ "zh-tw": "合併的 PR 總計",
505
+ cs: "Celkem sloučených PR",
506
+ de: "Insgesamt zusammengeführte PRs",
507
+ sw: "Idadi ya PRs zilizounganishwa",
508
+ ur: "کل پی آرز ضم کیے",
509
+ bg: "Сляти заявки за изтегляния",
510
+ bn: "সর্বমোট PR একত্রীকৃত",
511
+ es: "PR totales fusionados",
512
+ fa: "مجموع درخواست‌های ادغام شده",
513
+ fi: "Yhteensä yhdistetyt PR:t",
514
+ fr: "Nombre total de PR fusionnés",
515
+ hi: "कुल PR का विलय",
516
+ sa: "विलीनाः कुल-पीआर",
517
+ hu: "Összes egyesített PR",
518
+ it: "PR totali uniti",
519
+ ja: "マージされた PR の総数",
520
+ kr: "병합된 총 PR",
521
+ nl: "Totaal samengevoegde PR's",
522
+ "pt-pt": "Total de PRs Fundidos",
523
+ "pt-br": "Total de PRs Integrados",
524
+ np: "कुल विलयित PRs",
525
+ el: "Σύνολο Συγχωνευμένων PR",
526
+ ro: "Total PR-uri Fuzionate",
527
+ ru: "Всего объединённых запросов",
528
+ "uk-ua": "Всього об'єднаних запитів",
529
+ id: "Total PR Digabungkan",
530
+ my: "စုစုပေါင်း ပေါင်းစည်းခဲ့သော PR များ",
531
+ ta: "இணைக்கப்பட்ட மொத்த PRகள்",
532
+ sk: "Celkový počet zlúčených PR",
533
+ tr: "Toplam Birleştirilmiş PR",
534
+ pl: "Łącznie połączonych PR",
535
+ uz: "Birlangan PR-lar soni",
536
+ vi: "Tổng Số PR Đã Hợp Nhất",
537
+ se: "Totalt antal sammanfogade PR",
538
+ he: "סך כל ה־PRs ששולבו",
539
+ fil: "Kabuuang mga PR na Pinagsama",
540
+ th: "PR ที่ถูก Merged แล้วทั้งหมด",
541
+ sr: "Укупно спојених PR-ова",
542
+ "sr-latn": "Ukupno spojenih PR-ova",
543
+ no: "Totalt antall sammenslåtte PR",
544
+ },
545
+ "statcard.prs-merged-percentage": {
546
+ en: "Merged PRs Percentage",
547
+ ar: "نسبة طلبات السحب المُدمجة",
548
+ az: "Birləşdirilmiş PR-ların Faizi",
549
+ ca: "Percentatge de PRs fusionats",
550
+ cn: "被合并的 PR 占比",
551
+ "zh-tw": "合併的 PR 百分比",
552
+ cs: "Sloučené PRs v procentech",
553
+ de: "Zusammengeführte PRs in Prozent",
554
+ sw: "Asilimia ya PRs zilizounganishwa",
555
+ ur: "ضم کیے گئے پی آرز کی شرح",
556
+ bg: "Процент сляти заявки за изтегляния",
557
+ bn: "PR একত্রীকরণের শতাংশ",
558
+ es: "Porcentaje de PR fusionados",
559
+ fa: "درصد درخواست‌های ادغام‌شده",
560
+ fi: "Yhdistettyjen PR:ien prosentti",
561
+ fr: "Pourcentage de PR fusionnés",
562
+ hi: "मर्ज किए गए PRs प्रतिशत",
563
+ sa: "विलीन-पीआर प्रतिशतम्",
564
+ hu: "Egyesített PR-k százaléka",
565
+ it: "Percentuale di PR uniti",
566
+ ja: "マージされた PR の割合",
567
+ kr: "병합된 PR의 비율",
568
+ nl: "Percentage samengevoegde PR's",
569
+ "pt-pt": "Percentagem de PRs Fundidos",
570
+ "pt-br": "Porcentagem de PRs Integrados",
571
+ np: "PR मर्ज गरिएको प्रतिशत",
572
+ el: "Ποσοστό Συγχωνευμένων PR",
573
+ ro: "Procentaj PR-uri Fuzionate",
574
+ ru: "Процент объединённых запросов",
575
+ "uk-ua": "Відсоток об'єднаних запитів",
576
+ id: "Persentase PR Digabungkan",
577
+ my: "PR များကို ပေါင်းစည်းခဲ့သော ရာခိုင်နှုန်း",
578
+ ta: "இணைக்கப்பட்ட PRகள் சதவீதம்",
579
+ sk: "Percento zlúčených PR",
580
+ tr: "Birleştirilmiş PR Yüzdesi",
581
+ pl: "Procent połączonych PR",
582
+ uz: "Birlangan PR-lar foizi",
583
+ vi: "Tỷ Lệ PR Đã Hợp Nhất",
584
+ se: "Procent av sammanfogade PR",
585
+ he: "אחוז ה־PRs ששולבו",
586
+ fil: "Porsyento ng mga PR na Pinagsama",
587
+ th: "เปอร์เซ็นต์ PR ที่ถูก Merged แล้วทั้งหมด",
588
+ sr: "Проценат спојених PR-ова",
589
+ "sr-latn": "Procenat spojenih PR-ova",
590
+ no: "Prosentandel sammenslåtte PR",
591
+ },
592
+ };
593
+ };
594
+
595
+ const repoCardLocales = {
596
+ "repocard.template": {
597
+ en: "Template",
598
+ ar: "قالب",
599
+ az: "Şablon",
600
+ bg: "Шаблон",
601
+ bn: "টেমপ্লেট",
602
+ ca: "Plantilla",
603
+ cn: "模板",
604
+ "zh-tw": "模板",
605
+ cs: "Šablona",
606
+ de: "Vorlage",
607
+ sw: "Kigezo",
608
+ ur: "سانچہ",
609
+ es: "Plantilla",
610
+ fa: "الگو",
611
+ fi: "Malli",
612
+ fr: "Modèle",
613
+ hi: "खाका",
614
+ sa: "प्रारूपम्",
615
+ hu: "Sablon",
616
+ it: "Template",
617
+ ja: "テンプレート",
618
+ kr: "템플릿",
619
+ nl: "Sjabloon",
620
+ "pt-pt": "Modelo",
621
+ "pt-br": "Modelo",
622
+ np: "टेम्पलेट",
623
+ el: "Πρότυπο",
624
+ ro: "Șablon",
625
+ ru: "Шаблон",
626
+ "uk-ua": "Шаблон",
627
+ id: "Pola",
628
+ ml: "ടെംപ്ലേറ്റ്",
629
+ my: "ပုံစံ",
630
+ ta: `டெம்ப்ளேட்`,
631
+ sk: "Šablóna",
632
+ tr: "Şablon",
633
+ pl: "Szablony",
634
+ uz: "Shablon",
635
+ vi: "Mẫu",
636
+ se: "Mall",
637
+ he: "תבנית",
638
+ fil: "Suleras",
639
+ th: "เทมเพลต",
640
+ sr: "Шаблон",
641
+ "sr-latn": "Šablon",
642
+ no: "Mal",
643
+ },
644
+ "repocard.archived": {
645
+ en: "Archived",
646
+ ar: "مُؤرشف",
647
+ az: "Arxiv",
648
+ bg: "Архивирани",
649
+ bn: "আর্কাইভড",
650
+ ca: "Arxivats",
651
+ cn: "已归档",
652
+ "zh-tw": "已封存",
653
+ cs: "Archivováno",
654
+ de: "Archiviert",
655
+ sw: "Hifadhiwa kwenye kumbukumbu",
656
+ ur: "محفوظ شدہ",
657
+ es: "Archivados",
658
+ fa: "بایگانی‌شده",
659
+ fi: "Arkistoitu",
660
+ fr: "Archivé",
661
+ hi: "संग्रहीत",
662
+ sa: "संगृहीतम्",
663
+ hu: "Archivált",
664
+ it: "Archiviata",
665
+ ja: "アーカイブ済み",
666
+ kr: "보관됨",
667
+ nl: "Gearchiveerd",
668
+ "pt-pt": "Arquivados",
669
+ "pt-br": "Arquivados",
670
+ np: "अभिलेख राखियो",
671
+ el: "Αρχειοθετημένα",
672
+ ro: "Arhivat",
673
+ ru: "Архивирован",
674
+ "uk-ua": "Архивований",
675
+ id: "Arsip",
676
+ ml: "ശേഖരിച്ചത്",
677
+ my: "သိုလှောင်ပြီး",
678
+ ta: `காப்பகப்படுத்தப்பட்டது`,
679
+ sk: "Archivované",
680
+ tr: "Arşiv",
681
+ pl: "Zarchiwizowano",
682
+ uz: "Arxivlangan",
683
+ vi: "Đã Lưu Trữ",
684
+ se: "Arkiverade",
685
+ he: "גנוז",
686
+ fil: "Naka-arkibo",
687
+ th: "เก็บถาวร",
688
+ sr: "Архивирано",
689
+ "sr-latn": "Arhivirano",
690
+ no: "Arkivert",
691
+ },
692
+ };
693
+
694
+ const langCardLocales = {
695
+ "langcard.title": {
696
+ en: "Most Used Languages",
697
+ ar: "أكثر اللغات استخدامًا",
698
+ az: "Ən Çox İstifadə Olunan Dillər",
699
+ ca: "Llenguatges més utilitzats",
700
+ cn: "最常用的语言",
701
+ "zh-tw": "最常用的語言",
702
+ cs: "Nejpoužívanější jazyky",
703
+ de: "Meist verwendete Sprachen",
704
+ bg: "Най-използвани езици",
705
+ bn: "সর্বাধিক ব্যবহৃত ভাষা সমূহ",
706
+ sw: "Lugha zilizotumika zaidi",
707
+ ur: "سب سے زیادہ استعمال شدہ زبانیں",
708
+ es: "Lenguajes más usados",
709
+ fa: "زبان‌های پرکاربرد",
710
+ fi: "Käytetyimmät kielet",
711
+ fr: "Langages les plus utilisés",
712
+ hi: "सर्वाधिक प्रयुक्त भाषा",
713
+ sa: "सर्वाधिक-प्रयुक्ताः भाषाः",
714
+ hu: "Leggyakrabban használt nyelvek",
715
+ it: "Linguaggi più utilizzati",
716
+ ja: "最もよく使っている言語",
717
+ kr: "가장 많이 사용된 언어",
718
+ nl: "Meest gebruikte talen",
719
+ "pt-pt": "Linguagens mais usadas",
720
+ "pt-br": "Linguagens mais usadas",
721
+ np: "अधिक प्रयोग गरिएको भाषाहरू",
722
+ el: "Οι περισσότερο χρησιμοποιούμενες γλώσσες",
723
+ ro: "Cele Mai Folosite Limbaje",
724
+ ru: "Наиболее используемые языки",
725
+ "uk-ua": "Найбільш використовувані мови",
726
+ id: "Bahasa Yang Paling Banyak Digunakan",
727
+ ml: "കൂടുതൽ ഉപയോഗിച്ച ഭാഷകൾ",
728
+ my: "အများဆုံးအသုံးပြုသောဘာသာစကားများ",
729
+ ta: `அதிகம் பயன்படுத்தப்படும் மொழிகள்`,
730
+ sk: "Najviac používané jazyky",
731
+ tr: "En Çok Kullanılan Diller",
732
+ pl: "Najczęściej używane języki",
733
+ uz: "Eng koʻp ishlatiladigan tillar",
734
+ vi: "Ngôn Ngữ Thường Sử Dụng",
735
+ se: "Mest använda språken",
736
+ he: "השפות הכי משומשות",
737
+ fil: "Mga Pinakamadalas na Ginagamit na Wika",
738
+ th: "ภาษาที่ใช้บ่อยที่สุด",
739
+ sr: "Најкоришћенији језици",
740
+ "sr-latn": "Najkorišćeniji jezici",
741
+ no: "Mest brukte språk",
742
+ },
743
+ "langcard.nodata": {
744
+ en: "No languages data.",
745
+ ar: "لا توجد بيانات للغات.",
746
+ az: "Dil məlumatı yoxdur.",
747
+ ca: "Sense dades d'idiomes",
748
+ cn: "没有语言数据。",
749
+ "zh-tw": "沒有語言資料。",
750
+ cs: "Žádné jazykové údaje.",
751
+ de: "Keine Sprachdaten.",
752
+ bg: "Няма данни за езици",
753
+ bn: "কোন ভাষার ডেটা নেই।",
754
+ sw: "Hakuna kumbukumbu ya lugha zozote",
755
+ ur: "کوئی زبان کا ڈیٹا نہیں۔",
756
+ es: "Sin datos de idiomas.",
757
+ fa: "داده‌ای برای زبان‌ها وجود ندارد.",
758
+ fi: "Ei kielitietoja.",
759
+ fr: "Aucune donnée sur les langues.",
760
+ hi: "कोई भाषा डेटा नहीं",
761
+ sa: "भाषा-विवरणं नास्ति।",
762
+ hu: "Nincsenek nyelvi adatok.",
763
+ it: "Nessun dato sulle lingue.",
764
+ ja: "言語データがありません。",
765
+ kr: "언어 데이터가 없습니다.",
766
+ nl: "Ingen sprogdata.",
767
+ "pt-pt": "Sem dados de linguagens.",
768
+ "pt-br": "Sem dados de linguagens.",
769
+ np: "कुनै भाषा डाटा छैन।",
770
+ el: "Δεν υπάρχουν δεδομένα γλωσσών.",
771
+ ro: "Lipsesc date despre limbă.",
772
+ ru: "Нет данных о языках.",
773
+ "uk-ua": "Немає даних про мови.",
774
+ id: "Tidak ada data bahasa.",
775
+ ml: "ഭാഷാ ഡാറ്റയില്ല.",
776
+ my: "ဒေတာ မရှိပါ။",
777
+ ta: `மொழி தரவு இல்லை.`,
778
+ sk: "Žiadne údaje o jazykoch.",
779
+ tr: "Dil verisi yok.",
780
+ pl: "Brak danych dotyczących języków.",
781
+ uz: "Til haqida ma'lumot yo'q.",
782
+ vi: "Không có dữ liệu ngôn ngữ.",
783
+ se: "Inga språkdata.",
784
+ he: "אין נתוני שפות",
785
+ fil: "Walang datos ng lenggwahe.",
786
+ th: "ไม่มีข้อมูลภาษา",
787
+ sr: "Нема података о језицима.",
788
+ "sr-latn": "Nema podataka o jezicima.",
789
+ no: "Ingen språkdata.",
790
+ },
791
+ };
792
+
793
+ const wakatimeCardLocales = {
794
+ "wakatimecard.title": {
795
+ en: "WakaTime Stats",
796
+ ar: "إحصائيات واكا تايم",
797
+ az: "WakaTime Statistikası",
798
+ ca: "Estadístiques de WakaTime",
799
+ cn: "WakaTime 周统计",
800
+ "zh-tw": "WakaTime 周統計",
801
+ cs: "Statistiky WakaTime",
802
+ de: "WakaTime Status",
803
+ sw: "Takwimu ya WakaTime",
804
+ ur: "وکاٹائم کے اعداد و شمار",
805
+ bg: "WakaTime статистика",
806
+ bn: "WakaTime স্ট্যাটাস",
807
+ es: "Estadísticas de WakaTime",
808
+ fa: "آمار WakaTime",
809
+ fi: "WakaTime-tilastot",
810
+ fr: "Statistiques de WakaTime",
811
+ hi: "वाकाटाइम आँकड़े",
812
+ sa: "WakaTime सांख्यिकी",
813
+ hu: "WakaTime statisztika",
814
+ it: "Statistiche WakaTime",
815
+ ja: "WakaTime ワカタイム統計",
816
+ kr: "WakaTime 주간 통계",
817
+ nl: "WakaTime-statistieken",
818
+ "pt-pt": "Estatísticas WakaTime",
819
+ "pt-br": "Estatísticas WakaTime",
820
+ np: "WakaTime तथ्या .्क",
821
+ el: "Στατιστικά WakaTime",
822
+ ro: "Statistici WakaTime",
823
+ ru: "Статистика WakaTime",
824
+ "uk-ua": "Статистика WakaTime",
825
+ id: "Status WakaTime",
826
+ ml: "വാകടൈം സ്ഥിതിവിവരക്കണക്കുകൾ",
827
+ my: "WakaTime အချက်အလက်များ",
828
+ ta: `WakaTime புள்ளிவிவரங்கள்`,
829
+ sk: "WakaTime štatistika",
830
+ tr: "WakaTime İstatistikler",
831
+ pl: "Statystyki WakaTime",
832
+ uz: "WakaTime statistikasi",
833
+ vi: "Thống Kê WakaTime",
834
+ se: "WakaTime statistik",
835
+ he: "סטטיסטיקות WakaTime",
836
+ fil: "Mga Estadistika ng WakaTime",
837
+ th: "สถิติ WakaTime",
838
+ sr: "WakaTime статистика",
839
+ "sr-latn": "WakaTime statistika",
840
+ no: "WakaTime-statistikk",
841
+ },
842
+ "wakatimecard.lastyear": {
843
+ en: "last year",
844
+ ar: "العام الماضي",
845
+ az: "Ötən il",
846
+ ca: "L'any passat",
847
+ cn: "去年",
848
+ "zh-tw": "去年",
849
+ cs: "Minulý rok",
850
+ de: "Letztes Jahr",
851
+ sw: "Mwaka uliopita",
852
+ ur: "پچھلا سال",
853
+ bg: "миналата год.",
854
+ bn: "গত বছর",
855
+ es: "El año pasado",
856
+ fa: "سال گذشته",
857
+ fi: "Viime vuosi",
858
+ fr: "L'année dernière",
859
+ hi: "पिछले साल",
860
+ sa: "गतवर्षे",
861
+ hu: "Tavaly",
862
+ it: "L'anno scorso",
863
+ ja: "昨年",
864
+ kr: "작년",
865
+ nl: "Vorig jaar",
866
+ "pt-pt": "Ano passado",
867
+ "pt-br": "Ano passado",
868
+ np: "गत वर्ष",
869
+ el: "Πέρυσι",
870
+ ro: "Anul trecut",
871
+ ru: "За прошлый год",
872
+ "uk-ua": "За минулий рік",
873
+ id: "Tahun lalu",
874
+ ml: "കഴിഞ്ഞ വർഷം",
875
+ my: "မနှစ်က",
876
+ ta: `கடந்த ஆண்டு`,
877
+ sk: "Minulý rok",
878
+ tr: "Geçen yıl",
879
+ pl: "W zeszłym roku",
880
+ uz: "O'tgan yil",
881
+ vi: "Năm ngoái",
882
+ se: "Förra året",
883
+ he: "שנה שעברה",
884
+ fil: "Nakaraang Taon",
885
+ th: "ปีที่แล้ว",
886
+ sr: "Прошла год.",
887
+ "sr-latn": "Prošla god.",
888
+ no: "I fjor",
889
+ },
890
+ "wakatimecard.last7days": {
891
+ en: "last 7 days",
892
+ ar: "آخر 7 أيام",
893
+ az: "Son 7 gün",
894
+ ca: "Ultims 7 dies",
895
+ cn: "最近 7 天",
896
+ "zh-tw": "最近 7 天",
897
+ cs: "Posledních 7 dní",
898
+ de: "Letzte 7 Tage",
899
+ sw: "Siku 7 zilizopita",
900
+ ur: "پچھلے 7 دن",
901
+ bg: "последните 7 дни",
902
+ bn: "গত ৭ দিন",
903
+ es: "Últimos 7 días",
904
+ fa: "هفت روز گذشته",
905
+ fi: "Viimeiset 7 päivää",
906
+ fr: "7 derniers jours",
907
+ hi: "पिछले 7 दिन",
908
+ sa: "विगतसप्तदिनेषु",
909
+ hu: "Elmúlt 7 nap",
910
+ it: "Ultimi 7 giorni",
911
+ ja: "過去 7 日間",
912
+ kr: "지난 7 일",
913
+ nl: "Afgelopen 7 dagen",
914
+ "pt-pt": "Últimos 7 dias",
915
+ "pt-br": "Últimos 7 dias",
916
+ np: "गत ७ दिन",
917
+ el: "Τελευταίες 7 ημέρες",
918
+ ro: "Ultimele 7 zile",
919
+ ru: "Последние 7 дней",
920
+ "uk-ua": "Останні 7 днів",
921
+ id: "7 hari terakhir",
922
+ ml: "കഴിഞ്ഞ 7 ദിവസം",
923
+ my: "7 ရက်အတွင်း",
924
+ ta: `கடந்த 7 நாட்கள்`,
925
+ sk: "Posledných 7 dní",
926
+ tr: "Son 7 gün",
927
+ pl: "Ostatnie 7 dni",
928
+ uz: "O'tgan 7 kun",
929
+ vi: "7 ngày qua",
930
+ se: "Senaste 7 dagarna",
931
+ he: "ב־7 הימים האחרונים",
932
+ fil: "Huling 7 Araw",
933
+ th: "7 วันที่ผ่านมา",
934
+ sr: "Претходних 7 дана",
935
+ "sr-latn": "Prethodnih 7 dana",
936
+ no: "Siste 7 dager",
937
+ },
938
+ "wakatimecard.notpublic": {
939
+ en: "WakaTime user profile not public",
940
+ ar: "ملف مستخدم واكا تايم شخصي",
941
+ az: "WakaTime istifadəçi profili ictimai deyil",
942
+ ca: "Perfil d'usuari de WakaTime no públic",
943
+ cn: "WakaTime 用户个人资料未公开",
944
+ "zh-tw": "WakaTime 使用者個人資料未公開",
945
+ cs: "Profil uživatele WakaTime není veřejný",
946
+ de: "WakaTime-Benutzerprofil nicht öffentlich",
947
+ sw: "Maelezo ya mtumizi wa WakaTime si ya watu wote(umma)",
948
+ ur: "وکاٹائم صارف کا پروفائل عوامی نہیں",
949
+ bg: "Потребителски профил в WakaTime не е общодостъпен",
950
+ bn: "WakaTime ব্যবহারকারীর প্রোফাইল প্রকাশ্য নয়",
951
+ es: "Perfil de usuario de WakaTime no público",
952
+ fa: "پروفایل کاربری WakaTime عمومی نیست",
953
+ fi: "WakaTime-käyttäjäprofiili ei ole julkinen",
954
+ fr: "Profil utilisateur WakaTime non public",
955
+ hi: "WakaTime उपयोगकर्ता प्रोफ़ाइल सार्वजनिक नहीं है",
956
+ sa: "WakaTime उपयोगकर्ता-प्रोफ़ाइल सार्वजनिकं नास्ति",
957
+ hu: "A WakaTime felhasználói profilja nem nyilvános",
958
+ it: "Profilo utente WakaTime non pubblico",
959
+ ja: "WakaTime ユーザープロファイルは公開されていません",
960
+ kr: "WakaTime 사용자 프로필이 공개되지 않았습니다",
961
+ nl: "WakaTime gebruikersprofiel niet openbaar",
962
+ "pt-pt": "Perfil de utilizador WakaTime não público",
963
+ "pt-br": "Perfil de usuário WakaTime não público",
964
+ np: "WakaTime प्रयोगकर्ता प्रोफाइल सार्वजनिक छैन",
965
+ el: "Το προφίλ χρήστη WakaTime δεν είναι δημόσιο",
966
+ ro: "Profilul utilizatorului de Wakatime nu este public",
967
+ ru: "Профиль пользователя WakaTime не общедоступный",
968
+ "uk-ua": "Профіль користувача WakaTime не публічний",
969
+ id: "Profil pengguna WakaTime tidak publik",
970
+ ml: "WakaTime ഉപയോക്തൃ പ്രൊഫൈൽ പൊതുവായി പ്രസിദ്ധീകരിക്കപ്പെടാത്തതാണ്",
971
+ my: "Public Profile မဟုတ်ပါ။",
972
+ ta: `WakaTime பயனர் சுயவிவரம் பொதுவில் இல்லை.`,
973
+ sk: "Profil používateľa WakaTime nie je verejný",
974
+ tr: "WakaTime kullanıcı profili herkese açık değil",
975
+ pl: "Profil użytkownika WakaTime nie jest publiczny",
976
+ uz: "WakaTime foydalanuvchi profili ochiq emas",
977
+ vi: "Hồ sơ người dùng WakaTime không công khai",
978
+ se: "WakaTime användarprofil inte offentlig",
979
+ he: "פרופיל משתמש WakaTime לא פומבי",
980
+ fil: "Hindi pampubliko ang profile ng gumagamit ng WakaTime",
981
+ th: "โปรไฟล์ผู้ใช้ WakaTime ไม่ได้เป็นสาธารณะ",
982
+ sr: "WakaTime профил корисника није јаван",
983
+ "sr-latn": "WakaTime profil korisnika nije javan",
984
+ no: "WakaTime brukerprofil ikke offentlig",
985
+ },
986
+ "wakatimecard.nocodedetails": {
987
+ en: "User doesn't publicly share detailed code statistics",
988
+ ar: "المستخدم لا يشارك المعلومات التفصيلية",
989
+ az: "İstifadəçi kod statistikalarını ictimai şəkildə paylaşmır",
990
+ ca: "L'usuari no comparteix dades públiques del seu codi",
991
+ cn: "用户不公开分享详细的代码统计信息",
992
+ "zh-tw": "使用者不公開分享詳細的程式碼統計資訊",
993
+ cs: "Uživatel nesdílí podrobné statistiky kódu",
994
+ de: "Benutzer teilt keine detaillierten Code-Statistiken",
995
+ sw: "Mtumizi hagawi kila kitu au takwimu na umma",
996
+ ur: "صارف عوامی طور پر تفصیلی کوڈ کے اعداد و شمار شیئر نہیں کرتا",
997
+ bg: "Потребителят не споделя подробна статистика за код",
998
+ bn: "ব্যবহারকারী বিস্তারিত কোড পরিসংখ্যান প্রকাশ করেন না",
999
+ es: "El usuario no comparte públicamente estadísticas detalladas de código",
1000
+ fa: "کاربر آمار کد تفصیلی را به‌صورت عمومی به اشتراک نمی‌گذارد",
1001
+ fi: "Käyttäjä ei jaa julkisesti tarkkoja kooditilastoja",
1002
+ fr: "L'utilisateur ne partage pas publiquement de statistiques de code détaillées",
1003
+ hi: "उपयोगकर्ता विस्तृत कोड आँकड़े सार्वजनिक रूप से साझा नहीं करता है",
1004
+ sa: "उपयोगकर्ता विस्तृत-कोड-सांख्यिकीं सार्वजनिकरूपेण न दर्शयति",
1005
+ hu: "A felhasználó nem osztja meg nyilvánosan a részletes kódstatisztikákat",
1006
+ it: "L'utente non condivide pubblicamente statistiche dettagliate sul codice",
1007
+ ja: "ユーザーは詳細なコード統計を公開しません",
1008
+ kr: "사용자는 자세한 코드 통계를 공개하지 않습니다",
1009
+ nl: "Gebruiker deelt geen gedetailleerde code-statistieken",
1010
+ "pt-pt":
1011
+ "O utilizador não partilha publicamente estatísticas detalhadas de código",
1012
+ "pt-br":
1013
+ "O usuário não compartilha publicamente estatísticas detalhadas de código",
1014
+ np: "प्रयोगकर्ता सार्वजनिक रूपमा विस्तृत कोड तथ्याङ्क स���झा गर्दैन",
1015
+ el: "Ο χρήστης δεν δημοσιεύει δημόσια λεπτομερείς στατιστικές κώδικα",
1016
+ ro: "Utilizatorul nu își publică statisticile detaliate ale codului",
1017
+ ru: "Пользователь не делится подробной статистикой кода",
1018
+ "uk-ua": "Користувач не публікує детальну статистику коду",
1019
+ id: "Pengguna tidak membagikan statistik kode terperinci secara publik",
1020
+ ml: "ഉപയോക്താവ് പൊതുവായി വിശദീകരിച്ച കോഡ് സ്റ്റാറ്റിസ്റ്റിക്സ് പങ്കിടുന്നില്ല",
1021
+ my: "အသုံးပြုသူသည် အသေးစိတ် ကုဒ် စာရင်းအင်းများကို အများသို့ မမျှဝေပါ။",
1022
+ ta: `பயனர் விரிவான குறியீட்டு புள்ளிவிவரங்களைப் பொதுவில் பகிர்வதில்லை.`,
1023
+ sk: "Používateľ neposkytuje verejne podrobné štatistiky kódu",
1024
+ tr: "Kullanıcı ayrıntılı kod istatistiklerini herkese açık olarak paylaşmıyor",
1025
+ pl: "Użytkownik nie udostępnia publicznie szczegółowych statystyk kodu",
1026
+ uz: "Foydalanuvchi umumiy ko`d statistikasini ochiq ravishda almashmaydi",
1027
+ vi: "Người dùng không chia sẻ thống kê mã chi tiết công khai",
1028
+ se: "Användaren delar inte offentligt detaljerad kodstatistik",
1029
+ he: "משתמש לא מפרסם פומבית סטטיסטיקות קוד מפורטות",
1030
+ fil: "Hindi ibinabahagi ng gumagamit ang detalyadong estadistika ng code nang pampubliko",
1031
+ th: "ผู้ใช้ไม่ได้แชร์สถิติโค้ดแบบสาธารณะ",
1032
+ sr: "Корисник не дели јавно детаљну статистику кода",
1033
+ "sr-latn": "Korisnik ne deli javno detaljnu statistiku koda",
1034
+ no: "Brukeren deler ikke detaljert kodestatistikk offentlig",
1035
+ },
1036
+ "wakatimecard.nocodingactivity": {
1037
+ en: "No coding activity this week",
1038
+ ar: "لا يوجد نشاط برمجي لهذا الأسبوع",
1039
+ az: "Bu həftə heç bir kodlaşdırma fəaliyyəti olmayıb",
1040
+ ca: "No hi ha activitat de codificació aquesta setmana",
1041
+ cn: "本周没有编程活动",
1042
+ "zh-tw": "本周沒有編程活動",
1043
+ cs: "Tento týden žádná aktivita v kódování",
1044
+ de: "Keine Aktivitäten in dieser Woche",
1045
+ sw: "Hakuna matukio yoyote ya kusimba wiki hii",
1046
+ ur: "اس ہفتے کوئی کوڈنگ سرگرمی نہیں",
1047
+ bg: "Няма активност при кодирането тази седмица",
1048
+ bn: "এই সপ্তাহে কোন কোডিং অ্যাক্টিভিটি নেই",
1049
+ es: "No hay actividad de codificación esta semana",
1050
+ fa: "فعالیت کدنویسی در این هفته وجود ندارد",
1051
+ fi: "Ei koodaustoimintaa tällä viikolla",
1052
+ fr: "Aucune activité de codage cette semaine",
1053
+ hi: "इस सप्ताह कोई कोडिंग गतिविधि नहीं ",
1054
+ sa: "अस्मिन् सप्ताहे कोडिङ्-कार्यं नास्ति",
1055
+ hu: "Nem volt aktivitás ezen a héten",
1056
+ it: "Nessuna attività in questa settimana",
1057
+ ja: "今週のコーディング活動はありません",
1058
+ kr: "이번 주 작업내역 없음",
1059
+ nl: "Geen programmeeractiviteit deze week",
1060
+ "pt-pt": "Sem atividade esta semana",
1061
+ "pt-br": "Nenhuma atividade de codificação esta semana",
1062
+ np: "यस हप्ता कुनै कोडिंग गतिविधि छैन",
1063
+ el: "Δεν υπάρχει δραστηριότητα κώδικα γι' αυτή την εβδομάδα",
1064
+ ro: "Nicio activitate de programare săptămâna aceasta",
1065
+ ru: "На этой неделе не было активности",
1066
+ "uk-ua": "Цього тижня не було активності",
1067
+ id: "Tidak ada aktivitas perkodingan minggu ini",
1068
+ ml: "ഈ ആഴ്ച കോഡിംഗ് പ്രവർത്തനങ്ങളൊന്നുമില്ല",
1069
+ my: "ဒီအပတ်မှာ ကုဒ်ရေးခြင်း မရှိပါ။",
1070
+ ta: `இந்த வாரம் குறியீட்டு செயல்பாடு இல்லை.`,
1071
+ sk: "Žiadna kódovacia aktivita tento týždeň",
1072
+ tr: "Bu hafta herhangi bir kod yazma aktivitesi olmadı",
1073
+ pl: "Brak aktywności w tym tygodniu",
1074
+ uz: "Bu hafta faol bo'lmadi",
1075
+ vi: "Không Có Hoạt Động Trong Tuần Này",
1076
+ se: "Ingen aktivitet denna vecka",
1077
+ he: "אין פעילות תכנותית השבוע",
1078
+ fil: "Walang aktibidad sa pag-code ngayong linggo",
1079
+ th: "ไม่มีกิจกรรมการเขียนโค้ดในสัปดาห์นี้",
1080
+ sr: "Током ове недеље није било никаквих активности",
1081
+ "sr-latn": "Tokom ove nedelje nije bilo nikakvih aktivnosti",
1082
+ no: "Ingen kodeaktivitet denne uken",
1083
+ },
1084
+ };
1085
+
1086
+ const availableLocales = Object.keys(repoCardLocales["repocard.archived"]);
1087
+
1088
+ /**
1089
+ * Checks whether the locale is available or not.
1090
+ *
1091
+ * @param {string} locale The locale to check.
1092
+ * @returns {boolean} Boolean specifying whether the locale is available or not.
1093
+ */
1094
+ const isLocaleAvailable = (locale) => {
1095
+ return availableLocales.includes(locale.toLowerCase());
1096
+ };
1097
+
1098
+ export {
1099
+ availableLocales,
1100
+ isLocaleAvailable,
1101
+ langCardLocales,
1102
+ repoCardLocales,
1103
+ statCardLocales,
1104
+ wakatimeCardLocales,
1105
+ };