diff --git a/api/top-langs.js b/api/top-langs.js
new file mode 100644
index 0000000..026705c
--- /dev/null
+++ b/api/top-langs.js
@@ -0,0 +1,53 @@
+require("dotenv").config();
+const {
+ renderError,
+ clampValue,
+ parseBoolean,
+ CONSTANTS,
+} = require("../src/utils");
+const fetchTopLanguages = require("../src/fetchTopLanguages");
+const renderTopLanguages = require("../src/renderTopLanguages");
+
+module.exports = async (req, res) => {
+ const {
+ username,
+ hide_langs_below,
+ hide_title,
+ card_width,
+ title_color,
+ text_color,
+ bg_color,
+ theme,
+ cache_seconds,
+ } = req.query;
+ let topLangs;
+
+ res.setHeader("Content-Type", "image/svg+xml");
+
+ try {
+ topLangs = await fetchTopLanguages(username);
+ } catch (err) {
+ return res.send(renderError(err.message));
+ }
+
+ const cacheSeconds = clampValue(
+ parseInt(cache_seconds || CONSTANTS.THIRTY_MINUTES, 10),
+ CONSTANTS.THIRTY_MINUTES,
+ CONSTANTS.ONE_DAY
+ );
+
+ res.setHeader("Cache-Control", `public, max-age=${cacheSeconds}`);
+
+ res.send(
+ renderTopLanguages(topLangs, {
+ theme,
+ hide_title: parseBoolean(hide_title),
+ card_width: parseInt(card_width, 10),
+ hide_langs_below: parseFloat(hide_langs_below, 10),
+ title_color,
+ text_color,
+ bg_color,
+ theme,
+ })
+ );
+};
diff --git a/readme.md b/readme.md
index c7012e1..0719d2e 100644
--- a/readme.md
+++ b/readme.md
@@ -35,6 +35,7 @@
- [GitHub Stats Card](#github-stats-card)
- [GitHub Extra Pins](#github-extra-pins)
+- [Top Languages Card](#top-languages-card)
- [Themes](#themes)
- [Customization](#customization)
- [Deploy Yourself](#deploy-on-your-own-vercel-instance)
@@ -93,27 +94,80 @@ You can customize the appearance of your `Stats Card` or `Repo Card` however you
Customization Options:
-| Option | type | description | Stats Card (default) | Repo Card (default) |
-| ------------- | --------- | ------------------------------------ | -------------------- | ------------------- |
-| title_color | hex color | title color | 2f80ed | 2f80ed |
-| text_color | hex color | body color | 333 | 333 |
-| icon_color | hex color | icon color | 4c71f2 | 586069 |
-| bg_color | hex color | card bg color | FFFEFE | FFFEFE |
-| line_height | number | control the line-height between text | 30 | N/A |
-| hide_rank | boolean | hides the ranking | false | N/A |
-| hide_title | boolean | hides the stats title | false | N/A |
-| hide_border | boolean | hides the stats card border | false | N/A |
-| show_owner | boolean | shows owner name in repo card | N/A | false |
-| show_icons | boolean | shows icons | false | N/A |
-| theme | string | sets inbuilt theme | 'default' | 'default_repocard' |
-| cache_seconds | number | manually set custom cache control | 1800 | 1800 |
+| Option | type | description | Stats Card (default) | Repo Card (default) | Top Lang Card (default) |
+| ---------------- | --------- | ---------------------------------------------- | -------------------- | ------------------- | ----------------------- |
+| title_color | hex color | title color | 2f80ed | 2f80ed | 2f80ed |
+| text_color | hex color | body color | 333 | 333 | 333 |
+| icon_color | hex color | icon color | 4c71f2 | 586069 | 586069 |
+| bg_color | hex color | card bg color | FFFEFE | FFFEFE | FFFEFE |
+| line_height | number | control the line-height between text | 30 | N/A | N/A |
+| hide_rank | boolean | hides the ranking | false | N/A | N/A |
+| hide_title | boolean | hides the stats title | false | N/A | false |
+| hide_border | boolean | hides the stats card border | false | N/A | N/A |
+| show_owner | boolean | shows owner name in repo card | N/A | false | N/A |
+| show_icons | boolean | shows icons | false | N/A | N/A |
+| theme | string | sets inbuilt theme | 'default' | 'default_repocard' | 'default |
+| cache_seconds | number | manually set custom cache control | 1800 | 1800 | '1800' |
+| hide_langs_below | number | hide langs below certain threshold (lang card) | N/A | N/A | undefined |
> Note on cache: Repo cards have default cache of 30mins (1800 seconds) if the fork count & star count is less than 1k otherwise it's 2hours (7200). Also note that cache is clamped to minimum of 30min and maximum of 24hours
----
+# GitHub Extra Pins
+
+GitHub extra pins allow you to pin more than 6 repositories in your profile using a GitHub readme profile.
+
+Yey! You are no longer limited to 6 pinned repositories.
+
+### Usage
+
+Copy-paste this code into your readme and change the links.
+
+Endpoint: `api/pin?username=anuraghazra&repo=github-readme-stats`
+
+```md
+[](https://github.com/anuraghazra/github-readme-stats)
+```
### Demo
+[](https://github.com/anuraghazra/github-readme-stats)
+
+Use [show_owner](#customization) variable to include the repo's owner username
+
+[](https://github.com/anuraghazra/github-readme-stats)
+
+# Top Languages Card
+
+Top languages card shows github user's top langauges which has been mostly used.
+
+_NOTE: Top languages does not indicate my skill level or something like that, it's a github metric of which languages i have the most code on github, it's a new feature of github-readme-stats_
+
+### Usage
+
+Copy-paste this code into your readme and change the links.
+
+Endpoint: `api/top-langs?username=anuraghazra`
+
+```md
+[](https://github.com/anuraghazra/github-readme-stats)
+```
+
+### Hide languages below certain threshold
+
+You can use `?hide_langs_below=NUMBER` parameter to hide languages below a specified threshold percentage.
+
+```md
+[](https://github.com/anuraghazra/github-readme-stats)
+```
+
+### Demo
+
+[](https://github.com/anuraghazra/github-readme-stats)
+
+---
+
+### All Demos
+
- Default

@@ -140,32 +194,12 @@ Choose from any of the [default themes](#themes)

+- Top languages
+
+[](https://github.com/anuraghazra/github-readme-stats)
+
---
-# GitHub Extra Pins
-
-GitHub extra pins allow you to pin more than 6 repositories in your profile using a GitHub readme profile.
-
-Yey! You are no longer limited to 6 pinned repositories.
-
-### Usage
-
-Copy-paste this code into your readme and change the links.
-
-Endpoint: `api/pin?username=anuraghazra&repo=github-readme-stats`
-
-```md
-[](https://github.com/anuraghazra/github-readme-stats)
-```
-
-### Demo
-
-[](https://github.com/anuraghazra/github-readme-stats)
-
-Use [show_owner](#customization) variable to include the repo's owner username
-
-[](https://github.com/anuraghazra/github-readme-stats)
-
### Quick Tip (Align The Repo Cards)
You usually won't be able to layout the images side by side. To do that you can use this approach:
diff --git a/src/fetchTopLanguages.js b/src/fetchTopLanguages.js
new file mode 100644
index 0000000..95c9b48
--- /dev/null
+++ b/src/fetchTopLanguages.js
@@ -0,0 +1,84 @@
+const { request } = require("./utils");
+const retryer = require("./retryer");
+require("dotenv").config();
+
+const fetcher = (variables, token) => {
+ return request(
+ {
+ query: `
+ query userInfo($login: String!) {
+ user(login: $login) {
+ repositories(isFork: false, first: 100) {
+ nodes {
+ languages(first: 1) {
+ edges {
+ size
+ node {
+ color
+ name
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ `,
+ variables,
+ },
+ {
+ Authorization: `bearer ${token}`,
+ }
+ );
+};
+
+async function fetchTopLanguages(username) {
+ if (!username) throw Error("Invalid username");
+
+ let res = await retryer(fetcher, { login: username });
+
+ if (res.data.errors) {
+ console.log(res.data.errors);
+ throw Error(res.data.errors[0].message || "Could not fetch user");
+ }
+
+ let repoNodes = res.data.data.user.repositories.nodes;
+
+ // TODO: perf improvement
+ repoNodes = repoNodes
+ .filter((node) => {
+ return node.languages.edges.length > 0;
+ })
+ .sort((a, b) => {
+ return b.languages.edges[0].size - a.languages.edges[0].size;
+ })
+ .map((node) => {
+ return node.languages.edges[0];
+ })
+ .reduce((acc, prev) => {
+ let langSize = prev.size;
+ if (acc[prev.node.name] && prev.node.name === acc[prev.node.name].name) {
+ langSize = prev.size + acc[prev.node.name].size;
+ }
+
+ return {
+ ...acc,
+ [prev.node.name]: {
+ name: prev.node.name,
+ color: prev.node.color,
+ size: langSize,
+ },
+ };
+ }, {});
+
+ const topLangs = Object.keys(repoNodes)
+ .slice(0, 5)
+ .reduce((result, key) => {
+ result[key] = repoNodes[key];
+ return result;
+ }, {});
+
+ return topLangs;
+}
+
+module.exports = fetchTopLanguages;
diff --git a/src/renderTopLanguages.js b/src/renderTopLanguages.js
new file mode 100644
index 0000000..a233a09
--- /dev/null
+++ b/src/renderTopLanguages.js
@@ -0,0 +1,96 @@
+const { getCardColors, FlexLayout, clampValue } = require("../src/utils");
+
+const createProgressNode = ({ width, color, name, progress }) => {
+ const paddingRight = 95;
+ const progressTextX = width - paddingRight + 10;
+ const progressWidth = width - paddingRight;
+ const progressPercentage = clampValue(progress, 2, 100);
+
+ return `
+ ${name}
+ ${progress}%
+
+ `;
+};
+
+const renderTopLanguages = (topLangs, options = {}) => {
+ const {
+ hide_title,
+ card_width,
+ title_color,
+ text_color,
+ bg_color,
+ hide_langs_below,
+ theme,
+ } = options;
+
+ let langs = Object.values(topLangs);
+
+ const totalSize = langs.reduce((acc, curr) => {
+ return acc + curr.size;
+ }, 0);
+
+ // hide langs
+ langs = langs.filter((lang) => {
+ if (!hide_langs_below) return true;
+ return (lang.size / totalSize) * 100 > hide_langs_below;
+ });
+
+ // returns theme based colors with proper overrides and defaults
+ const { titleColor, textColor, bgColor } = getCardColors({
+ title_color,
+ text_color,
+ bg_color,
+ theme,
+ });
+
+ const width = isNaN(card_width) ? 300 : card_width;
+ let height = 45 + (langs.length + 1) * 40;
+
+ if (hide_title) {
+ height -= 30;
+ }
+ return `
+
+ `;
+};
+
+module.exports = renderTopLanguages;
diff --git a/tests/fetchTopLanguages.test.js b/tests/fetchTopLanguages.test.js
new file mode 100644
index 0000000..9f90e77
--- /dev/null
+++ b/tests/fetchTopLanguages.test.js
@@ -0,0 +1,84 @@
+require("@testing-library/jest-dom");
+const axios = require("axios");
+const MockAdapter = require("axios-mock-adapter");
+const fetchTopLanguages = require("../src/fetchTopLanguages");
+
+const mock = new MockAdapter(axios);
+
+afterEach(() => {
+ mock.reset();
+});
+
+const data_langs = {
+ data: {
+ user: {
+ repositories: {
+ nodes: [
+ {
+ languages: {
+ edges: [{ size: 100, node: { color: "#0f0", name: "HTML" } }],
+ },
+ },
+ {
+ languages: {
+ edges: [{ size: 100, node: { color: "#0f0", name: "HTML" } }],
+ },
+ },
+ {
+ languages: {
+ edges: [
+ { size: 100, node: { color: "#0ff", name: "javascript" } },
+ ],
+ },
+ },
+ {
+ 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", async () => {
+ mock.onPost("https://api.github.com/graphql").reply(200, data_langs);
+
+ let repo = await fetchTopLanguages("anuraghazra");
+ expect(repo).toStrictEqual({
+ HTML: {
+ color: "#0f0",
+ name: "HTML",
+ size: 200,
+ },
+ javascript: {
+ color: "#0ff",
+ name: "javascript",
+ size: 200,
+ },
+ });
+ });
+
+ it("should throw error", 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'."
+ );
+ });
+});
diff --git a/tests/renderTopLanguages.test.js b/tests/renderTopLanguages.test.js
new file mode 100644
index 0000000..17e07c5
--- /dev/null
+++ b/tests/renderTopLanguages.test.js
@@ -0,0 +1,202 @@
+require("@testing-library/jest-dom");
+const cssToObject = require("css-to-object");
+const renderTopLanguages = require("../src/renderTopLanguages");
+
+const {
+ getByTestId,
+ queryByTestId,
+ queryAllByTestId,
+} = require("@testing-library/dom");
+const themes = require("../themes");
+
+describe("Test renderTopLanguages", () => {
+ const langs = {
+ HTML: {
+ color: "#0f0",
+ name: "HTML",
+ size: 200,
+ },
+ javascript: {
+ color: "#0ff",
+ name: "javascript",
+ size: 200,
+ },
+ css: {
+ color: "#ff0",
+ name: "css",
+ size: 100,
+ },
+ };
+
+ it("should render correctly", () => {
+ document.body.innerHTML = renderTopLanguages(langs);
+
+ expect(queryByTestId(document.body, "header")).toHaveTextContent(
+ "Top Languages"
+ );
+
+ expect(queryAllByTestId(document.body, "lang-name")[0]).toHaveTextContent(
+ "HTML"
+ );
+ expect(queryAllByTestId(document.body, "lang-name")[1]).toHaveTextContent(
+ "javascript"
+ );
+ expect(queryAllByTestId(document.body, "lang-name")[2]).toHaveTextContent(
+ "css"
+ );
+ expect(queryAllByTestId(document.body, "lang-progress")[0]).toHaveAttribute(
+ "width",
+ "40%"
+ );
+ expect(queryAllByTestId(document.body, "lang-progress")[1]).toHaveAttribute(
+ "width",
+ "40%"
+ );
+ expect(queryAllByTestId(document.body, "lang-progress")[2]).toHaveAttribute(
+ "width",
+ "20%"
+ );
+ });
+
+ it("should hide_langs_below", () => {
+ document.body.innerHTML = renderTopLanguages(langs, {
+ hide_langs_below: 34,
+ });
+
+ expect(queryAllByTestId(document.body, "lang-name")[0]).toBeInTheDocument(
+ "HTML"
+ );
+ expect(queryAllByTestId(document.body, "lang-name")[1]).toBeInTheDocument(
+ "javascript"
+ );
+ expect(queryAllByTestId(document.body, "lang-name")[2]).not.toBeDefined();
+ });
+
+ it("should resize the height correctly depending on langs", () => {
+ document.body.innerHTML = renderTopLanguages(langs, {});
+ expect(document.querySelector("svg")).toHaveAttribute("height", "205");
+
+ document.body.innerHTML = renderTopLanguages(
+ {
+ ...langs,
+ python: {
+ color: "#ff0",
+ name: "python",
+ size: 100,
+ },
+ },
+ {}
+ );
+ expect(document.querySelector("svg")).toHaveAttribute("height", "245");
+ });
+
+ it("should hide_title", () => {
+ document.body.innerHTML = renderTopLanguages(langs, { hide_title: false });
+ expect(document.querySelector("svg")).toHaveAttribute("height", "205");
+ expect(queryByTestId(document.body, "lang-items")).toHaveAttribute(
+ "y",
+ "55"
+ );
+
+ // Lets hide now
+ document.body.innerHTML = renderTopLanguages(langs, { hide_title: true });
+ expect(document.querySelector("svg")).toHaveAttribute("height", "175");
+
+ expect(queryByTestId(document.body, "header")).not.toBeInTheDocument();
+ expect(queryByTestId(document.body, "lang-items")).toHaveAttribute(
+ "y",
+ "25"
+ );
+ });
+
+ it("should render with custom width set", () => {
+ document.body.innerHTML = renderTopLanguages(langs, {});
+
+ expect(document.querySelector("svg")).toHaveAttribute("width", "300");
+
+ document.body.innerHTML = renderTopLanguages(langs, { card_width: 400 });
+ expect(document.querySelector("svg")).toHaveAttribute("width", "400");
+ });
+
+ it("should render default colors properly", () => {
+ document.body.innerHTML = renderTopLanguages(langs);
+
+ const styleTag = document.querySelector("style");
+ const stylesObject = cssToObject(styleTag.textContent);
+
+ const headerStyles = stylesObject[".header"];
+ const langNameStyles = stylesObject[".lang-name"];
+
+ expect(headerStyles.fill).toBe("#2f80ed");
+ expect(langNameStyles.fill).toBe("#333");
+ 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 = renderTopLanguages(langs, { ...customColors });
+
+ const styleTag = document.querySelector("style");
+ const stylesObject = cssToObject(styleTag.innerHTML);
+
+ const headerStyles = stylesObject[".header"];
+ const langNameStyles = stylesObject[".lang-name"];
+
+ expect(headerStyles.fill).toBe(`#${customColors.title_color}`);
+ expect(langNameStyles.fill).toBe(`#${customColors.text_color}`);
+ expect(queryByTestId(document.body, "card-bg")).toHaveAttribute(
+ "fill",
+ "#252525"
+ );
+ });
+
+ it("should render custom colors with themes", () => {
+ document.body.innerHTML = renderTopLanguages(langs, {
+ title_color: "5a0",
+ theme: "radical",
+ });
+
+ const styleTag = document.querySelector("style");
+ const stylesObject = cssToObject(styleTag.innerHTML);
+
+ const headerStyles = stylesObject[".header"];
+ const langNameStyles = stylesObject[".lang-name"];
+
+ expect(headerStyles.fill).toBe("#5a0");
+ expect(langNameStyles.fill).toBe(`#${themes.radical.text_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 = renderTopLanguages(langs, {
+ theme: name,
+ });
+
+ const styleTag = document.querySelector("style");
+ const stylesObject = cssToObject(styleTag.innerHTML);
+
+ const headerStyles = stylesObject[".header"];
+ const langNameStyles = stylesObject[".lang-name"];
+
+ expect(headerStyles.fill).toBe(`#${themes[name].title_color}`);
+ expect(langNameStyles.fill).toBe(`#${themes[name].text_color}`);
+ expect(queryByTestId(document.body, "card-bg")).toHaveAttribute(
+ "fill",
+ `#${themes[name].bg_color}`
+ );
+ });
+ });
+});
diff --git a/tests/top-langs.test.js b/tests/top-langs.test.js
new file mode 100644
index 0000000..330acfa
--- /dev/null
+++ b/tests/top-langs.test.js
@@ -0,0 +1,142 @@
+require("@testing-library/jest-dom");
+const axios = require("axios");
+const MockAdapter = require("axios-mock-adapter");
+const topLangs = require("../api/top-langs");
+const renderTopLanguages = require("../src/renderTopLanguages");
+const { renderError } = require("../src/utils");
+
+const data_langs = {
+ data: {
+ user: {
+ repositories: {
+ nodes: [
+ {
+ languages: {
+ edges: [{ size: 100, node: { color: "#0f0", name: "HTML" } }],
+ },
+ },
+ {
+ languages: {
+ edges: [{ size: 100, node: { color: "#0f0", name: "HTML" } }],
+ },
+ },
+ {
+ languages: {
+ edges: [
+ { size: 100, node: { color: "#0ff", name: "javascript" } },
+ ],
+ },
+ },
+ {
+ languages: {
+ edges: [
+ { size: 100, node: { color: "#0ff", name: "javascript" } },
+ ],
+ },
+ },
+ ],
+ },
+ },
+ },
+};
+
+const error = {
+ errors: [
+ {
+ type: "NOT_FOUND",
+ path: ["user"],
+ locations: [],
+ message: "Could not fetch user",
+ },
+ ],
+};
+
+const langs = {
+ HTML: {
+ color: "#0f0",
+ name: "HTML",
+ size: 200,
+ },
+ javascript: {
+ color: "#0ff",
+ name: "javascript",
+ size: 200,
+ },
+};
+
+const mock = new MockAdapter(axios);
+
+afterEach(() => {
+ mock.reset();
+});
+
+describe("Test /api/top-langs", () => {
+ it("should test the request", async () => {
+ const req = {
+ query: {
+ username: "anuraghazra",
+ },
+ };
+ const res = {
+ setHeader: jest.fn(),
+ send: jest.fn(),
+ };
+ mock.onPost("https://api.github.com/graphql").reply(200, data_langs);
+
+ await topLangs(req, res);
+
+ expect(res.setHeader).toBeCalledWith("Content-Type", "image/svg+xml");
+ expect(res.send).toBeCalledWith(renderTopLanguages(langs));
+ });
+
+ it("should work with the query options", async () => {
+ const req = {
+ query: {
+ username: "anuraghazra",
+ hide_title: true,
+ card_width: 100,
+ title_color: "fff",
+ icon_color: "fff",
+ text_color: "fff",
+ bg_color: "fff",
+ },
+ };
+ const res = {
+ setHeader: jest.fn(),
+ send: jest.fn(),
+ };
+ mock.onPost("https://api.github.com/graphql").reply(200, data_langs);
+
+ await topLangs(req, res);
+
+ expect(res.setHeader).toBeCalledWith("Content-Type", "image/svg+xml");
+ expect(res.send).toBeCalledWith(
+ renderTopLanguages(langs, {
+ hide_title: true,
+ card_width: 100,
+ title_color: "fff",
+ icon_color: "fff",
+ text_color: "fff",
+ bg_color: "fff",
+ })
+ );
+ });
+
+ it("should render error card on error", async () => {
+ const req = {
+ query: {
+ username: "anuraghazra",
+ },
+ };
+ const res = {
+ setHeader: jest.fn(),
+ send: jest.fn(),
+ };
+ mock.onPost("https://api.github.com/graphql").reply(200, error);
+
+ await topLangs(req, res);
+
+ expect(res.setHeader).toBeCalledWith("Content-Type", "image/svg+xml");
+ expect(res.send).toBeCalledWith(renderError(error.errors[0].message));
+ });
+});