Repository: anuraghazra/github-readme-stats
Pull request #2414 (2023-01-15 01:48:44)

Update readme.md
fix: missing "&" in show_icons=true in Showing icons section

vercel[bot]: @raphaelricardo10 is attempting to deploy a commit to the **github readme stats** Team on [Vercel](https://vercel.com).

A member of the Team first needs to [authorize it](https://vercel.com/git/authorize?team=github%20readme%20stats&type=github&job=%7B%22headInfo%22%3A%7B%22sha%22%3A%22dec19fbfd21b8a1af843db072a252f095b660c8f%22%7D%2C%22id%22%3A%22QmeJcR4dMQwEsyVXPRJTBuaNXmqb9EvFXQx7RonCkJYtXx%22%2C%22org%22%3A%22anuraghazra%22%2C%22prId%22%3A2414%2C%22repo%22%3A%22github-readme-stats%22%7D).



Patch:
diff --git a/.github/labeler.yml b/.github/labeler.yml
index be97765..fad3eee 100644
--- a/.github/labeler.yml
+++ b/.github/labeler.yml
@@ -1,3 +1,4 @@
 themes: themes/index.js
 doc-translation: docs/*
 card-i18n: src/translations.js
+documentation: readme.md
diff --git a/.github/workflows/deploy-prep.py b/.github/workflows/deploy-prep.py
new file mode 100644
index 0000000..794c19a
--- /dev/null
+++ b/.github/workflows/deploy-prep.py
@@ -0,0 +1,10 @@
+import os
+
+file = open('./vercel.json', 'r')
+str = file.read()
+file = open('./vercel.json', 'w')
+
+str = str.replace('"maxDuration": 10', '"maxDuration": 30')
+
+file.write(str)
+file.close()
diff --git a/.github/workflows/deploy-prep.yml b/.github/workflows/deploy-prep.yml
new file mode 100644
index 0000000..0626e13
--- /dev/null
+++ b/.github/workflows/deploy-prep.yml
@@ -0,0 +1,20 @@
+name: Deployment Prep
+on:
+  workflow_dispatch:
+  push:
+    branches:
+      - master
+
+jobs:
+  config:
+    if: github.repository == 'anuraghazra/github-readme-stats'
+    runs-on: ubuntu-latest
+    steps:
+      - uses: actions/checkout@v3
+      - name: Deployment Prep
+        run: python ./.github/workflows/deploy-prep.py
+      - uses: stefanzweifel/git-auto-commit-action@v4
+        with:
+          branch: vercel
+          create_branch: true
+          push_options: "--force"
diff --git a/.github/workflows/empty-issues-closer.yaml b/.github/workflows/empty-issues-closer.yaml
index b20eef3..a65ea63 100644
--- a/.github/workflows/empty-issues-closer.yaml
+++ b/.github/workflows/empty-issues-closer.yaml
@@ -8,6 +8,7 @@ on:
 
 jobs:
   closeEmptyIssuesAndTemplates:
+    if: github.repository == 'anuraghazra/github-readme-stats'
     name: Close empty issues
     runs-on: ubuntu-latest
     steps:
diff --git a/.github/workflows/generate-theme-doc.yml b/.github/workflows/generate-theme-doc.yml
index d5fac06..75f6511 100644
--- a/.github/workflows/generate-theme-doc.yml
+++ b/.github/workflows/generate-theme-doc.yml
@@ -23,6 +23,10 @@ jobs:
           node-version: ${{ matrix.node-version }}
           cache: npm
 
+      # Fix the unsafe repo error which was introduced by the CVE-2022-24765 git patches.
+      - name: Fix unsafe repo error
+        run: git config --global --add safe.directory ${{ github.workspace }}
+
       - name: npm install, generate readme
         run: |
           npm ci
diff --git a/.github/workflows/label-pr.yml b/.github/workflows/label-pr.yml
index c401d32..03715bf 100644
--- a/.github/workflows/label-pr.yml
+++ b/.github/workflows/label-pr.yml
@@ -4,6 +4,7 @@ on:
 
 jobs:
   triage:
+    if: github.repository == 'anuraghazra/github-readme-stats'
     runs-on: ubuntu-latest
     steps:
       - uses: actions/labeler@v4
diff --git a/.github/workflows/stale-theme-pr-closer.yaml b/.github/workflows/stale-theme-pr-closer.yaml
index 9a62498..aa104fe 100644
--- a/.github/workflows/stale-theme-pr-closer.yaml
+++ b/.github/workflows/stale-theme-pr-closer.yaml
@@ -5,6 +5,7 @@ on:
 
 jobs:
   closeOldThemePrs:
+    if: github.repository == 'anuraghazra/github-readme-stats'
     name: Close stale 'invalid' theme PRs
     runs-on: ubuntu-latest
     strategy:
diff --git a/.github/workflows/top-issues-dashboard.yml b/.github/workflows/top-issues-dashboard.yml
index 2b36e52..3a9ec82 100644
--- a/.github/workflows/top-issues-dashboard.yml
+++ b/.github/workflows/top-issues-dashboard.yml
@@ -5,6 +5,7 @@ on:
 
 jobs:
   showAndLabelTopIssues:
+    if: github.repository == 'anuraghazra/github-readme-stats'
     name: Update top issues Dashboard.
     runs-on: ubuntu-latest
     steps:
diff --git a/.github/workflows/update-langs.yaml b/.github/workflows/update-langs.yaml
new file mode 100644
index 0000000..ad6bfb6
--- /dev/null
+++ b/.github/workflows/update-langs.yaml
@@ -0,0 +1,44 @@
+name: Update supported languages
+on:
+  schedule:
+    - cron: "0 0 */30 * *"
+
+jobs:
+  updateLanguages:
+    if: github.repository == 'anuraghazra/github-readme-stats'
+    name: Update supported languages
+    runs-on: ubuntu-latest
+    strategy:
+      matrix:
+        node-version: [16.x]
+
+    steps:
+      - uses: actions/checkout@v3
+
+      - name: Setup Node
+        uses: actions/setup-node@v3
+        with:
+          node-version: ${{ matrix.node-version }}
+          cache: npm
+
+      - name: Install dependencies
+        run: npm ci
+        env:
+          CI: true
+
+      - name: Run update-languages-json.js script
+        run: npm run generate-langs-json
+
+      - name: Create Pull Request if upstream language file is changed
+        uses: peter-evans/create-pull-request@v4
+        with:
+          commit-message: "refactor: update languages JSON"
+          branch: "update_langs/patch"
+          delete-branch: true
+          title: Update languages JSON
+          body:
+            "The
+            [update-langs](https://github.com/anuraghazra/github-readme-stats/actions/workflows/update-langs.yaml)
+            action found new/updated languages in the [upstream languages JSON
+            file](https://raw.githubusercontent.com/github/linguist/master/lib/linguist/languages.yml)."
+          labels: "ci, lang-card"
diff --git a/api/status/pat-info.js b/api/status/pat-info.js
new file mode 100644
index 0000000..775e068
--- /dev/null
+++ b/api/status/pat-info.js
@@ -0,0 +1,139 @@
+/**
+ * @file Contains a simple cloud function that can be used to check which PATs are no
+ * longer working. It returns a list of valid PATs, expired PATs and PATs with errors.
+ *
+ * @description This function is currently rate limited to 1 request per 10 minutes.
+ */
+
+import { logger, request, dateDiff } from "../../src/common/utils.js";
+export const RATE_LIMIT_SECONDS = 60 * 5; // 1 request per 10 minutes
+
+/**
+ * Simple uptime check fetcher for the PATs.
+ *
+ * @param {import('axios').AxiosRequestHeaders} variables
+ * @param {string} token
+ */
+const uptimeFetcher = (variables, token) => {
+  return request(
+    {
+      query: `
+        query {
+          rateLimit {
+            remaining
+            resetAt
+          },
+        }`,
+      variables,
+    },
+    {
+      Authorization: `bearer ${token}`,
+    },
+  );
+};
+
+const getAllPATs = () => {
+  return Object.keys(process.env).filter((key) => /PAT_\d*$/.exec(key));
+};
+
+/**
+ * Check whether any of the PATs is expired.
+ */
+const getPATInfo = async (fetcher, variables) => {
+  const details = {};
+  const PATs = getAllPATs();
+
+  for (const pat of PATs) {
+    try {
+      const response = await fetcher(variables, process.env[pat]);
+      const errors = response.data.errors;
+      const hasErrors = Boolean(errors);
+      const errorType = errors?.[0]?.type;
+      const isRateLimited =
+        (hasErrors && errorType === "RATE_LIMITED") ||
+        response.data.data?.rateLimit?.remaining === 0;
+
+      // Store PATs with errors.
+      if (hasErrors && errorType !== "RATE_LIMITED") {
+        details[pat] = {
+          status: "error",
+          error: {
+            type: errors[0].type,
+            message: errors[0].message,
+          },
+        };
+        continue;
+      } else if (isRateLimited) {
+        const date1 = new Date();
+        const date2 = new Date(response.data?.data?.rateLimit?.resetAt);
+        details[pat] = {
+          status: "exhausted",
+          remaining: 0,
+          resetIn: dateDiff(date2, date1) + " minutes",
+        };
+      } else {
+        details[pat] = {
+          status: "valid",
+          remaining: response.data.data.rateLimit.remaining,
+        };
+      }
+    } catch (err) {
+      // Store the PAT if it is expired.
+      const errorMessage = err.response?.data?.message?.toLowerCase();
+      if (errorMessage === "bad credentials") {
+        details[pat] = {
+          status: "expired",
+        };
+      } else if (errorMessage === "sorry. your account was suspended.") {
+        details[pat] = {
+          status: "suspended",
+        };
+      } else {
+        throw err;
+      }
+    }
+  }
+
+  const filterPATsByStatus = (status) => {
+    return Object.keys(details).filter((pat) => details[pat].status === status);
+  };
+
+  const sortedDetails = Object.keys(details)
+    .sort()
+    .reduce((obj, key) => {
+      obj[key] = details[key];
+      return obj;
+    }, {});
+
+  return {
+    validPATs: filterPATsByStatus("valid"),
+    expiredPATs: filterPATsByStatus("expired"),
+    exhaustedPATs: filterPATsByStatus("exhausted"),
+    suspendedPATs: filterPATsByStatus("suspended"),
+    errorPATs: filterPATsByStatus("error"),
+    details: sortedDetails,
+  };
+};
+
+/**
+ * Cloud function that returns information about the used PATs.
+ */
+export default async (_, res) => {
+  res.setHeader("Content-Type", "application/json");
+  try {
+    // Add header to prevent abuse.
+    const PATsInfo = await getPATInfo(uptimeFetcher, {});
+    if (PATsInfo) {
+      res.setHeader(
+        "Cache-Control",
+        `max-age=0, s-maxage=${RATE_LIMIT_SECONDS}`,
+      );
+    }
+    res.send(JSON.stringify(PATsInfo, null, 2));
+  } catch (err) {
+    // Throw error if something went wrong.
+    logger.error(err);
+    res.setHeader("Cache-Control", "no-store");
+    res.send("Something went wrong: " + err.message);
+  }
+};
diff --git a/api/status/up.js b/api/status/up.js
new file mode 100644
index 0000000..33fe8f9
--- /dev/null
+++ b/api/status/up.js
@@ -0,0 +1,103 @@
+/**
+ * @file Contains a simple cloud function that can be used to check if the PATs are still
+ * functional.
+ *
+ * @description This function is currently rate limited to 1 request per 10 minutes.
+ */
+
+import retryer from "../../src/common/retryer.js";
+import { logger, request } from "../../src/common/utils.js";
+
+export const RATE_LIMIT_SECONDS = 60 * 10; // 1 request per 10 minutes
+
+/**
+ * Simple uptime check fetcher for the PATs.
+ *
+ * @param {import('axios').AxiosRequestHeaders} variables
+ * @param {string} token
+ */
+const uptimeFetcher = (variables, token) => {
+  return request(
+    {
+      query: `
+        query {
+          rateLimit {
+              remaining
+          }
+        }
+        `,
+      variables,
+    },
+    {
+      Authorization: `bearer ${token}`,
+    },
+  );
+};
+
+/**
+ * Creates Json response that can be used for shields.io dynamic card generation.
+ *
+ * @param {*} up Whether the PATs are up or not.
+ * @returns Dynamic shields.io JSON response object.
+ *
+ * @see https://shields.io/endpoint.
+ */
+const shieldsUptimeBadge = (up) => {
+  const schemaVersion = 1;
+  const isError = true;
+  const label = "Public Instance";
+  const message = up ? "up" : "down";
+  const color = up ? "brightgreen" : "red";
+  return {
+    schemaVersion,
+    label,
+    message,
+    color,
+    isError,
+  };
+};
+
+/**
+ * Cloud function that returns whether the PATs are still functional.
+ */
+export default async (req, res) => {
+  let { type } = req.query;
+  type = type ? type.toLowerCase() : "boolean";
+
+  res.setHeader("Content-Type", "application/json");
+
+  try {
+    let PATsValid = true;
+    try {
+      await retryer(uptimeFetcher, {});
+    } catch (err) {
+      PATsValid = false;
+    }
+
+    if (PATsValid) {
+      res.setHeader(
+        "Cache-Control",
+        `max-age=0, s-maxage=${RATE_LIMIT_SECONDS}`,
+      );
+    } else {
+      res.setHeader("Cache-Control", "no-store");
+    }
+
+    switch (type) {
+      case "shields":
+        res.send(shieldsUptimeBadge(PATsValid));
+        break;
+      case "json":
+        res.send({ up: PATsValid });
+        break;
+      default:
+        res.send(PATsValid);
+        break;
+    }
+  } catch (err) {
+    // Return fail boolean if something went wrong.
+    logger.error(err);
+    res.setHeader("Cache-Control", "no-store");
+    res.send("Something went wrong: " + err.message);
+  }
+};
diff --git a/api/top-langs.js b/api/top-langs.js
index 19cccb8..e67d953 100644
--- a/api/top-langs.js
+++ b/api/top-langs.js
@@ -30,6 +30,7 @@ export default async (req, res) => {
     border_radius,
     border_color,
     disable_animations,
+    hide_progress,
   } = req.query;
   res.setHeader("Content-Type", "image/svg+xml");
 
@@ -77,6 +78,7 @@ export default async (req, res) => {
         border_color,
         locale: locale ? locale.toLowerCase() : null,
         disable_animations: parseBoolean(disable_animations),
+        hide_progress: parseBoolean(hide_progress),
       }),
     );
   } catch (err) {
diff --git a/package-lock.json b/package-lock.json
index 048c316..ebc7570 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -22,7 +22,7 @@
         "@testing-library/dom": "^8.17.1",
         "@testing-library/jest-dom": "^5.16.5",
         "@uppercod/css-to-object": "^1.1.1",
-        "axios-mock-adapter": "^1.18.1",
+        "axios-mock-adapter": "^1.21.2",
         "color-contrast-checker": "^2.1.0",
         "hjson": "^3.2.2",
         "husky": "^8.0.0",
diff --git a/package.json b/package.json
index 95b1a11..606d4f5 100644
--- a/package.json
+++ b/package.json
@@ -39,7 +39,7 @@
     "@testing-library/dom": "^8.17.1",
     "@testing-library/jest-dom": "^5.16.5",
     "@uppercod/css-to-object": "^1.1.1",
-    "axios-mock-adapter": "^1.18.1",
+    "axios-mock-adapter": "^1.21.2",
     "color-contrast-checker": "^2.1.0",
     "hjson": "^3.2.2",
     "husky": "^8.0.0",
diff --git a/readme.md b/readme.md
index 281c487..83f573e 100644
--- a/readme.md
+++ b/readme.md
@@ -93,7 +93,7 @@ Visit <https://indiafightscorona.giveindia.org> and make a small donation to hel
     -   [Language Card Exclusive Options](#language-card-exclusive-options)
     -   [Wakatime Card Exclusive Option](#wakatime-card-exclusive-options)
 -   [Deploy Yourself](#deploy-on-your-own-vercel-instance)
-    - [Keep your fork up to date](#keep-your-fork-up-to-date)   
+    -   [Keep your fork up to date](#keep-your-fork-up-to-date)
 
 # GitHub Stats Card
 
@@ -133,7 +133,7 @@ You can add the count of all your private contributions to the total commits cou
 
 ### Showing icons
 
-To enable icons, you can pass `show_icons=true` in the query param, like so:
+To enable icons, you can pass `&show_icons=true` in the query param, like so:
 
 ```md
 ![Anurag's GitHub stats](https://github-readme-stats.vercel.app/api?username=anuraghazra&show_icons=true)
@@ -264,7 +264,7 @@ You can customize the appearance of your `Stats Card` or `Repo Card` however you
 -   `border_radius` - Corner rounding on the card. Default: `4.5`.
 
 > **Warning**
-> We use caching to decrease the load on our servers (see https://github.com/anuraghazra/github-readme-stats/issues/1471#issuecomment-1271551425). Our cards have a default cache of 4 hours (14400 seconds). Also, note that the cache is clamped to a minimum of 4 hours and a maximum of 24 hours.
+> We use caching to decrease the load on our servers (see <https://github.com/anuraghazra/github-readme-stats/issues/1471#issuecomment-1271551425>). Our cards have a default cache of 4 hours (14400 seconds). Also, note that the cache is clamped to a minimum of 4 hours and a maximum of 24 hours.
 
 ##### Gradient in bg_color
 
@@ -305,6 +305,7 @@ You can provide multiple comma-separated values in the bg_color option to render
 -   `exclude_repo` - Exclude specified repositories _(Comma-separated values)_. Default: `[] (blank array)`.
 -   `custom_title` - Sets a custom title for the card _(string)_. Default `Most Used Languages`.
 -   `disable_animations` - Disables all animations in the card _(boolean)_. Default: `false`.
+-   `hide_progress` - It uses the compact layout option, hides percentages, and removes the bars. Default: `false`.
 
 > **Warning**
 > Language names should be URI-escaped, as specified in [Percent Encoding](https://en.wikipedia.org/wiki/Percent-encoding)
@@ -354,7 +355,7 @@ Use [show_owner](#customization) variable to include the repo's owner username
 The top languages card shows a GitHub user's most frequently used top language.
 
 > **Note**
-> Top Languages does not indicate my skill level or anything like that; it's a GitHub metric to determine which languages have the most code on GitHub. It is a new feature of github-readme-stats._
+> Top Languages does not indicate my skill level or anything like that; it's a GitHub metric to determine which languages have the most code on GitHub. It is a new feature of github-readme-stats.
 
 ### Usage
 
@@ -398,6 +399,14 @@ You can use the `&layout=compact` option to change the card design.
 [![Top Langs](https://github-readme-stats.vercel.app/api/top-langs/?username=anuraghazra&layout=compact)](https://github.com/anuraghazra/github-readme-stats)
 ```
 
+### Hide Progress Bars
+
+You can use the `&hide_progress=true` option to hide the percentages and the progress bars (layout will be automatically set to `compact`).
+
+```md
+[![Top Langs](https://github-readme-stats.vercel.app/api/top-langs/?username=anuraghazra&hide_progress=true)](https://github.com/anuraghazra/github-readme-stats)
+```
+
 ### Demo
 
 [![Top Langs](https://github-readme-stats.vercel.app/api/top-langs/?username=anuraghazra)](https://github.com/anuraghazra/github-readme-stats)
@@ -406,6 +415,10 @@ You can use the `&layout=compact` option to change the card design.
 
 [![Top Langs](https://github-readme-stats.vercel.app/api/top-langs/?username=anuraghazra&layout=compact)](https://github.com/anuraghazra/github-readme-stats)
 
+- Hidden progress bars
+
+[![Top Langs](https://github-readme-stats.vercel.app/api/top-langs/?username=anuraghazra&hide_progress=true)](https://github.com/anuraghazra/github-readme-stats)
+
 # Wakatime Week Stats
 
 Change the `?username=` value to your [Wakatime](https://wakatime.com) username.
@@ -498,7 +511,7 @@ By default, GitHub does not lay out the cards side by side. To do that, you can
 
 ## Deploy on your own Vercel instance
 
-#### [Check Out Step By Step Video Tutorial By @codeSTACKr](https://youtu.be/n6d4KHSKqGk?t=107)
+#### :film_projector: [Check Out Step By Step Video Tutorial By @codeSTACKr](https://youtu.be/n6d4KHSKqGk?t=107)
 
 > **Warning**
 > If you are on the [hobby (i.e. free)](https://vercel.com/pricing) Vercel plan, please make sure you change the `maxDuration` parameter in the [vercel.json](https://github.com/anuraghazra/github-readme-stats/blob/master/vercel.json) file from `30` to `10` (see [#1416](https://github.com/anuraghazra/github-readme-stats/issues/1416#issuecomment-950275476) for more information).
diff --git a/src/cards/stats-card.js b/src/cards/stats-card.js
index a049ecc..f39a968 100644
--- a/src/cards/stats-card.js
+++ b/src/cards/stats-card.js
@@ -12,6 +12,11 @@ import {
 import { getStyles } from "../getStyles.js";
 import { statCardLocales } 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;
+
 /**
  * Create a stats card text item.
  *
@@ -218,11 +223,17 @@ const renderStatsCard = (stats = {}, options = { hide: [] }) => {
     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 ? 16 : 0;
-  const minCardWidth = hide_rank
-    ? clampValue(50 /* padding */ + calculateTextWidth() * 2, 270, Infinity)
-    : 340 + iconWidth;
-  const defaultCardWidth = hide_rank ? 270 : 495;
+  const iconWidth = show_icons ? 16 + /* padding */ 1 : 0;
+  const minCardWidth =
+    (hide_rank
+      ? clampValue(
+          50 /* padding */ + calculateTextWidth() * 2,
+          CARD_MIN_WIDTH,
+          Infinity,
+        )
+      : RANK_CARD_MIN_WIDTH) + iconWidth;
+  const defaultCardWidth =
+    (hide_rank ? CARD_DEFAULT_WIDTH : RANK_CARD_DEFAULT_WIDTH) + iconWidth;
   let width = isNaN(card_width) ? defaultCardWidth : card_width;
   if (width < minCardWidth) {
     width = minCardWidth;
@@ -251,18 +262,21 @@ const renderStatsCard = (stats = {}, options = { hide: [] }) => {
 
   /**
    * Calculates the right rank circle translation values such that the rank circle
-   * keeps respecting the padding.
+   * keeps respecting the following padding:
    *
-   * width > 450: The default left padding of 50 px will be used.
-   * width < 450: The left and right padding will shrink equally.
+   * 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 (width < 450) {
-      return width - 95 + (45 * (450 - 340)) / 110;
+    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 width - 95;
+      return minXTranslation + (width - minCardWidth) / 2;
     }
   };
 
diff --git a/src/cards/top-languages-card.js b/src/cards/top-languages-card.js
index 9396ff8..be1328c 100644
--- a/src/cards/top-languages-card.js
+++ b/src/cards/top-languages-card.js
@@ -76,10 +76,11 @@ const createProgressTextNode = ({ width, color, name, progress, index }) => {
  * @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 {number} props.index Index of the programming language.
  * @returns {string} Compact layout programming language SVG node.
  */
-const createCompactLangNode = ({ lang, totalSize, index }) => {
+const createCompactLangNode = ({ lang, totalSize, hideProgress, index }) => {
   const percentage = ((lang.size / totalSize) * 100).toFixed(2);
   const staggerDelay = (index + 3) * 150;
   const color = lang.color || "#858585";
@@ -88,7 +89,7 @@ const createCompactLangNode = ({ lang, totalSize, index }) => {
     <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} ${percentage}%
+        ${lang.name} ${hideProgress ? "" : percentage + "%"}
       </text>
     </g>
   `;
@@ -100,9 +101,10 @@ const createCompactLangNode = ({ lang, totalSize, index }) => {
  * @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.
  * @returns {string} Programming languages SVG node.
  */
-const createLanguageTextNode = ({ langs, totalSize }) => {
+const createLanguageTextNode = ({ langs, totalSize, hideProgress }) => {
   const longestLang = getLongestLang(langs);
   const chunked = chunkArray(langs, langs.length / 2);
   const layouts = chunked.map((array) => {
@@ -111,6 +113,7 @@ const createLanguageTextNode = ({ langs, totalSize }) => {
       createCompactLangNode({
         lang,
         totalSize,
+        hideProgress,
         index,
       }),
     );
@@ -160,9 +163,10 @@ const renderNormalLayout = (langs, width, totalLanguageSize) => {
  * @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.
  * @returns {string} Compact layout card SVG object.
  */
-const renderCompactLayout = (langs, width, totalLanguageSize) => {
+const renderCompactLayout = (langs, width, totalLanguageSize, hideProgress) => {
   const paddingRight = 50;
   const offsetWidth = width - paddingRight;
   // progressOffset holds the previous language's width and used to offset the next language
@@ -193,15 +197,21 @@ const renderCompactLayout = (langs, width, totalLanguageSize) => {
     .join("");
 
   return `
-    <mask id="rect-mask">
+  ${
+    !hideProgress
+      ? `
+  <mask id="rect-mask">
       <rect x="0" y="0" width="${offsetWidth}" height="8" fill="white" rx="5"/>
     </mask>
     ${compactProgressBar}
-
-    <g transform="translate(0, 25)">
+  `
+      : ""
+  }
+    <g transform="translate(0, ${hideProgress ? "0" : "25"})">
       ${createLanguageTextNode({
         langs,
         totalSize: totalLanguageSize,
+        hideProgress: hideProgress,
       })}
     </g>
   `;
@@ -276,6 +286,7 @@ const renderTopLanguages = (topLangs, options = {}) => {
     text_color,
     bg_color,
     hide,
+    hide_progress,
     theme,
     layout,
     custom_title,
@@ -305,11 +316,17 @@ const renderTopLanguages = (topLangs, options = {}) => {
   let height = calculateNormalLayoutHeight(langs.length);
 
   let finalLayout = "";
-  if (layout === "compact") {
+  if (layout === "compact" || hide_progress == true) {
     width = width + 50; // padding
-    height = calculateCompactLayoutHeight(langs.length);
-
-    finalLayout = renderCompactLayout(langs, width, totalLanguageSize);
+    height =
+      calculateCompactLayoutHeight(langs.length) + (hide_progress ? -25 : 0);
+
+    finalLayout = renderCompactLayout(
+      langs,
+      width,
+      totalLanguageSize,
+      hide_progress,
+    );
   } else {
     finalLayout = renderNormalLayout(langs, width, totalLanguageSize);
   }
diff --git a/src/cards/types.d.ts b/src/cards/types.d.ts
index c5945d4..52ee0ed 100644
--- a/src/cards/types.d.ts
+++ b/src/cards/types.d.ts
@@ -38,6 +38,7 @@ export type TopLangOptions = CommonOptions & {
   custom_title: string;
   langs_count: number;
   disable_animations: boolean;
+  hide_progress: boolean;
 };
 
 type WakaTimeOptions = CommonOptions & {
diff --git a/src/common/languageColors.json b/src/common/languageColors.json
index 7e8cd55..47bfb1c 100644
--- a/src/common/languageColors.json
+++ b/src/common/languageColors.json
@@ -102,6 +102,7 @@
     "Csound Score": "#1a1a1a",
     "Cuda": "#3A4E3A",
     "Curry": "#531242",
+    "Cypher": "#34c0eb",
     "Cython": "#fedf5b",
     "D": "#ba595e",
     "DM": "#447265",
@@ -124,6 +125,7 @@
     "Earthly": "#2af0ff",
     "Easybuild": "#069406",
     "Ecere Projects": "#913960",
+    "Ecmarkup": "#eb8131",
     "EditorConfig": "#fff1f2",
     "Eiffel": "#4d6977",
     "Elixir": "#6e4a7e",
@@ -215,6 +217,7 @@
     "Idris": "#b30000",
     "Ignore List": "#000000",
     "ImageJ Macro": "#99AAFF",
+    "Imba": "#16cec6",
     "Inno Setup": "#264b99",
     "Io": "#a9188d",
     "Ioke": "#078193",
@@ -286,6 +289,7 @@
     "Mathematica": "#dd1100",
     "Max": "#c4a79c",
     "Mercury": "#ff2b2b",
+    "Mermaid": "#ff3670",
     "Meson": "#007800",
     "Metal": "#8f14e9",
     "MiniYAML": "#ff1111",
@@ -318,6 +322,10 @@
     "Nu": "#c9df40",
     "NumPy": "#9C8AF9",
     "Nunjucks": "#3d8137",
+    "OASv2-json": "#85ea2d",
+    "OASv2-yaml": "#85ea2d",
+    "OASv3-json": "#85ea2d",
+    "OASv3-yaml": "#85ea2d",
     "OCaml": "#3be133",
     "ObjectScript": "#424893",
     "Objective-C": "#438eff",
@@ -327,14 +335,18 @@
     "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",
     "Oxygene": "#cdd0e3",
     "Oz": "#fab738",
     "P4": "#7055b5",
+    "PDDL": "#0d00ff",
     "PEG.js": "#234d6b",
     "PHP": "#4F5D95",
     "PLSQL": "#dad8d8",
@@ -351,6 +363,7 @@
     "PigLatin": "#fcd7de",
     "Pike": "#005390",
     "PogoScript": "#d80074",
+    "Polar": "#ae81ff",
     "Portugol": "#f8bd00",
     "PostCSS": "#dc3a0c",
     "PostScript": "#da291c",
@@ -414,6 +427,7 @@
     "Sass": "#a53b70",
     "Scala": "#c22d40",
     "Scaml": "#bd181a",
+    "Scenic": "#fdc700",
     "Scheme": "#1e4aec",
     "Scilab": "#ca0f21",
     "Self": "#0579aa",
@@ -421,6 +435,7 @@
     "Shell": "#89e051",
     "ShellCheck Config": "#cecfcb",
     "Shen": "#120F14",
+    "Simple File Verification": "#C9BFED",
     "Singularity": "#64E6AD",
     "Slash": "#007eff",
     "Slice": "#003fa2",
@@ -428,6 +443,7 @@
     "SmPL": "#c94949",
     "Smalltalk": "#596706",
     "Smarty": "#f0c040",
+    "Smithy": "#c44536",
     "Solidity": "#AA6746",
     "SourcePawn": "#f69e1d",
     "Squirrel": "#800000",
@@ -478,6 +494,7 @@
     "Vim Script": "#199f4b",
     "Vim Snippet": "#199f4b",
     "Visual Basic .NET": "#945db7",
+    "Visual Basic 6.0": "#2c6353",
     "Volt": "#1F1F1F",
     "Vue": "#41b883",
     "Vyper": "#2980b9",
@@ -514,6 +531,7 @@
     "fish": "#4aae47",
     "hoon": "#00b171",
     "jq": "#c7254e",
+    "just": "#384d54",
     "kvlang": "#1da6e0",
     "mIRC Script": "#3d57c3",
     "mcfunction": "#E22837",
diff --git a/src/common/utils.js b/src/common/utils.js
index 1215fc9..c600c71 100644
--- a/src/common/utils.js
+++ b/src/common/utils.js
@@ -424,6 +424,19 @@ const parseEmojis = (str) => {
   });
 };
 
+/**
+ * Get diff in minutes
+ * @param {Date} d1
+ * @param {Date} d2
+ * @returns {number}
+ */
+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));
+};
+
 export {
   ERROR_CARD_LENGTH,
   renderError,
@@ -447,4 +460,5 @@ export {
   lowercaseTrim,
   chunkArray,
   parseEmojis,
+  dateDiff,
 };
diff --git a/src/fetchers/stats-fetcher.js b/src/fetchers/stats-fetcher.js
index 7f6cb9e..a7df1e5 100644
--- a/src/fetchers/stats-fetcher.js
+++ b/src/fetchers/stats-fetcher.js
@@ -1,5 +1,6 @@
 // @ts-check
 import axios from "axios";
+import * as dotenv from "dotenv";
 import githubUsernameRegex from "github-username-regex";
 import { calculateRank } from "../calculateRank.js";
 import { retryer } from "../common/retryer.js";
@@ -11,46 +12,74 @@ import {
   wrapTextMultiline,
 } from "../common/utils.js";
 
+dotenv.config();
+
+// GraphQL queries.
+const GRAPHQL_REPOS_FIELD = `
+  repositories(first: 100, ownerAffiliations: OWNER, orderBy: {direction: DESC, field: STARGAZERS}, after: $after) {
+    totalCount
+    nodes {
+      name
+      stargazers {
+        totalCount
+      }
+    }
+    pageInfo {
+      hasNextPage
+      endCursor
+    }
+  }
+`;
+
+const GRAPHQL_REPOS_QUERY = `
+  query userInfo($login: String!, $after: String) {
+    user(login: $login) {
+      ${GRAPHQL_REPOS_FIELD}
+    }
+  }
+`;
+
+const GRAPHQL_STATS_QUERY = `
+  query userInfo($login: String!, $after: String) {
+    user(login: $login) {
+      name
+      login
+      contributionsCollection {
+        totalCommitContributions
+        restrictedContributionsCount
+      }
+      repositoriesContributedTo(first: 1, contributionTypes: [COMMIT, ISSUE, PULL_REQUEST, REPOSITORY]) {
+        totalCount
+      }
+      pullRequests(first: 1) {
+        totalCount
+      }
+      openIssues: issues(states: OPEN) {
+        totalCount
+      }
+      closedIssues: issues(states: CLOSED) {
+        totalCount
+      }
+      followers {
+        totalCount
+      }
+      ${GRAPHQL_REPOS_FIELD}
+    }
+  }
+`;
+
 /**
  * Stats fetcher object.
  *
  * @param {import('axios').AxiosRequestHeaders} variables Fetcher variables.
  * @param {string} token GitHub token.
- * @returns {Promise<import('../common/types').StatsFetcherResponse>} Stats fetcher response.
+ * @returns {Promise<import('../common/types').Fetcher>} Stats fetcher response.
  */
 const fetcher = (variables, token) => {
+  const query = !variables.after ? GRAPHQL_STATS_QUERY : GRAPHQL_REPOS_QUERY;
   return request(
     {
-      query: `
-      query userInfo($login: String!) {
-        user(login: $login) {
-          name
-          login
-          contributionsCollection {
-            totalCommitContributions
-            restrictedContributionsCount
-          }
-          repositoriesContributedTo(contributionTypes: [COMMIT, ISSUE, PULL_REQUEST, REPOSITORY]) {
-            totalCount
-          }
-          pullRequests {
-            totalCount
-          }
-          openIssues: issues(states: OPEN) {
-            totalCount
-          }
-          closedIssues: issues(states: CLOSED) {
-            totalCount
-          }
-          followers {
-            totalCount
-          }
-          repositories(ownerAffiliations: OWNER) {
-            totalCount
-          }
-        }
-      }
-      `,
+      query,
       variables,
     },
     {
@@ -60,39 +89,42 @@ const fetcher = (variables, token) => {
 };
 
 /**
- * Fetch first 100 repositories for a given username.
+ * Fetch stats information for a given username.
  *
- * @param {import('axios').AxiosRequestHeaders} variables Fetcher variables.
- * @param {string} token GitHub token.
- * @returns {Promise<import('../common/types').StatsFetcherResponse>} Repositories fetcher response.
+ * @param {string} username Github username.
+ * @returns {Promise<import('../common/types').StatsFetcher>} GraphQL Stats object.
+ *
+ * @description This function supports multi-page fetching if the 'FETCH_MULTI_PAGE_STARS' environment variable is set to true.
  */
-const repositoriesFetcher = (variables, token) => {
-  return request(
-    {
-      query: `
-      query userInfo($login: String!, $after: String) {
-        user(login: $login) {
-          repositories(first: 100, ownerAffiliations: OWNER, orderBy: {direction: DESC, field: STARGAZERS}, after: $after) {
-            nodes {
-              name
-              stargazers {
-                totalCount
-              }
-            }
-            pageInfo {
-              hasNextPage
-              endCursor
-            }
-          }
-        }
-      }
-      `,
-      variables,
-    },
-    {
-      Authorization: `bearer ${token}`,
-    },
-  );
+const statsFetcher = async (username) => {
+  let stats;
+  let hasNextPage = true;
+  let endCursor = null;
+  while (hasNextPage) {
+    const variables = { login: username, first: 100, after: endCursor };
+    let res = await retryer(fetcher, variables);
+    if (res.data.errors) return res;
+
+    // Store stats data.
+    const repoNodes = res.data.data.user.repositories.nodes;
+    if (!stats) {
+      stats = res;
+    } else {
+      stats.data.data.user.repositories.nodes.push(...repoNodes);
+    }
+
+    // Disable multi page fetching on public Vercel instance due to rate limits.
+    const repoNodesWithStars = repoNodes.filter(
+      (node) => node.stargazers.totalCount !== 0,
+    );
+    hasNextPage =
+      process.env.FETCH_MULTI_PAGE_STARS === "true" &&
+      repoNodes.length === repoNodesWithStars.length &&
+      res.data.data.user.repositories.pageInfo.hasNextPage;
+    endCursor = res.data.data.user.repositories.pageInfo.endCursor;
+  }
+
+  return stats;
 };
 
 /**
@@ -137,46 +169,6 @@ const totalCommitsFetcher = async (username) => {
   return 0;
 };
 
-/**
- * Fetch all the stars for all the repositories of a given username.
- *
- * @param {string} username GitHub username.
- * @param {array} repoToHide Repositories to hide.
- * @returns {Promise<number>} Total stars.
- */
-const totalStarsFetcher = async (username, repoToHide) => {
-  let nodes = [];
-  let hasNextPage = true;
-  let endCursor = null;
-  while (hasNextPage) {
-    const variables = { login: username, first: 100, after: endCursor };
-    let res = await retryer(repositoriesFetcher, variables);
-
-    if (res.data.errors) {
-      logger.error(res.data.errors);
-      throw new CustomError(
-        res.data.errors[0].message || "Could not fetch user",
-        CustomError.USER_NOT_FOUND,
-      );
-    }
-
-    const allNodes = res.data.data.user.repositories.nodes;
-    const nodesWithStars = allNodes.filter(
-      (node) => node.stargazers.totalCount !== 0,
-    );
-    nodes.push(...nodesWithStars);
-    // hasNextPage =
-    //   allNodes.length === nodesWithStars.length &&
-    //   res.data.data.user.repositories.pageInfo.hasNextPage;
-    hasNextPage = false; // NOTE: Temporarily disable fetching of multiple pages. Done because of #2130.
-    endCursor = res.data.data.user.repositories.pageInfo.endCursor;
-  }
-
-  return nodes
-    .filter((data) => !repoToHide[data.name])
-    .reduce((prev, curr) => prev + curr.stargazers.totalCount, 0);
-};
-
 /**
  * Fetch stats for a given username.
  *
@@ -203,7 +195,7 @@ const fetchStats = async (
     rank: { level: "C", score: 0 },
   };
 
-  let res = await retryer(fetcher, { login: username });
+  let res = await statsFetcher(username);
 
   // Catch GraphQL errors.
   if (res.data.errors) {
@@ -259,8 +251,15 @@ const fetchStats = async (
   stats.contributedTo = user.repositoriesContributedTo.totalCount;
 
   // Retrieve stars while filtering out repositories to be hidden
-  stats.totalStars = await totalStarsFetcher(username, repoToHide);
+  stats.totalStars = user.repositories.nodes
+    .filter((data) => {
+      return !repoToHide[data.name];
+    })
+    .reduce((prev, curr) => {
+      return prev + curr.stargazers.totalCount;
+    }, 0);
 
+  // @ts-ignore // TODO: Fix this.
   stats.rank = calculateRank({
     totalCommits: stats.totalCommits,
     totalRepos: user.repositories.totalCount,
diff --git a/themes/index.js b/themes/index.js
index a5d3aba..01c9a8e 100644
--- a/themes/index.js
+++ b/themes/index.js
@@ -374,6 +374,27 @@ export const themes = {
     border_color: "170F0C",
     bg_color: "170F0C",
   },
+  one_dark_pro: {
+    title_color: "61AFEF",
+    text_color: "E5C06E",
+    icon_color: "C678DD",
+    border_color: "3B4048",
+    bg_color: "23272E",
+  },
+  rose: {
+    title_color: "8d192b",
+    text_color: "862931",
+    icon_color: "B71F36",
+    border_color: "e9d8d4",
+    bg_color: "e9d8d4",
+  },
+  holi_theme: {
+    title_color: "5FABEE",
+    text_color: "D6E7FF",
+    icon_color: "5FABEE",
+    border_color: "85A4C0",
+    bg_color: "030314",
+  },
 };
 
 export default themes;
diff --git a/vercel.json b/vercel.json
index aee61ce..ddf82eb 100644
--- a/vercel.json
+++ b/vercel.json
@@ -2,7 +2,7 @@
   "functions": {
     "api/*.js": {
       "memory": 128,
-      "maxDuration": 30
+      "maxDuration": 10
     }
   },
   "redirects": [


Test patch:
diff --git a/.github/workflows/e2e-test.yml b/.github/workflows/e2e-test.yml
index b9d275f..d45c76b 100644
--- a/.github/workflows/e2e-test.yml
+++ b/.github/workflows/e2e-test.yml
@@ -5,6 +5,7 @@ on:
 jobs:
   e2eTests:
     if:
+      github.repository == 'anuraghazra/github-readme-stats' &&
       github.event_name == 'deployment_status' &&
       github.event.deployment_status.state == 'success'
     name: Perform 2e2 tests
diff --git a/tests/api.test.js b/tests/api.test.js
index 0037bcd..461f3e1 100644
--- a/tests/api.test.js
+++ b/tests/api.test.js
@@ -25,7 +25,7 @@ stats.rank = calculateRank({
   issues: stats.totalIssues,
 });
 
-const data = {
+const data_stats = {
   data: {
     user: {
       name: stats.name,
@@ -40,15 +40,6 @@ const data = {
       followers: { totalCount: 0 },
       repositories: {
         totalCount: 1,
-      },
-    },
-  },
-};
-
-const repositoriesData = {
-  data: {
-    user: {
-      repositories: {
         nodes: [{ stargazers: { totalCount: 100 } }],
         pageInfo: {
           hasNextPage: false,
@@ -83,11 +74,7 @@ const faker = (query, data) => {
     setHeader: jest.fn(),
     send: jest.fn(),
   };
-  mock
-    .onPost("https://api.github.com/graphql")
-    .replyOnce(200, data)
-    .onPost("https://api.github.com/graphql")
-    .replyOnce(200, repositoriesData);
+  mock.onPost("https://api.github.com/graphql").replyOnce(200, data);
 
   return { req, res };
 };
@@ -98,7 +85,7 @@ afterEach(() => {
 
 describe("Test /api/", () => {
   it("should test the request", async () => {
-    const { req, res } = faker({}, data);
+    const { req, res } = faker({}, data_stats);
 
     await api(req, res);
 
@@ -133,7 +120,7 @@ describe("Test /api/", () => {
         text_color: "fff",
         bg_color: "fff",
       },
-      data,
+      data_stats,
     );
 
     await api(req, res);
@@ -154,7 +141,7 @@ describe("Test /api/", () => {
   });
 
   it("should have proper cache", async () => {
-    const { req, res } = faker({}, data);
+    const { req, res } = faker({}, data_stats);
 
     await api(req, res);
 
@@ -170,7 +157,7 @@ describe("Test /api/", () => {
   });
 
   it("should set proper cache", async () => {
-    const { req, res } = faker({ cache_seconds: 15000 }, data);
+    const { req, res } = faker({ cache_seconds: 15000 }, data_stats);
     await api(req, res);
 
     expect(res.setHeader.mock.calls).toEqual([
@@ -196,7 +183,7 @@ describe("Test /api/", () => {
 
   it("should set proper cache with clamped values", async () => {
     {
-      let { req, res } = faker({ cache_seconds: 200000 }, data);
+      let { req, res } = faker({ cache_seconds: 200000 }, data_stats);
       await api(req, res);
 
       expect(res.setHeader.mock.calls).toEqual([
@@ -212,7 +199,7 @@ describe("Test /api/", () => {
 
     // note i'm using block scoped vars
     {
-      let { req, res } = faker({ cache_seconds: 0 }, data);
+      let { req, res } = faker({ cache_seconds: 0 }, data_stats);
       await api(req, res);
 
       expect(res.setHeader.mock.calls).toEqual([
@@ -227,7 +214,7 @@ describe("Test /api/", () => {
     }
 
     {
-      let { req, res } = faker({ cache_seconds: -10000 }, data);
+      let { req, res } = faker({ cache_seconds: -10000 }, data_stats);
       await api(req, res);
 
       expect(res.setHeader.mock.calls).toEqual([
@@ -248,7 +235,7 @@ describe("Test /api/", () => {
         username: "anuraghazra",
         count_private: true,
       },
-      data,
+      data_stats,
     );
 
     await api(req, res);
@@ -288,7 +275,7 @@ describe("Test /api/", () => {
         text_color: "fff",
         bg_color: "fff",
       },
-      data,
+      data_stats,
     );
 
     await api(req, res);
diff --git a/tests/e2e/e2e.test.js b/tests/e2e/e2e.test.js
index 48bf16a..402e210 100644
--- a/tests/e2e/e2e.test.js
+++ b/tests/e2e/e2e.test.js
@@ -11,27 +11,22 @@ 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";
 
-const REPO = "dummy-cra";
-const USER = "grsdummy";
+const REPO = "curly-fiesta";
+const USER = "catelinemnemosyne";
 const STATS_DATA = {
-  name: "grsdummy",
-  totalPRs: 2,
-  totalCommits: 2,
+  name: "Cateline Mnemosyne",
+  totalPRs: 1,
+  totalCommits: 7,
   totalIssues: 1,
   totalStars: 1,
-  contributedTo: 2,
+  contributedTo: 1,
   rank: {
     level: "A+",
-    score: 50.900829325065935,
+    score: 50.893750297869225,
   },
 };
 
 const LANGS_DATA = {
-  TypeScript: {
-    color: "#3178c6",
-    name: "TypeScript",
-    size: 2049,
-  },
   HTML: {
     color: "#e34c26",
     name: "HTML",
@@ -42,19 +37,19 @@ const LANGS_DATA = {
     name: "CSS",
     size: 930,
   },
-  Python: {
-    color: "#3572A5",
-    name: "Python",
-    size: 671,
+  JavaScript: {
+    color: "#f1e05a",
+    name: "JavaScript",
+    size: 1912,
   },
 };
 
 const WAKATIME_DATA = {
   human_readable_range: "last week",
   is_already_updating: false,
-  is_coding_activity_visible: false,
+  is_coding_activity_visible: true,
   is_including_today: false,
-  is_other_usage_visible: false,
+  is_other_usage_visible: true,
   is_stuck: false,
   is_up_to_date: false,
   is_up_to_date_pending_future: false,
@@ -62,24 +57,24 @@ const WAKATIME_DATA = {
   range: "last_7_days",
   status: "pending_update",
   timeout: 15,
-  username: "grsdummy",
+  username: USER,
   writes_only: false,
 };
 
 const REPOSITORY_DATA = {
-  name: "dummy-cra",
-  nameWithOwner: "grsdummy/dummy-cra",
+  name: REPO,
+  nameWithOwner: `${USER}/cra-test`,
   isPrivate: false,
   isArchived: false,
   isTemplate: false,
   stargazers: {
     totalCount: 1,
   },
-  description: "Dummy create react app.",
+  description: "Simple cra test repo.",
   primaryLanguage: {
-    color: "#3178c6",
-    id: "MDg6TGFuZ3VhZ2UyODc=",
-    name: "TypeScript",
+    color: "#f1e05a",
+    id: "MDg6TGFuZ3VhZ2UxNDA=",
+    name: "JavaScript",
   },
   forkCount: 0,
   starCount: 1,
diff --git a/tests/fetchStats.test.js b/tests/fetchStats.test.js
index 192146e..04e943a 100644
--- a/tests/fetchStats.test.js
+++ b/tests/fetchStats.test.js
@@ -4,7 +4,8 @@ import MockAdapter from "axios-mock-adapter";
 import { calculateRank } from "../src/calculateRank.js";
 import { fetchStats } from "../src/fetchers/stats-fetcher.js";
 
-const data = {
+// Test parameters.
+const data_stats = {
   data: {
     user: {
       name: "Anurag Hazra",
@@ -19,15 +20,6 @@ const data = {
       followers: { totalCount: 100 },
       repositories: {
         totalCount: 5,
-      },
-    },
-  },
-};
-
-const firstRepositoriesData = {
-  data: {
-    user: {
-      repositories: {
         nodes: [
           { name: "test-repo-1", stargazers: { totalCount: 100 } },
           { name: "test-repo-2", stargazers: { totalCount: 100 } },
@@ -42,7 +34,7 @@ const firstRepositoriesData = {
   },
 };
 
-const secondRepositoriesData = {
+const data_repo = {
   data: {
     user: {
       repositories: {
@@ -59,7 +51,7 @@ const secondRepositoriesData = {
   },
 };
 
-const repositoriesWithZeroStarsData = {
+const data_repo_zero_stars = {
   data: {
     user: {
       repositories: {
@@ -93,13 +85,12 @@ const error = {
 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")
-    .replyOnce(200, data)
+    .replyOnce(200, data_stats)
     .onPost("https://api.github.com/graphql")
-    .replyOnce(200, firstRepositoriesData);
-  // .onPost("https://api.github.com/graphql") // NOTE: Temporarily disable fetching of multiple pages. Done because of #2130.
-  // .replyOnce(200, secondRepositoriesData); // NOTE: Temporarily disable fetching of multiple pages. Done because of #2130.
+    .replyOnce(200, data_repo);
 });
 
 afterEach(() => {
@@ -114,8 +105,7 @@ describe("Test fetchStats", () => {
       totalRepos: 5,
       followers: 100,
       contributions: 61,
-      // stargazers: 400, // NOTE: Temporarily disable fetching of multiple pages. Done because of #2130.
-      stargazers: 300, // NOTE: Temporarily disable fetching of multiple pages. Done because of #2130.
+      stargazers: 300,
       prs: 300,
       issues: 200,
     });
@@ -126,8 +116,7 @@ describe("Test fetchStats", () => {
       totalCommits: 100,
       totalIssues: 200,
       totalPRs: 300,
-      // totalStars: 400, // NOTE: Temporarily disable fetching of multiple pages. Done because of #2130.
-      totalStars: 300, // NOTE: Temporarily disable fetching of multiple pages. Done because of #2130.
+      totalStars: 300,
       rank,
     });
   });
@@ -136,9 +125,9 @@ describe("Test fetchStats", () => {
     mock.reset();
     mock
       .onPost("https://api.github.com/graphql")
-      .replyOnce(200, data)
+      .replyOnce(200, data_stats)
       .onPost("https://api.github.com/graphql")
-      .replyOnce(200, repositoriesWithZeroStarsData);
+      .replyOnce(200, data_repo_zero_stars);
 
     let stats = await fetchStats("anuraghazra");
     const rank = calculateRank({
@@ -178,8 +167,7 @@ describe("Test fetchStats", () => {
       totalRepos: 5,
       followers: 100,
       contributions: 61,
-      // stargazers: 400, // NOTE: Temporarily disable fetching of multiple pages. Done because of #2130.
-      stargazers: 300, // NOTE: Temporarily disable fetching of multiple pages. Done because of #2130.
+      stargazers: 300,
       prs: 300,
       issues: 200,
     });
@@ -190,8 +178,7 @@ describe("Test fetchStats", () => {
       totalCommits: 150,
       totalIssues: 200,
       totalPRs: 300,
-      // totalStars: 400, // NOTE: Temporarily disable fetching of multiple pages. Done because of #2130.
-      totalStars: 300, // NOTE: Temporarily disable fetching of multiple pages. Done because of #2130.
+      totalStars: 300,
       rank,
     });
   });
@@ -207,8 +194,7 @@ describe("Test fetchStats", () => {
       totalRepos: 5,
       followers: 100,
       contributions: 61,
-      // stargazers: 400, // NOTE: Temporarily disable fetching of multiple pages. Done because of #2130.
-      stargazers: 300, // NOTE: Temporarily disable fetching of multiple pages. Done because of #2130.
+      stargazers: 300,
       prs: 300,
       issues: 200,
     });
@@ -219,8 +205,7 @@ describe("Test fetchStats", () => {
       totalCommits: 1050,
       totalIssues: 200,
       totalPRs: 300,
-      // totalStars: 400, // NOTE: Temporarily disable fetching of multiple pages. Done because of #2130.
-      totalStars: 300, // NOTE: Temporarily disable fetching of multiple pages. Done because of #2130.
+      totalStars: 300,
       rank,
     });
   });
@@ -236,8 +221,7 @@ describe("Test fetchStats", () => {
       totalRepos: 5,
       followers: 100,
       contributions: 61,
-      // stargazers: 300, // NOTE: Temporarily disable fetching of multiple pages. Done because of #2130.
-      stargazers: 200, // NOTE: Temporarily disable fetching of multiple pages. Done because of #2130.
+      stargazers: 200,
       prs: 300,
       issues: 200,
     });
@@ -248,8 +232,82 @@ describe("Test fetchStats", () => {
       totalCommits: 1050,
       totalIssues: 200,
       totalPRs: 300,
-      // totalStars: 300, // NOTE: Temporarily disable fetching of multiple pages. Done because of #2130.
-      totalStars: 200, // NOTE: Temporarily disable fetching of multiple pages. Done because of #2130.
+      totalStars: 200,
+      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({
+      totalCommits: 100,
+      totalRepos: 5,
+      followers: 100,
+      contributions: 61,
+      stargazers: 400,
+      prs: 300,
+      issues: 200,
+    });
+
+    expect(stats).toStrictEqual({
+      contributedTo: 61,
+      name: "Anurag Hazra",
+      totalCommits: 100,
+      totalIssues: 200,
+      totalPRs: 300,
+      totalStars: 400,
+      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({
+      totalCommits: 100,
+      totalRepos: 5,
+      followers: 100,
+      contributions: 61,
+      stargazers: 300,
+      prs: 300,
+      issues: 200,
+    });
+
+    expect(stats).toStrictEqual({
+      contributedTo: 61,
+      name: "Anurag Hazra",
+      totalCommits: 100,
+      totalIssues: 200,
+      totalPRs: 300,
+      totalStars: 300,
+      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({
+      totalCommits: 100,
+      totalRepos: 5,
+      followers: 100,
+      contributions: 61,
+      stargazers: 300,
+      prs: 300,
+      issues: 200,
+    });
+
+    expect(stats).toStrictEqual({
+      contributedTo: 61,
+      name: "Anurag Hazra",
+      totalCommits: 100,
+      totalIssues: 200,
+      totalPRs: 300,
+      totalStars: 300,
       rank,
     });
   });
diff --git a/tests/pat-info.test.js b/tests/pat-info.test.js
new file mode 100644
index 0000000..23aca8c
--- /dev/null
+++ b/tests/pat-info.test.js
@@ -0,0 +1,244 @@
+/**
+ * @file Tests for the status/pat-info cloud function.
+ */
+import dotenv from "dotenv";
+dotenv.config();
+
+import { jest } from "@jest/globals";
+import axios from "axios";
+import MockAdapter from "axios-mock-adapter";
+import patInfo, { RATE_LIMIT_SECONDS } from "../api/status/pat-info.js";
+
+const mock = new MockAdapter(axios);
+
+const successData = {
+  data: {
+    rateLimit: {
+      remaining: 4986,
+    },
+  },
+};
+
+const faker = (query) => {
+  const req = {
+    query: { ...query },
+  };
+  const res = {
+    setHeader: jest.fn(),
+    send: jest.fn(),
+  };
+
+  return { req, res };
+};
+
+const rate_limit_error = {
+  errors: [
+    {
+      type: "RATE_LIMITED",
+      message: "API rate limit exceeded for user ID.",
+    },
+  ],
+  data: {
+    rateLimit: {
+      resetAt: Date.now(),
+    },
+  },
+};
+
+const other_error = {
+  errors: [
+    {
+      type: "SOME_ERROR",
+      message: "This is a error",
+    },
+  ],
+};
+
+const bad_credentials_error = {
+  message: "Bad credentials",
+};
+
+afterEach(() => {
+  mock.reset();
+});
+
+describe("Test /api/status/pat-info", () => {
+  beforeAll(() => {
+    // reset patenv first so that dotenv doesn't populate them 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")
+      .replyOnce(200, rate_limit_error)
+      .onPost("https://api.github.com/graphql")
+      .reply(200, successData);
+
+    const { req, res } = faker({}, {});
+    await patInfo(req, res);
+
+    expect(res.setHeader).toBeCalledWith("Content-Type", "application/json");
+    expect(res.send).toBeCalledWith(
+      JSON.stringify(
+        {
+          validPATs: ["PAT_2", "PAT_3", "PAT_4"],
+          expiredPATs: [],
+          exhaustedPATs: ["PAT_1"],
+          suspendedPATs: [],
+          errorPATs: [],
+          details: {
+            PAT_1: {
+              status: "exhausted",
+              remaining: 0,
+              resetIn: "0 minutes",
+            },
+            PAT_2: {
+              status: "valid",
+              remaining: 4986,
+            },
+            PAT_3: {
+              status: "valid",
+              remaining: 4986,
+            },
+            PAT_4: {
+              status: "valid",
+              remaining: 4986,
+            },
+          },
+        },
+        null,
+        2,
+      ),
+    );
+  });
+
+  it("should return `errorPATs` if a PAT causes an error to be thrown", async () => {
+    mock
+      .onPost("https://api.github.com/graphql")
+      .replyOnce(200, other_error)
+      .onPost("https://api.github.com/graphql")
+      .reply(200, successData);
+
+    const { req, res } = faker({}, {});
+    await patInfo(req, res);
+
+    expect(res.setHeader).toBeCalledWith("Content-Type", "application/json");
+    expect(res.send).toBeCalledWith(
+      JSON.stringify(
+        {
+          validPATs: ["PAT_2", "PAT_3", "PAT_4"],
+          expiredPATs: [],
+          exhaustedPATs: [],
+          suspendedPATs: [],
+          errorPATs: ["PAT_1"],
+          details: {
+            PAT_1: {
+              status: "error",
+              error: {
+                type: "SOME_ERROR",
+                message: "This is a error",
+              },
+            },
+            PAT_2: {
+              status: "valid",
+              remaining: 4986,
+            },
+            PAT_3: {
+              status: "valid",
+              remaining: 4986,
+            },
+            PAT_4: {
+              status: "valid",
+              remaining: 4986,
+            },
+          },
+        },
+        null,
+        2,
+      ),
+    );
+  });
+
+  it("should return `expiredPaths` if a PAT returns a 'Bad credentials' error", async () => {
+    mock
+      .onPost("https://api.github.com/graphql")
+      .replyOnce(404, bad_credentials_error)
+      .onPost("https://api.github.com/graphql")
+      .reply(200, successData);
+
+    const { req, res } = faker({}, {});
+    await patInfo(req, res);
+
+    expect(res.setHeader).toBeCalledWith("Content-Type", "application/json");
+    expect(res.send).toBeCalledWith(
+      JSON.stringify(
+        {
+          validPATs: ["PAT_2", "PAT_3", "PAT_4"],
+          expiredPATs: ["PAT_1"],
+          exhaustedPATs: [],
+          suspendedPATs: [],
+          errorPATs: [],
+          details: {
+            PAT_1: {
+              status: "expired",
+            },
+            PAT_2: {
+              status: "valid",
+              remaining: 4986,
+            },
+            PAT_3: {
+              status: "valid",
+              remaining: 4986,
+            },
+            PAT_4: {
+              status: "valid",
+              remaining: 4986,
+            },
+          },
+        },
+        null,
+        2,
+      ),
+    );
+  });
+
+  it("should throw an error if something goes wrong", async () => {
+    mock.onPost("https://api.github.com/graphql").networkError();
+
+    const { req, res } = faker({}, {});
+    await patInfo(req, res);
+
+    expect(res.setHeader).toBeCalledWith("Content-Type", "application/json");
+    expect(res.send).toBeCalledWith("Something went wrong: Network Error");
+  });
+
+  it("should have proper cache when no error is thrown", async () => {
+    mock.onPost("https://api.github.com/graphql").reply(200, successData);
+
+    const { req, res } = faker({}, {});
+    await patInfo(req, res);
+
+    expect(res.setHeader.mock.calls).toEqual([
+      ["Content-Type", "application/json"],
+      ["Cache-Control", `max-age=0, s-maxage=${RATE_LIMIT_SECONDS}`],
+    ]);
+  });
+
+  it("should have proper cache when error is thrown", async () => {
+    mock.reset();
+    mock.onPost("https://api.github.com/graphql").networkError();
+
+    const { req, res } = faker({}, {});
+    await patInfo(req, res);
+
+    expect(res.setHeader.mock.calls).toEqual([
+      ["Content-Type", "application/json"],
+      ["Cache-Control", "no-store"],
+    ]);
+  });
+});
diff --git a/tests/renderStatsCard.test.js b/tests/renderStatsCard.test.js
index 5afb1f0..748b7a3 100644
--- a/tests/renderStatsCard.test.js
+++ b/tests/renderStatsCard.test.js
@@ -78,16 +78,17 @@ describe("Test renderStatsCard", () => {
 
   it("should render with custom width set", () => {
     document.body.innerHTML = renderStatsCard(stats);
-    expect(document.querySelector("svg")).toHaveAttribute("width", "495");
+    expect(document.querySelector("svg")).toHaveAttribute("width", "450");
 
-    document.body.innerHTML = renderStatsCard(stats, { card_width: 400 });
-    expect(document.querySelector("svg")).toHaveAttribute("width", "400");
+    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", "340");
+    expect(document.querySelector("svg")).toHaveAttribute("width", "420");
 
+    // Test default minimum card width without rank circle.
     document.body.innerHTML = renderStatsCard(stats, {
       card_width: 1,
       hide_rank: true,
@@ -97,6 +98,7 @@ describe("Test renderStatsCard", () => {
       "305.81250000000006",
     );
 
+    // Test minimum card width with rank and icons.
     document.body.innerHTML = renderStatsCard(stats, {
       card_width: 1,
       hide_rank: true,
@@ -104,22 +106,24 @@ describe("Test renderStatsCard", () => {
     });
     expect(document.querySelector("svg")).toHaveAttribute(
       "width",
-      "305.81250000000006",
+      "322.81250000000006",
     );
 
+    // 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", "356");
+    expect(document.querySelector("svg")).toHaveAttribute("width", "437");
 
+    // 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", "340");
+    expect(document.querySelector("svg")).toHaveAttribute("width", "420");
   });
 
   it("should render default colors properly", () => {
@@ -312,7 +316,7 @@ describe("Test renderStatsCard", () => {
 
     expect(
       document.body.getElementsByTagName("svg")[0].getAttribute("width"),
-    ).toBe("270");
+    ).toBe("287");
   });
 
   it("should render translations", () => {
diff --git a/tests/status.up.test.js b/tests/status.up.test.js
new file mode 100644
index 0000000..7cf0144
--- /dev/null
+++ b/tests/status.up.test.js
@@ -0,0 +1,194 @@
+/**
+ * @file Tests for the status/up cloud function.
+ */
+import { jest } from "@jest/globals";
+import axios from "axios";
+import MockAdapter from "axios-mock-adapter";
+import up, { RATE_LIMIT_SECONDS } from "../api/status/up.js";
+
+const mock = new MockAdapter(axios);
+
+const successData = {
+  rateLimit: {
+    remaining: 4986,
+  },
+};
+
+const faker = (query) => {
+  const req = {
+    query: { ...query },
+  };
+  const res = {
+    setHeader: jest.fn(),
+    send: jest.fn(),
+  };
+
+  return { req, res };
+};
+
+const rate_limit_error = {
+  errors: [
+    {
+      type: "RATE_LIMITED",
+    },
+  ],
+};
+
+const bad_credentials_error = {
+  message: "Bad credentials",
+};
+
+const shields_up = {
+  schemaVersion: 1,
+  label: "Public Instance",
+  isError: true,
+  message: "up",
+  color: "brightgreen",
+};
+const shields_down = {
+  schemaVersion: 1,
+  label: "Public Instance",
+  isError: true,
+  message: "down",
+  color: "red",
+};
+
+afterEach(() => {
+  mock.reset();
+});
+
+describe("Test /api/status/up", () => {
+  it("should return `true` if request was successful", async () => {
+    mock.onPost("https://api.github.com/graphql").replyOnce(200, successData);
+
+    const { req, res } = faker({}, {});
+    await up(req, res);
+
+    expect(res.setHeader).toBeCalledWith("Content-Type", "application/json");
+    expect(res.send).toBeCalledWith(true);
+  });
+
+  it("should return `false` if all PATs are rate limited", async () => {
+    mock.onPost("https://api.github.com/graphql").reply(200, rate_limit_error);
+
+    const { req, res } = faker({}, {});
+    await up(req, res);
+
+    expect(res.setHeader).toBeCalledWith("Content-Type", "application/json");
+    expect(res.send).toBeCalledWith(false);
+  });
+
+  it("should return JSON `true` if request was successful and type='json'", async () => {
+    mock.onPost("https://api.github.com/graphql").replyOnce(200, successData);
+
+    const { req, res } = faker({ type: "json" }, {});
+    await up(req, res);
+
+    expect(res.setHeader).toBeCalledWith("Content-Type", "application/json");
+    expect(res.send).toBeCalledWith({ up: true });
+  });
+
+  it("should return JSON `false` if all PATs are rate limited and type='json'", async () => {
+    mock.onPost("https://api.github.com/graphql").reply(200, rate_limit_error);
+
+    const { req, res } = faker({ type: "json" }, {});
+    await up(req, res);
+
+    expect(res.setHeader).toBeCalledWith("Content-Type", "application/json");
+    expect(res.send).toBeCalledWith({ up: false });
+  });
+
+  it("should return UP shields.io config if request was successful and type='shields'", async () => {
+    mock.onPost("https://api.github.com/graphql").replyOnce(200, successData);
+
+    const { req, res } = faker({ type: "shields" }, {});
+    await up(req, res);
+
+    expect(res.setHeader).toBeCalledWith("Content-Type", "application/json");
+    expect(res.send).toBeCalledWith(shields_up);
+  });
+
+  it("should return DOWN shields.io config if all PATs are rate limited and type='shields'", async () => {
+    mock.onPost("https://api.github.com/graphql").reply(200, rate_limit_error);
+
+    const { req, res } = faker({ type: "shields" }, {});
+    await up(req, res);
+
+    expect(res.setHeader).toBeCalledWith("Content-Type", "application/json");
+    expect(res.send).toBeCalledWith(shields_down);
+  });
+
+  it("should return `true` if the first PAT is rate limited but the second PATs works", async () => {
+    mock
+      .onPost("https://api.github.com/graphql")
+      .replyOnce(200, rate_limit_error)
+      .onPost("https://api.github.com/graphql")
+      .replyOnce(200, successData);
+
+    const { req, res } = faker({}, {});
+    await up(req, res);
+
+    expect(res.setHeader).toBeCalledWith("Content-Type", "application/json");
+    expect(res.send).toBeCalledWith(true);
+  });
+
+  it("should return `true` if the first PAT has 'Bad credentials' but the second PAT works", async () => {
+    mock
+      .onPost("https://api.github.com/graphql")
+      .replyOnce(404, bad_credentials_error)
+      .onPost("https://api.github.com/graphql")
+      .replyOnce(200, successData);
+
+    const { req, res } = faker({}, {});
+    await up(req, res);
+
+    expect(res.setHeader).toBeCalledWith("Content-Type", "application/json");
+    expect(res.send).toBeCalledWith(true);
+  });
+
+  it("should return `false` if all pats have 'Bad credentials'", async () => {
+    mock
+      .onPost("https://api.github.com/graphql")
+      .reply(404, bad_credentials_error);
+
+    const { req, res } = faker({}, {});
+    await up(req, res);
+
+    expect(res.setHeader).toBeCalledWith("Content-Type", "application/json");
+    expect(res.send).toBeCalledWith(false);
+  });
+
+  it("should throw an error if the request fails", async () => {
+    mock.onPost("https://api.github.com/graphql").networkError();
+
+    const { req, res } = faker({}, {});
+    await up(req, res);
+
+    expect(res.setHeader).toBeCalledWith("Content-Type", "application/json");
+    expect(res.send).toBeCalledWith(false);
+  });
+
+  it("should have proper cache when no error is thrown", async () => {
+    mock.onPost("https://api.github.com/graphql").replyOnce(200, successData);
+
+    const { req, res } = faker({}, {});
+    await up(req, res);
+
+    expect(res.setHeader.mock.calls).toEqual([
+      ["Content-Type", "application/json"],
+      ["Cache-Control", `max-age=0, s-maxage=${RATE_LIMIT_SECONDS}`],
+    ]);
+  });
+
+  it("should have proper cache when error is thrown", async () => {
+    mock.onPost("https://api.github.com/graphql").networkError();
+
+    const { req, res } = faker({}, {});
+    await up(req, res);
+
+    expect(res.setHeader.mock.calls).toEqual([
+      ["Content-Type", "application/json"],
+      ["Cache-Control", "no-store"],
+    ]);
+  });
+});