extract "core" package, small improvements (#137)
This PR mainly refactors the code by extracting a "core" package which is then used by the "backend" and "frontend" apps. Apart from this the PR also contains several smaller changes like fixing dependabot, upgrading dependencies, aligned "package.json" files, added tests, ... addresses step 1 of #32 closes #136 --------- Co-authored-by: Marco Pasqualetti <marco.pasqualetti@live.com>
This commit is contained in:
co-authored by
Marco Pasqualetti
parent
19c39f02af
commit
d476f8cbe1
@@ -0,0 +1,97 @@
|
||||
// @ts-check
|
||||
|
||||
import { renderGistCard } from "../cards/gist.js";
|
||||
import {
|
||||
MissingParamError,
|
||||
retrieveSecondaryMessage,
|
||||
} from "../common/error.js";
|
||||
import { parseBoolean } from "../common/ops.js";
|
||||
import { renderError } from "../common/render.js";
|
||||
import { fetchGist } from "../fetchers/gist.js";
|
||||
import { isLocaleAvailable } from "../translations.js";
|
||||
|
||||
// @ts-ignore
|
||||
export default async (
|
||||
{
|
||||
id,
|
||||
title_color,
|
||||
icon_color,
|
||||
text_color,
|
||||
bg_color,
|
||||
theme,
|
||||
locale,
|
||||
border_radius,
|
||||
border_color,
|
||||
show_owner,
|
||||
hide_border,
|
||||
},
|
||||
pat = null,
|
||||
) => {
|
||||
if (locale && !isLocaleAvailable(locale)) {
|
||||
return {
|
||||
status: "error - permanent",
|
||||
content: renderError({
|
||||
message: "Something went wrong",
|
||||
secondaryMessage: "Language not found",
|
||||
renderOptions: {
|
||||
title_color,
|
||||
text_color,
|
||||
bg_color,
|
||||
border_color,
|
||||
theme,
|
||||
},
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const gistData = await fetchGist(id, pat);
|
||||
|
||||
return {
|
||||
status: "success",
|
||||
content: renderGistCard(gistData, {
|
||||
title_color,
|
||||
icon_color,
|
||||
text_color,
|
||||
bg_color,
|
||||
theme,
|
||||
border_radius,
|
||||
border_color,
|
||||
locale: locale ? locale.toLowerCase() : null,
|
||||
show_owner: parseBoolean(show_owner),
|
||||
hide_border: parseBoolean(hide_border),
|
||||
}),
|
||||
};
|
||||
} catch (err) {
|
||||
if (err instanceof Error) {
|
||||
return {
|
||||
status: "error - temporary",
|
||||
content: renderError({
|
||||
message: err.message,
|
||||
secondaryMessage: retrieveSecondaryMessage(err),
|
||||
renderOptions: {
|
||||
title_color,
|
||||
text_color,
|
||||
bg_color,
|
||||
border_color,
|
||||
theme,
|
||||
show_repo_link: !(err instanceof MissingParamError),
|
||||
},
|
||||
}),
|
||||
};
|
||||
}
|
||||
return {
|
||||
status: "error - temporary",
|
||||
content: renderError({
|
||||
message: "An unknown error occurred",
|
||||
renderOptions: {
|
||||
title_color,
|
||||
text_color,
|
||||
bg_color,
|
||||
border_color,
|
||||
theme,
|
||||
},
|
||||
}),
|
||||
};
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,185 @@
|
||||
// @ts-check
|
||||
|
||||
import { renderStatsCard } from "../cards/stats.js";
|
||||
import {
|
||||
MissingParamError,
|
||||
retrieveSecondaryMessage,
|
||||
} from "../common/error.js";
|
||||
import { parseArray, parseBoolean } from "../common/ops.js";
|
||||
import { renderError } from "../common/render.js";
|
||||
import { fetchStats } from "../fetchers/stats.js";
|
||||
import { isLocaleAvailable } from "../translations.js";
|
||||
|
||||
// @ts-ignore
|
||||
export default async (
|
||||
{
|
||||
username,
|
||||
repo,
|
||||
owner,
|
||||
hide,
|
||||
hide_title,
|
||||
hide_border,
|
||||
card_width,
|
||||
hide_rank,
|
||||
show_icons,
|
||||
include_all_commits,
|
||||
commits_year,
|
||||
line_height,
|
||||
title_color,
|
||||
ring_color,
|
||||
icon_color,
|
||||
text_color,
|
||||
text_bold,
|
||||
bg_color,
|
||||
theme,
|
||||
exclude_repo,
|
||||
custom_title,
|
||||
locale,
|
||||
disable_animations,
|
||||
border_radius,
|
||||
number_format,
|
||||
role,
|
||||
number_precision,
|
||||
border_color,
|
||||
rank_icon,
|
||||
show,
|
||||
},
|
||||
pat = null,
|
||||
) => {
|
||||
if (locale && !isLocaleAvailable(locale)) {
|
||||
return {
|
||||
status: "error - permanent",
|
||||
content: renderError({
|
||||
message: "Something went wrong",
|
||||
secondaryMessage: "Language not found",
|
||||
renderOptions: {
|
||||
title_color,
|
||||
text_color,
|
||||
bg_color,
|
||||
border_color,
|
||||
theme,
|
||||
},
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
const safePattern = /^[-\w/.,]+$/;
|
||||
if (
|
||||
(username && !safePattern.test(username)) ||
|
||||
(repo && !safePattern.test(repo)) ||
|
||||
(owner && !safePattern.test(owner))
|
||||
) {
|
||||
return {
|
||||
status: "error - permanent",
|
||||
content: renderError({
|
||||
message: "Something went wrong",
|
||||
secondaryMessage:
|
||||
"Username, repository or owner contains unsafe characters",
|
||||
renderOptions: {
|
||||
title_color,
|
||||
text_color,
|
||||
bg_color,
|
||||
border_color,
|
||||
theme,
|
||||
},
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const showStats = parseArray(show);
|
||||
const repoOwner = parseArray(owner);
|
||||
let repository = parseArray(repo);
|
||||
repository = repository.map((repo) =>
|
||||
repo.includes("/") ? repo : `${username}/${repo}`,
|
||||
);
|
||||
|
||||
const stats = await fetchStats(
|
||||
username,
|
||||
parseBoolean(include_all_commits),
|
||||
parseArray(exclude_repo),
|
||||
showStats.includes("prs_merged") ||
|
||||
showStats.includes("prs_merged_percentage"),
|
||||
showStats.includes("discussions_started"),
|
||||
showStats.includes("discussions_answered"),
|
||||
parseInt(commits_year, 10),
|
||||
repository,
|
||||
repoOwner,
|
||||
showStats.includes("prs_authored"),
|
||||
showStats.includes("prs_commented"),
|
||||
showStats.includes("prs_reviewed"),
|
||||
showStats.includes("issues_authored"),
|
||||
showStats.includes("issues_commented"),
|
||||
parseArray(role),
|
||||
pat,
|
||||
);
|
||||
|
||||
return {
|
||||
status: "success",
|
||||
content: renderStatsCard(
|
||||
stats,
|
||||
{
|
||||
hide: parseArray(hide),
|
||||
show_icons: parseBoolean(show_icons),
|
||||
hide_title: parseBoolean(hide_title),
|
||||
hide_border: parseBoolean(hide_border),
|
||||
card_width: parseInt(card_width, 10),
|
||||
hide_rank: parseBoolean(hide_rank),
|
||||
include_all_commits: parseBoolean(include_all_commits),
|
||||
commits_year: parseInt(commits_year, 10),
|
||||
line_height,
|
||||
title_color,
|
||||
ring_color,
|
||||
icon_color,
|
||||
text_color,
|
||||
text_bold: parseBoolean(text_bold),
|
||||
bg_color,
|
||||
theme,
|
||||
custom_title,
|
||||
border_radius,
|
||||
border_color,
|
||||
number_format,
|
||||
number_precision: parseInt(number_precision, 10),
|
||||
locale: locale ? locale.toLowerCase() : null,
|
||||
disable_animations: parseBoolean(disable_animations),
|
||||
rank_icon,
|
||||
show: showStats,
|
||||
},
|
||||
username,
|
||||
repository,
|
||||
repoOwner,
|
||||
),
|
||||
};
|
||||
} catch (err) {
|
||||
if (err instanceof Error) {
|
||||
return {
|
||||
status: "error - temporary",
|
||||
content: renderError({
|
||||
message: err.message,
|
||||
secondaryMessage: retrieveSecondaryMessage(err),
|
||||
renderOptions: {
|
||||
title_color,
|
||||
text_color,
|
||||
bg_color,
|
||||
border_color,
|
||||
theme,
|
||||
show_repo_link: !(err instanceof MissingParamError),
|
||||
},
|
||||
}),
|
||||
};
|
||||
}
|
||||
return {
|
||||
status: "error - temporary",
|
||||
content: renderError({
|
||||
message: "An unknown error occurred",
|
||||
renderOptions: {
|
||||
title_color,
|
||||
text_color,
|
||||
bg_color,
|
||||
border_color,
|
||||
theme,
|
||||
},
|
||||
}),
|
||||
};
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,144 @@
|
||||
// @ts-check
|
||||
|
||||
import { renderRepoCard } from "../cards/repo.js";
|
||||
import {
|
||||
MissingParamError,
|
||||
retrieveSecondaryMessage,
|
||||
} from "../common/error.js";
|
||||
import { parseArray, parseBoolean } from "../common/ops.js";
|
||||
import { renderError } from "../common/render.js";
|
||||
import { fetchRepo } from "../fetchers/repo.js";
|
||||
import { isLocaleAvailable } from "../translations.js";
|
||||
|
||||
// @ts-ignore
|
||||
export default async (
|
||||
{
|
||||
username,
|
||||
repo,
|
||||
hide_border,
|
||||
title_color,
|
||||
icon_color,
|
||||
text_color,
|
||||
bg_color,
|
||||
card_width,
|
||||
theme,
|
||||
show_owner,
|
||||
show,
|
||||
show_icons,
|
||||
number_format,
|
||||
text_bold,
|
||||
line_height,
|
||||
locale,
|
||||
border_radius,
|
||||
border_color,
|
||||
description_lines_count,
|
||||
},
|
||||
pat = null,
|
||||
) => {
|
||||
if (locale && !isLocaleAvailable(locale)) {
|
||||
return {
|
||||
status: "error - permanent",
|
||||
content: renderError({
|
||||
message: "Something went wrong",
|
||||
secondaryMessage: "Language not found",
|
||||
renderOptions: {
|
||||
title_color,
|
||||
text_color,
|
||||
bg_color,
|
||||
border_color,
|
||||
theme,
|
||||
},
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
const safePattern = /^[-\w/.,]+$/;
|
||||
if (
|
||||
(username && !safePattern.test(username)) ||
|
||||
(repo && !safePattern.test(repo))
|
||||
) {
|
||||
return {
|
||||
status: "error - permanent",
|
||||
content: renderError({
|
||||
message: "Something went wrong",
|
||||
secondaryMessage: "Username or repository contains unsafe characters",
|
||||
renderOptions: {
|
||||
title_color,
|
||||
text_color,
|
||||
bg_color,
|
||||
border_color,
|
||||
theme,
|
||||
},
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const showStats = parseArray(show);
|
||||
const repoData = await fetchRepo(
|
||||
username,
|
||||
repo,
|
||||
showStats.includes("prs_authored"),
|
||||
showStats.includes("prs_commented"),
|
||||
showStats.includes("prs_reviewed"),
|
||||
showStats.includes("issues_authored"),
|
||||
showStats.includes("issues_commented"),
|
||||
pat,
|
||||
);
|
||||
|
||||
return {
|
||||
status: "success",
|
||||
content: renderRepoCard(repoData, {
|
||||
hide_border: parseBoolean(hide_border),
|
||||
title_color,
|
||||
icon_color,
|
||||
text_color,
|
||||
bg_color,
|
||||
theme,
|
||||
border_radius,
|
||||
border_color,
|
||||
card_width_input: parseInt(card_width, 10),
|
||||
show_owner: parseBoolean(show_owner),
|
||||
show: showStats,
|
||||
show_icons: parseBoolean(show_icons),
|
||||
number_format,
|
||||
text_bold: parseBoolean(text_bold),
|
||||
line_height,
|
||||
username,
|
||||
locale: locale ? locale.toLowerCase() : null,
|
||||
description_lines_count,
|
||||
}),
|
||||
};
|
||||
} catch (err) {
|
||||
if (err instanceof Error) {
|
||||
return {
|
||||
status: "error - temporary",
|
||||
content: renderError({
|
||||
message: err.message,
|
||||
secondaryMessage: retrieveSecondaryMessage(err),
|
||||
renderOptions: {
|
||||
title_color,
|
||||
text_color,
|
||||
bg_color,
|
||||
border_color,
|
||||
theme,
|
||||
show_repo_link: !(err instanceof MissingParamError),
|
||||
},
|
||||
}),
|
||||
};
|
||||
}
|
||||
return {
|
||||
status: "error - temporary",
|
||||
content: renderError({
|
||||
message: "An unknown error occurred",
|
||||
renderOptions: {
|
||||
title_color,
|
||||
text_color,
|
||||
bg_color,
|
||||
border_color,
|
||||
theme,
|
||||
},
|
||||
}),
|
||||
};
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,166 @@
|
||||
// @ts-check
|
||||
|
||||
import { renderTopLanguages } from "../cards/top-languages.js";
|
||||
import {
|
||||
MissingParamError,
|
||||
retrieveSecondaryMessage,
|
||||
} from "../common/error.js";
|
||||
import { parseArray, parseBoolean } from "../common/ops.js";
|
||||
import { renderError } from "../common/render.js";
|
||||
import { fetchTopLanguages } from "../fetchers/top-languages.js";
|
||||
import { isLocaleAvailable } from "../translations.js";
|
||||
|
||||
// @ts-ignore
|
||||
export default async (
|
||||
{
|
||||
username,
|
||||
hide,
|
||||
hide_title,
|
||||
hide_border,
|
||||
card_width,
|
||||
title_color,
|
||||
text_color,
|
||||
bg_color,
|
||||
prog_bar_bg_color,
|
||||
theme,
|
||||
layout,
|
||||
langs_count,
|
||||
exclude_repo,
|
||||
size_weight,
|
||||
count_weight,
|
||||
custom_title,
|
||||
locale,
|
||||
border_radius,
|
||||
border_color,
|
||||
role,
|
||||
disable_animations,
|
||||
hide_progress,
|
||||
stats_format,
|
||||
},
|
||||
pat = null,
|
||||
) => {
|
||||
if (locale && !isLocaleAvailable(locale)) {
|
||||
return {
|
||||
status: "error - permanent",
|
||||
content: renderError({
|
||||
message: "Something went wrong",
|
||||
secondaryMessage: "Locale not found",
|
||||
renderOptions: {
|
||||
title_color,
|
||||
text_color,
|
||||
bg_color,
|
||||
border_color,
|
||||
theme,
|
||||
},
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
if (
|
||||
layout !== undefined &&
|
||||
(typeof layout !== "string" ||
|
||||
!["compact", "normal", "donut", "donut-vertical", "pie"].includes(layout))
|
||||
) {
|
||||
return {
|
||||
status: "error - permanent",
|
||||
content: renderError({
|
||||
message: "Something went wrong",
|
||||
secondaryMessage: "Incorrect layout input",
|
||||
renderOptions: {
|
||||
title_color,
|
||||
text_color,
|
||||
bg_color,
|
||||
border_color,
|
||||
theme,
|
||||
},
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
if (
|
||||
stats_format !== undefined &&
|
||||
(typeof stats_format !== "string" ||
|
||||
!["bytes", "percentages"].includes(stats_format))
|
||||
) {
|
||||
return {
|
||||
status: "error - permanent",
|
||||
content: renderError({
|
||||
message: "Something went wrong",
|
||||
secondaryMessage: "Incorrect stats_format input",
|
||||
renderOptions: {
|
||||
title_color,
|
||||
text_color,
|
||||
bg_color,
|
||||
border_color,
|
||||
theme,
|
||||
},
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const topLangs = await fetchTopLanguages(
|
||||
username,
|
||||
parseArray(exclude_repo),
|
||||
size_weight,
|
||||
count_weight,
|
||||
parseArray(role),
|
||||
pat,
|
||||
);
|
||||
|
||||
return {
|
||||
status: "success",
|
||||
content: renderTopLanguages(topLangs, {
|
||||
custom_title,
|
||||
hide_title: parseBoolean(hide_title),
|
||||
hide_border: parseBoolean(hide_border),
|
||||
card_width: parseInt(card_width, 10),
|
||||
hide: parseArray(hide),
|
||||
title_color,
|
||||
text_color,
|
||||
bg_color,
|
||||
prog_bar_bg_color,
|
||||
theme,
|
||||
layout,
|
||||
langs_count,
|
||||
border_radius,
|
||||
border_color,
|
||||
locale: locale ? locale.toLowerCase() : null,
|
||||
disable_animations: parseBoolean(disable_animations),
|
||||
hide_progress: parseBoolean(hide_progress),
|
||||
stats_format,
|
||||
}),
|
||||
};
|
||||
} catch (err) {
|
||||
if (err instanceof Error) {
|
||||
return {
|
||||
status: "error - temporary",
|
||||
content: renderError({
|
||||
message: err.message,
|
||||
secondaryMessage: retrieveSecondaryMessage(err),
|
||||
renderOptions: {
|
||||
title_color,
|
||||
text_color,
|
||||
bg_color,
|
||||
border_color,
|
||||
theme,
|
||||
show_repo_link: !(err instanceof MissingParamError),
|
||||
},
|
||||
}),
|
||||
};
|
||||
}
|
||||
return {
|
||||
status: "error - temporary",
|
||||
content: renderError({
|
||||
message: "An unknown error occurred",
|
||||
renderOptions: {
|
||||
title_color,
|
||||
text_color,
|
||||
bg_color,
|
||||
border_color,
|
||||
theme,
|
||||
},
|
||||
}),
|
||||
};
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,113 @@
|
||||
// @ts-check
|
||||
|
||||
import { renderWakatimeCard } from "../cards/wakatime.js";
|
||||
import {
|
||||
MissingParamError,
|
||||
retrieveSecondaryMessage,
|
||||
} from "../common/error.js";
|
||||
import { parseArray, parseBoolean } from "../common/ops.js";
|
||||
import { renderError } from "../common/render.js";
|
||||
import { fetchWakatimeStats } from "../fetchers/wakatime.js";
|
||||
import { isLocaleAvailable } from "../translations.js";
|
||||
|
||||
// @ts-ignore
|
||||
export default async ({
|
||||
username,
|
||||
title_color,
|
||||
icon_color,
|
||||
hide_border,
|
||||
card_width,
|
||||
line_height,
|
||||
text_color,
|
||||
bg_color,
|
||||
theme,
|
||||
hide_title,
|
||||
hide_progress,
|
||||
custom_title,
|
||||
locale,
|
||||
layout,
|
||||
langs_count,
|
||||
hide,
|
||||
api_domain,
|
||||
border_radius,
|
||||
border_color,
|
||||
display_format,
|
||||
disable_animations,
|
||||
}) => {
|
||||
if (locale && !isLocaleAvailable(locale)) {
|
||||
return {
|
||||
status: "error - permanent",
|
||||
content: renderError({
|
||||
message: "Something went wrong",
|
||||
secondaryMessage: "Language not found",
|
||||
renderOptions: {
|
||||
title_color,
|
||||
text_color,
|
||||
bg_color,
|
||||
border_color,
|
||||
theme,
|
||||
},
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const stats = await fetchWakatimeStats({ username, api_domain });
|
||||
|
||||
return {
|
||||
status: "success",
|
||||
content: renderWakatimeCard(stats, {
|
||||
custom_title,
|
||||
hide_title: parseBoolean(hide_title),
|
||||
hide_border: parseBoolean(hide_border),
|
||||
card_width: parseInt(card_width, 10),
|
||||
hide: parseArray(hide),
|
||||
line_height,
|
||||
title_color,
|
||||
icon_color,
|
||||
text_color,
|
||||
bg_color,
|
||||
theme,
|
||||
hide_progress,
|
||||
border_radius,
|
||||
border_color,
|
||||
locale: locale ? locale.toLowerCase() : null,
|
||||
layout,
|
||||
langs_count,
|
||||
display_format,
|
||||
disable_animations: parseBoolean(disable_animations),
|
||||
}),
|
||||
};
|
||||
} catch (err) {
|
||||
if (err instanceof Error) {
|
||||
return {
|
||||
status: "error - temporary",
|
||||
content: renderError({
|
||||
message: err.message,
|
||||
secondaryMessage: retrieveSecondaryMessage(err),
|
||||
renderOptions: {
|
||||
title_color,
|
||||
text_color,
|
||||
bg_color,
|
||||
border_color,
|
||||
theme,
|
||||
show_repo_link: !(err instanceof MissingParamError),
|
||||
},
|
||||
}),
|
||||
};
|
||||
}
|
||||
return {
|
||||
status: "error - temporary",
|
||||
content: renderError({
|
||||
message: "An unknown error occurred",
|
||||
renderOptions: {
|
||||
title_color,
|
||||
text_color,
|
||||
bg_color,
|
||||
border_color,
|
||||
theme,
|
||||
},
|
||||
}),
|
||||
};
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,86 @@
|
||||
/**
|
||||
* Calculates the exponential cdf.
|
||||
*
|
||||
* @param {number} x The value.
|
||||
* @returns {number} The exponential cdf.
|
||||
*/
|
||||
function exponential_cdf(x) {
|
||||
return 1 - 2 ** -x;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculates the log normal cdf.
|
||||
*
|
||||
* @param {number} x The value.
|
||||
* @returns {number} The log normal cdf.
|
||||
*/
|
||||
function log_normal_cdf(x) {
|
||||
// approximation
|
||||
return x / (1 + x);
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculates the users rank.
|
||||
*
|
||||
* @param {object} params Parameters on which the user's rank depends.
|
||||
* @param {boolean} params.all_commits Whether `include_all_commits` was used.
|
||||
* @param {number} params.commits Number of commits.
|
||||
* @param {number} params.prs The number of pull requests.
|
||||
* @param {number} params.issues The number of issues.
|
||||
* @param {number} params.reviews The number of reviews.
|
||||
* @param {number} params.repos Total number of repos.
|
||||
* @param {number} params.stars The number of stars.
|
||||
* @param {number} params.followers The number of followers.
|
||||
* @returns {{ level: string, percentile: number }} The users rank.
|
||||
*/
|
||||
function calculateRank({
|
||||
all_commits,
|
||||
commits,
|
||||
prs,
|
||||
issues,
|
||||
reviews,
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
repos, // unused
|
||||
stars,
|
||||
followers,
|
||||
}) {
|
||||
const COMMITS_MEDIAN = all_commits ? 1000 : 250,
|
||||
COMMITS_WEIGHT = 2;
|
||||
const PRS_MEDIAN = 50,
|
||||
PRS_WEIGHT = 3;
|
||||
const ISSUES_MEDIAN = 25,
|
||||
ISSUES_WEIGHT = 1;
|
||||
const REVIEWS_MEDIAN = 2,
|
||||
REVIEWS_WEIGHT = 1;
|
||||
const STARS_MEDIAN = 50,
|
||||
STARS_WEIGHT = 4;
|
||||
const FOLLOWERS_MEDIAN = 10,
|
||||
FOLLOWERS_WEIGHT = 1;
|
||||
|
||||
const TOTAL_WEIGHT =
|
||||
COMMITS_WEIGHT +
|
||||
PRS_WEIGHT +
|
||||
ISSUES_WEIGHT +
|
||||
REVIEWS_WEIGHT +
|
||||
STARS_WEIGHT +
|
||||
FOLLOWERS_WEIGHT;
|
||||
|
||||
const THRESHOLDS = [1, 12.5, 25, 37.5, 50, 62.5, 75, 87.5, 100];
|
||||
const LEVELS = ["S", "A+", "A", "A-", "B+", "B", "B-", "C+", "C"];
|
||||
|
||||
const rank =
|
||||
1 -
|
||||
(COMMITS_WEIGHT * exponential_cdf(commits / COMMITS_MEDIAN) +
|
||||
PRS_WEIGHT * exponential_cdf(prs / PRS_MEDIAN) +
|
||||
ISSUES_WEIGHT * exponential_cdf(issues / ISSUES_MEDIAN) +
|
||||
REVIEWS_WEIGHT * exponential_cdf(reviews / REVIEWS_MEDIAN) +
|
||||
STARS_WEIGHT * log_normal_cdf(stars / STARS_MEDIAN) +
|
||||
FOLLOWERS_WEIGHT * log_normal_cdf(followers / FOLLOWERS_MEDIAN)) /
|
||||
TOTAL_WEIGHT;
|
||||
|
||||
const level = LEVELS[THRESHOLDS.findIndex((t) => rank * 100 <= t)];
|
||||
|
||||
return { level, percentile: rank * 100 };
|
||||
}
|
||||
|
||||
export { calculateRank };
|
||||
@@ -0,0 +1,141 @@
|
||||
// @ts-check
|
||||
|
||||
import { default as Card } from "../common/Card.js";
|
||||
import { getCardColors } from "../common/color.js";
|
||||
import { kFormatter, wrapTextMultiline } from "../common/fmt.js";
|
||||
import { encodeHTML } from "../common/html.js";
|
||||
import { icons } from "../common/icons.js";
|
||||
import languageColors from "../common/languageColors.json" with { type: "json" };
|
||||
import { parseEmojis } from "../common/ops.js";
|
||||
import {
|
||||
createLanguageNode,
|
||||
flexLayout,
|
||||
iconWithLabel,
|
||||
measureText,
|
||||
} from "../common/render.js";
|
||||
|
||||
const ICON_SIZE = 16;
|
||||
const CARD_DEFAULT_WIDTH = 400;
|
||||
const HEADER_MAX_LENGTH = 35;
|
||||
|
||||
/**
|
||||
* @typedef {import('./types').GistCardOptions} GistCardOptions Gist card options.
|
||||
* @typedef {import('../fetchers/types').GistData} GistData Gist data.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Render gist card.
|
||||
*
|
||||
* @param {GistData} gistData Gist data.
|
||||
* @param {Partial<GistCardOptions>} options Gist card options.
|
||||
* @returns {string} Gist card.
|
||||
*/
|
||||
const renderGistCard = (gistData, options = {}) => {
|
||||
const { name, nameWithOwner, description, language, starsCount, forksCount } =
|
||||
gistData;
|
||||
const {
|
||||
title_color,
|
||||
icon_color,
|
||||
text_color,
|
||||
bg_color,
|
||||
theme = "default_repocard",
|
||||
border_radius,
|
||||
border_color,
|
||||
show_owner = false,
|
||||
hide_border = false,
|
||||
} = options;
|
||||
|
||||
// returns theme based colors with proper overrides and defaults
|
||||
const { titleColor, textColor, iconColor, bgColor, borderColor } =
|
||||
getCardColors({
|
||||
title_color,
|
||||
icon_color,
|
||||
text_color,
|
||||
bg_color,
|
||||
border_color,
|
||||
theme,
|
||||
});
|
||||
|
||||
const lineWidth = 59;
|
||||
const linesLimit = 10;
|
||||
const desc = parseEmojis(description || "No description provided");
|
||||
const multiLineDescription = wrapTextMultiline(desc, lineWidth, linesLimit);
|
||||
const descriptionLines = multiLineDescription.length;
|
||||
const descriptionSvg = multiLineDescription
|
||||
.map((line) => `<tspan dy="1.2em" x="25">${encodeHTML(line)}</tspan>`)
|
||||
.join("");
|
||||
|
||||
const lineHeight = descriptionLines > 3 ? 12 : 10;
|
||||
const height =
|
||||
(descriptionLines > 1 ? 120 : 110) + descriptionLines * lineHeight;
|
||||
|
||||
const totalStars = kFormatter(starsCount);
|
||||
const totalForks = kFormatter(forksCount);
|
||||
const svgStars = iconWithLabel(
|
||||
icons.star,
|
||||
totalStars,
|
||||
"starsCount",
|
||||
ICON_SIZE,
|
||||
);
|
||||
const svgForks = iconWithLabel(
|
||||
icons.fork,
|
||||
totalForks,
|
||||
"forksCount",
|
||||
ICON_SIZE,
|
||||
);
|
||||
|
||||
const languageName = language || "Unspecified";
|
||||
// @ts-ignore
|
||||
const languageColor = languageColors[languageName] || "#858585";
|
||||
|
||||
const svgLanguage = createLanguageNode(languageName, languageColor);
|
||||
|
||||
const starAndForkCount = flexLayout({
|
||||
items: [svgLanguage, svgStars, svgForks],
|
||||
sizes: [
|
||||
measureText(languageName, 12),
|
||||
ICON_SIZE + measureText(`${totalStars}`, 12),
|
||||
ICON_SIZE + measureText(`${totalForks}`, 12),
|
||||
],
|
||||
gap: 25,
|
||||
}).join("");
|
||||
|
||||
const header = show_owner ? nameWithOwner : name;
|
||||
|
||||
const card = new Card({
|
||||
defaultTitle:
|
||||
header.length > HEADER_MAX_LENGTH
|
||||
? `${header.slice(0, HEADER_MAX_LENGTH)}...`
|
||||
: header,
|
||||
titlePrefixIcon: icons.gist,
|
||||
width: CARD_DEFAULT_WIDTH,
|
||||
height,
|
||||
border_radius,
|
||||
colors: {
|
||||
titleColor,
|
||||
textColor,
|
||||
iconColor,
|
||||
bgColor,
|
||||
borderColor,
|
||||
},
|
||||
});
|
||||
|
||||
card.setCSS(`
|
||||
.description { font: 400 13px 'Segoe UI', Ubuntu, Sans-Serif; fill: ${textColor} }
|
||||
.gray { font: 400 12px 'Segoe UI', Ubuntu, Sans-Serif; fill: ${textColor} }
|
||||
.icon { fill: ${iconColor} }
|
||||
`);
|
||||
card.setHideBorder(hide_border);
|
||||
|
||||
return card.render(`
|
||||
<text class="description" x="25" y="-5">
|
||||
${descriptionSvg}
|
||||
</text>
|
||||
|
||||
<g transform="translate(30, ${height - 75})">
|
||||
${starAndForkCount}
|
||||
</g>
|
||||
`);
|
||||
};
|
||||
|
||||
export { renderGistCard };
|
||||
@@ -0,0 +1,324 @@
|
||||
// @ts-check
|
||||
|
||||
import { Card } from "../common/Card.js";
|
||||
import { I18n } from "../common/I18n.js";
|
||||
import { getCardColors } from "../common/color.js";
|
||||
import { kFormatter, wrapTextMultiline } from "../common/fmt.js";
|
||||
import { encodeHTML } from "../common/html.js";
|
||||
import { icons } from "../common/icons.js";
|
||||
import { buildSearchFilter, clampValue, parseEmojis } from "../common/ops.js";
|
||||
import {
|
||||
createLanguageNode,
|
||||
flexLayout,
|
||||
iconWithLabel,
|
||||
measureText,
|
||||
} from "../common/render.js";
|
||||
import { repoCardLocales } from "../translations.js";
|
||||
|
||||
import { createTextNode } from "./stats.js";
|
||||
|
||||
const ICON_SIZE = 16;
|
||||
const DESCRIPTION_LINE_WIDTH = 59;
|
||||
const DESCRIPTION_MAX_LINES = 3;
|
||||
|
||||
/**
|
||||
* Retrieves the repository description and wraps it to fit the card width.
|
||||
*
|
||||
* @param {string} label The repository description.
|
||||
* @param {string} textColor The color of the text.
|
||||
* @returns {string} Wrapped repo description SVG object.
|
||||
*/
|
||||
const getBadgeSVG = (label, textColor, xOffset = 0) => `
|
||||
<g data-testid="badge" class="badge" transform="translate(${320 + xOffset}, -18)">
|
||||
<rect stroke="${textColor}" stroke-width="1" width="70" height="20" x="-12" y="-14" ry="10" rx="10"></rect>
|
||||
<text
|
||||
x="23" y="-5"
|
||||
alignment-baseline="central"
|
||||
dominant-baseline="central"
|
||||
text-anchor="middle"
|
||||
fill="${textColor}"
|
||||
>
|
||||
${label}
|
||||
</text>
|
||||
</g>
|
||||
`;
|
||||
|
||||
/**
|
||||
* @typedef {import("../fetchers/types").RepositoryData} RepositoryData Repository data.
|
||||
* @typedef {import("./types").RepoCardOptions} RepoCardOptions Repo card options.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Renders repository card details.
|
||||
*
|
||||
* @param {RepositoryData} repo Repository data.
|
||||
* @param {Partial<RepoCardOptions>} options Card options.
|
||||
* @returns {string} Repository card SVG object.
|
||||
*/
|
||||
const renderRepoCard = (repo, options = {}) => {
|
||||
const {
|
||||
name,
|
||||
nameWithOwner,
|
||||
description,
|
||||
primaryLanguage,
|
||||
isArchived,
|
||||
isTemplate,
|
||||
starCount,
|
||||
forkCount,
|
||||
totalPRsAuthored,
|
||||
totalPRsCommented,
|
||||
totalPRsReviewed,
|
||||
totalIssuesAuthored,
|
||||
totalIssuesCommented,
|
||||
} = repo;
|
||||
const {
|
||||
hide_border = false,
|
||||
title_color,
|
||||
icon_color,
|
||||
text_color,
|
||||
bg_color,
|
||||
card_width_input,
|
||||
show_owner = false,
|
||||
show = [],
|
||||
show_icons = true,
|
||||
number_format = "short",
|
||||
text_bold = false,
|
||||
line_height = 22,
|
||||
username,
|
||||
theme = "default_repocard",
|
||||
border_radius,
|
||||
border_color,
|
||||
locale,
|
||||
description_lines_count,
|
||||
} = options;
|
||||
|
||||
const card_width =
|
||||
card_width_input && !isNaN(card_width_input)
|
||||
? card_width_input
|
||||
: show.length >= 2
|
||||
? 430
|
||||
: 400;
|
||||
|
||||
const i18n = new I18n({
|
||||
locale,
|
||||
translations: repoCardLocales,
|
||||
});
|
||||
|
||||
let repoFilter = encodeURIComponent(buildSearchFilter([nameWithOwner], []));
|
||||
const STATS = {};
|
||||
if (show.includes("prs_authored")) {
|
||||
STATS.prs_authored = {
|
||||
icon: icons.prs,
|
||||
label: i18n.t("repocard.prs-authored"),
|
||||
value: totalPRsAuthored,
|
||||
id: "prs_authored",
|
||||
link: `https://github.com/search?q=${repoFilter}author%3A${username}&type=pullrequests`,
|
||||
};
|
||||
}
|
||||
if (show.includes("prs_commented")) {
|
||||
STATS.prs_commented = {
|
||||
icon: icons.comments,
|
||||
label: i18n.t("repocard.prs-commented"),
|
||||
value: totalPRsCommented,
|
||||
id: "prs_commented",
|
||||
link: `https://github.com/search?q=${repoFilter}commenter%3A${username}+-author%3A${username}&type=pullrequests`,
|
||||
};
|
||||
}
|
||||
if (show.includes("prs_reviewed")) {
|
||||
STATS.prs_reviewed = {
|
||||
icon: icons.reviews,
|
||||
label: i18n.t("repocard.prs-reviewed"),
|
||||
value: totalPRsReviewed,
|
||||
id: "prs_reviewed",
|
||||
link: `https://github.com/search?q=${repoFilter}reviewed-by%3A${username}+-author%3A${username}&type=pullrequests`,
|
||||
};
|
||||
}
|
||||
if (show.includes("issues_authored")) {
|
||||
STATS.issues_authored = {
|
||||
icon: icons.issues,
|
||||
label: i18n.t("repocard.issues-authored"),
|
||||
value: totalIssuesAuthored,
|
||||
id: "issues_authored",
|
||||
link: `https://github.com/search?q=${repoFilter}author%3A${username}&type=issues`,
|
||||
};
|
||||
}
|
||||
if (show.includes("issues_commented")) {
|
||||
STATS.issues_commented = {
|
||||
icon: icons.discussions_started,
|
||||
label: i18n.t("repocard.issues-commented"),
|
||||
value: totalIssuesCommented,
|
||||
id: "issues_commented",
|
||||
link: `https://github.com/search?q=${repoFilter}commenter%3A${username}+-author%3A${username}&type=issues`,
|
||||
};
|
||||
}
|
||||
|
||||
const statItems = Object.keys(STATS).map((key, index) =>
|
||||
// create the text nodes, and pass index so that we can calculate the line spacing
|
||||
createTextNode({
|
||||
icon: STATS[key].icon,
|
||||
label: STATS[key].label,
|
||||
value: STATS[key].value,
|
||||
id: STATS[key].id,
|
||||
unitSymbol: STATS[key].unitSymbol,
|
||||
index,
|
||||
showIcons: show_icons,
|
||||
shiftValuePos: 14.01,
|
||||
bold: text_bold,
|
||||
numberFormat: number_format,
|
||||
link: STATS[key].link,
|
||||
labelXOffset: 23,
|
||||
}),
|
||||
);
|
||||
|
||||
const extraLHeight = parseInt(String(line_height), 10);
|
||||
const lineHeight = 10;
|
||||
const header = show_owner ? nameWithOwner : name;
|
||||
const langName = (primaryLanguage && primaryLanguage.name) || "Unspecified";
|
||||
const langColor = (primaryLanguage && primaryLanguage.color) || "#333";
|
||||
const descriptionMaxLines = description_lines_count
|
||||
? clampValue(description_lines_count, 1, DESCRIPTION_MAX_LINES)
|
||||
: DESCRIPTION_MAX_LINES;
|
||||
|
||||
const desc = parseEmojis(description || "No description provided");
|
||||
const multiLineDescription = wrapTextMultiline(
|
||||
desc,
|
||||
Math.round((card_width - 400) / 5.93 + DESCRIPTION_LINE_WIDTH),
|
||||
descriptionMaxLines,
|
||||
);
|
||||
const descriptionLinesCount = description_lines_count
|
||||
? clampValue(description_lines_count, 1, DESCRIPTION_MAX_LINES)
|
||||
: multiLineDescription.length;
|
||||
|
||||
const descriptionSvg = multiLineDescription
|
||||
.map((line) => `<tspan dy="1.2em" x="25">${encodeHTML(line)}</tspan>`)
|
||||
.join("");
|
||||
|
||||
const extraHeight = Object.keys(STATS).length
|
||||
? -7 + (Math.ceil(statItems.length / 2) + 1) * extraLHeight
|
||||
: 0;
|
||||
const height =
|
||||
(descriptionLinesCount > 1 ? 120 : 110) +
|
||||
descriptionLinesCount * lineHeight +
|
||||
extraHeight;
|
||||
|
||||
// returns theme based colors with proper overrides and defaults
|
||||
const colors = getCardColors({
|
||||
title_color,
|
||||
icon_color,
|
||||
text_color,
|
||||
bg_color,
|
||||
border_color,
|
||||
theme,
|
||||
});
|
||||
|
||||
const svgLanguage = primaryLanguage
|
||||
? createLanguageNode(langName, langColor)
|
||||
: "";
|
||||
|
||||
const totalStars = kFormatter(starCount);
|
||||
const totalForks = kFormatter(forkCount);
|
||||
const svgStars = iconWithLabel(
|
||||
icons.star,
|
||||
totalStars,
|
||||
"stargazers",
|
||||
ICON_SIZE,
|
||||
);
|
||||
const svgForks = iconWithLabel(
|
||||
icons.fork,
|
||||
totalForks,
|
||||
"forkcount",
|
||||
ICON_SIZE,
|
||||
);
|
||||
|
||||
const starAndForkCount = flexLayout({
|
||||
items: [svgLanguage, svgStars, svgForks],
|
||||
sizes: [
|
||||
measureText(langName, 12),
|
||||
ICON_SIZE + measureText(`${totalStars}`, 12),
|
||||
ICON_SIZE + measureText(`${totalForks}`, 12),
|
||||
],
|
||||
gap: 25,
|
||||
}).join("");
|
||||
|
||||
let extraRows = [];
|
||||
for (let i = 0; i < statItems.length; i += 2) {
|
||||
extraRows.push(
|
||||
flexLayout({
|
||||
items: statItems.slice(i, i + 2),
|
||||
gap: 210,
|
||||
direction: "row",
|
||||
}).join(""),
|
||||
);
|
||||
}
|
||||
const extraItems = `
|
||||
<svg x="0" y="0"><g transform="translate(-3, ${height - 52 - extraHeight})">
|
||||
${flexLayout({
|
||||
items: extraRows,
|
||||
gap: extraLHeight,
|
||||
direction: "column",
|
||||
}).join("")}
|
||||
</g></svg>
|
||||
`;
|
||||
|
||||
const card = new Card({
|
||||
defaultTitle: header.length > 35 ? `${header.slice(0, 35)}...` : header,
|
||||
titlePrefixIcon: icons.contribs,
|
||||
width: card_width,
|
||||
height,
|
||||
border_radius,
|
||||
colors,
|
||||
});
|
||||
|
||||
card.disableAnimations();
|
||||
card.setHideBorder(hide_border);
|
||||
card.setHideTitle(false);
|
||||
card.setCSS(`
|
||||
.description { font: 400 13px 'Segoe UI', Ubuntu, Sans-Serif; fill: ${colors.textColor} }
|
||||
.gray { font: 400 12px 'Segoe UI', Ubuntu, Sans-Serif; fill: ${colors.textColor} }
|
||||
.badge { font: 600 11px 'Segoe UI', Ubuntu, Sans-Serif; }
|
||||
.badge rect { opacity: 0.2 }
|
||||
|
||||
.stat { font: 400 12px 'Segoe UI', Ubuntu, Sans-Serif; fill: ${colors.textColor} }
|
||||
.stagger {
|
||||
opacity: 0;
|
||||
animation: fadeInAnimation 0.3s ease-in-out forwards;
|
||||
}
|
||||
.not_bold { font-weight: 400 }
|
||||
.bold { font-weight: 700 }
|
||||
.icon {
|
||||
fill: ${colors.iconColor};
|
||||
display: block;
|
||||
}
|
||||
`);
|
||||
|
||||
return card.render(`
|
||||
${
|
||||
isTemplate
|
||||
? // @ts-ignore
|
||||
getBadgeSVG(
|
||||
i18n.t("repocard.template"),
|
||||
colors.textColor,
|
||||
card_width - 400,
|
||||
)
|
||||
: isArchived
|
||||
? // @ts-ignore
|
||||
getBadgeSVG(
|
||||
i18n.t("repocard.archived"),
|
||||
colors.textColor,
|
||||
card_width - 400,
|
||||
)
|
||||
: ""
|
||||
}
|
||||
|
||||
<text class="description" x="25" y="-5">
|
||||
${descriptionSvg}
|
||||
</text>
|
||||
|
||||
<g transform="translate(30, ${height - 75 - extraHeight})">
|
||||
${starAndForkCount}
|
||||
</g>
|
||||
${extraItems}
|
||||
`);
|
||||
};
|
||||
|
||||
export { renderRepoCard };
|
||||
@@ -0,0 +1,673 @@
|
||||
// @ts-check
|
||||
|
||||
import { Card } from "../common/Card.js";
|
||||
import { I18n } from "../common/I18n.js";
|
||||
import { getCardColors } from "../common/color.js";
|
||||
import { CustomError } from "../common/error.js";
|
||||
import { kFormatter } from "../common/fmt.js";
|
||||
import { icons, rankIcon } from "../common/icons.js";
|
||||
import { buildSearchFilter, clampValue } from "../common/ops.js";
|
||||
import { flexLayout, measureText } from "../common/render.js";
|
||||
import { statCardLocales, wakatimeCardLocales } from "../translations.js";
|
||||
|
||||
const CARD_MIN_WIDTH = 287;
|
||||
const CARD_DEFAULT_WIDTH = 287;
|
||||
const RANK_CARD_MIN_WIDTH = 420;
|
||||
const RANK_CARD_DEFAULT_WIDTH = 450;
|
||||
const RANK_ONLY_CARD_MIN_WIDTH = 290;
|
||||
const RANK_ONLY_CARD_DEFAULT_WIDTH = 290;
|
||||
|
||||
/**
|
||||
* Long locales that need more space for text. Keep sorted alphabetically.
|
||||
*
|
||||
* @type {(keyof typeof wakatimeCardLocales["wakatimecard.title"])[]}
|
||||
*/
|
||||
const LONG_LOCALES = [
|
||||
"az",
|
||||
"bg",
|
||||
"cs",
|
||||
"de",
|
||||
"el",
|
||||
"es",
|
||||
"fil",
|
||||
"fi",
|
||||
"fr",
|
||||
"hu",
|
||||
"id",
|
||||
"ja",
|
||||
"ml",
|
||||
"my",
|
||||
"nl",
|
||||
"pl",
|
||||
"pt-br",
|
||||
"pt-pt",
|
||||
"ru",
|
||||
"sr",
|
||||
"sr-latn",
|
||||
"sw",
|
||||
"ta",
|
||||
"uk-ua",
|
||||
"uz",
|
||||
"zh-tw",
|
||||
];
|
||||
|
||||
/**
|
||||
* Create a stats card text item.
|
||||
*
|
||||
* @param {object} params Object that contains the createTextNode parameters.
|
||||
* @param {string} params.icon The icon to display.
|
||||
* @param {string} params.label The label to display.
|
||||
* @param {number} params.value The value to display.
|
||||
* @param {string} params.id The id of the stat.
|
||||
* @param {string=} params.unitSymbol The unit symbol of the stat.
|
||||
* @param {number} params.index The index of the stat.
|
||||
* @param {boolean} params.showIcons Whether to show icons.
|
||||
* @param {number} params.shiftValuePos Number of pixels the value has to be shifted to the right.
|
||||
* @param {boolean} params.bold Whether to bold the label.
|
||||
* @param {string} params.numberFormat The format of numbers on card.
|
||||
* @param {number=} params.numberPrecision The precision of numbers on card.
|
||||
* @param {string} params.link Url to link to.
|
||||
* @param {number} params.labelXOffset horizontal offset for label.
|
||||
* @returns {string} The stats card text item SVG object.
|
||||
*/
|
||||
const createTextNode = ({
|
||||
icon,
|
||||
label,
|
||||
value,
|
||||
id,
|
||||
unitSymbol,
|
||||
index,
|
||||
showIcons,
|
||||
shiftValuePos,
|
||||
bold,
|
||||
numberFormat,
|
||||
numberPrecision,
|
||||
link,
|
||||
labelXOffset = 25,
|
||||
}) => {
|
||||
const precision =
|
||||
typeof numberPrecision === "number" && !isNaN(numberPrecision)
|
||||
? clampValue(numberPrecision, 0, 2)
|
||||
: undefined;
|
||||
const kValue =
|
||||
numberFormat.toLowerCase() === "long" || id === "prs_merged_percentage"
|
||||
? value
|
||||
: kFormatter(value, precision);
|
||||
const staggerDelay = (index + 3) * 150;
|
||||
|
||||
const labelOffset = showIcons ? `x="${labelXOffset}"` : "";
|
||||
const iconSvg = showIcons
|
||||
? `
|
||||
<svg data-testid="icon" class="icon" viewBox="0 0 16 16" version="1.1" width="16" height="16">
|
||||
${icon}
|
||||
</svg>
|
||||
`
|
||||
: "";
|
||||
return (
|
||||
`
|
||||
<g class="stagger" style="animation-delay: ${staggerDelay}ms" transform="translate(25, 0)">` +
|
||||
(link ? `<a href="${link}">` : "") +
|
||||
`
|
||||
${iconSvg}
|
||||
<text class="stat ${
|
||||
bold ? " bold" : "not_bold"
|
||||
}" ${labelOffset} y="12.5">${label}:</text>
|
||||
<text
|
||||
class="stat ${bold ? " bold" : "not_bold"}"
|
||||
x="${(showIcons ? 140 : 120) + (bold ? 5 : 0) + shiftValuePos}"
|
||||
y="12.5"
|
||||
data-testid="${id}"
|
||||
>${kValue}${unitSymbol ? ` ${unitSymbol}` : ""}</text>` +
|
||||
(link ? "</a>" : "") +
|
||||
`
|
||||
</g>
|
||||
`
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Calculates progress along the boundary of the circle, i.e. its circumference.
|
||||
*
|
||||
* @param {number} value The rank value to calculate progress for.
|
||||
* @returns {number} Progress value.
|
||||
*/
|
||||
const calculateCircleProgress = (value) => {
|
||||
const radius = 40;
|
||||
const c = Math.PI * (radius * 2);
|
||||
|
||||
if (value < 0) {
|
||||
value = 0;
|
||||
}
|
||||
if (value > 100) {
|
||||
value = 100;
|
||||
}
|
||||
|
||||
return ((100 - value) / 100) * c;
|
||||
};
|
||||
|
||||
/**
|
||||
* Retrieves the animation to display progress along the circumference of circle
|
||||
* from the beginning to the given value in a clockwise direction.
|
||||
*
|
||||
* @param {{progress: number}} progress The progress value to animate to.
|
||||
* @returns {string} Progress animation css.
|
||||
*/
|
||||
const getProgressAnimation = ({ progress }) => {
|
||||
return `
|
||||
@keyframes rankAnimation {
|
||||
from {
|
||||
stroke-dashoffset: ${calculateCircleProgress(0)};
|
||||
}
|
||||
to {
|
||||
stroke-dashoffset: ${calculateCircleProgress(progress)};
|
||||
}
|
||||
}
|
||||
`;
|
||||
};
|
||||
|
||||
/**
|
||||
* Retrieves CSS styles for a card.
|
||||
*
|
||||
* @param {Object} colors The colors to use for the card.
|
||||
* @param {string} colors.titleColor The title color.
|
||||
* @param {string} colors.textColor The text color.
|
||||
* @param {string} colors.iconColor The icon color.
|
||||
* @param {string} colors.ringColor The ring color.
|
||||
* @param {boolean} colors.show_icons Whether to show icons.
|
||||
* @param {number} colors.progress The progress value to animate to.
|
||||
* @returns {string} Card CSS styles.
|
||||
*/
|
||||
const getStyles = ({
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
titleColor,
|
||||
textColor,
|
||||
iconColor,
|
||||
ringColor,
|
||||
show_icons,
|
||||
progress,
|
||||
}) => {
|
||||
return `
|
||||
.stat {
|
||||
font: 600 14px 'Segoe UI', Ubuntu, "Helvetica Neue", Sans-Serif; fill: ${textColor};
|
||||
}
|
||||
@supports(-moz-appearance: auto) {
|
||||
/* Selector detects Firefox */
|
||||
.stat { font-size:12px; }
|
||||
}
|
||||
.stagger {
|
||||
opacity: 0;
|
||||
animation: fadeInAnimation 0.3s ease-in-out forwards;
|
||||
}
|
||||
.rank-text {
|
||||
font: 800 24px 'Segoe UI', Ubuntu, Sans-Serif; fill: ${textColor};
|
||||
animation: scaleInAnimation 0.3s ease-in-out forwards;
|
||||
}
|
||||
.rank-percentile-header {
|
||||
font-size: 14px;
|
||||
}
|
||||
.rank-percentile-text {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.not_bold { font-weight: 400 }
|
||||
.bold { font-weight: 700 }
|
||||
.icon {
|
||||
fill: ${iconColor};
|
||||
display: ${show_icons ? "block" : "none"};
|
||||
}
|
||||
|
||||
.rank-circle-rim {
|
||||
stroke: ${ringColor};
|
||||
fill: none;
|
||||
stroke-width: 6;
|
||||
opacity: 0.2;
|
||||
}
|
||||
.rank-circle {
|
||||
stroke: ${ringColor};
|
||||
stroke-dasharray: 250;
|
||||
fill: none;
|
||||
stroke-width: 6;
|
||||
stroke-linecap: round;
|
||||
opacity: 0.8;
|
||||
transform-origin: -10px 8px;
|
||||
transform: rotate(-90deg);
|
||||
animation: rankAnimation 1s forwards ease-in-out;
|
||||
}
|
||||
${getProgressAnimation({ progress })}
|
||||
`;
|
||||
};
|
||||
|
||||
/**
|
||||
* Return the label for commits according to the selected options
|
||||
*
|
||||
* @param {boolean} include_all_commits Option to include all years
|
||||
* @param {number|undefined} commits_year Option to include only selected year
|
||||
* @param {I18n} i18n The I18n instance.
|
||||
* @returns {string} The label corresponding to the options.
|
||||
*/
|
||||
const getTotalCommitsYearLabel = (include_all_commits, commits_year, i18n) =>
|
||||
include_all_commits
|
||||
? ""
|
||||
: commits_year
|
||||
? ` (${commits_year})`
|
||||
: ` (${i18n.t("wakatimecard.lastyear")})`;
|
||||
|
||||
/**
|
||||
* @typedef {import('../fetchers/types').StatsData} StatsData
|
||||
* @typedef {import('./types').StatCardOptions} StatCardOptions
|
||||
*/
|
||||
|
||||
/**
|
||||
* Renders the stats card.
|
||||
*
|
||||
* @param {StatsData} stats The stats data.
|
||||
* @param {Partial<StatCardOptions>} options The card options.
|
||||
* @returns {string} The stats card SVG object.
|
||||
*/
|
||||
const renderStatsCard = (
|
||||
stats,
|
||||
options = {},
|
||||
username,
|
||||
repo = [],
|
||||
owner = [],
|
||||
) => {
|
||||
const {
|
||||
name,
|
||||
totalStars,
|
||||
totalCommits,
|
||||
totalIssues,
|
||||
totalPRs,
|
||||
totalPRsMerged,
|
||||
mergedPRsPercentage,
|
||||
totalReviews,
|
||||
totalDiscussionsStarted,
|
||||
totalDiscussionsAnswered,
|
||||
contributedTo,
|
||||
totalPRsAuthored,
|
||||
totalPRsCommented,
|
||||
totalPRsReviewed,
|
||||
totalIssuesAuthored,
|
||||
totalIssuesCommented,
|
||||
rank,
|
||||
} = stats;
|
||||
const {
|
||||
hide = [],
|
||||
show_icons = false,
|
||||
hide_title = false,
|
||||
hide_border = false,
|
||||
card_width,
|
||||
hide_rank = false,
|
||||
include_all_commits = false,
|
||||
commits_year,
|
||||
line_height = 25,
|
||||
title_color,
|
||||
ring_color,
|
||||
icon_color,
|
||||
text_color,
|
||||
text_bold = true,
|
||||
bg_color,
|
||||
theme = "default",
|
||||
custom_title,
|
||||
border_radius,
|
||||
border_color,
|
||||
number_format = "short",
|
||||
number_precision,
|
||||
locale,
|
||||
disable_animations = false,
|
||||
rank_icon = "default",
|
||||
show = [],
|
||||
} = options;
|
||||
|
||||
const lheight = parseInt(String(line_height), 10);
|
||||
|
||||
// returns theme based colors with proper overrides and defaults
|
||||
const { titleColor, iconColor, textColor, bgColor, borderColor, ringColor } =
|
||||
getCardColors({
|
||||
title_color,
|
||||
text_color,
|
||||
icon_color,
|
||||
bg_color,
|
||||
border_color,
|
||||
ring_color,
|
||||
theme,
|
||||
});
|
||||
|
||||
const apostrophe = /s$/i.test(name.trim()) ? "" : "s";
|
||||
const i18n = new I18n({
|
||||
locale,
|
||||
translations: {
|
||||
...statCardLocales({ name, apostrophe }),
|
||||
...wakatimeCardLocales,
|
||||
},
|
||||
});
|
||||
|
||||
// Meta data for creating text nodes with createTextNode function
|
||||
const STATS = {};
|
||||
|
||||
STATS.stars = {
|
||||
icon: icons.star,
|
||||
label: i18n.t("statcard.totalstars"),
|
||||
value: totalStars,
|
||||
id: "stars",
|
||||
};
|
||||
STATS.commits = {
|
||||
icon: icons.commits,
|
||||
label: `${i18n.t("statcard.commits")}${getTotalCommitsYearLabel(
|
||||
include_all_commits,
|
||||
commits_year,
|
||||
i18n,
|
||||
)}`,
|
||||
value: totalCommits,
|
||||
id: "commits",
|
||||
};
|
||||
STATS.prs = {
|
||||
icon: icons.prs,
|
||||
label: i18n.t("statcard.prs"),
|
||||
value: totalPRs,
|
||||
id: "prs",
|
||||
};
|
||||
|
||||
if (show.includes("prs_merged")) {
|
||||
STATS.prs_merged = {
|
||||
icon: icons.prs_merged,
|
||||
label: i18n.t("statcard.prs-merged"),
|
||||
value: totalPRsMerged,
|
||||
id: "prs_merged",
|
||||
};
|
||||
}
|
||||
|
||||
if (show.includes("prs_merged_percentage")) {
|
||||
STATS.prs_merged_percentage = {
|
||||
icon: icons.prs_merged_percentage,
|
||||
label: i18n.t("statcard.prs-merged-percentage"),
|
||||
value: mergedPRsPercentage.toFixed(
|
||||
typeof number_precision === "number" && !isNaN(number_precision)
|
||||
? clampValue(number_precision, 0, 2)
|
||||
: 2,
|
||||
),
|
||||
id: "prs_merged_percentage",
|
||||
unitSymbol: "%",
|
||||
};
|
||||
}
|
||||
|
||||
if (show.includes("reviews")) {
|
||||
STATS.reviews = {
|
||||
icon: icons.reviews,
|
||||
label: i18n.t("statcard.reviews"),
|
||||
value: totalReviews,
|
||||
id: "reviews",
|
||||
};
|
||||
}
|
||||
|
||||
STATS.issues = {
|
||||
icon: icons.issues,
|
||||
label: i18n.t("statcard.issues"),
|
||||
value: totalIssues,
|
||||
id: "issues",
|
||||
};
|
||||
|
||||
if (show.includes("discussions_started")) {
|
||||
STATS.discussions_started = {
|
||||
icon: icons.discussions_started,
|
||||
label: i18n.t("statcard.discussions-started"),
|
||||
value: totalDiscussionsStarted,
|
||||
id: "discussions_started",
|
||||
};
|
||||
}
|
||||
if (show.includes("discussions_answered")) {
|
||||
STATS.discussions_answered = {
|
||||
icon: icons.discussions_answered,
|
||||
label: i18n.t("statcard.discussions-answered"),
|
||||
value: totalDiscussionsAnswered,
|
||||
id: "discussions_answered",
|
||||
};
|
||||
}
|
||||
|
||||
let repoFilter = encodeURIComponent(buildSearchFilter(repo, owner));
|
||||
if (show.includes("prs_authored")) {
|
||||
STATS.prs_authored = {
|
||||
icon: icons.prs,
|
||||
label: i18n.t("statcard.prs-authored"),
|
||||
value: totalPRsAuthored,
|
||||
id: "prs_authored",
|
||||
link: `https://github.com/search?q=${repoFilter}author%3A${username}&type=pullrequests`,
|
||||
};
|
||||
}
|
||||
if (show.includes("prs_commented")) {
|
||||
STATS.prs_commented = {
|
||||
icon: icons.comments,
|
||||
label: i18n.t("statcard.prs-commented"),
|
||||
value: totalPRsCommented,
|
||||
id: "prs_commented",
|
||||
link: `https://github.com/search?q=${repoFilter}commenter%3A${username}+-author%3A${username}&type=pullrequests`,
|
||||
};
|
||||
}
|
||||
if (show.includes("prs_reviewed")) {
|
||||
STATS.prs_reviewed = {
|
||||
icon: icons.reviews,
|
||||
label: i18n.t("statcard.prs-reviewed"),
|
||||
value: totalPRsReviewed,
|
||||
id: "prs_reviewed",
|
||||
link: `https://github.com/search?q=${repoFilter}reviewed-by%3A${username}+-author%3A${username}&type=pullrequests`,
|
||||
};
|
||||
}
|
||||
if (show.includes("issues_authored")) {
|
||||
STATS.issues_authored = {
|
||||
icon: icons.issues,
|
||||
label: i18n.t("statcard.issues-authored"),
|
||||
value: totalIssuesAuthored,
|
||||
id: "issues_authored",
|
||||
link: `https://github.com/search?q=${repoFilter}author%3A${username}&type=issues`,
|
||||
};
|
||||
}
|
||||
if (show.includes("issues_commented")) {
|
||||
STATS.issues_commented = {
|
||||
icon: icons.discussions_started,
|
||||
label: i18n.t("statcard.issues-commented"),
|
||||
value: totalIssuesCommented,
|
||||
id: "issues_commented",
|
||||
link: `https://github.com/search?q=${repoFilter}commenter%3A${username}+-author%3A${username}&type=issues`,
|
||||
};
|
||||
}
|
||||
|
||||
STATS.contribs = {
|
||||
icon: icons.contribs,
|
||||
label: i18n.t("statcard.contribs"),
|
||||
value: contributedTo,
|
||||
id: "contribs",
|
||||
};
|
||||
|
||||
// @ts-ignore
|
||||
const isLongLocale = locale ? LONG_LOCALES.includes(locale) : false;
|
||||
|
||||
// check if all used labels are short
|
||||
const longLabels =
|
||||
Object.keys(STATS)
|
||||
.filter((key) => !hide.includes(key))
|
||||
.filter((key) => STATS[key].label.length > 18).length > 0;
|
||||
|
||||
// filter out hidden stats defined by user & create the text nodes
|
||||
const statItems = Object.keys(STATS)
|
||||
.filter((key) => !hide.includes(key))
|
||||
.map((key, index) => {
|
||||
// @ts-ignore
|
||||
const stats = STATS[key];
|
||||
|
||||
// create the text nodes, and pass index so that we can calculate the line spacing
|
||||
return createTextNode({
|
||||
icon: stats.icon,
|
||||
label: stats.label,
|
||||
value: stats.value,
|
||||
id: stats.id,
|
||||
unitSymbol: stats.unitSymbol,
|
||||
index,
|
||||
showIcons: show_icons,
|
||||
shiftValuePos: 29.01 + (longLabels ? 50 : 0) + (isLongLocale ? 50 : 0),
|
||||
bold: text_bold,
|
||||
numberFormat: number_format,
|
||||
numberPrecision: number_precision,
|
||||
link: STATS[key].link,
|
||||
});
|
||||
});
|
||||
|
||||
if (statItems.length === 0 && hide_rank) {
|
||||
throw new CustomError(
|
||||
"Could not render stats card.",
|
||||
"Either stats or rank are required.",
|
||||
);
|
||||
}
|
||||
|
||||
// Calculate the card height depending on how many items there are
|
||||
// but if rank circle is visible clamp the minimum height to `150`
|
||||
let height = Math.max(
|
||||
45 + (statItems.length + 1) * lheight,
|
||||
hide_rank ? 0 : statItems.length ? 150 : 180,
|
||||
);
|
||||
|
||||
// the lower the user's percentile the better
|
||||
const progress = 100 - rank.percentile;
|
||||
const cssStyles = getStyles({
|
||||
titleColor,
|
||||
ringColor,
|
||||
textColor,
|
||||
iconColor,
|
||||
show_icons,
|
||||
progress,
|
||||
});
|
||||
|
||||
const calculateTextWidth = () => {
|
||||
return measureText(
|
||||
custom_title
|
||||
? custom_title
|
||||
: statItems.length
|
||||
? i18n.t("statcard.title")
|
||||
: i18n.t("statcard.ranktitle"),
|
||||
);
|
||||
};
|
||||
|
||||
/*
|
||||
When hide_rank=true, the minimum card width is 270 px + the title length and padding.
|
||||
When hide_rank=false, the minimum card_width is 340 px + the icon width (if show_icons=true).
|
||||
Numbers are picked by looking at existing dimensions on production.
|
||||
*/
|
||||
const iconWidth = show_icons && statItems.length ? 16 + /* padding */ 1 : 0;
|
||||
const minCardWidth =
|
||||
(hide_rank
|
||||
? clampValue(
|
||||
50 /* padding */ + calculateTextWidth() * 2,
|
||||
CARD_MIN_WIDTH,
|
||||
Infinity,
|
||||
)
|
||||
: statItems.length
|
||||
? RANK_CARD_MIN_WIDTH
|
||||
: RANK_ONLY_CARD_MIN_WIDTH) + iconWidth;
|
||||
const defaultCardWidth =
|
||||
(hide_rank
|
||||
? CARD_DEFAULT_WIDTH
|
||||
: statItems.length
|
||||
? RANK_CARD_DEFAULT_WIDTH
|
||||
: RANK_ONLY_CARD_DEFAULT_WIDTH) + iconWidth;
|
||||
let width = card_width
|
||||
? isNaN(card_width)
|
||||
? Math.max(defaultCardWidth, minCardWidth)
|
||||
: card_width
|
||||
: Math.max(defaultCardWidth, minCardWidth);
|
||||
|
||||
const card = new Card({
|
||||
customTitle: custom_title,
|
||||
defaultTitle: statItems.length
|
||||
? i18n.t("statcard.title")
|
||||
: i18n.t("statcard.ranktitle"),
|
||||
width,
|
||||
height,
|
||||
border_radius,
|
||||
colors: {
|
||||
titleColor,
|
||||
textColor,
|
||||
iconColor,
|
||||
bgColor,
|
||||
borderColor,
|
||||
},
|
||||
});
|
||||
|
||||
card.setHideBorder(hide_border);
|
||||
card.setHideTitle(hide_title);
|
||||
card.setCSS(cssStyles);
|
||||
|
||||
if (disable_animations) {
|
||||
card.disableAnimations();
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculates the right rank circle translation values such that the rank circle
|
||||
* keeps respecting the following padding:
|
||||
*
|
||||
* width > RANK_CARD_DEFAULT_WIDTH: The default right padding of 70 px will be used.
|
||||
* width < RANK_CARD_DEFAULT_WIDTH: The left and right padding will be enlarged
|
||||
* equally from a certain minimum at RANK_CARD_MIN_WIDTH.
|
||||
*
|
||||
* @returns {number} - Rank circle translation value.
|
||||
*/
|
||||
const calculateRankXTranslation = () => {
|
||||
if (statItems.length) {
|
||||
const minXTranslation = RANK_CARD_MIN_WIDTH + iconWidth - 70;
|
||||
if (width > RANK_CARD_DEFAULT_WIDTH) {
|
||||
const xMaxExpansion = minXTranslation + (450 - minCardWidth) / 2;
|
||||
return xMaxExpansion + width - RANK_CARD_DEFAULT_WIDTH;
|
||||
} else {
|
||||
return minXTranslation + (width - minCardWidth) / 2;
|
||||
}
|
||||
} else {
|
||||
return width / 2 + 20 - 10;
|
||||
}
|
||||
};
|
||||
|
||||
// Conditionally rendered elements
|
||||
const rankCircle = hide_rank
|
||||
? ""
|
||||
: `<g data-testid="rank-circle"
|
||||
transform="translate(${calculateRankXTranslation()}, ${
|
||||
height / 2 - 50
|
||||
})">
|
||||
<circle class="rank-circle-rim" cx="-10" cy="8" r="40" />
|
||||
<circle class="rank-circle" cx="-10" cy="8" r="40" />
|
||||
<g class="rank-text">
|
||||
${rankIcon(rank_icon, rank?.level, rank?.percentile)}
|
||||
</g>
|
||||
</g>`;
|
||||
|
||||
// Accessibility Labels
|
||||
const labels = Object.keys(STATS)
|
||||
.filter((key) => !hide.includes(key))
|
||||
.map((key) => {
|
||||
// @ts-ignore
|
||||
const stats = STATS[key];
|
||||
if (key === "commits") {
|
||||
return `${i18n.t("statcard.commits")} ${getTotalCommitsYearLabel(
|
||||
include_all_commits,
|
||||
commits_year,
|
||||
i18n,
|
||||
)} : ${stats.value}`;
|
||||
}
|
||||
return `${stats.label}: ${stats.value}`;
|
||||
})
|
||||
.join(", ");
|
||||
|
||||
card.setAccessibilityLabel({
|
||||
title: `${card.title}, Rank: ${rank.level}`,
|
||||
desc: labels,
|
||||
});
|
||||
|
||||
return card.render(`
|
||||
${rankCircle}
|
||||
<svg x="0" y="0">
|
||||
${flexLayout({
|
||||
items: statItems,
|
||||
gap: lheight,
|
||||
direction: "column",
|
||||
}).join("")}
|
||||
</svg>
|
||||
`);
|
||||
};
|
||||
|
||||
export { renderStatsCard, createTextNode };
|
||||
@@ -0,0 +1,977 @@
|
||||
// @ts-check
|
||||
|
||||
import { Card } from "../common/Card.js";
|
||||
import { I18n } from "../common/I18n.js";
|
||||
import { fallbackColor, getCardColors } from "../common/color.js";
|
||||
import { formatBytes } from "../common/fmt.js";
|
||||
import { chunkArray, clampValue, lowercaseTrim } from "../common/ops.js";
|
||||
import {
|
||||
createProgressNode,
|
||||
flexLayout,
|
||||
measureText,
|
||||
} from "../common/render.js";
|
||||
import { langCardLocales } from "../translations.js";
|
||||
|
||||
const DEFAULT_CARD_WIDTH = 300;
|
||||
const MIN_CARD_WIDTH = 280;
|
||||
const DEFAULT_LANG_COLOR = "#858585";
|
||||
const CARD_PADDING = 25;
|
||||
const COMPACT_LAYOUT_BASE_HEIGHT = 90;
|
||||
const MAXIMUM_LANGS_COUNT = 20;
|
||||
|
||||
const NORMAL_LAYOUT_DEFAULT_LANGS_COUNT = 5;
|
||||
const COMPACT_LAYOUT_DEFAULT_LANGS_COUNT = 6;
|
||||
const DONUT_LAYOUT_DEFAULT_LANGS_COUNT = 5;
|
||||
const PIE_LAYOUT_DEFAULT_LANGS_COUNT = 6;
|
||||
const DONUT_VERTICAL_LAYOUT_DEFAULT_LANGS_COUNT = 6;
|
||||
|
||||
/**
|
||||
* @typedef {import("../fetchers/types").Lang} Lang
|
||||
*/
|
||||
|
||||
/**
|
||||
* Retrieves the programming language whose name is the longest.
|
||||
*
|
||||
* @param {Lang[]} arr Array of programming languages.
|
||||
* @returns {{ name: string, size: number, color: string }} Longest programming language object.
|
||||
*/
|
||||
const getLongestLang = (arr) =>
|
||||
arr.reduce(
|
||||
(savedLang, lang) =>
|
||||
lang.name.length > savedLang.name.length ? lang : savedLang,
|
||||
{ name: "", size: 0, color: "" },
|
||||
);
|
||||
|
||||
/**
|
||||
* Convert degrees to radians.
|
||||
*
|
||||
* @param {number} angleInDegrees Angle in degrees.
|
||||
* @returns {number} Angle in radians.
|
||||
*/
|
||||
const degreesToRadians = (angleInDegrees) => angleInDegrees * (Math.PI / 180.0);
|
||||
|
||||
/**
|
||||
* Convert radians to degrees.
|
||||
*
|
||||
* @param {number} angleInRadians Angle in radians.
|
||||
* @returns {number} Angle in degrees.
|
||||
*/
|
||||
const radiansToDegrees = (angleInRadians) => angleInRadians / (Math.PI / 180.0);
|
||||
|
||||
/**
|
||||
* Convert polar coordinates to cartesian coordinates.
|
||||
*
|
||||
* @param {number} centerX Center x coordinate.
|
||||
* @param {number} centerY Center y coordinate.
|
||||
* @param {number} radius Radius of the circle.
|
||||
* @param {number} angleInDegrees Angle in degrees.
|
||||
* @returns {{x: number, y: number}} Cartesian coordinates.
|
||||
*/
|
||||
const polarToCartesian = (centerX, centerY, radius, angleInDegrees) => {
|
||||
const rads = degreesToRadians(angleInDegrees);
|
||||
return {
|
||||
x: centerX + radius * Math.cos(rads),
|
||||
y: centerY + radius * Math.sin(rads),
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Convert cartesian coordinates to polar coordinates.
|
||||
*
|
||||
* @param {number} centerX Center x coordinate.
|
||||
* @param {number} centerY Center y coordinate.
|
||||
* @param {number} x Point x coordinate.
|
||||
* @param {number} y Point y coordinate.
|
||||
* @returns {{radius: number, angleInDegrees: number}} Polar coordinates.
|
||||
*/
|
||||
const cartesianToPolar = (centerX, centerY, x, y) => {
|
||||
const radius = Math.sqrt(Math.pow(x - centerX, 2) + Math.pow(y - centerY, 2));
|
||||
let angleInDegrees = radiansToDegrees(Math.atan2(y - centerY, x - centerX));
|
||||
if (angleInDegrees < 0) {
|
||||
angleInDegrees += 360;
|
||||
}
|
||||
return { radius, angleInDegrees };
|
||||
};
|
||||
|
||||
/**
|
||||
* Calculates length of circle.
|
||||
*
|
||||
* @param {number} radius Radius of the circle.
|
||||
* @returns {number} The length of the circle.
|
||||
*/
|
||||
const getCircleLength = (radius) => {
|
||||
return 2 * Math.PI * radius;
|
||||
};
|
||||
|
||||
/**
|
||||
* Calculates height for the compact layout.
|
||||
*
|
||||
* @param {number} totalLangs Total number of languages.
|
||||
* @returns {number} Card height.
|
||||
*/
|
||||
const calculateCompactLayoutHeight = (totalLangs) => {
|
||||
return COMPACT_LAYOUT_BASE_HEIGHT + Math.round(totalLangs / 2) * 25;
|
||||
};
|
||||
|
||||
/**
|
||||
* Calculates height for the normal layout.
|
||||
*
|
||||
* @param {number} totalLangs Total number of languages.
|
||||
* @returns {number} Card height.
|
||||
*/
|
||||
const calculateNormalLayoutHeight = (totalLangs) => {
|
||||
return 45 + (totalLangs + 1) * 40;
|
||||
};
|
||||
|
||||
/**
|
||||
* Calculates height for the donut layout.
|
||||
*
|
||||
* @param {number} totalLangs Total number of languages.
|
||||
* @returns {number} Card height.
|
||||
*/
|
||||
const calculateDonutLayoutHeight = (totalLangs) => {
|
||||
return 215 + Math.max(totalLangs - 5, 0) * 32;
|
||||
};
|
||||
|
||||
/**
|
||||
* Calculates height for the donut vertical layout.
|
||||
*
|
||||
* @param {number} totalLangs Total number of languages.
|
||||
* @returns {number} Card height.
|
||||
*/
|
||||
const calculateDonutVerticalLayoutHeight = (totalLangs) => {
|
||||
return 300 + Math.round(totalLangs / 2) * 25;
|
||||
};
|
||||
|
||||
/**
|
||||
* Calculates height for the pie layout.
|
||||
*
|
||||
* @param {number} totalLangs Total number of languages.
|
||||
* @returns {number} Card height.
|
||||
*/
|
||||
const calculatePieLayoutHeight = (totalLangs) => {
|
||||
return 300 + Math.round(totalLangs / 2) * 25;
|
||||
};
|
||||
|
||||
/**
|
||||
* Calculates the center translation needed to keep the donut chart centred.
|
||||
* @param {number} totalLangs Total number of languages.
|
||||
* @returns {number} Donut center translation.
|
||||
*/
|
||||
const donutCenterTranslation = (totalLangs) => {
|
||||
return -45 + Math.max(totalLangs - 5, 0) * 16;
|
||||
};
|
||||
|
||||
/**
|
||||
* Trim top languages to lang_count while also hiding certain languages.
|
||||
*
|
||||
* @param {Record<string, Lang>} topLangs Top languages.
|
||||
* @param {number} langs_count Number of languages to show.
|
||||
* @param {string[]=} hide Languages to hide.
|
||||
* @returns {{ langs: Lang[], totalLanguageSize: number }} Trimmed top languages and total size.
|
||||
*/
|
||||
const trimTopLanguages = (topLangs, langs_count, hide) => {
|
||||
let langs = Object.values(topLangs);
|
||||
let langsToHide = {};
|
||||
let langsCount = clampValue(langs_count, 1, MAXIMUM_LANGS_COUNT);
|
||||
|
||||
// populate langsToHide map for quick lookup
|
||||
// while filtering out
|
||||
if (hide) {
|
||||
hide.forEach((langName) => {
|
||||
// @ts-ignore
|
||||
langsToHide[lowercaseTrim(langName)] = true;
|
||||
});
|
||||
}
|
||||
|
||||
// filter out languages to be hidden
|
||||
langs = langs
|
||||
.sort((a, b) => b.size - a.size)
|
||||
.filter((lang) => {
|
||||
// @ts-ignore
|
||||
return !langsToHide[lowercaseTrim(lang.name)];
|
||||
})
|
||||
.slice(0, langsCount);
|
||||
|
||||
const totalLanguageSize = langs.reduce((acc, curr) => acc + curr.size, 0);
|
||||
|
||||
return { langs, totalLanguageSize };
|
||||
};
|
||||
|
||||
/**
|
||||
* Get display value corresponding to the format.
|
||||
*
|
||||
* @param {number} size Bytes size.
|
||||
* @param {number} percentages Percentage value.
|
||||
* @param {string} format Format of the stats.
|
||||
* @returns {string} Display value.
|
||||
*/
|
||||
const getDisplayValue = (size, percentages, format) => {
|
||||
return format === "bytes" ? formatBytes(size) : `${percentages.toFixed(2)}%`;
|
||||
};
|
||||
|
||||
/**
|
||||
* Create progress bar text item for a programming language.
|
||||
*
|
||||
* @param {object} props Function properties.
|
||||
* @param {number} props.width The card width
|
||||
* @param {string} props.color Color of the programming language.
|
||||
* @param {string} props.progBarBgColor Color of the background of progress bar.
|
||||
* @param {string} props.name Name of the programming language.
|
||||
* @param {number} props.size Size of the programming language.
|
||||
* @param {number} props.totalSize Total size of all languages.
|
||||
* @param {string} props.statsFormat Stats format.
|
||||
* @param {number} props.index Index of the programming language.
|
||||
* @returns {string} Programming language SVG node.
|
||||
*/
|
||||
const createProgressTextNode = ({
|
||||
width,
|
||||
color,
|
||||
progBarBgColor,
|
||||
name,
|
||||
size,
|
||||
totalSize,
|
||||
statsFormat,
|
||||
index,
|
||||
}) => {
|
||||
const staggerDelay = (index + 3) * 150;
|
||||
const paddingRight = 95;
|
||||
const progressTextX = width - paddingRight + 10;
|
||||
const progressWidth = width - paddingRight;
|
||||
|
||||
const progress = (size / totalSize) * 100;
|
||||
const displayValue = getDisplayValue(size, progress, statsFormat);
|
||||
|
||||
return `
|
||||
<g class="stagger" style="animation-delay: ${staggerDelay}ms">
|
||||
<text data-testid="lang-name" x="2" y="15" class="lang-name">${name}</text>
|
||||
<text x="${progressTextX}" y="34" class="lang-name">${displayValue}</text>
|
||||
${createProgressNode({
|
||||
x: 0,
|
||||
y: 25,
|
||||
color,
|
||||
width: progressWidth,
|
||||
progress,
|
||||
progressBarBackgroundColor: progBarBgColor,
|
||||
delay: staggerDelay + 300,
|
||||
})}
|
||||
</g>
|
||||
`;
|
||||
};
|
||||
|
||||
/**
|
||||
* Creates compact text item for a programming language.
|
||||
*
|
||||
* @param {object} props Function properties.
|
||||
* @param {Lang} props.lang Programming language object.
|
||||
* @param {number} props.totalSize Total size of all languages.
|
||||
* @param {boolean=} props.hideProgress Whether to hide percentage.
|
||||
* @param {string=} props.statsFormat Stats format
|
||||
* @param {number} props.index Index of the programming language.
|
||||
* @returns {string} Compact layout programming language SVG node.
|
||||
*/
|
||||
const createCompactLangNode = ({
|
||||
lang,
|
||||
totalSize,
|
||||
hideProgress,
|
||||
statsFormat = "percentages",
|
||||
index,
|
||||
}) => {
|
||||
const percentages = (lang.size / totalSize) * 100;
|
||||
const displayValue = getDisplayValue(lang.size, percentages, statsFormat);
|
||||
|
||||
const staggerDelay = (index + 3) * 150;
|
||||
const color = lang.color || "#858585";
|
||||
|
||||
return `
|
||||
<g class="stagger" style="animation-delay: ${staggerDelay}ms">
|
||||
<circle cx="5" cy="6" r="5" fill="${color}" />
|
||||
<text data-testid="lang-name" x="15" y="10" class='lang-name'>
|
||||
${lang.name} ${hideProgress ? "" : displayValue}
|
||||
</text>
|
||||
</g>
|
||||
`;
|
||||
};
|
||||
|
||||
/**
|
||||
* Create compact languages text items for all programming languages.
|
||||
*
|
||||
* @param {object} props Function properties.
|
||||
* @param {Lang[]} props.langs Array of programming languages.
|
||||
* @param {number} props.totalSize Total size of all languages.
|
||||
* @param {boolean=} props.hideProgress Whether to hide percentage.
|
||||
* @param {string=} props.statsFormat Stats format
|
||||
* @returns {string} Programming languages SVG node.
|
||||
*/
|
||||
const createLanguageTextNode = ({
|
||||
langs,
|
||||
totalSize,
|
||||
hideProgress,
|
||||
statsFormat,
|
||||
}) => {
|
||||
const longestLang = getLongestLang(langs);
|
||||
const chunked = chunkArray(langs, langs.length / 2);
|
||||
const layouts = chunked.map((array) => {
|
||||
// @ts-ignore
|
||||
const items = array.map((lang, index) =>
|
||||
createCompactLangNode({
|
||||
lang,
|
||||
totalSize,
|
||||
hideProgress,
|
||||
statsFormat,
|
||||
index,
|
||||
}),
|
||||
);
|
||||
return flexLayout({
|
||||
items,
|
||||
gap: 25,
|
||||
direction: "column",
|
||||
}).join("");
|
||||
});
|
||||
|
||||
const percent = ((longestLang.size / totalSize) * 100).toFixed(2);
|
||||
const minGap = 150;
|
||||
const maxGap = 20 + measureText(`${longestLang.name} ${percent}%`, 11);
|
||||
return flexLayout({
|
||||
items: layouts,
|
||||
gap: maxGap < minGap ? minGap : maxGap,
|
||||
}).join("");
|
||||
};
|
||||
|
||||
/**
|
||||
* Create donut languages text items for all programming languages.
|
||||
*
|
||||
* @param {object} props Function properties.
|
||||
* @param {Lang[]} props.langs Array of programming languages.
|
||||
* @param {number} props.totalSize Total size of all languages.
|
||||
* @param {string} props.statsFormat Stats format
|
||||
* @returns {string} Donut layout programming language SVG node.
|
||||
*/
|
||||
const createDonutLanguagesNode = ({ langs, totalSize, statsFormat }) => {
|
||||
return flexLayout({
|
||||
items: langs.map((lang, index) => {
|
||||
return createCompactLangNode({
|
||||
lang,
|
||||
totalSize,
|
||||
hideProgress: false,
|
||||
statsFormat,
|
||||
index,
|
||||
});
|
||||
}),
|
||||
gap: 32,
|
||||
direction: "column",
|
||||
}).join("");
|
||||
};
|
||||
|
||||
/**
|
||||
* Renders the default language card layout.
|
||||
*
|
||||
* @param {Lang[]} langs Array of programming languages.
|
||||
* @param {number} width Card width.
|
||||
* @param {number} totalLanguageSize Total size of all languages.
|
||||
* @param progBarBgColor Color of the background of progress bar.
|
||||
* @param {string} statsFormat Stats format.
|
||||
* @returns {string} Normal layout card SVG object.
|
||||
*/
|
||||
const renderNormalLayout = (
|
||||
langs,
|
||||
width,
|
||||
totalLanguageSize,
|
||||
progBarBgColor,
|
||||
statsFormat,
|
||||
) => {
|
||||
return flexLayout({
|
||||
items: langs.map((lang, index) => {
|
||||
return createProgressTextNode({
|
||||
width,
|
||||
name: lang.name,
|
||||
color: lang.color || DEFAULT_LANG_COLOR,
|
||||
size: lang.size,
|
||||
totalSize: totalLanguageSize,
|
||||
statsFormat,
|
||||
index,
|
||||
progBarBgColor,
|
||||
});
|
||||
}),
|
||||
gap: 40,
|
||||
direction: "column",
|
||||
}).join("");
|
||||
};
|
||||
|
||||
/**
|
||||
* Renders the compact language card layout.
|
||||
*
|
||||
* @param {Lang[]} langs Array of programming languages.
|
||||
* @param {number} width Card width.
|
||||
* @param {number} totalLanguageSize Total size of all languages.
|
||||
* @param {boolean=} hideProgress Whether to hide progress bar.
|
||||
* @param {string} statsFormat Stats format.
|
||||
* @returns {string} Compact layout card SVG object.
|
||||
*/
|
||||
const renderCompactLayout = (
|
||||
langs,
|
||||
width,
|
||||
totalLanguageSize,
|
||||
hideProgress,
|
||||
statsFormat = "percentages",
|
||||
) => {
|
||||
const paddingRight = 50;
|
||||
const offsetWidth = width - paddingRight;
|
||||
// progressOffset holds the previous language's width and used to offset the next language
|
||||
// so that we can stack them one after another, like this: [--][----][---]
|
||||
let progressOffset = 0;
|
||||
const compactProgressBar = langs
|
||||
.map((lang) => {
|
||||
const percentage = parseFloat(
|
||||
((lang.size / totalLanguageSize) * offsetWidth).toFixed(2),
|
||||
);
|
||||
|
||||
const progress = percentage < 10 ? percentage + 10 : percentage;
|
||||
|
||||
const output = `
|
||||
<rect
|
||||
mask="url(#rect-mask)"
|
||||
data-testid="lang-progress"
|
||||
x="${progressOffset}"
|
||||
y="0"
|
||||
width="${progress}"
|
||||
height="8"
|
||||
fill="${lang.color || "#858585"}"
|
||||
/>
|
||||
`;
|
||||
progressOffset += percentage;
|
||||
return output;
|
||||
})
|
||||
.join("");
|
||||
|
||||
return `
|
||||
${
|
||||
hideProgress
|
||||
? ""
|
||||
: `
|
||||
<mask id="rect-mask">
|
||||
<rect x="0" y="0" width="${offsetWidth}" height="8" fill="white" rx="5"/>
|
||||
</mask>
|
||||
${compactProgressBar}
|
||||
`
|
||||
}
|
||||
<g transform="translate(0, ${hideProgress ? "0" : "25"})">
|
||||
${createLanguageTextNode({
|
||||
langs,
|
||||
totalSize: totalLanguageSize,
|
||||
hideProgress,
|
||||
statsFormat,
|
||||
})}
|
||||
</g>
|
||||
`;
|
||||
};
|
||||
|
||||
/**
|
||||
* Renders donut vertical layout to display user's most frequently used programming languages.
|
||||
*
|
||||
* @param {Lang[]} langs Array of programming languages.
|
||||
* @param {number} totalLanguageSize Total size of all languages.
|
||||
* @param {string} statsFormat Stats format.
|
||||
* @returns {string} Compact layout card SVG object.
|
||||
*/
|
||||
const renderDonutVerticalLayout = (langs, totalLanguageSize, statsFormat) => {
|
||||
// Donut vertical chart radius and total length
|
||||
const radius = 80;
|
||||
const totalCircleLength = getCircleLength(radius);
|
||||
|
||||
// SVG circles
|
||||
let circles = [];
|
||||
|
||||
// Start indent for donut vertical chart parts
|
||||
let indent = 0;
|
||||
|
||||
// Start delay coefficient for donut vertical chart parts
|
||||
let startDelayCoefficient = 1;
|
||||
|
||||
// Generate each donut vertical chart part
|
||||
for (const lang of langs) {
|
||||
const percentage = (lang.size / totalLanguageSize) * 100;
|
||||
const circleLength = totalCircleLength * (percentage / 100);
|
||||
const delay = startDelayCoefficient * 100;
|
||||
|
||||
circles.push(`
|
||||
<g class="stagger" style="animation-delay: ${delay}ms">
|
||||
<circle
|
||||
cx="150"
|
||||
cy="100"
|
||||
r="${radius}"
|
||||
fill="transparent"
|
||||
stroke="${lang.color}"
|
||||
stroke-width="25"
|
||||
stroke-dasharray="${totalCircleLength}"
|
||||
stroke-dashoffset="${indent}"
|
||||
size="${percentage}"
|
||||
data-testid="lang-donut"
|
||||
/>
|
||||
</g>
|
||||
`);
|
||||
|
||||
// Update the indent for the next part
|
||||
indent += circleLength;
|
||||
// Update the start delay coefficient for the next part
|
||||
startDelayCoefficient += 1;
|
||||
}
|
||||
|
||||
return `
|
||||
<svg data-testid="lang-items">
|
||||
<g transform="translate(0, 0)">
|
||||
<svg data-testid="donut">
|
||||
${circles.join("")}
|
||||
</svg>
|
||||
</g>
|
||||
<g transform="translate(0, 220)">
|
||||
<svg data-testid="lang-names" x="${CARD_PADDING}">
|
||||
${createLanguageTextNode({
|
||||
langs,
|
||||
totalSize: totalLanguageSize,
|
||||
hideProgress: false,
|
||||
statsFormat,
|
||||
})}
|
||||
</svg>
|
||||
</g>
|
||||
</svg>
|
||||
`;
|
||||
};
|
||||
|
||||
/**
|
||||
* Renders pie layout to display user's most frequently used programming languages.
|
||||
*
|
||||
* @param {Lang[]} langs Array of programming languages.
|
||||
* @param {number} totalLanguageSize Total size of all languages.
|
||||
* @param {string} statsFormat Stats format.
|
||||
* @returns {string} Compact layout card SVG object.
|
||||
*/
|
||||
const renderPieLayout = (langs, totalLanguageSize, statsFormat) => {
|
||||
// Pie chart radius and center coordinates
|
||||
const radius = 90;
|
||||
const centerX = 150;
|
||||
const centerY = 100;
|
||||
|
||||
// Start angle for the pie chart parts
|
||||
let startAngle = 0;
|
||||
|
||||
// Start delay coefficient for the pie chart parts
|
||||
let startDelayCoefficient = 1;
|
||||
|
||||
// SVG paths
|
||||
const paths = [];
|
||||
|
||||
// Generate each pie chart part
|
||||
for (const lang of langs) {
|
||||
if (langs.length === 1) {
|
||||
paths.push(`
|
||||
<circle
|
||||
cx="${centerX}"
|
||||
cy="${centerY}"
|
||||
r="${radius}"
|
||||
stroke="none"
|
||||
fill="${lang.color}"
|
||||
data-testid="lang-pie"
|
||||
size="100"
|
||||
/>
|
||||
`);
|
||||
break;
|
||||
}
|
||||
|
||||
const langSizePart = lang.size / totalLanguageSize;
|
||||
const percentage = langSizePart * 100;
|
||||
// Calculate the angle for the current part
|
||||
const angle = langSizePart * 360;
|
||||
|
||||
// Calculate the end angle
|
||||
const endAngle = startAngle + angle;
|
||||
|
||||
// Calculate the coordinates of the start and end points of the arc
|
||||
const startPoint = polarToCartesian(centerX, centerY, radius, startAngle);
|
||||
const endPoint = polarToCartesian(centerX, centerY, radius, endAngle);
|
||||
|
||||
// Determine the large arc flag based on the angle
|
||||
const largeArcFlag = angle > 180 ? 1 : 0;
|
||||
|
||||
// Calculate delay
|
||||
const delay = startDelayCoefficient * 100;
|
||||
|
||||
// SVG arc markup
|
||||
paths.push(`
|
||||
<g class="stagger" style="animation-delay: ${delay}ms">
|
||||
<path
|
||||
data-testid="lang-pie"
|
||||
size="${percentage}"
|
||||
d="M ${centerX} ${centerY} L ${startPoint.x} ${startPoint.y} A ${radius} ${radius} 0 ${largeArcFlag} 1 ${endPoint.x} ${endPoint.y} Z"
|
||||
fill="${lang.color}"
|
||||
/>
|
||||
</g>
|
||||
`);
|
||||
|
||||
// Update the start angle for the next part
|
||||
startAngle = endAngle;
|
||||
// Update the start delay coefficient for the next part
|
||||
startDelayCoefficient += 1;
|
||||
}
|
||||
|
||||
return `
|
||||
<svg data-testid="lang-items">
|
||||
<g transform="translate(0, 0)">
|
||||
<svg data-testid="pie">
|
||||
${paths.join("")}
|
||||
</svg>
|
||||
</g>
|
||||
<g transform="translate(0, 220)">
|
||||
<svg data-testid="lang-names" x="${CARD_PADDING}">
|
||||
${createLanguageTextNode({
|
||||
langs,
|
||||
totalSize: totalLanguageSize,
|
||||
hideProgress: false,
|
||||
statsFormat,
|
||||
})}
|
||||
</svg>
|
||||
</g>
|
||||
</svg>
|
||||
`;
|
||||
};
|
||||
|
||||
/**
|
||||
* Creates the SVG paths for the language donut chart.
|
||||
*
|
||||
* @param {number} cx Donut center x-position.
|
||||
* @param {number} cy Donut center y-position.
|
||||
* @param {number} radius Donut arc Radius.
|
||||
* @param {number[]} percentages Array with donut section percentages.
|
||||
* @returns {{d: string, percent: number}[]} Array of svg path elements
|
||||
*/
|
||||
const createDonutPaths = (cx, cy, radius, percentages) => {
|
||||
const paths = [];
|
||||
let startAngle = 0;
|
||||
let endAngle;
|
||||
|
||||
const totalPercent = percentages.reduce((acc, curr) => acc + curr, 0);
|
||||
for (let i = 0; i < percentages.length; i++) {
|
||||
const tmpPath = {};
|
||||
|
||||
let percent = parseFloat(
|
||||
((percentages[i] / totalPercent) * 100).toFixed(2),
|
||||
);
|
||||
|
||||
endAngle = 3.6 * percent + startAngle;
|
||||
const startPoint = polarToCartesian(cx, cy, radius, endAngle - 90); // rotate donut 90 degrees counter-clockwise.
|
||||
const endPoint = polarToCartesian(cx, cy, radius, startAngle - 90); // rotate donut 90 degrees counter-clockwise.
|
||||
const largeArc = endAngle - startAngle <= 180 ? 0 : 1;
|
||||
|
||||
tmpPath.percent = percent;
|
||||
tmpPath.d = `M ${startPoint.x} ${startPoint.y} A ${radius} ${radius} 0 ${largeArc} 0 ${endPoint.x} ${endPoint.y}`;
|
||||
|
||||
paths.push(tmpPath);
|
||||
startAngle = endAngle;
|
||||
}
|
||||
|
||||
return paths;
|
||||
};
|
||||
|
||||
/**
|
||||
* Renders the donut language card layout.
|
||||
*
|
||||
* @param {Lang[]} langs Array of programming languages.
|
||||
* @param {number} width Card width.
|
||||
* @param {number} totalLanguageSize Total size of all languages.
|
||||
* @param {string} statsFormat Stats format.
|
||||
* @returns {string} Donut layout card SVG object.
|
||||
*/
|
||||
const renderDonutLayout = (langs, width, totalLanguageSize, statsFormat) => {
|
||||
const centerX = width / 3;
|
||||
const centerY = width / 3;
|
||||
const radius = centerX - 60;
|
||||
const strokeWidth = 12;
|
||||
|
||||
const colors = langs.map((lang) => lang.color);
|
||||
const langsPercents = langs.map((lang) =>
|
||||
parseFloat(((lang.size / totalLanguageSize) * 100).toFixed(2)),
|
||||
);
|
||||
|
||||
const langPaths = createDonutPaths(centerX, centerY, radius, langsPercents);
|
||||
|
||||
const donutPaths =
|
||||
langs.length === 1
|
||||
? `<circle cx="${centerX}" cy="${centerY}" r="${radius}" stroke="${colors[0]}" fill="none" stroke-width="${strokeWidth}" data-testid="lang-donut" size="100"/>`
|
||||
: langPaths
|
||||
.map((section, index) => {
|
||||
const staggerDelay = (index + 3) * 100;
|
||||
const delay = staggerDelay + 300;
|
||||
|
||||
const output = `
|
||||
<g class="stagger" style="animation-delay: ${delay}ms">
|
||||
<path
|
||||
data-testid="lang-donut"
|
||||
size="${section.percent}"
|
||||
d="${section.d}"
|
||||
stroke="${colors[index]}"
|
||||
fill="none"
|
||||
stroke-width="${strokeWidth}">
|
||||
</path>
|
||||
</g>
|
||||
`;
|
||||
|
||||
return output;
|
||||
})
|
||||
.join("");
|
||||
|
||||
const donut = `<svg width="${width}" height="${width}">${donutPaths}</svg>`;
|
||||
|
||||
return `
|
||||
<g transform="translate(0, 0)">
|
||||
<g transform="translate(0, 0)">
|
||||
${createDonutLanguagesNode({ langs, totalSize: totalLanguageSize, statsFormat })}
|
||||
</g>
|
||||
|
||||
<g transform="translate(125, ${donutCenterTranslation(langs.length)})">
|
||||
${donut}
|
||||
</g>
|
||||
</g>
|
||||
`;
|
||||
};
|
||||
|
||||
/**
|
||||
* @typedef {import("./types").TopLangOptions} TopLangOptions
|
||||
* @typedef {TopLangOptions["layout"]} Layout
|
||||
*/
|
||||
|
||||
/**
|
||||
* Creates the no languages data SVG node.
|
||||
*
|
||||
* @param {object} props Object with function properties.
|
||||
* @param {string} props.color No languages data text color.
|
||||
* @param {string} props.text No languages data translated text.
|
||||
* @param {Layout | undefined} props.layout Card layout.
|
||||
* @returns {string} No languages data SVG node string.
|
||||
*/
|
||||
const noLanguagesDataNode = ({ color, text, layout }) => {
|
||||
return `
|
||||
<text x="${
|
||||
layout === "pie" || layout === "donut-vertical" ? CARD_PADDING : 0
|
||||
}" y="11" class="stat bold" fill="${color}">${text}</text>
|
||||
`;
|
||||
};
|
||||
|
||||
/**
|
||||
* Get default languages count for provided card layout.
|
||||
*
|
||||
* @param {object} props Function properties.
|
||||
* @param {Layout=} props.layout Input layout string.
|
||||
* @param {boolean=} props.hide_progress Input hide_progress parameter value.
|
||||
* @returns {number} Default languages count for input layout.
|
||||
*/
|
||||
const getDefaultLanguagesCountByLayout = ({ layout, hide_progress }) => {
|
||||
if (layout === "compact" || hide_progress === true) {
|
||||
return COMPACT_LAYOUT_DEFAULT_LANGS_COUNT;
|
||||
} else if (layout === "donut") {
|
||||
return DONUT_LAYOUT_DEFAULT_LANGS_COUNT;
|
||||
} else if (layout === "donut-vertical") {
|
||||
return DONUT_VERTICAL_LAYOUT_DEFAULT_LANGS_COUNT;
|
||||
} else if (layout === "pie") {
|
||||
return PIE_LAYOUT_DEFAULT_LANGS_COUNT;
|
||||
} else {
|
||||
return NORMAL_LAYOUT_DEFAULT_LANGS_COUNT;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* @typedef {import('../fetchers/types').TopLangData} TopLangData
|
||||
*/
|
||||
|
||||
/**
|
||||
* Renders card that display user's most frequently used programming languages.
|
||||
*
|
||||
* @param {TopLangData} topLangs User's most frequently used programming languages.
|
||||
* @param {Partial<TopLangOptions>} options Card options.
|
||||
* @returns {string} Language card SVG object.
|
||||
*/
|
||||
const renderTopLanguages = (topLangs, options = {}) => {
|
||||
const {
|
||||
hide_title = false,
|
||||
hide_border = false,
|
||||
card_width,
|
||||
title_color,
|
||||
text_color,
|
||||
bg_color,
|
||||
prog_bar_bg_color,
|
||||
hide,
|
||||
hide_progress,
|
||||
theme,
|
||||
layout,
|
||||
custom_title,
|
||||
locale,
|
||||
langs_count = getDefaultLanguagesCountByLayout({ layout, hide_progress }),
|
||||
border_radius,
|
||||
border_color,
|
||||
disable_animations,
|
||||
stats_format = "percentages",
|
||||
} = options;
|
||||
|
||||
const i18n = new I18n({
|
||||
locale,
|
||||
translations: langCardLocales,
|
||||
});
|
||||
|
||||
const { langs, totalLanguageSize } = trimTopLanguages(
|
||||
topLangs,
|
||||
langs_count,
|
||||
hide,
|
||||
);
|
||||
|
||||
let width = card_width
|
||||
? isNaN(card_width)
|
||||
? DEFAULT_CARD_WIDTH
|
||||
: card_width < MIN_CARD_WIDTH
|
||||
? MIN_CARD_WIDTH
|
||||
: card_width
|
||||
: DEFAULT_CARD_WIDTH;
|
||||
let height = calculateNormalLayoutHeight(langs.length);
|
||||
|
||||
// returns theme based colors with proper overrides and defaults
|
||||
const colors = getCardColors({
|
||||
title_color,
|
||||
text_color,
|
||||
bg_color,
|
||||
border_color,
|
||||
theme,
|
||||
});
|
||||
|
||||
let finalLayout;
|
||||
if (langs.length === 0) {
|
||||
height = COMPACT_LAYOUT_BASE_HEIGHT;
|
||||
finalLayout = noLanguagesDataNode({
|
||||
color: colors.textColor,
|
||||
text: i18n.t("langcard.nodata"),
|
||||
layout,
|
||||
});
|
||||
} else if (layout === "pie") {
|
||||
height = calculatePieLayoutHeight(langs.length);
|
||||
finalLayout = renderPieLayout(langs, totalLanguageSize, stats_format);
|
||||
} else if (layout === "donut-vertical") {
|
||||
height = calculateDonutVerticalLayoutHeight(langs.length);
|
||||
finalLayout = renderDonutVerticalLayout(
|
||||
langs,
|
||||
totalLanguageSize,
|
||||
stats_format,
|
||||
);
|
||||
} else if (layout === "compact" || hide_progress == true) {
|
||||
height =
|
||||
calculateCompactLayoutHeight(langs.length) + (hide_progress ? -25 : 0);
|
||||
|
||||
finalLayout = renderCompactLayout(
|
||||
langs,
|
||||
width,
|
||||
totalLanguageSize,
|
||||
hide_progress,
|
||||
stats_format,
|
||||
);
|
||||
} else if (layout === "donut") {
|
||||
height = calculateDonutLayoutHeight(langs.length);
|
||||
width = width + 50; // padding
|
||||
finalLayout = renderDonutLayout(
|
||||
langs,
|
||||
width,
|
||||
totalLanguageSize,
|
||||
stats_format,
|
||||
);
|
||||
} else {
|
||||
finalLayout = renderNormalLayout(
|
||||
langs,
|
||||
width,
|
||||
totalLanguageSize,
|
||||
fallbackColor(prog_bar_bg_color, "#ddd"),
|
||||
stats_format,
|
||||
);
|
||||
}
|
||||
|
||||
const card = new Card({
|
||||
customTitle: custom_title,
|
||||
defaultTitle: i18n.t("langcard.title"),
|
||||
width,
|
||||
height,
|
||||
border_radius,
|
||||
colors,
|
||||
});
|
||||
|
||||
if (disable_animations) {
|
||||
card.disableAnimations();
|
||||
}
|
||||
|
||||
card.setHideBorder(hide_border);
|
||||
card.setHideTitle(hide_title);
|
||||
card.setCSS(
|
||||
`
|
||||
@keyframes slideInAnimation {
|
||||
from {
|
||||
width: 0;
|
||||
}
|
||||
to {
|
||||
width: calc(100%-100px);
|
||||
}
|
||||
}
|
||||
@keyframes growWidthAnimation {
|
||||
from {
|
||||
width: 0;
|
||||
}
|
||||
to {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
.stat {
|
||||
font: 600 14px 'Segoe UI', Ubuntu, "Helvetica Neue", Sans-Serif; fill: ${colors.textColor};
|
||||
}
|
||||
@supports(-moz-appearance: auto) {
|
||||
/* Selector detects Firefox */
|
||||
.stat { font-size:12px; }
|
||||
}
|
||||
.bold { font-weight: 700 }
|
||||
.lang-name {
|
||||
font: 400 11px "Segoe UI", Ubuntu, Sans-Serif;
|
||||
fill: ${colors.textColor};
|
||||
}
|
||||
.stagger {
|
||||
opacity: 0;
|
||||
animation: fadeInAnimation 0.3s ease-in-out forwards;
|
||||
}
|
||||
#rect-mask rect{
|
||||
animation: slideInAnimation 1s ease-in-out forwards;
|
||||
}
|
||||
.lang-progress{
|
||||
animation: growWidthAnimation 0.6s ease-in-out forwards;
|
||||
}
|
||||
`,
|
||||
);
|
||||
|
||||
if (layout === "pie" || layout === "donut-vertical") {
|
||||
return card.render(finalLayout);
|
||||
}
|
||||
|
||||
return card.render(`
|
||||
<svg data-testid="lang-items" x="${CARD_PADDING}">
|
||||
${finalLayout}
|
||||
</svg>
|
||||
`);
|
||||
};
|
||||
|
||||
export {
|
||||
getLongestLang,
|
||||
degreesToRadians,
|
||||
radiansToDegrees,
|
||||
polarToCartesian,
|
||||
cartesianToPolar,
|
||||
getCircleLength,
|
||||
calculateCompactLayoutHeight,
|
||||
calculateNormalLayoutHeight,
|
||||
calculateDonutLayoutHeight,
|
||||
calculateDonutVerticalLayoutHeight,
|
||||
calculatePieLayoutHeight,
|
||||
donutCenterTranslation,
|
||||
trimTopLanguages,
|
||||
renderTopLanguages,
|
||||
MIN_CARD_WIDTH,
|
||||
getDefaultLanguagesCountByLayout,
|
||||
};
|
||||
Vendored
+75
@@ -0,0 +1,75 @@
|
||||
type ThemeNames = keyof typeof import("../themes/index.ts");
|
||||
type RankIcon = "default" | "github" | "percentile";
|
||||
|
||||
interface CommonOptions {
|
||||
title_color: string;
|
||||
icon_color: string;
|
||||
text_color: string;
|
||||
bg_color: string;
|
||||
theme: ThemeNames;
|
||||
border_radius: number;
|
||||
border_color: string;
|
||||
locale: string;
|
||||
hide_border: boolean;
|
||||
}
|
||||
|
||||
export type StatCardOptions = CommonOptions & {
|
||||
hide: Array<string>;
|
||||
show_icons: boolean;
|
||||
hide_title: boolean;
|
||||
card_width: number;
|
||||
hide_rank: boolean;
|
||||
include_all_commits: boolean;
|
||||
commits_year: number;
|
||||
line_height: number | string;
|
||||
custom_title: string;
|
||||
disable_animations: boolean;
|
||||
number_format: string;
|
||||
number_precision: number;
|
||||
ring_color: string;
|
||||
text_bold: boolean;
|
||||
rank_icon: RankIcon;
|
||||
show: Array<string>;
|
||||
};
|
||||
|
||||
export type RepoCardOptions = CommonOptions & {
|
||||
show_owner: boolean;
|
||||
description_lines_count: number;
|
||||
card_width_input;
|
||||
show: Array<string>;
|
||||
show_icons: boolean;
|
||||
number_format: string;
|
||||
text_bold: boolean;
|
||||
line_height: number | string;
|
||||
username;
|
||||
};
|
||||
|
||||
export type TopLangOptions = CommonOptions & {
|
||||
hide_title: boolean;
|
||||
card_width: number;
|
||||
hide: Array<string>;
|
||||
layout: "compact" | "normal" | "donut" | "donut-vertical" | "pie";
|
||||
custom_title: string;
|
||||
langs_count: number;
|
||||
disable_animations: boolean;
|
||||
hide_progress: boolean;
|
||||
prog_bar_bg_color: string;
|
||||
stats_format: "percentages" | "bytes";
|
||||
};
|
||||
|
||||
export type WakaTimeOptions = CommonOptions & {
|
||||
hide_title: boolean;
|
||||
hide: Array<string>;
|
||||
card_width: number;
|
||||
line_height: string;
|
||||
hide_progress: boolean;
|
||||
custom_title: string;
|
||||
layout: "compact" | "normal";
|
||||
langs_count: number;
|
||||
display_format: "time" | "percent";
|
||||
disable_animations: boolean;
|
||||
};
|
||||
|
||||
export type GistCardOptions = CommonOptions & {
|
||||
show_owner: boolean;
|
||||
};
|
||||
@@ -0,0 +1,471 @@
|
||||
// @ts-check
|
||||
|
||||
import { Card } from "../common/Card.js";
|
||||
import { I18n } from "../common/I18n.js";
|
||||
import { getCardColors } from "../common/color.js";
|
||||
import languageColors from "../common/languageColors.json" with { type: "json" };
|
||||
import { clampValue, lowercaseTrim } from "../common/ops.js";
|
||||
import { createProgressNode, flexLayout } from "../common/render.js";
|
||||
import { wakatimeCardLocales } from "../translations.js";
|
||||
|
||||
const DEFAULT_CARD_WIDTH = 495;
|
||||
const MIN_CARD_WIDTH = 250;
|
||||
const COMPACT_LAYOUT_MIN_WIDTH = 400;
|
||||
const DEFAULT_LINE_HEIGHT = 25;
|
||||
const PROGRESSBAR_PADDING = 130;
|
||||
const HIDDEN_PROGRESSBAR_PADDING = 170;
|
||||
const COMPACT_LAYOUT_PROGRESSBAR_PADDING = 25;
|
||||
const TOTAL_TEXT_WIDTH = 275;
|
||||
|
||||
/**
|
||||
* Creates the no coding activity SVG node.
|
||||
*
|
||||
* @param {object} props The function properties.
|
||||
* @param {string} props.color No coding activity text color.
|
||||
* @param {string} props.text No coding activity translated text.
|
||||
* @returns {string} No coding activity SVG node string.
|
||||
*/
|
||||
const noCodingActivityNode = ({ color, text }) => {
|
||||
return `
|
||||
<text x="25" y="11" class="stat bold" fill="${color}">${text}</text>
|
||||
`;
|
||||
};
|
||||
|
||||
/**
|
||||
* @typedef {import('../fetchers/types').WakaTimeLang} WakaTimeLang
|
||||
*/
|
||||
|
||||
/**
|
||||
* Format language value.
|
||||
*
|
||||
* @param {Object} args The function arguments.
|
||||
* @param {WakaTimeLang} args.lang The language object.
|
||||
* @param {"time" | "percent"} args.display_format The display format of the language node.
|
||||
* @returns {string} The formatted language value.
|
||||
*/
|
||||
const formatLanguageValue = ({ display_format, lang }) => {
|
||||
return display_format === "percent"
|
||||
? `${lang.percent.toFixed(2).toString()} %`
|
||||
: lang.text;
|
||||
};
|
||||
|
||||
/**
|
||||
* Create compact WakaTime layout.
|
||||
*
|
||||
* @param {Object} args The function arguments.
|
||||
* @param {WakaTimeLang} args.lang The languages array.
|
||||
* @param {number} args.x The x position of the language node.
|
||||
* @param {number} args.y The y position of the language node.
|
||||
* @param {"time" | "percent"} args.display_format The display format of the language node.
|
||||
* @returns {string} The compact layout language SVG node.
|
||||
*/
|
||||
const createCompactLangNode = ({ lang, x, y, display_format }) => {
|
||||
// @ts-ignore
|
||||
const color = languageColors[lang.name] || "#858585";
|
||||
const value = formatLanguageValue({ display_format, lang });
|
||||
|
||||
return `
|
||||
<g transform="translate(${x}, ${y})">
|
||||
<circle cx="5" cy="6" r="5" fill="${color}" />
|
||||
<text data-testid="lang-name" x="15" y="10" class='lang-name'>
|
||||
${lang.name} - ${value}
|
||||
</text>
|
||||
</g>
|
||||
`;
|
||||
};
|
||||
|
||||
/**
|
||||
* Create WakaTime language text node item.
|
||||
*
|
||||
* @param {Object} args The function arguments.
|
||||
* @param {WakaTimeLang[]} args.langs The language objects.
|
||||
* @param {number} args.y The y position of the language node.
|
||||
* @param {"time" | "percent"} args.display_format The display format of the language node.
|
||||
* @param {number} args.card_width Width in px of the card.
|
||||
* @returns {string[]} The language text node items.
|
||||
*/
|
||||
const createLanguageTextNode = ({ langs, y, display_format, card_width }) => {
|
||||
const LEFT_X = 25;
|
||||
const RIGHT_X_BASE = 230;
|
||||
const rightOffset = (card_width - DEFAULT_CARD_WIDTH) / 2;
|
||||
const RIGHT_X = RIGHT_X_BASE + rightOffset;
|
||||
|
||||
return langs.map((lang, index) => {
|
||||
const isLeft = index % 2 === 0;
|
||||
return createCompactLangNode({
|
||||
lang,
|
||||
x: isLeft ? LEFT_X : RIGHT_X,
|
||||
y: y + DEFAULT_LINE_HEIGHT * Math.floor(index / 2),
|
||||
display_format,
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Create WakaTime text item.
|
||||
*
|
||||
* @param {Object} args The function arguments.
|
||||
* @param {string} args.id The id of the text node item.
|
||||
* @param {string} args.label The label of the text node item.
|
||||
* @param {string} args.value The value of the text node item.
|
||||
* @param {number} args.index The index of the text node item.
|
||||
* @param {number} args.percent Percentage of the text node item.
|
||||
* @param {boolean=} args.hideProgress Whether to hide the progress bar.
|
||||
* @param {string} args.progressBarColor The color of the progress bar.
|
||||
* @param {string} args.progressBarBackgroundColor The color of the progress bar background.
|
||||
* @param {number} args.progressBarWidth The width of the progress bar.
|
||||
* @returns {string} The text SVG node.
|
||||
*/
|
||||
const createTextNode = ({
|
||||
id,
|
||||
label,
|
||||
value,
|
||||
index,
|
||||
percent,
|
||||
hideProgress,
|
||||
progressBarColor,
|
||||
progressBarBackgroundColor,
|
||||
progressBarWidth,
|
||||
}) => {
|
||||
const staggerDelay = (index + 3) * 150;
|
||||
const cardProgress = hideProgress
|
||||
? null
|
||||
: createProgressNode({
|
||||
x: 110,
|
||||
y: 4,
|
||||
progress: percent,
|
||||
color: progressBarColor,
|
||||
width: progressBarWidth,
|
||||
// @ts-ignore
|
||||
name: label,
|
||||
progressBarBackgroundColor,
|
||||
delay: staggerDelay + 300,
|
||||
});
|
||||
|
||||
return `
|
||||
<g class="stagger" style="animation-delay: ${staggerDelay}ms" transform="translate(25, 0)">
|
||||
<text class="stat bold" y="12.5" data-testid="${id}">${label}:</text>
|
||||
<text
|
||||
class="stat"
|
||||
x="${hideProgress ? HIDDEN_PROGRESSBAR_PADDING : PROGRESSBAR_PADDING + progressBarWidth}"
|
||||
y="12.5"
|
||||
>${value}</text>
|
||||
${cardProgress}
|
||||
</g>
|
||||
`;
|
||||
};
|
||||
|
||||
/**
|
||||
* Recalculating percentages so that, compact layout's progress bar does not break when
|
||||
* hiding languages.
|
||||
*
|
||||
* @param {WakaTimeLang[]} languages The languages array.
|
||||
* @returns {void} The recalculated languages array.
|
||||
*/
|
||||
const recalculatePercentages = (languages) => {
|
||||
const totalSum = languages.reduce(
|
||||
(totalSum, language) => totalSum + language.percent,
|
||||
0,
|
||||
);
|
||||
const weight = +(100 / totalSum).toFixed(2);
|
||||
languages.forEach((language) => {
|
||||
language.percent = +(language.percent * weight).toFixed(2);
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Retrieves CSS styles for a card.
|
||||
*
|
||||
* @param {Object} colors The colors to use for the card.
|
||||
* @param {string} colors.titleColor The title color.
|
||||
* @param {string} colors.textColor The text color.
|
||||
* @returns {string} Card CSS styles.
|
||||
*/
|
||||
const getStyles = ({
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
titleColor,
|
||||
textColor,
|
||||
}) => {
|
||||
return `
|
||||
.stat {
|
||||
font: 600 14px 'Segoe UI', Ubuntu, "Helvetica Neue", Sans-Serif; fill: ${textColor};
|
||||
}
|
||||
@supports(-moz-appearance: auto) {
|
||||
/* Selector detects Firefox */
|
||||
.stat { font-size:12px; }
|
||||
}
|
||||
.stagger {
|
||||
opacity: 0;
|
||||
animation: fadeInAnimation 0.3s ease-in-out forwards;
|
||||
}
|
||||
.not_bold { font-weight: 400 }
|
||||
.bold { font-weight: 700 }
|
||||
`;
|
||||
};
|
||||
|
||||
/**
|
||||
* Normalize incoming width (string or number) and clamp to minimum.
|
||||
*
|
||||
* @param {Object} args The function arguments.
|
||||
* @param {WakaTimeOptions["layout"] | undefined} args.layout The incoming layout value.
|
||||
* @param {number|undefined} args.value The incoming width value.
|
||||
* @returns {number} The normalized width value.
|
||||
*/
|
||||
const normalizeCardWidth = ({ value, layout }) => {
|
||||
if (value === undefined || value === null || isNaN(value)) {
|
||||
return DEFAULT_CARD_WIDTH;
|
||||
}
|
||||
return Math.max(
|
||||
layout === "compact" ? COMPACT_LAYOUT_MIN_WIDTH : MIN_CARD_WIDTH,
|
||||
value,
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* @typedef {import('../fetchers/types').WakaTimeData} WakaTimeData
|
||||
* @typedef {import('./types').WakaTimeOptions} WakaTimeOptions
|
||||
*/
|
||||
|
||||
/**
|
||||
* Renders WakaTime card.
|
||||
*
|
||||
* @param {Partial<WakaTimeData>} stats WakaTime stats.
|
||||
* @param {Partial<WakaTimeOptions>} options Card options.
|
||||
* @returns {string} WakaTime card SVG.
|
||||
*/
|
||||
const renderWakatimeCard = (stats = {}, options = { hide: [] }) => {
|
||||
let { languages = [] } = stats;
|
||||
const {
|
||||
hide_title = false,
|
||||
hide_border = false,
|
||||
card_width,
|
||||
hide,
|
||||
line_height = DEFAULT_LINE_HEIGHT,
|
||||
title_color,
|
||||
icon_color,
|
||||
text_color,
|
||||
bg_color,
|
||||
theme = "default",
|
||||
hide_progress,
|
||||
custom_title,
|
||||
locale,
|
||||
layout,
|
||||
langs_count = languages.length,
|
||||
border_radius,
|
||||
border_color,
|
||||
display_format = "time",
|
||||
disable_animations,
|
||||
} = options;
|
||||
|
||||
const normalizedWidth = normalizeCardWidth({ value: card_width, layout });
|
||||
|
||||
const shouldHideLangs = Array.isArray(hide) && hide.length > 0;
|
||||
if (shouldHideLangs) {
|
||||
const languagesToHide = new Set(hide.map((lang) => lowercaseTrim(lang)));
|
||||
languages = languages.filter(
|
||||
(lang) => !languagesToHide.has(lowercaseTrim(lang.name)),
|
||||
);
|
||||
}
|
||||
|
||||
// Since the percentages are sorted in descending order, we can just
|
||||
// slice from the beginning without sorting.
|
||||
languages = languages.slice(0, langs_count);
|
||||
recalculatePercentages(languages);
|
||||
|
||||
const i18n = new I18n({
|
||||
locale,
|
||||
translations: wakatimeCardLocales,
|
||||
});
|
||||
|
||||
const lheight = parseInt(String(line_height), 10);
|
||||
|
||||
const langsCount = clampValue(langs_count, 1, langs_count);
|
||||
|
||||
// returns theme based colors with proper overrides and defaults
|
||||
const { titleColor, textColor, iconColor, bgColor, borderColor } =
|
||||
getCardColors({
|
||||
title_color,
|
||||
icon_color,
|
||||
text_color,
|
||||
bg_color,
|
||||
border_color,
|
||||
theme,
|
||||
});
|
||||
|
||||
const filteredLanguages = languages
|
||||
.filter((language) => language.hours || language.minutes)
|
||||
.slice(0, langsCount);
|
||||
|
||||
// Calculate the card height depending on how many items there are
|
||||
// but if rank circle is visible clamp the minimum height to `150`
|
||||
let height = Math.max(45 + (filteredLanguages.length + 1) * lheight, 150);
|
||||
|
||||
const cssStyles = getStyles({
|
||||
titleColor,
|
||||
textColor,
|
||||
});
|
||||
|
||||
let finalLayout;
|
||||
|
||||
// RENDER COMPACT LAYOUT
|
||||
if (layout === "compact") {
|
||||
const width = normalizedWidth - 5;
|
||||
height =
|
||||
90 + Math.round(filteredLanguages.length / 2) * DEFAULT_LINE_HEIGHT;
|
||||
|
||||
// progressOffset holds the previous language's width and used to offset the next language
|
||||
// so that we can stack them one after another, like this: [--][----][---]
|
||||
let progressOffset = 0;
|
||||
const compactProgressBar = filteredLanguages
|
||||
.map((language) => {
|
||||
const progress =
|
||||
((width - COMPACT_LAYOUT_PROGRESSBAR_PADDING) * language.percent) /
|
||||
100;
|
||||
|
||||
// @ts-ignore
|
||||
const languageColor = languageColors[language.name] || "#858585";
|
||||
|
||||
const output = `
|
||||
<rect
|
||||
mask="url(#rect-mask)"
|
||||
data-testid="lang-progress"
|
||||
x="${progressOffset}"
|
||||
y="0"
|
||||
width="${progress}"
|
||||
height="8"
|
||||
fill="${languageColor}"
|
||||
/>
|
||||
`;
|
||||
progressOffset += progress;
|
||||
return output;
|
||||
})
|
||||
.join("");
|
||||
|
||||
finalLayout = `
|
||||
<mask id="rect-mask">
|
||||
<rect x="${COMPACT_LAYOUT_PROGRESSBAR_PADDING}" y="0" width="${width - 2 * COMPACT_LAYOUT_PROGRESSBAR_PADDING}" height="8" fill="white" rx="5" />
|
||||
</mask>
|
||||
${compactProgressBar}
|
||||
${
|
||||
filteredLanguages.length
|
||||
? createLanguageTextNode({
|
||||
y: 25,
|
||||
langs: filteredLanguages,
|
||||
display_format,
|
||||
card_width: normalizedWidth,
|
||||
}).join("")
|
||||
: noCodingActivityNode({
|
||||
// @ts-ignore
|
||||
color: textColor,
|
||||
text: stats.is_coding_activity_visible
|
||||
? stats.is_other_usage_visible
|
||||
? i18n.t("wakatimecard.nocodingactivity")
|
||||
: i18n.t("wakatimecard.nocodedetails")
|
||||
: i18n.t("wakatimecard.notpublic"),
|
||||
})
|
||||
}
|
||||
`;
|
||||
} else {
|
||||
finalLayout = flexLayout({
|
||||
items: filteredLanguages.length
|
||||
? filteredLanguages.map((language, index) => {
|
||||
return createTextNode({
|
||||
id: language.name,
|
||||
label: language.name,
|
||||
value: formatLanguageValue({ display_format, lang: language }),
|
||||
index,
|
||||
percent: language.percent,
|
||||
// @ts-ignore
|
||||
progressBarColor: titleColor,
|
||||
// @ts-ignore
|
||||
progressBarBackgroundColor:
|
||||
textColor === titleColor ? bgColor : textColor,
|
||||
hideProgress: hide_progress,
|
||||
progressBarWidth: normalizedWidth - TOTAL_TEXT_WIDTH,
|
||||
});
|
||||
})
|
||||
: [
|
||||
noCodingActivityNode({
|
||||
// @ts-ignore
|
||||
color: textColor,
|
||||
text: stats.is_coding_activity_visible
|
||||
? stats.is_other_usage_visible
|
||||
? i18n.t("wakatimecard.nocodingactivity")
|
||||
: i18n.t("wakatimecard.nocodedetails")
|
||||
: i18n.t("wakatimecard.notpublic"),
|
||||
}),
|
||||
],
|
||||
gap: lheight,
|
||||
direction: "column",
|
||||
}).join("");
|
||||
}
|
||||
|
||||
// Get title range text
|
||||
let titleText = i18n.t("wakatimecard.title");
|
||||
switch (stats.range) {
|
||||
case "last_7_days":
|
||||
titleText += ` (${i18n.t("wakatimecard.last7days")})`;
|
||||
break;
|
||||
case "last_year":
|
||||
titleText += ` (${i18n.t("wakatimecard.lastyear")})`;
|
||||
break;
|
||||
}
|
||||
|
||||
const card = new Card({
|
||||
customTitle: custom_title,
|
||||
defaultTitle: titleText,
|
||||
width: normalizedWidth,
|
||||
height,
|
||||
border_radius,
|
||||
colors: {
|
||||
titleColor,
|
||||
textColor,
|
||||
iconColor,
|
||||
bgColor,
|
||||
borderColor,
|
||||
},
|
||||
});
|
||||
|
||||
if (disable_animations) {
|
||||
card.disableAnimations();
|
||||
}
|
||||
|
||||
card.setHideBorder(hide_border);
|
||||
card.setHideTitle(hide_title);
|
||||
card.setCSS(
|
||||
`
|
||||
${cssStyles}
|
||||
@keyframes slideInAnimation {
|
||||
from {
|
||||
width: 0;
|
||||
}
|
||||
to {
|
||||
width: calc(100%-100px);
|
||||
}
|
||||
}
|
||||
@keyframes growWidthAnimation {
|
||||
from {
|
||||
width: 0;
|
||||
}
|
||||
to {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
.lang-name { font: 400 11px 'Segoe UI', Ubuntu, Sans-Serif; fill: ${textColor} }
|
||||
#rect-mask rect{
|
||||
animation: slideInAnimation 1s ease-in-out forwards;
|
||||
}
|
||||
.lang-progress{
|
||||
animation: growWidthAnimation 0.6s ease-in-out forwards;
|
||||
}
|
||||
`,
|
||||
);
|
||||
|
||||
return card.render(`
|
||||
<svg x="0" y="0" width="100%">
|
||||
${finalLayout}
|
||||
</svg>
|
||||
`);
|
||||
};
|
||||
|
||||
export { renderWakatimeCard };
|
||||
@@ -0,0 +1,275 @@
|
||||
// @ts-check
|
||||
|
||||
import { encodeHTML } from "./html.js";
|
||||
import { flexLayout } from "./render.js";
|
||||
|
||||
class Card {
|
||||
/**
|
||||
* Creates a new card instance.
|
||||
*
|
||||
* @param {object} args Card arguments.
|
||||
* @param {number=} args.width Card width.
|
||||
* @param {number=} args.height Card height.
|
||||
* @param {number=} args.border_radius Card border radius.
|
||||
* @param {string=} args.customTitle Card custom title.
|
||||
* @param {string=} args.defaultTitle Card default title.
|
||||
* @param {string=} args.titlePrefixIcon Card title prefix icon.
|
||||
* @param {object} [args.colors={}] Card colors arguments.
|
||||
* @param {string=} args.colors.titleColor Card title color.
|
||||
* @param {string=} args.colors.textColor Card text color.
|
||||
* @param {string=} args.colors.iconColor Card icon color.
|
||||
* @param {string|string[]=} args.colors.bgColor Card background color.
|
||||
* @param {string=} args.colors.borderColor Card border color.
|
||||
*/
|
||||
constructor({
|
||||
width = 100,
|
||||
height = 100,
|
||||
border_radius = 4.5,
|
||||
colors = {},
|
||||
customTitle,
|
||||
defaultTitle = "",
|
||||
titlePrefixIcon,
|
||||
}) {
|
||||
this.width = width;
|
||||
this.height = height;
|
||||
|
||||
this.hideBorder = false;
|
||||
this.hideTitle = false;
|
||||
|
||||
this.border_radius = border_radius;
|
||||
|
||||
// returns theme based colors with proper overrides and defaults
|
||||
this.colors = colors;
|
||||
this.title =
|
||||
customTitle === undefined
|
||||
? encodeHTML(defaultTitle)
|
||||
: encodeHTML(customTitle);
|
||||
|
||||
this.css = "";
|
||||
|
||||
this.paddingX = 25;
|
||||
this.paddingY = 35;
|
||||
this.titlePrefixIcon = titlePrefixIcon;
|
||||
this.animations = true;
|
||||
this.a11yTitle = "";
|
||||
this.a11yDesc = "";
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {void}
|
||||
*/
|
||||
disableAnimations() {
|
||||
this.animations = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Object} props The props object.
|
||||
* @param {string} props.title Accessibility title.
|
||||
* @param {string} props.desc Accessibility description.
|
||||
* @returns {void}
|
||||
*/
|
||||
setAccessibilityLabel({ title, desc }) {
|
||||
this.a11yTitle = title;
|
||||
this.a11yDesc = desc;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} value The CSS to add to the card.
|
||||
* @returns {void}
|
||||
*/
|
||||
setCSS(value) {
|
||||
this.css = value;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {boolean} value Whether to hide the border or not.
|
||||
* @returns {void}
|
||||
*/
|
||||
setHideBorder(value) {
|
||||
this.hideBorder = value;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {boolean} value Whether to hide the title or not.
|
||||
* @returns {void}
|
||||
*/
|
||||
setHideTitle(value) {
|
||||
this.hideTitle = value;
|
||||
if (value) {
|
||||
this.height -= 30;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} text The title to set.
|
||||
* @returns {void}
|
||||
*/
|
||||
setTitle(text) {
|
||||
this.title = text;
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {string} The rendered card title.
|
||||
*/
|
||||
renderTitle() {
|
||||
const titleText = `
|
||||
<text
|
||||
x="0"
|
||||
y="0"
|
||||
class="header"
|
||||
data-testid="header"
|
||||
>${this.title}</text>
|
||||
`;
|
||||
|
||||
const prefixIcon = `
|
||||
<svg
|
||||
class="icon"
|
||||
x="0"
|
||||
y="-13"
|
||||
viewBox="0 0 16 16"
|
||||
version="1.1"
|
||||
width="16"
|
||||
height="16"
|
||||
>
|
||||
${this.titlePrefixIcon}
|
||||
</svg>
|
||||
`;
|
||||
return `
|
||||
<g
|
||||
data-testid="card-title"
|
||||
transform="translate(${this.paddingX}, ${this.paddingY})"
|
||||
>
|
||||
${flexLayout({
|
||||
items: [this.titlePrefixIcon ? prefixIcon : "", titleText],
|
||||
gap: 25,
|
||||
}).join("")}
|
||||
</g>
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {string} The rendered card gradient.
|
||||
*/
|
||||
renderGradient() {
|
||||
if (typeof this.colors.bgColor !== "object") {
|
||||
return "";
|
||||
}
|
||||
|
||||
const gradients = this.colors.bgColor.slice(1);
|
||||
return typeof this.colors.bgColor === "object"
|
||||
? `
|
||||
<defs>
|
||||
<linearGradient
|
||||
id="gradient"
|
||||
gradientTransform="rotate(${this.colors.bgColor[0]})"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
${gradients.map((grad, index) => {
|
||||
let offset = (index * 100) / (gradients.length - 1);
|
||||
return `<stop offset="${offset}%" stop-color="#${grad}" />`;
|
||||
})}
|
||||
</linearGradient>
|
||||
</defs>
|
||||
`
|
||||
: "";
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves css animations for a card.
|
||||
*
|
||||
* @returns {string} Animation css.
|
||||
*/
|
||||
getAnimations = () => {
|
||||
return `
|
||||
/* Animations */
|
||||
@keyframes scaleInAnimation {
|
||||
from {
|
||||
transform: translate(-5px, 5px) scale(0);
|
||||
}
|
||||
to {
|
||||
transform: translate(-5px, 5px) scale(1);
|
||||
}
|
||||
}
|
||||
@keyframes fadeInAnimation {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
`;
|
||||
};
|
||||
|
||||
/**
|
||||
* @param {string} body The inner body of the card.
|
||||
* @returns {string} The rendered card.
|
||||
*/
|
||||
render(body) {
|
||||
return `
|
||||
<svg
|
||||
width="${this.width}"
|
||||
height="${this.height}"
|
||||
viewBox="0 0 ${this.width} ${this.height}"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
role="img"
|
||||
aria-labelledby="descId"
|
||||
>
|
||||
<title id="titleId">${this.a11yTitle}</title>
|
||||
<desc id="descId">${this.a11yDesc}</desc>
|
||||
<style>
|
||||
.header {
|
||||
font: 600 18px 'Segoe UI', Ubuntu, Sans-Serif;
|
||||
fill: ${this.colors.titleColor};
|
||||
animation: fadeInAnimation 0.8s ease-in-out forwards;
|
||||
}
|
||||
@supports(-moz-appearance: auto) {
|
||||
/* Selector detects Firefox */
|
||||
.header { font-size: 15.5px; }
|
||||
}
|
||||
${this.css}
|
||||
|
||||
${this.getAnimations()}
|
||||
${
|
||||
this.animations === false
|
||||
? `* { animation-duration: 0s !important; animation-delay: 0s !important; }`
|
||||
: ""
|
||||
}
|
||||
</style>
|
||||
|
||||
${this.renderGradient()}
|
||||
|
||||
<rect
|
||||
data-testid="card-bg"
|
||||
x="0.5"
|
||||
y="0.5"
|
||||
rx="${this.border_radius}"
|
||||
height="99%"
|
||||
stroke="${this.colors.borderColor}"
|
||||
width="${this.width - 1}"
|
||||
fill="${
|
||||
typeof this.colors.bgColor === "object"
|
||||
? "url(#gradient)"
|
||||
: this.colors.bgColor
|
||||
}"
|
||||
stroke-opacity="${this.hideBorder ? 0 : 1}"
|
||||
/>
|
||||
|
||||
${this.hideTitle ? "" : this.renderTitle()}
|
||||
|
||||
<g
|
||||
data-testid="main-card-body"
|
||||
transform="translate(0, ${
|
||||
this.hideTitle ? this.paddingX : this.paddingY + 20
|
||||
})"
|
||||
>
|
||||
${body}
|
||||
</g>
|
||||
</svg>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
export { Card };
|
||||
export default Card;
|
||||
@@ -0,0 +1,42 @@
|
||||
// @ts-check
|
||||
|
||||
const FALLBACK_LOCALE = "en";
|
||||
|
||||
/**
|
||||
* I18n translation class.
|
||||
*/
|
||||
class I18n {
|
||||
/**
|
||||
* Constructor.
|
||||
*
|
||||
* @param {Object} options Options.
|
||||
* @param {string=} options.locale Locale.
|
||||
* @param {any} options.translations Translations.
|
||||
*/
|
||||
constructor({ locale, translations }) {
|
||||
this.locale = locale || FALLBACK_LOCALE;
|
||||
this.translations = translations;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get translation.
|
||||
*
|
||||
* @param {string} str String to translate.
|
||||
* @returns {string} Translated string.
|
||||
*/
|
||||
t(str) {
|
||||
if (!this.translations[str]) {
|
||||
throw new Error(`${str} Translation string not found`);
|
||||
}
|
||||
|
||||
if (!this.translations[str][this.locale]) {
|
||||
throw new Error(
|
||||
`'${str}' translation not found for locale '${this.locale}'`,
|
||||
);
|
||||
}
|
||||
|
||||
return this.translations[str][this.locale];
|
||||
}
|
||||
}
|
||||
|
||||
export { I18n };
|
||||
@@ -0,0 +1,145 @@
|
||||
// @ts-check
|
||||
|
||||
import { themes } from "../themes/index.js";
|
||||
|
||||
/**
|
||||
* Checks if a string is a valid hex color.
|
||||
*
|
||||
* @param {string} hexColor String to check.
|
||||
* @returns {boolean} True if the given string is a valid hex color.
|
||||
*/
|
||||
const isValidHexColor = (hexColor) => {
|
||||
return new RegExp(
|
||||
/^([A-Fa-f0-9]{8}|[A-Fa-f0-9]{6}|[A-Fa-f0-9]{3}|[A-Fa-f0-9]{4})$/,
|
||||
).test(hexColor);
|
||||
};
|
||||
|
||||
/**
|
||||
* Check if the given string is a valid gradient.
|
||||
*
|
||||
* @param {string[]} colors Array of colors.
|
||||
* @returns {boolean} True if the given string is a valid gradient.
|
||||
*/
|
||||
const isValidGradient = (colors) => {
|
||||
return (
|
||||
colors.length > 2 &&
|
||||
colors.slice(1).every((color) => isValidHexColor(color))
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Retrieves a gradient if color has more than one valid hex codes else a single color.
|
||||
*
|
||||
* @param {string} color The color to parse.
|
||||
* @param {string | string[]} fallbackColor The fallback color.
|
||||
* @returns {string | string[]} The gradient or color.
|
||||
*/
|
||||
const fallbackColor = (color, fallbackColor) => {
|
||||
let gradient = null;
|
||||
|
||||
let colors = color ? color.split(",") : [];
|
||||
if (colors.length > 1 && isValidGradient(colors)) {
|
||||
gradient = colors;
|
||||
}
|
||||
|
||||
return (
|
||||
(gradient ? gradient : isValidHexColor(color) && `#${color}`) ||
|
||||
fallbackColor
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Object containing card colors.
|
||||
* @typedef {{
|
||||
* titleColor: string;
|
||||
* iconColor: string;
|
||||
* textColor: string;
|
||||
* bgColor: string | string[];
|
||||
* borderColor: string;
|
||||
* ringColor: string;
|
||||
* }} CardColors
|
||||
*/
|
||||
|
||||
/**
|
||||
* Returns theme based colors with proper overrides and defaults.
|
||||
*
|
||||
* @param {Object} args Function arguments.
|
||||
* @param {string=} args.title_color Card title color.
|
||||
* @param {string=} args.text_color Card text color.
|
||||
* @param {string=} args.icon_color Card icon color.
|
||||
* @param {string=} args.bg_color Card background color.
|
||||
* @param {string=} args.border_color Card border color.
|
||||
* @param {string=} args.ring_color Card ring color.
|
||||
* @param {string=} args.theme Card theme.
|
||||
* @returns {CardColors} Card colors.
|
||||
*/
|
||||
const getCardColors = ({
|
||||
title_color,
|
||||
text_color,
|
||||
icon_color,
|
||||
bg_color,
|
||||
border_color,
|
||||
ring_color,
|
||||
theme,
|
||||
}) => {
|
||||
const defaultTheme = themes["default"];
|
||||
const isThemeProvided =
|
||||
theme !== null && theme !== undefined && theme in themes;
|
||||
|
||||
// @ts-ignore
|
||||
const selectedTheme = isThemeProvided ? themes[theme] : defaultTheme;
|
||||
|
||||
const defaultBorderColor =
|
||||
"border_color" in selectedTheme
|
||||
? selectedTheme.border_color
|
||||
: // @ts-ignore
|
||||
defaultTheme.border_color;
|
||||
|
||||
// get the color provided by the user else the theme color
|
||||
// finally if both colors are invalid fallback to default theme
|
||||
const titleColor = fallbackColor(
|
||||
title_color || selectedTheme.title_color,
|
||||
"#" + defaultTheme.title_color,
|
||||
);
|
||||
|
||||
// get the color provided by the user else the theme color
|
||||
// finally if both colors are invalid we use the titleColor
|
||||
const ringColor = fallbackColor(
|
||||
// @ts-ignore
|
||||
ring_color || selectedTheme.ring_color,
|
||||
titleColor,
|
||||
);
|
||||
const iconColor = fallbackColor(
|
||||
icon_color || selectedTheme.icon_color,
|
||||
"#" + defaultTheme.icon_color,
|
||||
);
|
||||
const textColor = fallbackColor(
|
||||
text_color || selectedTheme.text_color,
|
||||
"#" + defaultTheme.text_color,
|
||||
);
|
||||
const bgColor = fallbackColor(
|
||||
bg_color || selectedTheme.bg_color,
|
||||
"#" + defaultTheme.bg_color,
|
||||
);
|
||||
|
||||
const borderColor = fallbackColor(
|
||||
border_color || defaultBorderColor,
|
||||
"#" + defaultBorderColor,
|
||||
);
|
||||
|
||||
if (
|
||||
typeof titleColor !== "string" ||
|
||||
typeof textColor !== "string" ||
|
||||
typeof ringColor !== "string" ||
|
||||
typeof iconColor !== "string" ||
|
||||
typeof borderColor !== "string"
|
||||
) {
|
||||
throw new Error(
|
||||
"Unexpected behavior, all colors except background should be string.",
|
||||
);
|
||||
}
|
||||
|
||||
return { titleColor, iconColor, textColor, bgColor, borderColor, ringColor };
|
||||
};
|
||||
|
||||
export { fallbackColor, getCardColors };
|
||||
@@ -0,0 +1,78 @@
|
||||
// @ts-check
|
||||
|
||||
/**
|
||||
* @param {string | undefined} value Comma-separated string.
|
||||
* @returns {string[] | undefined} Parsed string values.
|
||||
*/
|
||||
const parseCsv = (value) => {
|
||||
if (!value) {
|
||||
return undefined;
|
||||
}
|
||||
return value.split(",");
|
||||
};
|
||||
|
||||
/**
|
||||
* @param {Record<string, string | undefined>} env Environment variables to inspect.
|
||||
* @returns {{name: string, value: string}[]} Personal access tokens found in the environment.
|
||||
*/
|
||||
const parsePATsFromEnv = (env) => {
|
||||
return Object.keys(env)
|
||||
.filter((key) => /PAT_\d*$/.exec(key))
|
||||
.map((name) => ({
|
||||
name,
|
||||
value: env[name],
|
||||
}));
|
||||
};
|
||||
|
||||
/**
|
||||
* @returns {Record<string, string | undefined>} `process.env` if available, otherwise `{}`.
|
||||
*/
|
||||
const getDefaultEnv = () => {
|
||||
if (typeof process !== "undefined" && process?.env) {
|
||||
return process.env;
|
||||
}
|
||||
return {};
|
||||
};
|
||||
|
||||
/**
|
||||
* @param {Partial<ReturnType<typeof loadConfigFromEnv>>} config (Partial) config values to normalize.
|
||||
* @returns {{
|
||||
* whitelist: string[] | undefined,
|
||||
* gistWhitelist: string[] | undefined,
|
||||
* excludeRepositories: string[],
|
||||
* fetchMultiPageStars: string | undefined,
|
||||
* pats: {name: string, value: string}[],
|
||||
* }} Normalized config object with defaults applied.
|
||||
*/
|
||||
const normalizeConfig = (config = {}) => {
|
||||
return {
|
||||
whitelist: config.whitelist,
|
||||
gistWhitelist: config.gistWhitelist,
|
||||
excludeRepositories: config.excludeRepositories || [],
|
||||
fetchMultiPageStars: config.fetchMultiPageStars,
|
||||
pats: config.pats || [],
|
||||
};
|
||||
};
|
||||
|
||||
let currentConfig;
|
||||
|
||||
/**
|
||||
* @param {Record<string, string | undefined>} env Environment variables used to build the runtime config.
|
||||
*/
|
||||
export const loadConfigFromEnv = (env = getDefaultEnv()) => {
|
||||
const whitelist = parseCsv(env.WHITELIST);
|
||||
const gistWhitelist = parseCsv(env.GIST_WHITELIST);
|
||||
const excludeRepositories = parseCsv(env.EXCLUDE_REPO) || [];
|
||||
|
||||
currentConfig = normalizeConfig({
|
||||
whitelist,
|
||||
gistWhitelist,
|
||||
excludeRepositories,
|
||||
fetchMultiPageStars: env.FETCH_MULTI_PAGE_STARS,
|
||||
pats: parsePATsFromEnv(env),
|
||||
});
|
||||
};
|
||||
|
||||
loadConfigFromEnv();
|
||||
|
||||
export const getConfig = () => currentConfig;
|
||||
@@ -0,0 +1,9 @@
|
||||
// @ts-check
|
||||
|
||||
/**
|
||||
* Valid owner affiliations for GitHub API queries.
|
||||
* @type {Array<string>}
|
||||
*/
|
||||
const OWNER_AFFILIATIONS = ["OWNER", "COLLABORATOR", "ORGANIZATION_MEMBER"];
|
||||
|
||||
export { OWNER_AFFILIATIONS };
|
||||
@@ -0,0 +1,90 @@
|
||||
// @ts-check
|
||||
|
||||
import { OWNER_AFFILIATIONS } from "./constants.js";
|
||||
|
||||
/**
|
||||
* @type {string} A general message to ask user to try again later.
|
||||
*/
|
||||
const TRY_AGAIN_LATER = "Please try again later";
|
||||
|
||||
/**
|
||||
* @type {Object<string, string>} A map of error types to secondary error messages.
|
||||
*/
|
||||
const SECONDARY_ERROR_MESSAGES = {
|
||||
MAX_RETRY:
|
||||
"You can deploy own instance or wait until public will be no longer limited",
|
||||
NO_TOKENS:
|
||||
"Please add an env variable called PAT_1 with your GitHub API token in vercel",
|
||||
USER_NOT_FOUND: "Make sure the provided username is not an organization",
|
||||
GRAPHQL_ERROR: TRY_AGAIN_LATER,
|
||||
GITHUB_REST_API_ERROR: TRY_AGAIN_LATER,
|
||||
WAKATIME_USER_NOT_FOUND: "Make sure you have a public WakaTime profile",
|
||||
INVALID_AFFILIATION: `Invalid owner affiliations. Valid values are: ${OWNER_AFFILIATIONS.join(
|
||||
", ",
|
||||
)}`,
|
||||
};
|
||||
|
||||
/**
|
||||
* Custom error class to handle custom GRS errors.
|
||||
*/
|
||||
class CustomError extends Error {
|
||||
/**
|
||||
* Custom error constructor.
|
||||
*
|
||||
* @param {string} message Error message.
|
||||
* @param {string} type Error type.
|
||||
*/
|
||||
constructor(message, type) {
|
||||
super(message);
|
||||
this.type = type;
|
||||
this.secondaryMessage = SECONDARY_ERROR_MESSAGES[type] || type;
|
||||
}
|
||||
|
||||
static MAX_RETRY = "MAX_RETRY";
|
||||
static NO_TOKENS = "NO_TOKENS";
|
||||
static USER_NOT_FOUND = "USER_NOT_FOUND";
|
||||
static GRAPHQL_ERROR = "GRAPHQL_ERROR";
|
||||
static GITHUB_REST_API_ERROR = "GITHUB_REST_API_ERROR";
|
||||
static WAKATIME_ERROR = "WAKATIME_ERROR";
|
||||
static INVALID_AFFILIATION = "INVALID_AFFILIATION";
|
||||
}
|
||||
|
||||
/**
|
||||
* Missing query parameter class.
|
||||
*/
|
||||
class MissingParamError extends Error {
|
||||
/**
|
||||
* Missing query parameter error constructor.
|
||||
*
|
||||
* @param {string[]} missedParams An array of missing parameters names.
|
||||
* @param {string=} secondaryMessage Optional secondary message to display.
|
||||
*/
|
||||
constructor(missedParams, secondaryMessage) {
|
||||
const msg = `Missing params ${missedParams
|
||||
.map((p) => `"${p}"`)
|
||||
.join(", ")} make sure you pass the parameters in URL`;
|
||||
super(msg);
|
||||
this.missedParams = missedParams;
|
||||
this.secondaryMessage = secondaryMessage;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve secondary message from an error object.
|
||||
*
|
||||
* @param {Error} err The error object.
|
||||
* @returns {string|undefined} The secondary message if available, otherwise undefined.
|
||||
*/
|
||||
const retrieveSecondaryMessage = (err) => {
|
||||
return "secondaryMessage" in err && typeof err.secondaryMessage === "string"
|
||||
? err.secondaryMessage
|
||||
: undefined;
|
||||
};
|
||||
|
||||
export {
|
||||
CustomError,
|
||||
MissingParamError,
|
||||
SECONDARY_ERROR_MESSAGES,
|
||||
TRY_AGAIN_LATER,
|
||||
retrieveSecondaryMessage,
|
||||
};
|
||||
@@ -0,0 +1,91 @@
|
||||
// @ts-check
|
||||
|
||||
import wrap from "word-wrap";
|
||||
|
||||
import { encodeHTML } from "./html.js";
|
||||
|
||||
/**
|
||||
* Retrieves num with suffix k(thousands) precise to given decimal places.
|
||||
*
|
||||
* @param {number} num The number to format.
|
||||
* @param {number=} precision The number of decimal places to include.
|
||||
* @returns {string|number} The formatted number.
|
||||
*/
|
||||
const kFormatter = (num, precision) => {
|
||||
const abs = Math.abs(num);
|
||||
const sign = Math.sign(num);
|
||||
|
||||
if (typeof precision === "number" && !isNaN(precision)) {
|
||||
return (sign * (abs / 1000)).toFixed(precision) + "k";
|
||||
}
|
||||
|
||||
if (abs < 1000) {
|
||||
return sign * abs;
|
||||
}
|
||||
|
||||
return sign * parseFloat((abs / 1000).toFixed(1)) + "k";
|
||||
};
|
||||
|
||||
/**
|
||||
* Convert bytes to a human-readable string representation.
|
||||
*
|
||||
* @param {number} bytes The number of bytes to convert.
|
||||
* @returns {string} The human-readable representation of bytes.
|
||||
* @throws {Error} If bytes is negative or too large.
|
||||
*/
|
||||
const formatBytes = (bytes) => {
|
||||
if (bytes < 0) {
|
||||
throw new Error("Bytes must be a non-negative number");
|
||||
}
|
||||
|
||||
if (bytes === 0) {
|
||||
return "0 B";
|
||||
}
|
||||
|
||||
const sizes = ["B", "KB", "MB", "GB", "TB", "PB", "EB"];
|
||||
const base = 1024;
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(base));
|
||||
|
||||
if (i >= sizes.length) {
|
||||
throw new Error("Bytes is too large to convert to a human-readable string");
|
||||
}
|
||||
|
||||
return `${(bytes / Math.pow(base, i)).toFixed(1)} ${sizes[i]}`;
|
||||
};
|
||||
|
||||
/**
|
||||
* Split text over multiple lines based on the card width.
|
||||
*
|
||||
* @param {string} text Text to split.
|
||||
* @param {number} width Line width in number of characters.
|
||||
* @param {number} maxLines Maximum number of lines.
|
||||
* @returns {string[]} Array of lines.
|
||||
*/
|
||||
const wrapTextMultiline = (text, width = 59, maxLines = 3) => {
|
||||
const fullWidthComma = ",";
|
||||
const encoded = encodeHTML(text);
|
||||
const isChinese = encoded.includes(fullWidthComma);
|
||||
|
||||
let wrapped;
|
||||
|
||||
if (isChinese) {
|
||||
wrapped = encoded.split(fullWidthComma); // Chinese full punctuation
|
||||
} else {
|
||||
wrapped = wrap(encoded, {
|
||||
width,
|
||||
}).split("\n"); // Split wrapped lines to get an array of lines
|
||||
}
|
||||
|
||||
const lines = wrapped.map((line) => line.trim()).slice(0, maxLines); // Only consider maxLines lines
|
||||
|
||||
// Add "..." to the last line if the text exceeds maxLines
|
||||
if (wrapped.length > maxLines) {
|
||||
lines[maxLines - 1] += "...";
|
||||
}
|
||||
|
||||
// Remove empty lines if text fits in less than maxLines lines
|
||||
const multiLineText = lines.filter(Boolean);
|
||||
return multiLineText;
|
||||
};
|
||||
|
||||
export { kFormatter, formatBytes, wrapTextMultiline };
|
||||
@@ -0,0 +1,22 @@
|
||||
// @ts-check
|
||||
|
||||
/**
|
||||
* Encode string as HTML.
|
||||
*
|
||||
* @see https://stackoverflow.com/a/48073476/10629172
|
||||
*
|
||||
* @param {string} str String to encode.
|
||||
* @returns {string} Encoded string.
|
||||
*/
|
||||
const encodeHTML = (str) => {
|
||||
return (
|
||||
str
|
||||
.replace(/[\u00A0-\u9999<>&](?!#)/gim, (i) => {
|
||||
return "&#" + i.charCodeAt(0) + ";";
|
||||
})
|
||||
// eslint-disable-next-line no-control-regex
|
||||
.replace(/\u0008/gim, "")
|
||||
);
|
||||
};
|
||||
|
||||
export { encodeHTML };
|
||||
@@ -0,0 +1,21 @@
|
||||
// @ts-check
|
||||
|
||||
import axios from "axios";
|
||||
|
||||
/**
|
||||
* Send GraphQL request to GitHub API.
|
||||
*
|
||||
* @param {import('axios').AxiosRequestConfig['data']} data Request data.
|
||||
* @param {import('axios').AxiosRequestConfig['headers']} headers Request headers.
|
||||
* @returns {Promise<any>} Request response.
|
||||
*/
|
||||
const request = (data, headers) => {
|
||||
return axios({
|
||||
url: "https://api.github.com/graphql",
|
||||
method: "post",
|
||||
headers,
|
||||
data,
|
||||
});
|
||||
};
|
||||
|
||||
export { request };
|
||||
@@ -0,0 +1,55 @@
|
||||
// @ts-check
|
||||
|
||||
const icons = {
|
||||
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"/>`,
|
||||
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"/>`,
|
||||
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"/>`,
|
||||
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" />`,
|
||||
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" />`,
|
||||
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"/>`,
|
||||
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"/>`,
|
||||
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"/>`,
|
||||
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>`,
|
||||
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"/>`,
|
||||
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" />`,
|
||||
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" />`,
|
||||
comments: `<path d="M1 2.75C1 1.784 1.784 1 2.75 1h10.5c.966 0 1.75.784 1.75 1.75v7.5A1.75 1.75 0 0 1 13.25 12H9.06l-2.573 2.573A1.458 1.458 0 0 1 4 13.543V12H2.75A1.75 1.75 0 0 1 1 10.25Zm1.75-.25a.25.25 0 0 0-.25.25v7.5c0 .138.112.25.25.25h2a.75.75 0 0 1 .75.75v2.19l2.72-2.72a.749.749 0 0 1 .53-.22h4.5a.25.25 0 0 0 .25-.25v-7.5a.25.25 0 0 0-.25-.25Z" />`,
|
||||
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" />`,
|
||||
};
|
||||
|
||||
/**
|
||||
* Get rank icon
|
||||
*
|
||||
* @param {string} rankIcon - The rank icon type.
|
||||
* @param {string} rankLevel - The rank level.
|
||||
* @param {number} percentile - The rank percentile.
|
||||
* @returns {string} - The SVG code of the rank icon
|
||||
*/
|
||||
const rankIcon = (rankIcon, rankLevel, percentile) => {
|
||||
switch (rankIcon) {
|
||||
case "github":
|
||||
return `
|
||||
<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">
|
||||
<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>
|
||||
</svg>
|
||||
`;
|
||||
case "percentile":
|
||||
return `
|
||||
<text x="-5" y="-12" alignment-baseline="central" dominant-baseline="central" text-anchor="middle" data-testid="percentile-top-header" class="rank-percentile-header">
|
||||
Top
|
||||
</text>
|
||||
<text x="-5" y="12" alignment-baseline="central" dominant-baseline="central" text-anchor="middle" data-testid="percentile-rank-value" class="rank-percentile-text">
|
||||
${percentile.toFixed(1)}%
|
||||
</text>
|
||||
`;
|
||||
case "default":
|
||||
default:
|
||||
return `
|
||||
<text x="-5" y="3" alignment-baseline="central" dominant-baseline="central" text-anchor="middle" data-testid="level-rank-icon">
|
||||
${rankLevel}
|
||||
</text>
|
||||
`;
|
||||
}
|
||||
};
|
||||
|
||||
export { icons, rankIcon };
|
||||
@@ -0,0 +1,666 @@
|
||||
{
|
||||
"1C Enterprise": "#814CCC",
|
||||
"2-Dimensional Array": "#38761D",
|
||||
"4D": "#004289",
|
||||
"ABAP": "#E8274B",
|
||||
"ABAP CDS": "#555e25",
|
||||
"AGS Script": "#B9D9FF",
|
||||
"AIDL": "#34EB6B",
|
||||
"AL": "#3AA2B5",
|
||||
"ALGOL": "#D1E0DB",
|
||||
"AMPL": "#E6EFBB",
|
||||
"ANTLR": "#9DC3FF",
|
||||
"API Blueprint": "#2ACCA8",
|
||||
"APL": "#5A8164",
|
||||
"ASP.NET": "#9400ff",
|
||||
"ATS": "#1ac620",
|
||||
"ActionScript": "#882B0F",
|
||||
"Ada": "#02f88c",
|
||||
"Adblock Filter List": "#800000",
|
||||
"Adobe Font Metrics": "#fa0f00",
|
||||
"Agda": "#315665",
|
||||
"Aiken": "#640ff8",
|
||||
"Alloy": "#64C800",
|
||||
"Alpine Abuild": "#0D597F",
|
||||
"Altium Designer": "#A89663",
|
||||
"AngelScript": "#C7D7DC",
|
||||
"Answer Set Programming": "#A9CC29",
|
||||
"Ant Build System": "#A9157E",
|
||||
"Antlers": "#ff269e",
|
||||
"ApacheConf": "#d12127",
|
||||
"Apex": "#1797c0",
|
||||
"Apollo Guidance Computer": "#0B3D91",
|
||||
"AppleScript": "#101F1F",
|
||||
"Arc": "#aa2afe",
|
||||
"AsciiDoc": "#73a0c5",
|
||||
"AspectJ": "#a957b0",
|
||||
"Assembly": "#6E4C13",
|
||||
"Astro": "#ff5a03",
|
||||
"Asymptote": "#ff0000",
|
||||
"Augeas": "#9CC134",
|
||||
"AutoHotkey": "#6594b9",
|
||||
"AutoIt": "#1C3552",
|
||||
"Avro IDL": "#0040FF",
|
||||
"Awk": "#c30e9b",
|
||||
"B (Formal Method)": "#8aa8c5",
|
||||
"B4X": "#00e4ff",
|
||||
"BASIC": "#ff0000",
|
||||
"BQN": "#2b7067",
|
||||
"Ballerina": "#FF5000",
|
||||
"Batchfile": "#C1F12E",
|
||||
"Beef": "#a52f4e",
|
||||
"Berry": "#15A13C",
|
||||
"BibTeX": "#778899",
|
||||
"Bicep": "#519aba",
|
||||
"Bikeshed": "#5562ac",
|
||||
"Bison": "#6A463F",
|
||||
"BitBake": "#00bce4",
|
||||
"Blade": "#f7523f",
|
||||
"BlitzBasic": "#00FFAE",
|
||||
"BlitzMax": "#cd6400",
|
||||
"Bluespec": "#12223c",
|
||||
"Bluespec BH": "#12223c",
|
||||
"Boo": "#d4bec1",
|
||||
"Boogie": "#c80fa0",
|
||||
"Brainfuck": "#2F2530",
|
||||
"BrighterScript": "#66AABB",
|
||||
"Brightscript": "#662D91",
|
||||
"Browserslist": "#ffd539",
|
||||
"Bru": "#F4AA41",
|
||||
"BuildStream": "#006bff",
|
||||
"C": "#555555",
|
||||
"C#": "#178600",
|
||||
"C++": "#f34b7d",
|
||||
"C3": "#2563eb",
|
||||
"CAP CDS": "#0092d1",
|
||||
"CLIPS": "#00A300",
|
||||
"CMake": "#DA3434",
|
||||
"COLLADA": "#F1A42B",
|
||||
"CQL": "#006091",
|
||||
"CSON": "#244776",
|
||||
"CSS": "#663399",
|
||||
"CSV": "#237346",
|
||||
"CUE": "#5886E1",
|
||||
"CWeb": "#00007a",
|
||||
"Cabal Config": "#483465",
|
||||
"Caddyfile": "#22b638",
|
||||
"Cadence": "#00ef8b",
|
||||
"Cairo": "#ff4a48",
|
||||
"Cairo Zero": "#ff4a48",
|
||||
"CameLIGO": "#3be133",
|
||||
"Cangjie": "#00868B",
|
||||
"Cap'n Proto": "#c42727",
|
||||
"Carbon": "#222222",
|
||||
"Ceylon": "#dfa535",
|
||||
"Chapel": "#8dc63f",
|
||||
"ChucK": "#3f8000",
|
||||
"Circom": "#707575",
|
||||
"Cirru": "#ccccff",
|
||||
"Clarion": "#db901e",
|
||||
"Clarity": "#5546ff",
|
||||
"Classic ASP": "#6a40fd",
|
||||
"Clean": "#3F85AF",
|
||||
"Click": "#E4E6F3",
|
||||
"Clojure": "#db5855",
|
||||
"Closure Templates": "#0d948f",
|
||||
"Cloud Firestore Security Rules": "#FFA000",
|
||||
"Clue": "#0009b5",
|
||||
"CodeQL": "#140f46",
|
||||
"CoffeeScript": "#244776",
|
||||
"ColdFusion": "#ed2cd6",
|
||||
"ColdFusion CFC": "#ed2cd6",
|
||||
"Common Lisp": "#3fb68b",
|
||||
"Common Workflow Language": "#B5314C",
|
||||
"Component Pascal": "#B0CE4E",
|
||||
"Cooklang": "#E15A29",
|
||||
"Crystal": "#000100",
|
||||
"Csound": "#1a1a1a",
|
||||
"Csound Document": "#1a1a1a",
|
||||
"Csound Score": "#1a1a1a",
|
||||
"Cuda": "#3A4E3A",
|
||||
"Curry": "#531242",
|
||||
"Cylc": "#00b3fd",
|
||||
"Cypher": "#34c0eb",
|
||||
"Cython": "#fedf5b",
|
||||
"D": "#ba595e",
|
||||
"D2": "#526ee8",
|
||||
"DM": "#447265",
|
||||
"Dafny": "#FFEC25",
|
||||
"Darcs Patch": "#8eff23",
|
||||
"Dart": "#00B4AB",
|
||||
"Daslang": "#d3d3d3",
|
||||
"DataWeave": "#003a52",
|
||||
"Debian Package Control File": "#D70751",
|
||||
"DenizenScript": "#FBEE96",
|
||||
"Dhall": "#dfafff",
|
||||
"DirectX 3D File": "#aace60",
|
||||
"Dockerfile": "#384d54",
|
||||
"Dogescript": "#cca760",
|
||||
"Dotenv": "#e5d559",
|
||||
"Dune": "#89421e",
|
||||
"Dylan": "#6c616e",
|
||||
"E": "#ccce35",
|
||||
"ECL": "#8a1267",
|
||||
"ECLiPSe": "#001d9d",
|
||||
"EJS": "#a91e50",
|
||||
"EQ": "#a78649",
|
||||
"Earthly": "#2af0ff",
|
||||
"Easybuild": "#069406",
|
||||
"Ecere Projects": "#913960",
|
||||
"Ecmarkup": "#eb8131",
|
||||
"Edge": "#0dffe0",
|
||||
"EdgeQL": "#31A7FF",
|
||||
"EditorConfig": "#fff1f2",
|
||||
"Eiffel": "#4d6977",
|
||||
"Elixir": "#6e4a7e",
|
||||
"Elm": "#60B5CC",
|
||||
"Elvish": "#55BB55",
|
||||
"Elvish Transcript": "#55BB55",
|
||||
"Emacs Lisp": "#c065db",
|
||||
"EmberScript": "#FFF4F3",
|
||||
"Erlang": "#B83998",
|
||||
"Euphoria": "#FF790B",
|
||||
"F#": "#b845fc",
|
||||
"F*": "#572e30",
|
||||
"FIGlet Font": "#FFDDBB",
|
||||
"FIRRTL": "#2f632f",
|
||||
"FLUX": "#88ccff",
|
||||
"Factor": "#636746",
|
||||
"Fancy": "#7b9db4",
|
||||
"Fantom": "#14253c",
|
||||
"Faust": "#c37240",
|
||||
"Fennel": "#fff3d7",
|
||||
"Filebench WML": "#F6B900",
|
||||
"FlatBuffers": "#ed284a",
|
||||
"Flix": "#d44a45",
|
||||
"Fluent": "#ffcc33",
|
||||
"Forth": "#341708",
|
||||
"Fortran": "#4d41b1",
|
||||
"Fortran Free Form": "#4d41b1",
|
||||
"FreeBASIC": "#141AC9",
|
||||
"FreeMarker": "#0050b2",
|
||||
"Frege": "#00cafe",
|
||||
"Futhark": "#5f021f",
|
||||
"G-code": "#D08CF2",
|
||||
"GAML": "#FFC766",
|
||||
"GAMS": "#f49a22",
|
||||
"GAP": "#0000cc",
|
||||
"GCC Machine Description": "#FFCFAB",
|
||||
"GDScript": "#355570",
|
||||
"GDShader": "#478CBF",
|
||||
"GEDCOM": "#003058",
|
||||
"GLSL": "#5686a5",
|
||||
"GSC": "#FF6800",
|
||||
"Game Maker Language": "#71b417",
|
||||
"Gemfile.lock": "#701516",
|
||||
"Gemini": "#ff6900",
|
||||
"Genero 4gl": "#63408e",
|
||||
"Genero per": "#d8df39",
|
||||
"Genie": "#fb855d",
|
||||
"Genshi": "#951531",
|
||||
"Gentoo Ebuild": "#9400ff",
|
||||
"Gentoo Eclass": "#9400ff",
|
||||
"Gerber Image": "#d20b00",
|
||||
"Gherkin": "#5B2063",
|
||||
"Git Attributes": "#F44D27",
|
||||
"Git Commit": "#F44D27",
|
||||
"Git Config": "#F44D27",
|
||||
"Git Revision List": "#F44D27",
|
||||
"Gleam": "#ffaff3",
|
||||
"Glimmer JS": "#F5835F",
|
||||
"Glimmer TS": "#3178c6",
|
||||
"Glyph": "#c1ac7f",
|
||||
"Gnuplot": "#f0a9f0",
|
||||
"Go": "#00ADD8",
|
||||
"Go Checksums": "#00ADD8",
|
||||
"Go Module": "#00ADD8",
|
||||
"Go Template": "#00ADD8",
|
||||
"Go Workspace": "#00ADD8",
|
||||
"Godot Resource": "#355570",
|
||||
"Golo": "#88562A",
|
||||
"Gosu": "#82937f",
|
||||
"Grace": "#615f8b",
|
||||
"Gradle": "#02303a",
|
||||
"Gradle Kotlin DSL": "#02303a",
|
||||
"Grammatical Framework": "#ff0000",
|
||||
"GraphQL": "#e10098",
|
||||
"Graphviz (DOT)": "#2596be",
|
||||
"Groovy": "#4298b8",
|
||||
"Groovy Server Pages": "#4298b8",
|
||||
"HAProxy": "#106da9",
|
||||
"HCL": "#844FBA",
|
||||
"HIP": "#4F3A4F",
|
||||
"HLSL": "#aace60",
|
||||
"HOCON": "#9ff8ee",
|
||||
"HTML": "#e34c26",
|
||||
"HTML+ECR": "#2e1052",
|
||||
"HTML+EEX": "#6e4a7e",
|
||||
"HTML+ERB": "#701516",
|
||||
"HTML+PHP": "#4f5d95",
|
||||
"HTML+Razor": "#512be4",
|
||||
"HTTP": "#005C9C",
|
||||
"HXML": "#f68712",
|
||||
"Hack": "#878787",
|
||||
"Haml": "#ece2a9",
|
||||
"Handlebars": "#f7931e",
|
||||
"Harbour": "#0e60e3",
|
||||
"Hare": "#9d7424",
|
||||
"Haskell": "#5e5086",
|
||||
"Haxe": "#df7900",
|
||||
"HiveQL": "#dce200",
|
||||
"HolyC": "#ffefaf",
|
||||
"Hosts File": "#308888",
|
||||
"Hurl": "#FF0288",
|
||||
"Hy": "#7790B2",
|
||||
"IDL": "#a3522f",
|
||||
"IGOR Pro": "#0000cc",
|
||||
"INI": "#d1dbe0",
|
||||
"ISPC": "#2D68B1",
|
||||
"Idris": "#b30000",
|
||||
"Ignore List": "#000000",
|
||||
"ImageJ Macro": "#99AAFF",
|
||||
"Imba": "#16cec6",
|
||||
"Inno Setup": "#264b99",
|
||||
"Io": "#a9188d",
|
||||
"Ioke": "#078193",
|
||||
"Isabelle": "#FEFE00",
|
||||
"Isabelle ROOT": "#FEFE00",
|
||||
"J": "#9EEDFF",
|
||||
"JAR Manifest": "#b07219",
|
||||
"JCL": "#d90e09",
|
||||
"JFlex": "#DBCA00",
|
||||
"JSON": "#292929",
|
||||
"JSON with Comments": "#292929",
|
||||
"JSON5": "#267CB9",
|
||||
"JSONLD": "#0c479c",
|
||||
"JSONiq": "#40d47e",
|
||||
"Jac": "#FC792D",
|
||||
"Jai": "#ab8b4b",
|
||||
"Janet": "#0886a5",
|
||||
"Jasmin": "#d03600",
|
||||
"Java": "#b07219",
|
||||
"Java Properties": "#2A6277",
|
||||
"Java Server Pages": "#2A6277",
|
||||
"Java Template Engine": "#2A6277",
|
||||
"JavaScript": "#f1e05a",
|
||||
"JavaScript+ERB": "#f1e05a",
|
||||
"Jest Snapshot": "#15c213",
|
||||
"JetBrains MPS": "#21D789",
|
||||
"Jinja": "#a52a22",
|
||||
"Jison": "#56b3cb",
|
||||
"Jison Lex": "#56b3cb",
|
||||
"Jolie": "#843179",
|
||||
"Jsonnet": "#0064bd",
|
||||
"Julia": "#a270ba",
|
||||
"Julia REPL": "#a270ba",
|
||||
"Jupyter Notebook": "#DA5B0B",
|
||||
"Just": "#384d54",
|
||||
"KCL": "#7ABABF",
|
||||
"KDL": "#ffb3b3",
|
||||
"KFramework": "#4195c5",
|
||||
"KRL": "#28430A",
|
||||
"Kaitai Struct": "#773b37",
|
||||
"KakouneScript": "#6f8042",
|
||||
"KerboScript": "#41adf0",
|
||||
"KiCad Layout": "#2f4aab",
|
||||
"KiCad Legacy Layout": "#2f4aab",
|
||||
"KiCad Schematic": "#2f4aab",
|
||||
"KoLmafia ASH": "#B9D9B9",
|
||||
"Koka": "#215166",
|
||||
"Kotlin": "#A97BFF",
|
||||
"LFE": "#4C3023",
|
||||
"LLVM": "#185619",
|
||||
"LOLCODE": "#cc9900",
|
||||
"LSL": "#3d9970",
|
||||
"LabVIEW": "#fede06",
|
||||
"Lambdapi": "#8027a3",
|
||||
"Langium": "#2c8c87",
|
||||
"Lark": "#2980B9",
|
||||
"Lasso": "#999999",
|
||||
"Latte": "#f2a542",
|
||||
"Leo": "#C4FFC2",
|
||||
"Less": "#1d365d",
|
||||
"Lex": "#DBCA00",
|
||||
"LigoLANG": "#0e74ff",
|
||||
"LilyPond": "#9ccc7c",
|
||||
"Liquid": "#67b8de",
|
||||
"Liquidsoap": "#990066",
|
||||
"Literate Agda": "#315665",
|
||||
"Literate CoffeeScript": "#244776",
|
||||
"Literate Haskell": "#5e5086",
|
||||
"LiveCode Script": "#0c5ba5",
|
||||
"LiveScript": "#499886",
|
||||
"Logtalk": "#295b9a",
|
||||
"LookML": "#652B81",
|
||||
"Lua": "#000080",
|
||||
"Luau": "#00A2FF",
|
||||
"M3U": "#179C7D",
|
||||
"MATLAB": "#e16737",
|
||||
"MAXScript": "#00a6a6",
|
||||
"MDX": "#fcb32c",
|
||||
"MLIR": "#5EC8DB",
|
||||
"MQL4": "#62A8D6",
|
||||
"MQL5": "#4A76B8",
|
||||
"MTML": "#b7e1f4",
|
||||
"Macaulay2": "#d8ffff",
|
||||
"Makefile": "#427819",
|
||||
"Mako": "#7e858d",
|
||||
"Markdown": "#083fa1",
|
||||
"Marko": "#42bff2",
|
||||
"Mask": "#f97732",
|
||||
"Mathematical Programming System": "#0530ad",
|
||||
"Max": "#c4a79c",
|
||||
"MeTTa": "#6a5acd",
|
||||
"Mercury": "#ff2b2b",
|
||||
"Mermaid": "#ff3670",
|
||||
"Meson": "#007800",
|
||||
"Metal": "#8f14e9",
|
||||
"MiniYAML": "#ff1111",
|
||||
"MiniZinc": "#06a9e6",
|
||||
"Mint": "#02b046",
|
||||
"Mirah": "#c7a938",
|
||||
"Modelica": "#de1d31",
|
||||
"Modula-2": "#10253f",
|
||||
"Modula-3": "#223388",
|
||||
"Mojo": "#ff4c1f",
|
||||
"Monkey C": "#8D6747",
|
||||
"MoonBit": "#b92381",
|
||||
"MoonScript": "#ff4585",
|
||||
"Motoko": "#fbb03b",
|
||||
"Motorola 68K Assembly": "#005daa",
|
||||
"Move": "#4a137a",
|
||||
"Mustache": "#724b3b",
|
||||
"NCL": "#28431f",
|
||||
"NMODL": "#00356B",
|
||||
"NPM Config": "#cb3837",
|
||||
"NWScript": "#111522",
|
||||
"Nasal": "#1d2c4e",
|
||||
"Nearley": "#990000",
|
||||
"Nemerle": "#3d3c6e",
|
||||
"NetLinx": "#0aa0ff",
|
||||
"NetLinx+ERB": "#747faa",
|
||||
"NetLogo": "#ff6375",
|
||||
"NewLisp": "#87AED7",
|
||||
"Nextflow": "#3ac486",
|
||||
"Nginx": "#009639",
|
||||
"Nickel": "#E0C3FC",
|
||||
"Nim": "#ffc200",
|
||||
"Nit": "#009917",
|
||||
"Nix": "#7e7eff",
|
||||
"Noir": "#2f1f49",
|
||||
"Nu": "#c9df40",
|
||||
"NumPy": "#9C8AF9",
|
||||
"Nunjucks": "#3d8137",
|
||||
"Nushell": "#4E9906",
|
||||
"OASv2-json": "#85ea2d",
|
||||
"OASv2-yaml": "#85ea2d",
|
||||
"OASv3-json": "#85ea2d",
|
||||
"OASv3-yaml": "#85ea2d",
|
||||
"OCaml": "#ef7a08",
|
||||
"OMNeT++ MSG": "#a0e0a0",
|
||||
"OMNeT++ NED": "#08607c",
|
||||
"ObjectScript": "#424893",
|
||||
"Objective-C": "#438eff",
|
||||
"Objective-C++": "#6866fb",
|
||||
"Objective-J": "#ff0c5a",
|
||||
"Odin": "#60AFFE",
|
||||
"Omgrofl": "#cabbff",
|
||||
"Opal": "#f7ede0",
|
||||
"Open Policy Agent": "#7d9199",
|
||||
"OpenAPI Specification v2": "#85ea2d",
|
||||
"OpenAPI Specification v3": "#85ea2d",
|
||||
"OpenCL": "#ed2e2d",
|
||||
"OpenEdge ABL": "#5ce600",
|
||||
"OpenQASM": "#AA70FF",
|
||||
"OpenSCAD": "#e5cd45",
|
||||
"Option List": "#476732",
|
||||
"Org": "#77aa99",
|
||||
"OverpassQL": "#cce2aa",
|
||||
"Oxygene": "#cdd0e3",
|
||||
"Oz": "#fab738",
|
||||
"P4": "#7055b5",
|
||||
"PDDL": "#0d00ff",
|
||||
"PEG.js": "#234d6b",
|
||||
"PHP": "#4F5D95",
|
||||
"PLSQL": "#dad8d8",
|
||||
"PLpgSQL": "#336790",
|
||||
"POV-Ray SDL": "#6bac65",
|
||||
"Pact": "#F7A8B8",
|
||||
"Pan": "#cc0000",
|
||||
"Papyrus": "#6600cc",
|
||||
"Parrot": "#f3ca0a",
|
||||
"Pascal": "#E3F171",
|
||||
"Pawn": "#dbb284",
|
||||
"Pep8": "#C76F5B",
|
||||
"Perl": "#0298c3",
|
||||
"PicoLisp": "#6067af",
|
||||
"PigLatin": "#fcd7de",
|
||||
"Pike": "#005390",
|
||||
"Pip Requirements": "#FFD343",
|
||||
"Pkl": "#6b9543",
|
||||
"PlantUML": "#fbbd16",
|
||||
"PogoScript": "#d80074",
|
||||
"Polar": "#ae81ff",
|
||||
"Portugol": "#f8bd00",
|
||||
"PostCSS": "#dc3a0c",
|
||||
"PostScript": "#da291c",
|
||||
"PowerBuilder": "#8f0f8d",
|
||||
"PowerShell": "#012456",
|
||||
"Praat": "#c8506d",
|
||||
"Prisma": "#0c344b",
|
||||
"Processing": "#0096D8",
|
||||
"Procfile": "#3B2F63",
|
||||
"Prolog": "#74283c",
|
||||
"Promela": "#de0000",
|
||||
"Propeller Spin": "#7fa2a7",
|
||||
"Pug": "#a86454",
|
||||
"Puppet": "#302B6D",
|
||||
"PureBasic": "#5a6986",
|
||||
"PureScript": "#1D222D",
|
||||
"Pyret": "#ee1e10",
|
||||
"Python": "#3572A5",
|
||||
"Python console": "#3572A5",
|
||||
"Python traceback": "#3572A5",
|
||||
"Q#": "#fed659",
|
||||
"QML": "#44a51c",
|
||||
"Qt Script": "#00b841",
|
||||
"Quake": "#882233",
|
||||
"QuakeC": "#975777",
|
||||
"QuickBASIC": "#008080",
|
||||
"R": "#198CE7",
|
||||
"RAML": "#77d9fb",
|
||||
"RAScript": "#2C97FA",
|
||||
"RBS": "#701516",
|
||||
"RDoc": "#701516",
|
||||
"REXX": "#d90e09",
|
||||
"RMarkdown": "#198ce7",
|
||||
"RON": "#a62c00",
|
||||
"ROS Interface": "#22314e",
|
||||
"RPGLE": "#2BDE21",
|
||||
"RUNOFF": "#665a4e",
|
||||
"Racket": "#3c5caa",
|
||||
"Ragel": "#9d5200",
|
||||
"Raku": "#0000fb",
|
||||
"Rascal": "#fffaa0",
|
||||
"ReScript": "#ed5051",
|
||||
"Reason": "#ff5847",
|
||||
"ReasonLIGO": "#ff5847",
|
||||
"Rebol": "#358a5b",
|
||||
"Record Jar": "#0673ba",
|
||||
"Red": "#f50000",
|
||||
"Regular Expression": "#009a00",
|
||||
"Ren'Py": "#ff7f7f",
|
||||
"Rez": "#FFDAB3",
|
||||
"Ring": "#2D54CB",
|
||||
"Riot": "#A71E49",
|
||||
"RobotFramework": "#00c0b5",
|
||||
"Roc": "#7c38f5",
|
||||
"Rocq Prover": "#d0b68c",
|
||||
"Roff": "#ecdebe",
|
||||
"Roff Manpage": "#ecdebe",
|
||||
"Rouge": "#cc0088",
|
||||
"RouterOS Script": "#DE3941",
|
||||
"Ruby": "#701516",
|
||||
"Rust": "#dea584",
|
||||
"SAS": "#B34936",
|
||||
"SCSS": "#c6538c",
|
||||
"SPARQL": "#0C4597",
|
||||
"SQF": "#3F3F3F",
|
||||
"SQL": "#e38c00",
|
||||
"SQLPL": "#e38c00",
|
||||
"SRecode Template": "#348a34",
|
||||
"STL": "#373b5e",
|
||||
"SVG": "#ff9900",
|
||||
"Sail": "#259dd5",
|
||||
"SaltStack": "#646464",
|
||||
"Sass": "#a53b70",
|
||||
"Scala": "#c22d40",
|
||||
"Scaml": "#bd181a",
|
||||
"Scenic": "#fdc700",
|
||||
"Scheme": "#1e4aec",
|
||||
"Scilab": "#ca0f21",
|
||||
"Self": "#0579aa",
|
||||
"ShaderLab": "#222c37",
|
||||
"Shell": "#89e051",
|
||||
"ShellCheck Config": "#cecfcb",
|
||||
"Shen": "#120F14",
|
||||
"Simple File Verification": "#C9BFED",
|
||||
"Singularity": "#64E6AD",
|
||||
"Slang": "#1fbec9",
|
||||
"Slash": "#007eff",
|
||||
"Slice": "#003fa2",
|
||||
"Slim": "#2b2b2b",
|
||||
"Slint": "#2379F4",
|
||||
"SmPL": "#c94949",
|
||||
"Smalltalk": "#596706",
|
||||
"Smarty": "#f0c040",
|
||||
"Smithy": "#c44536",
|
||||
"Snakemake": "#419179",
|
||||
"Solidity": "#AA6746",
|
||||
"SourcePawn": "#f69e1d",
|
||||
"Squirrel": "#800000",
|
||||
"Stan": "#b2011d",
|
||||
"Standard ML": "#dc566d",
|
||||
"Starlark": "#76d275",
|
||||
"Stata": "#1a5f91",
|
||||
"StringTemplate": "#3fb34f",
|
||||
"Stylus": "#ff6347",
|
||||
"SubRip Text": "#9e0101",
|
||||
"SugarSS": "#2fcc9f",
|
||||
"SuperCollider": "#46390b",
|
||||
"SurrealQL": "#ff00a0",
|
||||
"Survex data": "#ffcc99",
|
||||
"Svelte": "#ff3e00",
|
||||
"Sway": "#00F58C",
|
||||
"Sweave": "#198ce7",
|
||||
"Swift": "#F05138",
|
||||
"SystemVerilog": "#DAE1C2",
|
||||
"TI Program": "#A0AA87",
|
||||
"TL-Verilog": "#C40023",
|
||||
"TLA": "#4b0079",
|
||||
"TMDL": "#f0c913",
|
||||
"TOML": "#9c4221",
|
||||
"TSQL": "#e38c00",
|
||||
"TSV": "#237346",
|
||||
"TSX": "#3178c6",
|
||||
"TXL": "#0178b8",
|
||||
"Tact": "#48b5ff",
|
||||
"Talon": "#333333",
|
||||
"Tcl": "#e4cc98",
|
||||
"TeX": "#3D6117",
|
||||
"Teal": "#00B1BC",
|
||||
"Terra": "#00004c",
|
||||
"Terraform Template": "#7b42bb",
|
||||
"TextGrid": "#c8506d",
|
||||
"TextMate Properties": "#df66e4",
|
||||
"Textile": "#ffe7ac",
|
||||
"Thrift": "#D12127",
|
||||
"Toit": "#c2c9fb",
|
||||
"Tor Config": "#59316b",
|
||||
"Tree-sitter Query": "#8ea64c",
|
||||
"Turing": "#cf142b",
|
||||
"Twig": "#c1d026",
|
||||
"TypeScript": "#3178c6",
|
||||
"TypeSpec": "#4A3665",
|
||||
"Typst": "#239dad",
|
||||
"Unified Parallel C": "#4e3617",
|
||||
"Unity3D Asset": "#222c37",
|
||||
"Uno": "#9933cc",
|
||||
"UnrealScript": "#a54c4d",
|
||||
"Untyped Plutus Core": "#36adbd",
|
||||
"UrWeb": "#ccccee",
|
||||
"V": "#4f87c4",
|
||||
"VBA": "#867db1",
|
||||
"VBScript": "#15dcdc",
|
||||
"VCL": "#148AA8",
|
||||
"VHDL": "#adb2cb",
|
||||
"Vala": "#a56de2",
|
||||
"Valve Data Format": "#f26025",
|
||||
"Velocity Template Language": "#507cff",
|
||||
"Vento": "#ff0080",
|
||||
"Verilog": "#b2b7f8",
|
||||
"Vim Help File": "#199f4b",
|
||||
"Vim Script": "#199f4b",
|
||||
"Vim Snippet": "#199f4b",
|
||||
"Visual Basic .NET": "#945db7",
|
||||
"Visual Basic 6.0": "#2c6353",
|
||||
"Volt": "#1F1F1F",
|
||||
"Vue": "#41b883",
|
||||
"Vyper": "#9F4CF2",
|
||||
"WDL": "#42f1f4",
|
||||
"WGSL": "#1a5e9a",
|
||||
"Web Ontology Language": "#5b70bd",
|
||||
"WebAssembly": "#04133b",
|
||||
"WebAssembly Interface Type": "#6250e7",
|
||||
"Whiley": "#d5c397",
|
||||
"Wikitext": "#fc5757",
|
||||
"Windows Registry Entries": "#52d5ff",
|
||||
"Witcher Script": "#ff0000",
|
||||
"Wolfram Language": "#dd1100",
|
||||
"Wollok": "#a23738",
|
||||
"World of Warcraft Addon Data": "#f7e43f",
|
||||
"Wren": "#383838",
|
||||
"X10": "#4B6BEF",
|
||||
"XC": "#99DA07",
|
||||
"XML": "#0060ac",
|
||||
"XML Property List": "#0060ac",
|
||||
"XQuery": "#5232e7",
|
||||
"XSLT": "#EB8CEB",
|
||||
"Xmake": "#22a079",
|
||||
"Xojo": "#81bd41",
|
||||
"Xonsh": "#285EEF",
|
||||
"Xtend": "#24255d",
|
||||
"YAML": "#cb171e",
|
||||
"YARA": "#220000",
|
||||
"YASnippet": "#32AB90",
|
||||
"Yacc": "#4B6C4B",
|
||||
"Yul": "#794932",
|
||||
"ZAP": "#0d665e",
|
||||
"ZIL": "#dc75e5",
|
||||
"ZenScript": "#00BCD1",
|
||||
"Zephir": "#118f9e",
|
||||
"Zig": "#ec915c",
|
||||
"Zimpl": "#d67711",
|
||||
"Zmodel": "#ff7100",
|
||||
"crontab": "#ead7ac",
|
||||
"eC": "#913960",
|
||||
"fish": "#4aae47",
|
||||
"hoon": "#00b171",
|
||||
"iCalendar": "#ec564c",
|
||||
"jq": "#c7254e",
|
||||
"kvlang": "#1da6e0",
|
||||
"mIRC Script": "#3d57c3",
|
||||
"mcfunction": "#E22837",
|
||||
"mdsvex": "#5f9ea0",
|
||||
"mupad": "#244963",
|
||||
"nanorc": "#2d004d",
|
||||
"nesC": "#94B0C7",
|
||||
"ooc": "#b0b77e",
|
||||
"q": "#0040cd",
|
||||
"reStructuredText": "#141414",
|
||||
"sed": "#64b970",
|
||||
"templ": "#66D0DD",
|
||||
"vCard": "#ee2647",
|
||||
"wisp": "#7582D1",
|
||||
"xBase": "#403a40"
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
// @ts-check
|
||||
/**
|
||||
* Return console instance based on the environment.
|
||||
*
|
||||
* @type {Console | {log: () => void, error: () => void}}
|
||||
*/
|
||||
const logger = console;
|
||||
|
||||
export { logger };
|
||||
@@ -0,0 +1,172 @@
|
||||
// @ts-check
|
||||
|
||||
import toEmoji from "emoji-name-map";
|
||||
|
||||
import { OWNER_AFFILIATIONS } from "./constants.js";
|
||||
import { CustomError } from "./error.js";
|
||||
|
||||
/**
|
||||
* Returns boolean if value is either "true" or "false" else the value as it is.
|
||||
*
|
||||
* @param {string | boolean} value The value to parse.
|
||||
* @returns {boolean | undefined } The parsed value.
|
||||
*/
|
||||
const parseBoolean = (value) => {
|
||||
if (typeof value === "boolean") {
|
||||
return value;
|
||||
}
|
||||
|
||||
if (typeof value === "string") {
|
||||
if (value.toLowerCase() === "true") {
|
||||
return true;
|
||||
} else if (value.toLowerCase() === "false") {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
/**
|
||||
* Parse string to array of strings.
|
||||
*
|
||||
* @param {string} str The string to parse.
|
||||
* @returns {string[]} The array of strings.
|
||||
*/
|
||||
const parseArray = (str) => {
|
||||
if (!str) {
|
||||
return [];
|
||||
}
|
||||
return str.split(",");
|
||||
};
|
||||
|
||||
/**
|
||||
* Clamp the given number between the given range.
|
||||
*
|
||||
* @param {number} number The number to clamp.
|
||||
* @param {number} min The minimum value.
|
||||
* @param {number} max The maximum value.
|
||||
* @returns {number} The clamped number.
|
||||
*/
|
||||
const clampValue = (number, min, max) => {
|
||||
// @ts-ignore
|
||||
if (Number.isNaN(parseInt(number, 10))) {
|
||||
return min;
|
||||
}
|
||||
return Math.max(min, Math.min(number, max));
|
||||
};
|
||||
|
||||
/**
|
||||
* Lowercase and trim string.
|
||||
*
|
||||
* @param {string} name String to lowercase and trim.
|
||||
* @returns {string} Lowercased and trimmed string.
|
||||
*/
|
||||
const lowercaseTrim = (name) => name.toLowerCase().trim();
|
||||
|
||||
/**
|
||||
* Split array of languages in two columns.
|
||||
*
|
||||
* @template T Language object.
|
||||
* @param {Array<T>} arr Array of languages.
|
||||
* @param {number} perChunk Number of languages per column.
|
||||
* @returns {Array<T>} Array of languages split in two columns.
|
||||
*/
|
||||
const chunkArray = (arr, perChunk) => {
|
||||
return arr.reduce((resultArray, item, index) => {
|
||||
const chunkIndex = Math.floor(index / perChunk);
|
||||
|
||||
if (!resultArray[chunkIndex]) {
|
||||
// @ts-ignore
|
||||
resultArray[chunkIndex] = []; // start a new chunk
|
||||
}
|
||||
|
||||
// @ts-ignore
|
||||
resultArray[chunkIndex].push(item);
|
||||
|
||||
return resultArray;
|
||||
}, []);
|
||||
};
|
||||
|
||||
/**
|
||||
* Parse emoji from string.
|
||||
*
|
||||
* @param {string} str String to parse emoji from.
|
||||
* @returns {string} String with emoji parsed.
|
||||
*/
|
||||
const parseEmojis = (str) => {
|
||||
if (!str) {
|
||||
throw new Error("[parseEmoji]: str argument not provided");
|
||||
}
|
||||
return str.replace(/:\w+:/gm, (emoji) => {
|
||||
return toEmoji.get(emoji) || "";
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Get diff in minutes between two dates.
|
||||
*
|
||||
* @param {Date} d1 First date.
|
||||
* @param {Date} d2 Second date.
|
||||
* @returns {number} Number of minutes between the two dates.
|
||||
*/
|
||||
const dateDiff = (d1, d2) => {
|
||||
const date1 = new Date(d1);
|
||||
const date2 = new Date(d2);
|
||||
const diff = date1.getTime() - date2.getTime();
|
||||
return Math.round(diff / (1000 * 60));
|
||||
};
|
||||
|
||||
/**
|
||||
* Parse owner affiliations.
|
||||
*
|
||||
* @param {string[]} affiliations input affiliations to be parsed.
|
||||
* @returns {string[]} Parsed affiliations.
|
||||
*
|
||||
* @throws {CustomError} If affiliations contains invalid values.
|
||||
*/
|
||||
const parseOwnerAffiliations = (affiliations) => {
|
||||
// Set default value for ownerAffiliations.
|
||||
// NOTE: Done here since parseArray() will always return an empty array even nothing
|
||||
//was specified.
|
||||
affiliations =
|
||||
affiliations && affiliations.length > 0
|
||||
? affiliations.map((affiliation) => affiliation.toUpperCase())
|
||||
: ["OWNER"];
|
||||
|
||||
// Check if ownerAffiliations contains valid values.
|
||||
if (
|
||||
affiliations.some(
|
||||
(affiliation) => !OWNER_AFFILIATIONS.includes(affiliation),
|
||||
)
|
||||
) {
|
||||
throw new CustomError(
|
||||
"Invalid query parameter",
|
||||
CustomError.INVALID_AFFILIATION,
|
||||
);
|
||||
}
|
||||
return affiliations;
|
||||
};
|
||||
|
||||
const buildSearchFilter = (repos = [], owners = []) => {
|
||||
let repoFilter =
|
||||
Array.isArray(repos) && repos.length > 0
|
||||
? repos.map((r) => `repo:${r} `).join("")
|
||||
: "";
|
||||
let orgFilter =
|
||||
Array.isArray(owners) && owners.length > 0
|
||||
? owners.map((o) => `owner:${o} `).join("")
|
||||
: "";
|
||||
return repoFilter + orgFilter;
|
||||
};
|
||||
|
||||
export {
|
||||
parseBoolean,
|
||||
parseArray,
|
||||
clampValue,
|
||||
lowercaseTrim,
|
||||
chunkArray,
|
||||
parseEmojis,
|
||||
dateDiff,
|
||||
parseOwnerAffiliations,
|
||||
buildSearchFilter,
|
||||
};
|
||||
@@ -0,0 +1,238 @@
|
||||
// @ts-check
|
||||
|
||||
import { getCardColors } from "./color.js";
|
||||
import { SECONDARY_ERROR_MESSAGES, TRY_AGAIN_LATER } from "./error.js";
|
||||
import { encodeHTML } from "./html.js";
|
||||
import { clampValue } from "./ops.js";
|
||||
|
||||
/**
|
||||
* Auto layout utility, allows us to layout things vertically or horizontally with
|
||||
* proper gaping.
|
||||
*
|
||||
* @param {object} props Function properties.
|
||||
* @param {string[]} props.items Array of items to layout.
|
||||
* @param {number} props.gap Gap between items.
|
||||
* @param {"column" | "row"=} props.direction Direction to layout items.
|
||||
* @param {number[]=} props.sizes Array of sizes for each item.
|
||||
* @returns {string[]} Array of items with proper layout.
|
||||
*/
|
||||
const flexLayout = ({ items, gap, direction, sizes = [] }) => {
|
||||
let lastSize = 0;
|
||||
// filter() for filtering out empty strings
|
||||
return items.filter(Boolean).map((item, i) => {
|
||||
const size = sizes[i] || 0;
|
||||
let transform = `translate(${lastSize}, 0)`;
|
||||
if (direction === "column") {
|
||||
transform = `translate(0, ${lastSize})`;
|
||||
}
|
||||
lastSize += size + gap;
|
||||
return `<g transform="${transform}">${item}</g>`;
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Creates a node to display the primary programming language of the repository/gist.
|
||||
*
|
||||
* @param {string} langName Language name.
|
||||
* @param {string} langColor Language color.
|
||||
* @returns {string} Language display SVG object.
|
||||
*/
|
||||
const createLanguageNode = (langName, langColor) => {
|
||||
return `
|
||||
<g data-testid="primary-lang">
|
||||
<circle data-testid="lang-color" cx="0" cy="-5" r="6" fill="${langColor}" />
|
||||
<text data-testid="lang-name" class="gray" x="15">${langName}</text>
|
||||
</g>
|
||||
`;
|
||||
};
|
||||
|
||||
/**
|
||||
* Create a node to indicate progress in percentage along a horizontal line.
|
||||
*
|
||||
* @param {Object} params Object that contains the createProgressNode parameters.
|
||||
* @param {number} params.x X-axis position.
|
||||
* @param {number} params.y Y-axis position.
|
||||
* @param {number} params.width Width of progress bar.
|
||||
* @param {string} params.color Progress color.
|
||||
* @param {number} params.progress Progress value.
|
||||
* @param {string} params.progressBarBackgroundColor Progress bar bg color.
|
||||
* @param {number} params.delay Delay before animation starts.
|
||||
* @returns {string} Progress node.
|
||||
*/
|
||||
const createProgressNode = ({
|
||||
x,
|
||||
y,
|
||||
width,
|
||||
color,
|
||||
progress,
|
||||
progressBarBackgroundColor,
|
||||
delay,
|
||||
}) => {
|
||||
const progressPercentage = clampValue(progress, 2, 100);
|
||||
|
||||
return `
|
||||
<svg width="${width}" x="${x}" y="${y}">
|
||||
<rect data-testid="progress-background" rx="5" ry="5" x="0" y="0" width="${width}" height="8" fill="${progressBarBackgroundColor}"></rect>
|
||||
<svg data-testid="lang-progress" width="${progressPercentage}%">
|
||||
<rect
|
||||
height="8"
|
||||
fill="${color}"
|
||||
rx="5" ry="5" x="0" y="0"
|
||||
class="lang-progress"
|
||||
style="animation-delay: ${delay}ms;"
|
||||
/>
|
||||
</svg>
|
||||
</svg>
|
||||
`;
|
||||
};
|
||||
|
||||
/**
|
||||
* Creates an icon with label to display repository/gist stats like forks, stars, etc.
|
||||
*
|
||||
* @param {string} icon The icon to display.
|
||||
* @param {number|string} label The label to display.
|
||||
* @param {string} testid The testid to assign to the label.
|
||||
* @param {number} iconSize The size of the icon.
|
||||
* @returns {string} Icon with label SVG object.
|
||||
*/
|
||||
const iconWithLabel = (icon, label, testid, iconSize) => {
|
||||
if (typeof label === "number" && label <= 0) {
|
||||
return "";
|
||||
}
|
||||
const iconSvg = `
|
||||
<svg
|
||||
class="icon"
|
||||
y="-12"
|
||||
viewBox="0 0 16 16"
|
||||
version="1.1"
|
||||
width="${iconSize}"
|
||||
height="${iconSize}"
|
||||
>
|
||||
${icon}
|
||||
</svg>
|
||||
`;
|
||||
const text = `<text data-testid="${testid}" class="gray">${label}</text>`;
|
||||
return flexLayout({ items: [iconSvg, text], gap: 20 }).join("");
|
||||
};
|
||||
|
||||
// Script parameters.
|
||||
const ERROR_CARD_LENGTH = 576.5;
|
||||
|
||||
const UPSTREAM_API_ERRORS = [
|
||||
TRY_AGAIN_LATER,
|
||||
SECONDARY_ERROR_MESSAGES.MAX_RETRY,
|
||||
];
|
||||
|
||||
/**
|
||||
* Renders error message on the card.
|
||||
*
|
||||
* @param {object} args Function arguments.
|
||||
* @param {string} args.message Main error message.
|
||||
* @param {string} [args.secondaryMessage=""] The secondary error message.
|
||||
* @param {object} [args.renderOptions={}] Render options.
|
||||
* @param {string=} args.renderOptions.title_color Card title color.
|
||||
* @param {string=} args.renderOptions.text_color Card text color.
|
||||
* @param {string=} args.renderOptions.bg_color Card background color.
|
||||
* @param {string=} args.renderOptions.border_color Card border color.
|
||||
* @param {Parameters<typeof getCardColors>[0]["theme"]=} args.renderOptions.theme Card theme.
|
||||
* @param {boolean=} args.renderOptions.show_repo_link Whether to show repo link or not.
|
||||
* @returns {string} The SVG markup.
|
||||
*/
|
||||
const renderError = ({
|
||||
message,
|
||||
secondaryMessage = "",
|
||||
renderOptions = {},
|
||||
}) => {
|
||||
const {
|
||||
title_color,
|
||||
text_color,
|
||||
bg_color,
|
||||
border_color,
|
||||
theme = "default",
|
||||
show_repo_link = true,
|
||||
} = renderOptions;
|
||||
|
||||
// returns theme based colors with proper overrides and defaults
|
||||
const { titleColor, textColor, bgColor, borderColor } = getCardColors({
|
||||
title_color,
|
||||
text_color,
|
||||
icon_color: "",
|
||||
bg_color,
|
||||
border_color,
|
||||
ring_color: "",
|
||||
theme,
|
||||
});
|
||||
|
||||
return `
|
||||
<svg width="${ERROR_CARD_LENGTH}" height="120" viewBox="0 0 ${ERROR_CARD_LENGTH} 120" fill="${bgColor}" xmlns="http://www.w3.org/2000/svg">
|
||||
<style>
|
||||
.text { font: 600 16px 'Segoe UI', Ubuntu, Sans-Serif; fill: ${titleColor} }
|
||||
.small { font: 600 12px 'Segoe UI', Ubuntu, Sans-Serif; fill: ${textColor} }
|
||||
.gray { fill: #858585 }
|
||||
</style>
|
||||
<rect x="0.5" y="0.5" width="${
|
||||
ERROR_CARD_LENGTH - 1
|
||||
}" height="99%" rx="4.5" fill="${bgColor}" stroke="${borderColor}"/>
|
||||
<text x="25" y="45" class="text">Something went wrong!${
|
||||
UPSTREAM_API_ERRORS.includes(secondaryMessage) || !show_repo_link
|
||||
? ""
|
||||
: " file an issue at https://tinyurl.com/github-stats"
|
||||
}</text>
|
||||
<text data-testid="message" x="25" y="55" class="text small">
|
||||
<tspan x="25" dy="18">${encodeHTML(message)}</tspan>
|
||||
<tspan x="25" dy="18" class="gray">${secondaryMessage}</tspan>
|
||||
</text>
|
||||
</svg>
|
||||
`;
|
||||
};
|
||||
|
||||
/**
|
||||
* Retrieve text length.
|
||||
*
|
||||
* @see https://stackoverflow.com/a/48172630/10629172
|
||||
* @param {string} str String to measure.
|
||||
* @param {number} fontSize Font size.
|
||||
* @returns {number} Text length.
|
||||
*/
|
||||
const measureText = (str, fontSize = 10) => {
|
||||
// prettier-ignore
|
||||
const widths = [
|
||||
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
|
||||
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
|
||||
0, 0, 0, 0, 0.2796875, 0.2765625,
|
||||
0.3546875, 0.5546875, 0.5546875, 0.8890625, 0.665625, 0.190625,
|
||||
0.3328125, 0.3328125, 0.3890625, 0.5828125, 0.2765625, 0.3328125,
|
||||
0.2765625, 0.3015625, 0.5546875, 0.5546875, 0.5546875, 0.5546875,
|
||||
0.5546875, 0.5546875, 0.5546875, 0.5546875, 0.5546875, 0.5546875,
|
||||
0.2765625, 0.2765625, 0.584375, 0.5828125, 0.584375, 0.5546875,
|
||||
1.0140625, 0.665625, 0.665625, 0.721875, 0.721875, 0.665625,
|
||||
0.609375, 0.7765625, 0.721875, 0.2765625, 0.5, 0.665625,
|
||||
0.5546875, 0.8328125, 0.721875, 0.7765625, 0.665625, 0.7765625,
|
||||
0.721875, 0.665625, 0.609375, 0.721875, 0.665625, 0.94375,
|
||||
0.665625, 0.665625, 0.609375, 0.2765625, 0.3546875, 0.2765625,
|
||||
0.4765625, 0.5546875, 0.3328125, 0.5546875, 0.5546875, 0.5,
|
||||
0.5546875, 0.5546875, 0.2765625, 0.5546875, 0.5546875, 0.221875,
|
||||
0.240625, 0.5, 0.221875, 0.8328125, 0.5546875, 0.5546875,
|
||||
0.5546875, 0.5546875, 0.3328125, 0.5, 0.2765625, 0.5546875,
|
||||
0.5, 0.721875, 0.5, 0.5, 0.5, 0.3546875, 0.259375, 0.353125, 0.5890625,
|
||||
];
|
||||
|
||||
const avg = 0.5279276315789471;
|
||||
return (
|
||||
str
|
||||
.split("")
|
||||
.map((c) =>
|
||||
c.charCodeAt(0) < widths.length ? widths[c.charCodeAt(0)] : avg,
|
||||
)
|
||||
.reduce((cur, acc) => acc + cur) * fontSize
|
||||
);
|
||||
};
|
||||
|
||||
export {
|
||||
renderError,
|
||||
createLanguageNode,
|
||||
createProgressNode,
|
||||
iconWithLabel,
|
||||
flexLayout,
|
||||
measureText,
|
||||
};
|
||||
@@ -0,0 +1,104 @@
|
||||
// @ts-check
|
||||
|
||||
import { getConfig } from "./config.js";
|
||||
import { CustomError } from "./error.js";
|
||||
import { logger } from "./log.js";
|
||||
|
||||
/**
|
||||
* Returns a random integer from 0 (inclusive) to `max` (exclusive).
|
||||
*
|
||||
* The value is generated using `Math.random()` and uniformly distributed
|
||||
* across the range.
|
||||
*
|
||||
* @param {number} max The upper bound (exclusive). Must be a positive number.
|
||||
*
|
||||
* @returns {number} A random integer `n` such that `0 <= n < max`.
|
||||
*/
|
||||
function getRandomInt(max) {
|
||||
return Math.floor(Math.random() * max);
|
||||
}
|
||||
|
||||
/**
|
||||
* @typedef {import("axios").AxiosResponse} AxiosResponse Axios response.
|
||||
* @typedef {(variables: any, token: string, retriesForTests?: number) => Promise<AxiosResponse>} FetcherFunction Fetcher function.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Try to execute the fetcher function until it succeeds or the max number of retries is reached.
|
||||
*
|
||||
* @param {FetcherFunction} fetcher The fetcher function.
|
||||
* @param {any} variables Object with arguments to pass to the fetcher function.
|
||||
* @param {string | null} pat Optional PAT override.
|
||||
* @returns {Promise<any>} The response from the fetcher function.
|
||||
*/
|
||||
const retryer = async (fetcher, variables, pat = null) => {
|
||||
let PATs;
|
||||
if (pat) {
|
||||
PATs = [{ name: "user PAT from database", value: pat }];
|
||||
} else {
|
||||
PATs = getConfig().pats;
|
||||
}
|
||||
|
||||
if (!PATs.length) {
|
||||
throw new CustomError("No GitHub API tokens found", CustomError.NO_TOKENS);
|
||||
}
|
||||
const startPAT = getRandomInt(PATs.length);
|
||||
|
||||
for (let retries = 0; retries < PATs.length; retries++) {
|
||||
const currentPAT = PATs[(startPAT + retries) % PATs.length];
|
||||
|
||||
try {
|
||||
let response = await fetcher(
|
||||
variables,
|
||||
// @ts-ignore
|
||||
currentPAT.value,
|
||||
// used in tests for faking rate limit
|
||||
retries,
|
||||
);
|
||||
|
||||
// react on both type and message-based rate-limit signals.
|
||||
// https://github.com/anuraghazra/github-readme-stats/issues/4425
|
||||
const errors = response?.data?.errors;
|
||||
const errorType = errors?.[0]?.type;
|
||||
const errorMsg = errors?.[0]?.message || "";
|
||||
const isRateLimited =
|
||||
(errors && errorType === "RATE_LIMITED") ||
|
||||
/rate limit/i.test(errorMsg);
|
||||
|
||||
if (isRateLimited) {
|
||||
logger.log(`${currentPAT.name} Failed due to rate limiting`);
|
||||
} else {
|
||||
return response;
|
||||
}
|
||||
} catch (err) {
|
||||
/** @type {any} */
|
||||
const e = err;
|
||||
|
||||
// network/unexpected error → let caller treat as failure
|
||||
if (!e?.response) {
|
||||
throw e;
|
||||
}
|
||||
|
||||
// prettier-ignore
|
||||
// also checking for bad credentials if any tokens gets invalidated
|
||||
const isBadCredential =
|
||||
e?.response?.data?.message === "Bad credentials";
|
||||
const isAccountSuspended =
|
||||
e?.response?.data?.message === "Sorry. Your account was suspended.";
|
||||
|
||||
if (isBadCredential || isAccountSuspended) {
|
||||
logger.log(`${currentPAT.name} Failed due to bad credentials`);
|
||||
} else {
|
||||
// HTTP error with a response → return it for caller-side handling
|
||||
return e.response;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
throw new CustomError(
|
||||
"Downtime due to GitHub API rate limiting",
|
||||
CustomError.MAX_RETRY,
|
||||
);
|
||||
};
|
||||
|
||||
export { retryer };
|
||||
@@ -0,0 +1,114 @@
|
||||
// @ts-check
|
||||
|
||||
import { MissingParamError } from "../common/error.js";
|
||||
import { request } from "../common/http.js";
|
||||
import { retryer } from "../common/retryer.js";
|
||||
|
||||
const QUERY = `
|
||||
query gistInfo($gistName: String!) {
|
||||
viewer {
|
||||
gist(name: $gistName) {
|
||||
description
|
||||
owner {
|
||||
login
|
||||
}
|
||||
stargazerCount
|
||||
forks {
|
||||
totalCount
|
||||
}
|
||||
files {
|
||||
name
|
||||
language {
|
||||
name
|
||||
}
|
||||
size
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
/**
|
||||
* Gist data fetcher.
|
||||
*
|
||||
* @param {object} variables Fetcher variables.
|
||||
* @param {string} token GitHub token.
|
||||
* @returns {Promise<import('axios').AxiosResponse>} The response.
|
||||
*/
|
||||
const fetcher = async (variables, token) => {
|
||||
return await request(
|
||||
{ query: QUERY, variables },
|
||||
{ Authorization: `token ${token}` },
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* @typedef {{ name: string; language: { name: string; }, size: number }} GistFile Gist file.
|
||||
*/
|
||||
|
||||
/**
|
||||
* This function calculates the primary language of a gist by files size.
|
||||
*
|
||||
* @param {GistFile[]} files Files.
|
||||
* @returns {string} Primary language.
|
||||
*/
|
||||
const calculatePrimaryLanguage = (files) => {
|
||||
/** @type {Record<string, number>} */
|
||||
const languages = {};
|
||||
|
||||
for (const file of files) {
|
||||
if (file.language) {
|
||||
if (languages[file.language.name]) {
|
||||
languages[file.language.name] += file.size;
|
||||
} else {
|
||||
languages[file.language.name] = file.size;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let primaryLanguage = Object.keys(languages)[0];
|
||||
for (const language in languages) {
|
||||
if (languages[language] > languages[primaryLanguage]) {
|
||||
primaryLanguage = language;
|
||||
}
|
||||
}
|
||||
|
||||
return primaryLanguage;
|
||||
};
|
||||
|
||||
/**
|
||||
* @typedef {import('./types').GistData} GistData Gist data.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Fetch GitHub gist information by given username and ID.
|
||||
*
|
||||
* @param {string} id GitHub gist ID.
|
||||
* @param {string | null} pat Optional PAT override.
|
||||
* @returns {Promise<GistData>} Gist data.
|
||||
*/
|
||||
const fetchGist = async (id, pat = null) => {
|
||||
if (!id) {
|
||||
throw new MissingParamError(["id"], "/api/gist?id=GIST_ID");
|
||||
}
|
||||
const res = await retryer(fetcher, { gistName: id }, pat);
|
||||
if (res.data.errors) {
|
||||
throw new Error(res.data.errors[0].message);
|
||||
}
|
||||
if (!res.data.data.viewer.gist) {
|
||||
throw new Error("Gist not found");
|
||||
}
|
||||
const data = res.data.data.viewer.gist;
|
||||
return {
|
||||
name: data.files[Object.keys(data.files)[0]].name,
|
||||
nameWithOwner: `${data.owner.login}/${
|
||||
data.files[Object.keys(data.files)[0]].name
|
||||
}`,
|
||||
description: data.description,
|
||||
language: calculatePrimaryLanguage(data.files),
|
||||
starsCount: data.stargazerCount,
|
||||
forksCount: data.forks.totalCount,
|
||||
};
|
||||
};
|
||||
|
||||
export { fetchGist };
|
||||
@@ -0,0 +1,165 @@
|
||||
// @ts-check
|
||||
|
||||
import { MissingParamError } from "../common/error.js";
|
||||
import { request } from "../common/http.js";
|
||||
import { retryer } from "../common/retryer.js";
|
||||
|
||||
import { fetchRepoUserStats } from "./stats.js";
|
||||
|
||||
/**
|
||||
* Repo data fetcher.
|
||||
*
|
||||
* @param {object} variables Fetcher variables.
|
||||
* @param {string} token GitHub token.
|
||||
* @returns {Promise<import('axios').AxiosResponse>} The response.
|
||||
*/
|
||||
const fetcher = (variables, token) => {
|
||||
return request(
|
||||
{
|
||||
query: `
|
||||
fragment RepoInfo on Repository {
|
||||
name
|
||||
nameWithOwner
|
||||
isPrivate
|
||||
isArchived
|
||||
isTemplate
|
||||
stargazers {
|
||||
totalCount
|
||||
}
|
||||
description
|
||||
primaryLanguage {
|
||||
color
|
||||
id
|
||||
name
|
||||
}
|
||||
forkCount
|
||||
}
|
||||
query getRepo($login: String!, $repo: String!) {
|
||||
user(login: $login) {
|
||||
repository(name: $repo) {
|
||||
...RepoInfo
|
||||
}
|
||||
}
|
||||
organization(login: $login) {
|
||||
repository(name: $repo) {
|
||||
...RepoInfo
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
variables,
|
||||
},
|
||||
{
|
||||
Authorization: `token ${token}`,
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
const urlExample = "/api/pin?username=USERNAME&repo=REPO_NAME";
|
||||
|
||||
/**
|
||||
* @typedef {import("./types").RepositoryData} RepositoryData Repository data.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Fetch repository data.
|
||||
*
|
||||
* @param {string} username GitHub username.
|
||||
* @param {string} reponame GitHub repository name.
|
||||
* @returns {Promise<RepositoryData>} Repository data.
|
||||
*/
|
||||
const fetchRepo = async (
|
||||
username,
|
||||
reponame,
|
||||
include_prs_authored = false,
|
||||
include_prs_commented = false,
|
||||
include_prs_reviewed = false,
|
||||
include_issues_authored = false,
|
||||
include_issues_commented = false,
|
||||
pat = null,
|
||||
) => {
|
||||
let owner = username;
|
||||
if (reponame && reponame.includes("/")) {
|
||||
const [parsed_owner, parsed_repo] = reponame.split("/");
|
||||
owner = parsed_owner;
|
||||
reponame = parsed_repo;
|
||||
}
|
||||
|
||||
if (owner && !username) {
|
||||
username = owner;
|
||||
}
|
||||
if (username && !owner) {
|
||||
owner = username;
|
||||
}
|
||||
if (!username && !reponame) {
|
||||
throw new MissingParamError(["username", "repo"], urlExample);
|
||||
}
|
||||
if (!username) {
|
||||
throw new MissingParamError(["username"], urlExample);
|
||||
}
|
||||
if (!reponame) {
|
||||
throw new MissingParamError(["repo"], urlExample);
|
||||
}
|
||||
|
||||
let res = await retryer(fetcher, { login: owner, repo: reponame }, pat);
|
||||
|
||||
const data = res.data.data;
|
||||
|
||||
if (!data.user && !data.organization) {
|
||||
throw new Error("Not found");
|
||||
}
|
||||
|
||||
const isUser = data.organization === null && data.user;
|
||||
const isOrg = data.user === null && data.organization;
|
||||
|
||||
if (isUser) {
|
||||
if (!data.user.repository || data.user.repository.isPrivate) {
|
||||
throw new Error("User Repository Not found");
|
||||
}
|
||||
let repoUserStats = await fetchRepoUserStats(
|
||||
username,
|
||||
[owner + "/" + reponame],
|
||||
[],
|
||||
include_prs_authored,
|
||||
include_prs_commented,
|
||||
include_prs_reviewed,
|
||||
include_issues_authored,
|
||||
include_issues_commented,
|
||||
pat,
|
||||
);
|
||||
return {
|
||||
...repoUserStats,
|
||||
...data.user.repository,
|
||||
starCount: data.user.repository.stargazers.totalCount,
|
||||
};
|
||||
}
|
||||
|
||||
if (isOrg) {
|
||||
if (
|
||||
!data.organization.repository ||
|
||||
data.organization.repository.isPrivate
|
||||
) {
|
||||
throw new Error("Organization Repository Not found");
|
||||
}
|
||||
let repoUserStats = await fetchRepoUserStats(
|
||||
username,
|
||||
[owner + "/" + reponame],
|
||||
[],
|
||||
include_prs_authored,
|
||||
include_prs_commented,
|
||||
include_prs_reviewed,
|
||||
include_issues_authored,
|
||||
include_issues_commented,
|
||||
pat,
|
||||
);
|
||||
return {
|
||||
...repoUserStats,
|
||||
...data.organization.repository,
|
||||
starCount: data.organization.repository.stargazers.totalCount,
|
||||
};
|
||||
}
|
||||
|
||||
throw new Error("Unexpected behavior");
|
||||
};
|
||||
|
||||
export { fetchRepo };
|
||||
@@ -0,0 +1,478 @@
|
||||
// @ts-check
|
||||
|
||||
import axios from "axios";
|
||||
import githubUsernameRegex from "github-username-regex";
|
||||
|
||||
import { calculateRank } from "../calculateRank.js";
|
||||
import { getConfig } from "../common/config.js";
|
||||
import { CustomError, MissingParamError } from "../common/error.js";
|
||||
import { wrapTextMultiline } from "../common/fmt.js";
|
||||
import { request } from "../common/http.js";
|
||||
import { logger } from "../common/log.js";
|
||||
import { buildSearchFilter, parseOwnerAffiliations } from "../common/ops.js";
|
||||
import { retryer } from "../common/retryer.js";
|
||||
|
||||
// GraphQL queries.
|
||||
const GRAPHQL_REPOS_FIELD = `
|
||||
repositories(first: 100, after: $after, ownerAffiliations: $ownerAffiliations, orderBy: {direction: DESC, field: STARGAZERS}) {
|
||||
totalCount
|
||||
nodes {
|
||||
name
|
||||
stargazers {
|
||||
totalCount
|
||||
}
|
||||
}
|
||||
pageInfo {
|
||||
hasNextPage
|
||||
endCursor
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const GRAPHQL_REPOS_QUERY = `
|
||||
query userInfo($login: String!, $after: String, $ownerAffiliations: [RepositoryAffiliation]) {
|
||||
user(login: $login) {
|
||||
${GRAPHQL_REPOS_FIELD}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const GRAPHQL_STATS_QUERY = `
|
||||
query userInfo($login: String!, $after: String, $includeMergedPullRequests: Boolean!, $includeDiscussions: Boolean!, $includeDiscussionsAnswers: Boolean!, $startTime: DateTime = null, $ownerAffiliations: [RepositoryAffiliation]) {
|
||||
user(login: $login) {
|
||||
name
|
||||
login
|
||||
commits: contributionsCollection (from: $startTime) {
|
||||
totalCommitContributions,
|
||||
}
|
||||
reviews: contributionsCollection {
|
||||
totalPullRequestReviewContributions
|
||||
}
|
||||
repositoriesContributedTo(first: 1, contributionTypes: [COMMIT, ISSUE, PULL_REQUEST, REPOSITORY]) {
|
||||
totalCount
|
||||
}
|
||||
pullRequests(first: 1) {
|
||||
totalCount
|
||||
}
|
||||
mergedPullRequests: pullRequests(states: MERGED) @include(if: $includeMergedPullRequests) {
|
||||
totalCount
|
||||
}
|
||||
openIssues: issues(states: OPEN) {
|
||||
totalCount
|
||||
}
|
||||
closedIssues: issues(states: CLOSED) {
|
||||
totalCount
|
||||
}
|
||||
followers {
|
||||
totalCount
|
||||
}
|
||||
repositoryDiscussions @include(if: $includeDiscussions) {
|
||||
totalCount
|
||||
}
|
||||
repositoryDiscussionComments(onlyAnswers: true) @include(if: $includeDiscussionsAnswers) {
|
||||
totalCount
|
||||
}
|
||||
${GRAPHQL_REPOS_FIELD}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
/**
|
||||
* Stats fetcher object.
|
||||
*
|
||||
* @param {object & { after: string | null }} variables Fetcher variables.
|
||||
* @param {string} token GitHub token.
|
||||
* @returns {Promise<import('axios').AxiosResponse>} Axios response.
|
||||
*/
|
||||
const fetcher = (variables, token) => {
|
||||
const query = variables.after ? GRAPHQL_REPOS_QUERY : GRAPHQL_STATS_QUERY;
|
||||
return request(
|
||||
{
|
||||
query,
|
||||
variables,
|
||||
},
|
||||
{
|
||||
Authorization: `bearer ${token}`,
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Fetch stats information for a given username.
|
||||
*
|
||||
* @param {object} variables Fetcher variables.
|
||||
* @param {string} variables.username GitHub username.
|
||||
* @param {boolean} variables.includeMergedPullRequests Include merged pull requests.
|
||||
* @param {boolean} variables.includeDiscussions Include discussions.
|
||||
* @param {boolean} variables.includeDiscussionsAnswers Include discussions answers.
|
||||
* @param {string|undefined} variables.startTime Time to start the count of total commits.
|
||||
* @param {string[]} variables.ownerAffiliations The owner affiliations to filter by. Default: OWNER.
|
||||
* @param {string | null} variables.pat PAT override or null.
|
||||
* @returns {Promise<import('axios').AxiosResponse>} Axios response.
|
||||
*
|
||||
* @description This function supports multi-page fetching if the 'FETCH_MULTI_PAGE_STARS' environment variable is set to true or a limit of fetches.
|
||||
*/
|
||||
const statsFetcher = async ({
|
||||
username,
|
||||
includeMergedPullRequests,
|
||||
includeDiscussions,
|
||||
includeDiscussionsAnswers,
|
||||
startTime,
|
||||
ownerAffiliations,
|
||||
pat,
|
||||
}) => {
|
||||
let stats;
|
||||
let hasNextPage = true;
|
||||
let endCursor = null;
|
||||
let fetchedPages = 0;
|
||||
while (hasNextPage) {
|
||||
const variables = {
|
||||
login: username,
|
||||
first: 100,
|
||||
after: endCursor,
|
||||
includeMergedPullRequests,
|
||||
includeDiscussions,
|
||||
includeDiscussionsAnswers,
|
||||
startTime,
|
||||
ownerAffiliations,
|
||||
};
|
||||
let res = await retryer(fetcher, variables, pat);
|
||||
if (res.data.errors) {
|
||||
return res;
|
||||
}
|
||||
|
||||
// Store stats data.
|
||||
const repoNodes = res.data.data.user.repositories.nodes;
|
||||
if (stats) {
|
||||
if (fetchedPages === 1) {
|
||||
// make deep copy of relevant stats fields to avoid altering the cached response object in frontend
|
||||
stats = structuredClone({
|
||||
data: stats.data,
|
||||
statusText: stats.statusText,
|
||||
});
|
||||
}
|
||||
stats.data.data.user.repositories.nodes.push(...repoNodes);
|
||||
} else {
|
||||
stats = res;
|
||||
}
|
||||
|
||||
// Disable multi page fetching on public Vercel instance due to rate limits.
|
||||
fetchedPages++;
|
||||
const repoNodesWithStars = repoNodes.filter(
|
||||
(node) => node.stargazers.totalCount !== 0,
|
||||
);
|
||||
|
||||
hasNextPage =
|
||||
(getConfig().fetchMultiPageStars === "true" ||
|
||||
getConfig().fetchMultiPageStars > fetchedPages) &&
|
||||
repoNodes.length === repoNodesWithStars.length &&
|
||||
res.data.data.user.repositories.pageInfo.hasNextPage;
|
||||
|
||||
endCursor = res.data.data.user.repositories.pageInfo.endCursor;
|
||||
}
|
||||
|
||||
return stats;
|
||||
};
|
||||
|
||||
/**
|
||||
* Fetch total commits using the REST API.
|
||||
*
|
||||
* @param {object} variables Fetcher variables.
|
||||
* @param {string} token GitHub token.
|
||||
* @returns {Promise<import('axios').AxiosResponse>} Axios response.
|
||||
*
|
||||
* @see https://developer.github.com/v3/search/#search-commits
|
||||
*/
|
||||
const fetchTotalItems = (variables, token) => {
|
||||
return axios({
|
||||
method: "get",
|
||||
url:
|
||||
`https://api.github.com/search/` +
|
||||
variables.type +
|
||||
`?per_page=1&q=` +
|
||||
buildSearchFilter(variables.repo, variables.owner).replaceAll(" ", "+") +
|
||||
variables.filter,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Accept: "application/vnd.github.cloak-preview",
|
||||
Authorization: `token ${token}`,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Fetch all the commits for all the repositories of a given username.
|
||||
*
|
||||
* @param {string} username GitHub username.
|
||||
* @returns {Promise<number>} Total commits.
|
||||
*
|
||||
* @description Done like this because the GitHub API does not provide a way to fetch all the commits. See
|
||||
* #92#issuecomment-661026467 and #211 for more information.
|
||||
*/
|
||||
const totalItemsFetcher = async (username, repo, owner, type, filter, pat) => {
|
||||
if (!githubUsernameRegex.test(username)) {
|
||||
logger.log("Invalid username provided.");
|
||||
throw new Error("Invalid username provided.");
|
||||
}
|
||||
|
||||
let res;
|
||||
try {
|
||||
res = await retryer(
|
||||
fetchTotalItems,
|
||||
{
|
||||
login: username,
|
||||
repo,
|
||||
owner,
|
||||
type,
|
||||
filter,
|
||||
},
|
||||
pat,
|
||||
);
|
||||
} catch (err) {
|
||||
logger.log(err);
|
||||
throw err;
|
||||
}
|
||||
|
||||
const totalCount = res.data.total_count;
|
||||
if (isNaN(totalCount)) {
|
||||
logger.error("GitHub error: " + JSON.stringify(res.data));
|
||||
throw new CustomError(
|
||||
"Could not fetch data from GitHub REST API.",
|
||||
CustomError.GITHUB_REST_API_ERROR,
|
||||
);
|
||||
}
|
||||
return totalCount;
|
||||
};
|
||||
|
||||
const fetchRepoUserStats = async (
|
||||
username,
|
||||
repo,
|
||||
owner,
|
||||
include_prs_authored,
|
||||
include_prs_commented,
|
||||
include_prs_reviewed,
|
||||
include_issues_authored,
|
||||
include_issues_commented,
|
||||
pat,
|
||||
) => {
|
||||
let stats = {};
|
||||
if (include_prs_authored) {
|
||||
stats.totalPRsAuthored = await totalItemsFetcher(
|
||||
username,
|
||||
repo,
|
||||
owner,
|
||||
"issues",
|
||||
`author:${username}+type:pr`,
|
||||
pat,
|
||||
);
|
||||
}
|
||||
if (include_prs_commented) {
|
||||
stats.totalPRsCommented = await totalItemsFetcher(
|
||||
username,
|
||||
repo,
|
||||
owner,
|
||||
"issues",
|
||||
`commenter:${username}+-author:${username}+type:pr`,
|
||||
pat,
|
||||
);
|
||||
}
|
||||
if (include_prs_reviewed) {
|
||||
stats.totalPRsReviewed = await totalItemsFetcher(
|
||||
username,
|
||||
repo,
|
||||
owner,
|
||||
"issues",
|
||||
`reviewed-by:${username}+-author:${username}+type:pr`,
|
||||
pat,
|
||||
);
|
||||
}
|
||||
if (include_issues_authored) {
|
||||
stats.totalIssuesAuthored = await totalItemsFetcher(
|
||||
username,
|
||||
repo,
|
||||
owner,
|
||||
"issues",
|
||||
`author:${username}+type:issue`,
|
||||
pat,
|
||||
);
|
||||
}
|
||||
if (include_issues_commented) {
|
||||
stats.totalIssuesCommented = await totalItemsFetcher(
|
||||
username,
|
||||
repo,
|
||||
owner,
|
||||
"issues",
|
||||
`commenter:${username}+-author:${username}+type:issue`,
|
||||
pat,
|
||||
);
|
||||
}
|
||||
return stats;
|
||||
};
|
||||
|
||||
/**
|
||||
* Fetch stats for a given username.
|
||||
*
|
||||
* @param {string} username GitHub username.
|
||||
* @param {boolean} include_all_commits Include all commits.
|
||||
* @param {string[]} exclude_repo Repositories to exclude.
|
||||
* @param {boolean} include_merged_pull_requests Include merged pull requests.
|
||||
* @param {boolean} include_discussions Include discussions.
|
||||
* @param {boolean} include_discussions_answers Include discussions answers.
|
||||
* @param {number|undefined} commits_year Year to count total commits
|
||||
* @param {string[]} ownerAffiliations Owner affiliations. Default: OWNER.
|
||||
* @returns {Promise<import("./types").StatsData>} Stats data.
|
||||
*/
|
||||
const fetchStats = async (
|
||||
username,
|
||||
include_all_commits = false,
|
||||
exclude_repo = [],
|
||||
include_merged_pull_requests = false,
|
||||
include_discussions = false,
|
||||
include_discussions_answers = false,
|
||||
commits_year,
|
||||
repo = [],
|
||||
owner = [],
|
||||
include_prs_authored = false,
|
||||
include_prs_commented = false,
|
||||
include_prs_reviewed = false,
|
||||
include_issues_authored = false,
|
||||
include_issues_commented = false,
|
||||
ownerAffiliations = [],
|
||||
pat = null,
|
||||
) => {
|
||||
if (!username) {
|
||||
throw new MissingParamError(["username"]);
|
||||
}
|
||||
|
||||
const stats = {
|
||||
name: "",
|
||||
totalPRs: 0,
|
||||
totalPRsMerged: 0,
|
||||
mergedPRsPercentage: 0,
|
||||
totalReviews: 0,
|
||||
totalCommits: 0,
|
||||
totalIssues: 0,
|
||||
totalStars: 0,
|
||||
totalDiscussionsStarted: 0,
|
||||
totalDiscussionsAnswered: 0,
|
||||
contributedTo: 0,
|
||||
totalPRsAuthored: 0,
|
||||
totalPRsCommented: 0,
|
||||
totalPRsReviewed: 0,
|
||||
totalIssuesAuthored: 0,
|
||||
totalIssuesCommented: 0,
|
||||
rank: { level: "C", percentile: 100 },
|
||||
};
|
||||
ownerAffiliations = parseOwnerAffiliations(ownerAffiliations);
|
||||
|
||||
let res = await statsFetcher(
|
||||
{
|
||||
username,
|
||||
includeMergedPullRequests: include_merged_pull_requests,
|
||||
includeDiscussions: include_discussions,
|
||||
includeDiscussionsAnswers: include_discussions_answers,
|
||||
startTime: commits_year ? `${commits_year}-01-01T00:00:00Z` : undefined,
|
||||
ownerAffiliations,
|
||||
},
|
||||
pat,
|
||||
);
|
||||
|
||||
// Catch GraphQL errors.
|
||||
if (res.data.errors) {
|
||||
logger.error(res.data.errors);
|
||||
if (res.data.errors[0].type === "NOT_FOUND") {
|
||||
throw new CustomError(
|
||||
res.data.errors[0].message || "Could not fetch user.",
|
||||
CustomError.USER_NOT_FOUND,
|
||||
);
|
||||
}
|
||||
if (res.data.errors[0].message) {
|
||||
throw new CustomError(
|
||||
wrapTextMultiline(res.data.errors[0].message, 90, 1)[0],
|
||||
res.statusText,
|
||||
);
|
||||
}
|
||||
throw new CustomError(
|
||||
"Something went wrong while trying to retrieve the stats data using the GraphQL API.",
|
||||
CustomError.GRAPHQL_ERROR,
|
||||
);
|
||||
}
|
||||
|
||||
const user = res.data.data.user;
|
||||
|
||||
stats.name = user.name || user.login;
|
||||
|
||||
// if include_all_commits, fetch all commits using the REST API.
|
||||
if (include_all_commits) {
|
||||
stats.totalCommits = await totalItemsFetcher(
|
||||
username,
|
||||
repo,
|
||||
owner,
|
||||
"commits",
|
||||
`author:${username}`,
|
||||
pat,
|
||||
);
|
||||
} else {
|
||||
stats.totalCommits = user.commits.totalCommitContributions;
|
||||
}
|
||||
let repoUserStats = await fetchRepoUserStats(
|
||||
username,
|
||||
repo,
|
||||
owner,
|
||||
include_prs_authored,
|
||||
include_prs_commented,
|
||||
include_prs_reviewed,
|
||||
include_issues_authored,
|
||||
include_issues_commented,
|
||||
pat,
|
||||
);
|
||||
Object.assign(stats, repoUserStats);
|
||||
|
||||
stats.totalPRs = user.pullRequests.totalCount;
|
||||
if (include_merged_pull_requests) {
|
||||
stats.totalPRsMerged = user.mergedPullRequests.totalCount;
|
||||
stats.mergedPRsPercentage =
|
||||
(user.mergedPullRequests.totalCount / user.pullRequests.totalCount) *
|
||||
100 || 0;
|
||||
}
|
||||
stats.totalReviews = user.reviews.totalPullRequestReviewContributions;
|
||||
stats.totalIssues = user.openIssues.totalCount + user.closedIssues.totalCount;
|
||||
if (include_discussions) {
|
||||
stats.totalDiscussionsStarted = user.repositoryDiscussions.totalCount;
|
||||
}
|
||||
if (include_discussions_answers) {
|
||||
stats.totalDiscussionsAnswered =
|
||||
user.repositoryDiscussionComments.totalCount;
|
||||
}
|
||||
stats.contributedTo = user.repositoriesContributedTo.totalCount;
|
||||
|
||||
// Retrieve stars while filtering out repositories to be hidden.
|
||||
const allExcludedRepos = [
|
||||
...exclude_repo,
|
||||
...getConfig().excludeRepositories,
|
||||
];
|
||||
let repoToHide = new Set(allExcludedRepos);
|
||||
|
||||
stats.totalStars = user.repositories.nodes
|
||||
.filter((data) => {
|
||||
return !repoToHide.has(data.name);
|
||||
})
|
||||
.reduce((prev, curr) => {
|
||||
return prev + curr.stargazers.totalCount;
|
||||
}, 0);
|
||||
|
||||
stats.rank = calculateRank({
|
||||
all_commits: include_all_commits,
|
||||
commits: stats.totalCommits,
|
||||
prs: stats.totalPRs,
|
||||
reviews: stats.totalReviews,
|
||||
issues: stats.totalIssues,
|
||||
repos: user.repositories.totalCount,
|
||||
stars: stats.totalStars,
|
||||
followers: user.followers.totalCount,
|
||||
});
|
||||
|
||||
return stats;
|
||||
};
|
||||
|
||||
export { fetchStats, fetchRepoUserStats };
|
||||
@@ -0,0 +1,177 @@
|
||||
// @ts-check
|
||||
|
||||
import { getConfig } from "../common/config.js";
|
||||
import { CustomError, MissingParamError } from "../common/error.js";
|
||||
import { wrapTextMultiline } from "../common/fmt.js";
|
||||
import { request } from "../common/http.js";
|
||||
import { logger } from "../common/log.js";
|
||||
import { parseOwnerAffiliations } from "../common/ops.js";
|
||||
import { retryer } from "../common/retryer.js";
|
||||
|
||||
/**
|
||||
* Top languages fetcher object.
|
||||
*
|
||||
* @param {any} variables Fetcher variables.
|
||||
* @param {string} token GitHub token.
|
||||
* @returns {Promise<import("axios").AxiosResponse>} Languages fetcher response.
|
||||
*/
|
||||
const fetcher = (variables, token) => {
|
||||
return request(
|
||||
{
|
||||
query: `
|
||||
query userInfo($login: String!, $ownerAffiliations: [RepositoryAffiliation]) {
|
||||
user(login: $login) {
|
||||
# do not fetch forks
|
||||
repositories(ownerAffiliations: $ownerAffiliations, isFork: false, first: 100) {
|
||||
nodes {
|
||||
name
|
||||
languages(first: 10, orderBy: {field: SIZE, direction: DESC}) {
|
||||
edges {
|
||||
size
|
||||
node {
|
||||
color
|
||||
name
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
variables,
|
||||
},
|
||||
{
|
||||
Authorization: `token ${token}`,
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* @typedef {import("./types").TopLangData} TopLangData Top languages data.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Fetch top languages for a given username.
|
||||
*
|
||||
* @param {string} username GitHub username.
|
||||
* @param {string[]} exclude_repo List of repositories to exclude. Default: [].
|
||||
* @param {number} size_weight Weightage to be given to size.
|
||||
* @param {number} count_weight Weightage to be given to count.
|
||||
* @param {string[]} ownerAffiliations The owner affiliations to filter by. Default: OWNER.
|
||||
* @param {string|null} pat Optional PAT override.
|
||||
* @returns {Promise<TopLangData>} Top languages data.
|
||||
*/
|
||||
const fetchTopLanguages = async (
|
||||
username,
|
||||
exclude_repo = [],
|
||||
size_weight = 1,
|
||||
count_weight = 0,
|
||||
ownerAffiliations = [],
|
||||
pat = null,
|
||||
) => {
|
||||
if (!username) {
|
||||
throw new MissingParamError(["username"]);
|
||||
}
|
||||
ownerAffiliations = parseOwnerAffiliations(ownerAffiliations);
|
||||
|
||||
const res = await retryer(
|
||||
fetcher,
|
||||
{
|
||||
login: username,
|
||||
ownerAffiliations,
|
||||
},
|
||||
pat,
|
||||
);
|
||||
|
||||
if (res.data.errors) {
|
||||
logger.error(res.data.errors);
|
||||
if (res.data.errors[0].type === "NOT_FOUND") {
|
||||
throw new CustomError(
|
||||
res.data.errors[0].message || "Could not fetch user.",
|
||||
CustomError.USER_NOT_FOUND,
|
||||
);
|
||||
}
|
||||
if (res.data.errors[0].message) {
|
||||
throw new CustomError(
|
||||
wrapTextMultiline(res.data.errors[0].message, 90, 1)[0],
|
||||
res.statusText,
|
||||
);
|
||||
}
|
||||
throw new CustomError(
|
||||
"Something went wrong while trying to retrieve the language data using the GraphQL API.",
|
||||
CustomError.GRAPHQL_ERROR,
|
||||
);
|
||||
}
|
||||
|
||||
let repoNodes = res.data.data.user.repositories.nodes;
|
||||
/** @type {Record<string, boolean>} */
|
||||
let repoToHide = {};
|
||||
const allExcludedRepos = [
|
||||
...exclude_repo,
|
||||
...getConfig().excludeRepositories,
|
||||
];
|
||||
|
||||
// populate repoToHide map for quick lookup
|
||||
// while filtering out
|
||||
if (allExcludedRepos) {
|
||||
allExcludedRepos.forEach((repoName) => {
|
||||
repoToHide[repoName] = true;
|
||||
});
|
||||
}
|
||||
|
||||
// filter out repositories to be hidden
|
||||
repoNodes = repoNodes
|
||||
.sort((a, b) => b.size - a.size)
|
||||
.filter((name) => !repoToHide[name.name]);
|
||||
|
||||
let repoCount = 0;
|
||||
|
||||
repoNodes = repoNodes
|
||||
.filter((node) => node.languages.edges.length > 0)
|
||||
// flatten the list of language nodes
|
||||
.reduce((acc, curr) => curr.languages.edges.concat(acc), [])
|
||||
.reduce((acc, prev) => {
|
||||
// get the size of the language (bytes)
|
||||
let langSize = prev.size;
|
||||
|
||||
// if we already have the language in the accumulator
|
||||
// & the current language name is same as previous name
|
||||
// add the size to the language size and increase repoCount.
|
||||
if (acc[prev.node.name] && prev.node.name === acc[prev.node.name].name) {
|
||||
langSize = prev.size + acc[prev.node.name].size;
|
||||
repoCount += 1;
|
||||
} else {
|
||||
// reset repoCount to 1
|
||||
// language must exist in at least one repo to be detected
|
||||
repoCount = 1;
|
||||
}
|
||||
return {
|
||||
...acc,
|
||||
[prev.node.name]: {
|
||||
name: prev.node.name,
|
||||
color: prev.node.color,
|
||||
size: langSize,
|
||||
count: repoCount,
|
||||
},
|
||||
};
|
||||
}, {});
|
||||
|
||||
Object.keys(repoNodes).forEach((name) => {
|
||||
// comparison index calculation
|
||||
repoNodes[name].size =
|
||||
Math.pow(repoNodes[name].size, size_weight) *
|
||||
Math.pow(repoNodes[name].count, count_weight);
|
||||
});
|
||||
|
||||
const topLangs = Object.keys(repoNodes)
|
||||
.sort((a, b) => repoNodes[b].size - repoNodes[a].size)
|
||||
.reduce((result, key) => {
|
||||
result[key] = repoNodes[key];
|
||||
return result;
|
||||
}, {});
|
||||
|
||||
return topLangs;
|
||||
};
|
||||
|
||||
export { fetchTopLanguages };
|
||||
+128
@@ -0,0 +1,128 @@
|
||||
export interface GistData {
|
||||
name: string;
|
||||
nameWithOwner: string;
|
||||
description: string | null;
|
||||
language: string | null;
|
||||
starsCount: number;
|
||||
forksCount: number;
|
||||
}
|
||||
|
||||
export interface RepositoryData {
|
||||
name: string;
|
||||
nameWithOwner: string;
|
||||
isPrivate: boolean;
|
||||
isArchived: boolean;
|
||||
isTemplate: boolean;
|
||||
stargazers: { totalCount: number };
|
||||
description: string;
|
||||
primaryLanguage: {
|
||||
color: string;
|
||||
id: string;
|
||||
name: string;
|
||||
};
|
||||
forkCount: number;
|
||||
starCount: number;
|
||||
totalPRsAuthored: number;
|
||||
totalPRsCommented: number;
|
||||
totalPRsReviewed: number;
|
||||
totalIssuesAuthored: number;
|
||||
totalIssuesCommented: number;
|
||||
}
|
||||
|
||||
export interface StatsData {
|
||||
name: string;
|
||||
totalPRs: number;
|
||||
totalPRsMerged: number;
|
||||
mergedPRsPercentage: number;
|
||||
totalReviews: number;
|
||||
totalCommits: number;
|
||||
totalIssues: number;
|
||||
totalStars: number;
|
||||
totalDiscussionsStarted: number;
|
||||
totalDiscussionsAnswered: number;
|
||||
contributedTo: number;
|
||||
totalPRsAuthored: number;
|
||||
totalPRsCommented: number;
|
||||
totalPRsReviewed: number;
|
||||
totalIssuesAuthored: number;
|
||||
totalIssuesCommented: number;
|
||||
rank: { level: string; percentile: number };
|
||||
}
|
||||
|
||||
export interface Lang {
|
||||
name: string;
|
||||
color: string;
|
||||
size: number;
|
||||
}
|
||||
|
||||
export type TopLangData = Record<string, Lang>;
|
||||
|
||||
export interface WakaTimeData {
|
||||
categories: Array<{
|
||||
digital: string;
|
||||
hours: number;
|
||||
minutes: number;
|
||||
name: string;
|
||||
percent: number;
|
||||
text: string;
|
||||
total_seconds: number;
|
||||
}>;
|
||||
daily_average: number;
|
||||
daily_average_including_other_language: number;
|
||||
days_including_holidays: number;
|
||||
days_minus_holidays: number;
|
||||
editors: Array<{
|
||||
digital: string;
|
||||
hours: number;
|
||||
minutes: number;
|
||||
name: string;
|
||||
percent: number;
|
||||
text: string;
|
||||
total_seconds: number;
|
||||
}>;
|
||||
holidays: number;
|
||||
human_readable_daily_average: string;
|
||||
human_readable_daily_average_including_other_language: string;
|
||||
human_readable_total: string;
|
||||
human_readable_total_including_other_language: string;
|
||||
id: string;
|
||||
is_already_updating: boolean;
|
||||
is_coding_activity_visible: boolean;
|
||||
is_including_today: boolean;
|
||||
is_other_usage_visible: boolean;
|
||||
is_stuck: boolean;
|
||||
is_up_to_date: boolean;
|
||||
languages: Array<{
|
||||
digital: string;
|
||||
hours: number;
|
||||
minutes: number;
|
||||
name: string;
|
||||
percent: number;
|
||||
text: string;
|
||||
total_seconds: number;
|
||||
}>;
|
||||
operating_systems: Array<{
|
||||
digital: string;
|
||||
hours: number;
|
||||
minutes: number;
|
||||
name: string;
|
||||
percent: number;
|
||||
text: string;
|
||||
total_seconds: number;
|
||||
}>;
|
||||
percent_calculated: number;
|
||||
range: string;
|
||||
status: string;
|
||||
timeout: number;
|
||||
total_seconds: number;
|
||||
total_seconds_including_other_language: number;
|
||||
user_id: string;
|
||||
username: string;
|
||||
writes_only: boolean;
|
||||
}
|
||||
|
||||
export interface WakaTimeLang {
|
||||
name: string;
|
||||
text: string;
|
||||
percent: number;
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
// @ts-check
|
||||
|
||||
import axios from "axios";
|
||||
|
||||
import { CustomError, MissingParamError } from "../common/error.js";
|
||||
|
||||
/**
|
||||
* WakaTime data fetcher.
|
||||
*
|
||||
* @param {{username: string, api_domain: string }} props Fetcher props.
|
||||
* @returns {Promise<import("./types").WakaTimeData>} WakaTime data response.
|
||||
*/
|
||||
const fetchWakatimeStats = async ({ username, api_domain }) => {
|
||||
if (!username) {
|
||||
throw new MissingParamError(["username"]);
|
||||
}
|
||||
|
||||
try {
|
||||
const { data } = await axios.get(
|
||||
`https://${
|
||||
api_domain ? api_domain.replace(/\/$/gi, "") : "wakatime.com"
|
||||
}/api/v1/users/${username}/stats?is_including_today=true`,
|
||||
);
|
||||
|
||||
return data.data;
|
||||
} catch (err) {
|
||||
if (err.response.status < 200 || err.response.status > 299) {
|
||||
throw new CustomError(
|
||||
`Could not resolve to a User with the login of '${username}'`,
|
||||
"WAKATIME_USER_NOT_FOUND",
|
||||
);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
|
||||
export { fetchWakatimeStats };
|
||||
@@ -0,0 +1,25 @@
|
||||
/**
|
||||
* We need this file to be in ts to allow custom conditions to work
|
||||
* The package will be converted
|
||||
*
|
||||
* @todo https://github.com/stats-organization/github-stats-extended/issues/140
|
||||
*/
|
||||
export { fetchWakatimeStats } from "./fetchers/wakatime.js";
|
||||
export { retryer } from "./common/retryer.js";
|
||||
|
||||
export { renderError } from "./common/render.js";
|
||||
|
||||
export { dateDiff, clampValue } from "./common/ops.js";
|
||||
|
||||
export { logger } from "./common/log.js";
|
||||
export { request } from "./common/http.js";
|
||||
|
||||
export { default as gist } from "./api/gist.js";
|
||||
export { default as api } from "./api/index.js";
|
||||
export { default as pin } from "./api/pin.js";
|
||||
export { default as topLangs } from "./api/top-langs.js";
|
||||
export { default as wakatime } from "./api/wakatime.js";
|
||||
|
||||
export { getConfig } from "./common/config.js";
|
||||
|
||||
export { themes } from "./themes/index.js";
|
||||
@@ -0,0 +1,229 @@
|
||||
## Available Themes
|
||||
|
||||
<!-- DO NOT EDIT THIS FILE DIRECTLY -->
|
||||
|
||||
With inbuilt themes, you can customize the look of the card without doing any manual customization.
|
||||
|
||||
Use `?theme=THEME_NAME` parameter like so:
|
||||
|
||||
```md
|
||||

|
||||
```
|
||||
|
||||
## Stats
|
||||
|
||||
> These themes work with all five of our cards: Stats Card, Repo Card, Gist Card, Top Languages Card, and WakaTime Card.
|
||||
|
||||
| | | |
|
||||
| :--: | :--: | :--: |
|
||||
| `default` ![default][default] | `transparent` ![transparent][transparent] | `shadow_red` ![shadow_red][shadow_red] |
|
||||
| `shadow_green` ![shadow_green][shadow_green] | `shadow_blue` ![shadow_blue][shadow_blue] | `dark` ![dark][dark] |
|
||||
| `radical` ![radical][radical] | `merko` ![merko][merko] | `gruvbox` ![gruvbox][gruvbox] |
|
||||
| `gruvbox_light` ![gruvbox_light][gruvbox_light] | `tokyonight` ![tokyonight][tokyonight] | `onedark` ![onedark][onedark] |
|
||||
| `cobalt` ![cobalt][cobalt] | `synthwave` ![synthwave][synthwave] | `highcontrast` ![highcontrast][highcontrast] |
|
||||
| `dracula` ![dracula][dracula] | `prussian` ![prussian][prussian] | `monokai` ![monokai][monokai] |
|
||||
| `vue` ![vue][vue] | `vue-dark` ![vue-dark][vue-dark] | `shades-of-purple` ![shades-of-purple][shades-of-purple] |
|
||||
| `nightowl` ![nightowl][nightowl] | `buefy` ![buefy][buefy] | `blue-green` ![blue-green][blue-green] |
|
||||
| `algolia` ![algolia][algolia] | `great-gatsby` ![great-gatsby][great-gatsby] | `darcula` ![darcula][darcula] |
|
||||
| `bear` ![bear][bear] | `solarized-dark` ![solarized-dark][solarized-dark] | `solarized-light` ![solarized-light][solarized-light] |
|
||||
| `chartreuse-dark` ![chartreuse-dark][chartreuse-dark] | `nord` ![nord][nord] | `gotham` ![gotham][gotham] |
|
||||
| `material-palenight` ![material-palenight][material-palenight] | `graywhite` ![graywhite][graywhite] | `vision-friendly-dark` ![vision-friendly-dark][vision-friendly-dark] |
|
||||
| `ayu-mirage` ![ayu-mirage][ayu-mirage] | `midnight-purple` ![midnight-purple][midnight-purple] | `calm` ![calm][calm] |
|
||||
| `flag-india` ![flag-india][flag-india] | `omni` ![omni][omni] | `react` ![react][react] |
|
||||
| `jolly` ![jolly][jolly] | `maroongold` ![maroongold][maroongold] | `yeblu` ![yeblu][yeblu] |
|
||||
| `blueberry` ![blueberry][blueberry] | `slateorange` ![slateorange][slateorange] | `kacho_ga` ![kacho_ga][kacho_ga] |
|
||||
| `outrun` ![outrun][outrun] | `ocean_dark` ![ocean_dark][ocean_dark] | `city_lights` ![city_lights][city_lights] |
|
||||
| `github_dark` ![github_dark][github_dark] | `github_dark_dimmed` ![github_dark_dimmed][github_dark_dimmed] | `discord_old_blurple` ![discord_old_blurple][discord_old_blurple] |
|
||||
| `aura_dark` ![aura_dark][aura_dark] | `panda` ![panda][panda] | `noctis_minimus` ![noctis_minimus][noctis_minimus] |
|
||||
| `cobalt2` ![cobalt2][cobalt2] | `swift` ![swift][swift] | `aura` ![aura][aura] |
|
||||
| `apprentice` ![apprentice][apprentice] | `moltack` ![moltack][moltack] | `codeSTACKr` ![codeSTACKr][codeSTACKr] |
|
||||
| `rose_pine` ![rose_pine][rose_pine] | `catppuccin_latte` ![catppuccin_latte][catppuccin_latte] | `catppuccin_mocha` ![catppuccin_mocha][catppuccin_mocha] |
|
||||
| `date_night` ![date_night][date_night] | `one_dark_pro` ![one_dark_pro][one_dark_pro] | `rose` ![rose][rose] |
|
||||
| `holi` ![holi][holi] | `neon` ![neon][neon] | `blue_navy` ![blue_navy][blue_navy] |
|
||||
| `calm_pink` ![calm_pink][calm_pink] | `ambient_gradient` ![ambient_gradient][ambient_gradient] | |
|
||||
|
||||
## Repo Card
|
||||
|
||||
> These themes work with all five of our cards: Stats Card, Repo Card, Gist Card, Top Languages Card, and WakaTime Card.
|
||||
|
||||
| | | |
|
||||
| :--: | :--: | :--: |
|
||||
| `default_repocard` ![default_repocard][default_repocard_repo] | `transparent` ![transparent][transparent_repo] | `shadow_red` ![shadow_red][shadow_red_repo] |
|
||||
| `shadow_green` ![shadow_green][shadow_green_repo] | `shadow_blue` ![shadow_blue][shadow_blue_repo] | `dark` ![dark][dark_repo] |
|
||||
| `radical` ![radical][radical_repo] | `merko` ![merko][merko_repo] | `gruvbox` ![gruvbox][gruvbox_repo] |
|
||||
| `gruvbox_light` ![gruvbox_light][gruvbox_light_repo] | `tokyonight` ![tokyonight][tokyonight_repo] | `onedark` ![onedark][onedark_repo] |
|
||||
| `cobalt` ![cobalt][cobalt_repo] | `synthwave` ![synthwave][synthwave_repo] | `highcontrast` ![highcontrast][highcontrast_repo] |
|
||||
| `dracula` ![dracula][dracula_repo] | `prussian` ![prussian][prussian_repo] | `monokai` ![monokai][monokai_repo] |
|
||||
| `vue` ![vue][vue_repo] | `vue-dark` ![vue-dark][vue-dark_repo] | `shades-of-purple` ![shades-of-purple][shades-of-purple_repo] |
|
||||
| `nightowl` ![nightowl][nightowl_repo] | `buefy` ![buefy][buefy_repo] | `blue-green` ![blue-green][blue-green_repo] |
|
||||
| `algolia` ![algolia][algolia_repo] | `great-gatsby` ![great-gatsby][great-gatsby_repo] | `darcula` ![darcula][darcula_repo] |
|
||||
| `bear` ![bear][bear_repo] | `solarized-dark` ![solarized-dark][solarized-dark_repo] | `solarized-light` ![solarized-light][solarized-light_repo] |
|
||||
| `chartreuse-dark` ![chartreuse-dark][chartreuse-dark_repo] | `nord` ![nord][nord_repo] | `gotham` ![gotham][gotham_repo] |
|
||||
| `material-palenight` ![material-palenight][material-palenight_repo] | `graywhite` ![graywhite][graywhite_repo] | `vision-friendly-dark` ![vision-friendly-dark][vision-friendly-dark_repo] |
|
||||
| `ayu-mirage` ![ayu-mirage][ayu-mirage_repo] | `midnight-purple` ![midnight-purple][midnight-purple_repo] | `calm` ![calm][calm_repo] |
|
||||
| `flag-india` ![flag-india][flag-india_repo] | `omni` ![omni][omni_repo] | `react` ![react][react_repo] |
|
||||
| `jolly` ![jolly][jolly_repo] | `maroongold` ![maroongold][maroongold_repo] | `yeblu` ![yeblu][yeblu_repo] |
|
||||
| `blueberry` ![blueberry][blueberry_repo] | `slateorange` ![slateorange][slateorange_repo] | `kacho_ga` ![kacho_ga][kacho_ga_repo] |
|
||||
| `outrun` ![outrun][outrun_repo] | `ocean_dark` ![ocean_dark][ocean_dark_repo] | `city_lights` ![city_lights][city_lights_repo] |
|
||||
| `github_dark` ![github_dark][github_dark_repo] | `github_dark_dimmed` ![github_dark_dimmed][github_dark_dimmed_repo] | `discord_old_blurple` ![discord_old_blurple][discord_old_blurple_repo] |
|
||||
| `aura_dark` ![aura_dark][aura_dark_repo] | `panda` ![panda][panda_repo] | `noctis_minimus` ![noctis_minimus][noctis_minimus_repo] |
|
||||
| `cobalt2` ![cobalt2][cobalt2_repo] | `swift` ![swift][swift_repo] | `aura` ![aura][aura_repo] |
|
||||
| `apprentice` ![apprentice][apprentice_repo] | `moltack` ![moltack][moltack_repo] | `codeSTACKr` ![codeSTACKr][codeSTACKr_repo] |
|
||||
| `rose_pine` ![rose_pine][rose_pine_repo] | `catppuccin_latte` ![catppuccin_latte][catppuccin_latte_repo] | `catppuccin_mocha` ![catppuccin_mocha][catppuccin_mocha_repo] |
|
||||
| `date_night` ![date_night][date_night_repo] | `one_dark_pro` ![one_dark_pro][one_dark_pro_repo] | `rose` ![rose][rose_repo] |
|
||||
| `holi` ![holi][holi_repo] | `neon` ![neon][neon_repo] | `blue_navy` ![blue_navy][blue_navy_repo] |
|
||||
| `calm_pink` ![calm_pink][calm_pink_repo] | `ambient_gradient` ![ambient_gradient][ambient_gradient_repo] | |
|
||||
|
||||
|
||||
[default]: https://github-stats-extended.vercel.app/api?username=anuraghazra&show_icons=true&hide=contribs,prs&cache_seconds=86400&theme=default
|
||||
[default_repocard]: https://github-stats-extended.vercel.app/api?username=anuraghazra&show_icons=true&hide=contribs,prs&cache_seconds=86400&theme=default_repocard
|
||||
[transparent]: https://github-stats-extended.vercel.app/api?username=anuraghazra&show_icons=true&hide=contribs,prs&cache_seconds=86400&theme=transparent
|
||||
[shadow_red]: https://github-stats-extended.vercel.app/api?username=anuraghazra&show_icons=true&hide=contribs,prs&cache_seconds=86400&theme=shadow_red
|
||||
[shadow_green]: https://github-stats-extended.vercel.app/api?username=anuraghazra&show_icons=true&hide=contribs,prs&cache_seconds=86400&theme=shadow_green
|
||||
[shadow_blue]: https://github-stats-extended.vercel.app/api?username=anuraghazra&show_icons=true&hide=contribs,prs&cache_seconds=86400&theme=shadow_blue
|
||||
[dark]: https://github-stats-extended.vercel.app/api?username=anuraghazra&show_icons=true&hide=contribs,prs&cache_seconds=86400&theme=dark
|
||||
[radical]: https://github-stats-extended.vercel.app/api?username=anuraghazra&show_icons=true&hide=contribs,prs&cache_seconds=86400&theme=radical
|
||||
[merko]: https://github-stats-extended.vercel.app/api?username=anuraghazra&show_icons=true&hide=contribs,prs&cache_seconds=86400&theme=merko
|
||||
[gruvbox]: https://github-stats-extended.vercel.app/api?username=anuraghazra&show_icons=true&hide=contribs,prs&cache_seconds=86400&theme=gruvbox
|
||||
[gruvbox_light]: https://github-stats-extended.vercel.app/api?username=anuraghazra&show_icons=true&hide=contribs,prs&cache_seconds=86400&theme=gruvbox_light
|
||||
[tokyonight]: https://github-stats-extended.vercel.app/api?username=anuraghazra&show_icons=true&hide=contribs,prs&cache_seconds=86400&theme=tokyonight
|
||||
[onedark]: https://github-stats-extended.vercel.app/api?username=anuraghazra&show_icons=true&hide=contribs,prs&cache_seconds=86400&theme=onedark
|
||||
[cobalt]: https://github-stats-extended.vercel.app/api?username=anuraghazra&show_icons=true&hide=contribs,prs&cache_seconds=86400&theme=cobalt
|
||||
[synthwave]: https://github-stats-extended.vercel.app/api?username=anuraghazra&show_icons=true&hide=contribs,prs&cache_seconds=86400&theme=synthwave
|
||||
[highcontrast]: https://github-stats-extended.vercel.app/api?username=anuraghazra&show_icons=true&hide=contribs,prs&cache_seconds=86400&theme=highcontrast
|
||||
[dracula]: https://github-stats-extended.vercel.app/api?username=anuraghazra&show_icons=true&hide=contribs,prs&cache_seconds=86400&theme=dracula
|
||||
[prussian]: https://github-stats-extended.vercel.app/api?username=anuraghazra&show_icons=true&hide=contribs,prs&cache_seconds=86400&theme=prussian
|
||||
[monokai]: https://github-stats-extended.vercel.app/api?username=anuraghazra&show_icons=true&hide=contribs,prs&cache_seconds=86400&theme=monokai
|
||||
[vue]: https://github-stats-extended.vercel.app/api?username=anuraghazra&show_icons=true&hide=contribs,prs&cache_seconds=86400&theme=vue
|
||||
[vue-dark]: https://github-stats-extended.vercel.app/api?username=anuraghazra&show_icons=true&hide=contribs,prs&cache_seconds=86400&theme=vue-dark
|
||||
[shades-of-purple]: https://github-stats-extended.vercel.app/api?username=anuraghazra&show_icons=true&hide=contribs,prs&cache_seconds=86400&theme=shades-of-purple
|
||||
[nightowl]: https://github-stats-extended.vercel.app/api?username=anuraghazra&show_icons=true&hide=contribs,prs&cache_seconds=86400&theme=nightowl
|
||||
[buefy]: https://github-stats-extended.vercel.app/api?username=anuraghazra&show_icons=true&hide=contribs,prs&cache_seconds=86400&theme=buefy
|
||||
[blue-green]: https://github-stats-extended.vercel.app/api?username=anuraghazra&show_icons=true&hide=contribs,prs&cache_seconds=86400&theme=blue-green
|
||||
[algolia]: https://github-stats-extended.vercel.app/api?username=anuraghazra&show_icons=true&hide=contribs,prs&cache_seconds=86400&theme=algolia
|
||||
[great-gatsby]: https://github-stats-extended.vercel.app/api?username=anuraghazra&show_icons=true&hide=contribs,prs&cache_seconds=86400&theme=great-gatsby
|
||||
[darcula]: https://github-stats-extended.vercel.app/api?username=anuraghazra&show_icons=true&hide=contribs,prs&cache_seconds=86400&theme=darcula
|
||||
[bear]: https://github-stats-extended.vercel.app/api?username=anuraghazra&show_icons=true&hide=contribs,prs&cache_seconds=86400&theme=bear
|
||||
[solarized-dark]: https://github-stats-extended.vercel.app/api?username=anuraghazra&show_icons=true&hide=contribs,prs&cache_seconds=86400&theme=solarized-dark
|
||||
[solarized-light]: https://github-stats-extended.vercel.app/api?username=anuraghazra&show_icons=true&hide=contribs,prs&cache_seconds=86400&theme=solarized-light
|
||||
[chartreuse-dark]: https://github-stats-extended.vercel.app/api?username=anuraghazra&show_icons=true&hide=contribs,prs&cache_seconds=86400&theme=chartreuse-dark
|
||||
[nord]: https://github-stats-extended.vercel.app/api?username=anuraghazra&show_icons=true&hide=contribs,prs&cache_seconds=86400&theme=nord
|
||||
[gotham]: https://github-stats-extended.vercel.app/api?username=anuraghazra&show_icons=true&hide=contribs,prs&cache_seconds=86400&theme=gotham
|
||||
[material-palenight]: https://github-stats-extended.vercel.app/api?username=anuraghazra&show_icons=true&hide=contribs,prs&cache_seconds=86400&theme=material-palenight
|
||||
[graywhite]: https://github-stats-extended.vercel.app/api?username=anuraghazra&show_icons=true&hide=contribs,prs&cache_seconds=86400&theme=graywhite
|
||||
[vision-friendly-dark]: https://github-stats-extended.vercel.app/api?username=anuraghazra&show_icons=true&hide=contribs,prs&cache_seconds=86400&theme=vision-friendly-dark
|
||||
[ayu-mirage]: https://github-stats-extended.vercel.app/api?username=anuraghazra&show_icons=true&hide=contribs,prs&cache_seconds=86400&theme=ayu-mirage
|
||||
[midnight-purple]: https://github-stats-extended.vercel.app/api?username=anuraghazra&show_icons=true&hide=contribs,prs&cache_seconds=86400&theme=midnight-purple
|
||||
[calm]: https://github-stats-extended.vercel.app/api?username=anuraghazra&show_icons=true&hide=contribs,prs&cache_seconds=86400&theme=calm
|
||||
[flag-india]: https://github-stats-extended.vercel.app/api?username=anuraghazra&show_icons=true&hide=contribs,prs&cache_seconds=86400&theme=flag-india
|
||||
[omni]: https://github-stats-extended.vercel.app/api?username=anuraghazra&show_icons=true&hide=contribs,prs&cache_seconds=86400&theme=omni
|
||||
[react]: https://github-stats-extended.vercel.app/api?username=anuraghazra&show_icons=true&hide=contribs,prs&cache_seconds=86400&theme=react
|
||||
[jolly]: https://github-stats-extended.vercel.app/api?username=anuraghazra&show_icons=true&hide=contribs,prs&cache_seconds=86400&theme=jolly
|
||||
[maroongold]: https://github-stats-extended.vercel.app/api?username=anuraghazra&show_icons=true&hide=contribs,prs&cache_seconds=86400&theme=maroongold
|
||||
[yeblu]: https://github-stats-extended.vercel.app/api?username=anuraghazra&show_icons=true&hide=contribs,prs&cache_seconds=86400&theme=yeblu
|
||||
[blueberry]: https://github-stats-extended.vercel.app/api?username=anuraghazra&show_icons=true&hide=contribs,prs&cache_seconds=86400&theme=blueberry
|
||||
[slateorange]: https://github-stats-extended.vercel.app/api?username=anuraghazra&show_icons=true&hide=contribs,prs&cache_seconds=86400&theme=slateorange
|
||||
[kacho_ga]: https://github-stats-extended.vercel.app/api?username=anuraghazra&show_icons=true&hide=contribs,prs&cache_seconds=86400&theme=kacho_ga
|
||||
[outrun]: https://github-stats-extended.vercel.app/api?username=anuraghazra&show_icons=true&hide=contribs,prs&cache_seconds=86400&theme=outrun
|
||||
[ocean_dark]: https://github-stats-extended.vercel.app/api?username=anuraghazra&show_icons=true&hide=contribs,prs&cache_seconds=86400&theme=ocean_dark
|
||||
[city_lights]: https://github-stats-extended.vercel.app/api?username=anuraghazra&show_icons=true&hide=contribs,prs&cache_seconds=86400&theme=city_lights
|
||||
[github_dark]: https://github-stats-extended.vercel.app/api?username=anuraghazra&show_icons=true&hide=contribs,prs&cache_seconds=86400&theme=github_dark
|
||||
[github_dark_dimmed]: https://github-stats-extended.vercel.app/api?username=anuraghazra&show_icons=true&hide=contribs,prs&cache_seconds=86400&theme=github_dark_dimmed
|
||||
[discord_old_blurple]: https://github-stats-extended.vercel.app/api?username=anuraghazra&show_icons=true&hide=contribs,prs&cache_seconds=86400&theme=discord_old_blurple
|
||||
[aura_dark]: https://github-stats-extended.vercel.app/api?username=anuraghazra&show_icons=true&hide=contribs,prs&cache_seconds=86400&theme=aura_dark
|
||||
[panda]: https://github-stats-extended.vercel.app/api?username=anuraghazra&show_icons=true&hide=contribs,prs&cache_seconds=86400&theme=panda
|
||||
[noctis_minimus]: https://github-stats-extended.vercel.app/api?username=anuraghazra&show_icons=true&hide=contribs,prs&cache_seconds=86400&theme=noctis_minimus
|
||||
[cobalt2]: https://github-stats-extended.vercel.app/api?username=anuraghazra&show_icons=true&hide=contribs,prs&cache_seconds=86400&theme=cobalt2
|
||||
[swift]: https://github-stats-extended.vercel.app/api?username=anuraghazra&show_icons=true&hide=contribs,prs&cache_seconds=86400&theme=swift
|
||||
[aura]: https://github-stats-extended.vercel.app/api?username=anuraghazra&show_icons=true&hide=contribs,prs&cache_seconds=86400&theme=aura
|
||||
[apprentice]: https://github-stats-extended.vercel.app/api?username=anuraghazra&show_icons=true&hide=contribs,prs&cache_seconds=86400&theme=apprentice
|
||||
[moltack]: https://github-stats-extended.vercel.app/api?username=anuraghazra&show_icons=true&hide=contribs,prs&cache_seconds=86400&theme=moltack
|
||||
[codeSTACKr]: https://github-stats-extended.vercel.app/api?username=anuraghazra&show_icons=true&hide=contribs,prs&cache_seconds=86400&theme=codeSTACKr
|
||||
[rose_pine]: https://github-stats-extended.vercel.app/api?username=anuraghazra&show_icons=true&hide=contribs,prs&cache_seconds=86400&theme=rose_pine
|
||||
[catppuccin_latte]: https://github-stats-extended.vercel.app/api?username=anuraghazra&show_icons=true&hide=contribs,prs&cache_seconds=86400&theme=catppuccin_latte
|
||||
[catppuccin_mocha]: https://github-stats-extended.vercel.app/api?username=anuraghazra&show_icons=true&hide=contribs,prs&cache_seconds=86400&theme=catppuccin_mocha
|
||||
[date_night]: https://github-stats-extended.vercel.app/api?username=anuraghazra&show_icons=true&hide=contribs,prs&cache_seconds=86400&theme=date_night
|
||||
[one_dark_pro]: https://github-stats-extended.vercel.app/api?username=anuraghazra&show_icons=true&hide=contribs,prs&cache_seconds=86400&theme=one_dark_pro
|
||||
[rose]: https://github-stats-extended.vercel.app/api?username=anuraghazra&show_icons=true&hide=contribs,prs&cache_seconds=86400&theme=rose
|
||||
[holi]: https://github-stats-extended.vercel.app/api?username=anuraghazra&show_icons=true&hide=contribs,prs&cache_seconds=86400&theme=holi
|
||||
[neon]: https://github-stats-extended.vercel.app/api?username=anuraghazra&show_icons=true&hide=contribs,prs&cache_seconds=86400&theme=neon
|
||||
[blue_navy]: https://github-stats-extended.vercel.app/api?username=anuraghazra&show_icons=true&hide=contribs,prs&cache_seconds=86400&theme=blue_navy
|
||||
[calm_pink]: https://github-stats-extended.vercel.app/api?username=anuraghazra&show_icons=true&hide=contribs,prs&cache_seconds=86400&theme=calm_pink
|
||||
[ambient_gradient]: https://github-stats-extended.vercel.app/api?username=anuraghazra&show_icons=true&hide=contribs,prs&cache_seconds=86400&theme=ambient_gradient
|
||||
|
||||
|
||||
[default_repo]: https://github-stats-extended.vercel.app/api/pin/?username=anuraghazra&repo=github-readme-stats&cache_seconds=86400&theme=default
|
||||
[default_repocard_repo]: https://github-stats-extended.vercel.app/api/pin/?username=anuraghazra&repo=github-readme-stats&cache_seconds=86400&theme=default_repocard
|
||||
[transparent_repo]: https://github-stats-extended.vercel.app/api/pin/?username=anuraghazra&repo=github-readme-stats&cache_seconds=86400&theme=transparent
|
||||
[shadow_red_repo]: https://github-stats-extended.vercel.app/api/pin/?username=anuraghazra&repo=github-readme-stats&cache_seconds=86400&theme=shadow_red
|
||||
[shadow_green_repo]: https://github-stats-extended.vercel.app/api/pin/?username=anuraghazra&repo=github-readme-stats&cache_seconds=86400&theme=shadow_green
|
||||
[shadow_blue_repo]: https://github-stats-extended.vercel.app/api/pin/?username=anuraghazra&repo=github-readme-stats&cache_seconds=86400&theme=shadow_blue
|
||||
[dark_repo]: https://github-stats-extended.vercel.app/api/pin/?username=anuraghazra&repo=github-readme-stats&cache_seconds=86400&theme=dark
|
||||
[radical_repo]: https://github-stats-extended.vercel.app/api/pin/?username=anuraghazra&repo=github-readme-stats&cache_seconds=86400&theme=radical
|
||||
[merko_repo]: https://github-stats-extended.vercel.app/api/pin/?username=anuraghazra&repo=github-readme-stats&cache_seconds=86400&theme=merko
|
||||
[gruvbox_repo]: https://github-stats-extended.vercel.app/api/pin/?username=anuraghazra&repo=github-readme-stats&cache_seconds=86400&theme=gruvbox
|
||||
[gruvbox_light_repo]: https://github-stats-extended.vercel.app/api/pin/?username=anuraghazra&repo=github-readme-stats&cache_seconds=86400&theme=gruvbox_light
|
||||
[tokyonight_repo]: https://github-stats-extended.vercel.app/api/pin/?username=anuraghazra&repo=github-readme-stats&cache_seconds=86400&theme=tokyonight
|
||||
[onedark_repo]: https://github-stats-extended.vercel.app/api/pin/?username=anuraghazra&repo=github-readme-stats&cache_seconds=86400&theme=onedark
|
||||
[cobalt_repo]: https://github-stats-extended.vercel.app/api/pin/?username=anuraghazra&repo=github-readme-stats&cache_seconds=86400&theme=cobalt
|
||||
[synthwave_repo]: https://github-stats-extended.vercel.app/api/pin/?username=anuraghazra&repo=github-readme-stats&cache_seconds=86400&theme=synthwave
|
||||
[highcontrast_repo]: https://github-stats-extended.vercel.app/api/pin/?username=anuraghazra&repo=github-readme-stats&cache_seconds=86400&theme=highcontrast
|
||||
[dracula_repo]: https://github-stats-extended.vercel.app/api/pin/?username=anuraghazra&repo=github-readme-stats&cache_seconds=86400&theme=dracula
|
||||
[prussian_repo]: https://github-stats-extended.vercel.app/api/pin/?username=anuraghazra&repo=github-readme-stats&cache_seconds=86400&theme=prussian
|
||||
[monokai_repo]: https://github-stats-extended.vercel.app/api/pin/?username=anuraghazra&repo=github-readme-stats&cache_seconds=86400&theme=monokai
|
||||
[vue_repo]: https://github-stats-extended.vercel.app/api/pin/?username=anuraghazra&repo=github-readme-stats&cache_seconds=86400&theme=vue
|
||||
[vue-dark_repo]: https://github-stats-extended.vercel.app/api/pin/?username=anuraghazra&repo=github-readme-stats&cache_seconds=86400&theme=vue-dark
|
||||
[shades-of-purple_repo]: https://github-stats-extended.vercel.app/api/pin/?username=anuraghazra&repo=github-readme-stats&cache_seconds=86400&theme=shades-of-purple
|
||||
[nightowl_repo]: https://github-stats-extended.vercel.app/api/pin/?username=anuraghazra&repo=github-readme-stats&cache_seconds=86400&theme=nightowl
|
||||
[buefy_repo]: https://github-stats-extended.vercel.app/api/pin/?username=anuraghazra&repo=github-readme-stats&cache_seconds=86400&theme=buefy
|
||||
[blue-green_repo]: https://github-stats-extended.vercel.app/api/pin/?username=anuraghazra&repo=github-readme-stats&cache_seconds=86400&theme=blue-green
|
||||
[algolia_repo]: https://github-stats-extended.vercel.app/api/pin/?username=anuraghazra&repo=github-readme-stats&cache_seconds=86400&theme=algolia
|
||||
[great-gatsby_repo]: https://github-stats-extended.vercel.app/api/pin/?username=anuraghazra&repo=github-readme-stats&cache_seconds=86400&theme=great-gatsby
|
||||
[darcula_repo]: https://github-stats-extended.vercel.app/api/pin/?username=anuraghazra&repo=github-readme-stats&cache_seconds=86400&theme=darcula
|
||||
[bear_repo]: https://github-stats-extended.vercel.app/api/pin/?username=anuraghazra&repo=github-readme-stats&cache_seconds=86400&theme=bear
|
||||
[solarized-dark_repo]: https://github-stats-extended.vercel.app/api/pin/?username=anuraghazra&repo=github-readme-stats&cache_seconds=86400&theme=solarized-dark
|
||||
[solarized-light_repo]: https://github-stats-extended.vercel.app/api/pin/?username=anuraghazra&repo=github-readme-stats&cache_seconds=86400&theme=solarized-light
|
||||
[chartreuse-dark_repo]: https://github-stats-extended.vercel.app/api/pin/?username=anuraghazra&repo=github-readme-stats&cache_seconds=86400&theme=chartreuse-dark
|
||||
[nord_repo]: https://github-stats-extended.vercel.app/api/pin/?username=anuraghazra&repo=github-readme-stats&cache_seconds=86400&theme=nord
|
||||
[gotham_repo]: https://github-stats-extended.vercel.app/api/pin/?username=anuraghazra&repo=github-readme-stats&cache_seconds=86400&theme=gotham
|
||||
[material-palenight_repo]: https://github-stats-extended.vercel.app/api/pin/?username=anuraghazra&repo=github-readme-stats&cache_seconds=86400&theme=material-palenight
|
||||
[graywhite_repo]: https://github-stats-extended.vercel.app/api/pin/?username=anuraghazra&repo=github-readme-stats&cache_seconds=86400&theme=graywhite
|
||||
[vision-friendly-dark_repo]: https://github-stats-extended.vercel.app/api/pin/?username=anuraghazra&repo=github-readme-stats&cache_seconds=86400&theme=vision-friendly-dark
|
||||
[ayu-mirage_repo]: https://github-stats-extended.vercel.app/api/pin/?username=anuraghazra&repo=github-readme-stats&cache_seconds=86400&theme=ayu-mirage
|
||||
[midnight-purple_repo]: https://github-stats-extended.vercel.app/api/pin/?username=anuraghazra&repo=github-readme-stats&cache_seconds=86400&theme=midnight-purple
|
||||
[calm_repo]: https://github-stats-extended.vercel.app/api/pin/?username=anuraghazra&repo=github-readme-stats&cache_seconds=86400&theme=calm
|
||||
[flag-india_repo]: https://github-stats-extended.vercel.app/api/pin/?username=anuraghazra&repo=github-readme-stats&cache_seconds=86400&theme=flag-india
|
||||
[omni_repo]: https://github-stats-extended.vercel.app/api/pin/?username=anuraghazra&repo=github-readme-stats&cache_seconds=86400&theme=omni
|
||||
[react_repo]: https://github-stats-extended.vercel.app/api/pin/?username=anuraghazra&repo=github-readme-stats&cache_seconds=86400&theme=react
|
||||
[jolly_repo]: https://github-stats-extended.vercel.app/api/pin/?username=anuraghazra&repo=github-readme-stats&cache_seconds=86400&theme=jolly
|
||||
[maroongold_repo]: https://github-stats-extended.vercel.app/api/pin/?username=anuraghazra&repo=github-readme-stats&cache_seconds=86400&theme=maroongold
|
||||
[yeblu_repo]: https://github-stats-extended.vercel.app/api/pin/?username=anuraghazra&repo=github-readme-stats&cache_seconds=86400&theme=yeblu
|
||||
[blueberry_repo]: https://github-stats-extended.vercel.app/api/pin/?username=anuraghazra&repo=github-readme-stats&cache_seconds=86400&theme=blueberry
|
||||
[slateorange_repo]: https://github-stats-extended.vercel.app/api/pin/?username=anuraghazra&repo=github-readme-stats&cache_seconds=86400&theme=slateorange
|
||||
[kacho_ga_repo]: https://github-stats-extended.vercel.app/api/pin/?username=anuraghazra&repo=github-readme-stats&cache_seconds=86400&theme=kacho_ga
|
||||
[outrun_repo]: https://github-stats-extended.vercel.app/api/pin/?username=anuraghazra&repo=github-readme-stats&cache_seconds=86400&theme=outrun
|
||||
[ocean_dark_repo]: https://github-stats-extended.vercel.app/api/pin/?username=anuraghazra&repo=github-readme-stats&cache_seconds=86400&theme=ocean_dark
|
||||
[city_lights_repo]: https://github-stats-extended.vercel.app/api/pin/?username=anuraghazra&repo=github-readme-stats&cache_seconds=86400&theme=city_lights
|
||||
[github_dark_repo]: https://github-stats-extended.vercel.app/api/pin/?username=anuraghazra&repo=github-readme-stats&cache_seconds=86400&theme=github_dark
|
||||
[github_dark_dimmed_repo]: https://github-stats-extended.vercel.app/api/pin/?username=anuraghazra&repo=github-readme-stats&cache_seconds=86400&theme=github_dark_dimmed
|
||||
[discord_old_blurple_repo]: https://github-stats-extended.vercel.app/api/pin/?username=anuraghazra&repo=github-readme-stats&cache_seconds=86400&theme=discord_old_blurple
|
||||
[aura_dark_repo]: https://github-stats-extended.vercel.app/api/pin/?username=anuraghazra&repo=github-readme-stats&cache_seconds=86400&theme=aura_dark
|
||||
[panda_repo]: https://github-stats-extended.vercel.app/api/pin/?username=anuraghazra&repo=github-readme-stats&cache_seconds=86400&theme=panda
|
||||
[noctis_minimus_repo]: https://github-stats-extended.vercel.app/api/pin/?username=anuraghazra&repo=github-readme-stats&cache_seconds=86400&theme=noctis_minimus
|
||||
[cobalt2_repo]: https://github-stats-extended.vercel.app/api/pin/?username=anuraghazra&repo=github-readme-stats&cache_seconds=86400&theme=cobalt2
|
||||
[swift_repo]: https://github-stats-extended.vercel.app/api/pin/?username=anuraghazra&repo=github-readme-stats&cache_seconds=86400&theme=swift
|
||||
[aura_repo]: https://github-stats-extended.vercel.app/api/pin/?username=anuraghazra&repo=github-readme-stats&cache_seconds=86400&theme=aura
|
||||
[apprentice_repo]: https://github-stats-extended.vercel.app/api/pin/?username=anuraghazra&repo=github-readme-stats&cache_seconds=86400&theme=apprentice
|
||||
[moltack_repo]: https://github-stats-extended.vercel.app/api/pin/?username=anuraghazra&repo=github-readme-stats&cache_seconds=86400&theme=moltack
|
||||
[codeSTACKr_repo]: https://github-stats-extended.vercel.app/api/pin/?username=anuraghazra&repo=github-readme-stats&cache_seconds=86400&theme=codeSTACKr
|
||||
[rose_pine_repo]: https://github-stats-extended.vercel.app/api/pin/?username=anuraghazra&repo=github-readme-stats&cache_seconds=86400&theme=rose_pine
|
||||
[catppuccin_latte_repo]: https://github-stats-extended.vercel.app/api/pin/?username=anuraghazra&repo=github-readme-stats&cache_seconds=86400&theme=catppuccin_latte
|
||||
[catppuccin_mocha_repo]: https://github-stats-extended.vercel.app/api/pin/?username=anuraghazra&repo=github-readme-stats&cache_seconds=86400&theme=catppuccin_mocha
|
||||
[date_night_repo]: https://github-stats-extended.vercel.app/api/pin/?username=anuraghazra&repo=github-readme-stats&cache_seconds=86400&theme=date_night
|
||||
[one_dark_pro_repo]: https://github-stats-extended.vercel.app/api/pin/?username=anuraghazra&repo=github-readme-stats&cache_seconds=86400&theme=one_dark_pro
|
||||
[rose_repo]: https://github-stats-extended.vercel.app/api/pin/?username=anuraghazra&repo=github-readme-stats&cache_seconds=86400&theme=rose
|
||||
[holi_repo]: https://github-stats-extended.vercel.app/api/pin/?username=anuraghazra&repo=github-readme-stats&cache_seconds=86400&theme=holi
|
||||
[neon_repo]: https://github-stats-extended.vercel.app/api/pin/?username=anuraghazra&repo=github-readme-stats&cache_seconds=86400&theme=neon
|
||||
[blue_navy_repo]: https://github-stats-extended.vercel.app/api/pin/?username=anuraghazra&repo=github-readme-stats&cache_seconds=86400&theme=blue_navy
|
||||
[calm_pink_repo]: https://github-stats-extended.vercel.app/api/pin/?username=anuraghazra&repo=github-readme-stats&cache_seconds=86400&theme=calm_pink
|
||||
[ambient_gradient_repo]: https://github-stats-extended.vercel.app/api/pin/?username=anuraghazra&repo=github-readme-stats&cache_seconds=86400&theme=ambient_gradient
|
||||
@@ -0,0 +1,476 @@
|
||||
interface Theme {
|
||||
title_color: string;
|
||||
icon_color: string;
|
||||
text_color: string;
|
||||
bg_color: string;
|
||||
border_color?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Collection of available themes.
|
||||
*/
|
||||
export const themes = {
|
||||
default: {
|
||||
title_color: "2f80ed",
|
||||
icon_color: "4c71f2",
|
||||
text_color: "434d58",
|
||||
bg_color: "fffefe",
|
||||
border_color: "e4e2e2",
|
||||
},
|
||||
default_repocard: {
|
||||
title_color: "2f80ed",
|
||||
icon_color: "586069", // icon color is different
|
||||
text_color: "434d58",
|
||||
bg_color: "fffefe",
|
||||
},
|
||||
transparent: {
|
||||
title_color: "006AFF",
|
||||
icon_color: "0579C3",
|
||||
text_color: "417E87",
|
||||
bg_color: "ffffff00",
|
||||
},
|
||||
shadow_red: {
|
||||
title_color: "9A0000",
|
||||
text_color: "444",
|
||||
icon_color: "4F0000",
|
||||
border_color: "4F0000",
|
||||
bg_color: "ffffff00",
|
||||
},
|
||||
shadow_green: {
|
||||
title_color: "007A00",
|
||||
text_color: "444",
|
||||
icon_color: "003D00",
|
||||
border_color: "003D00",
|
||||
bg_color: "ffffff00",
|
||||
},
|
||||
shadow_blue: {
|
||||
title_color: "00779A",
|
||||
text_color: "444",
|
||||
icon_color: "004450",
|
||||
border_color: "004490",
|
||||
bg_color: "ffffff00",
|
||||
},
|
||||
dark: {
|
||||
title_color: "fff",
|
||||
icon_color: "79ff97",
|
||||
text_color: "9f9f9f",
|
||||
bg_color: "151515",
|
||||
},
|
||||
radical: {
|
||||
title_color: "fe428e",
|
||||
icon_color: "f8d847",
|
||||
text_color: "a9fef7",
|
||||
bg_color: "141321",
|
||||
},
|
||||
merko: {
|
||||
title_color: "abd200",
|
||||
icon_color: "b7d364",
|
||||
text_color: "68b587",
|
||||
bg_color: "0a0f0b",
|
||||
},
|
||||
gruvbox: {
|
||||
title_color: "fabd2f",
|
||||
icon_color: "fe8019",
|
||||
text_color: "8ec07c",
|
||||
bg_color: "282828",
|
||||
},
|
||||
gruvbox_light: {
|
||||
title_color: "b57614",
|
||||
icon_color: "af3a03",
|
||||
text_color: "427b58",
|
||||
bg_color: "fbf1c7",
|
||||
},
|
||||
tokyonight: {
|
||||
title_color: "70a5fd",
|
||||
icon_color: "bf91f3",
|
||||
text_color: "38bdae",
|
||||
bg_color: "1a1b27",
|
||||
},
|
||||
onedark: {
|
||||
title_color: "e4bf7a",
|
||||
icon_color: "8eb573",
|
||||
text_color: "df6d74",
|
||||
bg_color: "282c34",
|
||||
},
|
||||
cobalt: {
|
||||
title_color: "e683d9",
|
||||
icon_color: "0480ef",
|
||||
text_color: "75eeb2",
|
||||
bg_color: "193549",
|
||||
},
|
||||
synthwave: {
|
||||
title_color: "e2e9ec",
|
||||
icon_color: "ef8539",
|
||||
text_color: "e5289e",
|
||||
bg_color: "2b213a",
|
||||
},
|
||||
highcontrast: {
|
||||
title_color: "e7f216",
|
||||
icon_color: "00ffff",
|
||||
text_color: "fff",
|
||||
bg_color: "000",
|
||||
},
|
||||
dracula: {
|
||||
title_color: "ff6e96",
|
||||
icon_color: "79dafa",
|
||||
text_color: "f8f8f2",
|
||||
bg_color: "282a36",
|
||||
},
|
||||
prussian: {
|
||||
title_color: "bddfff",
|
||||
icon_color: "38a0ff",
|
||||
text_color: "6e93b5",
|
||||
bg_color: "172f45",
|
||||
},
|
||||
monokai: {
|
||||
title_color: "eb1f6a",
|
||||
icon_color: "e28905",
|
||||
text_color: "f1f1eb",
|
||||
bg_color: "272822",
|
||||
},
|
||||
vue: {
|
||||
title_color: "41b883",
|
||||
icon_color: "41b883",
|
||||
text_color: "273849",
|
||||
bg_color: "fffefe",
|
||||
},
|
||||
"vue-dark": {
|
||||
title_color: "41b883",
|
||||
icon_color: "41b883",
|
||||
text_color: "fffefe",
|
||||
bg_color: "273849",
|
||||
},
|
||||
"shades-of-purple": {
|
||||
title_color: "fad000",
|
||||
icon_color: "b362ff",
|
||||
text_color: "a599e9",
|
||||
bg_color: "2d2b55",
|
||||
},
|
||||
nightowl: {
|
||||
title_color: "c792ea",
|
||||
icon_color: "ffeb95",
|
||||
text_color: "7fdbca",
|
||||
bg_color: "011627",
|
||||
},
|
||||
buefy: {
|
||||
title_color: "7957d5",
|
||||
icon_color: "ff3860",
|
||||
text_color: "363636",
|
||||
bg_color: "ffffff",
|
||||
},
|
||||
"blue-green": {
|
||||
title_color: "2f97c1",
|
||||
icon_color: "f5b700",
|
||||
text_color: "0cf574",
|
||||
bg_color: "040f0f",
|
||||
},
|
||||
algolia: {
|
||||
title_color: "00AEFF",
|
||||
icon_color: "2DDE98",
|
||||
text_color: "FFFFFF",
|
||||
bg_color: "050F2C",
|
||||
},
|
||||
"great-gatsby": {
|
||||
title_color: "ffa726",
|
||||
icon_color: "ffb74d",
|
||||
text_color: "ffd95b",
|
||||
bg_color: "000000",
|
||||
},
|
||||
darcula: {
|
||||
title_color: "BA5F17",
|
||||
icon_color: "84628F",
|
||||
text_color: "BEBEBE",
|
||||
bg_color: "242424",
|
||||
},
|
||||
bear: {
|
||||
title_color: "e03c8a",
|
||||
icon_color: "00AEFF",
|
||||
text_color: "bcb28d",
|
||||
bg_color: "1f2023",
|
||||
},
|
||||
"solarized-dark": {
|
||||
title_color: "268bd2",
|
||||
icon_color: "b58900",
|
||||
text_color: "859900",
|
||||
bg_color: "002b36",
|
||||
},
|
||||
"solarized-light": {
|
||||
title_color: "268bd2",
|
||||
icon_color: "b58900",
|
||||
text_color: "859900",
|
||||
bg_color: "fdf6e3",
|
||||
},
|
||||
"chartreuse-dark": {
|
||||
title_color: "7fff00",
|
||||
icon_color: "00AEFF",
|
||||
text_color: "fff",
|
||||
bg_color: "000",
|
||||
},
|
||||
nord: {
|
||||
title_color: "81a1c1",
|
||||
text_color: "d8dee9",
|
||||
icon_color: "88c0d0",
|
||||
bg_color: "2e3440",
|
||||
},
|
||||
gotham: {
|
||||
title_color: "2aa889",
|
||||
icon_color: "599cab",
|
||||
text_color: "99d1ce",
|
||||
bg_color: "0c1014",
|
||||
},
|
||||
"material-palenight": {
|
||||
title_color: "c792ea",
|
||||
icon_color: "89ddff",
|
||||
text_color: "a6accd",
|
||||
bg_color: "292d3e",
|
||||
},
|
||||
graywhite: {
|
||||
title_color: "24292e",
|
||||
icon_color: "24292e",
|
||||
text_color: "24292e",
|
||||
bg_color: "ffffff",
|
||||
},
|
||||
"vision-friendly-dark": {
|
||||
title_color: "ffb000",
|
||||
icon_color: "785ef0",
|
||||
text_color: "ffffff",
|
||||
bg_color: "000000",
|
||||
},
|
||||
"ayu-mirage": {
|
||||
title_color: "f4cd7c",
|
||||
icon_color: "73d0ff",
|
||||
text_color: "c7c8c2",
|
||||
bg_color: "1f2430",
|
||||
},
|
||||
"midnight-purple": {
|
||||
title_color: "9745f5",
|
||||
icon_color: "9f4bff",
|
||||
text_color: "ffffff",
|
||||
bg_color: "000000",
|
||||
},
|
||||
calm: {
|
||||
title_color: "e07a5f",
|
||||
icon_color: "edae49",
|
||||
text_color: "ebcfb2",
|
||||
bg_color: "373f51",
|
||||
},
|
||||
"flag-india": {
|
||||
title_color: "ff8f1c",
|
||||
icon_color: "250E62",
|
||||
text_color: "509E2F",
|
||||
bg_color: "ffffff",
|
||||
},
|
||||
omni: {
|
||||
title_color: "FF79C6",
|
||||
icon_color: "e7de79",
|
||||
text_color: "E1E1E6",
|
||||
bg_color: "191622",
|
||||
},
|
||||
react: {
|
||||
title_color: "61dafb",
|
||||
icon_color: "61dafb",
|
||||
text_color: "ffffff",
|
||||
bg_color: "20232a",
|
||||
},
|
||||
jolly: {
|
||||
title_color: "ff64da",
|
||||
icon_color: "a960ff",
|
||||
text_color: "ffffff",
|
||||
bg_color: "291B3E",
|
||||
},
|
||||
maroongold: {
|
||||
title_color: "F7EF8A",
|
||||
icon_color: "F7EF8A",
|
||||
text_color: "E0AA3E",
|
||||
bg_color: "260000",
|
||||
},
|
||||
yeblu: {
|
||||
title_color: "ffff00",
|
||||
icon_color: "ffff00",
|
||||
text_color: "ffffff",
|
||||
bg_color: "002046",
|
||||
},
|
||||
blueberry: {
|
||||
title_color: "82aaff",
|
||||
icon_color: "89ddff",
|
||||
text_color: "27e8a7",
|
||||
bg_color: "242938",
|
||||
},
|
||||
slateorange: {
|
||||
title_color: "faa627",
|
||||
icon_color: "faa627",
|
||||
text_color: "ffffff",
|
||||
bg_color: "36393f",
|
||||
},
|
||||
kacho_ga: {
|
||||
title_color: "bf4a3f",
|
||||
icon_color: "a64833",
|
||||
text_color: "d9c8a9",
|
||||
bg_color: "402b23",
|
||||
},
|
||||
outrun: {
|
||||
title_color: "ffcc00",
|
||||
icon_color: "ff1aff",
|
||||
text_color: "8080ff",
|
||||
bg_color: "141439",
|
||||
},
|
||||
ocean_dark: {
|
||||
title_color: "8957B2",
|
||||
icon_color: "FFFFFF",
|
||||
text_color: "92D534",
|
||||
bg_color: "151A28",
|
||||
},
|
||||
city_lights: {
|
||||
title_color: "5D8CB3",
|
||||
icon_color: "4798FF",
|
||||
text_color: "718CA1",
|
||||
bg_color: "1D252C",
|
||||
},
|
||||
github_dark: {
|
||||
title_color: "58A6FF",
|
||||
icon_color: "1F6FEB",
|
||||
text_color: "C3D1D9",
|
||||
bg_color: "0D1117",
|
||||
},
|
||||
github_dark_dimmed: {
|
||||
title_color: "539bf5",
|
||||
icon_color: "539bf5",
|
||||
text_color: "ADBAC7",
|
||||
bg_color: "24292F",
|
||||
border_color: "373E47",
|
||||
},
|
||||
discord_old_blurple: {
|
||||
title_color: "7289DA",
|
||||
icon_color: "7289DA",
|
||||
text_color: "FFFFFF",
|
||||
bg_color: "2C2F33",
|
||||
},
|
||||
aura_dark: {
|
||||
title_color: "ff7372",
|
||||
icon_color: "6cffd0",
|
||||
text_color: "dbdbdb",
|
||||
bg_color: "252334",
|
||||
},
|
||||
panda: {
|
||||
title_color: "19f9d899",
|
||||
icon_color: "19f9d899",
|
||||
text_color: "FF75B5",
|
||||
bg_color: "31353a",
|
||||
},
|
||||
noctis_minimus: {
|
||||
title_color: "d3b692",
|
||||
icon_color: "72b7c0",
|
||||
text_color: "c5cdd3",
|
||||
bg_color: "1b2932",
|
||||
},
|
||||
cobalt2: {
|
||||
title_color: "ffc600",
|
||||
icon_color: "ffffff",
|
||||
text_color: "0088ff",
|
||||
bg_color: "193549",
|
||||
},
|
||||
swift: {
|
||||
title_color: "000000",
|
||||
icon_color: "f05237",
|
||||
text_color: "000000",
|
||||
bg_color: "f7f7f7",
|
||||
},
|
||||
aura: {
|
||||
title_color: "a277ff",
|
||||
icon_color: "ffca85",
|
||||
text_color: "61ffca",
|
||||
bg_color: "15141b",
|
||||
},
|
||||
apprentice: {
|
||||
title_color: "ffffff",
|
||||
icon_color: "ffffaf",
|
||||
text_color: "bcbcbc",
|
||||
bg_color: "262626",
|
||||
},
|
||||
moltack: {
|
||||
title_color: "86092C",
|
||||
icon_color: "86092C",
|
||||
text_color: "574038",
|
||||
bg_color: "F5E1C0",
|
||||
},
|
||||
codeSTACKr: {
|
||||
title_color: "ff652f",
|
||||
icon_color: "FFE400",
|
||||
text_color: "ffffff",
|
||||
bg_color: "09131B",
|
||||
border_color: "0c1a25",
|
||||
},
|
||||
rose_pine: {
|
||||
title_color: "9ccfd8",
|
||||
icon_color: "ebbcba",
|
||||
text_color: "e0def4",
|
||||
bg_color: "191724",
|
||||
},
|
||||
catppuccin_latte: {
|
||||
title_color: "137980",
|
||||
icon_color: "8839ef",
|
||||
text_color: "4c4f69",
|
||||
bg_color: "eff1f5",
|
||||
},
|
||||
catppuccin_mocha: {
|
||||
title_color: "94e2d5",
|
||||
icon_color: "cba6f7",
|
||||
text_color: "cdd6f4",
|
||||
bg_color: "1e1e2e",
|
||||
},
|
||||
date_night: {
|
||||
title_color: "DA7885",
|
||||
text_color: "E1B2A2",
|
||||
icon_color: "BB8470",
|
||||
border_color: "170F0C",
|
||||
bg_color: "170F0C",
|
||||
},
|
||||
one_dark_pro: {
|
||||
title_color: "61AFEF",
|
||||
text_color: "E5C06E",
|
||||
icon_color: "C678DD",
|
||||
border_color: "3B4048",
|
||||
bg_color: "23272E",
|
||||
},
|
||||
rose: {
|
||||
title_color: "8d192b",
|
||||
text_color: "862931",
|
||||
icon_color: "B71F36",
|
||||
border_color: "e9d8d4",
|
||||
bg_color: "e9d8d4",
|
||||
},
|
||||
holi: {
|
||||
title_color: "5FABEE",
|
||||
text_color: "D6E7FF",
|
||||
icon_color: "5FABEE",
|
||||
border_color: "85A4C0",
|
||||
bg_color: "030314",
|
||||
},
|
||||
neon: {
|
||||
title_color: "00EAD3",
|
||||
text_color: "FF449F",
|
||||
icon_color: "00EAD3",
|
||||
border_color: "ffffff",
|
||||
bg_color: "000000",
|
||||
},
|
||||
blue_navy: {
|
||||
title_color: "82AAFF",
|
||||
text_color: "82AAFF",
|
||||
icon_color: "82AAFF",
|
||||
border_color: "ffffff",
|
||||
bg_color: "000000",
|
||||
},
|
||||
calm_pink: {
|
||||
title_color: "e07a5f",
|
||||
text_color: "edae49",
|
||||
icon_color: "ebcfb2",
|
||||
border_color: "e1bc29",
|
||||
bg_color: "2b2d40",
|
||||
},
|
||||
ambient_gradient: {
|
||||
title_color: "ffffff",
|
||||
text_color: "ffffff",
|
||||
icon_color: "ffffff",
|
||||
bg_color: "35,4158d0,c850c0,ffcc70",
|
||||
},
|
||||
} as const satisfies Record<string, Theme>;
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user