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:
Martin
2026-04-18 10:37:26 +02:00
committed by GitHub
co-authored by Marco Pasqualetti
parent 19c39f02af
commit d476f8cbe1
159 changed files with 6515 additions and 4424 deletions
@@ -1,76 +0,0 @@
/* eslint-disable import-x/no-unresolved */
import { default as authenticate } from "./api-renamed/authenticate.js";
import { default as deleteUser } from "./api-renamed/delete-user.js";
import { default as downgrade } from "./api-renamed/downgrade.js";
import { default as gist } from "./api-renamed/gist.js";
import { default as api } from "./api-renamed/index.js";
import { default as pin } from "./api-renamed/pin.js";
import { default as repeatRecent } from "./api-renamed/repeat-recent.js";
import { default as patInfo } from "./api-renamed/status/pat-info.js";
import { default as statusUp } from "./api-renamed/status/up.js";
import { default as topLangs } from "./api-renamed/top-langs.js";
import { default as userAccess } from "./api-renamed/user-access.js";
import { default as wakatimeProxy } from "./api-renamed/wakatime-proxy.js";
import { default as wakatime } from "./api-renamed/wakatime.js";
export default async (req, res) => {
// remaining code expects express.js-like request and response objects
res.send = function (data) {
if (typeof data === "object") {
res.setHeader("Content-Type", "application/json");
res.end(JSON.stringify(data));
} else if (typeof data === "string") {
res.end(data);
} else {
res.end(String(data));
}
};
const url = new URL(req.url, "https://localhost");
req.query = Object.fromEntries(url.searchParams.entries());
switch (url.pathname) {
case "/api":
await api(req, res);
break;
case "/api/gist":
await gist(req, res);
break;
case "/api/pin":
await pin(req, res);
break;
case "/api/top-langs":
await topLangs(req, res);
break;
case "/api/wakatime":
await wakatime(req, res);
break;
case "/api/wakatime-proxy":
await wakatimeProxy(req, res);
break;
case "/api/repeat-recent":
await repeatRecent(req, res);
break;
case "/api/status/pat-info":
await patInfo(req, res);
break;
case "/api/status/up":
await statusUp(req, res);
break;
case "/api/authenticate":
await authenticate(req, res);
break;
case "/api/delete-user":
await deleteUser(req, res);
break;
case "/api/user-access":
await userAccess(req, res);
break;
case "/api/downgrade":
await downgrade(req, res);
break;
default:
res.statusCode = 404;
res.end("Not Found");
break;
}
};
+2 -1
View File
@@ -1,4 +1,5 @@
import { logger } from "../src/common/log.js";
import { logger } from "@stats-organization/github-readme-stats-core";
import { authenticate } from "../src/users.js";
/**
+2 -1
View File
@@ -1,5 +1,6 @@
import { logger } from "@stats-organization/github-readme-stats-core";
import { deleteUser } from "../src/common/database.js";
import { logger } from "../src/common/log.js";
/**
* @param {any} req The request.
+1 -1
View File
@@ -1,7 +1,7 @@
import { logger } from "@stats-organization/github-readme-stats-core";
import axios from "axios";
import { deleteUser, getUserAccessByKey } from "../src/common/database.js";
import { logger } from "../src/common/log.js";
export default async (req, res) => {
// We could optimize this method by doing both database operations in one statement, using "DELETE ... RETURNING ..."
-129
View File
@@ -1,129 +0,0 @@
// @ts-check
import { renderGistCard } from "../src/cards/gist.js";
import { guardAccess } from "../src/common/access.js";
import {
CACHE_TTL,
resolveCacheSeconds,
setCacheHeaders,
setErrorCacheHeaders,
} from "../src/common/cache.js";
import { storeRequest } from "../src/common/database.js";
import {
MissingParamError,
retrieveSecondaryMessage,
} from "../src/common/error.js";
import { parseBoolean } from "../src/common/ops.js";
import { renderError } from "../src/common/render.js";
import { fetchGist } from "../src/fetchers/gist.js";
import { isLocaleAvailable } from "../src/translations.js";
// @ts-ignore
export default async (req, res) => {
const {
id,
title_color,
icon_color,
text_color,
bg_color,
theme,
cache_seconds,
locale,
border_radius,
border_color,
show_owner,
hide_border,
} = req.query;
res.setHeader("Content-Type", "image/svg+xml");
const access = guardAccess({
res,
id,
type: "gist",
colors: {
title_color,
text_color,
bg_color,
border_color,
theme,
},
});
if (!access.isPassed) {
return access.result;
}
if (locale && !isLocaleAvailable(locale)) {
return res.send(
renderError({
message: "Something went wrong",
secondaryMessage: "Language not found",
renderOptions: {
title_color,
text_color,
bg_color,
border_color,
theme,
},
}),
);
}
try {
await storeRequest(req);
const gistData = await fetchGist(id);
const cacheSeconds = resolveCacheSeconds({
requested: parseInt(cache_seconds, 10),
def: CACHE_TTL.GIST_CARD.DEFAULT,
min: CACHE_TTL.GIST_CARD.MIN,
max: CACHE_TTL.GIST_CARD.MAX,
});
setCacheHeaders(res, cacheSeconds);
return res.send(
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) {
setErrorCacheHeaders(res);
if (err instanceof Error) {
return res.send(
renderError({
message: err.message,
secondaryMessage: retrieveSecondaryMessage(err),
renderOptions: {
title_color,
text_color,
bg_color,
border_color,
theme,
show_repo_link: !(err instanceof MissingParamError),
},
}),
);
}
return res.send(
renderError({
message: "An unknown error occurred",
renderOptions: {
title_color,
text_color,
bg_color,
border_color,
theme,
},
}),
);
}
};
-214
View File
@@ -1,214 +0,0 @@
// @ts-check
import { renderStatsCard } from "../src/cards/stats.js";
import { guardAccess } from "../src/common/access.js";
import {
CACHE_TTL,
resolveCacheSeconds,
setCacheHeaders,
setErrorCacheHeaders,
} from "../src/common/cache.js";
import { storeRequest } from "../src/common/database.js";
import {
MissingParamError,
retrieveSecondaryMessage,
} from "../src/common/error.js";
import { parseArray, parseBoolean } from "../src/common/ops.js";
import { renderError } from "../src/common/render.js";
import { fetchStats } from "../src/fetchers/stats.js";
import { isLocaleAvailable } from "../src/translations.js";
// @ts-ignore
export default async (req, res) => {
const {
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,
cache_seconds,
exclude_repo,
custom_title,
locale,
disable_animations,
border_radius,
number_format,
role,
number_precision,
border_color,
rank_icon,
show,
} = req.query;
res.setHeader("Content-Type", "image/svg+xml");
const access = guardAccess({
res,
id: username,
type: "username",
colors: {
title_color,
text_color,
bg_color,
border_color,
theme,
},
});
if (!access.isPassed) {
return access.result;
}
if (locale && !isLocaleAvailable(locale)) {
return res.send(
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 res.send(
renderError({
message: "Something went wrong",
secondaryMessage:
"Username, repository or owner contains unsafe characters",
renderOptions: {
title_color,
text_color,
bg_color,
border_color,
theme,
},
}),
);
}
try {
await storeRequest(req);
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),
);
const cacheSeconds = resolveCacheSeconds({
requested: parseInt(cache_seconds, 10),
def: CACHE_TTL.STATS_CARD.DEFAULT,
min: CACHE_TTL.STATS_CARD.MIN,
max: CACHE_TTL.STATS_CARD.MAX,
});
setCacheHeaders(res, cacheSeconds);
return res.send(
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) {
setErrorCacheHeaders(res);
if (err instanceof Error) {
return res.send(
renderError({
message: err.message,
secondaryMessage: retrieveSecondaryMessage(err),
renderOptions: {
title_color,
text_color,
bg_color,
border_color,
theme,
show_repo_link: !(err instanceof MissingParamError),
},
}),
);
}
return res.send(
renderError({
message: "An unknown error occurred",
renderOptions: {
title_color,
text_color,
bg_color,
border_color,
theme,
},
}),
);
}
};
-175
View File
@@ -1,175 +0,0 @@
// @ts-check
import { renderRepoCard } from "../src/cards/repo.js";
import { guardAccess } from "../src/common/access.js";
import {
CACHE_TTL,
resolveCacheSeconds,
setCacheHeaders,
setErrorCacheHeaders,
} from "../src/common/cache.js";
import { storeRequest } from "../src/common/database.js";
import {
MissingParamError,
retrieveSecondaryMessage,
} from "../src/common/error.js";
import { parseArray, parseBoolean } from "../src/common/ops.js";
import { renderError } from "../src/common/render.js";
import { fetchRepo } from "../src/fetchers/repo.js";
import { isLocaleAvailable } from "../src/translations.js";
// @ts-ignore
export default async (req, res) => {
const {
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,
cache_seconds,
locale,
border_radius,
border_color,
description_lines_count,
} = req.query;
res.setHeader("Content-Type", "image/svg+xml");
const access = guardAccess({
res,
id: username,
type: "username",
colors: {
title_color,
text_color,
bg_color,
border_color,
theme,
},
});
if (!access.isPassed) {
return access.result;
}
if (locale && !isLocaleAvailable(locale)) {
return res.send(
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 res.send(
renderError({
message: "Something went wrong",
secondaryMessage: "Username or repository contains unsafe characters",
renderOptions: {
title_color,
text_color,
bg_color,
border_color,
theme,
},
}),
);
}
try {
await storeRequest(req);
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"),
);
const cacheSeconds = resolveCacheSeconds({
requested: parseInt(cache_seconds, 10),
def: CACHE_TTL.PIN_CARD.DEFAULT,
min: CACHE_TTL.PIN_CARD.MIN,
max: CACHE_TTL.PIN_CARD.MAX,
});
setCacheHeaders(res, cacheSeconds);
return res.send(
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) {
setErrorCacheHeaders(res);
if (err instanceof Error) {
return res.send(
renderError({
message: err.message,
secondaryMessage: retrieveSecondaryMessage(err),
renderOptions: {
title_color,
text_color,
bg_color,
border_color,
theme,
show_repo_link: !(err instanceof MissingParamError),
},
}),
);
}
return res.send(
renderError({
message: "An unknown error occurred",
renderOptions: {
title_color,
text_color,
bg_color,
border_color,
theme,
},
}),
);
}
};
+13 -11
View File
@@ -6,10 +6,12 @@
*
* @description This function is currently rate limited to 1 request per 3 minutes.
*/
import { request } from "../../src/common/http.js";
import { logger } from "../../src/common/log.js";
import { dateDiff } from "../../src/common/ops.js";
import {
dateDiff,
getConfig,
logger,
request,
} from "@stats-organization/github-readme-stats-core";
export const RATE_LIMIT_SECONDS = 60 * 3; // 1 request per 3 minutes
@@ -39,7 +41,7 @@ const uptimeFetcher = (variables, token) => {
};
const getAllPATs = () => {
return Object.keys(process.env).filter((key) => /PAT_\d*$/.exec(key));
return getConfig().pats;
};
/**
@@ -61,7 +63,7 @@ const getPATInfo = async (fetcher, variables) => {
for (const pat of PATs) {
try {
const response = await fetcher(variables, process.env[pat]);
const response = await fetcher(variables, pat.value);
const errors = response.data.errors;
const hasErrors = Boolean(errors);
const errorType = errors?.[0]?.type;
@@ -71,7 +73,7 @@ const getPATInfo = async (fetcher, variables) => {
// Store PATs with errors.
if (hasErrors && errorType !== "RATE_LIMITED") {
details[pat] = {
details[pat.name] = {
status: "error",
error: {
type: errors[0].type,
@@ -82,13 +84,13 @@ const getPATInfo = async (fetcher, variables) => {
} else if (isRateLimited) {
const date1 = new Date();
const date2 = new Date(response.data?.data?.rateLimit?.resetAt);
details[pat] = {
details[pat.name] = {
status: "exhausted",
remaining: 0,
resetIn: dateDiff(date2, date1) + " minutes",
};
} else {
details[pat] = {
details[pat.name] = {
status: "valid",
remaining: response.data.data.rateLimit.remaining,
};
@@ -97,11 +99,11 @@ const getPATInfo = async (fetcher, variables) => {
// Store the PAT if it is expired.
const errorMessage = err.response?.data?.message?.toLowerCase();
if (errorMessage === "bad credentials") {
details[pat] = {
details[pat.name] = {
status: "expired",
};
} else if (errorMessage === "sorry. your account was suspended.") {
details[pat] = {
details[pat.name] = {
status: "suspended",
};
} else {
+6 -4
View File
@@ -7,9 +7,11 @@
* @description This function is currently rate limited to 1 request per 3 minutes.
*/
import { request } from "../../src/common/http.js";
import { logger } from "../../src/common/log.js";
import { default as retryer } from "../../src/common/retryer.js";
import {
logger,
request,
retryer,
} from "@stats-organization/github-readme-stats-core";
export const RATE_LIMIT_SECONDS = 60 * 3; // 1 request per 3 minutes
@@ -87,7 +89,7 @@ export default async (req, res) => {
try {
let PATsValid = true;
try {
await retryer(uptimeFetcher, null, {});
await retryer(uptimeFetcher, {});
} catch (err) {
// Resolve eslint no-unused-vars
err;
-194
View File
@@ -1,194 +0,0 @@
// @ts-check
import { renderTopLanguages } from "../src/cards/top-languages.js";
import { guardAccess } from "../src/common/access.js";
import {
CACHE_TTL,
resolveCacheSeconds,
setCacheHeaders,
setErrorCacheHeaders,
} from "../src/common/cache.js";
import { storeRequest } from "../src/common/database.js";
import {
MissingParamError,
retrieveSecondaryMessage,
} from "../src/common/error.js";
import { parseArray, parseBoolean } from "../src/common/ops.js";
import { renderError } from "../src/common/render.js";
import { fetchTopLanguages } from "../src/fetchers/top-languages.js";
import { isLocaleAvailable } from "../src/translations.js";
// @ts-ignore
export default async (req, res) => {
const {
username,
hide,
hide_title,
hide_border,
card_width,
title_color,
text_color,
bg_color,
prog_bar_bg_color,
theme,
cache_seconds,
layout,
langs_count,
exclude_repo,
size_weight,
count_weight,
custom_title,
locale,
border_radius,
border_color,
role,
disable_animations,
hide_progress,
stats_format,
} = req.query;
res.setHeader("Content-Type", "image/svg+xml");
const access = guardAccess({
res,
id: username,
type: "username",
colors: {
title_color,
text_color,
bg_color,
border_color,
theme,
},
});
if (!access.isPassed) {
return access.result;
}
if (locale && !isLocaleAvailable(locale)) {
return res.send(
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 res.send(
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 res.send(
renderError({
message: "Something went wrong",
secondaryMessage: "Incorrect stats_format input",
renderOptions: {
title_color,
text_color,
bg_color,
border_color,
theme,
},
}),
);
}
try {
await storeRequest(req);
const topLangs = await fetchTopLanguages(
username,
parseArray(exclude_repo),
size_weight,
count_weight,
parseArray(role),
);
const cacheSeconds = resolveCacheSeconds({
requested: parseInt(cache_seconds, 10),
def: CACHE_TTL.TOP_LANGS_CARD.DEFAULT,
min: CACHE_TTL.TOP_LANGS_CARD.MIN,
max: CACHE_TTL.TOP_LANGS_CARD.MAX,
});
setCacheHeaders(res, cacheSeconds);
return res.send(
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) {
setErrorCacheHeaders(res);
if (err instanceof Error) {
return res.send(
renderError({
message: err.message,
secondaryMessage: retrieveSecondaryMessage(err),
renderOptions: {
title_color,
text_color,
bg_color,
border_color,
theme,
show_repo_link: !(err instanceof MissingParamError),
},
}),
);
}
return res.send(
renderError({
message: "An unknown error occurred",
renderOptions: {
title_color,
text_color,
bg_color,
border_color,
theme,
},
}),
);
}
};
+2 -1
View File
@@ -1,5 +1,6 @@
import { logger } from "@stats-organization/github-readme-stats-core";
import { getUserAccessByKey } from "../src/common/database.js";
import { logger } from "../src/common/log.js";
/**
* @param {any} req The request.
+4 -2
View File
@@ -1,5 +1,7 @@
import { logger } from "../src/common/log.js";
import { fetchWakatimeStats } from "../src/fetchers/wakatime.js";
import {
fetchWakatimeStats,
logger,
} from "@stats-organization/github-readme-stats-core";
/**
* @param {any} req The request.
-148
View File
@@ -1,148 +0,0 @@
// @ts-check
import { renderWakatimeCard } from "../src/cards/wakatime.js";
import { guardAccess } from "../src/common/access.js";
import {
CACHE_TTL,
resolveCacheSeconds,
setCacheHeaders,
setErrorCacheHeaders,
} from "../src/common/cache.js";
import { storeRequest } from "../src/common/database.js";
import {
MissingParamError,
retrieveSecondaryMessage,
} from "../src/common/error.js";
import { parseArray, parseBoolean } from "../src/common/ops.js";
import { renderError } from "../src/common/render.js";
import { fetchWakatimeStats } from "../src/fetchers/wakatime.js";
import { isLocaleAvailable } from "../src/translations.js";
// @ts-ignore
export default async (req, res) => {
const {
username,
title_color,
icon_color,
hide_border,
card_width,
line_height,
text_color,
bg_color,
theme,
cache_seconds,
hide_title,
hide_progress,
custom_title,
locale,
layout,
langs_count,
hide,
api_domain,
border_radius,
border_color,
display_format,
disable_animations,
} = req.query;
res.setHeader("Content-Type", "image/svg+xml");
const access = guardAccess({
res,
id: username,
type: "wakatime",
colors: {
title_color,
text_color,
bg_color,
border_color,
theme,
},
});
if (!access.isPassed) {
return access.result;
}
if (locale && !isLocaleAvailable(locale)) {
return res.send(
renderError({
message: "Something went wrong",
secondaryMessage: "Language not found",
renderOptions: {
title_color,
text_color,
bg_color,
border_color,
theme,
},
}),
);
}
try {
await storeRequest(req);
const stats = await fetchWakatimeStats({ username, api_domain });
const cacheSeconds = resolveCacheSeconds({
requested: parseInt(cache_seconds, 10),
def: CACHE_TTL.WAKATIME_CARD.DEFAULT,
min: CACHE_TTL.WAKATIME_CARD.MIN,
max: CACHE_TTL.WAKATIME_CARD.MAX,
});
setCacheHeaders(res, cacheSeconds);
return res.send(
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) {
setErrorCacheHeaders(res);
if (err instanceof Error) {
return res.send(
renderError({
message: err.message,
secondaryMessage: retrieveSecondaryMessage(err),
renderOptions: {
title_color,
text_color,
bg_color,
border_color,
theme,
show_repo_link: !(err instanceof MissingParamError),
},
}),
);
}
return res.send(
renderError({
message: "An unknown error occurred",
renderOptions: {
title_color,
text_color,
bg_color,
border_color,
theme,
},
}),
);
}
};
-13
View File
@@ -1,13 +0,0 @@
codecov:
require_ci_to_pass: yes
coverage:
precision: 2
round: down
range: "70...100"
status:
project:
default:
threshold: 5
patch: false
+2 -13
View File
@@ -1,21 +1,10 @@
import express from "express";
import gistCard from "./api-renamed/gist.js";
import statsCard from "./api-renamed/index.js";
import repoCard from "./api-renamed/pin.js";
import langCard from "./api-renamed/top-langs.js";
import wakatimeCard from "./api-renamed/wakatime.js";
import router from "./router.js";
const app = express();
const router = express.Router();
router.get("/", statsCard);
router.get("/pin", repoCard);
router.get("/top-langs", langCard);
router.get("/wakatime", wakatimeCard);
router.get("/gist", gistCard);
app.use("/api", router);
app.use(router);
const port = process.env.PORT || process.env.port || 9000;
app.listen(port, "0.0.0.0", () => {
+1
View File
@@ -0,0 +1 @@
export { default as router } from "./router.js";
+13 -36
View File
@@ -1,52 +1,29 @@
{
"name": "github-readme-stats",
"version": "1.0.0",
"description": "Dynamically generate stats for your GitHub readme",
"keywords": [
"github-readme-stats",
"readme-stats",
"cards",
"card-generator"
],
"main": "src/index.js",
"name": "@stats-organization/github-readme-stats-backend",
"type": "module",
"homepage": "https://github-stats-extended.vercel.app/frontend",
"bugs": {
"url": "https://github.com/stats-organization/github-stats-extended/issues"
},
"repository": {
"type": "git",
"url": "https://github.com/stats-organization/github-stats-extended.git"
"license": "MIT",
"engines": {
"node": "24.x"
},
"private": true,
"main": "index.js",
"scripts": {
"test": "vitest",
"test:update:snapshot": "vitest -u",
"test:e2e": "vitest --config vitest.config.e2e.ts",
"theme-readme-gen": "node scripts/generate-theme-doc",
"generate-langs-json": "node scripts/generate-langs-json",
"bench": "vitest bench --run --config vitest.config.bench.ts"
"bench": "vitest bench --run --config vitest.config.bench.ts",
"lint": "eslint",
"typecheck": "tsc -p tsconfig.typecheck.json"
},
"author": "Anurag Hazra",
"license": "MIT",
"devDependencies": {
"@testing-library/dom": "^10.4.1",
"@testing-library/jest-dom": "^6.9.1",
"@uppercod/css-to-object": "^1.1.1",
"@vitest/coverage-v8": "catalog:default",
"axios-mock-adapter": "^2.1.0",
"express": "^5.2.1",
"js-yaml": "^4.1.1",
"axios-mock-adapter": "2.1.0",
"express": "5.2.1",
"jsdom": "28.1.0",
"vitest": "catalog:default"
},
"dependencies": {
"axios": "^1.13.5",
"emoji-name-map": "^2.0.3",
"github-username-regex": "^1.0.0",
"pg": "^8.18.0",
"word-wrap": "^1.2.5"
},
"engines": {
"node": "24.x"
"@stats-organization/github-readme-stats-core": "workspace:^",
"pg": "^8.18.0"
}
}
File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 5.2 KiB

+225
View File
@@ -0,0 +1,225 @@
import {
api,
gist,
pin,
topLangs,
wakatime,
} from "@stats-organization/github-readme-stats-core";
import { default as authenticate } from "./api-renamed/authenticate.js";
import { default as deleteUser } from "./api-renamed/delete-user.js";
import { default as downgrade } from "./api-renamed/downgrade.js";
import { default as repeatRecent } from "./api-renamed/repeat-recent.js";
import { default as patInfo } from "./api-renamed/status/pat-info.js";
import { default as statusUp } from "./api-renamed/status/up.js";
import { default as userAccess } from "./api-renamed/user-access.js";
import { default as wakatimeProxy } from "./api-renamed/wakatime-proxy.js";
import { guardAccess } from "./src/common/access.js";
import {
CACHE_TTL,
resolveCacheSeconds,
setCacheHeaders,
setErrorCacheHeaders,
} from "./src/common/cache.js";
import { getUserAccessByName, storeRequest } from "./src/common/database.js";
const getGuardResult = (query, type, id) => {
const access = guardAccess({
id,
type,
colors: {
title_color: query.title_color,
text_color: query.text_color,
bg_color: query.bg_color,
border_color: query.border_color,
theme: query.theme,
},
});
if (access.isPassed) {
return null;
}
return {
status: "error - permanent",
content: access.result,
};
};
const getUserPat = async (username) => {
if (!username) {
return null;
}
const userAccess = await getUserAccessByName(username);
if (!userAccess?.token) {
return null;
}
return userAccess.token;
};
export default async (req, res) => {
const url = new URL(req.url, "https://localhost");
if (res.send === undefined) {
// remaining code expects express.js-like request and response objects
res.send = function (data) {
if (typeof data === "object") {
res.setHeader("Content-Type", "application/json");
res.end(JSON.stringify(data));
} else if (typeof data === "string") {
res.end(data);
} else {
res.end(String(data));
}
};
req.query = Object.fromEntries(url.searchParams.entries());
}
let result;
switch (url.pathname) {
case "/api": {
result = getGuardResult(req.query, "username", req.query.username);
if (!result) {
const userPat = await getUserPat(req.query.username);
result = await api(req.query, userPat);
}
if (result.status === "error - temporary") {
setErrorCacheHeaders(res);
} else {
const cacheSeconds = resolveCacheSeconds({
requested: parseInt(req.query.cache_seconds, 10),
def: CACHE_TTL.STATS_CARD.DEFAULT,
min: CACHE_TTL.STATS_CARD.MIN,
max: CACHE_TTL.STATS_CARD.MAX,
});
setCacheHeaders(res, cacheSeconds);
}
res.setHeader("Content-Type", "image/svg+xml");
res.end(result.content);
if (result.status !== "error - permanent") {
await storeRequest(req);
}
break;
}
case "/api/gist":
result =
getGuardResult(req.query, "gist", req.query.id) ??
(await gist(req.query));
if (result.status === "error - temporary") {
setErrorCacheHeaders(res);
} else {
const cacheSeconds = resolveCacheSeconds({
requested: parseInt(req.query.cache_seconds, 10),
def: CACHE_TTL.GIST_CARD.DEFAULT,
min: CACHE_TTL.GIST_CARD.MIN,
max: CACHE_TTL.GIST_CARD.MAX,
});
setCacheHeaders(res, cacheSeconds);
}
res.setHeader("Content-Type", "image/svg+xml");
res.end(result.content);
if (result.status !== "error - permanent") {
await storeRequest(req);
}
break;
case "/api/pin": {
result = getGuardResult(req.query, "username", req.query.username);
if (!result) {
const userPat = await getUserPat(req.query.username);
result = await pin(req.query, userPat);
}
if (result.status === "error - temporary") {
setErrorCacheHeaders(res);
} else {
const cacheSeconds = resolveCacheSeconds({
requested: parseInt(req.query.cache_seconds, 10),
def: CACHE_TTL.PIN_CARD.DEFAULT,
min: CACHE_TTL.PIN_CARD.MIN,
max: CACHE_TTL.PIN_CARD.MAX,
});
setCacheHeaders(res, cacheSeconds);
}
res.setHeader("Content-Type", "image/svg+xml");
res.end(result.content);
if (result.status !== "error - permanent") {
await storeRequest(req);
}
break;
}
case "/api/top-langs": {
result = getGuardResult(req.query, "username", req.query.username);
if (!result) {
const userPat = await getUserPat(req.query.username);
result = await topLangs(req.query, userPat);
}
if (result.status === "error - temporary") {
setErrorCacheHeaders(res);
} else {
const cacheSeconds = resolveCacheSeconds({
requested: parseInt(req.query.cache_seconds, 10),
def: CACHE_TTL.TOP_LANGS_CARD.DEFAULT,
min: CACHE_TTL.TOP_LANGS_CARD.MIN,
max: CACHE_TTL.TOP_LANGS_CARD.MAX,
});
setCacheHeaders(res, cacheSeconds);
}
res.setHeader("Content-Type", "image/svg+xml");
res.end(result.content);
if (result.status !== "error - permanent") {
await storeRequest(req);
}
break;
}
case "/api/wakatime":
result =
getGuardResult(req.query, "wakatime", req.query.username) ??
(await wakatime(req.query));
if (result.status === "error - temporary") {
setErrorCacheHeaders(res);
} else {
const cacheSeconds = resolveCacheSeconds({
requested: parseInt(req.query.cache_seconds, 10),
def: CACHE_TTL.WAKATIME_CARD.DEFAULT,
min: CACHE_TTL.WAKATIME_CARD.MIN,
max: CACHE_TTL.WAKATIME_CARD.MAX,
});
setCacheHeaders(res, cacheSeconds);
}
res.setHeader("Content-Type", "image/svg+xml");
res.end(result.content);
if (result.status !== "error - permanent") {
await storeRequest(req);
}
break;
case "/api/wakatime-proxy":
await wakatimeProxy(req, res);
break;
case "/api/repeat-recent":
await repeatRecent(req, res);
break;
case "/api/status/pat-info":
await patInfo(req, res);
break;
case "/api/status/up":
await statusUp(req, res);
break;
case "/api/authenticate":
await authenticate(req, res);
break;
case "/api/delete-user":
await deleteUser(req, res);
break;
case "/api/user-access":
await userAccess(req, res);
break;
case "/api/downgrade":
await downgrade(req, res);
break;
default:
res.statusCode = 404;
res.end("Not Found");
break;
}
};
@@ -1,30 +0,0 @@
import fs from "fs";
import axios from "axios";
import { default as jsYaml } from "js-yaml";
import * as prettier from "prettier";
const LANGS_FILEPATH = "./src/common/languageColors.json";
// Retrieve languages from github linguist repository yaml file
const response = await axios.get(
"https://raw.githubusercontent.com/github/linguist/master/lib/linguist/languages.yml",
);
// and convert them to a JS Object
const languages = jsYaml.load(response.data);
const languageColors = {};
// Filter only language colors from the whole file
Object.keys(languages).forEach((lang) => {
languageColors[lang] = languages[lang].color;
});
// Debug Print
// console.dir(languageColors);
const jsonString = JSON.stringify(languageColors);
fs.writeFileSync(
LANGS_FILEPATH,
await prettier.format(jsonString, { parser: "json" }),
);
-106
View File
@@ -1,106 +0,0 @@
import fs from "fs";
import { themes } from "../themes/index.js";
const TARGET_FILE = "./themes/README.md";
const REPO_CARD_LINKS_FLAG = "<!-- REPO_CARD_LINKS -->";
const STAT_CARD_LINKS_FLAG = "<!-- STATS_CARD_LINKS -->";
const STAT_CARD_TABLE_FLAG = "<!-- STATS_CARD_TABLE -->";
const REPO_CARD_TABLE_FLAG = "<!-- REPO_CARD_TABLE -->";
const THEME_TEMPLATE = `## 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
![Anurag's GitHub stats](https://github-stats-extended.vercel.app/api?username=anuraghazra&theme=dark&show_icons=true)
\`\`\`
## Stats
> These themes work with all five of our cards: Stats Card, Repo Card, Gist Card, Top Languages Card, and WakaTime Card.
| | | |
| :--: | :--: | :--: |
${STAT_CARD_TABLE_FLAG}
## Repo Card
> These themes work with all five of our cards: Stats Card, Repo Card, Gist Card, Top Languages Card, and WakaTime Card.
| | | |
| :--: | :--: | :--: |
${REPO_CARD_TABLE_FLAG}
${STAT_CARD_LINKS_FLAG}
${REPO_CARD_LINKS_FLAG}
`;
const createRepoMdLink = (theme) => {
return `\n[${theme}_repo]: https://github-stats-extended.vercel.app/api/pin/?username=anuraghazra&repo=github-readme-stats&cache_seconds=86400&theme=${theme}`;
};
const createStatMdLink = (theme) => {
return `\n[${theme}]: https://github-stats-extended.vercel.app/api?username=anuraghazra&show_icons=true&hide=contribs,prs&cache_seconds=86400&theme=${theme}`;
};
const generateLinks = (fn) => {
return Object.keys(themes)
.map((name) => fn(name))
.join("");
};
const createTableItem = ({ link, label, isRepoCard }) => {
if (!link || !label) {
return "";
}
return `\`${label}\` ![${link}][${link}${isRepoCard ? "_repo" : ""}]`;
};
const generateTable = ({ isRepoCard }) => {
const rows = [];
const themesFiltered = Object.keys(themes).filter(
(name) => name !== (isRepoCard ? "default" : "default_repocard"),
);
for (let i = 0; i < themesFiltered.length; i += 3) {
const one = themesFiltered[i];
const two = themesFiltered[i + 1];
const three = themesFiltered[i + 2];
let tableItem1 = createTableItem({ link: one, label: one, isRepoCard });
let tableItem2 = createTableItem({ link: two, label: two, isRepoCard });
let tableItem3 = createTableItem({ link: three, label: three, isRepoCard });
rows.push(`| ${tableItem1} | ${tableItem2} | ${tableItem3} |`);
}
return rows.join("\n");
};
const buildReadme = () => {
return THEME_TEMPLATE.split("\n")
.map((line) => {
if (line.includes(REPO_CARD_LINKS_FLAG)) {
return generateLinks(createRepoMdLink);
}
if (line.includes(STAT_CARD_LINKS_FLAG)) {
return generateLinks(createStatMdLink);
}
if (line.includes(REPO_CARD_TABLE_FLAG)) {
return generateTable({ isRepoCard: true });
}
if (line.includes(STAT_CARD_TABLE_FLAG)) {
return generateTable({ isRepoCard: false });
}
return line;
})
.join("\n");
};
fs.writeFileSync(TARGET_FILE, buildReadme());
-86
View File
@@ -1,86 +0,0 @@
/**
* 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 };
-141
View File
@@ -1,141 +0,0 @@
// @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 };
-4
View File
@@ -1,4 +0,0 @@
export { renderRepoCard } from "./repo.js";
export { renderStatsCard } from "./stats.js";
export { renderTopLanguages } from "./top-languages.js";
export { renderWakatimeCard } from "./wakatime.js";
-324
View File
@@ -1,324 +0,0 @@
// @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}&amp;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}&amp;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}&amp;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}&amp;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}&amp;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 };
-673
View File
@@ -1,673 +0,0 @@
// @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;
}
${process.env.NODE_ENV === "test" ? "" : 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}&amp;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}&amp;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}&amp;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}&amp;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}&amp;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 };
-977
View File
@@ -1,977 +0,0 @@
// @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 = 0;
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,
};
-75
View File
@@ -1,75 +0,0 @@
type ThemeNames = keyof typeof import("../../themes/index.js");
type RankIcon = "default" | "github" | "percentile";
type 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: 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: string[];
};
export type RepoCardOptions = CommonOptions & {
show_owner: boolean;
description_lines_count: number;
card_width_input;
show: 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: 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: 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;
};
-471
View File
@@ -1,471 +0,0 @@
// @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 };
-275
View File
@@ -1,275 +0,0 @@
// @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}
${process.env.NODE_ENV === "test" ? "" : 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;
-42
View File
@@ -1,42 +0,0 @@
// @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 };
+25 -25
View File
@@ -1,8 +1,11 @@
// @ts-check
import {
getConfig,
renderError,
} from "@stats-organization/github-readme-stats-core";
import { blacklist } from "./blacklist.js";
import { gistWhitelist, whitelist } from "./envs.js";
import { renderError } from "./render.js";
const NOT_WHITELISTED_USERNAME_MESSAGE = "This username is not whitelisted";
const NOT_WHITELISTED_GIST_MESSAGE = "This gist ID is not whitelisted";
@@ -12,36 +15,35 @@ const BLACKLISTED_MESSAGE = "This username is blacklisted";
* Guards access using whitelist/blacklist.
*
* @param {Object} args The parameters object.
* @param {any} args.res The response object.
* @param {string} args.id Resource identifier (username or gist id).
* @param {"username"|"gist"|"wakatime"} args.type The type of identifier.
* @param {{ title_color?: string, text_color?: string, bg_color?: string, border_color?: string, theme?: string }} args.colors Color options for the error card.
* @returns {{ isPassed: boolean, result?: any }} The result object indicating success or failure.
*/
const guardAccess = ({ res, id, type, colors }) => {
const guardAccess = ({ id, type, colors }) => {
if (!["username", "gist", "wakatime"].includes(type)) {
throw new Error(
'Invalid type. Expected "username", "gist", or "wakatime".',
);
}
const currentWhitelist = type === "gist" ? gistWhitelist : whitelist;
const config = getConfig();
const currentWhitelist =
type === "gist" ? config.gistWhitelist : config.whitelist;
const notWhitelistedMsg =
type === "gist"
? NOT_WHITELISTED_GIST_MESSAGE
: NOT_WHITELISTED_USERNAME_MESSAGE;
if (Array.isArray(currentWhitelist) && !currentWhitelist.includes(id)) {
const result = res.send(
renderError({
message: notWhitelistedMsg,
secondaryMessage: "Please deploy your own instance",
renderOptions: {
...colors,
show_repo_link: false,
},
}),
);
const result = renderError({
message: notWhitelistedMsg,
secondaryMessage: "Please deploy your own instance",
renderOptions: {
...colors,
show_repo_link: false,
},
});
return { isPassed: false, result };
}
@@ -50,16 +52,14 @@ const guardAccess = ({ res, id, type, colors }) => {
currentWhitelist === undefined &&
blacklist.includes(id)
) {
const result = res.send(
renderError({
message: BLACKLISTED_MESSAGE,
secondaryMessage: "Please deploy your own instance",
renderOptions: {
...colors,
show_repo_link: false,
},
}),
);
const result = renderError({
message: BLACKLISTED_MESSAGE,
secondaryMessage: "Please deploy your own instance",
renderOptions: {
...colors,
show_repo_link: false,
},
});
return { isPassed: false, result };
}
+3 -6
View File
@@ -1,6 +1,6 @@
// @ts-check
import { clampValue } from "./ops.js";
import { clampValue } from "@stats-organization/github-readme-stats-core";
const MIN = 60;
const HOUR = 60 * MIN;
@@ -106,7 +106,7 @@ const disableCaching = (res) => {
* @param {number} cacheSeconds The cache seconds to set in the headers.
*/
const setCacheHeaders = (res, cacheSeconds) => {
if (cacheSeconds < 1 || process.env.NODE_ENV === "development") {
if (cacheSeconds < 1) {
disableCaching(res);
return;
}
@@ -128,10 +128,7 @@ const setErrorCacheHeaders = (res) => {
const envCacheSeconds = process.env.CACHE_SECONDS
? parseInt(process.env.CACHE_SECONDS, 10)
: NaN;
if (
(!isNaN(envCacheSeconds) && envCacheSeconds < 1) ||
process.env.NODE_ENV === "development"
) {
if (!isNaN(envCacheSeconds) && envCacheSeconds < 1) {
disableCaching(res);
return;
}
-145
View File
@@ -1,145 +0,0 @@
// @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 };
-9
View File
@@ -1,9 +0,0 @@
// @ts-check
/**
* Valid owner affiliations for GitHub API queries.
* @type {Array<string>}
*/
const OWNER_AFFILIATIONS = ["OWNER", "COLLABORATOR", "ORGANIZATION_MEMBER"];
export { OWNER_AFFILIATIONS };
+7 -11
View File
@@ -1,14 +1,10 @@
/**
* In the browser this has to be mocked to avoid runtime errors
* @see apps/frontend/vite.config.ts
*/
import { Pool } from "pg";
export const pool = process.env.POSTGRES_URL
? new Pool({
connectionString: process.env.POSTGRES_URL,
})
: null;
export let pool = null;
if (process.env.POSTGRES_URL) {
const { Pool } = await import("pg");
pool = new Pool({
connectionString: process.env.POSTGRES_URL,
});
}
/**
* Creates all required tables if they do not exist.
-15
View File
@@ -1,15 +0,0 @@
// @ts-check
const whitelist = process.env.WHITELIST
? process.env.WHITELIST.split(",")
: undefined;
const gistWhitelist = process.env.GIST_WHITELIST
? process.env.GIST_WHITELIST.split(",")
: undefined;
const excludeRepositories = process.env.EXCLUDE_REPO
? process.env.EXCLUDE_REPO.split(",")
: [];
export { whitelist, gistWhitelist, excludeRepositories };
-90
View File
@@ -1,90 +0,0 @@
// @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,
};
-91
View File
@@ -1,91 +0,0 @@
// @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 };
-22
View File
@@ -1,22 +0,0 @@
// @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 };
-21
View File
@@ -1,21 +0,0 @@
// @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 };
-55
View File
@@ -1,55 +0,0 @@
// @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 };
-13
View File
@@ -1,13 +0,0 @@
// @ts-check
export { blacklist } from "./blacklist.js";
export { Card } from "./Card.js";
export { I18n } from "./I18n.js";
export { icons } from "./icons.js";
export { retryer } from "./retryer.js";
export {
ERROR_CARD_LENGTH,
renderError,
flexLayout,
measureText,
} from "./render.js";
-663
View File
@@ -1,663 +0,0 @@
{
"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",
"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",
"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",
"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"
}
-13
View File
@@ -1,13 +0,0 @@
// @ts-check
const noop = () => {};
/**
* Return console instance based on the environment.
*
* @type {Console | {log: () => void, error: () => void}}
*/
const logger =
process.env.NODE_ENV === "test" ? { log: noop, error: noop } : console;
export { logger };
-172
View File
@@ -1,172 +0,0 @@
// @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,
};
-239
View File
@@ -1,239 +0,0 @@
// @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 {
ERROR_CARD_LENGTH,
renderError,
createLanguageNode,
createProgressNode,
iconWithLabel,
flexLayout,
measureText,
};
-113
View File
@@ -1,113 +0,0 @@
// @ts-check
import { getUserAccessByName } from "./database.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 {string?} username GitHub username of the user whose PAT to use, if available
* @param {any} variables Object with arguments to pass to the fetcher function.
* @returns {Promise<any>} The response from the fetcher function.
*/
const retryer = async (fetcher, username, variables) => {
let userPAT;
if (username) {
userPAT = await getUserAccessByName(username);
}
let PATs;
if (userPAT?.token) {
PATs = [{ name: `USER_${username}`, value: userPAT.token }];
} else {
const patNames = Object.keys(process.env).filter((key) =>
/PAT_\d*$/.exec(key),
);
PATs = patNames.map((name) => ({ name, value: process.env[name] }));
}
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 };
export default retryer;
-113
View File
@@ -1,113 +0,0 @@
// @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.
* @returns {Promise<GistData>} Gist data.
*/
const fetchGist = async (id) => {
if (!id) {
throw new MissingParamError(["id"], "/api/gist?id=GIST_ID");
}
const res = await retryer(fetcher, null, { gistName: id });
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 };
-162
View File
@@ -1,162 +0,0 @@
// @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&amp;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,
) => {
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, username, { login: owner, repo: reponame });
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,
);
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,
);
return {
...repoUserStats,
...data.organization.repository,
starCount: data.organization.repository.stargazers.totalCount,
};
}
throw new Error("Unexpected behavior");
};
export { fetchRepo };
-457
View File
@@ -1,457 +0,0 @@
// @ts-check
import axios from "axios";
import githubUsernameRegex from "github-username-regex";
import { calculateRank } from "../calculateRank.js";
import { excludeRepositories } from "../common/envs.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[]} ownerAffiliations The owner affiliations to filter by. Default: OWNER.
* @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,
}) => {
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, username, variables);
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 =
(process.env.FETCH_MULTI_PAGE_STARS === "true" ||
process.env.FETCH_MULTI_PAGE_STARS > 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) => {
if (!githubUsernameRegex.test(username)) {
logger.log("Invalid username provided.");
throw new Error("Invalid username provided.");
}
let res;
try {
res = await retryer(fetchTotalItems, username, {
login: username,
repo,
owner,
type,
filter,
});
} catch (err) {
logger.log(err);
throw new Error(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,
) => {
let stats = {};
if (include_prs_authored) {
stats.totalPRsAuthored = await totalItemsFetcher(
username,
repo,
owner,
"issues",
`author:${username}+type:pr`,
);
}
if (include_prs_commented) {
stats.totalPRsCommented = await totalItemsFetcher(
username,
repo,
owner,
"issues",
`commenter:${username}+-author:${username}+type:pr`,
);
}
if (include_prs_reviewed) {
stats.totalPRsReviewed = await totalItemsFetcher(
username,
repo,
owner,
"issues",
`reviewed-by:${username}+-author:${username}+type:pr`,
);
}
if (include_issues_authored) {
stats.totalIssuesAuthored = await totalItemsFetcher(
username,
repo,
owner,
"issues",
`author:${username}+type:issue`,
);
}
if (include_issues_commented) {
stats.totalIssuesCommented = await totalItemsFetcher(
username,
repo,
owner,
"issues",
`commenter:${username}+-author:${username}+type:issue`,
);
}
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 = [],
) => {
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,
});
// 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}`,
);
} 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,
);
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, ...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 };
-168
View File
@@ -1,168 +0,0 @@
// @ts-check
import { excludeRepositories } from "../common/envs.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.
* @returns {Promise<TopLangData>} Top languages data.
*/
const fetchTopLanguages = async (
username,
exclude_repo = [],
size_weight = 1,
count_weight = 0,
ownerAffiliations = [],
) => {
if (!username) {
throw new MissingParamError(["username"]);
}
ownerAffiliations = parseOwnerAffiliations(ownerAffiliations);
const res = await retryer(fetcher, username, {
login: username,
ownerAffiliations,
});
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, ...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
View File
@@ -1,128 +0,0 @@
export type GistData = {
name: string;
nameWithOwner: string;
description: string | null;
language: string | null;
starsCount: number;
forksCount: number;
};
export type 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 type 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 type Lang = {
name: string;
color: string;
size: number;
};
export type TopLangData = Record<string, Lang>;
export type WakaTimeData = {
categories: {
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: {
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: {
digital: string;
hours: number;
minutes: number;
name: string;
percent: number;
text: string;
total_seconds: number;
}[];
operating_systems: {
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 type WakaTimeLang = {
name: string;
text: string;
percent: number;
};
-37
View File
@@ -1,37 +0,0 @@
// @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 };
-2
View File
@@ -1,2 +0,0 @@
export * from "./common/index.js";
export * from "./cards/index.js";
File diff suppressed because it is too large Load Diff
+1
View File
@@ -80,6 +80,7 @@ async function githubAuthenticate(code, privateAccess) {
return { userId, accessToken, needDowngrade };
} catch (err) {
if (err.response) {
// eslint-disable-next-line preserve-caught-error
throw new Error(`OAuth Error: ${err.response.status}`);
}
throw err;
@@ -1,621 +0,0 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`Test Render WakaTime Card > should render correctly 1`] = `
"
<svg
width="495"
height="150"
viewBox="0 0 495 150"
fill="none"
xmlns="http://www.w3.org/2000/svg"
role="img"
aria-labelledby="descId"
>
<title id="titleId"></title>
<desc id="descId"></desc>
<style>
.header {
font: 600 18px 'Segoe UI', Ubuntu, Sans-Serif;
fill: #2f80ed;
animation: fadeInAnimation 0.8s ease-in-out forwards;
}
@supports(-moz-appearance: auto) {
/* Selector detects Firefox */
.header { font-size: 15.5px; }
}
.stat {
font: 600 14px 'Segoe UI', Ubuntu, "Helvetica Neue", Sans-Serif; fill: #434d58;
}
@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 }
@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: #434d58 }
#rect-mask rect{
animation: slideInAnimation 1s ease-in-out forwards;
}
.lang-progress{
animation: growWidthAnimation 0.6s ease-in-out forwards;
}
</style>
<rect
data-testid="card-bg"
x="0.5"
y="0.5"
rx="4.5"
height="99%"
stroke="#e4e2e2"
width="494"
fill="#fffefe"
stroke-opacity="1"
/>
<g
data-testid="card-title"
transform="translate(25, 35)"
>
<g transform="translate(0, 0)">
<text
x="0"
y="0"
class="header"
data-testid="header"
>WakaTime Stats (last 7 days)</text>
</g>
</g>
<g
data-testid="main-card-body"
transform="translate(0, 55)"
>
<svg x="0" y="0" width="100%">
<g transform="translate(0, 0)">
<g class="stagger" style="animation-delay: 450ms" transform="translate(25, 0)">
<text class="stat bold" y="12.5" data-testid="Other">Other:</text>
<text
class="stat"
x="350"
y="12.5"
>19 mins</text>
<svg width="220" x="110" y="4">
<rect data-testid="progress-background" rx="5" ry="5" x="0" y="0" width="220" height="8" fill="#434d58"></rect>
<svg data-testid="lang-progress" width="89.38%">
<rect
height="8"
fill="#2f80ed"
rx="5" ry="5" x="0" y="0"
class="lang-progress"
style="animation-delay: 750ms;"
/>
</svg>
</svg>
</g>
</g><g transform="translate(0, 25)">
<g class="stagger" style="animation-delay: 600ms" transform="translate(25, 0)">
<text class="stat bold" y="12.5" data-testid="TypeScript">TypeScript:</text>
<text
class="stat"
x="350"
y="12.5"
>1 min</text>
<svg width="220" x="110" y="4">
<rect data-testid="progress-background" rx="5" ry="5" x="0" y="0" width="220" height="8" fill="#434d58"></rect>
<svg data-testid="lang-progress" width="6.25%">
<rect
height="8"
fill="#2f80ed"
rx="5" ry="5" x="0" y="0"
class="lang-progress"
style="animation-delay: 900ms;"
/>
</svg>
</svg>
</g>
</g>
</svg>
</g>
</svg>
"
`;
exports[`Test Render WakaTime Card > should render correctly with compact layout 1`] = `
"
<svg
width="495"
height="115"
viewBox="0 0 495 115"
fill="none"
xmlns="http://www.w3.org/2000/svg"
role="img"
aria-labelledby="descId"
>
<title id="titleId"></title>
<desc id="descId"></desc>
<style>
.header {
font: 600 18px 'Segoe UI', Ubuntu, Sans-Serif;
fill: #2f80ed;
animation: fadeInAnimation 0.8s ease-in-out forwards;
}
@supports(-moz-appearance: auto) {
/* Selector detects Firefox */
.header { font-size: 15.5px; }
}
.stat {
font: 600 14px 'Segoe UI', Ubuntu, "Helvetica Neue", Sans-Serif; fill: #434d58;
}
@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 }
@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: #434d58 }
#rect-mask rect{
animation: slideInAnimation 1s ease-in-out forwards;
}
.lang-progress{
animation: growWidthAnimation 0.6s ease-in-out forwards;
}
</style>
<rect
data-testid="card-bg"
x="0.5"
y="0.5"
rx="4.5"
height="99%"
stroke="#e4e2e2"
width="494"
fill="#fffefe"
stroke-opacity="1"
/>
<g
data-testid="card-title"
transform="translate(25, 35)"
>
<g transform="translate(0, 0)">
<text
x="0"
y="0"
class="header"
data-testid="header"
>WakaTime Stats (last 7 days)</text>
</g>
</g>
<g
data-testid="main-card-body"
transform="translate(0, 55)"
>
<svg x="0" y="0" width="100%">
<mask id="rect-mask">
<rect x="25" y="0" width="440" height="8" fill="white" rx="5" />
</mask>
<rect
mask="url(#rect-mask)"
data-testid="lang-progress"
x="0"
y="0"
width="415.61699999999996"
height="8"
fill="#858585"
/>
<rect
mask="url(#rect-mask)"
data-testid="lang-progress"
x="415.61699999999996"
y="0"
width="29.0625"
height="8"
fill="#3178c6"
/>
<g transform="translate(25, 25)">
<circle cx="5" cy="6" r="5" fill="#858585" />
<text data-testid="lang-name" x="15" y="10" class='lang-name'>
Other - 19 mins
</text>
</g>
<g transform="translate(230, 25)">
<circle cx="5" cy="6" r="5" fill="#3178c6" />
<text data-testid="lang-name" x="15" y="10" class='lang-name'>
TypeScript - 1 min
</text>
</g>
</svg>
</g>
</svg>
"
`;
exports[`Test Render WakaTime Card > should render correctly with compact layout when langs_count is set 1`] = `
"
<svg
width="495"
height="115"
viewBox="0 0 495 115"
fill="none"
xmlns="http://www.w3.org/2000/svg"
role="img"
aria-labelledby="descId"
>
<title id="titleId"></title>
<desc id="descId"></desc>
<style>
.header {
font: 600 18px 'Segoe UI', Ubuntu, Sans-Serif;
fill: #2f80ed;
animation: fadeInAnimation 0.8s ease-in-out forwards;
}
@supports(-moz-appearance: auto) {
/* Selector detects Firefox */
.header { font-size: 15.5px; }
}
.stat {
font: 600 14px 'Segoe UI', Ubuntu, "Helvetica Neue", Sans-Serif; fill: #434d58;
}
@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 }
@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: #434d58 }
#rect-mask rect{
animation: slideInAnimation 1s ease-in-out forwards;
}
.lang-progress{
animation: growWidthAnimation 0.6s ease-in-out forwards;
}
</style>
<rect
data-testid="card-bg"
x="0.5"
y="0.5"
rx="4.5"
height="99%"
stroke="#e4e2e2"
width="494"
fill="#fffefe"
stroke-opacity="1"
/>
<g
data-testid="card-title"
transform="translate(25, 35)"
>
<g transform="translate(0, 0)">
<text
x="0"
y="0"
class="header"
data-testid="header"
>WakaTime Stats (last 7 days)</text>
</g>
</g>
<g
data-testid="main-card-body"
transform="translate(0, 55)"
>
<svg x="0" y="0" width="100%">
<mask id="rect-mask">
<rect x="25" y="0" width="440" height="8" fill="white" rx="5" />
</mask>
<rect
mask="url(#rect-mask)"
data-testid="lang-progress"
x="0"
y="0"
width="436.4025"
height="8"
fill="#858585"
/>
<rect
mask="url(#rect-mask)"
data-testid="lang-progress"
x="436.4025"
y="0"
width="30.503999999999998"
height="8"
fill="#3178c6"
/>
<g transform="translate(25, 25)">
<circle cx="5" cy="6" r="5" fill="#858585" />
<text data-testid="lang-name" x="15" y="10" class='lang-name'>
Other - 19 mins
</text>
</g>
<g transform="translate(230, 25)">
<circle cx="5" cy="6" r="5" fill="#3178c6" />
<text data-testid="lang-name" x="15" y="10" class='lang-name'>
TypeScript - 1 min
</text>
</g>
</svg>
</g>
</svg>
"
`;
exports[`Test Render WakaTime Card > should render correctly with percent display format 1`] = `
"
<svg
width="495"
height="150"
viewBox="0 0 495 150"
fill="none"
xmlns="http://www.w3.org/2000/svg"
role="img"
aria-labelledby="descId"
>
<title id="titleId"></title>
<desc id="descId"></desc>
<style>
.header {
font: 600 18px 'Segoe UI', Ubuntu, Sans-Serif;
fill: #2f80ed;
animation: fadeInAnimation 0.8s ease-in-out forwards;
}
@supports(-moz-appearance: auto) {
/* Selector detects Firefox */
.header { font-size: 15.5px; }
}
.stat {
font: 600 14px 'Segoe UI', Ubuntu, "Helvetica Neue", Sans-Serif; fill: #434d58;
}
@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 }
@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: #434d58 }
#rect-mask rect{
animation: slideInAnimation 1s ease-in-out forwards;
}
.lang-progress{
animation: growWidthAnimation 0.6s ease-in-out forwards;
}
</style>
<rect
data-testid="card-bg"
x="0.5"
y="0.5"
rx="4.5"
height="99%"
stroke="#e4e2e2"
width="494"
fill="#fffefe"
stroke-opacity="1"
/>
<g
data-testid="card-title"
transform="translate(25, 35)"
>
<g transform="translate(0, 0)">
<text
x="0"
y="0"
class="header"
data-testid="header"
>WakaTime Stats (last 7 days)</text>
</g>
</g>
<g
data-testid="main-card-body"
transform="translate(0, 55)"
>
<svg x="0" y="0" width="100%">
<g transform="translate(0, 0)">
<g class="stagger" style="animation-delay: 450ms" transform="translate(25, 0)">
<text class="stat bold" y="12.5" data-testid="Other">Other:</text>
<text
class="stat"
x="350"
y="12.5"
>47.39 %</text>
<svg width="220" x="110" y="4">
<rect data-testid="progress-background" rx="5" ry="5" x="0" y="0" width="220" height="8" fill="#434d58"></rect>
<svg data-testid="lang-progress" width="47.39%">
<rect
height="8"
fill="#2f80ed"
rx="5" ry="5" x="0" y="0"
class="lang-progress"
style="animation-delay: 750ms;"
/>
</svg>
</svg>
</g>
</g><g transform="translate(0, 25)">
<g class="stagger" style="animation-delay: 600ms" transform="translate(25, 0)">
<text class="stat bold" y="12.5" data-testid="TypeScript">TypeScript:</text>
<text
class="stat"
x="350"
y="12.5"
>50.48 %</text>
<svg width="220" x="110" y="4">
<rect data-testid="progress-background" rx="5" ry="5" x="0" y="0" width="220" height="8" fill="#434d58"></rect>
<svg data-testid="lang-progress" width="50.48%">
<rect
height="8"
fill="#2f80ed"
rx="5" ry="5" x="0" y="0"
class="lang-progress"
style="animation-delay: 900ms;"
/>
</svg>
</svg>
</g>
</g>
</svg>
</g>
</svg>
"
`;
@@ -1,115 +0,0 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`Test /api/wakatime > should render error if user data is not accessible 1`] = `
"
<svg
width="495"
height="150"
viewBox="0 0 495 150"
fill="none"
xmlns="http://www.w3.org/2000/svg"
role="img"
aria-labelledby="descId"
>
<title id="titleId"></title>
<desc id="descId"></desc>
<style>
.header {
font: 600 18px 'Segoe UI', Ubuntu, Sans-Serif;
fill: #2f80ed;
animation: fadeInAnimation 0.8s ease-in-out forwards;
}
@supports(-moz-appearance: auto) {
/* Selector detects Firefox */
.header { font-size: 15.5px; }
}
.stat {
font: 600 14px 'Segoe UI', Ubuntu, "Helvetica Neue", Sans-Serif; fill: #434d58;
}
@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 }
@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: #434d58 }
#rect-mask rect{
animation: slideInAnimation 1s ease-in-out forwards;
}
.lang-progress{
animation: growWidthAnimation 0.6s ease-in-out forwards;
}
</style>
<rect
data-testid="card-bg"
x="0.5"
y="0.5"
rx="4.5"
height="99%"
stroke="#e4e2e2"
width="494"
fill="#fffefe"
stroke-opacity="1"
/>
<g
data-testid="card-title"
transform="translate(25, 35)"
>
<g transform="translate(0, 0)">
<text
x="0"
y="0"
class="header"
data-testid="header"
>WakaTime Stats</text>
</g>
</g>
<g
data-testid="main-card-body"
transform="translate(0, 55)"
>
<svg x="0" y="0" width="100%">
<g transform="translate(0, 0)">
<text x="25" y="11" class="stat bold" fill="#434d58">WakaTime user profile not public</text>
</g>
</svg>
</g>
</svg>
"
`;
@@ -1,2 +0,0 @@
process.env.GIST_WHITELIST = "bbfce31e0217a3689c8d961a356cb10d";
process.env.WHITELIST = "anuraghazra";
+135 -298
View File
@@ -1,345 +1,182 @@
// @ts-check
import axios from "axios";
import MockAdapter from "axios-mock-adapter";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { beforeEach, describe, expect, it, vi } from "vitest";
import api from "../api-renamed/index.js";
import { calculateRank } from "../src/calculateRank.js";
import { renderStatsCard } from "../src/cards/stats.js";
import { CACHE_TTL, DURATIONS } from "../src/common/cache.js";
import { renderError } from "../src/common/render.js";
const mocks = vi.hoisted(() => ({
api: vi.fn(),
storeRequest: vi.fn(),
getUserAccessByName: vi.fn(),
config: {},
}));
import { data_stats, stats } from "./test-data/api-data.js";
stats.rank = calculateRank({
all_commits: false,
commits: stats.totalCommits,
prs: stats.totalPRs,
reviews: stats.totalReviews,
issues: stats.totalIssues,
repos: 1,
stars: stats.totalStars,
followers: 0,
vi.mock("@stats-organization/github-readme-stats-core", async () => {
const { mockCore } = await import("./utils.js");
return mockCore({ api: mocks.api, getConfig: () => mocks.config });
});
const error = {
errors: [
{
type: "NOT_FOUND",
path: ["user"],
locations: [],
message: "Could not fetch user",
},
],
};
vi.mock("../src/common/database.js", () => ({
storeRequest: mocks.storeRequest,
getUserAccessByName: mocks.getUserAccessByName,
}));
const mock = new MockAdapter(axios);
import router from "../router.js";
import { CACHE_TTL, DURATIONS } from "../src/common/cache.js";
// @ts-ignore
const faker = (query, data) => {
const req = {
query: {
username: "anuraghazra",
...query,
},
};
const res = {
setHeader: vi.fn(),
send: vi.fn(),
};
mock.onPost("https://api.github.com/graphql").replyOnce(200, data);
const createRequest = (search = "") => ({
headers: {},
url: `/api?${search}`,
});
return { req, res };
};
const createResponse = () => ({
end: vi.fn(),
setHeader: vi.fn(),
});
const defaultCacheHeader =
`max-age=${CACHE_TTL.STATS_CARD.DEFAULT}, ` +
`s-maxage=${CACHE_TTL.STATS_CARD.DEFAULT}, ` +
`stale-while-revalidate=${DURATIONS.ONE_DAY}`;
const errorCacheHeader =
`max-age=${CACHE_TTL.ERROR}, ` +
`s-maxage=${CACHE_TTL.ERROR}, ` +
`stale-while-revalidate=${DURATIONS.ONE_DAY}`;
beforeEach(() => {
process.env.CACHE_SECONDS = undefined;
mocks.api.mockReset();
mocks.storeRequest.mockReset().mockResolvedValue(undefined);
mocks.getUserAccessByName.mockReset().mockResolvedValue(null);
mocks.config = {};
// CACHE_SECONDS is not set here, this is just to safeguard against CACHE_SECONDS being set externally
delete process.env.CACHE_SECONDS;
});
afterEach(() => {
mock.reset();
});
describe("Test /api backend routing", () => {
it("happy path should pass query params and user PAT, respond with stats content and persist request", async () => {
mocks.getUserAccessByName.mockResolvedValue({ token: "user-pat" });
mocks.api.mockResolvedValue({
status: "success",
content: "mock-stats-svg",
});
describe("Test /api/", () => {
it("should test the request", async () => {
const { req, res } = faker({}, data_stats);
await api(req, res);
expect(res.setHeader).toHaveBeenCalledWith("Content-Type", "image/svg+xml");
expect(res.send).toHaveBeenCalledWith(
renderStatsCard(stats, { ...req.query }),
const req = createRequest(
"username=anuraghazra&theme=dark&hide=issues,prs,contribs",
);
});
const res = createResponse();
it("should render error card on error", async () => {
const { req, res } = faker({}, error);
await router(req, res);
await api(req, res);
expect(res.setHeader).toHaveBeenCalledWith("Content-Type", "image/svg+xml");
expect(res.send).toHaveBeenCalledWith(
renderError({
message: error.errors[0].message,
secondaryMessage:
"Make sure the provided username is not an organization",
}),
);
});
it("should render error card in same theme as requested card", async () => {
const { req, res } = faker({ theme: "merko" }, error);
await api(req, res);
expect(res.setHeader).toHaveBeenCalledWith("Content-Type", "image/svg+xml");
expect(res.send).toHaveBeenCalledWith(
renderError({
message: error.errors[0].message,
secondaryMessage:
"Make sure the provided username is not an organization",
renderOptions: { theme: "merko" },
}),
);
});
it("should get the query options", async () => {
const { req, res } = faker(
expect(mocks.getUserAccessByName).toHaveBeenCalledWith("anuraghazra");
expect(mocks.api).toHaveBeenCalledWith(
{
username: "anuraghazra",
theme: "dark",
hide: "issues,prs,contribs",
show_icons: true,
hide_border: true,
line_height: 100,
title_color: "fff",
icon_color: "fff",
text_color: "fff",
bg_color: "fff",
},
data_stats,
"user-pat",
);
await api(req, res);
expect(res.setHeader).toHaveBeenCalledWith("Content-Type", "image/svg+xml");
expect(res.send).toHaveBeenCalledWith(
renderStatsCard(stats, {
hide: ["issues", "prs", "contribs"],
show_icons: true,
hide_border: true,
line_height: 100,
title_color: "fff",
icon_color: "fff",
text_color: "fff",
bg_color: "fff",
}),
);
});
it("should have proper cache", async () => {
const { req, res } = faker({}, data_stats);
await api(req, res);
expect(req.query).toEqual({
username: "anuraghazra",
theme: "dark",
hide: "issues,prs,contribs",
});
expect(res.setHeader.mock.calls).toEqual([
["Cache-Control", defaultCacheHeader],
["Content-Type", "image/svg+xml"],
[
"Cache-Control",
`max-age=${CACHE_TTL.STATS_CARD.DEFAULT}, ` +
`s-maxage=${CACHE_TTL.STATS_CARD.DEFAULT}, ` +
`stale-while-revalidate=${DURATIONS.ONE_DAY}`,
],
]);
expect(res.end).toHaveBeenCalledExactlyOnceWith("mock-stats-svg");
expect(mocks.storeRequest).toHaveBeenCalledExactlyOnceWith(req);
});
it("should set proper cache", async () => {
const cache_seconds = DURATIONS.TWELVE_HOURS;
const { req, res } = faker({ cache_seconds }, data_stats);
await api(req, res);
it("should use the shorter error cache for temporary stats errors", async () => {
mocks.api.mockResolvedValue({
status: "error - temporary",
content: "temporary-error-svg",
});
expect(res.setHeader.mock.calls).toEqual([
["Content-Type", "image/svg+xml"],
[
"Cache-Control",
`max-age=${cache_seconds}, ` +
`s-maxage=${cache_seconds}, ` +
`stale-while-revalidate=${DURATIONS.ONE_DAY}`,
],
]);
});
const req = createRequest("username=anuraghazra");
const res = createResponse();
it("should set shorter cache when error", async () => {
const { req, res } = faker({}, error);
await api(req, res);
await router(req, res);
expect(res.setHeader.mock.calls).toEqual([
["Content-Type", "image/svg+xml"],
[
"Cache-Control",
`max-age=${CACHE_TTL.ERROR}, ` +
`s-maxage=${CACHE_TTL.ERROR}, ` +
`stale-while-revalidate=${DURATIONS.ONE_DAY}`,
],
]);
});
it("should properly set cache using CACHE_SECONDS env variable", async () => {
const cacheSeconds = "10000";
process.env.CACHE_SECONDS = cacheSeconds;
const { req, res } = faker({}, data_stats);
await api(req, res);
expect(res.setHeader.mock.calls).toEqual([
["Content-Type", "image/svg+xml"],
[
"Cache-Control",
`max-age=${cacheSeconds}, ` +
`s-maxage=${cacheSeconds}, ` +
`stale-while-revalidate=${DURATIONS.ONE_DAY}`,
],
]);
});
it("should disable cache when CACHE_SECONDS is set to 0", async () => {
process.env.CACHE_SECONDS = "0";
const { req, res } = faker({}, data_stats);
await api(req, res);
expect(res.setHeader.mock.calls).toEqual([
["Content-Type", "image/svg+xml"],
[
"Cache-Control",
"no-cache, no-store, must-revalidate, max-age=0, s-maxage=0",
],
["Pragma", "no-cache"],
["Expires", "0"],
]);
});
it("should set proper cache with clamped values", async () => {
{
let { req, res } = faker({ cache_seconds: 200_000 }, data_stats);
await api(req, res);
expect(res.setHeader.mock.calls).toEqual([
["Content-Type", "image/svg+xml"],
[
"Cache-Control",
`max-age=${CACHE_TTL.STATS_CARD.MAX}, ` +
`s-maxage=${CACHE_TTL.STATS_CARD.MAX}, ` +
`stale-while-revalidate=${DURATIONS.ONE_DAY}`,
],
]);
}
// note i'm using block scoped vars
{
let { req, res } = faker({ cache_seconds: 0 }, data_stats);
await api(req, res);
expect(res.setHeader.mock.calls).toEqual([
["Content-Type", "image/svg+xml"],
[
"Cache-Control",
`max-age=${CACHE_TTL.STATS_CARD.MIN}, ` +
`s-maxage=${CACHE_TTL.STATS_CARD.MIN}, ` +
`stale-while-revalidate=${DURATIONS.ONE_DAY}`,
],
]);
}
{
let { req, res } = faker({ cache_seconds: -10_000 }, data_stats);
await api(req, res);
expect(res.setHeader.mock.calls).toEqual([
["Content-Type", "image/svg+xml"],
[
"Cache-Control",
`max-age=${CACHE_TTL.STATS_CARD.MIN}, ` +
`s-maxage=${CACHE_TTL.STATS_CARD.MIN}, ` +
`stale-while-revalidate=${DURATIONS.ONE_DAY}`,
],
]);
}
});
it("should allow changing ring_color", async () => {
const { req, res } = faker(
expect(mocks.getUserAccessByName).toHaveBeenCalledWith("anuraghazra");
expect(mocks.api).toHaveBeenCalledWith(
{
username: "anuraghazra",
hide: "issues,prs,contribs",
show_icons: true,
hide_border: true,
line_height: 100,
title_color: "fff",
ring_color: "0000ff",
icon_color: "fff",
text_color: "fff",
bg_color: "fff",
},
data_stats,
);
await api(req, res);
expect(res.setHeader).toHaveBeenCalledWith("Content-Type", "image/svg+xml");
expect(res.send).toHaveBeenCalledWith(
renderStatsCard(stats, {
hide: ["issues", "prs", "contribs"],
show_icons: true,
hide_border: true,
line_height: 100,
title_color: "fff",
ring_color: "0000ff",
icon_color: "fff",
text_color: "fff",
bg_color: "fff",
}),
null,
);
expect(res.setHeader.mock.calls).toEqual([
["Cache-Control", errorCacheHeader],
["Content-Type", "image/svg+xml"],
]);
expect(res.end).toHaveBeenCalledExactlyOnceWith("temporary-error-svg");
expect(mocks.storeRequest).toHaveBeenCalledExactlyOnceWith(req);
});
it("should render error card when wrong locale is provided", async () => {
const { req, res } = faker({ locale: "asdf" }, data_stats);
it("should not persist permanent stats errors returned by core", async () => {
mocks.api.mockResolvedValue({
status: "error - permanent",
content: "permanent-error-svg",
});
await api(req, res);
const req = createRequest("username=anuraghazra");
const res = createResponse();
expect(res.setHeader).toHaveBeenCalledWith("Content-Type", "image/svg+xml");
expect(res.send).toHaveBeenCalledWith(
renderError({
message: "Something went wrong",
secondaryMessage: "Language not found",
}),
await router(req, res);
expect(mocks.getUserAccessByName).toHaveBeenCalledWith("anuraghazra");
expect(mocks.api).toHaveBeenCalledWith(
{
username: "anuraghazra",
},
null,
);
expect(res.setHeader.mock.calls).toEqual([
["Cache-Control", defaultCacheHeader],
["Content-Type", "image/svg+xml"],
]);
expect(res.end).toHaveBeenCalledExactlyOnceWith("permanent-error-svg");
expect(mocks.storeRequest).not.toHaveBeenCalled();
});
it("should render error card when include_all_commits true and upstream API fails", async () => {
mock
.onGet(
"https://api.github.com/search/commits?per_page=1&q=author:anuraghazra",
)
.reply(200, { error: "Some test error message" });
it("should reject blacklisted usernames before calling core logic", async () => {
const req = createRequest("username=renovate-bot");
const res = createResponse();
const { req, res } = faker(
{ username: "anuraghazra", include_all_commits: true },
data_stats,
);
await router(req, res);
await api(req, res);
expect(mocks.api).not.toHaveBeenCalled();
expect(mocks.getUserAccessByName).not.toHaveBeenCalled();
expect(res.setHeader.mock.calls).toEqual([
["Cache-Control", defaultCacheHeader],
["Content-Type", "image/svg+xml"],
]);
expect(res.end).toHaveBeenCalledExactlyOnceWith(
"render-error:This username is blacklisted",
);
expect(mocks.storeRequest).not.toHaveBeenCalled();
});
expect(res.setHeader).toHaveBeenCalledWith("Content-Type", "image/svg+xml");
expect(res.send).toHaveBeenCalledWith(
renderError({
message: "Could not fetch data from GitHub REST API.",
secondaryMessage: "Please try again later",
}),
);
// Received SVG output should not contain string "https://tiny.one/readme-stats"
expect(res.send.mock.calls[0][0]).not.toContain(
"https://tiny.one/readme-stats",
it("should reject non-whitelisted usernames before calling core logic", async () => {
mocks.config = {
whitelist: ["allowed-user"],
};
const req = createRequest("username=blocked-user");
const res = createResponse();
await router(req, res);
expect(mocks.api).not.toHaveBeenCalled();
expect(mocks.getUserAccessByName).not.toHaveBeenCalled();
expect(res.setHeader.mock.calls).toEqual([
["Cache-Control", defaultCacheHeader],
["Content-Type", "image/svg+xml"],
]);
expect(res.end).toHaveBeenCalledExactlyOnceWith(
"render-error:This username is not whitelisted",
);
expect(mocks.storeRequest).not.toHaveBeenCalled();
});
});
+25 -66
View File
@@ -1,81 +1,40 @@
import axios from "axios";
import MockAdapter from "axios-mock-adapter";
import { bench, describe, vi } from "vitest";
import { beforeAll, bench, describe, vi } from "vitest";
import api from "../../api-renamed/index.js";
const stats = {
name: "Anurag Hazra",
totalStars: 100,
totalCommits: 200,
totalIssues: 300,
totalPRs: 400,
totalPRsMerged: 320,
mergedPRsPercentage: 80,
totalReviews: 50,
totalDiscussionsStarted: 10,
totalDiscussionsAnswered: 40,
contributedTo: 50,
rank: null,
};
const data_stats = {
data: {
user: {
name: stats.name,
repositoriesContributedTo: { totalCount: stats.contributedTo },
commits: {
totalCommitContributions: stats.totalCommits,
},
reviews: {
totalPullRequestReviewContributions: stats.totalReviews,
},
pullRequests: { totalCount: stats.totalPRs },
mergedPullRequests: { totalCount: stats.totalPRsMerged },
openIssues: { totalCount: stats.totalIssues },
closedIssues: { totalCount: 0 },
followers: { totalCount: 0 },
repositoryDiscussions: { totalCount: stats.totalDiscussionsStarted },
repositoryDiscussionComments: {
totalCount: stats.totalDiscussionsAnswered,
},
repositories: {
totalCount: 1,
nodes: [{ stargazers: { totalCount: 100 } }],
pageInfo: {
hasNextPage: false,
endCursor: "cursor",
},
},
},
},
};
import { data_stats } from "../utils.js";
const mock = new MockAdapter(axios);
const faker = (query, data) => {
const req = {
query: {
username: "anuraghazra",
...query,
},
};
const res = {
setHeader: vi.fn(),
send: vi.fn(),
};
mock.onPost("https://api.github.com/graphql").replyOnce(200, data);
const createResponse = () => ({
end: vi.fn(),
setHeader: vi.fn(),
});
return { req, res };
};
let router;
describe("/api", () => {
beforeAll(async () => {
vi.stubEnv("CACHE_SECONDS", "");
vi.stubEnv("GIST_WHITELIST", "");
vi.stubEnv("POSTGRES_URL", "");
vi.stubEnv("WHITELIST", "");
({ default: router } = await import("../../router.js"));
mock.onPost("https://api.github.com/graphql").reply(200, data_stats);
});
describe("bench /api", () => {
bench(
"base",
async () => {
const { req, res } = faker({}, data_stats);
const req = {
headers: {},
url: "/api?username=anuraghazra",
};
const res = createResponse();
await api(req, res);
await router(req, res);
},
{ warmupIterations: 50 },
);
@@ -1,22 +0,0 @@
import { bench, describe } from "vitest";
import { calculateRank } from "../../src/calculateRank.js";
describe("calculateRank", () => {
bench(
"base",
async () => {
calculateRank({
all_commits: false,
commits: 1300,
prs: 1500,
issues: 4500,
reviews: 1000,
repos: 0,
stars: 600000,
followers: 50000,
});
},
{ warmupIterations: 50 },
);
});
+27 -39
View File
@@ -1,54 +1,42 @@
import axios from "axios";
import MockAdapter from "axios-mock-adapter";
import { bench, describe, vi } from "vitest";
import { beforeAll, bench, describe, vi } from "vitest";
import gist from "../../api-renamed/gist.js";
const gist_data = {
data: {
viewer: {
gist: {
description:
"List of countries and territories in English and Spanish: name, continent, capital, dial code, country codes, TLD, and area in sq km. Lista de países y territorios en Inglés y Español: nombre, continente, capital, código de teléfono, códigos de país, dominio y área en km cuadrados. Updated 2023",
owner: {
login: "Yizack",
},
stargazerCount: 33,
forks: {
totalCount: 11,
},
files: [
{
name: "countries.json",
language: {
name: "JSON",
},
size: 85858,
},
],
},
},
},
};
import { happy_path_gist_data } from "../utils.js";
const mock = new MockAdapter(axios);
mock.onPost("https://api.github.com/graphql").reply(200, gist_data);
describe("test /api/gist", () => {
const createResponse = () => ({
end: vi.fn(),
setHeader: vi.fn(),
});
let router;
beforeAll(async () => {
vi.stubEnv("CACHE_SECONDS", "");
vi.stubEnv("GIST_WHITELIST", "");
vi.stubEnv("POSTGRES_URL", "");
vi.stubEnv("WHITELIST", "");
({ default: router } = await import("../../router.js"));
mock
.onPost("https://api.github.com/graphql")
.reply(200, happy_path_gist_data);
});
describe("bench /api/gist", () => {
bench(
"base",
async () => {
const req = {
query: {
id: "bbfce31e0217a3689c8d961a356cb10d",
},
};
const res = {
setHeader: vi.fn(),
send: vi.fn(),
headers: {},
url: "/api/gist?id=happy-gist-id",
};
const res = createResponse();
await gist(req, res);
await router(req, res);
},
{ warmupIterations: 50 },
);
+25 -38
View File
@@ -1,53 +1,40 @@
import axios from "axios";
import MockAdapter from "axios-mock-adapter";
import { bench, describe, vi } from "vitest";
import { beforeAll, bench, describe, vi } from "vitest";
import pin from "../../api-renamed/pin.js";
const data_repo = {
repository: {
username: "anuraghazra",
name: "convoychat",
stargazers: {
totalCount: 38000,
},
description: "Help us take over the world! React + TS + GraphQL Chat App",
primaryLanguage: {
color: "#2b7489",
id: "MDg6TGFuZ3VhZ2UyODc=",
name: "TypeScript",
},
forkCount: 100,
isTemplate: false,
},
};
const data_user = {
data: {
user: { repository: data_repo.repository },
organization: null,
},
};
import { data_user } from "../utils.js";
const mock = new MockAdapter(axios);
mock.onPost("https://api.github.com/graphql").reply(200, data_user);
describe("/api/pin", () => {
const createResponse = () => ({
end: vi.fn(),
setHeader: vi.fn(),
});
let router;
beforeAll(async () => {
vi.stubEnv("CACHE_SECONDS", "");
vi.stubEnv("GIST_WHITELIST", "");
vi.stubEnv("POSTGRES_URL", "");
vi.stubEnv("WHITELIST", "");
({ default: router } = await import("../../router.js"));
mock.onPost("https://api.github.com/graphql").reply(200, data_user);
});
describe("bench /api/pin", () => {
bench(
"base",
async () => {
const req = {
query: {
username: "anuraghazra",
repo: "convoychat",
},
};
const res = {
setHeader: vi.fn(),
send: vi.fn(),
headers: {},
url: "/api/pin?username=anuraghazra&repo=convoychat",
};
const res = createResponse();
await pin(req, res);
await router(req, res);
},
{ warmupIterations: 50 },
);
-126
View File
@@ -1,126 +0,0 @@
import { describe, expect, it } from "vitest";
import { calculateRank } from "../src/calculateRank.js";
import { approxNumber } from "./utils.js";
import "@testing-library/jest-dom/vitest";
describe("Test calculateRank", () => {
it("new user gets C rank", () => {
expect(
calculateRank({
all_commits: false,
commits: 0,
prs: 0,
issues: 0,
reviews: 0,
repos: 0,
stars: 0,
followers: 0,
}),
).toStrictEqual({ level: "C", percentile: 100 });
});
it("beginner user gets B- rank", () => {
expect(
calculateRank({
all_commits: false,
commits: 125,
prs: 25,
issues: 10,
reviews: 5,
repos: 0,
stars: 25,
followers: 5,
}),
).toStrictEqual({
level: "B-",
percentile: approxNumber(65.02918514848255),
});
});
it("median user gets B+ rank", () => {
expect(
calculateRank({
all_commits: false,
commits: 250,
prs: 50,
issues: 25,
reviews: 10,
repos: 0,
stars: 50,
followers: 10,
}),
).toStrictEqual({ level: "B+", percentile: approxNumber(46.09375) });
});
it("average user gets B+ rank (include_all_commits)", () => {
expect(
calculateRank({
all_commits: true,
commits: 1000,
prs: 50,
issues: 25,
reviews: 10,
repos: 0,
stars: 50,
followers: 10,
}),
).toStrictEqual({ level: "B+", percentile: approxNumber(46.09375) });
});
it("advanced user gets A rank", () => {
expect(
calculateRank({
all_commits: false,
commits: 500,
prs: 100,
issues: 50,
reviews: 20,
repos: 0,
stars: 200,
followers: 40,
}),
).toStrictEqual({
level: "A",
percentile: approxNumber(20.841471354166664),
});
});
it("expert user gets A+ rank", () => {
expect(
calculateRank({
all_commits: false,
commits: 1000,
prs: 200,
issues: 100,
reviews: 40,
repos: 0,
stars: 800,
followers: 160,
}),
).toStrictEqual({
level: "A+",
percentile: approxNumber(5.575988339442828),
});
});
it("sindresorhus gets S rank", () => {
expect(
calculateRank({
all_commits: false,
commits: 1300,
prs: 1500,
issues: 4500,
reviews: 1000,
repos: 0,
stars: 600000,
followers: 50000,
}),
).toStrictEqual({
level: "S",
percentile: approxNumber(0.4578556547153667),
});
});
});
-198
View File
@@ -1,198 +0,0 @@
import { queryByTestId } from "@testing-library/dom";
import { cssToObject } from "@uppercod/css-to-object";
import { describe, expect, it } from "vitest";
import { Card } from "../src/common/Card.js";
import { getCardColors } from "../src/common/color.js";
import { icons } from "../src/common/icons.js";
import "@testing-library/jest-dom/vitest";
describe("Card", () => {
it("should hide border", () => {
const card = new Card({});
card.setHideBorder(true);
document.body.innerHTML = card.render(``);
expect(queryByTestId(document.body, "card-bg")).toHaveAttribute(
"stroke-opacity",
"0",
);
});
it("should not hide border", () => {
const card = new Card({});
card.setHideBorder(false);
document.body.innerHTML = card.render(``);
expect(queryByTestId(document.body, "card-bg")).toHaveAttribute(
"stroke-opacity",
"1",
);
});
it("should have a custom title", () => {
const card = new Card({
customTitle: "custom title",
defaultTitle: "default title",
});
document.body.innerHTML = card.render(``);
expect(queryByTestId(document.body, "card-title")).toHaveTextContent(
"custom title",
);
});
it("should set custom title", () => {
const card = new Card({});
card.setTitle("custom title");
document.body.innerHTML = card.render(``);
expect(queryByTestId(document.body, "card-title")).toHaveTextContent(
"custom title",
);
});
it("should hide title", () => {
const card = new Card({});
card.setHideTitle(true);
document.body.innerHTML = card.render(``);
expect(queryByTestId(document.body, "card-title")).toBeNull();
});
it("should not hide title", () => {
const card = new Card({});
card.setHideTitle(false);
document.body.innerHTML = card.render(``);
expect(queryByTestId(document.body, "card-title")).toBeInTheDocument();
});
it("title should have prefix icon", () => {
const card = new Card({ title: "ok", titlePrefixIcon: icons.contribs });
document.body.innerHTML = card.render(``);
expect(document.getElementsByClassName("icon")[0]).toBeInTheDocument();
});
it("title should not have prefix icon", () => {
const card = new Card({ title: "ok" });
document.body.innerHTML = card.render(``);
expect(document.getElementsByClassName("icon")[0]).toBeUndefined();
});
it("should have proper height, width", () => {
const card = new Card({ height: 200, width: 200, title: "ok" });
document.body.innerHTML = card.render(``);
expect(document.getElementsByTagName("svg")[0]).toHaveAttribute(
"height",
"200",
);
expect(document.getElementsByTagName("svg")[0]).toHaveAttribute(
"width",
"200",
);
});
it("should have less height after title is hidden", () => {
const card = new Card({ height: 200, title: "ok" });
card.setHideTitle(true);
document.body.innerHTML = card.render(``);
expect(document.getElementsByTagName("svg")[0]).toHaveAttribute(
"height",
"170",
);
});
it("main-card-body should have proper when title is visible", () => {
const card = new Card({ height: 200 });
document.body.innerHTML = card.render(``);
expect(queryByTestId(document.body, "main-card-body")).toHaveAttribute(
"transform",
"translate(0, 55)",
);
});
it("main-card-body should have proper position after title is hidden", () => {
const card = new Card({ height: 200 });
card.setHideTitle(true);
document.body.innerHTML = card.render(``);
expect(queryByTestId(document.body, "main-card-body")).toHaveAttribute(
"transform",
"translate(0, 25)",
);
});
it("should render with correct colors", () => {
// returns theme based colors with proper overrides and defaults
const { titleColor, textColor, iconColor, bgColor } = getCardColors({
title_color: "f00",
icon_color: "0f0",
text_color: "00f",
bg_color: "fff",
theme: "default",
});
const card = new Card({
height: 200,
colors: {
titleColor,
textColor,
iconColor,
bgColor,
},
});
document.body.innerHTML = card.render(``);
const styleTag = document.querySelector("style");
const stylesObject = cssToObject(styleTag.innerHTML);
const headerClassStyles = stylesObject[":host"][".header "];
expect(headerClassStyles["fill"].trim()).toBe("#f00");
expect(queryByTestId(document.body, "card-bg")).toHaveAttribute(
"fill",
"#fff",
);
});
it("should render gradient backgrounds", () => {
const { titleColor, textColor, iconColor, bgColor } = getCardColors({
title_color: "f00",
icon_color: "0f0",
text_color: "00f",
bg_color: "90,fff,000,f00",
theme: "default",
});
const card = new Card({
height: 200,
colors: {
titleColor,
textColor,
iconColor,
bgColor,
},
});
document.body.innerHTML = card.render(``);
expect(queryByTestId(document.body, "card-bg")).toHaveAttribute(
"fill",
"url(#gradient)",
);
expect(document.querySelector("defs #gradient")).toHaveAttribute(
"gradientTransform",
"rotate(90)",
);
expect(
document.querySelector("defs #gradient stop:nth-child(1)"),
).toHaveAttribute("stop-color", "#fff");
expect(
document.querySelector("defs #gradient stop:nth-child(2)"),
).toHaveAttribute("stop-color", "#000");
expect(
document.querySelector("defs #gradient stop:nth-child(3)"),
).toHaveAttribute("stop-color", "#f00");
});
});
-91
View File
@@ -1,91 +0,0 @@
import { describe, expect, it } from "vitest";
import { getCardColors } from "../src/common/color";
describe("Test color.js", () => {
it("getCardColors: should return expected values", () => {
let colors = getCardColors({
title_color: "f00",
text_color: "0f0",
ring_color: "0000ff",
icon_color: "00f",
bg_color: "fff",
border_color: "fff",
theme: "dark",
});
expect(colors).toStrictEqual({
titleColor: "#f00",
textColor: "#0f0",
iconColor: "#00f",
ringColor: "#0000ff",
bgColor: "#fff",
borderColor: "#fff",
});
});
it("getCardColors: should fallback to default colors if color is invalid", () => {
let colors = getCardColors({
title_color: "invalidcolor",
text_color: "0f0",
icon_color: "00f",
bg_color: "fff",
border_color: "invalidColor",
theme: "dark",
});
expect(colors).toStrictEqual({
titleColor: "#2f80ed",
textColor: "#0f0",
iconColor: "#00f",
ringColor: "#2f80ed",
bgColor: "#fff",
borderColor: "#e4e2e2",
});
});
it("getCardColors: should fallback to specified theme colors if is not defined", () => {
let colors = getCardColors({
theme: "dark",
});
expect(colors).toStrictEqual({
titleColor: "#fff",
textColor: "#9f9f9f",
ringColor: "#fff",
iconColor: "#79ff97",
bgColor: "#151515",
borderColor: "#e4e2e2",
});
});
it("getCardColors: should return ring color equal to title color if not ring color is defined", () => {
let colors = getCardColors({
title_color: "f00",
text_color: "0f0",
icon_color: "00f",
bg_color: "fff",
border_color: "fff",
theme: "dark",
});
expect(colors).toStrictEqual({
titleColor: "#f00",
textColor: "#0f0",
iconColor: "#00f",
ringColor: "#f00",
bgColor: "#fff",
borderColor: "#fff",
});
});
it("getCardColors: should fallback to default theme if theme is invalid", () => {
let colors = getCardColors({
theme: "invalidTheme",
});
expect(colors).toStrictEqual({
titleColor: "#2f80ed",
textColor: "#434d58",
iconColor: "#4c71f2",
ringColor: "#2f80ed",
bgColor: "#fffefe",
borderColor: "#e4e2e2",
});
});
});
+244 -122
View File
@@ -3,221 +3,343 @@
*/
import axios from "axios";
import { beforeAll, describe, expect, test } from "vitest";
import { renderGistCard } from "../../src/cards/gist.js";
import { renderRepoCard } from "../../src/cards/repo.js";
import { renderStatsCard } from "../../src/cards/stats.js";
import { renderTopLanguages } from "../../src/cards/top-languages.js";
import { renderWakatimeCard } from "../../src/cards/wakatime.js";
import MockAdapter from "axios-mock-adapter";
import { afterAll, beforeAll, describe, expect, test, vi } from "vitest";
const REPO = "curly-fiesta";
const USER = "catelinemnemosyne";
const STATS_CARD_USER = "e2eninja";
const GIST_ID = "372cef55fd897b31909fdeb3a7262758";
const STATS_DATA = {
name: "CodeNinja",
totalPRs: 1,
totalReviews: 0,
totalCommits: 3,
totalIssues: 1,
totalStars: 1,
contributedTo: 0,
rank: {
level: "C",
percentile: 98.73972605284538,
const STATS_MOCK_RESPONSE = {
data: {
user: {
name: "CodeNinja",
login: STATS_CARD_USER,
repositoriesContributedTo: { totalCount: 0 },
commits: {
totalCommitContributions: 3,
},
reviews: {
totalPullRequestReviewContributions: 0,
},
pullRequests: { totalCount: 1 },
openIssues: { totalCount: 1 },
closedIssues: { totalCount: 0 },
followers: { totalCount: 0 },
repositories: {
totalCount: 1,
nodes: [{ name: REPO, stargazers: { totalCount: 1 } }],
pageInfo: {
hasNextPage: false,
endCursor: "cursor",
},
},
},
},
};
const LANGS_DATA = {
HTML: {
color: "#e34c26",
name: "HTML",
size: 1721,
},
CSS: {
color: "#663399",
name: "CSS",
size: 930,
},
JavaScript: {
color: "#f1e05a",
name: "JavaScript",
size: 1912,
const COMMITS_SEARCH_MOCK_RESPONSE = {
total_count: 3,
};
const TOP_LANGS_MOCK_RESPONSE = {
data: {
user: {
repositories: {
nodes: [
{
name: REPO,
languages: {
edges: [
{
size: 1721,
node: {
color: "#e34c26",
name: "HTML",
},
},
{
size: 930,
node: {
color: "#663399",
name: "CSS",
},
},
{
size: 1912,
node: {
color: "#f1e05a",
name: "JavaScript",
},
},
],
},
},
],
},
},
},
};
const WAKATIME_DATA = {
human_readable_range: "last week",
is_already_updating: false,
is_coding_activity_visible: true,
is_including_today: false,
is_other_usage_visible: false,
is_stuck: false,
is_up_to_date: false,
is_up_to_date_pending_future: false,
percent_calculated: 0,
range: "all_time",
status: "pending_update",
timeout: 15,
username: USER,
writes_only: false,
const WAKATIME_MOCK_RESPONSE = {
data: {
human_readable_range: "last week",
is_already_updating: false,
is_coding_activity_visible: true,
is_including_today: false,
is_other_usage_visible: false,
is_stuck: false,
is_up_to_date: false,
is_up_to_date_pending_future: false,
percent_calculated: 0,
range: "all_time",
status: "pending_update",
timeout: 15,
username: USER,
writes_only: false,
},
};
const REPOSITORY_DATA = {
name: REPO,
nameWithOwner: `${USER}/cra-test`,
isPrivate: false,
isArchived: false,
isTemplate: false,
stargazers: {
totalCount: 1,
const REPO_MOCK_RESPONSE = {
data: {
user: {
repository: {
name: REPO,
nameWithOwner: `${USER}/cra-test`,
isPrivate: false,
isArchived: false,
isTemplate: false,
stargazers: {
totalCount: 1,
},
description: "Simple cra test repo.",
primaryLanguage: {
color: "#f1e05a",
id: "MDg6TGFuZ3VhZ2UxNDA=",
name: "JavaScript",
},
forkCount: 0,
},
},
organization: null,
},
description: "Simple cra test repo.",
primaryLanguage: {
color: "#f1e05a",
id: "MDg6TGFuZ3VhZ2UxNDA=",
name: "JavaScript",
},
forkCount: 0,
starCount: 1,
};
/**
* @typedef {import("../../src/fetchers/types").GistData} GistData Gist data type.
*/
/**
* @type {GistData}
*/
const GIST_DATA = {
name: "link.txt",
nameWithOwner: "qwerty541/link.txt",
description:
"Trying to access this path on Windows 10 ver. 1803+ will breaks NTFS",
language: "Text",
starsCount: 1,
forksCount: 0,
const GIST_MOCK_RESPONSE = {
data: {
viewer: {
gist: {
description:
"Trying to access this path on Windows 10 ver. 1803+ will breaks NTFS",
owner: {
login: "qwerty541",
},
stargazerCount: 1,
forks: {
totalCount: 0,
},
files: [
{
name: "link.txt",
language: {
name: "Text",
},
size: 1,
},
],
},
},
},
};
const CACHE_BURST_STRING = `v=${new Date().getTime()}`;
describe("Fetch Cards", () => {
let VERCEL_PREVIEW_URL = "https://github-stats-extended.vercel.app";
const mock = new MockAdapter(axios, { onNoMatch: "passthrough" });
beforeAll(() => {
process.env.NODE_ENV = "development";
const createResponse = () => ({
end: vi.fn(),
setHeader: vi.fn(),
});
let router;
/**
* Renders a card locally through the backend router.
* @param {string} url Card URL to render through the router.
* @returns {Promise<string>} Rendered SVG markup.
*/
async function getLocalSvg(url) {
const req = {
headers: {},
url,
};
const res = createResponse();
await router(req, res);
expect(res.end).toHaveBeenCalledOnce();
return res.end.mock.calls[0][0];
}
beforeAll(async () => {
vi.stubEnv("CACHE_SECONDS", "");
vi.stubEnv("GIST_WHITELIST", "");
vi.stubEnv("POSTGRES_URL", "");
vi.stubEnv("WHITELIST", "");
vi.stubEnv("PAT_1", "dummyPAT1");
vi.stubEnv("PAT_2", "dummyPAT2");
({ default: router } = await import("../../router.js"));
mock.onPost("https://api.github.com/graphql").reply((config) => {
const { query, variables } = JSON.parse(config.data);
if (
query.includes("query userInfo") &&
variables?.login === STATS_CARD_USER
) {
return [200, STATS_MOCK_RESPONSE];
}
if (query.includes("query userInfo") && variables?.login === USER) {
return [200, TOP_LANGS_MOCK_RESPONSE];
}
if (query.includes("query getRepo")) {
return [200, REPO_MOCK_RESPONSE];
}
if (query.includes("query gistInfo")) {
return [200, GIST_MOCK_RESPONSE];
}
return [500, { error: "Unhandled GraphQL request in e2e test" }];
});
mock
.onGet(
`https://api.github.com/search/commits?per_page=1&q=author:${STATS_CARD_USER}`,
)
.reply(200, COMMITS_SEARCH_MOCK_RESPONSE);
mock
.onGet(
`https://wakatime.com/api/v1/users/${USER}/stats?is_including_today=true`,
)
.reply(200, WAKATIME_MOCK_RESPONSE);
});
afterAll(() => {
mock.restore();
vi.unstubAllEnvs();
});
describe("Fetch Cards", () => {
const VERCEL_PREVIEW_URL = "https://github-stats-extended-preview.vercel.app";
test("retrieve stats card", async () => {
expect(VERCEL_PREVIEW_URL).toBeDefined();
const cardPath = `/api?username=${STATS_CARD_USER}&include_all_commits=true&${CACHE_BURST_STRING}`;
// Check if the Vercel preview instance stats card function is up and running.
await expect(
axios.get(`${VERCEL_PREVIEW_URL}/api?username=${STATS_CARD_USER}`),
axios.get(`${VERCEL_PREVIEW_URL}${cardPath}`),
).resolves.not.toThrow();
// Get local stats card.
const localStatsCardSVG = renderStatsCard(STATS_DATA, {
include_all_commits: true,
});
const localStatsCardSVG = await getLocalSvg(cardPath);
// Get the Vercel preview stats card response.
const serverStatsSvg = await axios.get(
`${VERCEL_PREVIEW_URL}/api?username=${STATS_CARD_USER}&include_all_commits=true&${CACHE_BURST_STRING}`,
);
const serverStatsSvg = await axios.get(`${VERCEL_PREVIEW_URL}${cardPath}`);
// Check if stats card from deployment matches the stats card from local.
expect(serverStatsSvg.data).toEqual(localStatsCardSVG);
}, 15000);
}, 20000);
test("retrieve language card", async () => {
expect(VERCEL_PREVIEW_URL).toBeDefined();
const cardPath = `/api/top-langs?username=${USER}&${CACHE_BURST_STRING}`;
// Check if the Vercel preview instance language card function is up and running.
console.log(
`${VERCEL_PREVIEW_URL}/api/top-langs/?username=${USER}&${CACHE_BURST_STRING}`,
);
await expect(
axios.get(
`${VERCEL_PREVIEW_URL}/api/top-langs/?username=${USER}&${CACHE_BURST_STRING}`,
),
axios.get(`${VERCEL_PREVIEW_URL}${cardPath}`),
).resolves.not.toThrow();
// Get local language card.
const localLanguageCardSVG = renderTopLanguages(LANGS_DATA);
const localLanguageCardSVG = await getLocalSvg(cardPath);
// Get the Vercel preview language card response.
const severLanguageSVG = await axios.get(
`${VERCEL_PREVIEW_URL}/api/top-langs/?username=${USER}&${CACHE_BURST_STRING}`,
const serverLanguageSVG = await axios.get(
`${VERCEL_PREVIEW_URL}${cardPath}`,
);
// Check if language card from deployment matches the local language card.
expect(severLanguageSVG.data).toEqual(localLanguageCardSVG);
}, 15000);
expect(serverLanguageSVG.data).toEqual(localLanguageCardSVG);
}, 20000);
test("retrieve WakaTime card", async () => {
expect(VERCEL_PREVIEW_URL).toBeDefined();
const cardPath = `/api/wakatime?username=${USER}&${CACHE_BURST_STRING}`;
// Check if the Vercel preview instance WakaTime function is up and running.
await expect(
axios.get(`${VERCEL_PREVIEW_URL}/api/wakatime?username=${USER}`),
axios.get(`${VERCEL_PREVIEW_URL}${cardPath}`),
).resolves.not.toThrow();
// Get local WakaTime card.
const localWakaCardSVG = renderWakatimeCard(WAKATIME_DATA);
const localWakaCardSVG = await getLocalSvg(cardPath);
// Get the Vercel preview WakaTime card response.
const serverWakaTimeSvg = await axios.get(
`${VERCEL_PREVIEW_URL}/api/wakatime?username=${USER}&${CACHE_BURST_STRING}`,
`${VERCEL_PREVIEW_URL}${cardPath}`,
);
// Check if WakaTime card from deployment matches the local WakaTime card.
expect(serverWakaTimeSvg.data).toEqual(localWakaCardSVG);
}, 15000);
}, 20000);
test("retrieve repo card", async () => {
expect(VERCEL_PREVIEW_URL).toBeDefined();
const cardPath = `/api/pin?username=${USER}&repo=${REPO}&${CACHE_BURST_STRING}`;
// Check if the Vercel preview instance Repo function is up and running.
await expect(
axios.get(
`${VERCEL_PREVIEW_URL}/api/pin/?username=${USER}&repo=${REPO}&${CACHE_BURST_STRING}`,
),
axios.get(`${VERCEL_PREVIEW_URL}${cardPath}`),
).resolves.not.toThrow();
// Get local repo card.
const localRepoCardSVG = renderRepoCard(REPOSITORY_DATA);
const localRepoCardSVG = await getLocalSvg(cardPath);
// Get the Vercel preview repo card response.
const serverRepoSvg = await axios.get(
`${VERCEL_PREVIEW_URL}/api/pin/?username=${USER}&repo=${REPO}&${CACHE_BURST_STRING}`,
);
const serverRepoSvg = await axios.get(`${VERCEL_PREVIEW_URL}${cardPath}`);
// Check if Repo card from deployment matches the local Repo card.
expect(serverRepoSvg.data).toEqual(localRepoCardSVG);
}, 15000);
}, 20000);
test("retrieve gist card", async () => {
expect(VERCEL_PREVIEW_URL).toBeDefined();
const cardPath = `/api/gist?id=${GIST_ID}&${CACHE_BURST_STRING}`;
// Check if the Vercel preview instance Gist function is up and running.
await expect(
axios.get(
`${VERCEL_PREVIEW_URL}/api/gist?id=${GIST_ID}&${CACHE_BURST_STRING}`,
),
axios.get(`${VERCEL_PREVIEW_URL}${cardPath}`),
).resolves.not.toThrow();
// Get local gist card.
const localGistCardSVG = renderGistCard(GIST_DATA);
const localGistCardSVG = await getLocalSvg(cardPath);
// Get the Vercel preview gist card response.
const serverGistSvg = await axios.get(
`${VERCEL_PREVIEW_URL}/api/gist?id=${GIST_ID}&${CACHE_BURST_STRING}`,
);
const serverGistSvg = await axios.get(`${VERCEL_PREVIEW_URL}${cardPath}`);
// Check if Gist card from deployment matches the local Gist card.
expect(serverGistSvg.data).toEqual(localGistCardSVG);
}, 15000);
}, 20000);
});
-119
View File
@@ -1,119 +0,0 @@
import axios from "axios";
import MockAdapter from "axios-mock-adapter";
import { afterEach, describe, expect, it } from "vitest";
import { fetchGist } from "../src/fetchers/gist.js";
import "@testing-library/jest-dom/vitest";
const gist_data = {
data: {
viewer: {
gist: {
description:
"List of countries and territories in English and Spanish: name, continent, capital, dial code, country codes, TLD, and area in sq km. Lista de países y territorios en Inglés y Español: nombre, continente, capital, código de teléfono, códigos de país, dominio y área en km cuadrados. Updated 2023",
owner: {
login: "Yizack",
},
stargazerCount: 33,
forks: {
totalCount: 11,
},
files: [
{
name: "countries.json",
language: {
name: "JSON",
},
size: 85858,
},
{
name: "territories.txt",
language: {
name: "Text",
},
size: 87858,
},
{
name: "countries_spanish.json",
language: {
name: "JSON",
},
size: 85858,
},
{
name: "territories_spanish.txt",
language: {
name: "Text",
},
size: 87858,
},
],
},
},
},
};
const gist_not_found_data = {
data: {
viewer: {
gist: null,
},
},
};
const gist_errors_data = {
errors: [
{
message: "Some test GraphQL error",
},
],
};
const mock = new MockAdapter(axios);
afterEach(() => {
mock.reset();
});
describe("Test fetchGist", () => {
it("should fetch gist correctly", async () => {
mock.onPost("https://api.github.com/graphql").reply(200, gist_data);
let gist = await fetchGist("bbfce31e0217a3689c8d961a356cb10d");
expect(gist).toStrictEqual({
name: "countries.json",
nameWithOwner: "Yizack/countries.json",
description:
"List of countries and territories in English and Spanish: name, continent, capital, dial code, country codes, TLD, and area in sq km. Lista de países y territorios en Inglés y Español: nombre, continente, capital, código de teléfono, códigos de país, dominio y área en km cuadrados. Updated 2023",
language: "Text",
starsCount: 33,
forksCount: 11,
});
});
it("should throw correct error if gist not found", async () => {
mock
.onPost("https://api.github.com/graphql")
.reply(200, gist_not_found_data);
await expect(fetchGist("bbfce31e0217a3689c8d961a356cb10d")).rejects.toThrow(
"Gist not found",
);
});
it("should throw error if response contains them", async () => {
mock.onPost("https://api.github.com/graphql").reply(200, gist_errors_data);
await expect(fetchGist("bbfce31e0217a3689c8d961a356cb10d")).rejects.toThrow(
"Some test GraphQL error",
);
});
it("should throw error if id is not provided", async () => {
await expect(fetchGist()).rejects.toThrow(
'Missing params "id" make sure you pass the parameters in URL',
);
});
});
-107
View File
@@ -1,107 +0,0 @@
import axios from "axios";
import MockAdapter from "axios-mock-adapter";
import { afterEach, describe, expect, it } from "vitest";
import { fetchRepo } from "../src/fetchers/repo.js";
import "@testing-library/jest-dom/vitest";
const data_repo = {
repository: {
name: "convoychat",
stargazers: { totalCount: 38000 },
description: "Help us take over the world! React + TS + GraphQL Chat App",
primaryLanguage: {
color: "#2b7489",
id: "MDg6TGFuZ3VhZ2UyODc=",
name: "TypeScript",
},
forkCount: 100,
},
};
const data_user = {
data: {
user: { repository: data_repo.repository },
organization: null,
},
};
const data_org = {
data: {
user: null,
organization: { repository: data_repo.repository },
},
};
const mock = new MockAdapter(axios);
afterEach(() => {
mock.reset();
});
describe("Test fetchRepo", () => {
it("should fetch correct user repo", async () => {
mock.onPost("https://api.github.com/graphql").reply(200, data_user);
let repo = await fetchRepo("anuraghazra", "convoychat");
expect(repo).toStrictEqual({
...data_repo.repository,
starCount: data_repo.repository.stargazers.totalCount,
});
});
it("should fetch correct org repo", async () => {
mock.onPost("https://api.github.com/graphql").reply(200, data_org);
let repo = await fetchRepo("anuraghazra", "convoychat");
expect(repo).toStrictEqual({
...data_repo.repository,
starCount: data_repo.repository.stargazers.totalCount,
});
});
it("should throw error if user is found but repo is null", async () => {
mock
.onPost("https://api.github.com/graphql")
.reply(200, { data: { user: { repository: null }, organization: null } });
await expect(fetchRepo("anuraghazra", "convoychat")).rejects.toThrow(
"User Repository Not found",
);
});
it("should throw error if org is found but repo is null", async () => {
mock
.onPost("https://api.github.com/graphql")
.reply(200, { data: { user: null, organization: { repository: null } } });
await expect(fetchRepo("anuraghazra", "convoychat")).rejects.toThrow(
"Organization Repository Not found",
);
});
it("should throw error if both user & org data not found", async () => {
mock
.onPost("https://api.github.com/graphql")
.reply(200, { data: { user: null, organization: null } });
await expect(fetchRepo("anuraghazra", "convoychat")).rejects.toThrow(
"Not found",
);
});
it("should throw error if repository is private", async () => {
mock.onPost("https://api.github.com/graphql").reply(200, {
data: {
user: { repository: { ...data_repo, isPrivate: true } },
organization: null,
},
});
await expect(fetchRepo("anuraghazra", "convoychat")).rejects.toThrow(
"User Repository Not found",
);
});
});
-570
View File
@@ -1,570 +0,0 @@
import axios from "axios";
import MockAdapter from "axios-mock-adapter";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { calculateRank } from "../src/calculateRank.js";
import { fetchStats } from "../src/fetchers/stats.js";
import "@testing-library/jest-dom/vitest";
// Test parameters.
const data_stats = {
data: {
user: {
name: "Anurag Hazra",
repositoriesContributedTo: { totalCount: 61 },
commits: {
totalCommitContributions: 100,
},
reviews: {
totalPullRequestReviewContributions: 50,
},
pullRequests: { totalCount: 300 },
mergedPullRequests: { totalCount: 240 },
openIssues: { totalCount: 100 },
closedIssues: { totalCount: 100 },
followers: { totalCount: 100 },
repositoryDiscussions: { totalCount: 10 },
repositoryDiscussionComments: { totalCount: 40 },
repositories: {
totalCount: 5,
nodes: [
{ name: "test-repo-1", stargazers: { totalCount: 100 } },
{ name: "test-repo-2", stargazers: { totalCount: 100 } },
{ name: "test-repo-3", stargazers: { totalCount: 100 } },
],
pageInfo: {
hasNextPage: true,
endCursor: "cursor",
},
},
},
},
};
const data_year2003 = JSON.parse(JSON.stringify(data_stats));
data_year2003.data.user.commits.totalCommitContributions = 428;
const data_without_pull_requests = {
data: {
user: {
...data_stats.data.user,
pullRequests: { totalCount: 0 },
mergedPullRequests: { totalCount: 0 },
},
},
};
const data_repo = {
data: {
user: {
repositories: {
nodes: [
{ name: "test-repo-4", stargazers: { totalCount: 50 } },
{ name: "test-repo-5", stargazers: { totalCount: 50 } },
],
pageInfo: {
hasNextPage: false,
endCursor: "cursor",
},
},
},
},
};
const data_repo_zero_stars = {
data: {
user: {
repositories: {
nodes: [
{ name: "test-repo-1", stargazers: { totalCount: 100 } },
{ name: "test-repo-2", stargazers: { totalCount: 100 } },
{ name: "test-repo-3", stargazers: { totalCount: 100 } },
{ name: "test-repo-4", stargazers: { totalCount: 0 } },
{ name: "test-repo-5", stargazers: { totalCount: 0 } },
],
pageInfo: {
hasNextPage: true,
endCursor: "cursor",
},
},
},
},
};
const error = {
errors: [
{
type: "NOT_FOUND",
path: ["user"],
locations: [],
message: "Could not resolve to a User with the login of 'noname'.",
},
],
};
const mock = new MockAdapter(axios);
beforeEach(() => {
process.env.FETCH_MULTI_PAGE_STARS = "false"; // Set to `false` to fetch only one page of stars.
mock.onPost("https://api.github.com/graphql").reply((cfg) => {
let req = JSON.parse(cfg.data);
if (
req.variables &&
req.variables.startTime &&
req.variables.startTime.startsWith("2003")
) {
return [200, data_year2003];
}
return [
200,
req.query.includes("totalCommitContributions") ? data_stats : data_repo,
];
});
});
afterEach(() => {
mock.reset();
});
describe("Test fetchStats", () => {
it("should fetch correct stats", async () => {
let stats = await fetchStats("anuraghazra");
const rank = calculateRank({
all_commits: false,
commits: 100,
prs: 300,
reviews: 50,
issues: 200,
repos: 5,
stars: 300,
followers: 100,
});
expect(stats).toStrictEqual({
contributedTo: 61,
name: "Anurag Hazra",
totalCommits: 100,
totalIssues: 200,
totalPRs: 300,
totalPRsMerged: 0,
mergedPRsPercentage: 0,
totalReviews: 50,
totalStars: 300,
totalDiscussionsStarted: 0,
totalDiscussionsAnswered: 0,
totalPRsAuthored: 0,
totalPRsCommented: 0,
totalPRsReviewed: 0,
totalIssuesAuthored: 0,
totalIssuesCommented: 0,
rank,
});
});
it("should stop fetching when there are repos with zero stars", async () => {
mock.reset();
mock
.onPost("https://api.github.com/graphql")
.replyOnce(200, data_stats)
.onPost("https://api.github.com/graphql")
.replyOnce(200, data_repo_zero_stars);
let stats = await fetchStats("anuraghazra");
const rank = calculateRank({
all_commits: false,
commits: 100,
prs: 300,
reviews: 50,
issues: 200,
repos: 5,
stars: 300,
followers: 100,
});
expect(stats).toStrictEqual({
contributedTo: 61,
name: "Anurag Hazra",
totalCommits: 100,
totalIssues: 200,
totalPRs: 300,
totalPRsMerged: 0,
mergedPRsPercentage: 0,
totalReviews: 50,
totalStars: 300,
totalDiscussionsStarted: 0,
totalDiscussionsAnswered: 0,
totalPRsAuthored: 0,
totalPRsCommented: 0,
totalPRsReviewed: 0,
totalIssuesAuthored: 0,
totalIssuesCommented: 0,
rank,
});
});
it("should throw error", async () => {
mock.reset();
mock.onPost("https://api.github.com/graphql").reply(200, error);
await expect(fetchStats("anuraghazra")).rejects.toThrow(
"Could not resolve to a User with the login of 'noname'.",
);
});
it("should fetch total commits", async () => {
mock
.onGet(
"https://api.github.com/search/commits?per_page=1&q=author:anuraghazra",
)
.reply(200, { total_count: 1000 });
let stats = await fetchStats("anuraghazra", true);
const rank = calculateRank({
all_commits: true,
commits: 1000,
prs: 300,
reviews: 50,
issues: 200,
repos: 5,
stars: 300,
followers: 100,
});
expect(stats).toStrictEqual({
contributedTo: 61,
name: "Anurag Hazra",
totalCommits: 1000,
totalIssues: 200,
totalPRs: 300,
totalPRsMerged: 0,
mergedPRsPercentage: 0,
totalReviews: 50,
totalStars: 300,
totalDiscussionsStarted: 0,
totalDiscussionsAnswered: 0,
totalPRsAuthored: 0,
totalPRsCommented: 0,
totalPRsReviewed: 0,
totalIssuesAuthored: 0,
totalIssuesCommented: 0,
rank,
});
});
it("should throw specific error when include_all_commits true and invalid username", async () => {
await expect(fetchStats("asdf///---", true)).rejects.toThrow(
"Invalid username provided.",
);
});
it("should throw specific error when include_all_commits true and API returns error", async () => {
mock
.onGet(
"https://api.github.com/search/commits?per_page=1&q=author:anuraghazra",
)
.reply(200, { error: "Some test error message" });
await expect(fetchStats("anuraghazra", true)).rejects.toThrow(
"Could not fetch data from GitHub REST API.",
);
});
it("should exclude stars of the `test-repo-1` repository", async () => {
mock
.onGet(
"https://api.github.com/search/commits?per_page=1&q=author:anuraghazra",
)
.reply(200, { total_count: 1000 });
let stats = await fetchStats("anuraghazra", true, ["test-repo-1"]);
const rank = calculateRank({
all_commits: true,
commits: 1000,
prs: 300,
reviews: 50,
issues: 200,
repos: 5,
stars: 200,
followers: 100,
});
expect(stats).toStrictEqual({
contributedTo: 61,
name: "Anurag Hazra",
totalCommits: 1000,
totalIssues: 200,
totalPRs: 300,
totalPRsMerged: 0,
mergedPRsPercentage: 0,
totalReviews: 50,
totalStars: 200,
totalDiscussionsStarted: 0,
totalDiscussionsAnswered: 0,
totalPRsAuthored: 0,
totalPRsCommented: 0,
totalPRsReviewed: 0,
totalIssuesAuthored: 0,
totalIssuesCommented: 0,
rank,
});
});
it("should fetch two pages of stars if 'FETCH_MULTI_PAGE_STARS' env variable is set to `true`", async () => {
process.env.FETCH_MULTI_PAGE_STARS = true;
let stats = await fetchStats("anuraghazra");
const rank = calculateRank({
all_commits: false,
commits: 100,
prs: 300,
reviews: 50,
issues: 200,
repos: 5,
stars: 400,
followers: 100,
});
expect(stats).toStrictEqual({
contributedTo: 61,
name: "Anurag Hazra",
totalCommits: 100,
totalIssues: 200,
totalPRs: 300,
totalPRsMerged: 0,
mergedPRsPercentage: 0,
totalReviews: 50,
totalStars: 400,
totalDiscussionsStarted: 0,
totalDiscussionsAnswered: 0,
totalPRsAuthored: 0,
totalPRsCommented: 0,
totalPRsReviewed: 0,
totalIssuesAuthored: 0,
totalIssuesCommented: 0,
rank,
});
});
it("should fetch one page of stars if 'FETCH_MULTI_PAGE_STARS' env variable is set to `false`", async () => {
process.env.FETCH_MULTI_PAGE_STARS = "false";
let stats = await fetchStats("anuraghazra");
const rank = calculateRank({
all_commits: false,
commits: 100,
prs: 300,
reviews: 50,
issues: 200,
repos: 5,
stars: 300,
followers: 100,
});
expect(stats).toStrictEqual({
contributedTo: 61,
name: "Anurag Hazra",
totalCommits: 100,
totalIssues: 200,
totalPRs: 300,
totalPRsMerged: 0,
mergedPRsPercentage: 0,
totalReviews: 50,
totalStars: 300,
totalDiscussionsStarted: 0,
totalDiscussionsAnswered: 0,
totalPRsAuthored: 0,
totalPRsCommented: 0,
totalPRsReviewed: 0,
totalIssuesAuthored: 0,
totalIssuesCommented: 0,
rank,
});
});
it("should fetch one page of stars if 'FETCH_MULTI_PAGE_STARS' env variable is not set", async () => {
process.env.FETCH_MULTI_PAGE_STARS = undefined;
let stats = await fetchStats("anuraghazra");
const rank = calculateRank({
all_commits: false,
commits: 100,
prs: 300,
reviews: 50,
issues: 200,
repos: 5,
stars: 300,
followers: 100,
});
expect(stats).toStrictEqual({
contributedTo: 61,
name: "Anurag Hazra",
totalCommits: 100,
totalIssues: 200,
totalPRs: 300,
totalPRsMerged: 0,
mergedPRsPercentage: 0,
totalReviews: 50,
totalStars: 300,
totalDiscussionsStarted: 0,
totalDiscussionsAnswered: 0,
totalPRsAuthored: 0,
totalPRsCommented: 0,
totalPRsReviewed: 0,
totalIssuesAuthored: 0,
totalIssuesCommented: 0,
rank,
});
});
it("should not fetch additional stats data when it not requested", async () => {
let stats = await fetchStats("anuraghazra");
const rank = calculateRank({
all_commits: false,
commits: 100,
prs: 300,
reviews: 50,
issues: 200,
repos: 5,
stars: 300,
followers: 100,
});
expect(stats).toStrictEqual({
contributedTo: 61,
name: "Anurag Hazra",
totalCommits: 100,
totalIssues: 200,
totalPRs: 300,
totalPRsMerged: 0,
mergedPRsPercentage: 0,
totalReviews: 50,
totalStars: 300,
totalDiscussionsStarted: 0,
totalDiscussionsAnswered: 0,
totalPRsAuthored: 0,
totalPRsCommented: 0,
totalPRsReviewed: 0,
totalIssuesAuthored: 0,
totalIssuesCommented: 0,
rank,
});
});
it("should fetch additional stats when it requested", async () => {
let stats = await fetchStats("anuraghazra", false, [], true, true, true);
const rank = calculateRank({
all_commits: false,
commits: 100,
prs: 300,
reviews: 50,
issues: 200,
repos: 5,
stars: 300,
followers: 100,
});
expect(stats).toStrictEqual({
contributedTo: 61,
name: "Anurag Hazra",
totalCommits: 100,
totalIssues: 200,
totalPRs: 300,
totalPRsMerged: 240,
mergedPRsPercentage: 80,
totalReviews: 50,
totalStars: 300,
totalDiscussionsStarted: 10,
totalDiscussionsAnswered: 40,
totalPRsAuthored: 0,
totalPRsCommented: 0,
totalPRsReviewed: 0,
totalIssuesAuthored: 0,
totalIssuesCommented: 0,
rank,
});
});
it("should get commits of provided year", async () => {
let stats = await fetchStats(
"anuraghazra",
false,
[],
false,
false,
false,
2003,
);
const rank = calculateRank({
all_commits: false,
commits: 428,
prs: 300,
reviews: 50,
issues: 200,
repos: 5,
stars: 300,
followers: 100,
});
expect(stats).toStrictEqual({
contributedTo: 61,
name: "Anurag Hazra",
totalCommits: 428,
totalIssues: 200,
totalPRs: 300,
totalPRsMerged: 0,
mergedPRsPercentage: 0,
totalReviews: 50,
totalStars: 300,
totalDiscussionsStarted: 0,
totalDiscussionsAnswered: 0,
totalIssuesAuthored: 0,
totalIssuesCommented: 0,
totalPRsAuthored: 0,
totalPRsCommented: 0,
totalPRsReviewed: 0,
rank,
});
});
it("should return correct data when user don't have any pull requests", async () => {
mock.reset();
mock
.onPost("https://api.github.com/graphql")
.reply(200, data_without_pull_requests);
const stats = await fetchStats("anuraghazra", false, [], true);
const rank = calculateRank({
all_commits: false,
commits: 100,
prs: 0,
reviews: 50,
issues: 200,
repos: 5,
stars: 300,
followers: 100,
});
expect(stats).toStrictEqual({
contributedTo: 61,
name: "Anurag Hazra",
totalCommits: 100,
totalIssues: 200,
totalPRs: 0,
totalPRsMerged: 0,
mergedPRsPercentage: 0,
totalReviews: 50,
totalStars: 300,
totalDiscussionsStarted: 0,
totalDiscussionsAnswered: 0,
totalIssuesAuthored: 0,
totalIssuesCommented: 0,
totalPRsAuthored: 0,
totalPRsCommented: 0,
totalPRsReviewed: 0,
rank,
});
});
});
@@ -1,175 +0,0 @@
import axios from "axios";
import MockAdapter from "axios-mock-adapter";
import { afterEach, describe, expect, it } from "vitest";
import { fetchTopLanguages } from "../src/fetchers/top-languages.js";
import { approxNumber } from "./utils.js";
import "@testing-library/jest-dom/vitest";
const mock = new MockAdapter(axios);
afterEach(() => {
mock.reset();
});
const data_langs = {
data: {
user: {
repositories: {
nodes: [
{
name: "test-repo-1",
languages: {
edges: [{ size: 100, node: { color: "#0f0", name: "HTML" } }],
},
},
{
name: "test-repo-2",
languages: {
edges: [{ size: 100, node: { color: "#0f0", name: "HTML" } }],
},
},
{
name: "test-repo-3",
languages: {
edges: [
{ size: 100, node: { color: "#0ff", name: "javascript" } },
],
},
},
{
name: "test-repo-4",
languages: {
edges: [
{ size: 100, node: { color: "#0ff", name: "javascript" } },
],
},
},
],
},
},
},
};
const error = {
errors: [
{
type: "NOT_FOUND",
path: ["user"],
locations: [],
message: "Could not resolve to a User with the login of 'noname'.",
},
],
};
describe("FetchTopLanguages", () => {
it("should fetch correct language data while using the new calculation", async () => {
mock.onPost("https://api.github.com/graphql").reply(200, data_langs);
let repo = await fetchTopLanguages("anuraghazra", [], 0.5, 0.5);
expect(repo).toStrictEqual({
HTML: {
color: "#0f0",
count: 2,
name: "HTML",
size: approxNumber(20.0),
},
javascript: {
color: "#0ff",
count: 2,
name: "javascript",
size: approxNumber(20.0),
},
});
});
it("should fetch correct language data while excluding the 'test-repo-1' repository", async () => {
mock.onPost("https://api.github.com/graphql").reply(200, data_langs);
let repo = await fetchTopLanguages("anuraghazra", ["test-repo-1"]);
expect(repo).toStrictEqual({
HTML: {
color: "#0f0",
count: 1,
name: "HTML",
size: 100,
},
javascript: {
color: "#0ff",
count: 2,
name: "javascript",
size: 200,
},
});
});
it("should fetch correct language data while using the old calculation", async () => {
mock.onPost("https://api.github.com/graphql").reply(200, data_langs);
let repo = await fetchTopLanguages("anuraghazra", [], 1, 0);
expect(repo).toStrictEqual({
HTML: {
color: "#0f0",
count: 2,
name: "HTML",
size: 200,
},
javascript: {
color: "#0ff",
count: 2,
name: "javascript",
size: 200,
},
});
});
it("should rank languages by the number of repositories they appear in", async () => {
mock.onPost("https://api.github.com/graphql").reply(200, data_langs);
let repo = await fetchTopLanguages("anuraghazra", [], 0, 1);
expect(repo).toStrictEqual({
HTML: {
color: "#0f0",
count: 2,
name: "HTML",
size: 2,
},
javascript: {
color: "#0ff",
count: 2,
name: "javascript",
size: 2,
},
});
});
it("should throw specific error when user not found", async () => {
mock.onPost("https://api.github.com/graphql").reply(200, error);
await expect(fetchTopLanguages("anuraghazra")).rejects.toThrow(
"Could not resolve to a User with the login of 'noname'.",
);
});
it("should throw other errors with their message", async () => {
mock.onPost("https://api.github.com/graphql").reply(200, {
errors: [{ message: "Some test GraphQL error" }],
});
await expect(fetchTopLanguages("anuraghazra")).rejects.toThrow(
"Some test GraphQL error",
);
});
it("should throw error with specific message when error does not contain message property", async () => {
mock.onPost("https://api.github.com/graphql").reply(200, {
errors: [{ type: "TEST" }],
});
await expect(fetchTopLanguages("anuraghazra")).rejects.toThrow(
"Something went wrong while trying to retrieve the language data using the GraphQL API.",
);
});
});
-137
View File
@@ -1,137 +0,0 @@
import axios from "axios";
import MockAdapter from "axios-mock-adapter";
import { afterEach, describe, expect, it } from "vitest";
import { fetchWakatimeStats } from "../src/fetchers/wakatime.js";
import "@testing-library/jest-dom/vitest";
const mock = new MockAdapter(axios);
afterEach(() => {
mock.reset();
});
const wakaTimeData = {
data: {
categories: [
{
digital: "22:40",
hours: 22,
minutes: 40,
name: "Coding",
percent: 100,
text: "22 hrs 40 mins",
total_seconds: 81643.570077,
},
],
daily_average: 16095,
daily_average_including_other_language: 16329,
days_including_holidays: 7,
days_minus_holidays: 5,
editors: [
{
digital: "22:40",
hours: 22,
minutes: 40,
name: "VS Code",
percent: 100,
text: "22 hrs 40 mins",
total_seconds: 81643.570077,
},
],
holidays: 2,
human_readable_daily_average: "4 hrs 28 mins",
human_readable_daily_average_including_other_language: "4 hrs 32 mins",
human_readable_total: "22 hrs 21 mins",
human_readable_total_including_other_language: "22 hrs 40 mins",
id: "random hash",
is_already_updating: false,
is_coding_activity_visible: true,
is_including_today: false,
is_other_usage_visible: true,
is_stuck: false,
is_up_to_date: true,
languages: [
{
digital: "0:19",
hours: 0,
minutes: 19,
name: "Other",
percent: 1.43,
text: "19 mins",
total_seconds: 1170.434361,
},
{
digital: "0:01",
hours: 0,
minutes: 1,
name: "TypeScript",
percent: 0.1,
text: "1 min",
total_seconds: 83.293809,
},
{
digital: "0:00",
hours: 0,
minutes: 0,
name: "YAML",
percent: 0.07,
text: "0 secs",
total_seconds: 54.975151,
},
],
operating_systems: [
{
digital: "22:40",
hours: 22,
minutes: 40,
name: "Mac",
percent: 100,
text: "22 hrs 40 mins",
total_seconds: 81643.570077,
},
],
percent_calculated: 100,
range: "last_7_days",
status: "ok",
timeout: 15,
total_seconds: 80473.135716,
total_seconds_including_other_language: 81643.570077,
user_id: "random hash",
username: "anuraghazra",
writes_only: false,
},
};
describe("WakaTime fetcher", () => {
it("should fetch correct WakaTime data", async () => {
const username = "anuraghazra";
mock
.onGet(
`https://wakatime.com/api/v1/users/${username}/stats?is_including_today=true`,
)
.reply(200, wakaTimeData);
const repo = await fetchWakatimeStats({ username });
expect(repo).toStrictEqual(wakaTimeData.data);
});
it("should throw error if username param missing", async () => {
mock.onGet(/\/https:\/\/wakatime\.com\/api/).reply(404, wakaTimeData);
await expect(fetchWakatimeStats("noone")).rejects.toThrow(
'Missing params "username" make sure you pass the parameters in URL',
);
});
it("should throw error if username is not found", async () => {
mock.onGet(/\/https:\/\/wakatime\.com\/api/).reply(404, wakaTimeData);
await expect(fetchWakatimeStats({ username: "noone" })).rejects.toThrow(
"Could not resolve to a User with the login of 'noone'",
);
});
});
export { wakaTimeData };
-48
View File
@@ -1,48 +0,0 @@
import { describe, expect, it } from "vitest";
import { flexLayout } from "../src/common/render.js";
describe("flexLayout", () => {
it("should work with row & col layouts", () => {
const layout = flexLayout({
items: ["<text>1</text>", "<text>2</text>"],
gap: 60,
});
expect(layout).toStrictEqual([
`<g transform="translate(0, 0)"><text>1</text></g>`,
`<g transform="translate(60, 0)"><text>2</text></g>`,
]);
const columns = flexLayout({
items: ["<text>1</text>", "<text>2</text>"],
gap: 60,
direction: "column",
});
expect(columns).toStrictEqual([
`<g transform="translate(0, 0)"><text>1</text></g>`,
`<g transform="translate(0, 60)"><text>2</text></g>`,
]);
});
it("should work with sizes", () => {
const layout = flexLayout({
items: [
"<text>1</text>",
"<text>2</text>",
"<text>3</text>",
"<text>4</text>",
],
gap: 20,
sizes: [200, 100, 55, 25],
});
expect(layout).toStrictEqual([
`<g transform="translate(0, 0)"><text>1</text></g>`,
`<g transform="translate(220, 0)"><text>2</text></g>`,
`<g transform="translate(340, 0)"><text>3</text></g>`,
`<g transform="translate(415, 0)"><text>4</text></g>`,
]);
});
});
-105
View File
@@ -1,105 +0,0 @@
import { describe, expect, it } from "vitest";
import {
formatBytes,
kFormatter,
wrapTextMultiline,
} from "../src/common/fmt.js";
describe("Test fmt.js", () => {
it("kFormatter: should format numbers correctly by default", () => {
expect(kFormatter(1)).toBe(1);
expect(kFormatter(-1)).toBe(-1);
expect(kFormatter(500)).toBe(500);
expect(kFormatter(1000)).toBe("1k");
expect(kFormatter(1200)).toBe("1.2k");
expect(kFormatter(10000)).toBe("10k");
expect(kFormatter(12345)).toBe("12.3k");
expect(kFormatter(99900)).toBe("99.9k");
expect(kFormatter(9900000)).toBe("9900k");
});
it("kFormatter: should format numbers correctly with 0 decimal precision", () => {
expect(kFormatter(1, 0)).toBe("0k");
expect(kFormatter(-1, 0)).toBe("-0k");
expect(kFormatter(500, 0)).toBe("1k");
expect(kFormatter(1000, 0)).toBe("1k");
expect(kFormatter(1200, 0)).toBe("1k");
expect(kFormatter(10000, 0)).toBe("10k");
expect(kFormatter(12345, 0)).toBe("12k");
expect(kFormatter(99000, 0)).toBe("99k");
expect(kFormatter(99900, 0)).toBe("100k");
expect(kFormatter(9900000, 0)).toBe("9900k");
});
it("kFormatter: should format numbers correctly with 1 decimal precision", () => {
expect(kFormatter(1, 1)).toBe("0.0k");
expect(kFormatter(-1, 1)).toBe("-0.0k");
expect(kFormatter(500, 1)).toBe("0.5k");
expect(kFormatter(1000, 1)).toBe("1.0k");
expect(kFormatter(1200, 1)).toBe("1.2k");
expect(kFormatter(10000, 1)).toBe("10.0k");
expect(kFormatter(12345, 1)).toBe("12.3k");
expect(kFormatter(99900, 1)).toBe("99.9k");
expect(kFormatter(9900000, 1)).toBe("9900.0k");
});
it("kFormatter: should format numbers correctly with 2 decimal precision", () => {
expect(kFormatter(1, 2)).toBe("0.00k");
expect(kFormatter(-1, 2)).toBe("-0.00k");
expect(kFormatter(500, 2)).toBe("0.50k");
expect(kFormatter(1000, 2)).toBe("1.00k");
expect(kFormatter(1200, 2)).toBe("1.20k");
expect(kFormatter(10000, 2)).toBe("10.00k");
expect(kFormatter(12345, 2)).toBe("12.35k");
expect(kFormatter(99900, 2)).toBe("99.90k");
expect(kFormatter(9900000, 2)).toBe("9900.00k");
});
it("formatBytes: should return expected values", () => {
expect(formatBytes(0)).toBe("0 B");
expect(formatBytes(100)).toBe("100.0 B");
expect(formatBytes(1024)).toBe("1.0 KB");
expect(formatBytes(1024 * 1024)).toBe("1.0 MB");
expect(formatBytes(1024 * 1024 * 1024)).toBe("1.0 GB");
expect(formatBytes(1024 * 1024 * 1024 * 1024)).toBe("1.0 TB");
expect(formatBytes(1024 * 1024 * 1024 * 1024 * 1024)).toBe("1.0 PB");
expect(formatBytes(1024 * 1024 * 1024 * 1024 * 1024 * 1024)).toBe("1.0 EB");
expect(formatBytes(1234 * 1024)).toBe("1.2 MB");
expect(formatBytes(123.4 * 1024)).toBe("123.4 KB");
});
it("wrapTextMultiline: should not wrap small texts", () => {
{
let multiLineText = wrapTextMultiline("Small text should not wrap");
expect(multiLineText).toEqual(["Small text should not wrap"]);
}
});
it("wrapTextMultiline: should wrap large texts", () => {
let multiLineText = wrapTextMultiline(
"Hello world long long long text",
20,
3,
);
expect(multiLineText).toEqual(["Hello world long", "long long text"]);
});
it("wrapTextMultiline: should wrap large texts and limit max lines", () => {
let multiLineText = wrapTextMultiline(
"Hello world long long long text",
10,
2,
);
expect(multiLineText).toEqual(["Hello", "world long..."]);
});
it("wrapTextMultiline: should wrap chinese by punctuation", () => {
let multiLineText = wrapTextMultiline(
"专门为刚开始刷题的同学准备的算法基地,没有最细只有更细,立志用动画将晦涩难懂的算法说的通俗易懂!",
);
expect(multiLineText.length).toEqual(3);
expect(multiLineText[0].length).toEqual(18 * 8); // &#xxxxx; x 8
});
});
+124 -135
View File
@@ -1,161 +1,150 @@
// @ts-check
import axios from "axios";
import MockAdapter from "axios-mock-adapter";
import { afterEach, describe, expect, it, vi } from "vitest";
import { beforeEach, describe, expect, it, vi } from "vitest";
import gist from "../api-renamed/gist.js";
import { renderGistCard } from "../src/cards/gist.js";
const mocks = vi.hoisted(() => ({
gist: vi.fn(),
storeRequest: vi.fn(),
getUserAccessByName: vi.fn(),
config: {},
}));
vi.mock("@stats-organization/github-readme-stats-core", async () => {
const { mockCore } = await import("./utils.js");
return mockCore({ gist: mocks.gist, getConfig: () => mocks.config });
});
vi.mock("../src/common/database.js", () => ({
storeRequest: mocks.storeRequest,
getUserAccessByName: mocks.getUserAccessByName,
}));
import router from "../router.js";
import { CACHE_TTL, DURATIONS } from "../src/common/cache.js";
import { renderError } from "../src/common/render.js";
import { gist_data } from "./test-data/gist-data.js";
import "@testing-library/jest-dom/vitest";
const gist_not_found_data = {
data: {
viewer: {
gist: null,
},
},
};
const mock = new MockAdapter(axios);
afterEach(() => {
mock.reset();
const createRequest = (search) => ({
headers: {},
url: `/api/gist?${search}`,
});
describe("Test /api/gist", () => {
it("should test the request", async () => {
const req = {
query: {
id: "bbfce31e0217a3689c8d961a356cb10d",
},
};
const res = {
setHeader: vi.fn(),
send: vi.fn(),
};
mock.onPost("https://api.github.com/graphql").reply(200, gist_data);
const createResponse = () => ({
end: vi.fn(),
setHeader: vi.fn(),
});
await gist(req, res);
const defaultCacheHeader =
`max-age=${CACHE_TTL.GIST_CARD.DEFAULT}, ` +
`s-maxage=${CACHE_TTL.GIST_CARD.DEFAULT}, ` +
`stale-while-revalidate=${DURATIONS.ONE_DAY}`;
expect(res.setHeader).toHaveBeenCalledWith("Content-Type", "image/svg+xml");
expect(res.send).toHaveBeenCalledWith(
renderGistCard({
name: gist_data.data.viewer.gist.files[0].name,
nameWithOwner: `${gist_data.data.viewer.gist.owner.login}/${gist_data.data.viewer.gist.files[0].name}`,
description: gist_data.data.viewer.gist.description,
language: gist_data.data.viewer.gist.files[0].language.name,
starsCount: gist_data.data.viewer.gist.stargazerCount,
forksCount: gist_data.data.viewer.gist.forks.totalCount,
}),
);
const errorCacheHeader =
`max-age=${CACHE_TTL.ERROR}, ` +
`s-maxage=${CACHE_TTL.ERROR}, ` +
`stale-while-revalidate=${DURATIONS.ONE_DAY}`;
beforeEach(() => {
mocks.gist.mockReset();
mocks.storeRequest.mockReset().mockResolvedValue(undefined);
mocks.getUserAccessByName.mockReset().mockResolvedValue(null);
mocks.config = {};
// CACHE_SECONDS is not set here, this is just to safeguard against CACHE_SECONDS being set externally
delete process.env.CACHE_SECONDS;
});
describe("Test /api/gist backend routing", () => {
it("happy path should pass query params, respond with gist content and persist request", async () => {
mocks.gist.mockResolvedValue({
status: "success",
content: "mock-gist-svg",
});
const req = createRequest("id=bbfce31e0217a3689c8d961a356cb10d&theme=dark");
const res = createResponse();
await router(req, res);
expect(mocks.gist).toHaveBeenCalledWith({
id: "bbfce31e0217a3689c8d961a356cb10d",
theme: "dark",
});
expect(mocks.getUserAccessByName).not.toHaveBeenCalled();
expect(req.query).toEqual({
id: "bbfce31e0217a3689c8d961a356cb10d",
theme: "dark",
});
expect(res.setHeader.mock.calls).toEqual([
["Cache-Control", defaultCacheHeader],
["Content-Type", "image/svg+xml"],
]);
expect(res.end).toHaveBeenCalledExactlyOnceWith("mock-gist-svg");
expect(mocks.storeRequest).toHaveBeenCalledExactlyOnceWith(req);
});
it("should get the query options", async () => {
const req = {
query: {
id: "bbfce31e0217a3689c8d961a356cb10d",
title_color: "fff",
icon_color: "fff",
text_color: "fff",
bg_color: "fff",
show_owner: true,
},
};
const res = {
setHeader: vi.fn(),
send: vi.fn(),
};
mock.onPost("https://api.github.com/graphql").reply(200, gist_data);
it("should use the shorter error cache for temporary gist errors", async () => {
mocks.gist.mockResolvedValue({
status: "error - temporary",
content: "temporary-error-svg",
});
await gist(req, res);
const req = createRequest("id=bbfce31e0217a3689c8d961a356cb10d");
const res = createResponse();
expect(res.setHeader).toHaveBeenCalledWith("Content-Type", "image/svg+xml");
expect(res.send).toHaveBeenCalledWith(
renderGistCard(
{
name: gist_data.data.viewer.gist.files[0].name,
nameWithOwner: `${gist_data.data.viewer.gist.owner.login}/${gist_data.data.viewer.gist.files[0].name}`,
description: gist_data.data.viewer.gist.description,
language: gist_data.data.viewer.gist.files[0].language.name,
starsCount: gist_data.data.viewer.gist.stargazerCount,
forksCount: gist_data.data.viewer.gist.forks.totalCount,
},
{ ...req.query },
),
);
await router(req, res);
expect(mocks.gist).toHaveBeenCalledWith({
id: "bbfce31e0217a3689c8d961a356cb10d",
});
expect(mocks.getUserAccessByName).not.toHaveBeenCalled();
expect(res.setHeader.mock.calls).toEqual([
["Cache-Control", errorCacheHeader],
["Content-Type", "image/svg+xml"],
]);
expect(res.end).toHaveBeenCalledExactlyOnceWith("temporary-error-svg");
expect(mocks.storeRequest).toHaveBeenCalledExactlyOnceWith(req);
});
it("should render error if gist is not found", async () => {
const req = {
query: {
id: "bbfce31e0217a3689c8d961a356cb10d",
},
};
const res = {
setHeader: vi.fn(),
send: vi.fn(),
};
mock
.onPost("https://api.github.com/graphql")
.reply(200, gist_not_found_data);
it("should not persist permanent gist errors returned by core", async () => {
mocks.gist.mockResolvedValue({
status: "error - permanent",
content: "permanent-error-svg",
});
await gist(req, res);
const req = createRequest("id=bbfce31e0217a3689c8d961a356cb10d");
const res = createResponse();
expect(res.setHeader).toHaveBeenCalledWith("Content-Type", "image/svg+xml");
expect(res.send).toHaveBeenCalledWith(
renderError({ message: "Gist not found" }),
);
await router(req, res);
expect(mocks.gist).toHaveBeenCalledWith({
id: "bbfce31e0217a3689c8d961a356cb10d",
});
expect(mocks.getUserAccessByName).not.toHaveBeenCalled();
expect(res.setHeader.mock.calls).toEqual([
["Cache-Control", defaultCacheHeader],
["Content-Type", "image/svg+xml"],
]);
expect(res.end).toHaveBeenCalledExactlyOnceWith("permanent-error-svg");
expect(mocks.storeRequest).not.toHaveBeenCalled();
});
it("should render error if wrong locale is provided", async () => {
const req = {
query: {
id: "bbfce31e0217a3689c8d961a356cb10d",
locale: "asdf",
},
it("should reject non-whitelisted gist ids before calling core logic", async () => {
mocks.config = {
gistWhitelist: ["allowed-gist-id"],
};
const res = {
setHeader: vi.fn(),
send: vi.fn(),
};
mock.onPost("https://api.github.com/graphql").reply(200, gist_data);
await gist(req, res);
const req = createRequest("id=blocked-gist-id");
const res = createResponse();
expect(res.setHeader).toHaveBeenCalledWith("Content-Type", "image/svg+xml");
expect(res.send).toHaveBeenCalledWith(
renderError({
message: "Something went wrong",
secondaryMessage: "Language not found",
}),
);
});
await router(req, res);
it("should have proper cache", async () => {
const req = {
query: {
id: "bbfce31e0217a3689c8d961a356cb10d",
},
};
const res = {
setHeader: vi.fn(),
send: vi.fn(),
};
mock.onPost("https://api.github.com/graphql").reply(200, gist_data);
await gist(req, res);
expect(res.setHeader).toHaveBeenCalledWith("Content-Type", "image/svg+xml");
expect(res.setHeader).toHaveBeenCalledWith(
"Cache-Control",
`max-age=${CACHE_TTL.GIST_CARD.DEFAULT}, ` +
`s-maxage=${CACHE_TTL.GIST_CARD.DEFAULT}, ` +
`stale-while-revalidate=${DURATIONS.ONE_DAY}`,
expect(mocks.gist).not.toHaveBeenCalled();
expect(mocks.getUserAccessByName).not.toHaveBeenCalled();
expect(res.setHeader.mock.calls).toEqual([
["Cache-Control", defaultCacheHeader],
["Content-Type", "image/svg+xml"],
]);
expect(res.end).toHaveBeenCalledExactlyOnceWith(
"render-error:This gist ID is not whitelisted",
);
expect(mocks.storeRequest).not.toHaveBeenCalled();
});
});
-11
View File
@@ -1,11 +0,0 @@
import { describe, expect, it } from "vitest";
import { encodeHTML } from "../src/common/html.js";
describe("Test html.js", () => {
it("should test encodeHTML", () => {
expect(encodeHTML(`<html>hello world<,.#4^&^@%!))`)).toBe(
"&#60;html&#62;hello world&#60;,.#4^&#38;^@%!))",
);
});
});
-34
View File
@@ -1,34 +0,0 @@
import { describe, expect, it } from "vitest";
import { I18n } from "../src/common/I18n.js";
import { statCardLocales } from "../src/translations.js";
describe("I18n", () => {
it("should return translated string", () => {
const i18n = new I18n({
locale: "en",
translations: statCardLocales({ name: "Anurag Hazra", apostrophe: "s" }),
});
expect(i18n.t("statcard.title")).toBe("Anurag Hazra's GitHub Stats");
});
it("should throw error if translation string not found", () => {
const i18n = new I18n({
locale: "en",
translations: statCardLocales({ name: "Anurag Hazra", apostrophe: "s" }),
});
expect(() => i18n.t("statcard.title1")).toThrow(
"statcard.title1 Translation string not found",
);
});
it("should throw error if translation not found for locale", () => {
const i18n = new I18n({
locale: "asdf",
translations: statCardLocales({ name: "Anurag Hazra", apostrophe: "s" }),
});
expect(() => i18n.t("statcard.title")).toThrow(
"'statcard.title' translation not found for locale 'asdf'",
);
});
});
-90
View File
@@ -1,90 +0,0 @@
import { describe, expect, it } from "vitest";
import {
chunkArray,
clampValue,
dateDiff,
lowercaseTrim,
parseArray,
parseBoolean,
parseEmojis,
} from "../src/common/ops.js";
describe("Test ops.js", () => {
it("should test parseBoolean", () => {
expect(parseBoolean(true)).toBe(true);
expect(parseBoolean(false)).toBe(false);
expect(parseBoolean("true")).toBe(true);
expect(parseBoolean("false")).toBe(false);
expect(parseBoolean("True")).toBe(true);
expect(parseBoolean("False")).toBe(false);
expect(parseBoolean("TRUE")).toBe(true);
expect(parseBoolean("FALSE")).toBe(false);
expect(parseBoolean("1")).toBe(undefined);
expect(parseBoolean("0")).toBe(undefined);
expect(parseBoolean("")).toBe(undefined);
// @ts-ignore
expect(parseBoolean(undefined)).toBe(undefined);
});
it("should test parseArray", () => {
expect(parseArray("a,b,c")).toEqual(["a", "b", "c"]);
expect(parseArray("a, b, c")).toEqual(["a", " b", " c"]); // preserves spaces
expect(parseArray("")).toEqual([]);
// @ts-ignore
expect(parseArray(undefined)).toEqual([]);
});
it("should test clampValue", () => {
expect(clampValue(5, 1, 10)).toBe(5);
expect(clampValue(0, 1, 10)).toBe(1);
expect(clampValue(15, 1, 10)).toBe(10);
// string inputs are coerced numerically by Math.min/Math.max
// @ts-ignore
expect(clampValue("7", 1, 10)).toBe(7);
// non-numeric and NaN fall back to min
// @ts-ignore
expect(clampValue("abc", 1, 10)).toBe(1);
expect(clampValue(NaN, 2, 5)).toBe(2);
});
it("should test lowercaseTrim", () => {
expect(lowercaseTrim(" Hello World ")).toBe("hello world");
expect(lowercaseTrim("already lower")).toBe("already lower");
});
it("should test chunkArray", () => {
expect(chunkArray([1, 2, 3, 4, 5], 2)).toEqual([[1, 2], [3, 4], [5]]);
expect(chunkArray([1, 2, 3, 4, 5], 1)).toEqual([[1], [2], [3], [4], [5]]);
expect(chunkArray([1, 2, 3, 4, 5], 10)).toEqual([[1, 2, 3, 4, 5]]);
});
it("should test parseEmojis", () => {
// unknown emoji name is stripped
expect(parseEmojis("Hello :nonexistent:")).toBe("Hello ");
// common emoji names should be replaced (at least token removed)
const out = parseEmojis("I :heart: OSS");
expect(out).not.toContain(":heart:");
expect(out.startsWith("I ")).toBe(true);
expect(out.endsWith(" OSS")).toBe(true);
expect(() => parseEmojis("")).toThrow(/parseEmoji/);
// @ts-ignore
expect(() => parseEmojis()).toThrow(/parseEmoji/);
});
it("should test dateDiff", () => {
const a = new Date("2020-01-01T00:10:00Z");
const b = new Date("2020-01-01T00:00:00Z");
expect(dateDiff(a, b)).toBe(10);
const c = new Date("2020-01-01T00:00:00Z");
const d = new Date("2020-01-01T00:10:30Z");
// rounds to nearest minute
expect(dateDiff(c, d)).toBe(-10);
});
});
+15 -12
View File
@@ -6,8 +6,6 @@ import axios from "axios";
import MockAdapter from "axios-mock-adapter";
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
import patInfo, { RATE_LIMIT_SECONDS } from "../api-renamed/status/pat-info.js";
const mock = new MockAdapter(axios);
const successData = {
@@ -57,20 +55,26 @@ const bad_credentials_error = {
message: "Bad credentials",
};
let RATE_LIMIT_SECONDS, patInfo;
beforeAll(async () => {
vi.stubEnv("PAT_1", "testPAT1");
vi.stubEnv("PAT_2", "testPAT2");
vi.stubEnv("PAT_3", "testPAT3");
vi.stubEnv("PAT_4", "testPAT4");
({ RATE_LIMIT_SECONDS, default: patInfo } =
await import("../api-renamed/status/pat-info.js"));
});
afterEach(() => {
mock.reset();
vi.unstubAllEnvs();
// modules may cache environment variables, so we need to reset them
vi.resetModules();
});
describe("Test /api/status/pat-info", () => {
beforeAll(() => {
// reset patenv first so that they are not populated with local envs
process.env = {};
process.env.PAT_1 = "testPAT1";
process.env.PAT_2 = "testPAT2";
process.env.PAT_3 = "testPAT3";
process.env.PAT_4 = "testPAT4";
});
it("should return only 'validPATs' if all PATs are valid", async () => {
mock
.onPost("https://api.github.com/graphql")
@@ -243,7 +247,6 @@ describe("Test /api/status/pat-info", () => {
});
it("should have proper cache when error is thrown", async () => {
mock.reset();
mock.onPost("https://api.github.com/graphql").networkError();
const { req, res } = faker({}, {});
+70 -162
View File
@@ -1,175 +1,83 @@
// @ts-check
import axios from "axios";
import MockAdapter from "axios-mock-adapter";
import { afterEach, describe, expect, it, vi } from "vitest";
import { beforeEach, describe, expect, it, vi } from "vitest";
import pin from "../api-renamed/pin.js";
import { renderRepoCard } from "../src/cards/repo.js";
const mocks = vi.hoisted(() => ({
pin: vi.fn(),
storeRequest: vi.fn(),
getUserAccessByName: vi.fn(),
}));
vi.mock("@stats-organization/github-readme-stats-core", async () => {
const { mockCore } = await import("./utils.js");
return mockCore({ pin: mocks.pin });
});
vi.mock("../src/common/database.js", () => ({
storeRequest: mocks.storeRequest,
getUserAccessByName: mocks.getUserAccessByName,
}));
import router from "../router.js";
import { CACHE_TTL, DURATIONS } from "../src/common/cache.js";
import { renderError } from "../src/common/render.js";
import { data_repo, data_user } from "./test-data/pin-data.js";
import "@testing-library/jest-dom/vitest";
const mock = new MockAdapter(axios);
afterEach(() => {
mock.reset();
const createRequest = (search = "") => ({
headers: {},
url: `/api/pin?${search}`,
});
describe("Test /api/pin", () => {
it("should test the request", async () => {
const req = {
query: {
const createResponse = () => ({
end: vi.fn(),
setHeader: vi.fn(),
});
const defaultCacheHeader =
`max-age=${CACHE_TTL.PIN_CARD.DEFAULT}, ` +
`s-maxage=${CACHE_TTL.PIN_CARD.DEFAULT}, ` +
`stale-while-revalidate=${DURATIONS.ONE_DAY}`;
beforeEach(() => {
mocks.pin.mockReset();
mocks.storeRequest.mockReset().mockResolvedValue(undefined);
mocks.getUserAccessByName.mockReset().mockResolvedValue(null);
// CACHE_SECONDS is not set here, this is just to safeguard against CACHE_SECONDS being set externally
delete process.env.CACHE_SECONDS;
});
describe("Test /api/pin backend routing", () => {
it("happy path should pass query params and user PAT, respond with pin content and persist request", async () => {
mocks.getUserAccessByName.mockResolvedValue({ token: "user-pat" });
mocks.pin.mockResolvedValue({
status: "success",
content: "mock-pin-svg",
});
const req = createRequest(
"username=anuraghazra&repo=convoychat&theme=dark",
);
const res = createResponse();
await router(req, res);
expect(mocks.getUserAccessByName).toHaveBeenCalledWith("anuraghazra");
expect(mocks.pin).toHaveBeenCalledWith(
{
username: "anuraghazra",
repo: "convoychat",
theme: "dark",
},
};
const res = {
setHeader: vi.fn(),
send: vi.fn(),
};
mock.onPost("https://api.github.com/graphql").reply(200, data_user);
await pin(req, res);
expect(res.setHeader).toHaveBeenCalledWith("Content-Type", "image/svg+xml");
expect(res.send).toHaveBeenCalledWith(
// @ts-ignore
renderRepoCard({
...data_repo.repository,
starCount: data_repo.repository.stargazers.totalCount,
}),
);
});
it("should get the query options", async () => {
const req = {
query: {
username: "anuraghazra",
repo: "convoychat",
title_color: "fff",
icon_color: "fff",
text_color: "fff",
bg_color: "fff",
full_name: "1",
},
};
const res = {
setHeader: vi.fn(),
send: vi.fn(),
};
mock.onPost("https://api.github.com/graphql").reply(200, data_user);
await pin(req, res);
expect(res.setHeader).toHaveBeenCalledWith("Content-Type", "image/svg+xml");
expect(res.send).toHaveBeenCalledWith(
renderRepoCard(
// @ts-ignore
{
...data_repo.repository,
starCount: data_repo.repository.stargazers.totalCount,
},
{ ...req.query },
),
);
});
it("should render error card if user repo not found", async () => {
const req = {
query: {
username: "anuraghazra",
repo: "convoychat",
},
};
const res = {
setHeader: vi.fn(),
send: vi.fn(),
};
mock
.onPost("https://api.github.com/graphql")
.reply(200, { data: { user: { repository: null }, organization: null } });
await pin(req, res);
expect(res.setHeader).toHaveBeenCalledWith("Content-Type", "image/svg+xml");
expect(res.send).toHaveBeenCalledWith(
renderError({ message: "User Repository Not found" }),
);
});
it("should render error card if org repo not found", async () => {
const req = {
query: {
username: "anuraghazra",
repo: "convoychat",
},
};
const res = {
setHeader: vi.fn(),
send: vi.fn(),
};
mock
.onPost("https://api.github.com/graphql")
.reply(200, { data: { user: null, organization: { repository: null } } });
await pin(req, res);
expect(res.setHeader).toHaveBeenCalledWith("Content-Type", "image/svg+xml");
expect(res.send).toHaveBeenCalledWith(
renderError({ message: "Organization Repository Not found" }),
);
});
it("should render error card if wrong locale provided", async () => {
const req = {
query: {
username: "anuraghazra",
repo: "convoychat",
locale: "asdf",
},
};
const res = {
setHeader: vi.fn(),
send: vi.fn(),
};
mock.onPost("https://api.github.com/graphql").reply(200, data_user);
await pin(req, res);
expect(res.setHeader).toHaveBeenCalledWith("Content-Type", "image/svg+xml");
expect(res.send).toHaveBeenCalledWith(
renderError({
message: "Something went wrong",
secondaryMessage: "Language not found",
}),
);
});
it("should have proper cache", async () => {
const req = {
query: {
username: "anuraghazra",
repo: "convoychat",
},
};
const res = {
setHeader: vi.fn(),
send: vi.fn(),
};
mock.onPost("https://api.github.com/graphql").reply(200, data_user);
await pin(req, res);
expect(res.setHeader).toHaveBeenCalledWith("Content-Type", "image/svg+xml");
expect(res.setHeader).toHaveBeenCalledWith(
"Cache-Control",
`max-age=${CACHE_TTL.PIN_CARD.DEFAULT}, ` +
`s-maxage=${CACHE_TTL.PIN_CARD.DEFAULT}, ` +
`stale-while-revalidate=${DURATIONS.ONE_DAY}`,
"user-pat",
);
expect(req.query).toEqual({
username: "anuraghazra",
repo: "convoychat",
theme: "dark",
});
expect(res.setHeader.mock.calls).toEqual([
["Cache-Control", defaultCacheHeader],
["Content-Type", "image/svg+xml"],
]);
expect(res.end).toHaveBeenCalledExactlyOnceWith("mock-pin-svg");
expect(mocks.storeRequest).toHaveBeenCalledExactlyOnceWith(req);
});
});
@@ -1,41 +0,0 @@
// @ts-check
import axios from "axios";
import MockAdapter from "axios-mock-adapter";
import { afterEach, describe, expect, it, vi } from "vitest";
import api from "../../api-renamed/index.js";
import { renderError } from "../../src/common/render.js";
import { data_stats } from "../test-data/api-data.js";
const mock = new MockAdapter(axios);
afterEach(() => {
mock.reset();
});
describe("Test /api/", () => {
it("should render error card if username not in whitelist", async () => {
const req = {
query: {
username: "renovate-bot",
},
};
const res = {
setHeader: vi.fn(),
send: vi.fn(),
};
mock.onPost("https://api.github.com/graphql").replyOnce(200, data_stats);
await api(req, res);
expect(res.setHeader).toHaveBeenCalledWith("Content-Type", "image/svg+xml");
expect(res.send).toHaveBeenCalledWith(
renderError({
message: "This username is not whitelisted",
secondaryMessage: "Please deploy your own instance",
renderOptions: { show_repo_link: false },
}),
);
});
});
@@ -1,41 +0,0 @@
// @ts-check
import axios from "axios";
import MockAdapter from "axios-mock-adapter";
import { afterEach, describe, expect, it, vi } from "vitest";
import gist from "../../api-renamed/gist.js";
import { renderError } from "../../src/common/render.js";
import { gist_data } from "../test-data/gist-data.js";
const mock = new MockAdapter(axios);
afterEach(() => {
mock.reset();
});
describe("Test /api/gist with gist whitelist", () => {
it("should render error card if id not in whitelist", async () => {
const req = {
query: {
id: "9bae0392ee3a26bac5cc388a6c8b1469",
},
};
const res = {
setHeader: vi.fn(),
send: vi.fn(),
};
mock.onPost("https://api.github.com/graphql").reply(200, gist_data);
await gist(req, res);
expect(res.setHeader).toHaveBeenCalledWith("Content-Type", "image/svg+xml");
expect(res.send).toHaveBeenCalledWith(
renderError({
message: "This gist ID is not whitelisted",
secondaryMessage: "Please deploy your own instance",
renderOptions: { show_repo_link: false },
}),
);
});
});
@@ -1,64 +0,0 @@
// @ts-check
import axios from "axios";
import MockAdapter from "axios-mock-adapter";
import { afterEach, describe, expect, it, vi } from "vitest";
import pin from "../../api-renamed/pin.js";
import { renderError } from "../../src/common/render.js";
import { data_user } from "../test-data/pin-data.js";
const mock = new MockAdapter(axios);
afterEach(() => {
mock.reset();
});
describe("Test /api/pin", () => {
it("should render error card if username not in whitelist", async () => {
const req = {
query: {
username: "renovate-bot",
repo: "convoychat",
},
};
const res = {
setHeader: vi.fn(),
send: vi.fn(),
};
mock.onPost("https://api.github.com/graphql").reply(200, data_user);
await pin(req, res);
expect(res.setHeader).toHaveBeenCalledWith("Content-Type", "image/svg+xml");
expect(res.send).toHaveBeenCalledWith(
renderError({
message: "This username is not whitelisted",
secondaryMessage: "Please deploy your own instance",
renderOptions: { show_repo_link: false },
}),
);
});
it("should render error card if missing required parameters", async () => {
const req = {
query: {},
};
const res = {
setHeader: vi.fn(),
send: vi.fn(),
};
mock.onPost("https://api.github.com/graphql").reply(200, data_user);
await pin(req, res);
expect(res.setHeader).toHaveBeenCalledWith("Content-Type", "image/svg+xml");
expect(res.send).toHaveBeenCalledWith(
renderError({
message: "This username is not whitelisted",
secondaryMessage: "Please deploy your own instance",
renderOptions: { show_repo_link: false },
}),
);
});
});
@@ -1,41 +0,0 @@
// @ts-check
import axios from "axios";
import MockAdapter from "axios-mock-adapter";
import { afterEach, describe, expect, it, vi } from "vitest";
import topLangs from "../../api-renamed/top-langs.js";
import { renderError } from "../../src/common/render.js";
import { data_langs } from "../test-data/langs-data.js";
const mock = new MockAdapter(axios);
afterEach(() => {
mock.reset();
});
describe("Test /api/top-langs", () => {
it("should render error card if username not in whitelist", async () => {
const req = {
query: {
username: "renovate-bot",
},
};
const res = {
setHeader: vi.fn(),
send: vi.fn(),
};
mock.onPost("https://api.github.com/graphql").reply(200, data_langs);
await topLangs(req, res);
expect(res.setHeader).toHaveBeenCalledWith("Content-Type", "image/svg+xml");
expect(res.send).toHaveBeenCalledWith(
renderError({
message: "This username is not whitelisted",
secondaryMessage: "Please deploy your own instance",
renderOptions: { show_repo_link: false },
}),
);
});
});
@@ -0,0 +1,514 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`Test /api contract > should match the private missing-username response snapshot 1`] = `
{
"content": "<svg width="576.5" height="120" viewBox="0 0 576.5 120" fill="#fffefe" xmlns="http://www.w3.org/2000/svg">
<style>
.text { font: 600 16px 'Segoe UI', Ubuntu, Sans-Serif; fill: #2f80ed }
.small { font: 600 12px 'Segoe UI', Ubuntu, Sans-Serif; fill: #434d58 }
.gray { fill: #858585 }
</style>
<rect x="0.5" y="0.5" width="575.5" height="99%" rx="4.5" fill="#fffefe" stroke="#e4e2e2"/>
<text x="25" y="45" class="text">Something went wrong!</text>
<text data-testid="message" x="25" y="55" class="text small">
<tspan x="25" dy="18">This username is not whitelisted</tspan>
<tspan x="25" dy="18" class="gray">Please deploy your own instance</tspan>
</text>
</svg>",
"headers": [
[
"Cache-Control",
"max-age=36000, s-maxage=36000, stale-while-revalidate=86400",
],
[
"Content-Type",
"image/svg+xml",
],
],
}
`;
exports[`Test /api contract > should match the public blacklisted-username response snapshot 1`] = `
{
"content": "<svg width="576.5" height="120" viewBox="0 0 576.5 120" fill="#fffefe" xmlns="http://www.w3.org/2000/svg">
<style>
.text { font: 600 16px 'Segoe UI', Ubuntu, Sans-Serif; fill: #2f80ed }
.small { font: 600 12px 'Segoe UI', Ubuntu, Sans-Serif; fill: #434d58 }
.gray { fill: #858585 }
</style>
<rect x="0.5" y="0.5" width="575.5" height="99%" rx="4.5" fill="#fffefe" stroke="#e4e2e2"/>
<text x="25" y="45" class="text">Something went wrong!</text>
<text data-testid="message" x="25" y="55" class="text small">
<tspan x="25" dy="18">This username is blacklisted</tspan>
<tspan x="25" dy="18" class="gray">Please deploy your own instance</tspan>
</text>
</svg>",
"headers": [
[
"Cache-Control",
"max-age=36000, s-maxage=36000, stale-while-revalidate=86400",
],
[
"Content-Type",
"image/svg+xml",
],
],
}
`;
exports[`Test /api contract > should match the public happy-path response snapshot 1`] = `
{
"content": "<svg width="450" height="195" viewBox="0 0 450 195" fill="none" xmlns="http://www.w3.org/2000/svg" role="img" aria-labelledby="descId">
<title id="titleId">Anurag Hazra's GitHub Stats, Rank: A+</title>
<desc id="descId">Total Stars Earned: 14099, Total Commits (last year) : 200, Total PRs: 4000, Total Issues: 340, Contributed to (last year): 51</desc>
<style>
.header {
font: 600 18px 'Segoe UI', Ubuntu, Sans-Serif;
fill: #2f80ed;
animation: fadeInAnimation 0.8s ease-in-out forwards;
}
@supports(-moz-appearance: auto) {
/* Selector detects Firefox */
.header { font-size: 15.5px; }
}
.stat {
font: 600 14px 'Segoe UI', Ubuntu, "Helvetica Neue", Sans-Serif; fill: #434d58;
}
@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: #434d58;
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: #4c71f2;
display: none;
}
.rank-circle-rim {
stroke: #2f80ed;
fill: none;
stroke-width: 6;
opacity: 0.2;
}
.rank-circle {
stroke: #2f80ed;
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;
}
@keyframes rankAnimation {
from {
stroke-dashoffset: 251.32741228718345;
}
to {
stroke-dashoffset: 25.665014275610705;
}
}
/* 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;
}
}
</style>
<rect data-testid="card-bg" x="0.5" y="0.5" rx="4.5" height="99%" stroke="#e4e2e2" width="449" fill="#fffefe" stroke-opacity="1"/>
<g data-testid="card-title" transform="translate(25, 35)">
<g transform="translate(0, 0)">
<text x="0" y="0" class="header" data-testid="header">Anurag Hazra's GitHub Stats</text>
</g>
</g>
<g data-testid="main-card-body" transform="translate(0, 55)">
<g data-testid="rank-circle" transform="translate(365, 47.5)">
<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">
<text x="-5" y="3" alignment-baseline="central" dominant-baseline="central" text-anchor="middle" data-testid="level-rank-icon">
A+
</text>
</g>
</g>
<svg x="0" y="0">
<g transform="translate(0, 0)">
<g class="stagger" style="animation-delay: 450ms" transform="translate(25, 0)">
<text class="stat bold" y="12.5">Total Stars Earned:</text>
<text class="stat bold" x="204.01" y="12.5" data-testid="stars">14.1k</text>
</g>
</g><g transform="translate(0, 25)">
<g class="stagger" style="animation-delay: 600ms" transform="translate(25, 0)">
<text class="stat bold" y="12.5">Total Commits (last year):</text>
<text class="stat bold" x="204.01" y="12.5" data-testid="commits">200</text>
</g>
</g><g transform="translate(0, 50)">
<g class="stagger" style="animation-delay: 750ms" transform="translate(25, 0)">
<text class="stat bold" y="12.5">Total PRs:</text>
<text class="stat bold" x="204.01" y="12.5" data-testid="prs">4k</text>
</g>
</g><g transform="translate(0, 75)">
<g class="stagger" style="animation-delay: 900ms" transform="translate(25, 0)">
<text class="stat bold" y="12.5">Total Issues:</text>
<text class="stat bold" x="204.01" y="12.5" data-testid="issues">340</text>
</g>
</g><g transform="translate(0, 100)">
<g class="stagger" style="animation-delay: 1050ms" transform="translate(25, 0)">
<text class="stat bold" y="12.5">Contributed to (last year):</text>
<text class="stat bold" x="204.01" y="12.5" data-testid="contribs">51</text>
</g>
</g>
</svg>
</g>
</svg>",
"headers": [
[
"Cache-Control",
"max-age=36000, s-maxage=36000, stale-while-revalidate=86400",
],
[
"Content-Type",
"image/svg+xml",
],
],
}
`;
exports[`Test /api contract > should match the public many-params response snapshot 1`] = `
{
"content": "<svg width="540" height="365" viewBox="0 0 540 365" fill="none" xmlns="http://www.w3.org/2000/svg" role="img" aria-labelledby="descId">
<title id="titleId">a custom title, Rank: A+</title>
<desc id="descId">कुल अर्जित सितारे: 4100, कुल commits (2024) : 200, कुल PR: 4000, कुल PR का विलय: 3200, मर्ज किए गए PRs प्रतिशत: 80.0, कुल PRs की समीक्षा की गई: 1234, कुल चर्चाएँ शुरू हुईं: 222, कुल चर्चाओं के उत्तर: 111, (पिछले वर्ष) में योगदान दिया: 51</desc>
<style>
.header {
font: 600 18px 'Segoe UI', Ubuntu, Sans-Serif;
fill: #123456;
animation: fadeInAnimation 0.8s ease-in-out forwards;
}
@supports(-moz-appearance: auto) {
/* Selector detects Firefox */
.header { font-size: 15.5px; }
}
.stat {
font: 600 14px 'Segoe UI', Ubuntu, "Helvetica Neue", Sans-Serif; fill: #abcdef;
}
@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: #abcdef;
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: #ff00aa;
display: block;
}
.rank-circle-rim {
stroke: #654321;
fill: none;
stroke-width: 6;
opacity: 0.2;
}
.rank-circle {
stroke: #654321;
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;
}
@keyframes rankAnimation {
from {
stroke-dashoffset: 251.32741228718345;
}
to {
stroke-dashoffset: 26.378312814443284;
}
}
/* 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;
}
}
* { animation-duration: 0s !important; animation-delay: 0s !important; }
</style>
<rect data-testid="card-bg" x="0.5" y="0.5" rx="12" height="99%" stroke="#fedcba" width="539" fill="#0f172a" stroke-opacity="1"/>
<g data-testid="card-title" transform="translate(25, 35)">
<g transform="translate(0, 0)">
<text x="0" y="0" class="header" data-testid="header">a custom title</text>
</g>
</g>
<g data-testid="main-card-body" transform="translate(0, 55)">
<g data-testid="rank-circle" transform="translate(463.5, 132.5)">
<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">
<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"/>
</svg>
</g>
</g>
<svg x="0" y="0">
<g transform="translate(0, 0)">
<g class="stagger" style="animation-delay: 450ms" transform="translate(25, 0)">
<svg data-testid="icon" class="icon" viewBox="0 0 16 16" version="1.1" width="16" height="16">
<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"/>
</svg>
<text class="stat not_bold" x="25" y="12.5">कुल अर्जित सितारे:</text>
<text class="stat not_bold" x="219.01" y="12.5" data-testid="stars">4100</text>
</g>
</g><g transform="translate(0, 32)">
<g class="stagger" style="animation-delay: 600ms" transform="translate(25, 0)">
<svg data-testid="icon" class="icon" viewBox="0 0 16 16" version="1.1" width="16" height="16">
<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"/>
</svg>
<text class="stat not_bold" x="25" y="12.5">कुल commits (2024):</text>
<text class="stat not_bold" x="219.01" y="12.5" data-testid="commits">200</text>
</g>
</g><g transform="translate(0, 64)">
<g class="stagger" style="animation-delay: 750ms" transform="translate(25, 0)">
<svg data-testid="icon" class="icon" viewBox="0 0 16 16" version="1.1" width="16" height="16">
<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"/>
</svg>
<text class="stat not_bold" x="25" y="12.5">कुल PR:</text>
<text class="stat not_bold" x="219.01" y="12.5" data-testid="prs">4000</text>
</g>
</g><g transform="translate(0, 96)">
<g class="stagger" style="animation-delay: 900ms" transform="translate(25, 0)">
<svg data-testid="icon" class="icon" viewBox="0 0 16 16" version="1.1" width="16" height="16">
<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"/>
</svg>
<text class="stat not_bold" x="25" y="12.5">कुल PR का विलय:</text>
<text class="stat not_bold" x="219.01" y="12.5" data-testid="prs_merged">3200</text>
</g>
</g><g transform="translate(0, 128)">
<g class="stagger" style="animation-delay: 1050ms" transform="translate(25, 0)">
<svg data-testid="icon" class="icon" viewBox="0 0 16 16" version="1.1" width="16" height="16">
<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"/>
</svg>
<text class="stat not_bold" x="25" y="12.5">मर्ज किए गए PRs प्रतिशत:</text>
<text class="stat not_bold" x="219.01" y="12.5" data-testid="prs_merged_percentage">80.0 %</text>
</g>
</g><g transform="translate(0, 160)">
<g class="stagger" style="animation-delay: 1200ms" transform="translate(25, 0)">
<svg data-testid="icon" class="icon" viewBox="0 0 16 16" version="1.1" width="16" height="16">
<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"/>
</svg>
<text class="stat not_bold" x="25" y="12.5">कुल PRs की समीक्षा की गई:</text>
<text class="stat not_bold" x="219.01" y="12.5" data-testid="reviews">1234</text>
</g>
</g><g transform="translate(0, 192)">
<g class="stagger" style="animation-delay: 1350ms" transform="translate(25, 0)">
<svg data-testid="icon" class="icon" viewBox="0 0 16 16" version="1.1" width="16" height="16">
<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"/>
</svg>
<text class="stat not_bold" x="25" y="12.5">कुल चर्चाएँ शुरू हुईं:</text>
<text class="stat not_bold" x="219.01" y="12.5" data-testid="discussions_started">222</text>
</g>
</g><g transform="translate(0, 224)">
<g class="stagger" style="animation-delay: 1500ms" transform="translate(25, 0)">
<svg data-testid="icon" class="icon" viewBox="0 0 16 16" version="1.1" width="16" height="16">
<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"/>
</svg>
<text class="stat not_bold" x="25" y="12.5">कुल चर्चाओं के उत्तर:</text>
<text class="stat not_bold" x="219.01" y="12.5" data-testid="discussions_answered">111</text>
</g>
</g><g transform="translate(0, 256)">
<g class="stagger" style="animation-delay: 1650ms" transform="translate(25, 0)">
<svg data-testid="icon" class="icon" viewBox="0 0 16 16" version="1.1" width="16" height="16">
<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"/>
</svg>
<text class="stat not_bold" x="25" y="12.5">(पिछले वर्ष) में योगदान दिया:</text>
<text class="stat not_bold" x="219.01" y="12.5" data-testid="contribs">51</text>
</g>
</g>
</svg>
</g>
</svg>",
"graphqlRequest": "{"query":"\\n query userInfo($login: String!, $after: String, $includeMergedPullRequests: Boolean!, $includeDiscussions: Boolean!, $includeDiscussionsAnswers: Boolean!, $startTime: DateTime = null, $ownerAffiliations: [RepositoryAffiliation]) {\\n user(login: $login) {\\n name\\n login\\n commits: contributionsCollection (from: $startTime) {\\n totalCommitContributions,\\n }\\n reviews: contributionsCollection {\\n totalPullRequestReviewContributions\\n }\\n repositoriesContributedTo(first: 1, contributionTypes: [COMMIT, ISSUE, PULL_REQUEST, REPOSITORY]) {\\n totalCount\\n }\\n pullRequests(first: 1) {\\n totalCount\\n }\\n mergedPullRequests: pullRequests(states: MERGED) @include(if: $includeMergedPullRequests) {\\n totalCount\\n }\\n openIssues: issues(states: OPEN) {\\n totalCount\\n }\\n closedIssues: issues(states: CLOSED) {\\n totalCount\\n }\\n followers {\\n totalCount\\n }\\n repositoryDiscussions @include(if: $includeDiscussions) {\\n totalCount\\n }\\n repositoryDiscussionComments(onlyAnswers: true) @include(if: $includeDiscussionsAnswers) {\\n totalCount\\n }\\n \\n repositories(first: 100, after: $after, ownerAffiliations: $ownerAffiliations, orderBy: {direction: DESC, field: STARGAZERS}) {\\n totalCount\\n nodes {\\n name\\n stargazers {\\n totalCount\\n }\\n }\\n pageInfo {\\n hasNextPage\\n endCursor\\n }\\n }\\n\\n }\\n }\\n","variables":{"login":"anuraghazra","first":100,"after":null,"includeMergedPullRequests":true,"includeDiscussions":true,"includeDiscussionsAnswers":true,"startTime":"2024-01-01T00:00:00Z","ownerAffiliations":["OWNER","COLLABORATOR"]}}",
"headers": [
[
"Cache-Control",
"max-age=36000, s-maxage=36000, stale-while-revalidate=86400",
],
[
"Content-Type",
"image/svg+xml",
],
],
}
`;
exports[`Test /api contract > should match the public missing-username response snapshot 1`] = `
{
"content": "<svg width="576.5" height="120" viewBox="0 0 576.5 120" fill="#fffefe" xmlns="http://www.w3.org/2000/svg">
<style>
.text { font: 600 16px 'Segoe UI', Ubuntu, Sans-Serif; fill: #2f80ed }
.small { font: 600 12px 'Segoe UI', Ubuntu, Sans-Serif; fill: #434d58 }
.gray { fill: #858585 }
</style>
<rect x="0.5" y="0.5" width="575.5" height="99%" rx="4.5" fill="#fffefe" stroke="#e4e2e2"/>
<text x="25" y="45" class="text">Something went wrong!</text>
<text data-testid="message" x="25" y="55" class="text small">
<tspan x="25" dy="18">Missing params "username" make sure you pass the parameters in URL</tspan>
<tspan x="25" dy="18" class="gray"/>
</text>
</svg>",
"headers": [
[
"Cache-Control",
"max-age=600, s-maxage=600, stale-while-revalidate=86400",
],
[
"Content-Type",
"image/svg+xml",
],
],
}
`;
exports[`Test /api contract > should render error card in same theme as requested card 1`] = `
{
"content": "<svg width="576.5" height="120" viewBox="0 0 576.5 120" fill="#0a0f0b" xmlns="http://www.w3.org/2000/svg">
<style>
.text { font: 600 16px 'Segoe UI', Ubuntu, Sans-Serif; fill: #abd200 }
.small { font: 600 12px 'Segoe UI', Ubuntu, Sans-Serif; fill: #68b587 }
.gray { fill: #858585 }
</style>
<rect x="0.5" y="0.5" width="575.5" height="99%" rx="4.5" fill="#0a0f0b" stroke="#e4e2e2"/>
<text x="25" y="45" class="text">Something went wrong!</text>
<text data-testid="message" x="25" y="55" class="text small">
<tspan x="25" dy="18">Missing params "username" make sure you pass the parameters in URL</tspan>
<tspan x="25" dy="18" class="gray"/>
</text>
</svg>",
"headers": [
[
"Cache-Control",
"max-age=600, s-maxage=600, stale-while-revalidate=86400",
],
[
"Content-Type",
"image/svg+xml",
],
],
}
`;
@@ -0,0 +1,252 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`Test /api/gist contract > should match the private missing-id response snapshot 1`] = `
{
"content": "<svg width="576.5" height="120" viewBox="0 0 576.5 120" fill="#fffefe" xmlns="http://www.w3.org/2000/svg">
<style>
.text { font: 600 16px 'Segoe UI', Ubuntu, Sans-Serif; fill: #2f80ed }
.small { font: 600 12px 'Segoe UI', Ubuntu, Sans-Serif; fill: #434d58 }
.gray { fill: #858585 }
</style>
<rect x="0.5" y="0.5" width="575.5" height="99%" rx="4.5" fill="#fffefe" stroke="#e4e2e2"/>
<text x="25" y="45" class="text">Something went wrong!</text>
<text data-testid="message" x="25" y="55" class="text small">
<tspan x="25" dy="18">This gist ID is not whitelisted</tspan>
<tspan x="25" dy="18" class="gray">Please deploy your own instance</tspan>
</text>
</svg>",
"headers": [
[
"Cache-Control",
"max-age=36000, s-maxage=36000, stale-while-revalidate=86400",
],
[
"Content-Type",
"image/svg+xml",
],
],
}
`;
exports[`Test /api/gist contract > should match the public happy-path response snapshot 1`] = `
{
"content": "<svg width="400" height="192" viewBox="0 0 400 192" fill="none" xmlns="http://www.w3.org/2000/svg" role="img" aria-labelledby="descId">
<title id="titleId"/>
<desc id="descId"/>
<style>
.header {
font: 600 18px 'Segoe UI', Ubuntu, Sans-Serif;
fill: #2f80ed;
animation: fadeInAnimation 0.8s ease-in-out forwards;
}
@supports(-moz-appearance: auto) {
/* Selector detects Firefox */
.header { font-size: 15.5px; }
}
.description { font: 400 13px 'Segoe UI', Ubuntu, Sans-Serif; fill: #434d58 }
.gray { font: 400 12px 'Segoe UI', Ubuntu, Sans-Serif; fill: #434d58 }
.icon { fill: #586069 }
/* 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;
}
}
</style>
<rect data-testid="card-bg" x="0.5" y="0.5" rx="4.5" height="99%" stroke="#e4e2e2" width="399" fill="#fffefe" stroke-opacity="1"/>
<g data-testid="card-title" transform="translate(25, 35)">
<g transform="translate(0, 0)">
<svg class="icon" x="0" y="-13" viewBox="0 0 16 16" version="1.1" width="16" height="16">
<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"/>
</svg>
</g><g transform="translate(25, 0)">
<text x="0" y="0" class="header" data-testid="header">countries.json</text>
</g>
</g>
<g data-testid="main-card-body" transform="translate(0, 55)">
<text class="description" x="25" y="-5">
<tspan dy="1.2em" x="25">List of countries and territories in English and Spanish:</tspan><tspan dy="1.2em" x="25">name, continent, capital, dial code, country codes, TLD,</tspan><tspan dy="1.2em" x="25">and area in sq km. Lista de países y territorios en</tspan><tspan dy="1.2em" x="25">Inglés y Español: nombre, continente, capital,</tspan><tspan dy="1.2em" x="25">código de teléfono, códigos de país,</tspan><tspan dy="1.2em" x="25">dominio y área en km cuadrados. Updated 2023</tspan>
</text>
<g transform="translate(30, 117)">
<g transform="translate(0, 0)">
<g data-testid="primary-lang">
<circle data-testid="lang-color" cx="0" cy="-5" r="6" fill="#292929"/>
<text data-testid="lang-name" class="gray" x="15">JSON</text>
</g>
</g><g transform="translate(56.96875, 0)"><g transform="translate(0, 0)">
<svg class="icon" y="-12" viewBox="0 0 16 16" version="1.1" width="16" height="16">
<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"/>
</svg>
</g><g transform="translate(20, 0)"><text data-testid="starsCount" class="gray">33</text></g></g><g transform="translate(111.28125, 0)"><g transform="translate(0, 0)">
<svg class="icon" y="-12" viewBox="0 0 16 16" version="1.1" width="16" height="16">
<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"/>
</svg>
</g><g transform="translate(20, 0)"><text data-testid="forksCount" class="gray">11</text></g></g>
</g>
</g>
</svg>",
"headers": [
[
"Cache-Control",
"max-age=36000, s-maxage=36000, stale-while-revalidate=86400",
],
[
"Content-Type",
"image/svg+xml",
],
],
}
`;
exports[`Test /api/gist contract > should match the public many-params response snapshot 1`] = `
{
"content": "<svg width="400" height="192" viewBox="0 0 400 192" fill="none" xmlns="http://www.w3.org/2000/svg" role="img" aria-labelledby="descId">
<title id="titleId"/>
<desc id="descId"/>
<style>
.header {
font: 600 18px 'Segoe UI', Ubuntu, Sans-Serif;
fill: #123456;
animation: fadeInAnimation 0.8s ease-in-out forwards;
}
@supports(-moz-appearance: auto) {
/* Selector detects Firefox */
.header { font-size: 15.5px; }
}
.description { font: 400 13px 'Segoe UI', Ubuntu, Sans-Serif; fill: #abcdef }
.gray { font: 400 12px 'Segoe UI', Ubuntu, Sans-Serif; fill: #abcdef }
.icon { fill: #ff00aa }
/* 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;
}
}
</style>
<rect data-testid="card-bg" x="0.5" y="0.5" rx="12" height="99%" stroke="#fedcba" width="399" fill="#0f172a" stroke-opacity="1"/>
<g data-testid="card-title" transform="translate(25, 35)">
<g transform="translate(0, 0)">
<svg class="icon" x="0" y="-13" viewBox="0 0 16 16" version="1.1" width="16" height="16">
<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"/>
</svg>
</g><g transform="translate(25, 0)">
<text x="0" y="0" class="header" data-testid="header">Yizack/countries.json</text>
</g>
</g>
<g data-testid="main-card-body" transform="translate(0, 55)">
<text class="description" x="25" y="-5">
<tspan dy="1.2em" x="25">List of countries and territories in English and Spanish:</tspan><tspan dy="1.2em" x="25">name, continent, capital, dial code, country codes, TLD,</tspan><tspan dy="1.2em" x="25">and area in sq km. Lista de países y territorios en</tspan><tspan dy="1.2em" x="25">Inglés y Español: nombre, continente, capital,</tspan><tspan dy="1.2em" x="25">código de teléfono, códigos de país,</tspan><tspan dy="1.2em" x="25">dominio y área en km cuadrados. Updated 2023</tspan>
</text>
<g transform="translate(30, 117)">
<g transform="translate(0, 0)">
<g data-testid="primary-lang">
<circle data-testid="lang-color" cx="0" cy="-5" r="6" fill="#292929"/>
<text data-testid="lang-name" class="gray" x="15">JSON</text>
</g>
</g><g transform="translate(56.96875, 0)"><g transform="translate(0, 0)">
<svg class="icon" y="-12" viewBox="0 0 16 16" version="1.1" width="16" height="16">
<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"/>
</svg>
</g><g transform="translate(20, 0)"><text data-testid="starsCount" class="gray">33</text></g></g><g transform="translate(111.28125, 0)"><g transform="translate(0, 0)">
<svg class="icon" y="-12" viewBox="0 0 16 16" version="1.1" width="16" height="16">
<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"/>
</svg>
</g><g transform="translate(20, 0)"><text data-testid="forksCount" class="gray">11</text></g></g>
</g>
</g>
</svg>",
"graphqlRequest": "{"query":"\\nquery gistInfo($gistName: String!) {\\n viewer {\\n gist(name: $gistName) {\\n description\\n owner {\\n login\\n }\\n stargazerCount\\n forks {\\n totalCount\\n }\\n files {\\n name\\n language {\\n name\\n }\\n size\\n }\\n }\\n }\\n}\\n","variables":{"gistName":"happy-gist-id"}}",
"headers": [
[
"Cache-Control",
"max-age=36000, s-maxage=36000, stale-while-revalidate=86400",
],
[
"Content-Type",
"image/svg+xml",
],
],
}
`;
exports[`Test /api/gist contract > should match the public missing-id response snapshot 1`] = `
{
"content": "<svg width="576.5" height="120" viewBox="0 0 576.5 120" fill="#fffefe" xmlns="http://www.w3.org/2000/svg">
<style>
.text { font: 600 16px 'Segoe UI', Ubuntu, Sans-Serif; fill: #2f80ed }
.small { font: 600 12px 'Segoe UI', Ubuntu, Sans-Serif; fill: #434d58 }
.gray { fill: #858585 }
</style>
<rect x="0.5" y="0.5" width="575.5" height="99%" rx="4.5" fill="#fffefe" stroke="#e4e2e2"/>
<text x="25" y="45" class="text">Something went wrong!</text>
<text data-testid="message" x="25" y="55" class="text small">
<tspan x="25" dy="18">Missing params "id" make sure you pass the parameters in URL</tspan>
<tspan x="25" dy="18" class="gray">/api/gist?id=GIST_ID</tspan>
</text>
</svg>",
"headers": [
[
"Cache-Control",
"max-age=600, s-maxage=600, stale-while-revalidate=86400",
],
[
"Content-Type",
"image/svg+xml",
],
],
}
`;
@@ -0,0 +1,406 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`Test /api/pin contract > should match the private missing-username response snapshot 1`] = `
{
"content": "<svg width="576.5" height="120" viewBox="0 0 576.5 120" fill="#fffefe" xmlns="http://www.w3.org/2000/svg">
<style>
.text { font: 600 16px 'Segoe UI', Ubuntu, Sans-Serif; fill: #2f80ed }
.small { font: 600 12px 'Segoe UI', Ubuntu, Sans-Serif; fill: #434d58 }
.gray { fill: #858585 }
</style>
<rect x="0.5" y="0.5" width="575.5" height="99%" rx="4.5" fill="#fffefe" stroke="#e4e2e2"/>
<text x="25" y="45" class="text">Something went wrong!</text>
<text data-testid="message" x="25" y="55" class="text small">
<tspan x="25" dy="18">This username is not whitelisted</tspan>
<tspan x="25" dy="18" class="gray">Please deploy your own instance</tspan>
</text>
</svg>",
"headers": [
[
"Cache-Control",
"max-age=36000, s-maxage=36000, stale-while-revalidate=86400",
],
[
"Content-Type",
"image/svg+xml",
],
],
}
`;
exports[`Test /api/pin contract > should match the private non-whitelisted username response snapshot 1`] = `
{
"content": "<svg width="576.5" height="120" viewBox="0 0 576.5 120" fill="#fffefe" xmlns="http://www.w3.org/2000/svg">
<style>
.text { font: 600 16px 'Segoe UI', Ubuntu, Sans-Serif; fill: #2f80ed }
.small { font: 600 12px 'Segoe UI', Ubuntu, Sans-Serif; fill: #434d58 }
.gray { fill: #858585 }
</style>
<rect x="0.5" y="0.5" width="575.5" height="99%" rx="4.5" fill="#fffefe" stroke="#e4e2e2"/>
<text x="25" y="45" class="text">Something went wrong!</text>
<text data-testid="message" x="25" y="55" class="text small">
<tspan x="25" dy="18">This username is not whitelisted</tspan>
<tspan x="25" dy="18" class="gray">Please deploy your own instance</tspan>
</text>
</svg>",
"headers": [
[
"Cache-Control",
"max-age=36000, s-maxage=36000, stale-while-revalidate=86400",
],
[
"Content-Type",
"image/svg+xml",
],
],
}
`;
exports[`Test /api/pin contract > should match the public blacklisted-username response snapshot 1`] = `
{
"content": "<svg width="576.5" height="120" viewBox="0 0 576.5 120" fill="#fffefe" xmlns="http://www.w3.org/2000/svg">
<style>
.text { font: 600 16px 'Segoe UI', Ubuntu, Sans-Serif; fill: #2f80ed }
.small { font: 600 12px 'Segoe UI', Ubuntu, Sans-Serif; fill: #434d58 }
.gray { fill: #858585 }
</style>
<rect x="0.5" y="0.5" width="575.5" height="99%" rx="4.5" fill="#fffefe" stroke="#e4e2e2"/>
<text x="25" y="45" class="text">Something went wrong!</text>
<text data-testid="message" x="25" y="55" class="text small">
<tspan x="25" dy="18">This username is blacklisted</tspan>
<tspan x="25" dy="18" class="gray">Please deploy your own instance</tspan>
</text>
</svg>",
"headers": [
[
"Cache-Control",
"max-age=36000, s-maxage=36000, stale-while-revalidate=86400",
],
[
"Content-Type",
"image/svg+xml",
],
],
}
`;
exports[`Test /api/pin contract > should match the public happy-path response snapshot 1`] = `
{
"content": "<svg width="400" height="150" viewBox="0 0 400 150" fill="none" xmlns="http://www.w3.org/2000/svg" role="img" aria-labelledby="descId">
<title id="titleId"/>
<desc id="descId"/>
<style>
.header {
font: 600 18px 'Segoe UI', Ubuntu, Sans-Serif;
fill: #2f80ed;
animation: fadeInAnimation 0.8s ease-in-out forwards;
}
@supports(-moz-appearance: auto) {
/* Selector detects Firefox */
.header { font-size: 15.5px; }
}
.description { font: 400 13px 'Segoe UI', Ubuntu, Sans-Serif; fill: #434d58 }
.gray { font: 400 12px 'Segoe UI', Ubuntu, Sans-Serif; fill: #434d58 }
.badge { font: 600 11px 'Segoe UI', Ubuntu, Sans-Serif; }
.badge rect { opacity: 0.2 }
.stat { font: 400 12px 'Segoe UI', Ubuntu, Sans-Serif; fill: #434d58 }
.stagger {
opacity: 0;
animation: fadeInAnimation 0.3s ease-in-out forwards;
}
.not_bold { font-weight: 400 }
.bold { font-weight: 700 }
.icon {
fill: #586069;
display: block;
}
/* 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;
}
}
* { animation-duration: 0s !important; animation-delay: 0s !important; }
</style>
<rect data-testid="card-bg" x="0.5" y="0.5" rx="4.5" height="99%" stroke="#e4e2e2" width="399" fill="#fffefe" stroke-opacity="1"/>
<g data-testid="card-title" transform="translate(25, 35)">
<g transform="translate(0, 0)">
<svg class="icon" x="0" y="-13" viewBox="0 0 16 16" version="1.1" width="16" height="16">
<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"/>
</svg>
</g><g transform="translate(25, 0)">
<text x="0" y="0" class="header" data-testid="header">convoychat</text>
</g>
</g>
<g data-testid="main-card-body" transform="translate(0, 55)">
<text class="description" x="25" y="-5">
<tspan dy="1.2em" x="25">Help us take over the world with a deeply customizable</tspan><tspan dy="1.2em" x="25">React, TypeScript and GraphQL chat app that has enough text</tspan><tspan dy="1.2em" x="25">to wrap across multiple lines in the repository card.</tspan>
</text>
<g transform="translate(30, 75)">
<g transform="translate(0, 0)">
<g data-testid="primary-lang">
<circle data-testid="lang-color" cx="0" cy="-5" r="6" fill="#2b7489"/>
<text data-testid="lang-name" class="gray" x="15">TypeScript</text>
</g>
</g><g transform="translate(82.24375, 0)"><g transform="translate(0, 0)">
<svg class="icon" y="-12" viewBox="0 0 16 16" version="1.1" width="16" height="16">
<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"/>
</svg>
</g><g transform="translate(20, 0)"><text data-testid="stargazers" class="gray">38k</text></g></g><g transform="translate(142.55625, 0)"><g transform="translate(0, 0)">
<svg class="icon" y="-12" viewBox="0 0 16 16" version="1.1" width="16" height="16">
<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"/>
</svg>
</g><g transform="translate(20, 0)"><text data-testid="forkcount" class="gray">100</text></g></g>
</g>
<svg x="0" y="0"><g transform="translate(-3, 98)">
</g></svg>
</g>
</svg>",
"headers": [
[
"Cache-Control",
"max-age=36000, s-maxage=36000, stale-while-revalidate=86400",
],
[
"Content-Type",
"image/svg+xml",
],
],
}
`;
exports[`Test /api/pin contract > should match the public many-params response snapshot 1`] = `
{
"content": "<svg width="560" height="233" viewBox="0 0 560 233" fill="none" xmlns="http://www.w3.org/2000/svg" role="img" aria-labelledby="descId">
<title id="titleId"/>
<desc id="descId"/>
<style>
.header {
font: 600 18px 'Segoe UI', Ubuntu, Sans-Serif;
fill: #123456;
animation: fadeInAnimation 0.8s ease-in-out forwards;
}
@supports(-moz-appearance: auto) {
/* Selector detects Firefox */
.header { font-size: 15.5px; }
}
.description { font: 400 13px 'Segoe UI', Ubuntu, Sans-Serif; fill: #abcdef }
.gray { font: 400 12px 'Segoe UI', Ubuntu, Sans-Serif; fill: #abcdef }
.badge { font: 600 11px 'Segoe UI', Ubuntu, Sans-Serif; }
.badge rect { opacity: 0.2 }
.stat { font: 400 12px 'Segoe UI', Ubuntu, Sans-Serif; fill: #abcdef }
.stagger {
opacity: 0;
animation: fadeInAnimation 0.3s ease-in-out forwards;
}
.not_bold { font-weight: 400 }
.bold { font-weight: 700 }
.icon {
fill: #ff00aa;
display: block;
}
/* 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;
}
}
* { animation-duration: 0s !important; animation-delay: 0s !important; }
</style>
<rect data-testid="card-bg" x="0.5" y="0.5" rx="12" height="99%" stroke="#fedcba" width="559" fill="#0f172a" stroke-opacity="1"/>
<g data-testid="card-title" transform="translate(25, 35)">
<g transform="translate(0, 0)">
<svg class="icon" x="0" y="-13" viewBox="0 0 16 16" version="1.1" width="16" height="16">
<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"/>
</svg>
</g><g transform="translate(25, 0)">
<text x="0" y="0" class="header" data-testid="header">anuraghazra/convoychat</text>
</g>
</g>
<g data-testid="main-card-body" transform="translate(0, 55)">
<text class="description" x="25" y="-5">
<tspan dy="1.2em" x="25">Help us take over the world with a deeply customizable React, TypeScript and GraphQL...</tspan>
</text>
<g transform="translate(30, 45)">
<g transform="translate(0, 0)">
<g data-testid="primary-lang">
<circle data-testid="lang-color" cx="0" cy="-5" r="6" fill="#2b7489"/>
<text data-testid="lang-name" class="gray" x="15">TypeScript</text>
</g>
</g><g transform="translate(82.24375, 0)"><g transform="translate(0, 0)">
<svg class="icon" y="-12" viewBox="0 0 16 16" version="1.1" width="16" height="16">
<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"/>
</svg>
</g><g transform="translate(20, 0)"><text data-testid="stargazers" class="gray">38k</text></g></g><g transform="translate(142.55625, 0)"><g transform="translate(0, 0)">
<svg class="icon" y="-12" viewBox="0 0 16 16" version="1.1" width="16" height="16">
<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"/>
</svg>
</g><g transform="translate(20, 0)"><text data-testid="forkcount" class="gray">100</text></g></g>
</g>
<svg x="0" y="0"><g transform="translate(-3, 68)">
<g transform="translate(0, 0)"><g transform="translate(0, 0)">
<g class="stagger" style="animation-delay: 450ms" transform="translate(25, 0)"><a href="https://github.com/search?q=repo%3Aanuraghazra%2Fconvoychat%20author%3Aanuraghazra&amp;type=pullrequests">
<text class="stat bold" y="12.5">my created PRs:</text>
<text class="stat bold" x="139.01" y="12.5" data-testid="prs_authored">1234</text></a>
</g>
</g><g transform="translate(210, 0)">
<g class="stagger" style="animation-delay: 600ms" transform="translate(25, 0)"><a href="https://github.com/search?q=repo%3Aanuraghazra%2Fconvoychat%20commenter%3Aanuraghazra+-author%3Aanuraghazra&amp;type=pullrequests">
<text class="stat bold" y="12.5">my commented PRs:</text>
<text class="stat bold" x="139.01" y="12.5" data-testid="prs_commented">2345</text></a>
</g>
</g></g><g transform="translate(0, 30)"><g transform="translate(0, 0)">
<g class="stagger" style="animation-delay: 750ms" transform="translate(25, 0)"><a href="https://github.com/search?q=repo%3Aanuraghazra%2Fconvoychat%20reviewed-by%3Aanuraghazra+-author%3Aanuraghazra&amp;type=pullrequests">
<text class="stat bold" y="12.5">my reviewed PRs:</text>
<text class="stat bold" x="139.01" y="12.5" data-testid="prs_reviewed">3456</text></a>
</g>
</g><g transform="translate(210, 0)">
<g class="stagger" style="animation-delay: 900ms" transform="translate(25, 0)"><a href="https://github.com/search?q=repo%3Aanuraghazra%2Fconvoychat%20author%3Aanuraghazra&amp;type=issues">
<text class="stat bold" y="12.5">my created issues:</text>
<text class="stat bold" x="139.01" y="12.5" data-testid="issues_authored">4567</text></a>
</g>
</g></g><g transform="translate(0, 60)"><g transform="translate(0, 0)">
<g class="stagger" style="animation-delay: 1050ms" transform="translate(25, 0)"><a href="https://github.com/search?q=repo%3Aanuraghazra%2Fconvoychat%20commenter%3Aanuraghazra+-author%3Aanuraghazra&amp;type=issues">
<text class="stat bold" y="12.5">my commented issues:</text>
<text class="stat bold" x="139.01" y="12.5" data-testid="issues_commented">5678</text></a>
</g>
</g></g>
</g></svg>
</g>
</svg>",
"graphqlRequest": "{"query":"\\n fragment RepoInfo on Repository {\\n name\\n nameWithOwner\\n isPrivate\\n isArchived\\n isTemplate\\n stargazers {\\n totalCount\\n }\\n description\\n primaryLanguage {\\n color\\n id\\n name\\n }\\n forkCount\\n }\\n query getRepo($login: String!, $repo: String!) {\\n user(login: $login) {\\n repository(name: $repo) {\\n ...RepoInfo\\n }\\n }\\n organization(login: $login) {\\n repository(name: $repo) {\\n ...RepoInfo\\n }\\n }\\n }\\n ","variables":{"login":"anuraghazra","repo":"convoychat"}}",
"headers": [
[
"Cache-Control",
"max-age=36000, s-maxage=36000, stale-while-revalidate=86400",
],
[
"Content-Type",
"image/svg+xml",
],
],
}
`;
exports[`Test /api/pin contract > should match the public missing-params response snapshot 1`] = `
{
"content": "<svg width="576.5" height="120" viewBox="0 0 576.5 120" fill="#fffefe" xmlns="http://www.w3.org/2000/svg">
<style>
.text { font: 600 16px 'Segoe UI', Ubuntu, Sans-Serif; fill: #2f80ed }
.small { font: 600 12px 'Segoe UI', Ubuntu, Sans-Serif; fill: #434d58 }
.gray { fill: #858585 }
</style>
<rect x="0.5" y="0.5" width="575.5" height="99%" rx="4.5" fill="#fffefe" stroke="#e4e2e2"/>
<text x="25" y="45" class="text">Something went wrong!</text>
<text data-testid="message" x="25" y="55" class="text small">
<tspan x="25" dy="18">Missing params "username", "repo" make sure you pass the parameters in URL</tspan>
<tspan x="25" dy="18" class="gray">/api/pin?username=USERNAME&amp;repo=REPO_NAME</tspan>
</text>
</svg>",
"headers": [
[
"Cache-Control",
"max-age=600, s-maxage=600, stale-while-revalidate=86400",
],
[
"Content-Type",
"image/svg+xml",
],
],
}
`;
exports[`Test /api/pin contract > should render error card in same theme as requested card 1`] = `
{
"content": "<svg width="576.5" height="120" viewBox="0 0 576.5 120" fill="#0a0f0b" xmlns="http://www.w3.org/2000/svg">
<style>
.text { font: 600 16px 'Segoe UI', Ubuntu, Sans-Serif; fill: #abd200 }
.small { font: 600 12px 'Segoe UI', Ubuntu, Sans-Serif; fill: #68b587 }
.gray { fill: #858585 }
</style>
<rect x="0.5" y="0.5" width="575.5" height="99%" rx="4.5" fill="#0a0f0b" stroke="#e4e2e2"/>
<text x="25" y="45" class="text">Something went wrong!</text>
<text data-testid="message" x="25" y="55" class="text small">
<tspan x="25" dy="18">Missing params "username", "repo" make sure you pass the parameters in URL</tspan>
<tspan x="25" dy="18" class="gray">/api/pin?username=USERNAME&amp;repo=REPO_NAME</tspan>
</text>
</svg>",
"headers": [
[
"Cache-Control",
"max-age=600, s-maxage=600, stale-while-revalidate=86400",
],
[
"Content-Type",
"image/svg+xml",
],
],
}
`;
@@ -0,0 +1,460 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`Test /api/top-langs contract > should match the private missing-username response snapshot 1`] = `
{
"content": "<svg width="576.5" height="120" viewBox="0 0 576.5 120" fill="#fffefe" xmlns="http://www.w3.org/2000/svg">
<style>
.text { font: 600 16px 'Segoe UI', Ubuntu, Sans-Serif; fill: #2f80ed }
.small { font: 600 12px 'Segoe UI', Ubuntu, Sans-Serif; fill: #434d58 }
.gray { fill: #858585 }
</style>
<rect x="0.5" y="0.5" width="575.5" height="99%" rx="4.5" fill="#fffefe" stroke="#e4e2e2"/>
<text x="25" y="45" class="text">Something went wrong!</text>
<text data-testid="message" x="25" y="55" class="text small">
<tspan x="25" dy="18">This username is not whitelisted</tspan>
<tspan x="25" dy="18" class="gray">Please deploy your own instance</tspan>
</text>
</svg>",
"headers": [
[
"Cache-Control",
"max-age=36000, s-maxage=36000, stale-while-revalidate=86400",
],
[
"Content-Type",
"image/svg+xml",
],
],
}
`;
exports[`Test /api/top-langs contract > should match the private non-whitelisted username response snapshot 1`] = `
{
"content": "<svg width="576.5" height="120" viewBox="0 0 576.5 120" fill="#fffefe" xmlns="http://www.w3.org/2000/svg">
<style>
.text { font: 600 16px 'Segoe UI', Ubuntu, Sans-Serif; fill: #2f80ed }
.small { font: 600 12px 'Segoe UI', Ubuntu, Sans-Serif; fill: #434d58 }
.gray { fill: #858585 }
</style>
<rect x="0.5" y="0.5" width="575.5" height="99%" rx="4.5" fill="#fffefe" stroke="#e4e2e2"/>
<text x="25" y="45" class="text">Something went wrong!</text>
<text data-testid="message" x="25" y="55" class="text small">
<tspan x="25" dy="18">This username is not whitelisted</tspan>
<tspan x="25" dy="18" class="gray">Please deploy your own instance</tspan>
</text>
</svg>",
"headers": [
[
"Cache-Control",
"max-age=36000, s-maxage=36000, stale-while-revalidate=86400",
],
[
"Content-Type",
"image/svg+xml",
],
],
}
`;
exports[`Test /api/top-langs contract > should match the public blacklisted-username response snapshot 1`] = `
{
"content": "<svg width="576.5" height="120" viewBox="0 0 576.5 120" fill="#fffefe" xmlns="http://www.w3.org/2000/svg">
<style>
.text { font: 600 16px 'Segoe UI', Ubuntu, Sans-Serif; fill: #2f80ed }
.small { font: 600 12px 'Segoe UI', Ubuntu, Sans-Serif; fill: #434d58 }
.gray { fill: #858585 }
</style>
<rect x="0.5" y="0.5" width="575.5" height="99%" rx="4.5" fill="#fffefe" stroke="#e4e2e2"/>
<text x="25" y="45" class="text">Something went wrong!</text>
<text data-testid="message" x="25" y="55" class="text small">
<tspan x="25" dy="18">This username is blacklisted</tspan>
<tspan x="25" dy="18" class="gray">Please deploy your own instance</tspan>
</text>
</svg>",
"headers": [
[
"Cache-Control",
"max-age=36000, s-maxage=36000, stale-while-revalidate=86400",
],
[
"Content-Type",
"image/svg+xml",
],
],
}
`;
exports[`Test /api/top-langs contract > should match the public happy-path response snapshot 1`] = `
{
"content": "<svg width="300" height="285" viewBox="0 0 300 285" fill="none" xmlns="http://www.w3.org/2000/svg" role="img" aria-labelledby="descId">
<title id="titleId"/>
<desc id="descId"/>
<style>
.header {
font: 600 18px 'Segoe UI', Ubuntu, Sans-Serif;
fill: #2f80ed;
animation: fadeInAnimation 0.8s ease-in-out forwards;
}
@supports(-moz-appearance: auto) {
/* Selector detects Firefox */
.header { font-size: 15.5px; }
}
@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: #434d58;
}
@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: #434d58;
}
.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;
}
/* 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;
}
}
</style>
<rect data-testid="card-bg" x="0.5" y="0.5" rx="4.5" height="99%" stroke="#e4e2e2" width="299" fill="#fffefe" stroke-opacity="1"/>
<g data-testid="card-title" transform="translate(25, 35)">
<g transform="translate(0, 0)">
<text x="0" y="0" class="header" data-testid="header">Most Used Languages</text>
</g>
</g>
<g data-testid="main-card-body" transform="translate(0, 55)">
<svg data-testid="lang-items" x="25">
<g transform="translate(0, 0)">
<g class="stagger" style="animation-delay: 450ms">
<text data-testid="lang-name" x="2" y="15" class="lang-name">Rust</text>
<text x="215" y="34" class="lang-name">70.92%</text>
<svg width="205" x="0" y="25">
<rect data-testid="progress-background" rx="5" ry="5" x="0" y="0" width="205" height="8" fill="#ddd"/>
<svg data-testid="lang-progress" width="70.92198581560284%">
<rect height="8" fill="#dea584" rx="5" ry="5" x="0" y="0" class="lang-progress" style="animation-delay: 750ms;"/>
</svg>
</svg>
</g>
</g><g transform="translate(0, 40)">
<g class="stagger" style="animation-delay: 600ms">
<text data-testid="lang-name" x="2" y="15" class="lang-name">HTML</text>
<text x="215" y="34" class="lang-name">12.77%</text>
<svg width="205" x="0" y="25">
<rect data-testid="progress-background" rx="5" ry="5" x="0" y="0" width="205" height="8" fill="#ddd"/>
<svg data-testid="lang-progress" width="12.76595744680851%">
<rect height="8" fill="#0f0" rx="5" ry="5" x="0" y="0" class="lang-progress" style="animation-delay: 900ms;"/>
</svg>
</svg>
</g>
</g><g transform="translate(0, 80)">
<g class="stagger" style="animation-delay: 750ms">
<text data-testid="lang-name" x="2" y="15" class="lang-name">JavaScript</text>
<text x="215" y="34" class="lang-name">8.51%</text>
<svg width="205" x="0" y="25">
<rect data-testid="progress-background" rx="5" ry="5" x="0" y="0" width="205" height="8" fill="#ddd"/>
<svg data-testid="lang-progress" width="8.51063829787234%">
<rect height="8" fill="#0ff" rx="5" ry="5" x="0" y="0" class="lang-progress" style="animation-delay: 1050ms;"/>
</svg>
</svg>
</g>
</g><g transform="translate(0, 120)">
<g class="stagger" style="animation-delay: 900ms">
<text data-testid="lang-name" x="2" y="15" class="lang-name">TypeScript</text>
<text x="215" y="34" class="lang-name">6.38%</text>
<svg width="205" x="0" y="25">
<rect data-testid="progress-background" rx="5" ry="5" x="0" y="0" width="205" height="8" fill="#ddd"/>
<svg data-testid="lang-progress" width="6.382978723404255%">
<rect height="8" fill="#3178c6" rx="5" ry="5" x="0" y="0" class="lang-progress" style="animation-delay: 1200ms;"/>
</svg>
</svg>
</g>
</g><g transform="translate(0, 160)">
<g class="stagger" style="animation-delay: 1050ms">
<text data-testid="lang-name" x="2" y="15" class="lang-name">Java</text>
<text x="215" y="34" class="lang-name">1.42%</text>
<svg width="205" x="0" y="25">
<rect data-testid="progress-background" rx="5" ry="5" x="0" y="0" width="205" height="8" fill="#ddd"/>
<svg data-testid="lang-progress" width="2%">
<rect height="8" fill="#f00" rx="5" ry="5" x="0" y="0" class="lang-progress" style="animation-delay: 1350ms;"/>
</svg>
</svg>
</g>
</g>
</svg>
</g>
</svg>",
"headers": [
[
"Cache-Control",
"max-age=36000, s-maxage=36000, stale-while-revalidate=86400",
],
[
"Content-Type",
"image/svg+xml",
],
],
}
`;
exports[`Test /api/top-langs contract > should match the public many-params response snapshot 1`] = `
{
"content": "<svg width="420" height="110" viewBox="0 0 420 110" fill="none" xmlns="http://www.w3.org/2000/svg" role="img" aria-labelledby="descId">
<title id="titleId"/>
<desc id="descId"/>
<style>
.header {
font: 600 18px 'Segoe UI', Ubuntu, Sans-Serif;
fill: #123456;
animation: fadeInAnimation 0.8s ease-in-out forwards;
}
@supports(-moz-appearance: auto) {
/* Selector detects Firefox */
.header { font-size: 15.5px; }
}
@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: #abcdef;
}
@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: #abcdef;
}
.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;
}
/* 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;
}
}
* { animation-duration: 0s !important; animation-delay: 0s !important; }
</style>
<rect data-testid="card-bg" x="0.5" y="0.5" rx="4.5" height="99%" stroke="#e4e2e2" width="419" fill="#0f172a" stroke-opacity="0"/>
<g data-testid="main-card-body" transform="translate(0, 25)">
<svg data-testid="lang-items" x="25">
<mask id="rect-mask">
<rect x="0" y="0" width="370" height="8" fill="white" rx="5"/>
</mask>
<rect mask="url(#rect-mask)" data-testid="lang-progress" x="0" y="0" width="256.98" height="8" fill="#3178c6"/>
<rect mask="url(#rect-mask)" data-testid="lang-progress" x="256.98" y="0" width="60.57" height="8" fill="#f00"/>
<rect mask="url(#rect-mask)" data-testid="lang-progress" x="317.55" y="0" width="52.45" height="8" fill="#00f"/>
<g transform="translate(0, 25)">
<g transform="translate(0, 0)"><g transform="translate(0, 0)">
<g class="stagger" style="animation-delay: 450ms">
<circle cx="5" cy="6" r="5" fill="#3178c6"/>
<text data-testid="lang-name" x="15" y="10" class="lang-name">
TypeScript 19.0 B
</text>
</g>
</g><g transform="translate(0, 25)">
<g class="stagger" style="animation-delay: 600ms">
<circle cx="5" cy="6" r="5" fill="#f00"/>
<text data-testid="lang-name" x="15" y="10" class="lang-name">
Java 4.5 B
</text>
</g>
</g></g><g transform="translate(150, 0)"><g transform="translate(0, 0)">
<g class="stagger" style="animation-delay: 450ms">
<circle cx="5" cy="6" r="5" fill="#00f"/>
<text data-testid="lang-name" x="15" y="10" class="lang-name">
Python 3.9 B
</text>
</g>
</g></g>
</g>
</svg>
</g>
</svg>",
"graphqlRequest": "{"query":"\\n query userInfo($login: String!, $ownerAffiliations: [RepositoryAffiliation]) {\\n user(login: $login) {\\n # do not fetch forks\\n repositories(ownerAffiliations: $ownerAffiliations, isFork: false, first: 100) {\\n nodes {\\n name\\n languages(first: 10, orderBy: {field: SIZE, direction: DESC}) {\\n edges {\\n size\\n node {\\n color\\n name\\n }\\n }\\n }\\n }\\n }\\n }\\n }\\n ","variables":{"login":"anuraghazra","ownerAffiliations":["OWNER","COLLABORATOR"]}}",
"headers": [
[
"Cache-Control",
"max-age=36000, s-maxage=36000, stale-while-revalidate=86400",
],
[
"Content-Type",
"image/svg+xml",
],
],
}
`;
exports[`Test /api/top-langs contract > should match the public missing-username response snapshot 1`] = `
{
"content": "<svg width="576.5" height="120" viewBox="0 0 576.5 120" fill="#fffefe" xmlns="http://www.w3.org/2000/svg">
<style>
.text { font: 600 16px 'Segoe UI', Ubuntu, Sans-Serif; fill: #2f80ed }
.small { font: 600 12px 'Segoe UI', Ubuntu, Sans-Serif; fill: #434d58 }
.gray { fill: #858585 }
</style>
<rect x="0.5" y="0.5" width="575.5" height="99%" rx="4.5" fill="#fffefe" stroke="#e4e2e2"/>
<text x="25" y="45" class="text">Something went wrong!</text>
<text data-testid="message" x="25" y="55" class="text small">
<tspan x="25" dy="18">Missing params "username" make sure you pass the parameters in URL</tspan>
<tspan x="25" dy="18" class="gray"/>
</text>
</svg>",
"headers": [
[
"Cache-Control",
"max-age=600, s-maxage=600, stale-while-revalidate=86400",
],
[
"Content-Type",
"image/svg+xml",
],
],
}
`;
exports[`Test /api/top-langs contract > should render error card in same theme as requested card 1`] = `
{
"content": "<svg width="576.5" height="120" viewBox="0 0 576.5 120" fill="#0a0f0b" xmlns="http://www.w3.org/2000/svg">
<style>
.text { font: 600 16px 'Segoe UI', Ubuntu, Sans-Serif; fill: #abd200 }
.small { font: 600 12px 'Segoe UI', Ubuntu, Sans-Serif; fill: #68b587 }
.gray { fill: #858585 }
</style>
<rect x="0.5" y="0.5" width="575.5" height="99%" rx="4.5" fill="#0a0f0b" stroke="#e4e2e2"/>
<text x="25" y="45" class="text">Something went wrong!</text>
<text data-testid="message" x="25" y="55" class="text small">
<tspan x="25" dy="18">Missing params "username" make sure you pass the parameters in URL</tspan>
<tspan x="25" dy="18" class="gray"/>
</text>
</svg>",
"headers": [
[
"Cache-Control",
"max-age=600, s-maxage=600, stale-while-revalidate=86400",
],
[
"Content-Type",
"image/svg+xml",
],
],
}
`;
@@ -0,0 +1,546 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`Test /api/wakatime contract > should match the private missing-username response snapshot 1`] = `
{
"content": "<svg width="576.5" height="120" viewBox="0 0 576.5 120" fill="#fffefe" xmlns="http://www.w3.org/2000/svg">
<style>
.text { font: 600 16px 'Segoe UI', Ubuntu, Sans-Serif; fill: #2f80ed }
.small { font: 600 12px 'Segoe UI', Ubuntu, Sans-Serif; fill: #434d58 }
.gray { fill: #858585 }
</style>
<rect x="0.5" y="0.5" width="575.5" height="99%" rx="4.5" fill="#fffefe" stroke="#e4e2e2"/>
<text x="25" y="45" class="text">Something went wrong!</text>
<text data-testid="message" x="25" y="55" class="text small">
<tspan x="25" dy="18">This username is not whitelisted</tspan>
<tspan x="25" dy="18" class="gray">Please deploy your own instance</tspan>
</text>
</svg>",
"headers": [
[
"Cache-Control",
"max-age=36000, s-maxage=36000, stale-while-revalidate=86400",
],
[
"Content-Type",
"image/svg+xml",
],
],
}
`;
exports[`Test /api/wakatime contract > should match the private non-whitelisted username response snapshot 1`] = `
{
"content": "<svg width="576.5" height="120" viewBox="0 0 576.5 120" fill="#fffefe" xmlns="http://www.w3.org/2000/svg">
<style>
.text { font: 600 16px 'Segoe UI', Ubuntu, Sans-Serif; fill: #2f80ed }
.small { font: 600 12px 'Segoe UI', Ubuntu, Sans-Serif; fill: #434d58 }
.gray { fill: #858585 }
</style>
<rect x="0.5" y="0.5" width="575.5" height="99%" rx="4.5" fill="#fffefe" stroke="#e4e2e2"/>
<text x="25" y="45" class="text">Something went wrong!</text>
<text data-testid="message" x="25" y="55" class="text small">
<tspan x="25" dy="18">This username is not whitelisted</tspan>
<tspan x="25" dy="18" class="gray">Please deploy your own instance</tspan>
</text>
</svg>",
"headers": [
[
"Cache-Control",
"max-age=36000, s-maxage=36000, stale-while-revalidate=86400",
],
[
"Content-Type",
"image/svg+xml",
],
],
}
`;
exports[`Test /api/wakatime contract > should match the public happy-path response snapshot 1`] = `
{
"content": "<svg width="495" height="195" viewBox="0 0 495 195" fill="none" xmlns="http://www.w3.org/2000/svg" role="img" aria-labelledby="descId">
<title id="titleId"/>
<desc id="descId"/>
<style>
.header {
font: 600 18px 'Segoe UI', Ubuntu, Sans-Serif;
fill: #2f80ed;
animation: fadeInAnimation 0.8s ease-in-out forwards;
}
@supports(-moz-appearance: auto) {
/* Selector detects Firefox */
.header { font-size: 15.5px; }
}
.stat {
font: 600 14px 'Segoe UI', Ubuntu, "Helvetica Neue", Sans-Serif; fill: #434d58;
}
@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 }
@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: #434d58 }
#rect-mask rect{
animation: slideInAnimation 1s ease-in-out forwards;
}
.lang-progress{
animation: growWidthAnimation 0.6s ease-in-out forwards;
}
/* 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;
}
}
</style>
<rect data-testid="card-bg" x="0.5" y="0.5" rx="4.5" height="99%" stroke="#e4e2e2" width="494" fill="#fffefe" stroke-opacity="1"/>
<g data-testid="card-title" transform="translate(25, 35)">
<g transform="translate(0, 0)">
<text x="0" y="0" class="header" data-testid="header">WakaTime Stats (last 7 days)</text>
</g>
</g>
<g data-testid="main-card-body" transform="translate(0, 55)">
<svg x="0" y="0" width="100%">
<g transform="translate(0, 0)">
<g class="stagger" style="animation-delay: 450ms" transform="translate(25, 0)">
<text class="stat bold" y="12.5" data-testid="TypeScript">TypeScript:</text>
<text class="stat" x="350" y="12.5">12 hrs</text>
<svg width="220" x="110" y="4">
<rect data-testid="progress-background" rx="5" ry="5" x="0" y="0" width="220" height="8" fill="#434d58"/>
<svg data-testid="lang-progress" width="52%">
<rect height="8" fill="#2f80ed" rx="5" ry="5" x="0" y="0" class="lang-progress" style="animation-delay: 750ms;"/>
</svg>
</svg>
</g>
</g><g transform="translate(0, 25)">
<g class="stagger" style="animation-delay: 600ms" transform="translate(25, 0)">
<text class="stat bold" y="12.5" data-testid="JavaScript">JavaScript:</text>
<text class="stat" x="350" y="12.5">6 hrs 30 mins</text>
<svg width="220" x="110" y="4">
<rect data-testid="progress-background" rx="5" ry="5" x="0" y="0" width="220" height="8" fill="#434d58"/>
<svg data-testid="lang-progress" width="28%">
<rect height="8" fill="#2f80ed" rx="5" ry="5" x="0" y="0" class="lang-progress" style="animation-delay: 900ms;"/>
</svg>
</svg>
</g>
</g><g transform="translate(0, 50)">
<g class="stagger" style="animation-delay: 750ms" transform="translate(25, 0)">
<text class="stat bold" y="12.5" data-testid="Other">Other:</text>
<text class="stat" x="350" y="12.5">3 hrs</text>
<svg width="220" x="110" y="4">
<rect data-testid="progress-background" rx="5" ry="5" x="0" y="0" width="220" height="8" fill="#434d58"/>
<svg data-testid="lang-progress" width="13%">
<rect height="8" fill="#2f80ed" rx="5" ry="5" x="0" y="0" class="lang-progress" style="animation-delay: 1050ms;"/>
</svg>
</svg>
</g>
</g><g transform="translate(0, 75)">
<g class="stagger" style="animation-delay: 900ms" transform="translate(25, 0)">
<text class="stat bold" y="12.5" data-testid="YAML">YAML:</text>
<text class="stat" x="350" y="12.5">1 hr</text>
<svg width="220" x="110" y="4">
<rect data-testid="progress-background" rx="5" ry="5" x="0" y="0" width="220" height="8" fill="#434d58"/>
<svg data-testid="lang-progress" width="4%">
<rect height="8" fill="#2f80ed" rx="5" ry="5" x="0" y="0" class="lang-progress" style="animation-delay: 1200ms;"/>
</svg>
</svg>
</g>
</g><g transform="translate(0, 100)">
<g class="stagger" style="animation-delay: 1050ms" transform="translate(25, 0)">
<text class="stat bold" y="12.5" data-testid="JSON">JSON:</text>
<text class="stat" x="350" y="12.5">30 mins</text>
<svg width="220" x="110" y="4">
<rect data-testid="progress-background" rx="5" ry="5" x="0" y="0" width="220" height="8" fill="#434d58"/>
<svg data-testid="lang-progress" width="3%">
<rect height="8" fill="#2f80ed" rx="5" ry="5" x="0" y="0" class="lang-progress" style="animation-delay: 1350ms;"/>
</svg>
</svg>
</g>
</g>
</svg>
</g>
</svg>",
"headers": [
[
"Cache-Control",
"max-age=36000, s-maxage=36000, stale-while-revalidate=86400",
],
[
"Content-Type",
"image/svg+xml",
],
],
}
`;
exports[`Test /api/wakatime contract > should match the public inaccessible-profile response snapshot 1`] = `
{
"content": "<svg width="495" height="150" viewBox="0 0 495 150" fill="none" xmlns="http://www.w3.org/2000/svg" role="img" aria-labelledby="descId">
<title id="titleId"/>
<desc id="descId"/>
<style>
.header {
font: 600 18px 'Segoe UI', Ubuntu, Sans-Serif;
fill: #2f80ed;
animation: fadeInAnimation 0.8s ease-in-out forwards;
}
@supports(-moz-appearance: auto) {
/* Selector detects Firefox */
.header { font-size: 15.5px; }
}
.stat {
font: 600 14px 'Segoe UI', Ubuntu, "Helvetica Neue", Sans-Serif; fill: #434d58;
}
@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 }
@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: #434d58 }
#rect-mask rect{
animation: slideInAnimation 1s ease-in-out forwards;
}
.lang-progress{
animation: growWidthAnimation 0.6s ease-in-out forwards;
}
/* 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;
}
}
</style>
<rect data-testid="card-bg" x="0.5" y="0.5" rx="4.5" height="99%" stroke="#e4e2e2" width="494" fill="#fffefe" stroke-opacity="1"/>
<g data-testid="card-title" transform="translate(25, 35)">
<g transform="translate(0, 0)">
<text x="0" y="0" class="header" data-testid="header">WakaTime Stats</text>
</g>
</g>
<g data-testid="main-card-body" transform="translate(0, 55)">
<svg x="0" y="0" width="100%">
<g transform="translate(0, 0)">
<text x="25" y="11" class="stat bold" fill="#434d58">WakaTime user profile not public</text>
</g>
</svg>
</g>
</svg>",
"headers": [
[
"Cache-Control",
"max-age=36000, s-maxage=36000, stale-while-revalidate=86400",
],
[
"Content-Type",
"image/svg+xml",
],
],
}
`;
exports[`Test /api/wakatime contract > should match the public many-params response snapshot 1`] = `
{
"content": "<svg width="620" height="140" viewBox="0 0 620 140" fill="none" xmlns="http://www.w3.org/2000/svg" role="img" aria-labelledby="descId">
<title id="titleId"/>
<desc id="descId"/>
<style>
.header {
font: 600 18px 'Segoe UI', Ubuntu, Sans-Serif;
fill: #123456;
animation: fadeInAnimation 0.8s ease-in-out forwards;
}
@supports(-moz-appearance: auto) {
/* Selector detects Firefox */
.header { font-size: 15.5px; }
}
.stat {
font: 600 14px 'Segoe UI', Ubuntu, "Helvetica Neue", Sans-Serif; fill: #abcdef;
}
@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 }
@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: #abcdef }
#rect-mask rect{
animation: slideInAnimation 1s ease-in-out forwards;
}
.lang-progress{
animation: growWidthAnimation 0.6s ease-in-out forwards;
}
/* 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;
}
}
* { animation-duration: 0s !important; animation-delay: 0s !important; }
</style>
<rect data-testid="card-bg" x="0.5" y="0.5" rx="12" height="99%" stroke="#fedcba" width="619" fill="#0f172a" stroke-opacity="1"/>
<g data-testid="card-title" transform="translate(25, 35)">
<g transform="translate(0, 0)">
<text x="0" y="0" class="header" data-testid="header">a custom title</text>
</g>
</g>
<g data-testid="main-card-body" transform="translate(0, 55)">
<svg x="0" y="0" width="100%">
<mask id="rect-mask">
<rect x="25" y="0" width="565" height="8" fill="white" rx="5"/>
</mask>
<rect mask="url(#rect-mask)" data-testid="lang-progress" x="0" y="0" width="365.09200000000004" height="8" fill="#3178c6"/>
<rect mask="url(#rect-mask)" data-testid="lang-progress" x="365.09200000000004" y="0" width="196.588" height="8" fill="#f1e05a"/>
<rect mask="url(#rect-mask)" data-testid="lang-progress" x="561.6800000000001" y="0" width="28.084" height="8" fill="#cb171e"/>
<g transform="translate(25, 25)">
<circle cx="5" cy="6" r="5" fill="#3178c6"/>
<text data-testid="lang-name" x="15" y="10" class="lang-name">
TypeScript - 61.88 %
</text>
</g>
<g transform="translate(292.5, 25)">
<circle cx="5" cy="6" r="5" fill="#f1e05a"/>
<text data-testid="lang-name" x="15" y="10" class="lang-name">
JavaScript - 33.32 %
</text>
</g>
<g transform="translate(25, 50)">
<circle cx="5" cy="6" r="5" fill="#cb171e"/>
<text data-testid="lang-name" x="15" y="10" class="lang-name">
YAML - 4.76 %
</text>
</g>
</svg>
</g>
</svg>",
"headers": [
[
"Cache-Control",
"max-age=36000, s-maxage=36000, stale-while-revalidate=86400",
],
[
"Content-Type",
"image/svg+xml",
],
],
}
`;
exports[`Test /api/wakatime contract > should match the public missing-username response snapshot 1`] = `
{
"content": "<svg width="576.5" height="120" viewBox="0 0 576.5 120" fill="#fffefe" xmlns="http://www.w3.org/2000/svg">
<style>
.text { font: 600 16px 'Segoe UI', Ubuntu, Sans-Serif; fill: #2f80ed }
.small { font: 600 12px 'Segoe UI', Ubuntu, Sans-Serif; fill: #434d58 }
.gray { fill: #858585 }
</style>
<rect x="0.5" y="0.5" width="575.5" height="99%" rx="4.5" fill="#fffefe" stroke="#e4e2e2"/>
<text x="25" y="45" class="text">Something went wrong!</text>
<text data-testid="message" x="25" y="55" class="text small">
<tspan x="25" dy="18">Missing params "username" make sure you pass the parameters in URL</tspan>
<tspan x="25" dy="18" class="gray"/>
</text>
</svg>",
"headers": [
[
"Cache-Control",
"max-age=600, s-maxage=600, stale-while-revalidate=86400",
],
[
"Content-Type",
"image/svg+xml",
],
],
}
`;
exports[`Test /api/wakatime contract > should render error card in same theme as requested card 1`] = `
{
"content": "<svg width="576.5" height="120" viewBox="0 0 576.5 120" fill="#0a0f0b" xmlns="http://www.w3.org/2000/svg">
<style>
.text { font: 600 16px 'Segoe UI', Ubuntu, Sans-Serif; fill: #abd200 }
.small { font: 600 12px 'Segoe UI', Ubuntu, Sans-Serif; fill: #68b587 }
.gray { fill: #858585 }
</style>
<rect x="0.5" y="0.5" width="575.5" height="99%" rx="4.5" fill="#0a0f0b" stroke="#e4e2e2"/>
<text x="25" y="45" class="text">Something went wrong!</text>
<text data-testid="message" x="25" y="55" class="text small">
<tspan x="25" dy="18">Missing params "username" make sure you pass the parameters in URL</tspan>
<tspan x="25" dy="18" class="gray"/>
</text>
</svg>",
"headers": [
[
"Cache-Control",
"max-age=600, s-maxage=600, stale-while-revalidate=86400",
],
[
"Content-Type",
"image/svg+xml",
],
],
}
`;
+158 -22
View File
@@ -2,40 +2,176 @@
import axios from "axios";
import MockAdapter from "axios-mock-adapter";
import { afterEach, describe, expect, it, vi } from "vitest";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import api from "../../api-renamed/index.js";
import { renderError } from "../../src/common/render.js";
import { data_stats } from "../test-data/api-data.js";
import { data_stats, normalizeSvg } from "../utils.js";
const mock = new MockAdapter(axios);
const createResponse = () => ({
end: vi.fn(),
setHeader: vi.fn(),
});
beforeEach(() => {
vi.stubEnv("CACHE_SECONDS", "");
vi.stubEnv("GIST_WHITELIST", "");
vi.stubEnv("POSTGRES_URL", "");
vi.stubEnv("WHITELIST", "");
mock.onPost("https://api.github.com/graphql").reply(200, data_stats);
});
afterEach(() => {
mock.reset();
vi.unstubAllEnvs();
// modules may cache environment variables, so we need to reset them
vi.resetModules();
});
describe("Test /api/", () => {
it("should render error card if username in blacklist", async () => {
describe("Test /api contract", () => {
it("should match the public happy-path response snapshot", async () => {
const { default: router } = await import("../../router.js");
const req = {
query: {
username: "renovate-bot",
},
headers: {},
url: "/api?username=anuraghazra",
};
const res = {
setHeader: vi.fn(),
send: vi.fn(),
const res = createResponse();
await router(req, res);
expect(res.end).toHaveBeenCalledOnce();
expect({
headers: res.setHeader.mock.calls,
content: normalizeSvg(res.end.mock.calls[0][0]),
}).toMatchSnapshot();
});
it("should match the public many-params response snapshot", async () => {
mock.onPost("https://api.github.com/graphql").reply(200, data_stats);
const { default: router } = await import("../../router.js");
const params = new URLSearchParams({
username: "anuraghazra",
show_icons: "true",
card_width: "540",
line_height: "32",
title_color: "123456",
ring_color: "654321",
icon_color: "ff00aa",
text_color: "abcdef",
text_bold: "false",
bg_color: "0f172a",
exclude_repo: "repo-exclude-me",
custom_title: "a custom title",
locale: "hi",
disable_animations: "true",
border_radius: "12",
number_format: "long",
number_precision: "1",
border_color: "fedcba",
rank_icon: "github",
commits_year: "2024",
hide: "issues",
role: "OWNER,COLLABORATOR",
show: "reviews,prs_merged,prs_merged_percentage,discussions_started,discussions_answered",
});
const req = {
headers: {},
url: `/api?${params.toString()}`,
};
mock.onPost("https://api.github.com/graphql").replyOnce(200, data_stats);
const res = createResponse();
await api(req, res);
await router(req, res);
expect(res.setHeader).toHaveBeenCalledWith("Content-Type", "image/svg+xml");
expect(res.send).toHaveBeenCalledWith(
renderError({
message: "This username is blacklisted",
secondaryMessage: "Please deploy your own instance",
renderOptions: { show_repo_link: false },
}),
);
expect(res.end).toHaveBeenCalledOnce();
expect({
graphqlRequest: mock.history.post[0].data,
headers: res.setHeader.mock.calls,
content: normalizeSvg(res.end.mock.calls[0][0]),
}).toMatchSnapshot();
});
it("should match the public missing-username response snapshot", async () => {
const { default: router } = await import("../../router.js");
const req = {
headers: {},
url: "/api",
};
const res = createResponse();
await router(req, res);
expect(res.end).toHaveBeenCalledOnce();
expect({
headers: res.setHeader.mock.calls,
content: normalizeSvg(res.end.mock.calls[0][0]),
}).toMatchSnapshot();
});
it("should render error card in same theme as requested card", async () => {
const { default: router } = await import("../../router.js");
const req = {
headers: {},
url: "/api?theme=merko",
};
const res = createResponse();
await router(req, res);
expect(res.end).toHaveBeenCalledOnce();
expect({
headers: res.setHeader.mock.calls,
content: normalizeSvg(res.end.mock.calls[0][0]),
}).toMatchSnapshot();
});
it("should match the public blacklisted-username response snapshot", async () => {
const { default: router } = await import("../../router.js");
const req = {
headers: {},
url: "/api?username=renovate-bot",
};
const res = createResponse();
await router(req, res);
expect(res.end).toHaveBeenCalledOnce();
expect({
headers: res.setHeader.mock.calls,
content: normalizeSvg(res.end.mock.calls[0][0]),
}).toMatchSnapshot();
});
it("should match the private missing-username response snapshot", async () => {
vi.stubEnv("WHITELIST", "anuraghazra");
const { default: router } = await import("../../router.js");
const req = {
headers: {},
url: "/api",
};
const res = createResponse();
await router(req, res);
expect(res.end).toHaveBeenCalledOnce();
expect({
headers: res.setHeader.mock.calls,
content: normalizeSvg(res.end.mock.calls[0][0]),
}).toMatchSnapshot();
});
});
+105 -20
View File
@@ -2,38 +2,123 @@
import axios from "axios";
import MockAdapter from "axios-mock-adapter";
import { afterEach, describe, expect, it, vi } from "vitest";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import gist from "../../api-renamed/gist.js";
import { renderError } from "../../src/common/render.js";
import { gist_data } from "../test-data/gist-data.js";
import { happy_path_gist_data, normalizeSvg } from "../utils.js";
const mock = new MockAdapter(axios);
const createResponse = () => ({
end: vi.fn(),
setHeader: vi.fn(),
});
beforeEach(() => {
vi.stubEnv("CACHE_SECONDS", "");
vi.stubEnv("GIST_WHITELIST", "");
vi.stubEnv("POSTGRES_URL", "");
vi.stubEnv("WHITELIST", "");
mock
.onPost("https://api.github.com/graphql")
.reply(200, happy_path_gist_data);
});
afterEach(() => {
mock.reset();
vi.unstubAllEnvs();
// modules may cache environment variables, so we need to reset them
vi.resetModules();
});
describe("Test /api/gist", () => {
it("should render error if id is not provided", async () => {
describe("Test /api/gist contract", () => {
it("should match the public happy-path response snapshot", async () => {
const { default: router } = await import("../../router.js");
const req = {
query: {},
headers: {},
url: "/api/gist?id=happy-gist-id",
};
const res = {
setHeader: vi.fn(),
send: vi.fn(),
const res = createResponse();
await router(req, res);
expect(res.end).toHaveBeenCalledOnce();
expect({
headers: res.setHeader.mock.calls,
content: normalizeSvg(res.end.mock.calls[0][0]),
}).toMatchSnapshot();
});
it("should match the public many-params response snapshot", async () => {
const { default: router } = await import("../../router.js");
const params = new URLSearchParams({
id: "happy-gist-id",
title_color: "123456",
icon_color: "ff00aa",
text_color: "abcdef",
bg_color: "0f172a",
border_radius: "12",
border_color: "fedcba",
show_owner: "true",
});
const req = {
headers: {},
url: `/api/gist?${params.toString()}`,
};
mock.onPost("https://api.github.com/graphql").reply(200, gist_data);
const res = createResponse();
await gist(req, res);
await router(req, res);
expect(res.setHeader).toHaveBeenCalledWith("Content-Type", "image/svg+xml");
expect(res.send).toHaveBeenCalledWith(
renderError({
message: 'Missing params "id" make sure you pass the parameters in URL',
secondaryMessage: "/api/gist?id=GIST_ID",
renderOptions: { show_repo_link: false },
}),
);
expect(res.end).toHaveBeenCalledOnce();
expect({
graphqlRequest: mock.history.post[0].data,
headers: res.setHeader.mock.calls,
content: normalizeSvg(res.end.mock.calls[0][0]),
}).toMatchSnapshot();
});
it("should match the public missing-id response snapshot", async () => {
const { default: router } = await import("../../router.js");
const req = {
headers: {},
url: "/api/gist",
};
const res = createResponse();
await router(req, res);
expect(res.end).toHaveBeenCalledOnce();
expect({
headers: res.setHeader.mock.calls,
content: normalizeSvg(res.end.mock.calls[0][0]),
}).toMatchSnapshot();
});
it("should match the private missing-id response snapshot", async () => {
vi.stubEnv("GIST_WHITELIST", "allowed-gist-id");
const { default: router } = await import("../../router.js");
const req = {
headers: {},
url: "/api/gist",
};
const res = createResponse();
await router(req, res);
expect(res.end).toHaveBeenCalledOnce();
expect({
headers: res.setHeader.mock.calls,
content: normalizeSvg(res.end.mock.calls[0][0]),
}).toMatchSnapshot();
});
});
+193 -42
View File
@@ -2,64 +2,215 @@
import axios from "axios";
import MockAdapter from "axios-mock-adapter";
import { afterEach, describe, expect, it, vi } from "vitest";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import pin from "../../api-renamed/pin.js";
import { renderError } from "../../src/common/render.js";
import { data_user } from "../test-data/pin-data.js";
import { data_user, normalizeSvg } from "../utils.js";
const mock = new MockAdapter(axios);
const createResponse = () => ({
end: vi.fn(),
setHeader: vi.fn(),
});
beforeEach(() => {
vi.stubEnv("CACHE_SECONDS", "");
vi.stubEnv("GIST_WHITELIST", "");
vi.stubEnv("POSTGRES_URL", "");
vi.stubEnv("WHITELIST", "");
mock.onPost("https://api.github.com/graphql").reply(200, data_user);
});
afterEach(() => {
mock.reset();
vi.unstubAllEnvs();
// modules may cache environment variables, so we need to reset them
vi.resetModules();
});
describe("Test /api/pin", () => {
it("should render error card if username in blacklist", async () => {
describe("Test /api/pin contract", () => {
it("should match the public happy-path response snapshot", async () => {
const { default: router } = await import("../../router.js");
const req = {
query: {
username: "renovate-bot",
repo: "convoychat",
},
headers: {},
url: "/api/pin?username=anuraghazra&repo=convoychat",
};
const res = {
setHeader: vi.fn(),
send: vi.fn(),
};
mock.onPost("https://api.github.com/graphql").reply(200, data_user);
const res = createResponse();
await pin(req, res);
await router(req, res);
expect(res.setHeader).toHaveBeenCalledWith("Content-Type", "image/svg+xml");
expect(res.send).toHaveBeenCalledWith(
renderError({
message: "This username is blacklisted",
secondaryMessage: "Please deploy your own instance",
renderOptions: { show_repo_link: false },
}),
);
expect(res.end).toHaveBeenCalledOnce();
expect({
headers: res.setHeader.mock.calls,
content: normalizeSvg(res.end.mock.calls[0][0]),
}).toMatchSnapshot();
});
it("should render error card if missing required parameters", async () => {
const req = {
query: {},
};
const res = {
setHeader: vi.fn(),
send: vi.fn(),
};
it("should match the public many-params response snapshot", async () => {
mock.onPost("https://api.github.com/graphql").reply(200, data_user);
mock
.onGet(
"https://api.github.com/search/issues?per_page=1&q=repo:anuraghazra/convoychat+author:anuraghazra+type:pr",
)
.reply(200, { total_count: 1234 });
mock
.onGet(
"https://api.github.com/search/issues?per_page=1&q=repo:anuraghazra/convoychat+commenter:anuraghazra+-author:anuraghazra+type:pr",
)
.reply(200, { total_count: 2345 });
mock
.onGet(
"https://api.github.com/search/issues?per_page=1&q=repo:anuraghazra/convoychat+reviewed-by:anuraghazra+-author:anuraghazra+type:pr",
)
.reply(200, { total_count: 3456 });
mock
.onGet(
"https://api.github.com/search/issues?per_page=1&q=repo:anuraghazra/convoychat+author:anuraghazra+type:issue",
)
.reply(200, { total_count: 4567 });
mock
.onGet(
"https://api.github.com/search/issues?per_page=1&q=repo:anuraghazra/convoychat+commenter:anuraghazra+-author:anuraghazra+type:issue",
)
.reply(200, { total_count: 5678 });
await pin(req, res);
const { default: router } = await import("../../router.js");
expect(res.setHeader).toHaveBeenCalledWith("Content-Type", "image/svg+xml");
expect(res.send).toHaveBeenCalledWith(
renderError({
message:
'Missing params "username", "repo" make sure you pass the parameters in URL',
secondaryMessage: "/api/pin?username=USERNAME&amp;repo=REPO_NAME",
renderOptions: { show_repo_link: false },
}),
);
const params = new URLSearchParams({
username: "anuraghazra",
repo: "convoychat",
title_color: "123456",
icon_color: "ff00aa",
text_color: "abcdef",
bg_color: "0f172a",
card_width: "560",
show_owner: "true",
show: "prs_authored,prs_commented,prs_reviewed,issues_authored,issues_commented",
show_icons: "false",
number_format: "long",
text_bold: "true",
line_height: "30",
border_radius: "12",
border_color: "fedcba",
description_lines_count: "1",
});
const req = {
headers: {},
url: `/api/pin?${params.toString()}`,
};
const res = createResponse();
await router(req, res);
expect(res.end).toHaveBeenCalledOnce();
expect({
graphqlRequest: mock.history.post[0].data,
headers: res.setHeader.mock.calls,
content: normalizeSvg(res.end.mock.calls[0][0]),
}).toMatchSnapshot();
});
it("should match the public missing-params response snapshot", async () => {
const { default: router } = await import("../../router.js");
const req = {
headers: {},
url: "/api/pin",
};
const res = createResponse();
await router(req, res);
expect(res.end).toHaveBeenCalledOnce();
expect({
headers: res.setHeader.mock.calls,
content: normalizeSvg(res.end.mock.calls[0][0]),
}).toMatchSnapshot();
});
it("should render error card in same theme as requested card", async () => {
const { default: router } = await import("../../router.js");
const req = {
headers: {},
url: "/api/pin?theme=merko",
};
const res = createResponse();
await router(req, res);
expect(res.end).toHaveBeenCalledOnce();
expect({
headers: res.setHeader.mock.calls,
content: normalizeSvg(res.end.mock.calls[0][0]),
}).toMatchSnapshot();
});
it("should match the public blacklisted-username response snapshot", async () => {
const { default: router } = await import("../../router.js");
const req = {
headers: {},
url: "/api/pin?username=renovate-bot&repo=convoychat",
};
const res = createResponse();
await router(req, res);
expect(res.end).toHaveBeenCalledOnce();
expect({
headers: res.setHeader.mock.calls,
content: normalizeSvg(res.end.mock.calls[0][0]),
}).toMatchSnapshot();
});
it("should match the private non-whitelisted username response snapshot", async () => {
vi.stubEnv("WHITELIST", "anuraghazra");
const { default: router } = await import("../../router.js");
const req = {
headers: {},
url: "/api/pin?username=martin-mfg",
};
const res = createResponse();
await router(req, res);
expect(res.end).toHaveBeenCalledOnce();
expect({
headers: res.setHeader.mock.calls,
content: normalizeSvg(res.end.mock.calls[0][0]),
}).toMatchSnapshot();
});
it("should match the private missing-username response snapshot", async () => {
vi.stubEnv("WHITELIST", "anuraghazra");
const { default: router } = await import("../../router.js");
const req = {
headers: {},
url: "/api/pin",
};
const res = createResponse();
await router(req, res);
expect(res.end).toHaveBeenCalledOnce();
expect({
headers: res.setHeader.mock.calls,
content: normalizeSvg(res.end.mock.calls[0][0]),
}).toMatchSnapshot();
});
});
@@ -2,40 +2,190 @@
import axios from "axios";
import MockAdapter from "axios-mock-adapter";
import { afterEach, describe, expect, it, vi } from "vitest";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import topLangs from "../../api-renamed/top-langs.js";
import { renderError } from "../../src/common/render.js";
import { data_langs } from "../test-data/langs-data.js";
import { data_langs, normalizeSvg } from "../utils.js";
const mock = new MockAdapter(axios);
const createResponse = () => ({
end: vi.fn(),
setHeader: vi.fn(),
});
beforeEach(() => {
vi.stubEnv("CACHE_SECONDS", "");
vi.stubEnv("GIST_WHITELIST", "");
vi.stubEnv("POSTGRES_URL", "");
vi.stubEnv("WHITELIST", "");
mock.onPost("https://api.github.com/graphql").reply(200, data_langs);
});
afterEach(() => {
mock.reset();
vi.unstubAllEnvs();
// modules may cache environment variables, so we need to reset them
vi.resetModules();
});
describe("Test /api/top-langs", () => {
it("should render error card if username in blacklist", async () => {
describe("Test /api/top-langs contract", () => {
it("should match the public happy-path response snapshot", async () => {
const { default: router } = await import("../../router.js");
const req = {
query: {
username: "renovate-bot",
},
};
const res = {
setHeader: vi.fn(),
send: vi.fn(),
headers: {},
url: "/api/top-langs?username=anuraghazra",
};
const res = createResponse();
await router(req, res);
expect(res.end).toHaveBeenCalledOnce();
expect({
headers: res.setHeader.mock.calls,
content: normalizeSvg(res.end.mock.calls[0][0]),
}).toMatchSnapshot();
});
it("should match the public many-params response snapshot", async () => {
mock.onPost("https://api.github.com/graphql").reply(200, data_langs);
await topLangs(req, res);
const { default: router } = await import("../../router.js");
expect(res.setHeader).toHaveBeenCalledWith("Content-Type", "image/svg+xml");
expect(res.send).toHaveBeenCalledWith(
renderError({
message: "This username is blacklisted",
secondaryMessage: "Please deploy your own instance",
renderOptions: { show_repo_link: false },
}),
);
const params = new URLSearchParams({
username: "anuraghazra",
hide: "javascript,HTML",
hide_title: "true",
hide_border: "true",
card_width: "420",
title_color: "123456",
text_color: "abcdef",
bg_color: "0f172a",
layout: "compact",
langs_count: "3",
exclude_repo: "repo-hidden",
size_weight: "0.5",
count_weight: "1",
role: "OWNER,COLLABORATOR",
disable_animations: "true",
stats_format: "bytes",
});
const req = {
headers: {},
url: `/api/top-langs?${params.toString()}`,
};
const res = createResponse();
await router(req, res);
expect(res.end).toHaveBeenCalledOnce();
expect({
graphqlRequest: mock.history.post[0].data,
headers: res.setHeader.mock.calls,
content: normalizeSvg(res.end.mock.calls[0][0]),
}).toMatchSnapshot();
});
it("should match the public missing-username response snapshot", async () => {
const { default: router } = await import("../../router.js");
const req = {
headers: {},
url: "/api/top-langs",
};
const res = createResponse();
await router(req, res);
expect(res.end).toHaveBeenCalledOnce();
expect({
headers: res.setHeader.mock.calls,
content: normalizeSvg(res.end.mock.calls[0][0]),
}).toMatchSnapshot();
});
it("should render error card in same theme as requested card", async () => {
const { default: router } = await import("../../router.js");
const req = {
headers: {},
url: "/api/top-langs?theme=merko",
};
const res = createResponse();
await router(req, res);
expect(res.end).toHaveBeenCalledOnce();
expect({
headers: res.setHeader.mock.calls,
content: normalizeSvg(res.end.mock.calls[0][0]),
}).toMatchSnapshot();
});
it("should match the public blacklisted-username response snapshot", async () => {
const { default: router } = await import("../../router.js");
const req = {
headers: {},
url: "/api/top-langs?username=renovate-bot",
};
const res = createResponse();
await router(req, res);
expect(res.end).toHaveBeenCalledOnce();
expect({
headers: res.setHeader.mock.calls,
content: normalizeSvg(res.end.mock.calls[0][0]),
}).toMatchSnapshot();
});
it("should match the private non-whitelisted username response snapshot", async () => {
vi.stubEnv("WHITELIST", "anuraghazra");
const { default: router } = await import("../../router.js");
const req = {
headers: {},
url: "/api/top-langs?username=martin-mfg",
};
const res = createResponse();
await router(req, res);
expect(res.end).toHaveBeenCalledOnce();
expect({
headers: res.setHeader.mock.calls,
content: normalizeSvg(res.end.mock.calls[0][0]),
}).toMatchSnapshot();
});
it("should match the private missing-username response snapshot", async () => {
vi.stubEnv("WHITELIST", "anuraghazra");
const { default: router } = await import("../../router.js");
const req = {
headers: {},
url: "/api/top-langs",
};
const res = createResponse();
await router(req, res);
expect(res.end).toHaveBeenCalledOnce();
expect({
headers: res.setHeader.mock.calls,
content: normalizeSvg(res.end.mock.calls[0][0]),
}).toMatchSnapshot();
});
});
@@ -0,0 +1,212 @@
// @ts-check
import axios from "axios";
import MockAdapter from "axios-mock-adapter";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { normalizeSvg, wakaTimeData } from "../utils.js";
const mock = new MockAdapter(axios);
const wakaTimeProfileNotPublicData = {
data: {
viewer: {
gist: null,
},
},
};
const createResponse = () => ({
end: vi.fn(),
setHeader: vi.fn(),
});
beforeEach(() => {
vi.stubEnv("CACHE_SECONDS", "");
vi.stubEnv("GIST_WHITELIST", "");
vi.stubEnv("POSTGRES_URL", "");
vi.stubEnv("WHITELIST", "");
mock
.onGet(
"https://wakatime.com/api/v1/users/anuraghazra/stats?is_including_today=true",
)
.reply(200, wakaTimeData);
});
afterEach(() => {
mock.reset();
vi.unstubAllEnvs();
// modules may cache environment variables, so we need to reset them
vi.resetModules();
});
describe("Test /api/wakatime contract", () => {
it("should match the public happy-path response snapshot", async () => {
const { default: router } = await import("../../router.js");
const req = {
headers: {},
url: "/api/wakatime?username=anuraghazra",
};
const res = createResponse();
await router(req, res);
expect(res.end).toHaveBeenCalledOnce();
expect({
headers: res.setHeader.mock.calls,
content: normalizeSvg(res.end.mock.calls[0][0]),
}).toMatchSnapshot();
});
it("should match the public many-params response snapshot", async () => {
mock.reset(); // to verify api_domain param is working
mock
.onGet(
"https://wakatime.local/api/v1/users/anuraghazra/stats?is_including_today=true",
)
.reply(200, wakaTimeData);
const { default: router } = await import("../../router.js");
const params = new URLSearchParams({
username: "anuraghazra",
title_color: "123456",
text_color: "abcdef",
bg_color: "0f172a",
card_width: "620",
custom_title: "a custom title",
layout: "compact",
langs_count: "3",
hide: "other",
api_domain: "wakatime.local",
border_radius: "12",
border_color: "fedcba",
display_format: "percent",
disable_animations: "true",
});
const req = {
headers: {},
url: `/api/wakatime?${params.toString()}`,
};
const res = createResponse();
await router(req, res);
expect(res.end).toHaveBeenCalledOnce();
expect({
headers: res.setHeader.mock.calls,
content: normalizeSvg(res.end.mock.calls[0][0]),
}).toMatchSnapshot();
});
it("should match the public missing-username response snapshot", async () => {
const { default: router } = await import("../../router.js");
const req = {
headers: {},
url: "/api/wakatime",
};
const res = createResponse();
await router(req, res);
expect(res.end).toHaveBeenCalledOnce();
expect({
headers: res.setHeader.mock.calls,
content: normalizeSvg(res.end.mock.calls[0][0]),
}).toMatchSnapshot();
});
it("should render error card in same theme as requested card", async () => {
const { default: router } = await import("../../router.js");
const req = {
headers: {},
url: "/api/wakatime?theme=merko",
};
const res = createResponse();
await router(req, res);
expect(res.end).toHaveBeenCalledOnce();
expect({
headers: res.setHeader.mock.calls,
content: normalizeSvg(res.end.mock.calls[0][0]),
}).toMatchSnapshot();
});
it("should match the public inaccessible-profile response snapshot", async () => {
mock.reset();
mock
.onGet(
"https://wakatime.com/api/v1/users/anuraghazra/stats?is_including_today=true",
)
.reply(200, wakaTimeProfileNotPublicData);
const { default: router } = await import("../../router.js");
const req = {
headers: {},
url: "/api/wakatime?username=anuraghazra",
};
const res = createResponse();
await router(req, res);
expect(res.end).toHaveBeenCalledOnce();
expect({
headers: res.setHeader.mock.calls,
content: normalizeSvg(res.end.mock.calls[0][0]),
}).toMatchSnapshot();
});
it("should match the private non-whitelisted username response snapshot", async () => {
vi.stubEnv("WHITELIST", "anuraghazra");
const { default: router } = await import("../../router.js");
const req = {
headers: {},
url: "/api/wakatime?username=martin-mfg",
};
const res = createResponse();
await router(req, res);
expect(res.end).toHaveBeenCalledOnce();
expect({
headers: res.setHeader.mock.calls,
content: normalizeSvg(res.end.mock.calls[0][0]),
}).toMatchSnapshot();
});
it("should match the private missing-username response snapshot", async () => {
vi.stubEnv("WHITELIST", "anuraghazra");
const { default: router } = await import("../../router.js");
const req = {
headers: {},
url: "/api/wakatime",
};
const res = createResponse();
await router(req, res);
expect(res.end).toHaveBeenCalledOnce();
expect({
headers: res.setHeader.mock.calls,
content: normalizeSvg(res.end.mock.calls[0][0]),
}).toMatchSnapshot();
});
});
-29
View File
@@ -1,29 +0,0 @@
// @ts-check
import { queryByTestId } from "@testing-library/dom";
import { describe, expect, it } from "vitest";
import { renderError } from "../src/common/render.js";
import "@testing-library/jest-dom/vitest";
describe("Test render.js", () => {
it("should test renderError", () => {
document.body.innerHTML = renderError({ message: "Something went wrong" });
expect(
queryByTestId(document.body, "message")?.children[0],
).toHaveTextContent(/Something went wrong/gim);
expect(
queryByTestId(document.body, "message")?.children[1],
).toBeEmptyDOMElement();
// Secondary message
document.body.innerHTML = renderError({
message: "Something went wrong",
secondaryMessage: "Secondary Message",
});
expect(
queryByTestId(document.body, "message")?.children[1],
).toHaveTextContent(/Secondary Message/gim);
});
});
-241
View File
@@ -1,241 +0,0 @@
import { queryByTestId } from "@testing-library/dom";
import { cssToObject } from "@uppercod/css-to-object";
import { describe, expect, it } from "vitest";
import { renderGistCard } from "../src/cards/gist.js";
import { themes } from "../themes/index.js";
import "@testing-library/jest-dom/vitest";
/**
* @type {import("../src/fetchers/gist").GistData}
*/
const data = {
name: "test",
nameWithOwner: "anuraghazra/test",
description: "Small test repository with different Python programs.",
language: "Python",
starsCount: 163,
forksCount: 19,
};
describe("test renderGistCard", () => {
it("should render correctly", () => {
document.body.innerHTML = renderGistCard(data);
const [header] = document.getElementsByClassName("header");
expect(header).toHaveTextContent("test");
expect(header).not.toHaveTextContent("anuraghazra");
expect(document.getElementsByClassName("description")[0]).toHaveTextContent(
"Small test repository with different Python programs.",
);
expect(queryByTestId(document.body, "starsCount")).toHaveTextContent("163");
expect(queryByTestId(document.body, "forksCount")).toHaveTextContent("19");
expect(queryByTestId(document.body, "lang-name")).toHaveTextContent(
"Python",
);
expect(queryByTestId(document.body, "lang-color")).toHaveAttribute(
"fill",
"#3572A5",
);
});
it("should display username in title if show_owner is true", () => {
document.body.innerHTML = renderGistCard(data, { show_owner: true });
const [header] = document.getElementsByClassName("header");
expect(header).toHaveTextContent("anuraghazra/test");
});
it("should trim header if name is too long", () => {
document.body.innerHTML = renderGistCard({
...data,
name: "some-really-long-repo-name-for-test-purposes",
});
const [header] = document.getElementsByClassName("header");
expect(header).toHaveTextContent("some-really-long-repo-name-for-test...");
});
it("should trim description if description os too long", () => {
document.body.innerHTML = renderGistCard({
...data,
description:
"The quick brown fox jumps over the lazy dog is an English-language pangram—a sentence that contains all of the letters of the English alphabet",
});
expect(
document.getElementsByClassName("description")[0].children[0].textContent,
).toBe("The quick brown fox jumps over the lazy dog is an");
expect(
document.getElementsByClassName("description")[0].children[1].textContent,
).toBe("English-language pangram—a sentence that contains all");
});
it("should not trim description if it is short", () => {
document.body.innerHTML = renderGistCard({
...data,
description: "Small text should not trim",
});
expect(document.getElementsByClassName("description")[0]).toHaveTextContent(
"Small text should not trim",
);
});
it("should render emojis in description", () => {
document.body.innerHTML = renderGistCard({
...data,
description: "This is a test gist description with :heart: emoji.",
});
expect(document.getElementsByClassName("description")[0]).toHaveTextContent(
"This is a test gist description with ❤️ emoji.",
);
});
it("should render custom colors properly", () => {
const customColors = {
title_color: "5a0",
icon_color: "1b998b",
text_color: "9991",
bg_color: "252525",
};
document.body.innerHTML = renderGistCard(data, {
...customColors,
});
const styleTag = document.querySelector("style");
const stylesObject = cssToObject(styleTag.innerHTML);
const headerClassStyles = stylesObject[":host"][".header "];
const descClassStyles = stylesObject[":host"][".description "];
const iconClassStyles = stylesObject[":host"][".icon "];
expect(headerClassStyles.fill.trim()).toBe(`#${customColors.title_color}`);
expect(descClassStyles.fill.trim()).toBe(`#${customColors.text_color}`);
expect(iconClassStyles.fill.trim()).toBe(`#${customColors.icon_color}`);
expect(queryByTestId(document.body, "card-bg")).toHaveAttribute(
"fill",
"#252525",
);
});
it("should render with all the themes", () => {
Object.keys(themes).forEach((name) => {
document.body.innerHTML = renderGistCard(data, {
theme: name,
});
const styleTag = document.querySelector("style");
const stylesObject = cssToObject(styleTag.innerHTML);
const headerClassStyles = stylesObject[":host"][".header "];
const descClassStyles = stylesObject[":host"][".description "];
const iconClassStyles = stylesObject[":host"][".icon "];
expect(headerClassStyles.fill.trim()).toBe(
`#${themes[name].title_color}`,
);
expect(descClassStyles.fill.trim()).toBe(`#${themes[name].text_color}`);
expect(iconClassStyles.fill.trim()).toBe(`#${themes[name].icon_color}`);
const backgroundElement = queryByTestId(document.body, "card-bg");
const backgroundElementFill = backgroundElement.getAttribute("fill");
expect([`#${themes[name].bg_color}`, "url(#gradient)"]).toContain(
backgroundElementFill,
);
});
});
it("should render custom colors with themes", () => {
document.body.innerHTML = renderGistCard(data, {
title_color: "5a0",
theme: "radical",
});
const styleTag = document.querySelector("style");
const stylesObject = cssToObject(styleTag.innerHTML);
const headerClassStyles = stylesObject[":host"][".header "];
const descClassStyles = stylesObject[":host"][".description "];
const iconClassStyles = stylesObject[":host"][".icon "];
expect(headerClassStyles.fill.trim()).toBe("#5a0");
expect(descClassStyles.fill.trim()).toBe(`#${themes.radical.text_color}`);
expect(iconClassStyles.fill.trim()).toBe(`#${themes.radical.icon_color}`);
expect(queryByTestId(document.body, "card-bg")).toHaveAttribute(
"fill",
`#${themes.radical.bg_color}`,
);
});
it("should render custom colors with themes and fallback to default colors if invalid", () => {
document.body.innerHTML = renderGistCard(data, {
title_color: "invalid color",
text_color: "invalid color",
theme: "radical",
});
const styleTag = document.querySelector("style");
const stylesObject = cssToObject(styleTag.innerHTML);
const headerClassStyles = stylesObject[":host"][".header "];
const descClassStyles = stylesObject[":host"][".description "];
const iconClassStyles = stylesObject[":host"][".icon "];
expect(headerClassStyles.fill.trim()).toBe(
`#${themes.default.title_color}`,
);
expect(descClassStyles.fill.trim()).toBe(`#${themes.default.text_color}`);
expect(iconClassStyles.fill.trim()).toBe(`#${themes.radical.icon_color}`);
expect(queryByTestId(document.body, "card-bg")).toHaveAttribute(
"fill",
`#${themes.radical.bg_color}`,
);
});
it("should not render star count or fork count if either of the are zero", () => {
document.body.innerHTML = renderGistCard({
...data,
starsCount: 0,
});
expect(queryByTestId(document.body, "starsCount")).toBeNull();
expect(queryByTestId(document.body, "forksCount")).toBeInTheDocument();
document.body.innerHTML = renderGistCard({
...data,
starsCount: 1,
forksCount: 0,
});
expect(queryByTestId(document.body, "starsCount")).toBeInTheDocument();
expect(queryByTestId(document.body, "forksCount")).toBeNull();
document.body.innerHTML = renderGistCard({
...data,
starsCount: 0,
forksCount: 0,
});
expect(queryByTestId(document.body, "starsCount")).toBeNull();
expect(queryByTestId(document.body, "forksCount")).toBeNull();
});
it("should render without rounding", () => {
document.body.innerHTML = renderGistCard(data, {
border_radius: "0",
});
expect(document.querySelector("rect")).toHaveAttribute("rx", "0");
document.body.innerHTML = renderGistCard(data, {});
expect(document.querySelector("rect")).toHaveAttribute("rx", "4.5");
});
it("should fallback to default description", () => {
document.body.innerHTML = renderGistCard({
...data,
description: undefined,
});
expect(document.getElementsByClassName("description")[0]).toHaveTextContent(
"No description provided",
);
});
});
-373
View File
@@ -1,373 +0,0 @@
import { queryByTestId } from "@testing-library/dom";
import { cssToObject } from "@uppercod/css-to-object";
import { describe, expect, it } from "vitest";
import { renderRepoCard } from "../src/cards/repo.js";
import { themes } from "../themes/index.js";
import "@testing-library/jest-dom/vitest";
const data_repo = {
repository: {
nameWithOwner: "anuraghazra/convoychat",
name: "convoychat",
description: "Help us take over the world! React + TS + GraphQL Chat App",
primaryLanguage: {
color: "#2b7489",
id: "MDg6TGFuZ3VhZ2UyODc=",
name: "TypeScript",
},
starCount: 38000,
forkCount: 100,
},
};
describe("Test renderRepoCard", () => {
it("should render correctly", () => {
document.body.innerHTML = renderRepoCard(data_repo.repository);
const [header] = document.getElementsByClassName("header");
expect(header).toHaveTextContent("convoychat");
expect(header).not.toHaveTextContent("anuraghazra");
expect(document.getElementsByClassName("description")[0]).toHaveTextContent(
"Help us take over the world! React + TS + GraphQL Chat App",
);
expect(queryByTestId(document.body, "stargazers")).toHaveTextContent("38k");
expect(queryByTestId(document.body, "forkcount")).toHaveTextContent("100");
expect(queryByTestId(document.body, "lang-name")).toHaveTextContent(
"TypeScript",
);
expect(queryByTestId(document.body, "lang-color")).toHaveAttribute(
"fill",
"#2b7489",
);
});
it("should display username in title (full repo name)", () => {
document.body.innerHTML = renderRepoCard(data_repo.repository, {
show_owner: true,
});
expect(document.getElementsByClassName("header")[0]).toHaveTextContent(
"anuraghazra/convoychat",
);
});
it("should trim header", () => {
document.body.innerHTML = renderRepoCard({
...data_repo.repository,
name: "some-really-long-repo-name-for-test-purposes",
});
expect(document.getElementsByClassName("header")[0].textContent).toBe(
"some-really-long-repo-name-for-test...",
);
});
it("should trim description", () => {
document.body.innerHTML = renderRepoCard({
...data_repo.repository,
description:
"The quick brown fox jumps over the lazy dog is an English-language pangram—a sentence that contains all of the letters of the English alphabet",
});
expect(
document.getElementsByClassName("description")[0].children[0].textContent,
).toBe("The quick brown fox jumps over the lazy dog is an");
expect(
document.getElementsByClassName("description")[0].children[1].textContent,
).toBe("English-language pangram—a sentence that contains all");
// Should not trim
document.body.innerHTML = renderRepoCard({
...data_repo.repository,
description: "Small text should not trim",
});
expect(document.getElementsByClassName("description")[0]).toHaveTextContent(
"Small text should not trim",
);
});
it("should render emojis", () => {
document.body.innerHTML = renderRepoCard({
...data_repo.repository,
description: "This is a text with a :poop: poo emoji",
});
// poop emoji may not show in all editors but it's there between "a" and "poo"
expect(document.getElementsByClassName("description")[0]).toHaveTextContent(
"This is a text with a 💩 poo emoji",
);
});
it("should hide language if primaryLanguage is null & fallback to correct values", () => {
document.body.innerHTML = renderRepoCard({
...data_repo.repository,
primaryLanguage: null,
});
expect(queryByTestId(document.body, "primary-lang")).toBeNull();
document.body.innerHTML = renderRepoCard({
...data_repo.repository,
primaryLanguage: { color: null, name: null },
});
expect(queryByTestId(document.body, "primary-lang")).toBeInTheDocument();
expect(queryByTestId(document.body, "lang-color")).toHaveAttribute(
"fill",
"#333",
);
expect(queryByTestId(document.body, "lang-name")).toHaveTextContent(
"Unspecified",
);
});
it("should render default colors properly", () => {
document.body.innerHTML = renderRepoCard(data_repo.repository);
const styleTag = document.querySelector("style");
const stylesObject = cssToObject(styleTag.innerHTML);
const headerClassStyles = stylesObject[":host"][".header "];
const descClassStyles = stylesObject[":host"][".description "];
const iconClassStyles = stylesObject[":host"][".icon "];
expect(headerClassStyles.fill.trim()).toBe("#2f80ed");
expect(descClassStyles.fill.trim()).toBe("#434d58");
expect(iconClassStyles.fill.trim()).toBe("#586069");
expect(queryByTestId(document.body, "card-bg")).toHaveAttribute(
"fill",
"#fffefe",
);
});
it("should render custom colors properly", () => {
const customColors = {
title_color: "5a0",
icon_color: "1b998b",
text_color: "9991",
bg_color: "252525",
};
document.body.innerHTML = renderRepoCard(data_repo.repository, {
...customColors,
});
const styleTag = document.querySelector("style");
const stylesObject = cssToObject(styleTag.innerHTML);
const headerClassStyles = stylesObject[":host"][".header "];
const descClassStyles = stylesObject[":host"][".description "];
const iconClassStyles = stylesObject[":host"][".icon "];
expect(headerClassStyles.fill.trim()).toBe(`#${customColors.title_color}`);
expect(descClassStyles.fill.trim()).toBe(`#${customColors.text_color}`);
expect(iconClassStyles.fill.trim()).toBe(`#${customColors.icon_color}`);
expect(queryByTestId(document.body, "card-bg")).toHaveAttribute(
"fill",
"#252525",
);
});
it("should render with all the themes", () => {
Object.keys(themes).forEach((name) => {
document.body.innerHTML = renderRepoCard(data_repo.repository, {
theme: name,
});
const styleTag = document.querySelector("style");
const stylesObject = cssToObject(styleTag.innerHTML);
const headerClassStyles = stylesObject[":host"][".header "];
const descClassStyles = stylesObject[":host"][".description "];
const iconClassStyles = stylesObject[":host"][".icon "];
expect(headerClassStyles.fill.trim()).toBe(
`#${themes[name].title_color}`,
);
expect(descClassStyles.fill.trim()).toBe(`#${themes[name].text_color}`);
expect(iconClassStyles.fill.trim()).toBe(`#${themes[name].icon_color}`);
const backgroundElement = queryByTestId(document.body, "card-bg");
const backgroundElementFill = backgroundElement.getAttribute("fill");
expect([`#${themes[name].bg_color}`, "url(#gradient)"]).toContain(
backgroundElementFill,
);
});
});
it("should render custom colors with themes", () => {
document.body.innerHTML = renderRepoCard(data_repo.repository, {
title_color: "5a0",
theme: "radical",
});
const styleTag = document.querySelector("style");
const stylesObject = cssToObject(styleTag.innerHTML);
const headerClassStyles = stylesObject[":host"][".header "];
const descClassStyles = stylesObject[":host"][".description "];
const iconClassStyles = stylesObject[":host"][".icon "];
expect(headerClassStyles.fill.trim()).toBe("#5a0");
expect(descClassStyles.fill.trim()).toBe(`#${themes.radical.text_color}`);
expect(iconClassStyles.fill.trim()).toBe(`#${themes.radical.icon_color}`);
expect(queryByTestId(document.body, "card-bg")).toHaveAttribute(
"fill",
`#${themes.radical.bg_color}`,
);
});
it("should render custom colors with themes and fallback to default colors if invalid", () => {
document.body.innerHTML = renderRepoCard(data_repo.repository, {
title_color: "invalid color",
text_color: "invalid color",
theme: "radical",
});
const styleTag = document.querySelector("style");
const stylesObject = cssToObject(styleTag.innerHTML);
const headerClassStyles = stylesObject[":host"][".header "];
const descClassStyles = stylesObject[":host"][".description "];
const iconClassStyles = stylesObject[":host"][".icon "];
expect(headerClassStyles.fill.trim()).toBe(
`#${themes.default.title_color}`,
);
expect(descClassStyles.fill.trim()).toBe(`#${themes.default.text_color}`);
expect(iconClassStyles.fill.trim()).toBe(`#${themes.radical.icon_color}`);
expect(queryByTestId(document.body, "card-bg")).toHaveAttribute(
"fill",
`#${themes.radical.bg_color}`,
);
});
it("should not render star count or fork count if either of the are zero", () => {
document.body.innerHTML = renderRepoCard({
...data_repo.repository,
starCount: 0,
});
expect(queryByTestId(document.body, "stargazers")).toBeNull();
expect(queryByTestId(document.body, "forkcount")).toBeInTheDocument();
document.body.innerHTML = renderRepoCard({
...data_repo.repository,
starCount: 1,
forkCount: 0,
});
expect(queryByTestId(document.body, "stargazers")).toBeInTheDocument();
expect(queryByTestId(document.body, "forkcount")).toBeNull();
document.body.innerHTML = renderRepoCard({
...data_repo.repository,
starCount: 0,
forkCount: 0,
});
expect(queryByTestId(document.body, "stargazers")).toBeNull();
expect(queryByTestId(document.body, "forkcount")).toBeNull();
});
it("should render badges", () => {
document.body.innerHTML = renderRepoCard({
...data_repo.repository,
isArchived: true,
});
expect(queryByTestId(document.body, "badge")).toHaveTextContent("Archived");
document.body.innerHTML = renderRepoCard({
...data_repo.repository,
isTemplate: true,
});
expect(queryByTestId(document.body, "badge")).toHaveTextContent("Template");
});
it("should not render template", () => {
document.body.innerHTML = renderRepoCard({
...data_repo.repository,
});
expect(queryByTestId(document.body, "badge")).toBeNull();
});
it("should render translated badges", () => {
document.body.innerHTML = renderRepoCard(
{
...data_repo.repository,
isArchived: true,
},
{
locale: "cn",
},
);
expect(queryByTestId(document.body, "badge")).toHaveTextContent("已归档");
document.body.innerHTML = renderRepoCard(
{
...data_repo.repository,
isTemplate: true,
},
{
locale: "cn",
},
);
expect(queryByTestId(document.body, "badge")).toHaveTextContent("模板");
});
it("should render without rounding", () => {
document.body.innerHTML = renderRepoCard(data_repo.repository, {
border_radius: "0",
});
expect(document.querySelector("rect")).toHaveAttribute("rx", "0");
document.body.innerHTML = renderRepoCard(data_repo.repository, {});
expect(document.querySelector("rect")).toHaveAttribute("rx", "4.5");
});
it("should fallback to default description", () => {
document.body.innerHTML = renderRepoCard({
...data_repo.repository,
description: undefined,
isArchived: true,
});
expect(document.getElementsByClassName("description")[0]).toHaveTextContent(
"No description provided",
);
});
it("should have correct height with specified `description_lines_count` parameter", () => {
// Testing short description
document.body.innerHTML = renderRepoCard(data_repo.repository, {
description_lines_count: 1,
});
expect(document.querySelector("svg")).toHaveAttribute("height", "120");
document.body.innerHTML = renderRepoCard(data_repo.repository, {
description_lines_count: 3,
});
expect(document.querySelector("svg")).toHaveAttribute("height", "150");
// Testing long description
const longDescription =
"A tool that will make a lot of iPhone/iPad developers' life easier. It shares your app over-the-air in a WiFi network. Bonjour is used and no configuration is needed.";
document.body.innerHTML = renderRepoCard(
{ ...data_repo.repository, description: longDescription },
{
description_lines_count: 3,
},
);
expect(document.querySelector("svg")).toHaveAttribute("height", "150");
document.body.innerHTML = renderRepoCard(
{ ...data_repo.repository, description: longDescription },
{
description_lines_count: 1,
},
);
expect(document.querySelector("svg")).toHaveAttribute("height", "120");
});
});
-468
View File
@@ -1,468 +0,0 @@
import {
getByTestId,
queryAllByTestId,
queryByTestId,
} from "@testing-library/dom";
import { cssToObject } from "@uppercod/css-to-object";
import { describe, expect, it } from "vitest";
import { renderStatsCard } from "../src/cards/stats.js";
import { CustomError } from "../src/common/error.js";
import { themes } from "../themes/index.js";
import "@testing-library/jest-dom/vitest";
const stats = {
name: "Anurag Hazra",
totalStars: 100,
totalCommits: 200,
totalIssues: 300,
totalPRs: 400,
totalPRsMerged: 320,
mergedPRsPercentage: 80,
totalReviews: 50,
totalDiscussionsStarted: 10,
totalDiscussionsAnswered: 50,
contributedTo: 500,
rank: { level: "A+", percentile: 40 },
};
describe("Test renderStatsCard", () => {
it("should render correctly", () => {
document.body.innerHTML = renderStatsCard(stats);
expect(document.getElementsByClassName("header")[0].textContent).toBe(
"Anurag Hazra's GitHub Stats",
);
expect(
document.body.getElementsByTagName("svg")[0].getAttribute("height"),
).toBe("195");
expect(getByTestId(document.body, "stars").textContent).toBe("100");
expect(getByTestId(document.body, "commits").textContent).toBe("200");
expect(getByTestId(document.body, "issues").textContent).toBe("300");
expect(getByTestId(document.body, "prs").textContent).toBe("400");
expect(getByTestId(document.body, "contribs").textContent).toBe("500");
expect(queryByTestId(document.body, "card-bg")).toBeInTheDocument();
expect(queryByTestId(document.body, "rank-circle")).toBeInTheDocument();
// Default hidden stats
expect(queryByTestId(document.body, "reviews")).not.toBeInTheDocument();
expect(
queryByTestId(document.body, "discussions_started"),
).not.toBeInTheDocument();
expect(
queryByTestId(document.body, "discussions_answered"),
).not.toBeInTheDocument();
expect(queryByTestId(document.body, "prs_merged")).not.toBeInTheDocument();
expect(
queryByTestId(document.body, "prs_merged_percentage"),
).not.toBeInTheDocument();
});
it("should have proper name apostrophe", () => {
document.body.innerHTML = renderStatsCard({ ...stats, name: "Anil Das" });
expect(document.getElementsByClassName("header")[0].textContent).toBe(
"Anil Das' GitHub Stats",
);
document.body.innerHTML = renderStatsCard({ ...stats, name: "Felix" });
expect(document.getElementsByClassName("header")[0].textContent).toBe(
"Felix's GitHub Stats",
);
});
it("should hide individual stats", () => {
document.body.innerHTML = renderStatsCard(stats, {
hide: ["issues", "prs", "contribs"],
});
expect(
document.body.getElementsByTagName("svg")[0].getAttribute("height"),
).toBe("150"); // height should be 150 because we clamped it.
expect(queryByTestId(document.body, "stars")).toBeDefined();
expect(queryByTestId(document.body, "commits")).toBeDefined();
expect(queryByTestId(document.body, "issues")).toBeNull();
expect(queryByTestId(document.body, "prs")).toBeNull();
expect(queryByTestId(document.body, "contribs")).toBeNull();
expect(queryByTestId(document.body, "reviews")).toBeNull();
expect(queryByTestId(document.body, "discussions_started")).toBeNull();
expect(queryByTestId(document.body, "discussions_answered")).toBeNull();
expect(queryByTestId(document.body, "prs_merged")).toBeNull();
expect(queryByTestId(document.body, "prs_merged_percentage")).toBeNull();
});
it("should show additional stats", () => {
document.body.innerHTML = renderStatsCard(stats, {
show: [
"reviews",
"discussions_started",
"discussions_answered",
"prs_merged",
"prs_merged_percentage",
],
});
expect(
document.body.getElementsByTagName("svg")[0].getAttribute("height"),
).toBe("320");
expect(queryByTestId(document.body, "stars")).toBeDefined();
expect(queryByTestId(document.body, "commits")).toBeDefined();
expect(queryByTestId(document.body, "issues")).toBeDefined();
expect(queryByTestId(document.body, "prs")).toBeDefined();
expect(queryByTestId(document.body, "contribs")).toBeDefined();
expect(queryByTestId(document.body, "reviews")).toBeDefined();
expect(queryByTestId(document.body, "discussions_started")).toBeDefined();
expect(queryByTestId(document.body, "discussions_answered")).toBeDefined();
expect(queryByTestId(document.body, "prs_merged")).toBeDefined();
expect(queryByTestId(document.body, "prs_merged_percentage")).toBeDefined();
});
it("should hide_rank", () => {
document.body.innerHTML = renderStatsCard(stats, { hide_rank: true });
expect(queryByTestId(document.body, "rank-circle")).not.toBeInTheDocument();
});
it("should render with custom width set", () => {
document.body.innerHTML = renderStatsCard(stats);
expect(document.querySelector("svg")).toHaveAttribute("width", "450");
document.body.innerHTML = renderStatsCard(stats, { card_width: 500 });
expect(document.querySelector("svg")).toHaveAttribute("width", "500");
});
it("should render with custom width set and limit minimum width", () => {
document.body.innerHTML = renderStatsCard(stats, { card_width: 1 });
expect(document.querySelector("svg")).toHaveAttribute("width", "1");
// Test default minimum card width without rank circle.
document.body.innerHTML = renderStatsCard(stats, {
card_width: 1,
hide_rank: true,
});
expect(document.querySelector("svg")).toHaveAttribute("width", "1");
// Test minimum card width with rank and icons.
document.body.innerHTML = renderStatsCard(stats, {
card_width: 1,
hide_rank: true,
show_icons: true,
});
expect(document.querySelector("svg")).toHaveAttribute("width", "1");
// Test minimum card width with icons but without rank.
document.body.innerHTML = renderStatsCard(stats, {
card_width: 1,
hide_rank: false,
show_icons: true,
});
expect(document.querySelector("svg")).toHaveAttribute("width", "1");
// Test minimum card width without icons or rank.
document.body.innerHTML = renderStatsCard(stats, {
card_width: 1,
hide_rank: false,
show_icons: false,
});
expect(document.querySelector("svg")).toHaveAttribute("width", "1");
});
it("should render default colors properly", () => {
document.body.innerHTML = renderStatsCard(stats);
const styleTag = document.querySelector("style");
const stylesObject = cssToObject(styleTag.textContent);
const headerClassStyles = stylesObject[":host"][".header "];
const statClassStyles = stylesObject[":host"][".stat "];
const iconClassStyles = stylesObject[":host"][".icon "];
expect(headerClassStyles.fill.trim()).toBe("#2f80ed");
expect(statClassStyles.fill.trim()).toBe("#434d58");
expect(iconClassStyles.fill.trim()).toBe("#4c71f2");
expect(queryByTestId(document.body, "card-bg")).toHaveAttribute(
"fill",
"#fffefe",
);
});
it("should render custom colors properly", () => {
const customColors = {
title_color: "5a0",
icon_color: "1b998b",
text_color: "9991",
bg_color: "252525",
};
document.body.innerHTML = renderStatsCard(stats, { ...customColors });
const styleTag = document.querySelector("style");
const stylesObject = cssToObject(styleTag.innerHTML);
const headerClassStyles = stylesObject[":host"][".header "];
const statClassStyles = stylesObject[":host"][".stat "];
const iconClassStyles = stylesObject[":host"][".icon "];
expect(headerClassStyles.fill.trim()).toBe(`#${customColors.title_color}`);
expect(statClassStyles.fill.trim()).toBe(`#${customColors.text_color}`);
expect(iconClassStyles.fill.trim()).toBe(`#${customColors.icon_color}`);
expect(queryByTestId(document.body, "card-bg")).toHaveAttribute(
"fill",
"#252525",
);
});
it("should render custom colors with themes", () => {
document.body.innerHTML = renderStatsCard(stats, {
title_color: "5a0",
theme: "radical",
});
const styleTag = document.querySelector("style");
const stylesObject = cssToObject(styleTag.innerHTML);
const headerClassStyles = stylesObject[":host"][".header "];
const statClassStyles = stylesObject[":host"][".stat "];
const iconClassStyles = stylesObject[":host"][".icon "];
expect(headerClassStyles.fill.trim()).toBe("#5a0");
expect(statClassStyles.fill.trim()).toBe(`#${themes.radical.text_color}`);
expect(iconClassStyles.fill.trim()).toBe(`#${themes.radical.icon_color}`);
expect(queryByTestId(document.body, "card-bg")).toHaveAttribute(
"fill",
`#${themes.radical.bg_color}`,
);
});
it("should render with all the themes", () => {
Object.keys(themes).forEach((name) => {
document.body.innerHTML = renderStatsCard(stats, {
theme: name,
});
const styleTag = document.querySelector("style");
const stylesObject = cssToObject(styleTag.innerHTML);
const headerClassStyles = stylesObject[":host"][".header "];
const statClassStyles = stylesObject[":host"][".stat "];
const iconClassStyles = stylesObject[":host"][".icon "];
expect(headerClassStyles.fill.trim()).toBe(
`#${themes[name].title_color}`,
);
expect(statClassStyles.fill.trim()).toBe(`#${themes[name].text_color}`);
expect(iconClassStyles.fill.trim()).toBe(`#${themes[name].icon_color}`);
const backgroundElement = queryByTestId(document.body, "card-bg");
const backgroundElementFill = backgroundElement.getAttribute("fill");
expect([`#${themes[name].bg_color}`, "url(#gradient)"]).toContain(
backgroundElementFill,
);
});
});
it("should render custom colors with themes and fallback to default colors if invalid", () => {
document.body.innerHTML = renderStatsCard(stats, {
title_color: "invalid color",
text_color: "invalid color",
theme: "radical",
});
const styleTag = document.querySelector("style");
const stylesObject = cssToObject(styleTag.innerHTML);
const headerClassStyles = stylesObject[":host"][".header "];
const statClassStyles = stylesObject[":host"][".stat "];
const iconClassStyles = stylesObject[":host"][".icon "];
expect(headerClassStyles.fill.trim()).toBe(
`#${themes.default.title_color}`,
);
expect(statClassStyles.fill.trim()).toBe(`#${themes.default.text_color}`);
expect(iconClassStyles.fill.trim()).toBe(`#${themes.radical.icon_color}`);
expect(queryByTestId(document.body, "card-bg")).toHaveAttribute(
"fill",
`#${themes.radical.bg_color}`,
);
});
it("should render custom ring_color properly", () => {
const customColors = {
title_color: "5a0",
ring_color: "0000ff",
icon_color: "1b998b",
text_color: "9991",
bg_color: "252525",
};
document.body.innerHTML = renderStatsCard(stats, { ...customColors });
const styleTag = document.querySelector("style");
const stylesObject = cssToObject(styleTag.innerHTML);
const headerClassStyles = stylesObject[":host"][".header "];
const statClassStyles = stylesObject[":host"][".stat "];
const iconClassStyles = stylesObject[":host"][".icon "];
const rankCircleStyles = stylesObject[":host"][".rank-circle "];
const rankCircleRimStyles = stylesObject[":host"][".rank-circle-rim "];
expect(headerClassStyles.fill.trim()).toBe(`#${customColors.title_color}`);
expect(statClassStyles.fill.trim()).toBe(`#${customColors.text_color}`);
expect(iconClassStyles.fill.trim()).toBe(`#${customColors.icon_color}`);
expect(rankCircleStyles.stroke.trim()).toBe(`#${customColors.ring_color}`);
expect(rankCircleRimStyles.stroke.trim()).toBe(
`#${customColors.ring_color}`,
);
expect(queryByTestId(document.body, "card-bg")).toHaveAttribute(
"fill",
"#252525",
);
});
it("should render icons correctly", () => {
document.body.innerHTML = renderStatsCard(stats, {
show_icons: true,
});
expect(queryAllByTestId(document.body, "icon")[0]).toBeDefined();
expect(queryByTestId(document.body, "stars")).toBeDefined();
expect(
queryByTestId(document.body, "stars").previousElementSibling, // the label
).toHaveAttribute("x", "25");
});
it("should not have icons if show_icons is false", () => {
document.body.innerHTML = renderStatsCard(stats, { show_icons: false });
expect(queryAllByTestId(document.body, "icon")[0]).not.toBeDefined();
expect(queryByTestId(document.body, "stars")).toBeDefined();
expect(
queryByTestId(document.body, "stars").previousElementSibling, // the label
).not.toHaveAttribute("x");
});
it("should auto resize if hide_rank is true", () => {
document.body.innerHTML = renderStatsCard(stats, {
hide_rank: true,
});
expect(
document.body.getElementsByTagName("svg")[0].getAttribute("width"),
).toBe("305.81250000000006");
});
it("should auto resize if hide_rank is true & custom_title is set", () => {
document.body.innerHTML = renderStatsCard(stats, {
hide_rank: true,
custom_title: "Hello world",
});
expect(
document.body.getElementsByTagName("svg")[0].getAttribute("width"),
).toBe("287");
});
it("should render translations", () => {
document.body.innerHTML = renderStatsCard(stats, { locale: "cn" });
expect(document.getElementsByClassName("header")[0].textContent).toBe(
"Anurag Hazra 的 GitHub 统计数据",
);
expect(
document.querySelector(
'g[transform="translate(0, 0)"]>.stagger>.stat.bold',
).textContent,
).toMatchInlineSnapshot(`"获标星数:"`);
expect(
document.querySelector(
'g[transform="translate(0, 25)"]>.stagger>.stat.bold',
).textContent,
).toMatchInlineSnapshot(`"累计提交总数 (去年):"`);
expect(
document.querySelector(
'g[transform="translate(0, 50)"]>.stagger>.stat.bold',
).textContent,
).toMatchInlineSnapshot(`"发起的 PR 总数:"`);
expect(
document.querySelector(
'g[transform="translate(0, 75)"]>.stagger>.stat.bold',
).textContent,
).toMatchInlineSnapshot(`"提出的 issue 总数:"`);
expect(
document.querySelector(
'g[transform="translate(0, 100)"]>.stagger>.stat.bold',
).textContent,
).toMatchInlineSnapshot(`"贡献的项目数(去年):"`);
});
it("should render without rounding", () => {
document.body.innerHTML = renderStatsCard(stats, { border_radius: "0" });
expect(document.querySelector("rect")).toHaveAttribute("rx", "0");
document.body.innerHTML = renderStatsCard(stats, {});
expect(document.querySelector("rect")).toHaveAttribute("rx", "4.5");
});
it("should shorten values", () => {
stats["totalCommits"] = 1999;
document.body.innerHTML = renderStatsCard(stats);
expect(getByTestId(document.body, "commits").textContent).toBe("2k");
document.body.innerHTML = renderStatsCard(stats, { number_format: "long" });
expect(getByTestId(document.body, "commits").textContent).toBe("1999");
document.body.innerHTML = renderStatsCard(stats, { number_precision: 2 });
expect(getByTestId(document.body, "commits").textContent).toBe("2.00k");
document.body.innerHTML = renderStatsCard(stats, {
number_format: "long",
number_precision: 2,
});
expect(getByTestId(document.body, "commits").textContent).toBe("1999");
});
it("should render default rank icon with level A+", () => {
document.body.innerHTML = renderStatsCard(stats, {
rank_icon: "default",
});
expect(queryByTestId(document.body, "level-rank-icon")).toBeDefined();
expect(
queryByTestId(document.body, "level-rank-icon").textContent.trim(),
).toBe("A+");
});
it("should render github rank icon", () => {
document.body.innerHTML = renderStatsCard(stats, {
rank_icon: "github",
});
expect(queryByTestId(document.body, "github-rank-icon")).toBeDefined();
});
it("should show the rank percentile", () => {
document.body.innerHTML = renderStatsCard(stats, {
rank_icon: "percentile",
});
expect(queryByTestId(document.body, "percentile-top-header")).toBeDefined();
expect(
queryByTestId(document.body, "percentile-top-header").textContent.trim(),
).toBe("Top");
expect(queryByTestId(document.body, "rank-percentile-text")).toBeDefined();
expect(
queryByTestId(document.body, "percentile-rank-value").textContent.trim(),
).toBe(stats.rank.percentile.toFixed(1) + "%");
});
it("should throw error if all stats and rank icon are hidden", () => {
expect(() =>
renderStatsCard(stats, {
hide: ["stars", "commits", "prs", "issues", "contribs"],
hide_rank: true,
}),
).toThrow(
new CustomError(
"Could not render stats card.",
"Either stats or rank are required.",
),
);
});
});

Some files were not shown because too many files have changed in this diff Show More