@dytsou/github-readme-stats 1.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/api/gist.js +98 -0
- package/api/index.js +146 -0
- package/api/pin.js +114 -0
- package/api/status/pat-info.js +193 -0
- package/api/status/up.js +129 -0
- package/api/top-langs.js +163 -0
- package/api/wakatime.js +129 -0
- package/package.json +102 -0
- package/readme.md +412 -0
- package/src/calculateRank.js +87 -0
- package/src/cards/gist.js +151 -0
- package/src/cards/index.js +4 -0
- package/src/cards/repo.js +199 -0
- package/src/cards/stats.js +607 -0
- package/src/cards/top-languages.js +968 -0
- package/src/cards/types.d.ts +67 -0
- package/src/cards/wakatime.js +482 -0
- package/src/common/Card.js +294 -0
- package/src/common/I18n.js +41 -0
- package/src/common/access.js +69 -0
- package/src/common/api-utils.js +221 -0
- package/src/common/blacklist.js +10 -0
- package/src/common/cache.js +153 -0
- package/src/common/color.js +194 -0
- package/src/common/envs.js +15 -0
- package/src/common/error.js +84 -0
- package/src/common/fmt.js +90 -0
- package/src/common/html.js +43 -0
- package/src/common/http.js +24 -0
- package/src/common/icons.js +63 -0
- package/src/common/index.js +13 -0
- package/src/common/languageColors.json +651 -0
- package/src/common/log.js +14 -0
- package/src/common/ops.js +124 -0
- package/src/common/render.js +261 -0
- package/src/common/retryer.js +97 -0
- package/src/common/worker-adapter.js +148 -0
- package/src/common/worker-env.js +48 -0
- package/src/fetchers/gist.js +114 -0
- package/src/fetchers/repo.js +118 -0
- package/src/fetchers/stats.js +350 -0
- package/src/fetchers/top-languages.js +192 -0
- package/src/fetchers/types.d.ts +118 -0
- package/src/fetchers/wakatime.js +109 -0
- package/src/index.js +2 -0
- package/src/translations.js +1105 -0
- package/src/worker.ts +116 -0
- package/themes/README.md +229 -0
- package/themes/index.js +467 -0
|
@@ -0,0 +1,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
|
+
};
|
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
|
|
3
|
+
import escapeHtml from "escape-html";
|
|
4
|
+
import { SECONDARY_ERROR_MESSAGES, TRY_AGAIN_LATER } from "./error.js";
|
|
5
|
+
import { getCardColors } from "./color.js";
|
|
6
|
+
import { escapeCSSValue } from "./html.js";
|
|
7
|
+
import { clampValue } from "./ops.js";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Auto layout utility, allows us to layout things vertically or horizontally with
|
|
11
|
+
* proper gaping.
|
|
12
|
+
*
|
|
13
|
+
* @param {object} props Function properties.
|
|
14
|
+
* @param {string[]} props.items Array of items to layout.
|
|
15
|
+
* @param {number} props.gap Gap between items.
|
|
16
|
+
* @param {"column" | "row"=} props.direction Direction to layout items.
|
|
17
|
+
* @param {number[]=} props.sizes Array of sizes for each item.
|
|
18
|
+
* @returns {string[]} Array of items with proper layout.
|
|
19
|
+
*/
|
|
20
|
+
const flexLayout = ({ items, gap, direction, sizes = [] }) => {
|
|
21
|
+
let lastSize = 0;
|
|
22
|
+
// filter() for filtering out empty strings
|
|
23
|
+
return items.filter(Boolean).map((item, i) => {
|
|
24
|
+
const size = sizes[i] || 0;
|
|
25
|
+
let transform = `translate(${lastSize}, 0)`;
|
|
26
|
+
if (direction === "column") {
|
|
27
|
+
transform = `translate(0, ${lastSize})`;
|
|
28
|
+
}
|
|
29
|
+
lastSize += size + gap;
|
|
30
|
+
return `<g transform="${transform}">${item}</g>`;
|
|
31
|
+
});
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Creates a node to display the primary programming language of the repository/gist.
|
|
36
|
+
*
|
|
37
|
+
* **Security Note:** This function safely handles untrusted input by HTML-encoding
|
|
38
|
+
* the `langName` parameter and CSS-escaping the `langColor` parameter.
|
|
39
|
+
* Data from external APIs (like GitHub) is sanitized before being inserted into the SVG.
|
|
40
|
+
*
|
|
41
|
+
* @param {string} langName Language name (will be HTML-encoded).
|
|
42
|
+
* @param {string} langColor Language color (will be CSS-escaped).
|
|
43
|
+
* @returns {string} Language display SVG object.
|
|
44
|
+
*/
|
|
45
|
+
const createLanguageNode = (langName, langColor) => {
|
|
46
|
+
const safeLangColor = escapeCSSValue(langColor);
|
|
47
|
+
return `
|
|
48
|
+
<g data-testid="primary-lang">
|
|
49
|
+
<circle data-testid="lang-color" cx="0" cy="-5" r="6" fill="${safeLangColor}" />
|
|
50
|
+
<text data-testid="lang-name" class="gray" x="15">${escapeHtml(langName)}</text>
|
|
51
|
+
</g>
|
|
52
|
+
`;
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Create a node to indicate progress in percentage along a horizontal line.
|
|
57
|
+
*
|
|
58
|
+
* @param {Object} params Object that contains the createProgressNode parameters.
|
|
59
|
+
* @param {number} params.x X-axis position.
|
|
60
|
+
* @param {number} params.y Y-axis position.
|
|
61
|
+
* @param {number} params.width Width of progress bar.
|
|
62
|
+
* @param {string} params.color Progress color.
|
|
63
|
+
* @param {number} params.progress Progress value.
|
|
64
|
+
* @param {string} params.progressBarBackgroundColor Progress bar bg color.
|
|
65
|
+
* @param {number} params.delay Delay before animation starts.
|
|
66
|
+
* @returns {string} Progress node.
|
|
67
|
+
*/
|
|
68
|
+
const createProgressNode = ({
|
|
69
|
+
x,
|
|
70
|
+
y,
|
|
71
|
+
width,
|
|
72
|
+
color,
|
|
73
|
+
progress,
|
|
74
|
+
progressBarBackgroundColor,
|
|
75
|
+
delay,
|
|
76
|
+
}) => {
|
|
77
|
+
const progressPercentage = clampValue(progress, 2, 100);
|
|
78
|
+
|
|
79
|
+
return `
|
|
80
|
+
<svg width="${width}" x="${x}" y="${y}">
|
|
81
|
+
<rect rx="5" ry="5" x="0" y="0" width="${width}" height="8" fill="${progressBarBackgroundColor}"></rect>
|
|
82
|
+
<svg data-testid="lang-progress" width="${progressPercentage}%">
|
|
83
|
+
<rect
|
|
84
|
+
height="8"
|
|
85
|
+
fill="${color}"
|
|
86
|
+
rx="5" ry="5" x="0" y="0"
|
|
87
|
+
class="lang-progress"
|
|
88
|
+
style="animation-delay: ${delay}ms;"
|
|
89
|
+
/>
|
|
90
|
+
</svg>
|
|
91
|
+
</svg>
|
|
92
|
+
`;
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Creates an icon with label to display repository/gist stats like forks, stars, etc.
|
|
97
|
+
*
|
|
98
|
+
* @param {string} icon The icon to display.
|
|
99
|
+
* @param {number|string} label The label to display.
|
|
100
|
+
* @param {string} testid The testid to assign to the label.
|
|
101
|
+
* @param {number} iconSize The size of the icon.
|
|
102
|
+
* @returns {string} Icon with label SVG object.
|
|
103
|
+
*/
|
|
104
|
+
const iconWithLabel = (icon, label, testid, iconSize) => {
|
|
105
|
+
if (typeof label === "number" && label <= 0) {
|
|
106
|
+
return "";
|
|
107
|
+
}
|
|
108
|
+
const iconSvg = `
|
|
109
|
+
<svg
|
|
110
|
+
class="icon"
|
|
111
|
+
y="-12"
|
|
112
|
+
viewBox="0 0 16 16"
|
|
113
|
+
version="1.1"
|
|
114
|
+
width="${iconSize}"
|
|
115
|
+
height="${iconSize}"
|
|
116
|
+
>
|
|
117
|
+
${icon}
|
|
118
|
+
</svg>
|
|
119
|
+
`;
|
|
120
|
+
const text = `<text data-testid="${testid}" class="gray">${label}</text>`;
|
|
121
|
+
return flexLayout({ items: [iconSvg, text], gap: 20 }).join("");
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
// Script parameters.
|
|
125
|
+
const ERROR_CARD_LENGTH = 576.5;
|
|
126
|
+
|
|
127
|
+
const UPSTREAM_API_ERRORS = [
|
|
128
|
+
TRY_AGAIN_LATER,
|
|
129
|
+
SECONDARY_ERROR_MESSAGES.MAX_RETRY,
|
|
130
|
+
];
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Renders error message on the card.
|
|
134
|
+
*
|
|
135
|
+
* **Security Note:** This function safely handles untrusted input by HTML-encoding
|
|
136
|
+
* the `message` and `secondaryMessage` parameters using the escape-html library.
|
|
137
|
+
* All user-provided or external data is sanitized before being inserted into the SVG.
|
|
138
|
+
*
|
|
139
|
+
* @param {object} args Function arguments.
|
|
140
|
+
* @param {string} args.message Main error message (will be HTML-encoded).
|
|
141
|
+
* @param {string} [args.secondaryMessage=""] The secondary error message (will be HTML-encoded).
|
|
142
|
+
* @param {object} [args.renderOptions={}] Render options.
|
|
143
|
+
* @param {string=} args.renderOptions.title_color Card title color.
|
|
144
|
+
* @param {string=} args.renderOptions.text_color Card text color.
|
|
145
|
+
* @param {string=} args.renderOptions.bg_color Card background color.
|
|
146
|
+
* @param {string=} args.renderOptions.border_color Card border color.
|
|
147
|
+
* @param {Parameters<typeof getCardColors>[0]["theme"]=} args.renderOptions.theme Card theme.
|
|
148
|
+
* @param {boolean=} args.renderOptions.show_repo_link Whether to show repo link or not.
|
|
149
|
+
* @returns {string} The SVG markup.
|
|
150
|
+
*/
|
|
151
|
+
const renderError = ({
|
|
152
|
+
message,
|
|
153
|
+
secondaryMessage = "",
|
|
154
|
+
renderOptions = {},
|
|
155
|
+
}) => {
|
|
156
|
+
const {
|
|
157
|
+
title_color,
|
|
158
|
+
text_color,
|
|
159
|
+
bg_color,
|
|
160
|
+
border_color,
|
|
161
|
+
theme = "default",
|
|
162
|
+
show_repo_link = true,
|
|
163
|
+
} = renderOptions;
|
|
164
|
+
|
|
165
|
+
// returns theme based colors with proper overrides and defaults
|
|
166
|
+
const { titleColor, textColor, bgColor, borderColor } = getCardColors({
|
|
167
|
+
title_color,
|
|
168
|
+
text_color,
|
|
169
|
+
icon_color: "",
|
|
170
|
+
bg_color,
|
|
171
|
+
border_color,
|
|
172
|
+
ring_color: "",
|
|
173
|
+
theme,
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
// Sanitize color values to prevent XSS in SVG attributes
|
|
177
|
+
const safeTitleColor = escapeCSSValue(titleColor);
|
|
178
|
+
const safeTextColor = escapeCSSValue(textColor);
|
|
179
|
+
// Handle both string colors and gradient arrays (for gradients, use first color as fallback)
|
|
180
|
+
const safeBgColor =
|
|
181
|
+
typeof bgColor === "string"
|
|
182
|
+
? escapeCSSValue(bgColor)
|
|
183
|
+
: Array.isArray(bgColor) && bgColor.length > 0
|
|
184
|
+
? escapeCSSValue(`#${bgColor[0]}`)
|
|
185
|
+
: "#1f2328"; // Default fallback color
|
|
186
|
+
const safeBorderColor = escapeCSSValue(borderColor);
|
|
187
|
+
|
|
188
|
+
return `
|
|
189
|
+
<svg width="${ERROR_CARD_LENGTH}" height="120" viewBox="0 0 ${ERROR_CARD_LENGTH} 120" fill="${safeBgColor}" xmlns="http://www.w3.org/2000/svg">
|
|
190
|
+
<style>
|
|
191
|
+
.text { font: 600 16px 'Segoe UI', Ubuntu, Sans-Serif; fill: ${safeTitleColor} }
|
|
192
|
+
.small { font: 600 12px 'Segoe UI', Ubuntu, Sans-Serif; fill: ${safeTextColor} }
|
|
193
|
+
.gray { fill: #858585 }
|
|
194
|
+
</style>
|
|
195
|
+
<rect x="0.5" y="0.5" width="${
|
|
196
|
+
ERROR_CARD_LENGTH - 1
|
|
197
|
+
}" height="99%" rx="4.5" fill="${safeBgColor}" stroke="${safeBorderColor}"/>
|
|
198
|
+
<text x="25" y="45" class="text">Something went wrong!${
|
|
199
|
+
UPSTREAM_API_ERRORS.includes(secondaryMessage) || !show_repo_link
|
|
200
|
+
? ""
|
|
201
|
+
: " file an issue at https://tiny.one/readme-stats"
|
|
202
|
+
}</text>
|
|
203
|
+
<text data-testid="message" x="25" y="55" class="text small">
|
|
204
|
+
<tspan x="25" dy="18">${escapeHtml(message)}</tspan>
|
|
205
|
+
<tspan x="25" dy="18" class="gray">${escapeHtml(secondaryMessage)}</tspan>
|
|
206
|
+
</text>
|
|
207
|
+
</svg>
|
|
208
|
+
`;
|
|
209
|
+
};
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Retrieve text length.
|
|
213
|
+
*
|
|
214
|
+
* @see https://stackoverflow.com/a/48172630/10629172
|
|
215
|
+
* @param {string} str String to measure.
|
|
216
|
+
* @param {number} fontSize Font size.
|
|
217
|
+
* @returns {number} Text length.
|
|
218
|
+
*/
|
|
219
|
+
const measureText = (str, fontSize = 10) => {
|
|
220
|
+
// prettier-ignore
|
|
221
|
+
const widths = [
|
|
222
|
+
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
|
|
223
|
+
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
|
|
224
|
+
0, 0, 0, 0, 0.2796875, 0.2765625,
|
|
225
|
+
0.3546875, 0.5546875, 0.5546875, 0.8890625, 0.665625, 0.190625,
|
|
226
|
+
0.3328125, 0.3328125, 0.3890625, 0.5828125, 0.2765625, 0.3328125,
|
|
227
|
+
0.2765625, 0.3015625, 0.5546875, 0.5546875, 0.5546875, 0.5546875,
|
|
228
|
+
0.5546875, 0.5546875, 0.5546875, 0.5546875, 0.5546875, 0.5546875,
|
|
229
|
+
0.2765625, 0.2765625, 0.584375, 0.5828125, 0.584375, 0.5546875,
|
|
230
|
+
1.0140625, 0.665625, 0.665625, 0.721875, 0.721875, 0.665625,
|
|
231
|
+
0.609375, 0.7765625, 0.721875, 0.2765625, 0.5, 0.665625,
|
|
232
|
+
0.5546875, 0.8328125, 0.721875, 0.7765625, 0.665625, 0.7765625,
|
|
233
|
+
0.721875, 0.665625, 0.609375, 0.721875, 0.665625, 0.94375,
|
|
234
|
+
0.665625, 0.665625, 0.609375, 0.2765625, 0.3546875, 0.2765625,
|
|
235
|
+
0.4765625, 0.5546875, 0.3328125, 0.5546875, 0.5546875, 0.5,
|
|
236
|
+
0.5546875, 0.5546875, 0.2765625, 0.5546875, 0.5546875, 0.221875,
|
|
237
|
+
0.240625, 0.5, 0.221875, 0.8328125, 0.5546875, 0.5546875,
|
|
238
|
+
0.5546875, 0.5546875, 0.3328125, 0.5, 0.2765625, 0.5546875,
|
|
239
|
+
0.5, 0.721875, 0.5, 0.5, 0.5, 0.3546875, 0.259375, 0.353125, 0.5890625,
|
|
240
|
+
];
|
|
241
|
+
|
|
242
|
+
const avg = 0.5279276315789471;
|
|
243
|
+
return (
|
|
244
|
+
str
|
|
245
|
+
.split("")
|
|
246
|
+
.map((c) =>
|
|
247
|
+
c.charCodeAt(0) < widths.length ? widths[c.charCodeAt(0)] : avg,
|
|
248
|
+
)
|
|
249
|
+
.reduce((cur, acc) => acc + cur) * fontSize
|
|
250
|
+
);
|
|
251
|
+
};
|
|
252
|
+
|
|
253
|
+
export {
|
|
254
|
+
ERROR_CARD_LENGTH,
|
|
255
|
+
renderError,
|
|
256
|
+
createLanguageNode,
|
|
257
|
+
createProgressNode,
|
|
258
|
+
iconWithLabel,
|
|
259
|
+
flexLayout,
|
|
260
|
+
measureText,
|
|
261
|
+
};
|
|
@@ -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;
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
/**
|
|
3
|
+
* Adapter utilities to convert Express-style handlers to Hono handlers.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { encodeHTML } from "./html.js";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Creates a mock Express response object that works with Hono context.
|
|
10
|
+
*
|
|
11
|
+
* @returns {any} Mock Express response object
|
|
12
|
+
*/
|
|
13
|
+
export function createMockResponse() {
|
|
14
|
+
const headers = {};
|
|
15
|
+
let responseSent = false;
|
|
16
|
+
let responseBody = null;
|
|
17
|
+
|
|
18
|
+
return {
|
|
19
|
+
setHeader: (name, value) => {
|
|
20
|
+
headers[name] = value;
|
|
21
|
+
},
|
|
22
|
+
send: (body) => {
|
|
23
|
+
responseSent = true;
|
|
24
|
+
responseBody = body;
|
|
25
|
+
|
|
26
|
+
// Ensure body is a string (Camo requires valid SVG)
|
|
27
|
+
const svgBody = typeof body === "string" ? body : String(body);
|
|
28
|
+
|
|
29
|
+
// Build headers object
|
|
30
|
+
const responseHeaders = new Headers();
|
|
31
|
+
|
|
32
|
+
// Set Content-Type first (required by GitHub Camo CDN - must be image/svg+xml)
|
|
33
|
+
responseHeaders.set("Content-Type", "image/svg+xml; charset=utf-8");
|
|
34
|
+
|
|
35
|
+
// Set Cache-Control if not already set (Camo expects cacheable responses)
|
|
36
|
+
const cacheControl = headers["Cache-Control"] || headers["cache-control"];
|
|
37
|
+
if (cacheControl) {
|
|
38
|
+
responseHeaders.set("Cache-Control", String(cacheControl));
|
|
39
|
+
} else {
|
|
40
|
+
responseHeaders.set("Cache-Control", "public, max-age=3600");
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Set all other headers
|
|
44
|
+
Object.entries(headers).forEach(([name, value]) => {
|
|
45
|
+
// Don't override Content-Type or Cache-Control if already set
|
|
46
|
+
const lowerName = name.toLowerCase();
|
|
47
|
+
if (lowerName !== "content-type" && lowerName !== "cache-control") {
|
|
48
|
+
responseHeaders.set(name, String(value));
|
|
49
|
+
}
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
// Return Response with proper image content type for Camo compatibility
|
|
53
|
+
return new Response(svgBody, {
|
|
54
|
+
status: 200,
|
|
55
|
+
headers: responseHeaders,
|
|
56
|
+
});
|
|
57
|
+
},
|
|
58
|
+
_wasSent: () => responseSent,
|
|
59
|
+
_getBody: () => responseBody,
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Creates a mock Express request object from Hono context.
|
|
65
|
+
*
|
|
66
|
+
* @param {import('hono').Context} c Hono context
|
|
67
|
+
* @returns {any} Mock Express request object
|
|
68
|
+
*/
|
|
69
|
+
export function createMockRequest(c) {
|
|
70
|
+
return {
|
|
71
|
+
query: c.req.query(),
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Adapts an Express-style handler to work with Hono.
|
|
77
|
+
*
|
|
78
|
+
* @param {Function} expressHandler Express handler function (req, res) => {}
|
|
79
|
+
* @returns {Function} Hono handler function
|
|
80
|
+
*/
|
|
81
|
+
export function adaptExpressHandler(expressHandler) {
|
|
82
|
+
return async (c) => {
|
|
83
|
+
const req = createMockRequest(c);
|
|
84
|
+
const res = createMockResponse();
|
|
85
|
+
|
|
86
|
+
try {
|
|
87
|
+
const result = await expressHandler(req, res);
|
|
88
|
+
|
|
89
|
+
// If res.send() was called, it returns a Response object
|
|
90
|
+
if (res._wasSent()) {
|
|
91
|
+
// If result is a Response (from res.send()), return it directly
|
|
92
|
+
if (result instanceof Response) {
|
|
93
|
+
return result;
|
|
94
|
+
}
|
|
95
|
+
// Otherwise, result might be undefined or something else
|
|
96
|
+
// In that case, we already have the body from res._getBody()
|
|
97
|
+
const body = res._getBody();
|
|
98
|
+
if (body !== null) {
|
|
99
|
+
// Reconstruct the response with proper image content type for Camo
|
|
100
|
+
const svgBody = typeof body === "string" ? body : String(body);
|
|
101
|
+
return new Response(svgBody, {
|
|
102
|
+
status: 200,
|
|
103
|
+
headers: {
|
|
104
|
+
"Content-Type": "image/svg+xml; charset=utf-8",
|
|
105
|
+
"Cache-Control": "public, max-age=3600",
|
|
106
|
+
},
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
if (result !== undefined) {
|
|
110
|
+
return result;
|
|
111
|
+
}
|
|
112
|
+
return c;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// If handler returns something directly (like a Response from guardAccess)
|
|
116
|
+
if (result !== undefined) {
|
|
117
|
+
// If it's a Response, return it directly
|
|
118
|
+
if (result instanceof Response) {
|
|
119
|
+
return result;
|
|
120
|
+
}
|
|
121
|
+
return result;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Fallback - should not happen in normal flow
|
|
125
|
+
// Return SVG error card instead of text for Camo compatibility
|
|
126
|
+
const errorSvg = `<svg width="400" height="100" xmlns="http://www.w3.org/2000/svg"><text x="20" y="50" font-family="Arial" font-size="16" fill="red">No response generated</text></svg>`;
|
|
127
|
+
return new Response(errorSvg, {
|
|
128
|
+
status: 500,
|
|
129
|
+
headers: { "Content-Type": "image/svg+xml; charset=utf-8" },
|
|
130
|
+
});
|
|
131
|
+
} catch (error) {
|
|
132
|
+
// Log the error for debugging
|
|
133
|
+
console.error("Adapter error:", error);
|
|
134
|
+
console.error("Error stack:", error.stack);
|
|
135
|
+
console.error("Request URL:", c.req.url);
|
|
136
|
+
console.error("Request query:", c.req.query());
|
|
137
|
+
|
|
138
|
+
// Return SVG error card instead of text for Camo compatibility
|
|
139
|
+
// Sanitize error message to prevent XSS
|
|
140
|
+
const safeMessage = encodeHTML(String(error.message || "Unknown error"));
|
|
141
|
+
const errorSvg = `<svg width="400" height="100" xmlns="http://www.w3.org/2000/svg"><text x="20" y="50" font-family="Arial" font-size="16" fill="red">Error: ${safeMessage}</text></svg>`;
|
|
142
|
+
return new Response(errorSvg, {
|
|
143
|
+
status: 500,
|
|
144
|
+
headers: { "Content-Type": "image/svg+xml; charset=utf-8" },
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
};
|
|
148
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
|
|
3
|
+
// Initialize process.env early to prevent errors during module imports
|
|
4
|
+
// This is safe because if we're in Node.js, process.env already exists
|
|
5
|
+
// If we're in Workers, we'll populate it later via setupWorkerEnv
|
|
6
|
+
if (typeof globalThis.process === "undefined") {
|
|
7
|
+
globalThis.process = { env: {} };
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Checks if we're running in Cloudflare Workers environment.
|
|
12
|
+
* Detects by checking for Cloudflare-specific globals that exist at import time.
|
|
13
|
+
* @returns {boolean} True if running in Cloudflare Workers, false otherwise
|
|
14
|
+
*/
|
|
15
|
+
export function isCloudflareWorkers() {
|
|
16
|
+
// Check for Cloudflare Workers runtime globals
|
|
17
|
+
// These exist at import time, before setupWorkerEnv is called
|
|
18
|
+
// In Workers, process.env typically doesn't exist or is empty at import time
|
|
19
|
+
return (
|
|
20
|
+
typeof globalThis.process === "undefined" ||
|
|
21
|
+
!globalThis.process.env ||
|
|
22
|
+
Object.keys(globalThis.process.env).length === 0
|
|
23
|
+
);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Sets up process.env from Cloudflare Workers env object.
|
|
28
|
+
* This allows the existing codebase to work with Cloudflare Workers
|
|
29
|
+
* without requiring changes to all files that use process.env.
|
|
30
|
+
*
|
|
31
|
+
* @param {Record<string, any>} env Cloudflare Workers environment object
|
|
32
|
+
*/
|
|
33
|
+
export function setupWorkerEnv(env) {
|
|
34
|
+
// Copy all env vars to process.env
|
|
35
|
+
// Cloudflare Workers don't have process.env, so we create a mock
|
|
36
|
+
if (typeof globalThis.process === "undefined") {
|
|
37
|
+
globalThis.process = { env: {} };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Merge Cloudflare env vars into process.env
|
|
41
|
+
Object.assign(globalThis.process.env, env);
|
|
42
|
+
|
|
43
|
+
// Also set NODE_ENV if not already set
|
|
44
|
+
// Check using 'in' operator to avoid triggering the "define" replacement warning
|
|
45
|
+
if (!("NODE_ENV" in globalThis.process.env)) {
|
|
46
|
+
globalThis.process.env.NODE_ENV = "production";
|
|
47
|
+
}
|
|
48
|
+
}
|