@fajarkim/github-readme-profile
Version:
๐ Generate dynamic GitHub stats cards in SVG format. Fast, customizable, and ready to embed in your profile README.
339 lines (313 loc) โข 13.9 kB
text/typescript
import sharp from "sharp";
import { minify } from "html-minifier-terser";
import parseBoolean from "@barudakrosul/parse-boolean";
import type { GetData } from "./getData";
import type { UiConfig } from "../api/index";
import locales from "../i18n/index";
import icons from "./icons";
/**
* Processes a profile picture: resizes it, converts it to JPEG, and returns the base64 encoding.
*
* @param {Buffer | string} picture - A Buffer or base64 string representing the image.
* @param {number} quality - JPEG quality (0-100).
* @param {number} size - Image width.
* @returns {Promise<string>} - Base64 string of the processed image.
*/
async function processProfilePicture(
picture: Buffer | string,
quality: number,
size: number
): Promise<string> {
const imageBuffer = typeof picture === "string" ? Buffer.from(picture, "base64") : picture;
const outputBuffer = await sharp(imageBuffer)
.resize({ width: size })
.jpeg({ quality })
.toBuffer();
return outputBuffer.toString("base64");
}
/**
* Generates an SVG linear gradient element based on an array of colors.
* The first element is the rotation angle (e.g., โ45โ), and the rest are hex color codes.
*
* @param {string[]} colors - An array containing the angles and colors of the gradient.
* @param {UiConfig} uiConfig - UI configuration for obtaining the borderRadius.
* @param {string} hideBorder - Border attribute string or empty.
* @returns {string} - SVG markup for a linear gradient.
*/
function generateGradient(colors: string[], uiConfig: UiConfig, hideBorder: string): string {
const [angle, ...colorStops] = colors;
const stops = colorStops.map((color, i) => {
const offset = (i * 100) / (colorStops.length - 1);
return `<stop offset="${offset}%" stop-color="#${color}"/>`;
}).join("");
return `
<defs>
<linearGradient id="gradient" gradientTransform="rotate(${angle})" gradientUnits="userSpaceOnUse">
${stops}
</linearGradient>
</defs>
<rect x="0.5" y="0.5" rx="${uiConfig.borderRadius || 20}" height="99.4%" width="99.8%" fill="url(#gradient)" ${hideBorder}/>
`;
}
/**
* Creates an SVG background based on the uiConfig.bgColor configuration.
* Supports solid colors or linear gradients (with angles and colors).
*
* @param {UiConfig} uiConfig - UI configuration.
* @param {string} hideBorder - Border attribute string or empty.
* @param {number} borderRadius - Border radius.
* @returns {string} - SVG markup for the background.
*/
function buildBackground(
uiConfig: UiConfig,
hideBorder: string,
borderRadius: number
): string {
const { bgColor } = uiConfig;
if (!bgColor) return "";
if (Array.isArray(bgColor)) {
return generateGradient(bgColor, uiConfig, hideBorder);
}
const gradientParts = bgColor.split(",").map((c: string) => c.trim());
if (gradientParts.length >= 2) {
return generateGradient(gradientParts, uiConfig, hideBorder);
}
return `
<rect x="0.5" y="0.5" rx="${borderRadius}" height="99.4%" width="99.8%" fill="#${bgColor}" ${hideBorder}/>
`;
}
/**
* Calculates the position of all elements within a card based on direction (RTL/LTR),
* animation status, and revert mode.
*
* @param {boolean|undefined} isRtl - Whether to use right-to-left direction.
* @param {boolean|undefined} isAnimDisabled - Whether animation is disabled.
* @param {boolean|undefined} isRevert - Whether the layout is reversed (revert).
* @returns {Object} An object containing the X/Y coordinates for each element.
*/
function getPositions(
isRtl: boolean | undefined,
isAnimDisabled: boolean | undefined,
isRevert: boolean | undefined
) {
const configs = {
animated: {
title: { x: isRtl ? 510 : 5, y: -10 },
image: { x: isRevert ? 417 : 127, y: 65 },
user: { x: isRevert ? 402 : 112, y: 130 },
foll: { x: isRevert ? 402 : 112, y: 151 },
},
static: {
title: { x: isRtl ? 520 : 15, y: 0 },
image: { x: isRevert ? 412 : 122, y: 70 },
user: { x: isRevert ? 412 : 122, y: 140 },
foll: { x: isRevert ? 412 : 122, y: 161 },
},
};
const mode = isAnimDisabled ? "static" : "animated";
const config = configs[mode];
return {
titleX: config.title.x,
titleY: config.title.y,
textX: isRtl ? 225 : 20,
dataX: isRtl ? 25 : 220,
iconX: isRtl ? 235 : -5,
imageX: config.image.x,
imageY: config.image.y,
userX: config.user.x,
userY: config.user.y,
follX: config.foll.x,
follY: config.foll.y,
itemStatsX: isRevert ? (isRtl ? 10 : 0) : 230,
};
}
/**
* Creates a CSS animation block if animations are enabled.
*
* @param {boolean|undefined} isAnimDisabled - Whether animations are disabled.
* @param {number} imageX - The X coordinate of the profile image (relative to the transform origin).
* @param {number} imageY - The Y coordinate of the profile image (relative to the transform origin).
* @returns {string} - The CSS animation or an empty string.
*/
function getAnimationStyles(
isAnimDisabled: boolean | undefined,
imageX: number,
imageY: number
): string {
if (isAnimDisabled) return "";
return `
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: ${imageX}px ${imageY}px;
}
.single-item-animation {
opacity: 0;
animation: fadeInAnimation 0.3s ease-in-out forwards;
}
`;
}
/**
* Generates SVG markup for a GitHub statistics card.
*
* @param {GetData} data - GitHub user statistics data.
* @param {UiConfig} uiConfig - Card display configuration.
* @returns {Promise<string>} - SVG markup for the statistics card.
*/
export default async function card(data: GetData, uiConfig: UiConfig): Promise<string> {
const getLocale = (locale: string): string => {
return Object.keys(locales).includes(locale) ? locale : "en";
};
const createLocalizedLocale = (primaryLocale: string, fallbackLocale: string = "en") => {
const primary = locales[primaryLocale] || {};
const fallback = locales[fallbackLocale] || locales.en || {};
return { ...fallback, ...primary };
};
const activeLocale = getLocale(uiConfig.Locale);
const selectedLocale = createLocalizedLocale(activeLocale);
const isRtl = parseBoolean(selectedLocale.rtlDirection || false);
const isAnimDisabled = parseBoolean(uiConfig.disabledAnimations || uiConfig.Format === "png");
const isRevert = parseBoolean(uiConfig.Revert || false);
// Card title
const titleCard = (uiConfig.Title && uiConfig.Title !== "undefined")
? uiConfig.Title.split("{name}").join(data.name)
: (selectedLocale.titleCard).split("{name}").join(data.name);
// Profile picture process
const photoQuality = Number(uiConfig.photoQuality || 80);
const photoResize = Number(uiConfig.photoResize || 512);
const pictureBase64 = await processProfilePicture(data.picture, photoQuality, photoResize);
// Element position
const positions = getPositions(isRtl, isAnimDisabled, isRevert);
// Stroke and border
const hideStroke = parseBoolean(uiConfig.hideStroke || false) ? "" : `stroke="#${uiConfig.strokeColor}" stroke-width="5"`;
const hideBorder = parseBoolean(uiConfig.hideBorder || false) ? "" : `stroke="#${uiConfig.borderColor}" stroke-opacity="1" stroke-width="${uiConfig.borderWidth}"`;
const borderRadius = Number(uiConfig.borderRadius || 20);
// Background
const backgroundSVG = buildBackground(uiConfig, hideBorder, borderRadius);
// CSS animations
const animationsCSS = getAnimationStyles(isAnimDisabled, positions.imageX, positions.imageY);
// Statistical item
const hiddenSet = new Set((uiConfig.hiddenItems || "").split(",").filter(Boolean));
const showSet = new Set((uiConfig.showItems || "").split(",").filter(Boolean));
// List of all items that may be displayed
const itemsConfig = [
{ key: "repos", labelKey: "totalReposText", valueKey: "public_repos", icon: icons.repository, visible: !hiddenSet.has("repos") },
{ key: "stars", labelKey: "starsCountText", valueKey: "total_stars", icon: icons.star, visible: !hiddenSet.has("stars") },
{ key: "forks", labelKey: "forksCountText", valueKey: "total_forks", icon: icons.fork, visible: !hiddenSet.has("forks") },
{ key: "commits", labelKey: "commitsCountText", valueKey: "total_commits", icon: icons.commit, visible: !hiddenSet.has("commits") },
{ key: "prs", labelKey: "totalPRText", valueKey: "total_prs", icon: icons.pull_request, visible: !hiddenSet.has("prs") },
{ key: "prs_merged", labelKey: "totalPRMergedText", valueKey: "total_prs_merged", icon: icons.pull_request_merged, visible: !hiddenSet.has("prs_merged") },
{ key: "reviews", labelKey: "totalPRReviewedText", valueKey: "total_review", icon: icons.review, visible: showSet.has("reviews") },
{ key: "issues", labelKey: "totalIssuesText", valueKey: "total_issues", icon: icons.issue, visible: !hiddenSet.has("issues") },
{ key: "issues_closed", labelKey: "totalIssuesClosedText", valueKey: "total_closed_issues", icon: icons.issue_closed, visible: showSet.has("issues_closed") },
{ key: "discussions_started", labelKey: "totalDiscussionStartedText", valueKey: "total_discussion_started", icon: icons.discussion_started, visible: showSet.has("discussions_started") },
{ key: "discussions_answered", labelKey: "totalDiscussionAnsweredText", valueKey: "total_discussion_answered", icon: icons.discussion_answered, visible: showSet.has("discussions_answered") },
{ key: "contributed", labelKey: "contributedToText", valueKey: "total_contributed_to", icon: icons.contributed_to, visible: !hiddenSet.has("contributed") },
];
const visibleItems = itemsConfig.filter(item => item.visible);
const cardItemsSVG = visibleItems.map((item, idx) => {
const label = (selectedLocale as Record<string, string>)[item.labelKey];
const value = data[item.valueKey as keyof GetData];
return `
<g transform="translate(${positions.itemStatsX}, ${15 + idx * 25})">
<g class="single-item-animation" style="animation-delay: ${210 + idx * 100}ms" transform="translate(25, 0)">
<svg x="${positions.iconX}" y="0" class="icon" viewBox="0 0 16 16" version="1.1" width="16" height="16">
${item.icon}
</svg>
<text class="text" x="${positions.textX}" y="12.5">${label}:</text>
<text class="text text-bold" x="${positions.dataX}" y="12.5">${value}</text>
</g>
</g>
`;
}).join("\n");
// Dynamic SVG height based on the number of items
const svgHeight = Math.max(220, 45 + visibleItems.length * 25);
const rawSVG = `
<svg width="535" height="${svgHeight}" direction="${isRtl ? "rtl" : "ltr"}" viewBox="0 0 535 ${svgHeight}" fill="none" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<style>
${animationsCSS}
.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">${titleCard}</title>
${backgroundSVG}
<g transform="translate(0, 25)">
<g class="div-animation">
<text x="${positions.titleX}" y="${positions.titleY}" class="text-title">${titleCard}</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,${pictureBase64}" />
</pattern>
</defs>
<circle cx="${positions.imageX}" cy="${positions.imageY}" r="50" fill="url(#image)" ${hideStroke} />
</g>
<text x="${positions.userX}" y="${positions.userY}" direction="ltr" class="text-username div-animation">@${data.username}</text>
<g class="div-animation text-middle">
<text x="${positions.follX}" y="${positions.follY}" class="text-followers">
<tspan class="text-bold">${data.followers}</tspan> ${selectedLocale.followersText} ยท
<tspan class="text-bold">${data.following}</tspan> ${selectedLocale.followingText}
</text>
</g>
${cardItemsSVG}
</g>
</svg>
`;
// Minify SVG
const minifiedSVG = await minify(rawSVG, {
collapseWhitespace: true,
conservativeCollapse: true,
removeComments: true,
removeEmptyAttributes: true,
removeRedundantAttributes: true,
useShortDoctype: true,
removeOptionalTags: false,
removeAttributeQuotes: false,
minifyCSS: true,
minifyJS: false,
});
return minifiedSVG;
}