@fajarkim/github-readme-profile
Version:
🙀 Generate your Stats GitHub Profile in SVG
219 lines (196 loc) • 9.83 kB
text/typescript
import type { GetData } from "./getData";
import type { UiConfig } from "../api/index";
import { locales, Locales } from "./translations";
import icons from "./icons";
import { parseBoolean } from "./common/utils";
/**
* Generates the SVG markup for the GitHub stats card.
*
* @param {GetData} data - GitHub user data stats.
* @param {UiConfig} uiConfig - Configuration for the UI card options.
* @returns {string} - SVG markup for the GitHub stats card.
*/
function card(data: GetData, uiConfig: UiConfig): string {
const fallbackLocale = "en";
const defaultLocale: Locales[keyof Locales] = locales[fallbackLocale];
const selectLocale: Locales[keyof Locales] = locales[uiConfig.Locale] || defaultLocale;
const isRtlDirection = parseBoolean(selectLocale.rtlDirection);
const isDisabledAnimations = parseBoolean(uiConfig.disabledAnimations || uiConfig.Format === "png");
const direction = isRtlDirection ? "rtl" : "ltr";
const angle = {
titleXAngle: isDisabledAnimations ? (isRtlDirection ? 520 : 15) : (isRtlDirection ? 510 : 5),
titleYAngle: isDisabledAnimations ? 0 : -10,
textXAngle: isRtlDirection ? 215 : 20,
dataXAngle: isRtlDirection ? 15 : 220,
iconXAngle: isRtlDirection ? 225 : -5,
imageXAngle: isDisabledAnimations ? 120 : 125,
imageYAngle: isDisabledAnimations ? 70 : 65,
userXAngle: isDisabledAnimations ? 119.9 : 109.9,
userYAngle: isDisabledAnimations ? 140 : 130,
follXAngle: isDisabledAnimations ? 120 : 110,
follYAngle: isDisabledAnimations ? 161 : 151,
};
const hideStroke = parseBoolean(uiConfig.hideStroke) ? `` : `stroke="#${uiConfig.strokeColor}" stroke-width="5"`;
const hideBorder = parseBoolean(uiConfig.hideBorder) ? `` : `stroke="#${uiConfig.borderColor}" stroke-opacity="1" stroke-width="${uiConfig.borderWidth}"`;
const animations = parseBoolean(uiConfig.disabledAnimations || uiConfig.Format === "png") ? `` : `
/* Animations */
scaleInAnimation {
from {
transform: translate(-5px, 5px) scale(0);
}
to {
transform: translate(-5px, 5px) scale(1);
}
}
fadeInAnimation {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
fadeLeftInAnimation {
from {
opacity: 0;
transform: translate(-90px, 10px);
}
to {
opacity: 1;
transform: translate(10px, 10px);
}
}
.div-animation {
animation: fadeLeftInAnimation 0.7s ease-in-out forwards;
}
.image-profile-animation {
animation: scaleInAnimation 1.2s ease-in-out forwards;
transform-origin: ${angle.imageXAngle}px ${angle.imageYAngle}px;
}
.single-item-animation {
opacity: 0;
animation: fadeInAnimation 0.3s ease-in-out forwards;
}`;
const hiddenItems = uiConfig.hiddenItems || "";
const hiddenItemsArray = hiddenItems.split(",");
const showItems = uiConfig.showItems || "";
const showItemsArray = showItems.split(",");
const cardItems = [
{ text: selectLocale.totalReposText || defaultLocale.totalReposText, value: data.public_repos, icon: icons.repository, hidden: hiddenItemsArray.includes("repos") },
{ text: selectLocale.starsCountText || defaultLocale.starsCountText, value: data.total_stars, icon: icons.star, hidden: hiddenItemsArray.includes("stars") },
{ text: selectLocale.forksCountText || defaultLocale.forksCountText, value: data.total_forks, icon: icons.fork, hidden: hiddenItemsArray.includes("forks") },
{ text: selectLocale.commitsCountText || defaultLocale.commitsCountText, value: data.total_commits, icon: icons.commit, hidden: hiddenItemsArray.includes("commits") },
{ text: selectLocale.totalPRText || defaultLocale.totalPRText, value: data.total_prs, icon: icons.pull_request, hidden: hiddenItemsArray.includes("prs") },
{ text: selectLocale.totalPRMergedText || defaultLocale.totalPRMergedText, value: data.total_prs_merged, icon: icons.pull_request_merged, hidden: hiddenItemsArray.includes("prs_merged") },
{ text: selectLocale.totalPRReviewedText || defaultLocale.totalPRReviewedText, value: data.total_review, icon: icons.review, hidden: !showItemsArray.includes("reviews") },
{ text: selectLocale.totalIssuesText || defaultLocale.totalIssuesText, value: data.total_issues, icon: icons.issue, hidden: hiddenItemsArray.includes("issues") },
{ text: selectLocale.totalIssuesClosedText || defaultLocale.totalIssuesClosedText, value: data.total_closed_issues, icon: icons.issue_closed, hidden: !showItemsArray.includes("issues_closed") },
{ text: selectLocale.totalDiscussionStartedText || defaultLocale.totalDiscussionStartedText, value: data.total_discussion_started, icon: icons.discussion_started, hidden: !showItemsArray.includes("discussions_started") },
{ text: selectLocale.totalDiscussionAnsweredText || defaultLocale.totalDiscussionAnsweredText, value: data.total_discussion_answered, icon: icons.discussion_answered, hidden: !showItemsArray.includes("discussions_answered") },
{ text: selectLocale.contributedToText || defaultLocale.contributedToText, value: data.total_contributed_to, icon: icons.contributed_to, hidden: hiddenItemsArray.includes("contributed") },
];
const cardItemsToShow = cardItems.filter(item => !item.hidden);
const cardItemsSVG = cardItemsToShow.map((item, index) => `
<g transform="translate(230, ${15 + index * 25})">
<g class="single-item-animation" style="animation-delay: ${210 + index * 100}ms" transform="translate(25, 0)">
<svg x="${angle.iconXAngle}" y="0" class="icon" viewBox="0 0 16 16" version="1.1" width="16" height="16">
${item.icon}
</svg>
<text class="text" x="${angle.textXAngle}" y="12.5">${item.text}:</text>
<text class="text text-bold" x="${angle.dataXAngle}" y="12.5">${item.value}</text>
</g>
</g>`).join("\n");
function generateGradient(colors: string[]): string {
const gradientId = "gradient";
const gradientAngle = colors[0];
const getColors = colors.slice(1);
const gradientStops = getColors.map((color, index) => {
const offset = (index * 100) / (getColors.length - 1);
return `<stop offset="${offset}%" stop-color="#${color}"/>`;
}).join("");
return `
<defs>
<linearGradient id="${gradientId}" gradientTransform="rotate(${gradientAngle})" gradientUnits="userSpaceOnUse">
${gradientStops}
</linearGradient>
</defs>
<rect x="0.5" y="0.5" rx="${uiConfig.borderRadius}" height="99.4%" width="99.8%" fill="url(#${gradientId})" ${hideBorder}/>`;
}
let backgroundSVG;
if (uiConfig.bgColor) {
if (Array.isArray(uiConfig.bgColor)) {
backgroundSVG = generateGradient(uiConfig.bgColor);
} else if (typeof uiConfig.bgColor === 'string') {
const gradientHexArray = uiConfig.bgColor.split(',');
if (gradientHexArray.length >= 2) {
const gradientColors = gradientHexArray.map(color => color.trim());
backgroundSVG = generateGradient(gradientColors);
} else {
backgroundSVG = `
<rect x="0.5" y="0.5" rx="${uiConfig.borderRadius}" height="99.4%" width="99.8%" fill="#${uiConfig.bgColor}" ${hideBorder}/>`;
}
}
}
return `
<svg width="535" height="${Math.max(220, 45 + cardItemsToShow.length * 25)}" direction="${direction}" viewBox="0 0 535 ${Math.max(220, 45 + cardItemsToShow.length * 25)}" fill="none" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<style>
${animations}
.text {
font-family: "Segoe UI", Ubuntu, sans-serif;
fill: #${uiConfig.textColor};
font-size: 14px;
}
.text-bold {
font-weight: 700;
}
.text-middle {
alignment-baseline: middle;
text-anchor: middle;
}
.text-followers {
font-family: "Segoe UI", Ubuntu, sans-serif;
fill: #${uiConfig.textColor};
font-size: 13px;
}
.text-username {
font-family: "Segoe UI", Ubuntu, sans-serif;
fill: #${uiConfig.usernameColor};
font-weight: 750;
font-size: 14.6px;
alignment-baseline: middle;
text-anchor: middle;
}
.text-title {
font-family: "Segoe UI", Ubuntu, sans-serif;
fill: #${uiConfig.titleColor};
font-size: 17px;
font-weight: 600;
}
.icon {
fill: #${uiConfig.iconColor};
display: block;
}
</style>
<title id="titleId">${selectLocale.titleCard.split("{name}").join(data.name) || defaultLocale.titleCard.split("{name}").join(data.name)}</title>
${backgroundSVG}
<g transform="translate(0, 25)">
<g class="div-animation">
<text x="${angle.titleXAngle}" y="${angle.titleYAngle}" class="text-title">${selectLocale.titleCard.split("{name}").join(data.name) || defaultLocale.titleCard.split("{name}").join(data.name)}</text>
</g>
<g class="image-profile-animation">
<defs>
<pattern id="image" x="0%" y="0%" height="100%" width="100%" viewBox="0 0 512 512">
<image x="0%" y="0%" width="512" height="512" href="data:image/jpeg;base64,${data.picture}"></image>
</pattern>
</defs>
<circle cx="${angle.imageXAngle}" cy="${angle.imageYAngle}" r="50" fill="url(#image)" ${hideStroke}/>
</g>
<text x="${angle.userXAngle}" y="${angle.userYAngle}" direction="ltr" class="text-username div-animation">@${data.username}</text>
<g class="div-animation text-middle">
<text x="${angle.follXAngle}" y="${angle.follYAngle}" class="text-followers"><tspan class="text-bold">${data.followers}</tspan> ${selectLocale.followersText || defaultLocale.followersText} · <tspan class="text-bold">${data.following}</tspan> ${selectLocale.followingText || defaultLocale.followingText}</text>
</g>
${cardItemsSVG}
</g>
</svg>`;
}
export default card;