diff --git a/api/gist.js b/api/gist.js
new file mode 100644
index 0000000..4ef153d
--- /dev/null
+++ b/api/gist.js
@@ -0,0 +1,81 @@
+import {
+ clampValue,
+ CONSTANTS,
+ renderError,
+ parseBoolean,
+} from "../src/common/utils.js";
+import { isLocaleAvailable } from "../src/translations.js";
+import { renderGistCard } from "../src/cards/gist-card.js";
+import { fetchGist } from "../src/fetchers/gist-fetcher.js";
+
+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,
+ } = req.query;
+
+ res.setHeader("Content-Type", "image/svg+xml");
+
+ if (locale && !isLocaleAvailable(locale)) {
+ return res.send(renderError("Something went wrong", "Language not found"));
+ }
+
+ try {
+ const gistData = await fetchGist(id);
+
+ let cacheSeconds = clampValue(
+ parseInt(cache_seconds || CONSTANTS.FOUR_HOURS, 10),
+ CONSTANTS.FOUR_HOURS,
+ CONSTANTS.ONE_DAY,
+ );
+ cacheSeconds = process.env.CACHE_SECONDS
+ ? parseInt(process.env.CACHE_SECONDS, 10) || cacheSeconds
+ : cacheSeconds;
+
+ /*
+ if star count & fork count is over 1k then we are kFormating the text
+ and if both are zero we are not showing the stats
+ so we can just make the cache longer, since there is no need to frequent updates
+ */
+ const stars = gistData.starsCount;
+ const forks = gistData.forksCount;
+ const isBothOver1K = stars > 1000 && forks > 1000;
+ const isBothUnder1 = stars < 1 && forks < 1;
+ if (!cache_seconds && (isBothOver1K || isBothUnder1)) {
+ cacheSeconds = CONSTANTS.FOUR_HOURS;
+ }
+
+ res.setHeader(
+ "Cache-Control",
+ `max-age=${
+ cacheSeconds / 2
+ }, s-maxage=${cacheSeconds}, stale-while-revalidate=${CONSTANTS.ONE_DAY}`,
+ );
+
+ 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),
+ }),
+ );
+ } catch (err) {
+ res.setHeader("Cache-Control", `no-cache, no-store, must-revalidate`); // Don't cache error responses.
+ return res.send(renderError(err.message, err.secondaryMessage));
+ }
+};
diff --git a/readme.md b/readme.md
index 47783a2..caffaca 100644
--- a/readme.md
+++ b/readme.md
@@ -97,8 +97,11 @@ Please visit [this link](https://give.do/fundraisers/stand-beside-the-victims-of
- [GitHub Extra Pins](#github-extra-pins)
- [Usage](#usage)
- [Demo](#demo)
-- [Top Languages Card](#top-languages-card)
+- [GitHub Gist Pins](#github-gist-pins)
- [Usage](#usage-1)
+ - [Demo](#demo-1)
+- [Top Languages Card](#top-languages-card)
+ - [Usage](#usage-2)
- [Language stats algorithm](#language-stats-algorithm)
- [Exclude individual repositories](#exclude-individual-repositories)
- [Hide individual languages](#hide-individual-languages)
@@ -108,9 +111,9 @@ Please visit [this link](https://give.do/fundraisers/stand-beside-the-victims-of
- [Donut Vertical Chart Language Card Layout](#donut-vertical-chart-language-card-layout)
- [Pie Chart Language Card Layout](#pie-chart-language-card-layout)
- [Hide Progress Bars](#hide-progress-bars)
- - [Demo](#demo-1)
-- [Wakatime Stats Card](#wakatime-stats-card)
- [Demo](#demo-2)
+- [Wakatime Stats Card](#wakatime-stats-card)
+ - [Demo](#demo-3)
- [All Demos](#all-demos)
- [Quick Tip (Align The Cards)](#quick-tip-align-the-cards)
- [Deploy on your own](#deploy-on-your-own)
@@ -328,6 +331,10 @@ You can provide multiple comma-separated values in the bg\_color option to rende
* `show_owner` - Shows the repo's owner name *(boolean)*. Default: `false`.
+#### Gist Card Exclusive Options
+
+* `show_owner` - Shows the gist's owner name *(boolean)*. Default: `false`.
+
#### Language Card Exclusive Options
* `hide` - Hides the languages specified from the card *(Comma-separated values)*. Default: `[] (blank array)`.
@@ -384,6 +391,28 @@ Use [show\_owner](#repo-card-exclusive-options) query option to include the repo

+# GitHub Gist Pins
+
+GitHub gist pins allow you to pin gists in your GitHub profile using a GitHub readme profile.
+
+### Usage
+
+Copy-paste this code into your readme and change the links.
+
+Endpoint: `api/gist?id=bbfce31e0217a3689c8d961a356cb10d`
+
+```md
+[](https://gist.github.com/Yizack/bbfce31e0217a3689c8d961a356cb10d/)
+```
+
+### Demo
+
+
+
+Use [show\_owner](#gist-card-exclusive-options) query option to include the gist's owner username
+
+
+
# Top Languages Card
The top languages card shows a GitHub user's most frequently used languages.
@@ -592,6 +621,14 @@ Choose from any of the [default themes](#themes)

+* Gist card
+
+
+
+* Customizing gist card
+
+
+
* Top languages

diff --git a/src/cards/gist-card.js b/src/cards/gist-card.js
new file mode 100644
index 0000000..895616c
--- /dev/null
+++ b/src/cards/gist-card.js
@@ -0,0 +1,180 @@
+// @ts-check
+
+import {
+ getCardColors,
+ parseEmojis,
+ wrapTextMultiline,
+ encodeHTML,
+ kFormatter,
+ measureText,
+ flexLayout,
+} from "../common/utils.js";
+import Card from "../common/Card.js";
+import { icons } from "../common/icons.js";
+
+/** Import language colors.
+ *
+ * @description Here we use the workaround found in
+ * https://stackoverflow.com/questions/66726365/how-should-i-import-json-in-node
+ * since vercel is using v16.14.0 which does not yet support json imports without the
+ * --experimental-json-modules flag.
+ */
+import { createRequire } from "module";
+const require = createRequire(import.meta.url);
+const languageColors = require("../common/languageColors.json"); // now works
+
+const ICON_SIZE = 16;
+const CARD_DEFAULT_WIDTH = 400;
+const HEADER_MAX_LENGTH = 35;
+
+/**
+ * Creates a node to display the primary programming language of the gist.
+ *
+ * @param {string} langName Language name.
+ * @param {string} langColor Language color.
+ * @returns {string} Language display SVG object.
+ */
+const createLanguageNode = (langName, langColor) => {
+ return `
+
+
+ ${langName}
+
+ `;
+};
+
+/**
+ * Creates an icon with label to display 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.
+ * @returns {string} Icon with label SVG object.
+ */
+const iconWithLabel = (icon, label, testid) => {
+ if (typeof label === "number" && label <= 0) return "";
+ const iconSvg = `
+
+ `;
+ const text = `${label}`;
+ return flexLayout({ items: [iconSvg, text], gap: 20 }).join("");
+};
+
+/**
+ * @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} 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,
+ border_radius,
+ border_color,
+ show_owner = 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) => `${encodeHTML(line)}`)
+ .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");
+ const svgForks = iconWithLabel(icons.fork, totalForks, "forksCount");
+
+ const languageName = language || "Unspecified";
+ 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} }
+ `);
+
+ return card.render(`
+
+ ${descriptionSvg}
+
+
+
+ ${starAndForkCount}
+
+ `);
+};
+
+export { renderGistCard, HEADER_MAX_LENGTH };
+export default renderGistCard;
diff --git a/src/cards/types.d.ts b/src/cards/types.d.ts
index a3f0b2b..3274d8c 100644
--- a/src/cards/types.d.ts
+++ b/src/cards/types.d.ts
@@ -57,3 +57,7 @@ type WakaTimeOptions = CommonOptions & {
layout: "compact" | "normal";
langs_count: number;
};
+
+export type GistCardOptions = CommonOptions & {
+ show_owner: boolean;
+};
diff --git a/src/common/icons.js b/src/common/icons.js
index d600c14..771704a 100644
--- a/src/common/icons.js
+++ b/src/common/icons.js
@@ -11,6 +11,7 @@ const icons = {
reviews: ``,
discussions_started: ``,
discussions_answered: ``,
+ gist: ``,
};
/**
diff --git a/src/fetchers/gist-fetcher.js b/src/fetchers/gist-fetcher.js
new file mode 100644
index 0000000..2afc62a
--- /dev/null
+++ b/src/fetchers/gist-fetcher.js
@@ -0,0 +1,106 @@
+// @ts-check
+
+import { request } from "../common/utils.js";
+import { retryer } from "../common/retryer.js";
+
+/**
+ * @typedef {import('axios').AxiosRequestHeaders} AxiosRequestHeaders Axios request headers.
+ * @typedef {import('axios').AxiosResponse} AxiosResponse Axios response.
+ */
+
+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 {AxiosRequestHeaders} variables Fetcher variables.
+ * @param {string} token GitHub token.
+ * @returns {Promise} The response.
+ */
+const fetcher = async (variables, token) => {
+ return await request(
+ { query: QUERY, variables },
+ { Authorization: `token ${token}` },
+ );
+};
+
+/**
+ * @typedef {import('./types').GistData} GistData Gist data.
+ */
+
+/**
+ * Fetch GitHub gist information by given username and ID.
+ *
+ * @param {string} id Github gist ID.
+ * @returns {Promise} Gist data.
+ */
+const fetchGist = async (id) => {
+ const res = await retryer(fetcher, { gistName: id });
+ if (res.data.errors) throw new Error(res.data.errors[0].message);
+ 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,
+ };
+};
+
+/**
+ * @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) => {
+ 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;
+};
+
+export { fetchGist };
+export default fetchGist;
diff --git a/src/fetchers/types.d.ts b/src/fetchers/types.d.ts
index b0cc2f4..73c390e 100644
--- a/src/fetchers/types.d.ts
+++ b/src/fetchers/types.d.ts
@@ -1,3 +1,12 @@
+export type GistData = {
+ name: string;
+ nameWithOwner: string;
+ description: string;
+ language: string | null;
+ starsCount: number;
+ forksCount: number;
+};
+
export type RepositoryData = {
name: string;
nameWithOwner: string;
diff --git a/tests/e2e/e2e.test.js b/tests/e2e/e2e.test.js
index e220d52..2ff55e7 100644
--- a/tests/e2e/e2e.test.js
+++ b/tests/e2e/e2e.test.js
@@ -9,10 +9,13 @@ import { renderRepoCard } from "../../src/cards/repo-card.js";
import { renderStatsCard } from "../../src/cards/stats-card.js";
import { renderTopLanguages } from "../../src/cards/top-languages-card.js";
import { renderWakatimeCard } from "../../src/cards/wakatime-card.js";
+import { renderGistCard } from "../../src/cards/gist-card.js";
import { expect, describe, beforeAll, test } from "@jest/globals";
const REPO = "curly-fiesta";
const USER = "catelinemnemosyne";
+const GIST_ID = "372cef55fd897b31909fdeb3a7262758";
+
const STATS_DATA = {
name: "Cateline Mnemosyne",
totalPRs: 2,
@@ -81,6 +84,23 @@ const REPOSITORY_DATA = {
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 Windown 10 ver. 1803+ will breaks NTFS",
+ language: "Text",
+ starsCount: 1,
+ forksCount: 0,
+};
+
const CACHE_BURST_STRING = `v=${new Date().getTime()}`;
describe("Fetch Cards", () => {
@@ -177,4 +197,26 @@ describe("Fetch Cards", () => {
// Check if Repo card from deployment matches the local Repo card.
expect(serverRepoSvg.data).toEqual(localRepoCardSVG);
}, 15000);
+
+ test("retrieve gist card", async () => {
+ expect(VERCEL_PREVIEW_URL).toBeDefined();
+
+ // 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}`,
+ ),
+ ).resolves.not.toThrow();
+
+ // Get local gist card.
+ const localGistCardSVG = renderGistCard(GIST_DATA);
+
+ // Get the Vercel preview gist card response.
+ const serverGistSvg = await axios.get(
+ `${VERCEL_PREVIEW_URL}/api/gist?id=${GIST_ID}&${CACHE_BURST_STRING}`,
+ );
+
+ // Check if Gist card from deployment matches the local Gist card.
+ expect(serverGistSvg.data).toEqual(localGistCardSVG);
+ }, 15000);
});
diff --git a/tests/fetchGist.test.js b/tests/fetchGist.test.js
new file mode 100644
index 0000000..2cdffcf
--- /dev/null
+++ b/tests/fetchGist.test.js
@@ -0,0 +1,72 @@
+import "@testing-library/jest-dom";
+import axios from "axios";
+import MockAdapter from "axios-mock-adapter";
+import { expect, it, describe, afterEach } from "@jest/globals";
+import { fetchGist } from "../src/fetchers/gist-fetcher.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,
+ },
+ ],
+ },
+ },
+ },
+};
+
+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: "JSON",
+ starsCount: 33,
+ forksCount: 11,
+ });
+ });
+
+ it("should throw error if reaponse 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",
+ );
+ });
+});
diff --git a/tests/renderGistCard.test.js b/tests/renderGistCard.test.js
new file mode 100644
index 0000000..2af6889
--- /dev/null
+++ b/tests/renderGistCard.test.js
@@ -0,0 +1,79 @@
+import { renderGistCard } from "../src/cards/gist-card";
+import { describe, expect, it } from "@jest/globals";
+import { queryByTestId } from "@testing-library/dom";
+import "@testing-library/jest-dom";
+
+/**
+ * @type {import("../src/fetchers/gist-fetcher").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",
+ );
+ });
+});