Compare commits

..
12 Commits
Author SHA1 Message Date
Martin 5c4ea2a797 merge master: documentation updates (#127) 2026-03-07 20:52:27 +01:00
Martin 7d666e2510 merge master: fix dark mode colors in delete modal (#125) 2026-03-07 16:18:04 +01:00
Martin 96a18562f0 update release (#122)
Merging the latest `master` into `release` in order to create a new
release.
2026-03-07 14:36:04 +01:00
martin-mfg b64d601d47 Merge remote-tracking branch 'origin/master' into update-release 2026-03-07 14:34:01 +01:00
martin-mfg 26aec2a4af Merge remote-tracking branch 'origin/master' into release 2026-03-07 12:55:13 +01:00
martin-mfg ad23ab39ed Merge branch 'master' into release 2026-03-07 12:23:50 +01:00
martin-mfg d982bef3c5 Revert "fix problem on simultaneous login"
This reverts commit 23ba727b9a.
2026-01-08 18:57:49 +01:00
martin-mfg 2463f0900d Merge branch 'master' into release 2026-01-08 18:51:11 +01:00
martin-mfg 23ba727b9a fix problem on simultaneous login 2026-01-07 11:55:03 +01:00
martin-mfg 6c625fb76b Merge branch 'master' into release 2026-01-07 11:44:26 +01:00
martin-mfg 0aafbbbdb5 back to release rewrite 2026-01-06 20:21:14 +01:00
martin-mfg 8e6e0fac47 back to relase env vars 2026-01-06 20:00:49 +01:00
173 changed files with 6403 additions and 8615 deletions
+2 -2
View File
@@ -5,9 +5,9 @@
To set up the project GitHub-Stats-Extended locally, run the following commands: To set up the project GitHub-Stats-Extended locally, run the following commands:
```bash ```bash
./vercel-preparation.sh
pnpm install pnpm install
pnpm run build:packages pnpm --filter frontend run build
pnpm run dev:frontend
``` ```
The easiest way to run and test the project is to deploy it to Vercel as described in the [deployment guide](../docs/deploy.md). The easiest way to run and test the project is to deploy it to Vercel as described in the [deployment guide](../docs/deploy.md).
@@ -14,11 +14,11 @@ runs:
steps: steps:
- name: Install pnpm - name: Install pnpm
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5 uses: pnpm/action-setup@v4
- name: Setup Node.js (via input) - name: Setup Node.js (via input)
if: ${{ inputs.node-version }} if: ${{ inputs.node-version }}
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 uses: actions/setup-node@v6
with: with:
node-version: ${{ inputs.node-version }} node-version: ${{ inputs.node-version }}
cache: "pnpm" cache: "pnpm"
@@ -26,7 +26,7 @@ runs:
- name: Setup Node.js (via .nvmrc) - name: Setup Node.js (via .nvmrc)
if: ${{ !inputs.node-version }} if: ${{ !inputs.node-version }}
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 uses: actions/setup-node@v6
with: with:
node-version-file: ".nvmrc" node-version-file: ".nvmrc"
cache: "pnpm" cache: "pnpm"
+9 -49
View File
@@ -4,8 +4,9 @@ updates:
- package-ecosystem: npm - package-ecosystem: npm
directory: "/" directory: "/"
schedule: schedule:
interval: daily interval: cron
open-pull-requests-limit: 20 cronjob: every day at 5am
open-pull-requests-limit: 10
commit-message: commit-message:
prefix: "build(deps)" prefix: "build(deps)"
prefix-development: "build(deps-dev)" prefix-development: "build(deps-dev)"
@@ -14,56 +15,14 @@ updates:
ignore: ignore:
- dependency-name: "@types/node" - dependency-name: "@types/node"
update-types: ["version-update:semver-major"] update-types: ["version-update:semver-major"]
groups:
react:
patterns:
- "react"
- "react-dom"
- "react-*"
- "@types/react"
- "@types/react-dom"
redux:
patterns:
- "redux"
- "@reduxjs/*"
- "react-redux"
vite:
patterns:
- "vite"
- "@vitejs/*"
tailwind:
patterns:
- "tailwindcss"
- "@tailwindcss/*"
- "daisyui"
vitest:
patterns:
- "vitest"
- "@vitest/*"
eslint:
patterns:
- "eslint"
- "@eslint/*"
- "eslint-*"
- "typescript-eslint"
typescript:
patterns:
- "typescript"
- "@types/*"
playwright:
patterns:
- "@playwright/*"
axios:
patterns:
- "axios"
- "axios-*"
# Maintain dependencies for GitHub Actions # Maintain dependencies for GitHub Actions
- package-ecosystem: github-actions - package-ecosystem: github-actions
directory: "/" directory: "/"
schedule: schedule:
interval: daily interval: cron
open-pull-requests-limit: 20 cronjob: every day at 5am
open-pull-requests-limit: 10
commit-message: commit-message:
prefix: "ci(deps)" prefix: "ci(deps)"
prefix-development: "ci(deps-dev)" prefix-development: "ci(deps-dev)"
@@ -74,8 +33,9 @@ updates:
- package-ecosystem: devcontainers - package-ecosystem: devcontainers
directory: "/" directory: "/"
schedule: schedule:
interval: daily interval: cron
open-pull-requests-limit: 20 cronjob: every day at 5am
open-pull-requests-limit: 10
commit-message: commit-message:
prefix: "build(deps)" prefix: "build(deps)"
prefix-development: "build(deps-dev)" prefix-development: "build(deps-dev)"
+29 -35
View File
@@ -31,21 +31,26 @@ jobs:
steps: steps:
- name: Checkout code - name: Checkout code
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 uses: actions/checkout@v6
- name: Run vercel-preparation.sh
run: |
chmod +x ./vercel-preparation.sh
./vercel-preparation.sh
- name: Install Dependencies - name: Install Dependencies
uses: ./.github/actions/install-dependencies uses: ./.github/actions/install-dependencies
with: with:
node-version: ${{ matrix.node }} node-version: ${{ matrix.node }}
- name: Build packages
run: pnpm run build:packages
- name: Build frontend - name: Build frontend
run: pnpm run build:frontend run: pnpm --filter frontend run build
- name: Run tests - name: Run frontend tests
run: pnpm run test run: pnpm --filter frontend run test
- name: Run backend tests
run: pnpm --filter github-readme-stats run test
frontend-test-e2e: frontend-test-e2e:
name: Frontend E2E test name: Frontend E2E test
@@ -59,42 +64,21 @@ jobs:
steps: steps:
- name: Checkout code - name: Checkout code
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 uses: actions/checkout@v6
- name: Install Dependencies - name: Install Dependencies
uses: ./.github/actions/install-dependencies uses: ./.github/actions/install-dependencies
- name: Build packages - name: Run vercel-preparation.sh
run: pnpm run build:packages run: |
chmod +x ./vercel-preparation.sh
./vercel-preparation.sh
- name: Install Playwright Browsers - name: Install Playwright Browsers
run: pnpm exec playwright install --with-deps run: pnpm exec playwright install --with-deps
- name: Run Playwright tests - name: Run Playwright tests
run: pnpm --filter ./apps/frontend/ run test:e2e run: pnpm --filter frontend run test:e2e
backend-test-e2e:
name: Backend E2E test
runs-on: ubuntu-latest
permissions:
contents: read
continue-on-error: true
steps:
- name: Checkout code
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- name: Install Dependencies
uses: ./.github/actions/install-dependencies
- name: Build packages
run: pnpm run build:packages
- name: Run backend end-to-end tests
run: pnpm --filter ./apps/backend/ run test:e2e
code-checks: code-checks:
name: Code checks name: Code checks
@@ -106,7 +90,17 @@ jobs:
steps: steps:
- name: Checkout code - name: Checkout code
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 uses: actions/checkout@v6
# Heads up!
#
# 1. Execution of this script is needed to resolve `.vercel` folder from `apps/frontend/src/components/Card/SVG.js`
# 2. This scripts removes `apps/backend/node_modules` breaking ESLints module resolution.
# Dependency installation must occur after running ./vercel-preparation.sh.
- name: Run vercel-preparation.sh
run: |
chmod +x ./vercel-preparation.sh
./vercel-preparation.sh
- name: Install Dependencies - name: Install Dependencies
uses: ./.github/actions/install-dependencies uses: ./.github/actions/install-dependencies
+4 -4
View File
@@ -4,7 +4,7 @@ on:
branches: branches:
- master - master
paths: paths:
- "packages/core/src/themes/index.js" - "apps/backend/themes/index.js"
workflow_dispatch: workflow_dispatch:
permissions: {} permissions: {}
@@ -30,17 +30,17 @@ jobs:
steps: steps:
- name: Checkout code - name: Checkout code
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 uses: actions/checkout@v6
- name: Install Dependencies - name: Install Dependencies
uses: ./.github/actions/install-dependencies uses: ./.github/actions/install-dependencies
- name: Generate readme - name: Generate readme
run: | run: |
pnpm --filter ./packages/core/ run theme-readme-gen pnpm --filter github-readme-stats run theme-readme-gen
- name: Create Pull Request if themes README has changed - name: Create Pull Request if themes README has changed
uses: peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1 # v8 uses: peter-evans/create-pull-request@v8
with: with:
commit-message: "feat(backend): update themes README" commit-message: "feat(backend): update themes README"
branch: "update_themes_readme/patch" branch: "update_themes_readme/patch"
-30
View File
@@ -1,30 +0,0 @@
name: Publish core package to npm
on:
workflow_dispatch:
release:
types: [released]
permissions: {}
jobs:
release:
runs-on: ubuntu-latest
permissions:
contents: read
id-token: write
steps:
- name: Checkout code
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- name: Install Dependencies
uses: ./.github/actions/install-dependencies
- name: Verify core package
run: |
pnpm --filter @stats-organization/github-readme-stats-core run typecheck
pnpm test
pnpm build:packages
pnpm --filter @stats-organization/github-readme-stats-core pack --dry-run
- name: Publish core package to npm
run: pnpm --filter @stats-organization/github-readme-stats-core publish --provenance --access public --no-git-checks
+1 -5
View File
@@ -15,16 +15,12 @@ on:
jobs: jobs:
triggerRepeatRecent: triggerRepeatRecent:
if: |
github.repository == 'anuraghazra/github-readme-stats' ||
github.repository == 'stats-organization/github-readme-stats' ||
github.repository == 'stats-organization/github-stats-extended'
name: Trigger server to update cached data. name: Trigger server to update cached data.
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Make Request - name: Make Request
id: myRequest id: myRequest
uses: fjogeleit/http-request-action@551353b829c3646756b2ec2b3694f819d7957495 # v2 uses: fjogeleit/http-request-action@v2
with: with:
url: "https://github-stats-extended.vercel.app/api/repeat-recent" url: "https://github-stats-extended.vercel.app/api/repeat-recent"
method: "POST" method: "POST"
+3 -3
View File
@@ -38,16 +38,16 @@ jobs:
steps: steps:
- name: Checkout code - name: Checkout code
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 uses: actions/checkout@v6
- name: Install Dependencies - name: Install Dependencies
uses: ./.github/actions/install-dependencies uses: ./.github/actions/install-dependencies
- name: Run update-languages-json.js script - name: Run update-languages-json.js script
run: pnpm --filter ./packages/core/ run generate-langs-json run: pnpm --filter github-readme-stats run generate-langs-json
- name: Create Pull Request if upstream language file is changed - name: Create Pull Request if upstream language file is changed
uses: peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1 # v8 uses: peter-evans/create-pull-request@v8
with: with:
commit-message: "feat(backend): update languages JSON" commit-message: "feat(backend): update languages JSON"
branch: "update_langs/patch" branch: "update_langs/patch"
-18
View File
@@ -1,18 +0,0 @@
name: Update version tags
on:
push:
branches-ignore:
- "**"
tags:
- "v*.*.*"
permissions: {}
jobs:
update-semver:
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- uses: haya14busa/action-update-semver@7d2c558640ea49e798d46539536190aff8c18715 # v1.5.1
+13 -4
View File
@@ -1,8 +1,18 @@
node_modules node_modules
.env.local
.env.development.local
.env.test.local
.env.production.local
# OS # OS
.DS_Store .DS_Store
# Logs
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# Project # Project
coverage coverage
@@ -16,12 +26,11 @@ apps/backend/vercel_token
apps/backend-copy apps/backend-copy
apps/frontend/.env apps/frontend/.env
apps/frontend/src/backend
apps/frontend/build
.turbo
build
build-ts build-ts
*.tsbuildinfo tsconfig.tsbuildinfo
# IDE # IDE
.idea/ .idea/
+2 -1
View File
@@ -1,3 +1,4 @@
pnpm lint-staged pnpm lint-staged
pnpm run lint pnpm run lint
pnpm test # TODO enable
# npm test
+8 -19
View File
@@ -5,14 +5,13 @@
<a href="https://github-stats-extended.vercel.app/api?username=anuraghazra"><img src="https://github-stats-extended.vercel.app/api?username=anuraghazra"></a> <a href="https://github-stats-extended.vercel.app/api?username=anuraghazra"><img src="https://github-stats-extended.vercel.app/api?username=anuraghazra"></a>
</div> </div>
This project is the [extended, actively maintained successor](docs/fork.md) of [github-readme-stats](https://github.com/anuraghazra/github-readme-stats). It generates [various stats cards](#card-types), e.g. about your GitHub contributions, your top languages, etc. You can [customize](#advanced-customization) the cards via multiple parameters. This project is an [extended version](docs/fork.md) of [github-readme-stats](https://github.com/anuraghazra/github-readme-stats). It generates [various stats cards](#card-types), e.g. about your GitHub contributions, your top languages, etc. You can [customize](#advanced-customization) the cards via multiple parameters.
# Table of Contents # Table of Contents
- [Quick Start](#quick-start) - [Quick Start](#quick-start)
- [Migration from github-readme-stats](#migration-from-github-readme-stats)
- [Card Types](#card-types) - [Card Types](#card-types)
- [Advanced Customization](#advanced-customization) - [Advanced Customization](#advanced-customization)
- [Run It Yourself](#run-it-yourself) - [Self-Hosting](#self-hosting)
- [Acknowledgements](#acknowledgements) - [Acknowledgements](#acknowledgements)
- [Contributing](#contributing) - [Contributing](#contributing)
@@ -28,16 +27,6 @@ This project is the [extended, actively maintained successor](docs/fork.md) of [
As more comfortable alternative, use the [GitHub-Stats-Extended Wizard](https://github-stats-extended.vercel.app/frontend) to create your custom stats card. Copy the generated markdown code and paste it into your [GitHub profile README](https://docs.github.com/en/account-and-profile/how-tos/profile-customization/managing-your-profile-readme#adding-a-profile-readme). Done! As more comfortable alternative, use the [GitHub-Stats-Extended Wizard](https://github-stats-extended.vercel.app/frontend) to create your custom stats card. Copy the generated markdown code and paste it into your [GitHub profile README](https://docs.github.com/en/account-and-profile/how-tos/profile-customization/managing-your-profile-readme#adding-a-profile-readme). Done!
# Migration from github-readme-stats
To migrate from [github-readme-stats](https://github.com/anuraghazra/github-readme-stats) you only need to change the domain from `github-readme-stats.vercel.app` to `github-stats-extended.vercel.app`:
```diff
- https://github-readme-stats.vercel.app/api?username=octocat&theme=radical
+ https://github-stats-extended.vercel.app/api?username=octocat&theme=radical
```
GitHub-Stats-Extended aims to be fully compatible with github-readme-stats. For more details see [Compatibility Notes](docs/fork.md#compatibility-notes).
# Card Types # Card Types
- Show your GitHub statistics: - Show your GitHub statistics:
@@ -66,13 +55,13 @@ GitHub-Stats-Extended aims to be fully compatible with github-readme-stats. For
# Advanced Customization # Advanced Customization
The [GitHub-Stats-Extended Wizard](https://github-stats-extended.vercel.app/frontend) offers some essential customization options. For more advanced customization check out the [advanced documentation](docs/advanced_documentation.md). The [GitHub-Stats-Extended Wizard](https://github-stats-extended.vercel.app/frontend) offers some essential customization options. For more advanced customization check out the [advanced documentation](docs/advanced_documentation.md).
# Run It Yourself
If you want to run GitHub-Stats-Extended on your own, there are two main deployment options: you can use [github-readme-stats-action](https://github.com/stats-organization/github-readme-stats-action) to generate cards in your own GitHub Actions workflow. Or you can self-host GitHub-Stats-Extended on Vercel.
See [Run It Yourself](docs/deploy.md) for detailed instructions.
# Acknowledgements # Acknowledgements
This project is based on [github-readme-stats](https://github.com/anuraghazra/github-readme-stats). On top of that project's functionality GitHub-Stats-Extended adds several new features and improvements. See [Fork Information](docs/fork.md) for a list of changes. The frontend added to GitHub-Stats-Extended is based on [GitHub Trends](https://github.com/avgupta456/github-trends). Big thanks to [@anuraghazra](https://github.com/anuraghazra), [@avgupta456](https://github.com/avgupta456), [@rickstaa](https://github.com/rickstaa), [@qwerty541](https://github.com/qwerty541) and everyone else who worked on these projects! ❤️ This project is based on [github-readme-stats](https://github.com/anuraghazra/github-readme-stats). On top of their functionality I added several new features and improvements. See [Fork Information](docs/fork.md) for a list of changes. The frontend I added to the project is based on [GitHub Trends](https://github.com/avgupta456/github-trends). Big thanks to [@anuraghazra](https://github.com/anuraghazra), [@avgupta456](https://github.com/avgupta456), [@rickstaa](https://github.com/rickstaa), [@qwerty541](https://github.com/qwerty541) and everyone else who worked on these projects! ❤️
# Self-Hosting
Since the GitHub API only allows a limited number of requests per hour, the public instance of GitHub-Stats-Extended at https://github-stats-extended.vercel.app/api could possibly hit the rate limiter. If you host your own instance you do not have to worry about anything. Also, if you don't want to give my GitHub-Stats-Extended instance access to your private contributions but still want to include these contributions in your stats, you can simply host your own instance.
See [Deploy on your own](docs/deploy.md) for various deployment options.
# Contributing # Contributing
Contributions are welcome! Contributions are welcome!
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "GitHub Stats Extended Dev", "name": "GitHub Stats Extended Dev",
"image": "mcr.microsoft.com/devcontainers/base:ubuntu", "image": "mcr.microsoft.com/devcontainers/base:ubuntu",
"features": { "features": {
"ghcr.io/devcontainers/features/node:1": { "version": "24" } "ghcr.io/devcontainers/features/node:1": { "version": "22" }
}, },
"forwardPorts": [3000], "forwardPorts": [3000],
"portsAttributes": { "portsAttributes": {
@@ -1,5 +1,5 @@
{ {
"runtime": "nodejs24.x", "runtime": "nodejs22.x",
"handler": "router.js", "handler": "router.js",
"launcherType": "Nodejs" "launcherType": "Nodejs"
} }
@@ -0,0 +1,76 @@
/* eslint-disable import-x/no-unresolved */
import { default as authenticate } from "./api-renamed/authenticate.js";
import { default as deleteUser } from "./api-renamed/delete-user.js";
import { default as downgrade } from "./api-renamed/downgrade.js";
import { default as gist } from "./api-renamed/gist.js";
import { default as api } from "./api-renamed/index.js";
import { default as pin } from "./api-renamed/pin.js";
import { default as repeatRecent } from "./api-renamed/repeat-recent.js";
import { default as patInfo } from "./api-renamed/status/pat-info.js";
import { default as statusUp } from "./api-renamed/status/up.js";
import { default as topLangs } from "./api-renamed/top-langs.js";
import { default as userAccess } from "./api-renamed/user-access.js";
import { default as wakatimeProxy } from "./api-renamed/wakatime-proxy.js";
import { default as wakatime } from "./api-renamed/wakatime.js";
export default async (req, res) => {
// remaining code expects express.js-like request and response objects
res.send = function (data) {
if (typeof data === "object") {
res.setHeader("Content-Type", "application/json");
res.end(JSON.stringify(data));
} else if (typeof data === "string") {
res.end(data);
} else {
res.end(String(data));
}
};
const url = new URL(req.url, "https://localhost");
req.query = Object.fromEntries(url.searchParams.entries());
switch (url.pathname) {
case "/api":
await api(req, res);
break;
case "/api/gist":
await gist(req, res);
break;
case "/api/pin":
await pin(req, res);
break;
case "/api/top-langs":
await topLangs(req, res);
break;
case "/api/wakatime":
await wakatime(req, res);
break;
case "/api/wakatime-proxy":
await wakatimeProxy(req, res);
break;
case "/api/repeat-recent":
await repeatRecent(req, res);
break;
case "/api/status/pat-info":
await patInfo(req, res);
break;
case "/api/status/up":
await statusUp(req, res);
break;
case "/api/authenticate":
await authenticate(req, res);
break;
case "/api/delete-user":
await deleteUser(req, res);
break;
case "/api/user-access":
await userAccess(req, res);
break;
case "/api/downgrade":
await downgrade(req, res);
break;
default:
res.statusCode = 404;
res.end("Not Found");
break;
}
};
+1 -2
View File
@@ -1,5 +1,4 @@
import { logger } from "@stats-organization/github-readme-stats-core"; import { logger } from "../src/common/log.js";
import { authenticate } from "../src/users.js"; import { authenticate } from "../src/users.js";
/** /**
+1 -2
View File
@@ -1,6 +1,5 @@
import { logger } from "@stats-organization/github-readme-stats-core";
import { deleteUser } from "../src/common/database.js"; import { deleteUser } from "../src/common/database.js";
import { logger } from "../src/common/log.js";
/** /**
* @param {any} req The request. * @param {any} req The request.
+1 -1
View File
@@ -1,7 +1,7 @@
import { logger } from "@stats-organization/github-readme-stats-core";
import axios from "axios"; import axios from "axios";
import { deleteUser, getUserAccessByKey } from "../src/common/database.js"; import { deleteUser, getUserAccessByKey } from "../src/common/database.js";
import { logger } from "../src/common/log.js";
export default async (req, res) => { export default async (req, res) => {
// We could optimize this method by doing both database operations in one statement, using "DELETE ... RETURNING ..." // We could optimize this method by doing both database operations in one statement, using "DELETE ... RETURNING ..."
+129
View File
@@ -0,0 +1,129 @@
// @ts-check
import { renderGistCard } from "../src/cards/gist.js";
import { guardAccess } from "../src/common/access.js";
import {
CACHE_TTL,
resolveCacheSeconds,
setCacheHeaders,
setErrorCacheHeaders,
} from "../src/common/cache.js";
import { storeRequest } from "../src/common/database.js";
import {
MissingParamError,
retrieveSecondaryMessage,
} from "../src/common/error.js";
import { parseBoolean } from "../src/common/ops.js";
import { renderError } from "../src/common/render.js";
import { fetchGist } from "../src/fetchers/gist.js";
import { isLocaleAvailable } from "../src/translations.js";
// @ts-ignore
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,
hide_border,
} = req.query;
res.setHeader("Content-Type", "image/svg+xml");
const access = guardAccess({
res,
id,
type: "gist",
colors: {
title_color,
text_color,
bg_color,
border_color,
theme,
},
});
if (!access.isPassed) {
return access.result;
}
if (locale && !isLocaleAvailable(locale)) {
return res.send(
renderError({
message: "Something went wrong",
secondaryMessage: "Language not found",
renderOptions: {
title_color,
text_color,
bg_color,
border_color,
theme,
},
}),
);
}
try {
await storeRequest(req);
const gistData = await fetchGist(id);
const cacheSeconds = resolveCacheSeconds({
requested: parseInt(cache_seconds, 10),
def: CACHE_TTL.GIST_CARD.DEFAULT,
min: CACHE_TTL.GIST_CARD.MIN,
max: CACHE_TTL.GIST_CARD.MAX,
});
setCacheHeaders(res, cacheSeconds);
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),
hide_border: parseBoolean(hide_border),
}),
);
} catch (err) {
setErrorCacheHeaders(res);
if (err instanceof Error) {
return res.send(
renderError({
message: err.message,
secondaryMessage: retrieveSecondaryMessage(err),
renderOptions: {
title_color,
text_color,
bg_color,
border_color,
theme,
show_repo_link: !(err instanceof MissingParamError),
},
}),
);
}
return res.send(
renderError({
message: "An unknown error occurred",
renderOptions: {
title_color,
text_color,
bg_color,
border_color,
theme,
},
}),
);
}
};
@@ -1,18 +1,26 @@
// @ts-check // @ts-check
import { renderStatsCard } from "../cards/stats.js"; import { renderStatsCard } from "../src/cards/stats.js";
import { guardAccess } from "../src/common/access.js";
import {
CACHE_TTL,
resolveCacheSeconds,
setCacheHeaders,
setErrorCacheHeaders,
} from "../src/common/cache.js";
import { storeRequest } from "../src/common/database.js";
import { import {
MissingParamError, MissingParamError,
retrieveSecondaryMessage, retrieveSecondaryMessage,
} from "../common/error.js"; } from "../src/common/error.js";
import { parseArray, parseBoolean } from "../common/ops.js"; import { parseArray, parseBoolean } from "../src/common/ops.js";
import { renderError } from "../common/render.js"; import { renderError } from "../src/common/render.js";
import { fetchStats } from "../fetchers/stats.js"; import { fetchStats } from "../src/fetchers/stats.js";
import { isLocaleAvailable } from "../translations.js"; import { isLocaleAvailable } from "../src/translations.js";
// @ts-ignore // @ts-ignore
export default async ( export default async (req, res) => {
{ const {
username, username,
repo, repo,
owner, owner,
@@ -32,6 +40,7 @@ export default async (
text_bold, text_bold,
bg_color, bg_color,
theme, theme,
cache_seconds,
exclude_repo, exclude_repo,
custom_title, custom_title,
locale, locale,
@@ -43,13 +52,28 @@ export default async (
border_color, border_color,
rank_icon, rank_icon,
show, show,
}, } = req.query;
pat = null, res.setHeader("Content-Type", "image/svg+xml");
) => {
const access = guardAccess({
res,
id: username,
type: "username",
colors: {
title_color,
text_color,
bg_color,
border_color,
theme,
},
});
if (!access.isPassed) {
return access.result;
}
if (locale && !isLocaleAvailable(locale)) { if (locale && !isLocaleAvailable(locale)) {
return { return res.send(
status: "error - permanent", renderError({
content: renderError({
message: "Something went wrong", message: "Something went wrong",
secondaryMessage: "Language not found", secondaryMessage: "Language not found",
renderOptions: { renderOptions: {
@@ -60,7 +84,7 @@ export default async (
theme, theme,
}, },
}), }),
}; );
} }
const safePattern = /^[-\w/.,]+$/; const safePattern = /^[-\w/.,]+$/;
@@ -69,9 +93,8 @@ export default async (
(repo && !safePattern.test(repo)) || (repo && !safePattern.test(repo)) ||
(owner && !safePattern.test(owner)) (owner && !safePattern.test(owner))
) { ) {
return { return res.send(
status: "error - permanent", renderError({
content: renderError({
message: "Something went wrong", message: "Something went wrong",
secondaryMessage: secondaryMessage:
"Username, repository or owner contains unsafe characters", "Username, repository or owner contains unsafe characters",
@@ -83,10 +106,11 @@ export default async (
theme, theme,
}, },
}), }),
}; );
} }
try { try {
await storeRequest(req);
const showStats = parseArray(show); const showStats = parseArray(show);
const repoOwner = parseArray(owner); const repoOwner = parseArray(owner);
let repository = parseArray(repo); let repository = parseArray(repo);
@@ -111,12 +135,18 @@ export default async (
showStats.includes("issues_authored"), showStats.includes("issues_authored"),
showStats.includes("issues_commented"), showStats.includes("issues_commented"),
parseArray(role), parseArray(role),
pat,
); );
const cacheSeconds = resolveCacheSeconds({
requested: parseInt(cache_seconds, 10),
def: CACHE_TTL.STATS_CARD.DEFAULT,
min: CACHE_TTL.STATS_CARD.MIN,
max: CACHE_TTL.STATS_CARD.MAX,
});
return { setCacheHeaders(res, cacheSeconds);
status: "success",
content: renderStatsCard( return res.send(
renderStatsCard(
stats, stats,
{ {
hide: parseArray(hide), hide: parseArray(hide),
@@ -149,12 +179,12 @@ export default async (
repository, repository,
repoOwner, repoOwner,
), ),
}; );
} catch (err) { } catch (err) {
setErrorCacheHeaders(res);
if (err instanceof Error) { if (err instanceof Error) {
return { return res.send(
status: "error - temporary", renderError({
content: renderError({
message: err.message, message: err.message,
secondaryMessage: retrieveSecondaryMessage(err), secondaryMessage: retrieveSecondaryMessage(err),
renderOptions: { renderOptions: {
@@ -166,11 +196,10 @@ export default async (
show_repo_link: !(err instanceof MissingParamError), show_repo_link: !(err instanceof MissingParamError),
}, },
}), }),
}; );
} }
return { return res.send(
status: "error - temporary", renderError({
content: renderError({
message: "An unknown error occurred", message: "An unknown error occurred",
renderOptions: { renderOptions: {
title_color, title_color,
@@ -180,6 +209,6 @@ export default async (
theme, theme,
}, },
}), }),
}; );
} }
}; };
@@ -1,18 +1,26 @@
// @ts-check // @ts-check
import { renderRepoCard } from "../cards/repo.js"; import { renderRepoCard } from "../src/cards/repo.js";
import { guardAccess } from "../src/common/access.js";
import {
CACHE_TTL,
resolveCacheSeconds,
setCacheHeaders,
setErrorCacheHeaders,
} from "../src/common/cache.js";
import { storeRequest } from "../src/common/database.js";
import { import {
MissingParamError, MissingParamError,
retrieveSecondaryMessage, retrieveSecondaryMessage,
} from "../common/error.js"; } from "../src/common/error.js";
import { parseArray, parseBoolean } from "../common/ops.js"; import { parseArray, parseBoolean } from "../src/common/ops.js";
import { renderError } from "../common/render.js"; import { renderError } from "../src/common/render.js";
import { fetchRepo } from "../fetchers/repo.js"; import { fetchRepo } from "../src/fetchers/repo.js";
import { isLocaleAvailable } from "../translations.js"; import { isLocaleAvailable } from "../src/translations.js";
// @ts-ignore // @ts-ignore
export default async ( export default async (req, res) => {
{ const {
username, username,
repo, repo,
hide_border, hide_border,
@@ -23,23 +31,39 @@ export default async (
card_width, card_width,
theme, theme,
show_owner, show_owner,
browser_rendering,
show, show,
show_icons, show_icons,
number_format, number_format,
text_bold, text_bold,
line_height, line_height,
cache_seconds,
locale, locale,
border_radius, border_radius,
border_color, border_color,
description_lines_count, description_lines_count,
}, } = req.query;
pat = null,
) => { res.setHeader("Content-Type", "image/svg+xml");
const access = guardAccess({
res,
id: username,
type: "username",
colors: {
title_color,
text_color,
bg_color,
border_color,
theme,
},
});
if (!access.isPassed) {
return access.result;
}
if (locale && !isLocaleAvailable(locale)) { if (locale && !isLocaleAvailable(locale)) {
return { return res.send(
status: "error - permanent", renderError({
content: renderError({
message: "Something went wrong", message: "Something went wrong",
secondaryMessage: "Language not found", secondaryMessage: "Language not found",
renderOptions: { renderOptions: {
@@ -50,7 +74,7 @@ export default async (
theme, theme,
}, },
}), }),
}; );
} }
const safePattern = /^[-\w/.,]+$/; const safePattern = /^[-\w/.,]+$/;
@@ -58,9 +82,8 @@ export default async (
(username && !safePattern.test(username)) || (username && !safePattern.test(username)) ||
(repo && !safePattern.test(repo)) (repo && !safePattern.test(repo))
) { ) {
return { return res.send(
status: "error - permanent", renderError({
content: renderError({
message: "Something went wrong", message: "Something went wrong",
secondaryMessage: "Username or repository contains unsafe characters", secondaryMessage: "Username or repository contains unsafe characters",
renderOptions: { renderOptions: {
@@ -71,10 +94,11 @@ export default async (
theme, theme,
}, },
}), }),
}; );
} }
try { try {
await storeRequest(req);
const showStats = parseArray(show); const showStats = parseArray(show);
const repoData = await fetchRepo( const repoData = await fetchRepo(
username, username,
@@ -84,12 +108,19 @@ export default async (
showStats.includes("prs_reviewed"), showStats.includes("prs_reviewed"),
showStats.includes("issues_authored"), showStats.includes("issues_authored"),
showStats.includes("issues_commented"), showStats.includes("issues_commented"),
pat,
); );
return { const cacheSeconds = resolveCacheSeconds({
status: "success", requested: parseInt(cache_seconds, 10),
content: renderRepoCard(repoData, { def: CACHE_TTL.PIN_CARD.DEFAULT,
min: CACHE_TTL.PIN_CARD.MIN,
max: CACHE_TTL.PIN_CARD.MAX,
});
setCacheHeaders(res, cacheSeconds);
return res.send(
renderRepoCard(repoData, {
hide_border: parseBoolean(hide_border), hide_border: parseBoolean(hide_border),
title_color, title_color,
icon_color, icon_color,
@@ -100,7 +131,6 @@ export default async (
border_color, border_color,
card_width_input: parseInt(card_width, 10), card_width_input: parseInt(card_width, 10),
show_owner: parseBoolean(show_owner), show_owner: parseBoolean(show_owner),
browser_rendering: parseBoolean(browser_rendering),
show: showStats, show: showStats,
show_icons: parseBoolean(show_icons), show_icons: parseBoolean(show_icons),
number_format, number_format,
@@ -110,12 +140,12 @@ export default async (
locale: locale ? locale.toLowerCase() : null, locale: locale ? locale.toLowerCase() : null,
description_lines_count, description_lines_count,
}), }),
}; );
} catch (err) { } catch (err) {
setErrorCacheHeaders(res);
if (err instanceof Error) { if (err instanceof Error) {
return { return res.send(
status: "error - temporary", renderError({
content: renderError({
message: err.message, message: err.message,
secondaryMessage: retrieveSecondaryMessage(err), secondaryMessage: retrieveSecondaryMessage(err),
renderOptions: { renderOptions: {
@@ -127,11 +157,10 @@ export default async (
show_repo_link: !(err instanceof MissingParamError), show_repo_link: !(err instanceof MissingParamError),
}, },
}), }),
}; );
} }
return { return res.send(
status: "error - temporary", renderError({
content: renderError({
message: "An unknown error occurred", message: "An unknown error occurred",
renderOptions: { renderOptions: {
title_color, title_color,
@@ -141,6 +170,6 @@ export default async (
theme, theme,
}, },
}), }),
}; );
} }
}; };
+11 -13
View File
@@ -6,12 +6,10 @@
* *
* @description This function is currently rate limited to 1 request per 3 minutes. * @description This function is currently rate limited to 1 request per 3 minutes.
*/ */
import {
dateDiff, import { request } from "../../src/common/http.js";
getConfig, import { logger } from "../../src/common/log.js";
logger, import { dateDiff } from "../../src/common/ops.js";
request,
} from "@stats-organization/github-readme-stats-core";
export const RATE_LIMIT_SECONDS = 60 * 3; // 1 request per 3 minutes export const RATE_LIMIT_SECONDS = 60 * 3; // 1 request per 3 minutes
@@ -41,7 +39,7 @@ const uptimeFetcher = (variables, token) => {
}; };
const getAllPATs = () => { const getAllPATs = () => {
return getConfig().pats; return Object.keys(process.env).filter((key) => /PAT_\d*$/.exec(key));
}; };
/** /**
@@ -63,7 +61,7 @@ const getPATInfo = async (fetcher, variables) => {
for (const pat of PATs) { for (const pat of PATs) {
try { try {
const response = await fetcher(variables, pat.value); const response = await fetcher(variables, process.env[pat]);
const errors = response.data.errors; const errors = response.data.errors;
const hasErrors = Boolean(errors); const hasErrors = Boolean(errors);
const errorType = errors?.[0]?.type; const errorType = errors?.[0]?.type;
@@ -73,7 +71,7 @@ const getPATInfo = async (fetcher, variables) => {
// Store PATs with errors. // Store PATs with errors.
if (hasErrors && errorType !== "RATE_LIMITED") { if (hasErrors && errorType !== "RATE_LIMITED") {
details[pat.name] = { details[pat] = {
status: "error", status: "error",
error: { error: {
type: errors[0].type, type: errors[0].type,
@@ -84,13 +82,13 @@ const getPATInfo = async (fetcher, variables) => {
} else if (isRateLimited) { } else if (isRateLimited) {
const date1 = new Date(); const date1 = new Date();
const date2 = new Date(response.data?.data?.rateLimit?.resetAt); const date2 = new Date(response.data?.data?.rateLimit?.resetAt);
details[pat.name] = { details[pat] = {
status: "exhausted", status: "exhausted",
remaining: 0, remaining: 0,
resetIn: dateDiff(date2, date1) + " minutes", resetIn: dateDiff(date2, date1) + " minutes",
}; };
} else { } else {
details[pat.name] = { details[pat] = {
status: "valid", status: "valid",
remaining: response.data.data.rateLimit.remaining, remaining: response.data.data.rateLimit.remaining,
}; };
@@ -99,11 +97,11 @@ const getPATInfo = async (fetcher, variables) => {
// Store the PAT if it is expired. // Store the PAT if it is expired.
const errorMessage = err.response?.data?.message?.toLowerCase(); const errorMessage = err.response?.data?.message?.toLowerCase();
if (errorMessage === "bad credentials") { if (errorMessage === "bad credentials") {
details[pat.name] = { details[pat] = {
status: "expired", status: "expired",
}; };
} else if (errorMessage === "sorry. your account was suspended.") { } else if (errorMessage === "sorry. your account was suspended.") {
details[pat.name] = { details[pat] = {
status: "suspended", status: "suspended",
}; };
} else { } else {
+4 -6
View File
@@ -7,11 +7,9 @@
* @description This function is currently rate limited to 1 request per 3 minutes. * @description This function is currently rate limited to 1 request per 3 minutes.
*/ */
import { import { request } from "../../src/common/http.js";
logger, import { logger } from "../../src/common/log.js";
request, import { default as retryer } from "../../src/common/retryer.js";
retryer,
} from "@stats-organization/github-readme-stats-core";
export const RATE_LIMIT_SECONDS = 60 * 3; // 1 request per 3 minutes export const RATE_LIMIT_SECONDS = 60 * 3; // 1 request per 3 minutes
@@ -89,7 +87,7 @@ export default async (req, res) => {
try { try {
let PATsValid = true; let PATsValid = true;
try { try {
await retryer(uptimeFetcher, {}); await retryer(uptimeFetcher, null, {});
} catch (err) { } catch (err) {
// Resolve eslint no-unused-vars // Resolve eslint no-unused-vars
err; err;
@@ -1,18 +1,26 @@
// @ts-check // @ts-check
import { renderTopLanguages } from "../cards/top-languages.js"; import { renderTopLanguages } from "../src/cards/top-languages.js";
import { guardAccess } from "../src/common/access.js";
import {
CACHE_TTL,
resolveCacheSeconds,
setCacheHeaders,
setErrorCacheHeaders,
} from "../src/common/cache.js";
import { storeRequest } from "../src/common/database.js";
import { import {
MissingParamError, MissingParamError,
retrieveSecondaryMessage, retrieveSecondaryMessage,
} from "../common/error.js"; } from "../src/common/error.js";
import { parseArray, parseBoolean } from "../common/ops.js"; import { parseArray, parseBoolean } from "../src/common/ops.js";
import { renderError } from "../common/render.js"; import { renderError } from "../src/common/render.js";
import { fetchTopLanguages } from "../fetchers/top-languages.js"; import { fetchTopLanguages } from "../src/fetchers/top-languages.js";
import { isLocaleAvailable } from "../translations.js"; import { isLocaleAvailable } from "../src/translations.js";
// @ts-ignore // @ts-ignore
export default async ( export default async (req, res) => {
{ const {
username, username,
hide, hide,
hide_title, hide_title,
@@ -23,6 +31,7 @@ export default async (
bg_color, bg_color,
prog_bar_bg_color, prog_bar_bg_color,
theme, theme,
cache_seconds,
layout, layout,
langs_count, langs_count,
exclude_repo, exclude_repo,
@@ -35,15 +44,29 @@ export default async (
role, role,
disable_animations, disable_animations,
hide_progress, hide_progress,
hide_values,
stats_format, stats_format,
}, } = req.query;
pat = null, res.setHeader("Content-Type", "image/svg+xml");
) => {
const access = guardAccess({
res,
id: username,
type: "username",
colors: {
title_color,
text_color,
bg_color,
border_color,
theme,
},
});
if (!access.isPassed) {
return access.result;
}
if (locale && !isLocaleAvailable(locale)) { if (locale && !isLocaleAvailable(locale)) {
return { return res.send(
status: "error - permanent", renderError({
content: renderError({
message: "Something went wrong", message: "Something went wrong",
secondaryMessage: "Locale not found", secondaryMessage: "Locale not found",
renderOptions: { renderOptions: {
@@ -54,7 +77,7 @@ export default async (
theme, theme,
}, },
}), }),
}; );
} }
if ( if (
@@ -62,9 +85,8 @@ export default async (
(typeof layout !== "string" || (typeof layout !== "string" ||
!["compact", "normal", "donut", "donut-vertical", "pie"].includes(layout)) !["compact", "normal", "donut", "donut-vertical", "pie"].includes(layout))
) { ) {
return { return res.send(
status: "error - permanent", renderError({
content: renderError({
message: "Something went wrong", message: "Something went wrong",
secondaryMessage: "Incorrect layout input", secondaryMessage: "Incorrect layout input",
renderOptions: { renderOptions: {
@@ -75,7 +97,7 @@ export default async (
theme, theme,
}, },
}), }),
}; );
} }
if ( if (
@@ -83,9 +105,8 @@ export default async (
(typeof stats_format !== "string" || (typeof stats_format !== "string" ||
!["bytes", "percentages"].includes(stats_format)) !["bytes", "percentages"].includes(stats_format))
) { ) {
return { return res.send(
status: "error - permanent", renderError({
content: renderError({
message: "Something went wrong", message: "Something went wrong",
secondaryMessage: "Incorrect stats_format input", secondaryMessage: "Incorrect stats_format input",
renderOptions: { renderOptions: {
@@ -96,22 +117,29 @@ export default async (
theme, theme,
}, },
}), }),
}; );
} }
try { try {
await storeRequest(req);
const topLangs = await fetchTopLanguages( const topLangs = await fetchTopLanguages(
username, username,
parseArray(exclude_repo), parseArray(exclude_repo),
size_weight, size_weight,
count_weight, count_weight,
parseArray(role), parseArray(role),
pat,
); );
const cacheSeconds = resolveCacheSeconds({
requested: parseInt(cache_seconds, 10),
def: CACHE_TTL.TOP_LANGS_CARD.DEFAULT,
min: CACHE_TTL.TOP_LANGS_CARD.MIN,
max: CACHE_TTL.TOP_LANGS_CARD.MAX,
});
return { setCacheHeaders(res, cacheSeconds);
status: "success",
content: renderTopLanguages(topLangs, { return res.send(
renderTopLanguages(topLangs, {
custom_title, custom_title,
hide_title: parseBoolean(hide_title), hide_title: parseBoolean(hide_title),
hide_border: parseBoolean(hide_border), hide_border: parseBoolean(hide_border),
@@ -129,15 +157,14 @@ export default async (
locale: locale ? locale.toLowerCase() : null, locale: locale ? locale.toLowerCase() : null,
disable_animations: parseBoolean(disable_animations), disable_animations: parseBoolean(disable_animations),
hide_progress: parseBoolean(hide_progress), hide_progress: parseBoolean(hide_progress),
hide_values: parseBoolean(hide_values),
stats_format, stats_format,
}), }),
}; );
} catch (err) { } catch (err) {
setErrorCacheHeaders(res);
if (err instanceof Error) { if (err instanceof Error) {
return { return res.send(
status: "error - temporary", renderError({
content: renderError({
message: err.message, message: err.message,
secondaryMessage: retrieveSecondaryMessage(err), secondaryMessage: retrieveSecondaryMessage(err),
renderOptions: { renderOptions: {
@@ -149,11 +176,10 @@ export default async (
show_repo_link: !(err instanceof MissingParamError), show_repo_link: !(err instanceof MissingParamError),
}, },
}), }),
}; );
} }
return { return res.send(
status: "error - temporary", renderError({
content: renderError({
message: "An unknown error occurred", message: "An unknown error occurred",
renderOptions: { renderOptions: {
title_color, title_color,
@@ -163,6 +189,6 @@ export default async (
theme, theme,
}, },
}), }),
}; );
} }
}; };
+1 -2
View File
@@ -1,6 +1,5 @@
import { logger } from "@stats-organization/github-readme-stats-core";
import { getUserAccessByKey } from "../src/common/database.js"; import { getUserAccessByKey } from "../src/common/database.js";
import { logger } from "../src/common/log.js";
/** /**
* @param {any} req The request. * @param {any} req The request.
+2 -4
View File
@@ -1,7 +1,5 @@
import { import { logger } from "../src/common/log.js";
fetchWakatimeStats, import { fetchWakatimeStats } from "../src/fetchers/wakatime.js";
logger,
} from "@stats-organization/github-readme-stats-core";
/** /**
* @param {any} req The request. * @param {any} req The request.
+148
View File
@@ -0,0 +1,148 @@
// @ts-check
import { renderWakatimeCard } from "../src/cards/wakatime.js";
import { guardAccess } from "../src/common/access.js";
import {
CACHE_TTL,
resolveCacheSeconds,
setCacheHeaders,
setErrorCacheHeaders,
} from "../src/common/cache.js";
import { storeRequest } from "../src/common/database.js";
import {
MissingParamError,
retrieveSecondaryMessage,
} from "../src/common/error.js";
import { parseArray, parseBoolean } from "../src/common/ops.js";
import { renderError } from "../src/common/render.js";
import { fetchWakatimeStats } from "../src/fetchers/wakatime.js";
import { isLocaleAvailable } from "../src/translations.js";
// @ts-ignore
export default async (req, res) => {
const {
username,
title_color,
icon_color,
hide_border,
card_width,
line_height,
text_color,
bg_color,
theme,
cache_seconds,
hide_title,
hide_progress,
custom_title,
locale,
layout,
langs_count,
hide,
api_domain,
border_radius,
border_color,
display_format,
disable_animations,
} = req.query;
res.setHeader("Content-Type", "image/svg+xml");
const access = guardAccess({
res,
id: username,
type: "wakatime",
colors: {
title_color,
text_color,
bg_color,
border_color,
theme,
},
});
if (!access.isPassed) {
return access.result;
}
if (locale && !isLocaleAvailable(locale)) {
return res.send(
renderError({
message: "Something went wrong",
secondaryMessage: "Language not found",
renderOptions: {
title_color,
text_color,
bg_color,
border_color,
theme,
},
}),
);
}
try {
await storeRequest(req);
const stats = await fetchWakatimeStats({ username, api_domain });
const cacheSeconds = resolveCacheSeconds({
requested: parseInt(cache_seconds, 10),
def: CACHE_TTL.WAKATIME_CARD.DEFAULT,
min: CACHE_TTL.WAKATIME_CARD.MIN,
max: CACHE_TTL.WAKATIME_CARD.MAX,
});
setCacheHeaders(res, cacheSeconds);
return res.send(
renderWakatimeCard(stats, {
custom_title,
hide_title: parseBoolean(hide_title),
hide_border: parseBoolean(hide_border),
card_width: parseInt(card_width, 10),
hide: parseArray(hide),
line_height,
title_color,
icon_color,
text_color,
bg_color,
theme,
hide_progress,
border_radius,
border_color,
locale: locale ? locale.toLowerCase() : null,
layout,
langs_count,
display_format,
disable_animations: parseBoolean(disable_animations),
}),
);
} catch (err) {
setErrorCacheHeaders(res);
if (err instanceof Error) {
return res.send(
renderError({
message: err.message,
secondaryMessage: retrieveSecondaryMessage(err),
renderOptions: {
title_color,
text_color,
bg_color,
border_color,
theme,
show_repo_link: !(err instanceof MissingParamError),
},
}),
);
}
return res.send(
renderError({
message: "An unknown error occurred",
renderOptions: {
title_color,
text_color,
bg_color,
border_color,
theme,
},
}),
);
}
};
+13 -2
View File
@@ -1,10 +1,21 @@
import express from "express"; import express from "express";
import router from "./router.js"; import gistCard from "./api-renamed/gist.js";
import statsCard from "./api-renamed/index.js";
import repoCard from "./api-renamed/pin.js";
import langCard from "./api-renamed/top-langs.js";
import wakatimeCard from "./api-renamed/wakatime.js";
const app = express(); const app = express();
const router = express.Router();
app.use(router); router.get("/", statsCard);
router.get("/pin", repoCard);
router.get("/top-langs", langCard);
router.get("/wakatime", wakatimeCard);
router.get("/gist", gistCard);
app.use("/api", router);
const port = process.env.PORT || process.env.port || 9000; const port = process.env.PORT || process.env.port || 9000;
app.listen(port, "0.0.0.0", () => { app.listen(port, "0.0.0.0", () => {
-1
View File
@@ -1 +0,0 @@
export { default as router } from "./router.js";
+38 -15
View File
@@ -1,29 +1,52 @@
{ {
"name": "@stats-organization/github-readme-stats-backend", "name": "github-readme-stats",
"version": "1.0.0",
"description": "Dynamically generate stats for your GitHub readme",
"keywords": [
"github-readme-stats",
"readme-stats",
"cards",
"card-generator"
],
"main": "src/index.js",
"type": "module", "type": "module",
"license": "MIT", "homepage": "https://github-stats-extended.vercel.app/frontend",
"engines": { "bugs": {
"node": "24.x" "url": "https://github.com/stats-organization/github-stats-extended/issues"
},
"repository": {
"type": "git",
"url": "https://github.com/stats-organization/github-stats-extended.git"
}, },
"private": true,
"main": "index.js",
"scripts": { "scripts": {
"test": "vitest", "test": "vitest",
"test:update:snapshot": "vitest -u", "test:update:snapshot": "vitest -u",
"test:e2e": "vitest --config vitest.config.e2e.ts", "test:e2e": "vitest --config vitest.config.e2e.ts",
"bench": "vitest bench --run --config vitest.config.bench.ts", "theme-readme-gen": "node scripts/generate-theme-doc",
"lint": "eslint", "generate-langs-json": "node scripts/generate-langs-json",
"typecheck": "tsc -p tsconfig.typecheck.json" "bench": "vitest bench --run --config vitest.config.bench.ts"
}, },
"author": "Anurag Hazra",
"license": "MIT",
"devDependencies": { "devDependencies": {
"axios-mock-adapter": "2.1.0", "@testing-library/dom": "^10.4.1",
"express": "5.2.1", "@testing-library/jest-dom": "^6.9.1",
"jsdom": "catalog:default", "@uppercod/css-to-object": "^1.1.1",
"@vitest/coverage-v8": "catalog:default",
"axios-mock-adapter": "^2.1.0",
"express": "^5.2.1",
"js-yaml": "^4.1.1",
"jsdom": "28.1.0",
"vitest": "catalog:default" "vitest": "catalog:default"
}, },
"dependencies": { "dependencies": {
"axios": "catalog:default", "axios": "^1.13.5",
"@stats-organization/github-readme-stats-core": "workspace:^", "emoji-name-map": "^2.0.3",
"pg": "^8.21.0" "github-username-regex": "^1.0.0",
"pg": "^8.18.0",
"word-wrap": "^1.2.5"
},
"engines": {
"node": "24.x"
} }
} }
File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 5.2 KiB

-225
View File
@@ -1,225 +0,0 @@
import {
api,
gist,
pin,
topLangs,
wakatime,
} from "@stats-organization/github-readme-stats-core";
import { default as authenticate } from "./api-renamed/authenticate.js";
import { default as deleteUser } from "./api-renamed/delete-user.js";
import { default as downgrade } from "./api-renamed/downgrade.js";
import { default as repeatRecent } from "./api-renamed/repeat-recent.js";
import { default as patInfo } from "./api-renamed/status/pat-info.js";
import { default as statusUp } from "./api-renamed/status/up.js";
import { default as userAccess } from "./api-renamed/user-access.js";
import { default as wakatimeProxy } from "./api-renamed/wakatime-proxy.js";
import { guardAccess } from "./src/common/access.js";
import {
CACHE_TTL,
resolveCacheSeconds,
setCacheHeaders,
setErrorCacheHeaders,
} from "./src/common/cache.js";
import { getUserAccessByName, storeRequest } from "./src/common/database.js";
const getGuardResult = (query, type, id) => {
const access = guardAccess({
id,
type,
colors: {
title_color: query.title_color,
text_color: query.text_color,
bg_color: query.bg_color,
border_color: query.border_color,
theme: query.theme,
},
});
if (access.isPassed) {
return null;
}
return {
status: "error - permanent",
content: access.result,
};
};
const getUserPat = async (username) => {
if (!username) {
return null;
}
const userAccess = await getUserAccessByName(username);
if (!userAccess?.token) {
return null;
}
return userAccess.token;
};
export default async (req, res) => {
const url = new URL(req.url, "https://localhost");
if (res.send === undefined) {
// remaining code expects express.js-like request and response objects
res.send = function (data) {
if (typeof data === "object") {
res.setHeader("Content-Type", "application/json");
res.end(JSON.stringify(data));
} else if (typeof data === "string") {
res.end(data);
} else {
res.end(String(data));
}
};
req.query = Object.fromEntries(url.searchParams.entries());
}
let result;
switch (url.pathname) {
case "/api": {
result = getGuardResult(req.query, "username", req.query.username);
if (!result) {
const userPat = await getUserPat(req.query.username);
result = await api(req.query, userPat);
}
if (result.status === "error - temporary") {
setErrorCacheHeaders(res);
} else {
const cacheSeconds = resolveCacheSeconds({
requested: parseInt(req.query.cache_seconds, 10),
def: CACHE_TTL.STATS_CARD.DEFAULT,
min: CACHE_TTL.STATS_CARD.MIN,
max: CACHE_TTL.STATS_CARD.MAX,
});
setCacheHeaders(res, cacheSeconds);
}
res.setHeader("Content-Type", "image/svg+xml");
res.end(result.content);
if (result.status !== "error - permanent") {
await storeRequest(req);
}
break;
}
case "/api/gist":
result =
getGuardResult(req.query, "gist", req.query.id) ??
(await gist(req.query));
if (result.status === "error - temporary") {
setErrorCacheHeaders(res);
} else {
const cacheSeconds = resolveCacheSeconds({
requested: parseInt(req.query.cache_seconds, 10),
def: CACHE_TTL.GIST_CARD.DEFAULT,
min: CACHE_TTL.GIST_CARD.MIN,
max: CACHE_TTL.GIST_CARD.MAX,
});
setCacheHeaders(res, cacheSeconds);
}
res.setHeader("Content-Type", "image/svg+xml");
res.end(result.content);
if (result.status !== "error - permanent") {
await storeRequest(req);
}
break;
case "/api/pin": {
result = getGuardResult(req.query, "username", req.query.username);
if (!result) {
const userPat = await getUserPat(req.query.username);
result = await pin(req.query, userPat);
}
if (result.status === "error - temporary") {
setErrorCacheHeaders(res);
} else {
const cacheSeconds = resolveCacheSeconds({
requested: parseInt(req.query.cache_seconds, 10),
def: CACHE_TTL.PIN_CARD.DEFAULT,
min: CACHE_TTL.PIN_CARD.MIN,
max: CACHE_TTL.PIN_CARD.MAX,
});
setCacheHeaders(res, cacheSeconds);
}
res.setHeader("Content-Type", "image/svg+xml");
res.end(result.content);
if (result.status !== "error - permanent") {
await storeRequest(req);
}
break;
}
case "/api/top-langs": {
result = getGuardResult(req.query, "username", req.query.username);
if (!result) {
const userPat = await getUserPat(req.query.username);
result = await topLangs(req.query, userPat);
}
if (result.status === "error - temporary") {
setErrorCacheHeaders(res);
} else {
const cacheSeconds = resolveCacheSeconds({
requested: parseInt(req.query.cache_seconds, 10),
def: CACHE_TTL.TOP_LANGS_CARD.DEFAULT,
min: CACHE_TTL.TOP_LANGS_CARD.MIN,
max: CACHE_TTL.TOP_LANGS_CARD.MAX,
});
setCacheHeaders(res, cacheSeconds);
}
res.setHeader("Content-Type", "image/svg+xml");
res.end(result.content);
if (result.status !== "error - permanent") {
await storeRequest(req);
}
break;
}
case "/api/wakatime":
result =
getGuardResult(req.query, "wakatime", req.query.username) ??
(await wakatime(req.query));
if (result.status === "error - temporary") {
setErrorCacheHeaders(res);
} else {
const cacheSeconds = resolveCacheSeconds({
requested: parseInt(req.query.cache_seconds, 10),
def: CACHE_TTL.WAKATIME_CARD.DEFAULT,
min: CACHE_TTL.WAKATIME_CARD.MIN,
max: CACHE_TTL.WAKATIME_CARD.MAX,
});
setCacheHeaders(res, cacheSeconds);
}
res.setHeader("Content-Type", "image/svg+xml");
res.end(result.content);
if (result.status !== "error - permanent") {
await storeRequest(req);
}
break;
case "/api/wakatime-proxy":
await wakatimeProxy(req, res);
break;
case "/api/repeat-recent":
await repeatRecent(req, res);
break;
case "/api/status/pat-info":
await patInfo(req, res);
break;
case "/api/status/up":
await statusUp(req, res);
break;
case "/api/authenticate":
await authenticate(req, res);
break;
case "/api/delete-user":
await deleteUser(req, res);
break;
case "/api/user-access":
await userAccess(req, res);
break;
case "/api/downgrade":
await downgrade(req, res);
break;
default:
res.statusCode = 404;
res.end("Not Found");
break;
}
};
@@ -1,8 +1,8 @@
import fs from "fs"; import fs from "fs";
import { themes } from "../src/themes/index.js"; import { themes } from "../themes/index.js";
const TARGET_FILE = "./src/themes/README.md"; const TARGET_FILE = "./themes/README.md";
const REPO_CARD_LINKS_FLAG = "<!-- REPO_CARD_LINKS -->"; const REPO_CARD_LINKS_FLAG = "<!-- REPO_CARD_LINKS -->";
const STAT_CARD_LINKS_FLAG = "<!-- STATS_CARD_LINKS -->"; const STAT_CARD_LINKS_FLAG = "<!-- STATS_CARD_LINKS -->";
@@ -8,23 +8,15 @@ import { icons } from "../common/icons.js";
import languageColors from "../common/languageColors.json" with { type: "json" }; import languageColors from "../common/languageColors.json" with { type: "json" };
import { parseEmojis } from "../common/ops.js"; import { parseEmojis } from "../common/ops.js";
import { import {
countWrappedLines,
createLanguageNode, createLanguageNode,
flexLayout, flexLayout,
iconWithLabel, iconWithLabel,
measureText, measureText,
wrappedTextNode,
wrappedTextStyles,
} from "../common/render.js"; } from "../common/render.js";
const ICON_SIZE = 16; const ICON_SIZE = 16;
const CARD_DEFAULT_WIDTH = 400; const CARD_DEFAULT_WIDTH = 400;
const X_OFFSET = 25;
const HEADER_MAX_LENGTH = 35; const HEADER_MAX_LENGTH = 35;
const DESCRIPTION_BOX_WIDTH = CARD_DEFAULT_WIDTH - 2 * X_OFFSET;
const DESCRIPTION_FONT_SIZE = 13;
const DESCRIPTION_LINE_HEIGHT_PX = 16;
const DESCRIPTION_MAX_LINES = 10;
/** /**
* @typedef {import('./types').GistCardOptions} GistCardOptions Gist card options. * @typedef {import('./types').GistCardOptions} GistCardOptions Gist card options.
@@ -50,7 +42,6 @@ const renderGistCard = (gistData, options = {}) => {
border_radius, border_radius,
border_color, border_color,
show_owner = false, show_owner = false,
browser_rendering = false,
hide_border = false, hide_border = false,
} = options; } = options;
@@ -65,50 +56,14 @@ const renderGistCard = (gistData, options = {}) => {
theme, theme,
}); });
const lineWidth = 59;
const linesLimit = 10;
const desc = parseEmojis(description || "No description provided"); const desc = parseEmojis(description || "No description provided");
const multiLineDescription = wrapTextMultiline(desc, lineWidth, linesLimit);
let descriptionLines, descriptionSvg; const descriptionLines = multiLineDescription.length;
if (browser_rendering) { const descriptionSvg = multiLineDescription
// The browser performs the actual text wrapping inside the foreignObject; .map((line) => `<tspan dy="1.2em" x="25">${encodeHTML(line)}</tspan>`)
// we only estimate the line count server-side so the SVG can reserve enough .join("");
// height. The estimate uses measureText for font-aware widths instead of a
// fixed character count.
descriptionLines = countWrappedLines(
desc,
DESCRIPTION_FONT_SIZE,
DESCRIPTION_BOX_WIDTH,
DESCRIPTION_MAX_LINES,
);
descriptionSvg = wrappedTextNode({
text: desc,
x: X_OFFSET,
y: -3,
width: DESCRIPTION_BOX_WIDTH,
height: descriptionLines * DESCRIPTION_LINE_HEIGHT_PX + 10, // 10px extra for "descenders" like g, j, q, p, y
lineCount: descriptionLines,
className: "description",
testId: "description-text",
});
} else {
const linesLimit = 10;
const multiLineDescription = wrapTextMultiline(
desc,
DESCRIPTION_BOX_WIDTH,
DESCRIPTION_FONT_SIZE,
linesLimit,
);
descriptionLines = multiLineDescription.length;
descriptionSvg = multiLineDescription
.map(
(line) =>
`<tspan dy="1.2em" x="${X_OFFSET}">${encodeHTML(line)}</tspan>`,
)
.join("");
descriptionSvg = `<text class="description" x="${X_OFFSET}" y="-5">
${descriptionSvg}
</text>`;
}
const lineHeight = descriptionLines > 3 ? 12 : 10; const lineHeight = descriptionLines > 3 ? 12 : 10;
const height = const height =
@@ -166,17 +121,16 @@ const renderGistCard = (gistData, options = {}) => {
}); });
card.setCSS(` card.setCSS(`
.description { .description { font: 400 13px 'Segoe UI', Ubuntu, Sans-Serif; fill: ${textColor} }
font: 400 ${DESCRIPTION_FONT_SIZE}px 'Segoe UI', Ubuntu, Sans-Serif;fill: ${textColor};
${browser_rendering ? wrappedTextStyles(textColor) : ""}
}
.gray { font: 400 12px 'Segoe UI', Ubuntu, Sans-Serif; fill: ${textColor} } .gray { font: 400 12px 'Segoe UI', Ubuntu, Sans-Serif; fill: ${textColor} }
.icon { fill: ${iconColor} } .icon { fill: ${iconColor} }
`); `);
card.setHideBorder(hide_border); card.setHideBorder(hide_border);
return card.render(` return card.render(`
${descriptionSvg} <text class="description" x="25" y="-5">
${descriptionSvg}
</text>
<g transform="translate(30, ${height - 75})"> <g transform="translate(30, ${height - 75})">
${starAndForkCount} ${starAndForkCount}
+4
View File
@@ -0,0 +1,4 @@
export { renderRepoCard } from "./repo.js";
export { renderStatsCard } from "./stats.js";
export { renderTopLanguages } from "./top-languages.js";
export { renderWakatimeCard } from "./wakatime.js";
@@ -8,23 +8,17 @@ import { encodeHTML } from "../common/html.js";
import { icons } from "../common/icons.js"; import { icons } from "../common/icons.js";
import { buildSearchFilter, clampValue, parseEmojis } from "../common/ops.js"; import { buildSearchFilter, clampValue, parseEmojis } from "../common/ops.js";
import { import {
countWrappedLines,
createLanguageNode, createLanguageNode,
flexLayout, flexLayout,
iconWithLabel, iconWithLabel,
measureText, measureText,
wrappedTextNode,
wrappedTextStyles,
} from "../common/render.js"; } from "../common/render.js";
import { repoCardLocales } from "../translations.js"; import { repoCardLocales } from "../translations.js";
import { createTextNode } from "./stats.js"; import { createTextNode } from "./stats.js";
const ICON_SIZE = 16; const ICON_SIZE = 16;
const CARD_DEFAULT_WIDTH = 400; const DESCRIPTION_LINE_WIDTH = 59;
const X_OFFSET = 25;
const DESCRIPTION_FONT_SIZE = 13;
const DESCRIPTION_LINE_HEIGHT_PX = 16;
const DESCRIPTION_MAX_LINES = 3; const DESCRIPTION_MAX_LINES = 3;
/** /**
@@ -85,7 +79,6 @@ const renderRepoCard = (repo, options = {}) => {
bg_color, bg_color,
card_width_input, card_width_input,
show_owner = false, show_owner = false,
browser_rendering = false,
show = [], show = [],
show_icons = true, show_icons = true,
number_format = "short", number_format = "short",
@@ -103,8 +96,8 @@ const renderRepoCard = (repo, options = {}) => {
card_width_input && !isNaN(card_width_input) card_width_input && !isNaN(card_width_input)
? card_width_input ? card_width_input
: show.length >= 2 : show.length >= 2
? CARD_DEFAULT_WIDTH + 30 ? 430
: CARD_DEFAULT_WIDTH; : 400;
const i18n = new I18n({ const i18n = new I18n({
locale, locale,
@@ -182,56 +175,23 @@ const renderRepoCard = (repo, options = {}) => {
const header = show_owner ? nameWithOwner : name; const header = show_owner ? nameWithOwner : name;
const langName = (primaryLanguage && primaryLanguage.name) || "Unspecified"; const langName = (primaryLanguage && primaryLanguage.name) || "Unspecified";
const langColor = (primaryLanguage && primaryLanguage.color) || "#333"; const langColor = (primaryLanguage && primaryLanguage.color) || "#333";
const desc = parseEmojis(description || "No description provided"); const descriptionMaxLines = description_lines_count
const descriptionBoxWidth = card_width - 2 * X_OFFSET; ? clampValue(description_lines_count, 1, DESCRIPTION_MAX_LINES)
: DESCRIPTION_MAX_LINES;
let descriptionLinesCount, descriptionSvg; const desc = parseEmojis(description || "No description provided");
if (browser_rendering) { const multiLineDescription = wrapTextMultiline(
// The browser performs the actual text wrapping inside the foreignObject; desc,
// we only estimate the line count server-side so the SVG can reserve enough Math.round((card_width - 400) / 5.93 + DESCRIPTION_LINE_WIDTH),
// height. The estimate uses measureText for font-aware widths instead of a descriptionMaxLines,
// fixed character count. );
descriptionLinesCount = description_lines_count const descriptionLinesCount = description_lines_count
? clampValue(description_lines_count, 1, DESCRIPTION_MAX_LINES) ? clampValue(description_lines_count, 1, DESCRIPTION_MAX_LINES)
: countWrappedLines( : multiLineDescription.length;
desc,
DESCRIPTION_FONT_SIZE, const descriptionSvg = multiLineDescription
descriptionBoxWidth, .map((line) => `<tspan dy="1.2em" x="25">${encodeHTML(line)}</tspan>`)
DESCRIPTION_MAX_LINES, .join("");
);
descriptionSvg = wrappedTextNode({
text: desc,
x: X_OFFSET,
y: -3,
width: descriptionBoxWidth,
height: descriptionLinesCount * DESCRIPTION_LINE_HEIGHT_PX + 10, // 10px extra for "descenders" like g, j, q, p, y
lineCount: descriptionLinesCount,
className: "description",
testId: "description-text",
});
} else {
const descriptionMaxLines = description_lines_count
? clampValue(description_lines_count, 1, DESCRIPTION_MAX_LINES)
: DESCRIPTION_MAX_LINES;
const multiLineDescription = wrapTextMultiline(
desc,
descriptionBoxWidth,
DESCRIPTION_FONT_SIZE,
descriptionMaxLines,
);
descriptionLinesCount = description_lines_count
? clampValue(description_lines_count, 1, DESCRIPTION_MAX_LINES)
: multiLineDescription.length;
descriptionSvg = multiLineDescription
.map(
(line) =>
`<tspan dy="1.2em" x="${X_OFFSET}">${encodeHTML(line)}</tspan>`,
)
.join("");
descriptionSvg = `<text class="description" x="${X_OFFSET}" y="-5">
${descriptionSvg}
</text>`;
}
const extraHeight = Object.keys(STATS).length const extraHeight = Object.keys(STATS).length
? -7 + (Math.ceil(statItems.length / 2) + 1) * extraLHeight ? -7 + (Math.ceil(statItems.length / 2) + 1) * extraLHeight
@@ -313,14 +273,11 @@ const renderRepoCard = (repo, options = {}) => {
card.setHideBorder(hide_border); card.setHideBorder(hide_border);
card.setHideTitle(false); card.setHideTitle(false);
card.setCSS(` card.setCSS(`
.description { .description { font: 400 13px 'Segoe UI', Ubuntu, Sans-Serif; fill: ${colors.textColor} }
font: 400 ${DESCRIPTION_FONT_SIZE}px 'Segoe UI', Ubuntu, Sans-Serif;fill: ${colors.textColor};
${browser_rendering ? wrappedTextStyles(colors.textColor) : ""}
}
.gray { font: 400 12px 'Segoe UI', Ubuntu, Sans-Serif; fill: ${colors.textColor} } .gray { font: 400 12px 'Segoe UI', Ubuntu, Sans-Serif; fill: ${colors.textColor} }
.badge { font: 600 11px 'Segoe UI', Ubuntu, Sans-Serif; } .badge { font: 600 11px 'Segoe UI', Ubuntu, Sans-Serif; }
.badge rect { opacity: 0.2 } .badge rect { opacity: 0.2 }
.stat { font: 400 12px 'Segoe UI', Ubuntu, Sans-Serif; fill: ${colors.textColor} } .stat { font: 400 12px 'Segoe UI', Ubuntu, Sans-Serif; fill: ${colors.textColor} }
.stagger { .stagger {
opacity: 0; opacity: 0;
@@ -341,19 +298,21 @@ const renderRepoCard = (repo, options = {}) => {
getBadgeSVG( getBadgeSVG(
i18n.t("repocard.template"), i18n.t("repocard.template"),
colors.textColor, colors.textColor,
card_width - CARD_DEFAULT_WIDTH, card_width - 400,
) )
: isArchived : isArchived
? // @ts-ignore ? // @ts-ignore
getBadgeSVG( getBadgeSVG(
i18n.t("repocard.archived"), i18n.t("repocard.archived"),
colors.textColor, colors.textColor,
card_width - CARD_DEFAULT_WIDTH, card_width - 400,
) )
: "" : ""
} }
${descriptionSvg} <text class="description" x="25" y="-5">
${descriptionSvg}
</text>
<g transform="translate(30, ${height - 75 - extraHeight})"> <g transform="translate(30, ${height - 75 - extraHeight})">
${starAndForkCount} ${starAndForkCount}
@@ -233,7 +233,7 @@ const getStyles = ({
transform: rotate(-90deg); transform: rotate(-90deg);
animation: rankAnimation 1s forwards ease-in-out; animation: rankAnimation 1s forwards ease-in-out;
} }
${getProgressAnimation({ progress })} ${process.env.NODE_ENV === "test" ? "" : getProgressAnimation({ progress })}
`; `;
}; };
@@ -221,7 +221,6 @@ const getDisplayValue = (size, percentages, format) => {
* @param {number} props.size Size of the programming language. * @param {number} props.size Size of the programming language.
* @param {number} props.totalSize Total size of all languages. * @param {number} props.totalSize Total size of all languages.
* @param {string} props.statsFormat Stats format. * @param {string} props.statsFormat Stats format.
* @param {boolean=} props.hideValues Whether to hide stats values.
* @param {number} props.index Index of the programming language. * @param {number} props.index Index of the programming language.
* @returns {string} Programming language SVG node. * @returns {string} Programming language SVG node.
*/ */
@@ -233,11 +232,10 @@ const createProgressTextNode = ({
size, size,
totalSize, totalSize,
statsFormat, statsFormat,
hideValues,
index, index,
}) => { }) => {
const staggerDelay = (index + 3) * 150; const staggerDelay = (index + 3) * 150;
const paddingRight = hideValues ? CARD_PADDING * 2 : 95; const paddingRight = 95;
const progressTextX = width - paddingRight + 10; const progressTextX = width - paddingRight + 10;
const progressWidth = width - paddingRight; const progressWidth = width - paddingRight;
@@ -247,7 +245,7 @@ const createProgressTextNode = ({
return ` return `
<g class="stagger" style="animation-delay: ${staggerDelay}ms"> <g class="stagger" style="animation-delay: ${staggerDelay}ms">
<text data-testid="lang-name" x="2" y="15" class="lang-name">${name}</text> <text data-testid="lang-name" x="2" y="15" class="lang-name">${name}</text>
${hideValues ? "" : `<text x="${progressTextX}" y="34" class="lang-name">${displayValue}</text>`} <text x="${progressTextX}" y="34" class="lang-name">${displayValue}</text>
${createProgressNode({ ${createProgressNode({
x: 0, x: 0,
y: 25, y: 25,
@@ -268,7 +266,6 @@ const createProgressTextNode = ({
* @param {Lang} props.lang Programming language object. * @param {Lang} props.lang Programming language object.
* @param {number} props.totalSize Total size of all languages. * @param {number} props.totalSize Total size of all languages.
* @param {boolean=} props.hideProgress Whether to hide percentage. * @param {boolean=} props.hideProgress Whether to hide percentage.
* @param {boolean=} props.hideValues Whether to hide stats values (percentages/bytes).
* @param {string=} props.statsFormat Stats format * @param {string=} props.statsFormat Stats format
* @param {number} props.index Index of the programming language. * @param {number} props.index Index of the programming language.
* @returns {string} Compact layout programming language SVG node. * @returns {string} Compact layout programming language SVG node.
@@ -277,7 +274,6 @@ const createCompactLangNode = ({
lang, lang,
totalSize, totalSize,
hideProgress, hideProgress,
hideValues,
statsFormat = "percentages", statsFormat = "percentages",
index, index,
}) => { }) => {
@@ -291,7 +287,7 @@ const createCompactLangNode = ({
<g class="stagger" style="animation-delay: ${staggerDelay}ms"> <g class="stagger" style="animation-delay: ${staggerDelay}ms">
<circle cx="5" cy="6" r="5" fill="${color}" /> <circle cx="5" cy="6" r="5" fill="${color}" />
<text data-testid="lang-name" x="15" y="10" class='lang-name'> <text data-testid="lang-name" x="15" y="10" class='lang-name'>
${lang.name} ${hideProgress || hideValues ? "" : displayValue} ${lang.name} ${hideProgress ? "" : displayValue}
</text> </text>
</g> </g>
`; `;
@@ -304,7 +300,6 @@ const createCompactLangNode = ({
* @param {Lang[]} props.langs Array of programming languages. * @param {Lang[]} props.langs Array of programming languages.
* @param {number} props.totalSize Total size of all languages. * @param {number} props.totalSize Total size of all languages.
* @param {boolean=} props.hideProgress Whether to hide percentage. * @param {boolean=} props.hideProgress Whether to hide percentage.
* @param {boolean=} props.hideValues Whether to hide stats values.
* @param {string=} props.statsFormat Stats format * @param {string=} props.statsFormat Stats format
* @returns {string} Programming languages SVG node. * @returns {string} Programming languages SVG node.
*/ */
@@ -312,7 +307,6 @@ const createLanguageTextNode = ({
langs, langs,
totalSize, totalSize,
hideProgress, hideProgress,
hideValues,
statsFormat, statsFormat,
}) => { }) => {
const longestLang = getLongestLang(langs); const longestLang = getLongestLang(langs);
@@ -324,7 +318,6 @@ const createLanguageTextNode = ({
lang, lang,
totalSize, totalSize,
hideProgress, hideProgress,
hideValues,
statsFormat, statsFormat,
index, index,
}), }),
@@ -351,23 +344,16 @@ const createLanguageTextNode = ({
* @param {object} props Function properties. * @param {object} props Function properties.
* @param {Lang[]} props.langs Array of programming languages. * @param {Lang[]} props.langs Array of programming languages.
* @param {number} props.totalSize Total size of all languages. * @param {number} props.totalSize Total size of all languages.
* @param {boolean=} props.hideValues Whether to hide stats values.
* @param {string} props.statsFormat Stats format * @param {string} props.statsFormat Stats format
* @returns {string} Donut layout programming language SVG node. * @returns {string} Donut layout programming language SVG node.
*/ */
const createDonutLanguagesNode = ({ const createDonutLanguagesNode = ({ langs, totalSize, statsFormat }) => {
langs,
totalSize,
hideValues,
statsFormat,
}) => {
return flexLayout({ return flexLayout({
items: langs.map((lang, index) => { items: langs.map((lang, index) => {
return createCompactLangNode({ return createCompactLangNode({
lang, lang,
totalSize, totalSize,
hideProgress: false, hideProgress: false,
hideValues,
statsFormat, statsFormat,
index, index,
}); });
@@ -385,7 +371,6 @@ const createDonutLanguagesNode = ({
* @param {number} totalLanguageSize Total size of all languages. * @param {number} totalLanguageSize Total size of all languages.
* @param progBarBgColor Color of the background of progress bar. * @param progBarBgColor Color of the background of progress bar.
* @param {string} statsFormat Stats format. * @param {string} statsFormat Stats format.
* @param {boolean=} hideValues Whether to hide stats values.
* @returns {string} Normal layout card SVG object. * @returns {string} Normal layout card SVG object.
*/ */
const renderNormalLayout = ( const renderNormalLayout = (
@@ -394,7 +379,6 @@ const renderNormalLayout = (
totalLanguageSize, totalLanguageSize,
progBarBgColor, progBarBgColor,
statsFormat, statsFormat,
hideValues,
) => { ) => {
return flexLayout({ return flexLayout({
items: langs.map((lang, index) => { items: langs.map((lang, index) => {
@@ -405,7 +389,6 @@ const renderNormalLayout = (
size: lang.size, size: lang.size,
totalSize: totalLanguageSize, totalSize: totalLanguageSize,
statsFormat, statsFormat,
hideValues,
index, index,
progBarBgColor, progBarBgColor,
}); });
@@ -423,7 +406,6 @@ const renderNormalLayout = (
* @param {number} totalLanguageSize Total size of all languages. * @param {number} totalLanguageSize Total size of all languages.
* @param {boolean=} hideProgress Whether to hide progress bar. * @param {boolean=} hideProgress Whether to hide progress bar.
* @param {string} statsFormat Stats format. * @param {string} statsFormat Stats format.
* @param {boolean=} hideValues Whether to hide stats values.
* @returns {string} Compact layout card SVG object. * @returns {string} Compact layout card SVG object.
*/ */
const renderCompactLayout = ( const renderCompactLayout = (
@@ -432,7 +414,6 @@ const renderCompactLayout = (
totalLanguageSize, totalLanguageSize,
hideProgress, hideProgress,
statsFormat = "percentages", statsFormat = "percentages",
hideValues,
) => { ) => {
const paddingRight = 50; const paddingRight = 50;
const offsetWidth = width - paddingRight; const offsetWidth = width - paddingRight;
@@ -480,7 +461,6 @@ const renderCompactLayout = (
totalSize: totalLanguageSize, totalSize: totalLanguageSize,
hideProgress, hideProgress,
statsFormat, statsFormat,
hideValues,
})} })}
</g> </g>
`; `;
@@ -492,15 +472,9 @@ const renderCompactLayout = (
* @param {Lang[]} langs Array of programming languages. * @param {Lang[]} langs Array of programming languages.
* @param {number} totalLanguageSize Total size of all languages. * @param {number} totalLanguageSize Total size of all languages.
* @param {string} statsFormat Stats format. * @param {string} statsFormat Stats format.
* @param {boolean=} hideValues Whether to hide stats values.
* @returns {string} Compact layout card SVG object. * @returns {string} Compact layout card SVG object.
*/ */
const renderDonutVerticalLayout = ( const renderDonutVerticalLayout = (langs, totalLanguageSize, statsFormat) => {
langs,
totalLanguageSize,
statsFormat,
hideValues,
) => {
// Donut vertical chart radius and total length // Donut vertical chart radius and total length
const radius = 80; const radius = 80;
const totalCircleLength = getCircleLength(radius); const totalCircleLength = getCircleLength(radius);
@@ -557,7 +531,6 @@ const renderDonutVerticalLayout = (
totalSize: totalLanguageSize, totalSize: totalLanguageSize,
hideProgress: false, hideProgress: false,
statsFormat, statsFormat,
hideValues,
})} })}
</svg> </svg>
</g> </g>
@@ -571,10 +544,9 @@ const renderDonutVerticalLayout = (
* @param {Lang[]} langs Array of programming languages. * @param {Lang[]} langs Array of programming languages.
* @param {number} totalLanguageSize Total size of all languages. * @param {number} totalLanguageSize Total size of all languages.
* @param {string} statsFormat Stats format. * @param {string} statsFormat Stats format.
* @param {boolean=} hideValues Whether to hide stats values.
* @returns {string} Compact layout card SVG object. * @returns {string} Compact layout card SVG object.
*/ */
const renderPieLayout = (langs, totalLanguageSize, statsFormat, hideValues) => { const renderPieLayout = (langs, totalLanguageSize, statsFormat) => {
// Pie chart radius and center coordinates // Pie chart radius and center coordinates
const radius = 90; const radius = 90;
const centerX = 150; const centerX = 150;
@@ -656,7 +628,6 @@ const renderPieLayout = (langs, totalLanguageSize, statsFormat, hideValues) => {
totalSize: totalLanguageSize, totalSize: totalLanguageSize,
hideProgress: false, hideProgress: false,
statsFormat, statsFormat,
hideValues,
})} })}
</svg> </svg>
</g> </g>
@@ -676,7 +647,7 @@ const renderPieLayout = (langs, totalLanguageSize, statsFormat, hideValues) => {
const createDonutPaths = (cx, cy, radius, percentages) => { const createDonutPaths = (cx, cy, radius, percentages) => {
const paths = []; const paths = [];
let startAngle = 0; let startAngle = 0;
let endAngle; let endAngle = 0;
const totalPercent = percentages.reduce((acc, curr) => acc + curr, 0); const totalPercent = percentages.reduce((acc, curr) => acc + curr, 0);
for (let i = 0; i < percentages.length; i++) { for (let i = 0; i < percentages.length; i++) {
@@ -708,16 +679,9 @@ const createDonutPaths = (cx, cy, radius, percentages) => {
* @param {number} width Card width. * @param {number} width Card width.
* @param {number} totalLanguageSize Total size of all languages. * @param {number} totalLanguageSize Total size of all languages.
* @param {string} statsFormat Stats format. * @param {string} statsFormat Stats format.
* @param {boolean=} hideValues Whether to hide stats values.
* @returns {string} Donut layout card SVG object. * @returns {string} Donut layout card SVG object.
*/ */
const renderDonutLayout = ( const renderDonutLayout = (langs, width, totalLanguageSize, statsFormat) => {
langs,
width,
totalLanguageSize,
statsFormat,
hideValues,
) => {
const centerX = width / 3; const centerX = width / 3;
const centerY = width / 3; const centerY = width / 3;
const radius = centerX - 60; const radius = centerX - 60;
@@ -760,7 +724,7 @@ const renderDonutLayout = (
return ` return `
<g transform="translate(0, 0)"> <g transform="translate(0, 0)">
<g transform="translate(0, 0)"> <g transform="translate(0, 0)">
${createDonutLanguagesNode({ langs, totalSize: totalLanguageSize, hideValues, statsFormat })} ${createDonutLanguagesNode({ langs, totalSize: totalLanguageSize, statsFormat })}
</g> </g>
<g transform="translate(125, ${donutCenterTranslation(langs.length)})"> <g transform="translate(125, ${donutCenterTranslation(langs.length)})">
@@ -836,7 +800,6 @@ const renderTopLanguages = (topLangs, options = {}) => {
prog_bar_bg_color, prog_bar_bg_color,
hide, hide,
hide_progress, hide_progress,
hide_values,
theme, theme,
layout, layout,
custom_title, custom_title,
@@ -877,7 +840,7 @@ const renderTopLanguages = (topLangs, options = {}) => {
theme, theme,
}); });
let finalLayout; let finalLayout = "";
if (langs.length === 0) { if (langs.length === 0) {
height = COMPACT_LAYOUT_BASE_HEIGHT; height = COMPACT_LAYOUT_BASE_HEIGHT;
finalLayout = noLanguagesDataNode({ finalLayout = noLanguagesDataNode({
@@ -887,19 +850,13 @@ const renderTopLanguages = (topLangs, options = {}) => {
}); });
} else if (layout === "pie") { } else if (layout === "pie") {
height = calculatePieLayoutHeight(langs.length); height = calculatePieLayoutHeight(langs.length);
finalLayout = renderPieLayout( finalLayout = renderPieLayout(langs, totalLanguageSize, stats_format);
langs,
totalLanguageSize,
stats_format,
hide_values,
);
} else if (layout === "donut-vertical") { } else if (layout === "donut-vertical") {
height = calculateDonutVerticalLayoutHeight(langs.length); height = calculateDonutVerticalLayoutHeight(langs.length);
finalLayout = renderDonutVerticalLayout( finalLayout = renderDonutVerticalLayout(
langs, langs,
totalLanguageSize, totalLanguageSize,
stats_format, stats_format,
hide_values,
); );
} else if (layout === "compact" || hide_progress == true) { } else if (layout === "compact" || hide_progress == true) {
height = height =
@@ -911,7 +868,6 @@ const renderTopLanguages = (topLangs, options = {}) => {
totalLanguageSize, totalLanguageSize,
hide_progress, hide_progress,
stats_format, stats_format,
hide_values,
); );
} else if (layout === "donut") { } else if (layout === "donut") {
height = calculateDonutLayoutHeight(langs.length); height = calculateDonutLayoutHeight(langs.length);
@@ -921,7 +877,6 @@ const renderTopLanguages = (topLangs, options = {}) => {
width, width,
totalLanguageSize, totalLanguageSize,
stats_format, stats_format,
hide_values,
); );
} else { } else {
finalLayout = renderNormalLayout( finalLayout = renderNormalLayout(
@@ -930,7 +885,6 @@ const renderTopLanguages = (topLangs, options = {}) => {
totalLanguageSize, totalLanguageSize,
fallbackColor(prog_bar_bg_color, "#ddd"), fallbackColor(prog_bar_bg_color, "#ddd"),
stats_format, stats_format,
hide_values,
); );
} }
@@ -1,7 +1,7 @@
type ThemeNames = keyof typeof import("../themes/index.ts"); type ThemeNames = keyof typeof import("../../themes/index.js");
type RankIcon = "default" | "github" | "percentile"; type RankIcon = "default" | "github" | "percentile";
interface CommonOptions { type CommonOptions = {
title_color: string; title_color: string;
icon_color: string; icon_color: string;
text_color: string; text_color: string;
@@ -11,10 +11,10 @@ interface CommonOptions {
border_color: string; border_color: string;
locale: string; locale: string;
hide_border: boolean; hide_border: boolean;
} };
export type StatCardOptions = CommonOptions & { export type StatCardOptions = CommonOptions & {
hide: Array<string>; hide: string[];
show_icons: boolean; show_icons: boolean;
hide_title: boolean; hide_title: boolean;
card_width: number; card_width: number;
@@ -29,15 +29,14 @@ export type StatCardOptions = CommonOptions & {
ring_color: string; ring_color: string;
text_bold: boolean; text_bold: boolean;
rank_icon: RankIcon; rank_icon: RankIcon;
show: Array<string>; show: string[];
}; };
export type RepoCardOptions = CommonOptions & { export type RepoCardOptions = CommonOptions & {
show_owner: boolean; show_owner: boolean;
browser_rendering: boolean;
description_lines_count: number; description_lines_count: number;
card_width_input; card_width_input;
show: Array<string>; show: string[];
show_icons: boolean; show_icons: boolean;
number_format: string; number_format: string;
text_bold: boolean; text_bold: boolean;
@@ -48,20 +47,19 @@ export type RepoCardOptions = CommonOptions & {
export type TopLangOptions = CommonOptions & { export type TopLangOptions = CommonOptions & {
hide_title: boolean; hide_title: boolean;
card_width: number; card_width: number;
hide: Array<string>; hide: string[];
layout: "compact" | "normal" | "donut" | "donut-vertical" | "pie"; layout: "compact" | "normal" | "donut" | "donut-vertical" | "pie";
custom_title: string; custom_title: string;
langs_count: number; langs_count: number;
disable_animations: boolean; disable_animations: boolean;
hide_progress: boolean; hide_progress: boolean;
hide_values: boolean;
prog_bar_bg_color: string; prog_bar_bg_color: string;
stats_format: "percentages" | "bytes"; stats_format: "percentages" | "bytes";
}; };
export type WakaTimeOptions = CommonOptions & { export type WakaTimeOptions = CommonOptions & {
hide_title: boolean; hide_title: boolean;
hide: Array<string>; hide: string[];
card_width: number; card_width: number;
line_height: string; line_height: string;
hide_progress: boolean; hide_progress: boolean;
@@ -74,5 +72,4 @@ export type WakaTimeOptions = CommonOptions & {
export type GistCardOptions = CommonOptions & { export type GistCardOptions = CommonOptions & {
show_owner: boolean; show_owner: boolean;
browser_rendering: boolean;
}; };
@@ -305,7 +305,7 @@ const renderWakatimeCard = (stats = {}, options = { hide: [] }) => {
textColor, textColor,
}); });
let finalLayout; let finalLayout = "";
// RENDER COMPACT LAYOUT // RENDER COMPACT LAYOUT
if (layout === "compact") { if (layout === "compact") {
@@ -230,7 +230,7 @@ class Card {
} }
${this.css} ${this.css}
${this.getAnimations()} ${process.env.NODE_ENV === "test" ? "" : this.getAnimations()}
${ ${
this.animations === false this.animations === false
? `* { animation-duration: 0s !important; animation-delay: 0s !important; }` ? `* { animation-duration: 0s !important; animation-delay: 0s !important; }`
+25 -25
View File
@@ -1,11 +1,8 @@
// @ts-check // @ts-check
import {
getConfig,
renderError,
} from "@stats-organization/github-readme-stats-core";
import { blacklist } from "./blacklist.js"; import { blacklist } from "./blacklist.js";
import { gistWhitelist, whitelist } from "./envs.js";
import { renderError } from "./render.js";
const NOT_WHITELISTED_USERNAME_MESSAGE = "This username is not whitelisted"; const NOT_WHITELISTED_USERNAME_MESSAGE = "This username is not whitelisted";
const NOT_WHITELISTED_GIST_MESSAGE = "This gist ID is not whitelisted"; const NOT_WHITELISTED_GIST_MESSAGE = "This gist ID is not whitelisted";
@@ -15,35 +12,36 @@ const BLACKLISTED_MESSAGE = "This username is blacklisted";
* Guards access using whitelist/blacklist. * Guards access using whitelist/blacklist.
* *
* @param {Object} args The parameters object. * @param {Object} args The parameters object.
* @param {any} args.res The response object.
* @param {string} args.id Resource identifier (username or gist id). * @param {string} args.id Resource identifier (username or gist id).
* @param {"username"|"gist"|"wakatime"} args.type The type of identifier. * @param {"username"|"gist"|"wakatime"} args.type The type of identifier.
* @param {{ title_color?: string, text_color?: string, bg_color?: string, border_color?: string, theme?: string }} args.colors Color options for the error card. * @param {{ title_color?: string, text_color?: string, bg_color?: string, border_color?: string, theme?: string }} args.colors Color options for the error card.
* @returns {{ isPassed: boolean, result?: any }} The result object indicating success or failure. * @returns {{ isPassed: boolean, result?: any }} The result object indicating success or failure.
*/ */
const guardAccess = ({ id, type, colors }) => { const guardAccess = ({ res, id, type, colors }) => {
if (!["username", "gist", "wakatime"].includes(type)) { if (!["username", "gist", "wakatime"].includes(type)) {
throw new Error( throw new Error(
'Invalid type. Expected "username", "gist", or "wakatime".', 'Invalid type. Expected "username", "gist", or "wakatime".',
); );
} }
const config = getConfig(); const currentWhitelist = type === "gist" ? gistWhitelist : whitelist;
const currentWhitelist =
type === "gist" ? config.gistWhitelist : config.whitelist;
const notWhitelistedMsg = const notWhitelistedMsg =
type === "gist" type === "gist"
? NOT_WHITELISTED_GIST_MESSAGE ? NOT_WHITELISTED_GIST_MESSAGE
: NOT_WHITELISTED_USERNAME_MESSAGE; : NOT_WHITELISTED_USERNAME_MESSAGE;
if (Array.isArray(currentWhitelist) && !currentWhitelist.includes(id)) { if (Array.isArray(currentWhitelist) && !currentWhitelist.includes(id)) {
const result = renderError({ const result = res.send(
message: notWhitelistedMsg, renderError({
secondaryMessage: "Please deploy your own instance", message: notWhitelistedMsg,
renderOptions: { secondaryMessage: "Please deploy your own instance",
...colors, renderOptions: {
show_repo_link: false, ...colors,
}, show_repo_link: false,
}); },
}),
);
return { isPassed: false, result }; return { isPassed: false, result };
} }
@@ -52,14 +50,16 @@ const guardAccess = ({ id, type, colors }) => {
currentWhitelist === undefined && currentWhitelist === undefined &&
blacklist.includes(id) blacklist.includes(id)
) { ) {
const result = renderError({ const result = res.send(
message: BLACKLISTED_MESSAGE, renderError({
secondaryMessage: "Please deploy your own instance", message: BLACKLISTED_MESSAGE,
renderOptions: { secondaryMessage: "Please deploy your own instance",
...colors, renderOptions: {
show_repo_link: false, ...colors,
}, show_repo_link: false,
}); },
}),
);
return { isPassed: false, result }; return { isPassed: false, result };
} }
+6 -3
View File
@@ -1,6 +1,6 @@
// @ts-check // @ts-check
import { clampValue } from "@stats-organization/github-readme-stats-core"; import { clampValue } from "./ops.js";
const MIN = 60; const MIN = 60;
const HOUR = 60 * MIN; const HOUR = 60 * MIN;
@@ -106,7 +106,7 @@ const disableCaching = (res) => {
* @param {number} cacheSeconds The cache seconds to set in the headers. * @param {number} cacheSeconds The cache seconds to set in the headers.
*/ */
const setCacheHeaders = (res, cacheSeconds) => { const setCacheHeaders = (res, cacheSeconds) => {
if (cacheSeconds < 1) { if (cacheSeconds < 1 || process.env.NODE_ENV === "development") {
disableCaching(res); disableCaching(res);
return; return;
} }
@@ -128,7 +128,10 @@ const setErrorCacheHeaders = (res) => {
const envCacheSeconds = process.env.CACHE_SECONDS const envCacheSeconds = process.env.CACHE_SECONDS
? parseInt(process.env.CACHE_SECONDS, 10) ? parseInt(process.env.CACHE_SECONDS, 10)
: NaN; : NaN;
if (!isNaN(envCacheSeconds) && envCacheSeconds < 1) { if (
(!isNaN(envCacheSeconds) && envCacheSeconds < 1) ||
process.env.NODE_ENV === "development"
) {
disableCaching(res); disableCaching(res);
return; return;
} }
@@ -1,6 +1,6 @@
// @ts-check // @ts-check
import { themes } from "../themes/index.js"; import { themes } from "../../themes/index.js";
/** /**
* Checks if a string is a valid hex color. * Checks if a string is a valid hex color.
+11 -7
View File
@@ -1,10 +1,14 @@
export let pool = null; /**
if (process.env.POSTGRES_URL) { * In the browser this has to be mocked to avoid runtime errors
const { Pool } = await import("pg"); * @see apps/frontend/vite.config.ts
pool = new Pool({ */
connectionString: process.env.POSTGRES_URL, import { Pool } from "pg";
});
} export const pool = process.env.POSTGRES_URL
? new Pool({
connectionString: process.env.POSTGRES_URL,
})
: null;
/** /**
* Creates all required tables if they do not exist. * Creates all required tables if they do not exist.
+15
View File
@@ -0,0 +1,15 @@
// @ts-check
const whitelist = process.env.WHITELIST
? process.env.WHITELIST.split(",")
: undefined;
const gistWhitelist = process.env.GIST_WHITELIST
? process.env.GIST_WHITELIST.split(",")
: undefined;
const excludeRepositories = process.env.EXCLUDE_REPO
? process.env.EXCLUDE_REPO.split(",")
: [];
export { whitelist, gistWhitelist, excludeRepositories };
@@ -1,7 +1,8 @@
// @ts-check // @ts-check
import wrap from "word-wrap";
import { encodeHTML } from "./html.js"; import { encodeHTML } from "./html.js";
import { splitWrappedText } from "./render.js";
/** /**
* Retrieves num with suffix k(thousands) precise to given decimal places. * Retrieves num with suffix k(thousands) precise to given decimal places.
@@ -56,16 +57,26 @@ const formatBytes = (bytes) => {
* Split text over multiple lines based on the card width. * Split text over multiple lines based on the card width.
* *
* @param {string} text Text to split. * @param {string} text Text to split.
* @param {number} width Available wrap width in px. * @param {number} width Line width in number of characters.
* @param {number} fontSize Font size in px.
* @param {number} maxLines Maximum number of lines. * @param {number} maxLines Maximum number of lines.
* @returns {string[]} Array of lines. * @returns {string[]} Array of lines.
*/ */
const wrapTextMultiline = (text, width, fontSize, maxLines = 3) => { const wrapTextMultiline = (text, width = 59, maxLines = 3) => {
const wrapped = splitWrappedText(text, fontSize, width); const fullWidthComma = "";
const lines = wrapped const encoded = encodeHTML(text);
.map((line) => encodeHTML(line.trim())) const isChinese = encoded.includes(fullWidthComma);
.slice(0, maxLines); // Only consider maxLines lines
let wrapped = [];
if (isChinese) {
wrapped = encoded.split(fullWidthComma); // Chinese full punctuation
} else {
wrapped = wrap(encoded, {
width,
}).split("\n"); // Split wrapped lines to get an array of lines
}
const lines = wrapped.map((line) => line.trim()).slice(0, maxLines); // Only consider maxLines lines
// Add "..." to the last line if the text exceeds maxLines // Add "..." to the last line if the text exceeds maxLines
if (wrapped.length > maxLines) { if (wrapped.length > maxLines) {
@@ -1,31 +1,5 @@
// @ts-check // @ts-check
/*
The icons in this file are based on https://github.com/primer/octicons which is released under the MIT license:
MIT License
Copyright (c) 2026 GitHub Inc.
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
*/
const icons = { const icons = {
star: `<path fill-rule="evenodd" d="M8 .25a.75.75 0 01.673.418l1.882 3.815 4.21.612a.75.75 0 01.416 1.279l-3.046 2.97.719 4.192a.75.75 0 01-1.088.791L8 12.347l-3.766 1.98a.75.75 0 01-1.088-.79l.72-4.194L.818 6.374a.75.75 0 01.416-1.28l4.21-.611L7.327.668A.75.75 0 018 .25zm0 2.445L6.615 5.5a.75.75 0 01-.564.41l-3.097.45 2.24 2.184a.75.75 0 01.216.664l-.528 3.084 2.769-1.456a.75.75 0 01.698 0l2.77 1.456-.53-3.084a.75.75 0 01.216-.664l2.24-2.183-3.096-.45a.75.75 0 01-.564-.41L8 2.694v.001z"/>`, star: `<path fill-rule="evenodd" d="M8 .25a.75.75 0 01.673.418l1.882 3.815 4.21.612a.75.75 0 01.416 1.279l-3.046 2.97.719 4.192a.75.75 0 01-1.088.791L8 12.347l-3.766 1.98a.75.75 0 01-1.088-.79l.72-4.194L.818 6.374a.75.75 0 01.416-1.28l4.21-.611L7.327.668A.75.75 0 018 .25zm0 2.445L6.615 5.5a.75.75 0 01-.564.41l-3.097.45 2.24 2.184a.75.75 0 01.216.664l-.528 3.084 2.769-1.456a.75.75 0 01.698 0l2.77 1.456-.53-3.084a.75.75 0 01.216-.664l2.24-2.183-3.096-.45a.75.75 0 01-.564-.41L8 2.694v.001z"/>`,
commits: `<path fill-rule="evenodd" d="M1.643 3.143L.427 1.927A.25.25 0 000 2.104V5.75c0 .138.112.25.25.25h3.646a.25.25 0 00.177-.427L2.715 4.215a6.5 6.5 0 11-1.18 4.458.75.75 0 10-1.493.154 8.001 8.001 0 101.6-5.684zM7.75 4a.75.75 0 01.75.75v2.992l2.028.812a.75.75 0 01-.557 1.392l-2.5-1A.75.75 0 017 8.25v-3.5A.75.75 0 017.75 4z"/>`, commits: `<path fill-rule="evenodd" d="M1.643 3.143L.427 1.927A.25.25 0 000 2.104V5.75c0 .138.112.25.25.25h3.646a.25.25 0 00.177-.427L2.715 4.215a6.5 6.5 0 11-1.18 4.458.75.75 0 10-1.493.154 8.001 8.001 0 101.6-5.684zM7.75 4a.75.75 0 01.75.75v2.992l2.028.812a.75.75 0 01-.557 1.392l-2.5-1A.75.75 0 017 8.25v-3.5A.75.75 0 017.75 4z"/>`,
+13
View File
@@ -0,0 +1,13 @@
// @ts-check
export { blacklist } from "./blacklist.js";
export { Card } from "./Card.js";
export { I18n } from "./I18n.js";
export { icons } from "./icons.js";
export { retryer } from "./retryer.js";
export {
ERROR_CARD_LENGTH,
renderError,
flexLayout,
measureText,
} from "./render.js";
@@ -171,7 +171,6 @@
"Faust": "#c37240", "Faust": "#c37240",
"Fennel": "#fff3d7", "Fennel": "#fff3d7",
"Filebench WML": "#F6B900", "Filebench WML": "#F6B900",
"FlatBuffers": "#ed284a",
"Flix": "#d44a45", "Flix": "#d44a45",
"Fluent": "#ffcc33", "Fluent": "#ffcc33",
"Forth": "#341708", "Forth": "#341708",
@@ -254,7 +253,6 @@
"Hy": "#7790B2", "Hy": "#7790B2",
"IDL": "#a3522f", "IDL": "#a3522f",
"IGOR Pro": "#0000cc", "IGOR Pro": "#0000cc",
"IL Assembly": "#512BD4",
"INI": "#d1dbe0", "INI": "#d1dbe0",
"ISPC": "#2D68B1", "ISPC": "#2D68B1",
"Idris": "#b30000", "Idris": "#b30000",
@@ -325,7 +323,6 @@
"LigoLANG": "#0e74ff", "LigoLANG": "#0e74ff",
"LilyPond": "#9ccc7c", "LilyPond": "#9ccc7c",
"Liquid": "#67b8de", "Liquid": "#67b8de",
"Liquidsoap": "#990066",
"Literate Agda": "#315665", "Literate Agda": "#315665",
"Literate CoffeeScript": "#244776", "Literate CoffeeScript": "#244776",
"Literate Haskell": "#5e5086", "Literate Haskell": "#5e5086",
@@ -351,7 +348,6 @@
"Mask": "#f97732", "Mask": "#f97732",
"Mathematical Programming System": "#0530ad", "Mathematical Programming System": "#0530ad",
"Max": "#c4a79c", "Max": "#c4a79c",
"MeTTa": "#6a5acd",
"Mercury": "#ff2b2b", "Mercury": "#ff2b2b",
"Mermaid": "#ff3670", "Mermaid": "#ff3670",
"Meson": "#007800", "Meson": "#007800",
@@ -468,7 +464,6 @@
"Quake": "#882233", "Quake": "#882233",
"QuakeC": "#975777", "QuakeC": "#975777",
"QuickBASIC": "#008080", "QuickBASIC": "#008080",
"Quint": "#9d6ce5",
"R": "#198CE7", "R": "#198CE7",
"RAML": "#77d9fb", "RAML": "#77d9fb",
"RAScript": "#2C97FA", "RAScript": "#2C97FA",
@@ -540,7 +535,6 @@
"Snakemake": "#419179", "Snakemake": "#419179",
"Solidity": "#AA6746", "Solidity": "#AA6746",
"SourcePawn": "#f69e1d", "SourcePawn": "#f69e1d",
"SpiceDB Schema": "#a5318a",
"Squirrel": "#800000", "Squirrel": "#800000",
"Stan": "#b2011d", "Stan": "#b2011d",
"Standard ML": "#dc566d", "Standard ML": "#dc566d",
+13
View File
@@ -0,0 +1,13 @@
// @ts-check
const noop = () => {};
/**
* Return console instance based on the environment.
*
* @type {Console | {log: () => void, error: () => void}}
*/
const logger =
process.env.NODE_ENV === "test" ? { log: noop, error: noop } : console;
export { logger };
+239
View File
@@ -0,0 +1,239 @@
// @ts-check
import { getCardColors } from "./color.js";
import { SECONDARY_ERROR_MESSAGES, TRY_AGAIN_LATER } from "./error.js";
import { encodeHTML } from "./html.js";
import { clampValue } from "./ops.js";
/**
* Auto layout utility, allows us to layout things vertically or horizontally with
* proper gaping.
*
* @param {object} props Function properties.
* @param {string[]} props.items Array of items to layout.
* @param {number} props.gap Gap between items.
* @param {"column" | "row"=} props.direction Direction to layout items.
* @param {number[]=} props.sizes Array of sizes for each item.
* @returns {string[]} Array of items with proper layout.
*/
const flexLayout = ({ items, gap, direction, sizes = [] }) => {
let lastSize = 0;
// filter() for filtering out empty strings
return items.filter(Boolean).map((item, i) => {
const size = sizes[i] || 0;
let transform = `translate(${lastSize}, 0)`;
if (direction === "column") {
transform = `translate(0, ${lastSize})`;
}
lastSize += size + gap;
return `<g transform="${transform}">${item}</g>`;
});
};
/**
* Creates a node to display the primary programming language of the repository/gist.
*
* @param {string} langName Language name.
* @param {string} langColor Language color.
* @returns {string} Language display SVG object.
*/
const createLanguageNode = (langName, langColor) => {
return `
<g data-testid="primary-lang">
<circle data-testid="lang-color" cx="0" cy="-5" r="6" fill="${langColor}" />
<text data-testid="lang-name" class="gray" x="15">${langName}</text>
</g>
`;
};
/**
* Create a node to indicate progress in percentage along a horizontal line.
*
* @param {Object} params Object that contains the createProgressNode parameters.
* @param {number} params.x X-axis position.
* @param {number} params.y Y-axis position.
* @param {number} params.width Width of progress bar.
* @param {string} params.color Progress color.
* @param {number} params.progress Progress value.
* @param {string} params.progressBarBackgroundColor Progress bar bg color.
* @param {number} params.delay Delay before animation starts.
* @returns {string} Progress node.
*/
const createProgressNode = ({
x,
y,
width,
color,
progress,
progressBarBackgroundColor,
delay,
}) => {
const progressPercentage = clampValue(progress, 2, 100);
return `
<svg width="${width}" x="${x}" y="${y}">
<rect data-testid="progress-background" rx="5" ry="5" x="0" y="0" width="${width}" height="8" fill="${progressBarBackgroundColor}"></rect>
<svg data-testid="lang-progress" width="${progressPercentage}%">
<rect
height="8"
fill="${color}"
rx="5" ry="5" x="0" y="0"
class="lang-progress"
style="animation-delay: ${delay}ms;"
/>
</svg>
</svg>
`;
};
/**
* Creates an icon with label to display repository/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.
* @param {number} iconSize The size of the icon.
* @returns {string} Icon with label SVG object.
*/
const iconWithLabel = (icon, label, testid, iconSize) => {
if (typeof label === "number" && label <= 0) {
return "";
}
const iconSvg = `
<svg
class="icon"
y="-12"
viewBox="0 0 16 16"
version="1.1"
width="${iconSize}"
height="${iconSize}"
>
${icon}
</svg>
`;
const text = `<text data-testid="${testid}" class="gray">${label}</text>`;
return flexLayout({ items: [iconSvg, text], gap: 20 }).join("");
};
// Script parameters.
const ERROR_CARD_LENGTH = 576.5;
const UPSTREAM_API_ERRORS = [
TRY_AGAIN_LATER,
SECONDARY_ERROR_MESSAGES.MAX_RETRY,
];
/**
* Renders error message on the card.
*
* @param {object} args Function arguments.
* @param {string} args.message Main error message.
* @param {string} [args.secondaryMessage=""] The secondary error message.
* @param {object} [args.renderOptions={}] Render options.
* @param {string=} args.renderOptions.title_color Card title color.
* @param {string=} args.renderOptions.text_color Card text color.
* @param {string=} args.renderOptions.bg_color Card background color.
* @param {string=} args.renderOptions.border_color Card border color.
* @param {Parameters<typeof getCardColors>[0]["theme"]=} args.renderOptions.theme Card theme.
* @param {boolean=} args.renderOptions.show_repo_link Whether to show repo link or not.
* @returns {string} The SVG markup.
*/
const renderError = ({
message,
secondaryMessage = "",
renderOptions = {},
}) => {
const {
title_color,
text_color,
bg_color,
border_color,
theme = "default",
show_repo_link = true,
} = renderOptions;
// returns theme based colors with proper overrides and defaults
const { titleColor, textColor, bgColor, borderColor } = getCardColors({
title_color,
text_color,
icon_color: "",
bg_color,
border_color,
ring_color: "",
theme,
});
return `
<svg width="${ERROR_CARD_LENGTH}" height="120" viewBox="0 0 ${ERROR_CARD_LENGTH} 120" fill="${bgColor}" xmlns="http://www.w3.org/2000/svg">
<style>
.text { font: 600 16px 'Segoe UI', Ubuntu, Sans-Serif; fill: ${titleColor} }
.small { font: 600 12px 'Segoe UI', Ubuntu, Sans-Serif; fill: ${textColor} }
.gray { fill: #858585 }
</style>
<rect x="0.5" y="0.5" width="${
ERROR_CARD_LENGTH - 1
}" height="99%" rx="4.5" fill="${bgColor}" stroke="${borderColor}"/>
<text x="25" y="45" class="text">Something went wrong!${
UPSTREAM_API_ERRORS.includes(secondaryMessage) || !show_repo_link
? ""
: " file an issue at https://tinyurl.com/github-stats"
}</text>
<text data-testid="message" x="25" y="55" class="text small">
<tspan x="25" dy="18">${encodeHTML(message)}</tspan>
<tspan x="25" dy="18" class="gray">${secondaryMessage}</tspan>
</text>
</svg>
`;
};
/**
* Retrieve text length.
*
* @see https://stackoverflow.com/a/48172630/10629172
* @param {string} str String to measure.
* @param {number} fontSize Font size.
* @returns {number} Text length.
*/
const measureText = (str, fontSize = 10) => {
// prettier-ignore
const widths = [
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0.2796875, 0.2765625,
0.3546875, 0.5546875, 0.5546875, 0.8890625, 0.665625, 0.190625,
0.3328125, 0.3328125, 0.3890625, 0.5828125, 0.2765625, 0.3328125,
0.2765625, 0.3015625, 0.5546875, 0.5546875, 0.5546875, 0.5546875,
0.5546875, 0.5546875, 0.5546875, 0.5546875, 0.5546875, 0.5546875,
0.2765625, 0.2765625, 0.584375, 0.5828125, 0.584375, 0.5546875,
1.0140625, 0.665625, 0.665625, 0.721875, 0.721875, 0.665625,
0.609375, 0.7765625, 0.721875, 0.2765625, 0.5, 0.665625,
0.5546875, 0.8328125, 0.721875, 0.7765625, 0.665625, 0.7765625,
0.721875, 0.665625, 0.609375, 0.721875, 0.665625, 0.94375,
0.665625, 0.665625, 0.609375, 0.2765625, 0.3546875, 0.2765625,
0.4765625, 0.5546875, 0.3328125, 0.5546875, 0.5546875, 0.5,
0.5546875, 0.5546875, 0.2765625, 0.5546875, 0.5546875, 0.221875,
0.240625, 0.5, 0.221875, 0.8328125, 0.5546875, 0.5546875,
0.5546875, 0.5546875, 0.3328125, 0.5, 0.2765625, 0.5546875,
0.5, 0.721875, 0.5, 0.5, 0.5, 0.3546875, 0.259375, 0.353125, 0.5890625,
];
const avg = 0.5279276315789471;
return (
str
.split("")
.map((c) =>
c.charCodeAt(0) < widths.length ? widths[c.charCodeAt(0)] : avg,
)
.reduce((cur, acc) => acc + cur) * fontSize
);
};
export {
ERROR_CARD_LENGTH,
renderError,
createLanguageNode,
createProgressNode,
iconWithLabel,
flexLayout,
measureText,
};
@@ -1,6 +1,6 @@
// @ts-check // @ts-check
import { getConfig } from "./config.js"; import { getUserAccessByName } from "./database.js";
import { CustomError } from "./error.js"; import { CustomError } from "./error.js";
import { logger } from "./log.js"; import { logger } from "./log.js";
@@ -27,16 +27,24 @@ function getRandomInt(max) {
* Try to execute the fetcher function until it succeeds or the max number of retries is reached. * Try to execute the fetcher function until it succeeds or the max number of retries is reached.
* *
* @param {FetcherFunction} fetcher The fetcher function. * @param {FetcherFunction} fetcher The fetcher function.
* @param {string?} username GitHub username of the user whose PAT to use, if available
* @param {any} variables Object with arguments to pass to the fetcher function. * @param {any} variables Object with arguments to pass to the fetcher function.
* @param {string | null} pat Optional PAT override.
* @returns {Promise<any>} The response from the fetcher function. * @returns {Promise<any>} The response from the fetcher function.
*/ */
const retryer = async (fetcher, variables, pat = null) => { const retryer = async (fetcher, username, variables) => {
let userPAT;
if (username) {
userPAT = await getUserAccessByName(username);
}
let PATs; let PATs;
if (pat) { if (userPAT?.token) {
PATs = [{ name: "user PAT from database", value: pat }]; PATs = [{ name: `USER_${username}`, value: userPAT.token }];
} else { } else {
PATs = getConfig().pats; const patNames = Object.keys(process.env).filter((key) =>
/PAT_\d*$/.exec(key),
);
PATs = patNames.map((name) => ({ name, value: process.env[name] }));
} }
if (!PATs.length) { if (!PATs.length) {
@@ -102,3 +110,4 @@ const retryer = async (fetcher, variables, pat = null) => {
}; };
export { retryer }; export { retryer };
export default retryer;
@@ -84,14 +84,13 @@ const calculatePrimaryLanguage = (files) => {
* Fetch GitHub gist information by given username and ID. * Fetch GitHub gist information by given username and ID.
* *
* @param {string} id GitHub gist ID. * @param {string} id GitHub gist ID.
* @param {string | null} pat Optional PAT override.
* @returns {Promise<GistData>} Gist data. * @returns {Promise<GistData>} Gist data.
*/ */
const fetchGist = async (id, pat = null) => { const fetchGist = async (id) => {
if (!id) { if (!id) {
throw new MissingParamError(["id"], "/api/gist?id=GIST_ID"); throw new MissingParamError(["id"], "/api/gist?id=GIST_ID");
} }
const res = await retryer(fetcher, { gistName: id }, pat); const res = await retryer(fetcher, null, { gistName: id });
if (res.data.errors) { if (res.data.errors) {
throw new Error(res.data.errors[0].message); throw new Error(res.data.errors[0].message);
} }
@@ -76,7 +76,6 @@ const fetchRepo = async (
include_prs_reviewed = false, include_prs_reviewed = false,
include_issues_authored = false, include_issues_authored = false,
include_issues_commented = false, include_issues_commented = false,
pat = null,
) => { ) => {
let owner = username; let owner = username;
if (reponame && reponame.includes("/")) { if (reponame && reponame.includes("/")) {
@@ -101,7 +100,7 @@ const fetchRepo = async (
throw new MissingParamError(["repo"], urlExample); throw new MissingParamError(["repo"], urlExample);
} }
let res = await retryer(fetcher, { login: owner, repo: reponame }, pat); let res = await retryer(fetcher, username, { login: owner, repo: reponame });
const data = res.data.data; const data = res.data.data;
@@ -125,7 +124,6 @@ const fetchRepo = async (
include_prs_reviewed, include_prs_reviewed,
include_issues_authored, include_issues_authored,
include_issues_commented, include_issues_commented,
pat,
); );
return { return {
...repoUserStats, ...repoUserStats,
@@ -150,7 +148,6 @@ const fetchRepo = async (
include_prs_reviewed, include_prs_reviewed,
include_issues_authored, include_issues_authored,
include_issues_commented, include_issues_commented,
pat,
); );
return { return {
...repoUserStats, ...repoUserStats,
@@ -4,7 +4,7 @@ import axios from "axios";
import githubUsernameRegex from "github-username-regex"; import githubUsernameRegex from "github-username-regex";
import { calculateRank } from "../calculateRank.js"; import { calculateRank } from "../calculateRank.js";
import { getConfig } from "../common/config.js"; import { excludeRepositories } from "../common/envs.js";
import { CustomError, MissingParamError } from "../common/error.js"; import { CustomError, MissingParamError } from "../common/error.js";
import { wrapTextMultiline } from "../common/fmt.js"; import { wrapTextMultiline } from "../common/fmt.js";
import { request } from "../common/http.js"; import { request } from "../common/http.js";
@@ -106,8 +106,7 @@ const fetcher = (variables, token) => {
* @param {boolean} variables.includeDiscussions Include discussions. * @param {boolean} variables.includeDiscussions Include discussions.
* @param {boolean} variables.includeDiscussionsAnswers Include discussions answers. * @param {boolean} variables.includeDiscussionsAnswers Include discussions answers.
* @param {string|undefined} variables.startTime Time to start the count of total commits. * @param {string|undefined} variables.startTime Time to start the count of total commits.
* @param {string[]} variables.ownerAffiliations The owner affiliations to filter by. Default: OWNER. * @param {string[]} ownerAffiliations The owner affiliations to filter by. Default: OWNER.
* @param {string | null} variables.pat PAT override or null.
* @returns {Promise<import('axios').AxiosResponse>} Axios response. * @returns {Promise<import('axios').AxiosResponse>} Axios response.
* *
* @description This function supports multi-page fetching if the 'FETCH_MULTI_PAGE_STARS' environment variable is set to true or a limit of fetches. * @description This function supports multi-page fetching if the 'FETCH_MULTI_PAGE_STARS' environment variable is set to true or a limit of fetches.
@@ -119,7 +118,6 @@ const statsFetcher = async ({
includeDiscussionsAnswers, includeDiscussionsAnswers,
startTime, startTime,
ownerAffiliations, ownerAffiliations,
pat,
}) => { }) => {
let stats; let stats;
let hasNextPage = true; let hasNextPage = true;
@@ -136,7 +134,7 @@ const statsFetcher = async ({
startTime, startTime,
ownerAffiliations, ownerAffiliations,
}; };
let res = await retryer(fetcher, variables, pat); let res = await retryer(fetcher, username, variables);
if (res.data.errors) { if (res.data.errors) {
return res; return res;
} }
@@ -163,8 +161,8 @@ const statsFetcher = async ({
); );
hasNextPage = hasNextPage =
(getConfig().fetchMultiPageStars === "true" || (process.env.FETCH_MULTI_PAGE_STARS === "true" ||
getConfig().fetchMultiPageStars > fetchedPages) && process.env.FETCH_MULTI_PAGE_STARS > fetchedPages) &&
repoNodes.length === repoNodesWithStars.length && repoNodes.length === repoNodesWithStars.length &&
res.data.data.user.repositories.pageInfo.hasNextPage; res.data.data.user.repositories.pageInfo.hasNextPage;
@@ -209,7 +207,7 @@ const fetchTotalItems = (variables, token) => {
* @description Done like this because the GitHub API does not provide a way to fetch all the commits. See * @description Done like this because the GitHub API does not provide a way to fetch all the commits. See
* #92#issuecomment-661026467 and #211 for more information. * #92#issuecomment-661026467 and #211 for more information.
*/ */
const totalItemsFetcher = async (username, repo, owner, type, filter, pat) => { const totalItemsFetcher = async (username, repo, owner, type, filter) => {
if (!githubUsernameRegex.test(username)) { if (!githubUsernameRegex.test(username)) {
logger.log("Invalid username provided."); logger.log("Invalid username provided.");
throw new Error("Invalid username provided."); throw new Error("Invalid username provided.");
@@ -217,20 +215,16 @@ const totalItemsFetcher = async (username, repo, owner, type, filter, pat) => {
let res; let res;
try { try {
res = await retryer( res = await retryer(fetchTotalItems, username, {
fetchTotalItems, login: username,
{ repo,
login: username, owner,
repo, type,
owner, filter,
type, });
filter,
},
pat,
);
} catch (err) { } catch (err) {
logger.log(err); logger.log(err);
throw err; throw new Error(err);
} }
const totalCount = res.data.total_count; const totalCount = res.data.total_count;
@@ -253,7 +247,6 @@ const fetchRepoUserStats = async (
include_prs_reviewed, include_prs_reviewed,
include_issues_authored, include_issues_authored,
include_issues_commented, include_issues_commented,
pat,
) => { ) => {
let stats = {}; let stats = {};
if (include_prs_authored) { if (include_prs_authored) {
@@ -263,7 +256,6 @@ const fetchRepoUserStats = async (
owner, owner,
"issues", "issues",
`author:${username}+type:pr`, `author:${username}+type:pr`,
pat,
); );
} }
if (include_prs_commented) { if (include_prs_commented) {
@@ -273,7 +265,6 @@ const fetchRepoUserStats = async (
owner, owner,
"issues", "issues",
`commenter:${username}+-author:${username}+type:pr`, `commenter:${username}+-author:${username}+type:pr`,
pat,
); );
} }
if (include_prs_reviewed) { if (include_prs_reviewed) {
@@ -283,7 +274,6 @@ const fetchRepoUserStats = async (
owner, owner,
"issues", "issues",
`reviewed-by:${username}+-author:${username}+type:pr`, `reviewed-by:${username}+-author:${username}+type:pr`,
pat,
); );
} }
if (include_issues_authored) { if (include_issues_authored) {
@@ -293,7 +283,6 @@ const fetchRepoUserStats = async (
owner, owner,
"issues", "issues",
`author:${username}+type:issue`, `author:${username}+type:issue`,
pat,
); );
} }
if (include_issues_commented) { if (include_issues_commented) {
@@ -303,7 +292,6 @@ const fetchRepoUserStats = async (
owner, owner,
"issues", "issues",
`commenter:${username}+-author:${username}+type:issue`, `commenter:${username}+-author:${username}+type:issue`,
pat,
); );
} }
return stats; return stats;
@@ -338,7 +326,6 @@ const fetchStats = async (
include_issues_authored = false, include_issues_authored = false,
include_issues_commented = false, include_issues_commented = false,
ownerAffiliations = [], ownerAffiliations = [],
pat = null,
) => { ) => {
if (!username) { if (!username) {
throw new MissingParamError(["username"]); throw new MissingParamError(["username"]);
@@ -365,17 +352,14 @@ const fetchStats = async (
}; };
ownerAffiliations = parseOwnerAffiliations(ownerAffiliations); ownerAffiliations = parseOwnerAffiliations(ownerAffiliations);
let res = await statsFetcher( let res = await statsFetcher({
{ username,
username, includeMergedPullRequests: include_merged_pull_requests,
includeMergedPullRequests: include_merged_pull_requests, includeDiscussions: include_discussions,
includeDiscussions: include_discussions, includeDiscussionsAnswers: include_discussions_answers,
includeDiscussionsAnswers: include_discussions_answers, startTime: commits_year ? `${commits_year}-01-01T00:00:00Z` : undefined,
startTime: commits_year ? `${commits_year}-01-01T00:00:00Z` : undefined, ownerAffiliations,
ownerAffiliations, });
},
pat,
);
// Catch GraphQL errors. // Catch GraphQL errors.
if (res.data.errors) { if (res.data.errors) {
@@ -388,7 +372,7 @@ const fetchStats = async (
} }
if (res.data.errors[0].message) { if (res.data.errors[0].message) {
throw new CustomError( throw new CustomError(
wrapTextMultiline(res.data.errors[0].message, 525, 12)[0], wrapTextMultiline(res.data.errors[0].message, 90, 1)[0],
res.statusText, res.statusText,
); );
} }
@@ -410,7 +394,6 @@ const fetchStats = async (
owner, owner,
"commits", "commits",
`author:${username}`, `author:${username}`,
pat,
); );
} else { } else {
stats.totalCommits = user.commits.totalCommitContributions; stats.totalCommits = user.commits.totalCommitContributions;
@@ -424,7 +407,6 @@ const fetchStats = async (
include_prs_reviewed, include_prs_reviewed,
include_issues_authored, include_issues_authored,
include_issues_commented, include_issues_commented,
pat,
); );
Object.assign(stats, repoUserStats); Object.assign(stats, repoUserStats);
@@ -447,10 +429,7 @@ const fetchStats = async (
stats.contributedTo = user.repositoriesContributedTo.totalCount; stats.contributedTo = user.repositoriesContributedTo.totalCount;
// Retrieve stars while filtering out repositories to be hidden. // Retrieve stars while filtering out repositories to be hidden.
const allExcludedRepos = [ const allExcludedRepos = [...exclude_repo, ...excludeRepositories];
...exclude_repo,
...getConfig().excludeRepositories,
];
let repoToHide = new Set(allExcludedRepos); let repoToHide = new Set(allExcludedRepos);
stats.totalStars = user.repositories.nodes stats.totalStars = user.repositories.nodes
@@ -1,6 +1,6 @@
// @ts-check // @ts-check
import { getConfig } from "../common/config.js"; import { excludeRepositories } from "../common/envs.js";
import { CustomError, MissingParamError } from "../common/error.js"; import { CustomError, MissingParamError } from "../common/error.js";
import { wrapTextMultiline } from "../common/fmt.js"; import { wrapTextMultiline } from "../common/fmt.js";
import { request } from "../common/http.js"; import { request } from "../common/http.js";
@@ -59,7 +59,6 @@ const fetcher = (variables, token) => {
* @param {number} size_weight Weightage to be given to size. * @param {number} size_weight Weightage to be given to size.
* @param {number} count_weight Weightage to be given to count. * @param {number} count_weight Weightage to be given to count.
* @param {string[]} ownerAffiliations The owner affiliations to filter by. Default: OWNER. * @param {string[]} ownerAffiliations The owner affiliations to filter by. Default: OWNER.
* @param {string|null} pat Optional PAT override.
* @returns {Promise<TopLangData>} Top languages data. * @returns {Promise<TopLangData>} Top languages data.
*/ */
const fetchTopLanguages = async ( const fetchTopLanguages = async (
@@ -68,21 +67,16 @@ const fetchTopLanguages = async (
size_weight = 1, size_weight = 1,
count_weight = 0, count_weight = 0,
ownerAffiliations = [], ownerAffiliations = [],
pat = null,
) => { ) => {
if (!username) { if (!username) {
throw new MissingParamError(["username"]); throw new MissingParamError(["username"]);
} }
ownerAffiliations = parseOwnerAffiliations(ownerAffiliations); ownerAffiliations = parseOwnerAffiliations(ownerAffiliations);
const res = await retryer( const res = await retryer(fetcher, username, {
fetcher, login: username,
{ ownerAffiliations,
login: username, });
ownerAffiliations,
},
pat,
);
if (res.data.errors) { if (res.data.errors) {
logger.error(res.data.errors); logger.error(res.data.errors);
@@ -94,7 +88,7 @@ const fetchTopLanguages = async (
} }
if (res.data.errors[0].message) { if (res.data.errors[0].message) {
throw new CustomError( throw new CustomError(
wrapTextMultiline(res.data.errors[0].message, 525, 12)[0], wrapTextMultiline(res.data.errors[0].message, 90, 1)[0],
res.statusText, res.statusText,
); );
} }
@@ -107,10 +101,7 @@ const fetchTopLanguages = async (
let repoNodes = res.data.data.user.repositories.nodes; let repoNodes = res.data.data.user.repositories.nodes;
/** @type {Record<string, boolean>} */ /** @type {Record<string, boolean>} */
let repoToHide = {}; let repoToHide = {};
const allExcludedRepos = [ const allExcludedRepos = [...exclude_repo, ...excludeRepositories];
...exclude_repo,
...getConfig().excludeRepositories,
];
// populate repoToHide map for quick lookup // populate repoToHide map for quick lookup
// while filtering out // while filtering out
@@ -1,13 +1,13 @@
export interface GistData { export type GistData = {
name: string; name: string;
nameWithOwner: string; nameWithOwner: string;
description: string | null; description: string | null;
language: string | null; language: string | null;
starsCount: number; starsCount: number;
forksCount: number; forksCount: number;
} };
export interface RepositoryData { export type RepositoryData = {
name: string; name: string;
nameWithOwner: string; nameWithOwner: string;
isPrivate: boolean; isPrivate: boolean;
@@ -27,9 +27,9 @@ export interface RepositoryData {
totalPRsReviewed: number; totalPRsReviewed: number;
totalIssuesAuthored: number; totalIssuesAuthored: number;
totalIssuesCommented: number; totalIssuesCommented: number;
} };
export interface StatsData { export type StatsData = {
name: string; name: string;
totalPRs: number; totalPRs: number;
totalPRsMerged: number; totalPRsMerged: number;
@@ -47,18 +47,18 @@ export interface StatsData {
totalIssuesAuthored: number; totalIssuesAuthored: number;
totalIssuesCommented: number; totalIssuesCommented: number;
rank: { level: string; percentile: number }; rank: { level: string; percentile: number };
} };
export interface Lang { export type Lang = {
name: string; name: string;
color: string; color: string;
size: number; size: number;
} };
export type TopLangData = Record<string, Lang>; export type TopLangData = Record<string, Lang>;
export interface WakaTimeData { export type WakaTimeData = {
categories: Array<{ categories: {
digital: string; digital: string;
hours: number; hours: number;
minutes: number; minutes: number;
@@ -66,12 +66,12 @@ export interface WakaTimeData {
percent: number; percent: number;
text: string; text: string;
total_seconds: number; total_seconds: number;
}>; }[];
daily_average: number; daily_average: number;
daily_average_including_other_language: number; daily_average_including_other_language: number;
days_including_holidays: number; days_including_holidays: number;
days_minus_holidays: number; days_minus_holidays: number;
editors: Array<{ editors: {
digital: string; digital: string;
hours: number; hours: number;
minutes: number; minutes: number;
@@ -79,7 +79,7 @@ export interface WakaTimeData {
percent: number; percent: number;
text: string; text: string;
total_seconds: number; total_seconds: number;
}>; }[];
holidays: number; holidays: number;
human_readable_daily_average: string; human_readable_daily_average: string;
human_readable_daily_average_including_other_language: string; human_readable_daily_average_including_other_language: string;
@@ -92,7 +92,7 @@ export interface WakaTimeData {
is_other_usage_visible: boolean; is_other_usage_visible: boolean;
is_stuck: boolean; is_stuck: boolean;
is_up_to_date: boolean; is_up_to_date: boolean;
languages: Array<{ languages: {
digital: string; digital: string;
hours: number; hours: number;
minutes: number; minutes: number;
@@ -100,8 +100,8 @@ export interface WakaTimeData {
percent: number; percent: number;
text: string; text: string;
total_seconds: number; total_seconds: number;
}>; }[];
operating_systems: Array<{ operating_systems: {
digital: string; digital: string;
hours: number; hours: number;
minutes: number; minutes: number;
@@ -109,7 +109,7 @@ export interface WakaTimeData {
percent: number; percent: number;
text: string; text: string;
total_seconds: number; total_seconds: number;
}>; }[];
percent_calculated: number; percent_calculated: number;
range: string; range: string;
status: string; status: string;
@@ -119,10 +119,10 @@ export interface WakaTimeData {
user_id: string; user_id: string;
username: string; username: string;
writes_only: boolean; writes_only: boolean;
} };
export interface WakaTimeLang { export type WakaTimeLang = {
name: string; name: string;
text: string; text: string;
percent: number; percent: number;
} };
+2
View File
@@ -0,0 +1,2 @@
export * from "./common/index.js";
export * from "./cards/index.js";
-1
View File
@@ -80,7 +80,6 @@ async function githubAuthenticate(code, privateAccess) {
return { userId, accessToken, needDowngrade }; return { userId, accessToken, needDowngrade };
} catch (err) { } catch (err) {
if (err.response) { if (err.response) {
// eslint-disable-next-line preserve-caught-error
throw new Error(`OAuth Error: ${err.response.status}`); throw new Error(`OAuth Error: ${err.response.status}`);
} }
throw err; throw err;
@@ -65,24 +65,6 @@ exports[`Test Render WakaTime Card > should render correctly 1`] = `
/* Animations */
@keyframes scaleInAnimation {
from {
transform: translate(-5px, 5px) scale(0);
}
to {
transform: translate(-5px, 5px) scale(1);
}
}
@keyframes fadeInAnimation {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
</style> </style>
@@ -241,24 +223,6 @@ exports[`Test Render WakaTime Card > should render correctly with compact layout
/* Animations */
@keyframes scaleInAnimation {
from {
transform: translate(-5px, 5px) scale(0);
}
to {
transform: translate(-5px, 5px) scale(1);
}
}
@keyframes fadeInAnimation {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
</style> </style>
@@ -411,24 +375,6 @@ exports[`Test Render WakaTime Card > should render correctly with compact layout
/* Animations */
@keyframes scaleInAnimation {
from {
transform: translate(-5px, 5px) scale(0);
}
to {
transform: translate(-5px, 5px) scale(1);
}
}
@keyframes fadeInAnimation {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
</style> </style>
@@ -581,24 +527,6 @@ exports[`Test Render WakaTime Card > should render correctly with percent displa
/* Animations */
@keyframes scaleInAnimation {
from {
transform: translate(-5px, 5px) scale(0);
}
to {
transform: translate(-5px, 5px) scale(1);
}
}
@keyframes fadeInAnimation {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
</style> </style>
@@ -0,0 +1,115 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`Test /api/wakatime > should render error if user data is not accessible 1`] = `
"
<svg
width="495"
height="150"
viewBox="0 0 495 150"
fill="none"
xmlns="http://www.w3.org/2000/svg"
role="img"
aria-labelledby="descId"
>
<title id="titleId"></title>
<desc id="descId"></desc>
<style>
.header {
font: 600 18px 'Segoe UI', Ubuntu, Sans-Serif;
fill: #2f80ed;
animation: fadeInAnimation 0.8s ease-in-out forwards;
}
@supports(-moz-appearance: auto) {
/* Selector detects Firefox */
.header { font-size: 15.5px; }
}
.stat {
font: 600 14px 'Segoe UI', Ubuntu, "Helvetica Neue", Sans-Serif; fill: #434d58;
}
@supports(-moz-appearance: auto) {
/* Selector detects Firefox */
.stat { font-size:12px; }
}
.stagger {
opacity: 0;
animation: fadeInAnimation 0.3s ease-in-out forwards;
}
.not_bold { font-weight: 400 }
.bold { font-weight: 700 }
@keyframes slideInAnimation {
from {
width: 0;
}
to {
width: calc(100%-100px);
}
}
@keyframes growWidthAnimation {
from {
width: 0;
}
to {
width: 100%;
}
}
.lang-name { font: 400 11px 'Segoe UI', Ubuntu, Sans-Serif; fill: #434d58 }
#rect-mask rect{
animation: slideInAnimation 1s ease-in-out forwards;
}
.lang-progress{
animation: growWidthAnimation 0.6s ease-in-out forwards;
}
</style>
<rect
data-testid="card-bg"
x="0.5"
y="0.5"
rx="4.5"
height="99%"
stroke="#e4e2e2"
width="494"
fill="#fffefe"
stroke-opacity="1"
/>
<g
data-testid="card-title"
transform="translate(25, 35)"
>
<g transform="translate(0, 0)">
<text
x="0"
y="0"
class="header"
data-testid="header"
>WakaTime Stats</text>
</g>
</g>
<g
data-testid="main-card-body"
transform="translate(0, 55)"
>
<svg x="0" y="0" width="100%">
<g transform="translate(0, 0)">
<text x="25" y="11" class="stat bold" fill="#434d58">WakaTime user profile not public</text>
</g>
</svg>
</g>
</svg>
"
`;
@@ -0,0 +1,2 @@
process.env.GIST_WHITELIST = "bbfce31e0217a3689c8d961a356cb10d";
process.env.WHITELIST = "anuraghazra";
+299 -135
View File
@@ -1,181 +1,345 @@
// @ts-check // @ts-check
import { api, getConfig } from "@stats-organization/github-readme-stats-core"; import axios from "axios";
import { beforeEach, describe, expect, it, vi } from "vitest"; import MockAdapter from "axios-mock-adapter";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import router from "../router.js"; import api from "../api-renamed/index.js";
import { calculateRank } from "../src/calculateRank.js";
import { renderStatsCard } from "../src/cards/stats.js";
import { CACHE_TTL, DURATIONS } from "../src/common/cache.js"; import { CACHE_TTL, DURATIONS } from "../src/common/cache.js";
import { getUserAccessByName, storeRequest } from "../src/common/database.js"; import { renderError } from "../src/common/render.js";
vi.mock(import("@stats-organization/github-readme-stats-core"), async () => { import { data_stats, stats } from "./test-data/api-data.js";
const { mockCore } = await import("./utils.js");
return mockCore(); stats.rank = calculateRank({
all_commits: false,
commits: stats.totalCommits,
prs: stats.totalPRs,
reviews: stats.totalReviews,
issues: stats.totalIssues,
repos: 1,
stars: stats.totalStars,
followers: 0,
}); });
vi.mock(import("../src/common/database.js"), async (importOriginal) => ({ const error = {
...(await importOriginal()), errors: [
storeRequest: vi.fn(), {
getUserAccessByName: vi.fn(), type: "NOT_FOUND",
})); path: ["user"],
locations: [],
message: "Could not fetch user",
},
],
};
const apiMock = vi.mocked(api); const mock = new MockAdapter(axios);
const getConfigMock = vi.mocked(getConfig);
const storeRequestMock = vi.mocked(storeRequest);
const getUserAccessByNameMock = vi.mocked(getUserAccessByName);
const createRequest = (search = "") => ({ // @ts-ignore
headers: {}, const faker = (query, data) => {
url: `/api?${search}`, const req = {
}); query: {
username: "anuraghazra",
...query,
},
};
const res = {
setHeader: vi.fn(),
send: vi.fn(),
};
mock.onPost("https://api.github.com/graphql").replyOnce(200, data);
const createResponse = () => ({ return { req, res };
end: vi.fn(), };
setHeader: vi.fn(),
});
const defaultCacheHeader =
`max-age=${CACHE_TTL.STATS_CARD.DEFAULT}, ` +
`s-maxage=${CACHE_TTL.STATS_CARD.DEFAULT}, ` +
`stale-while-revalidate=${DURATIONS.ONE_DAY}`;
const errorCacheHeader =
`max-age=${CACHE_TTL.ERROR}, ` +
`s-maxage=${CACHE_TTL.ERROR}, ` +
`stale-while-revalidate=${DURATIONS.ONE_DAY}`;
beforeEach(() => { beforeEach(() => {
apiMock.mockReset(); process.env.CACHE_SECONDS = undefined;
getConfigMock.mockReset().mockReturnValue({});
storeRequestMock.mockReset().mockResolvedValue(undefined);
getUserAccessByNameMock.mockReset().mockResolvedValue(null);
// CACHE_SECONDS is not set here, this is just to safeguard against CACHE_SECONDS being set externally
delete process.env.CACHE_SECONDS;
}); });
describe("Test /api backend routing", () => { afterEach(() => {
it("happy path should pass query params and user PAT, respond with stats content and persist request", async () => { mock.reset();
getUserAccessByNameMock.mockResolvedValue({ token: "user-pat" }); });
apiMock.mockResolvedValue({
status: "success",
content: "mock-stats-svg",
});
const req = createRequest( describe("Test /api/", () => {
"username=anuraghazra&theme=dark&hide=issues,prs,contribs", it("should test the request", async () => {
const { req, res } = faker({}, data_stats);
await api(req, res);
expect(res.setHeader).toHaveBeenCalledWith("Content-Type", "image/svg+xml");
expect(res.send).toHaveBeenCalledWith(
renderStatsCard(stats, { ...req.query }),
); );
const res = createResponse(); });
await router(req, res); it("should render error card on error", async () => {
const { req, res } = faker({}, error);
expect(getUserAccessByNameMock).toHaveBeenCalledWith("anuraghazra"); await api(req, res);
expect(apiMock).toHaveBeenCalledWith(
expect(res.setHeader).toHaveBeenCalledWith("Content-Type", "image/svg+xml");
expect(res.send).toHaveBeenCalledWith(
renderError({
message: error.errors[0].message,
secondaryMessage:
"Make sure the provided username is not an organization",
}),
);
});
it("should render error card in same theme as requested card", async () => {
const { req, res } = faker({ theme: "merko" }, error);
await api(req, res);
expect(res.setHeader).toHaveBeenCalledWith("Content-Type", "image/svg+xml");
expect(res.send).toHaveBeenCalledWith(
renderError({
message: error.errors[0].message,
secondaryMessage:
"Make sure the provided username is not an organization",
renderOptions: { theme: "merko" },
}),
);
});
it("should get the query options", async () => {
const { req, res } = faker(
{ {
username: "anuraghazra", username: "anuraghazra",
theme: "dark",
hide: "issues,prs,contribs", hide: "issues,prs,contribs",
show_icons: true,
hide_border: true,
line_height: 100,
title_color: "fff",
icon_color: "fff",
text_color: "fff",
bg_color: "fff",
}, },
"user-pat", data_stats,
);
await api(req, res);
expect(res.setHeader).toHaveBeenCalledWith("Content-Type", "image/svg+xml");
expect(res.send).toHaveBeenCalledWith(
renderStatsCard(stats, {
hide: ["issues", "prs", "contribs"],
show_icons: true,
hide_border: true,
line_height: 100,
title_color: "fff",
icon_color: "fff",
text_color: "fff",
bg_color: "fff",
}),
); );
expect(req.query).toEqual({
username: "anuraghazra",
theme: "dark",
hide: "issues,prs,contribs",
});
expect(res.setHeader.mock.calls).toEqual([
["Cache-Control", defaultCacheHeader],
["Content-Type", "image/svg+xml"],
]);
expect(res.end).toHaveBeenCalledExactlyOnceWith("mock-stats-svg");
expect(storeRequestMock).toHaveBeenCalledExactlyOnceWith(req);
}); });
it("should use the shorter error cache for temporary stats errors", async () => { it("should have proper cache", async () => {
apiMock.mockResolvedValue({ const { req, res } = faker({}, data_stats);
status: "error - temporary",
content: "temporary-error-svg",
});
const req = createRequest("username=anuraghazra"); await api(req, res);
const res = createResponse();
await router(req, res); expect(res.setHeader.mock.calls).toEqual([
["Content-Type", "image/svg+xml"],
[
"Cache-Control",
`max-age=${CACHE_TTL.STATS_CARD.DEFAULT}, ` +
`s-maxage=${CACHE_TTL.STATS_CARD.DEFAULT}, ` +
`stale-while-revalidate=${DURATIONS.ONE_DAY}`,
],
]);
});
expect(getUserAccessByNameMock).toHaveBeenCalledWith("anuraghazra"); it("should set proper cache", async () => {
expect(apiMock).toHaveBeenCalledWith( const cache_seconds = DURATIONS.TWELVE_HOURS;
const { req, res } = faker({ cache_seconds }, data_stats);
await api(req, res);
expect(res.setHeader.mock.calls).toEqual([
["Content-Type", "image/svg+xml"],
[
"Cache-Control",
`max-age=${cache_seconds}, ` +
`s-maxage=${cache_seconds}, ` +
`stale-while-revalidate=${DURATIONS.ONE_DAY}`,
],
]);
});
it("should set shorter cache when error", async () => {
const { req, res } = faker({}, error);
await api(req, res);
expect(res.setHeader.mock.calls).toEqual([
["Content-Type", "image/svg+xml"],
[
"Cache-Control",
`max-age=${CACHE_TTL.ERROR}, ` +
`s-maxage=${CACHE_TTL.ERROR}, ` +
`stale-while-revalidate=${DURATIONS.ONE_DAY}`,
],
]);
});
it("should properly set cache using CACHE_SECONDS env variable", async () => {
const cacheSeconds = "10000";
process.env.CACHE_SECONDS = cacheSeconds;
const { req, res } = faker({}, data_stats);
await api(req, res);
expect(res.setHeader.mock.calls).toEqual([
["Content-Type", "image/svg+xml"],
[
"Cache-Control",
`max-age=${cacheSeconds}, ` +
`s-maxage=${cacheSeconds}, ` +
`stale-while-revalidate=${DURATIONS.ONE_DAY}`,
],
]);
});
it("should disable cache when CACHE_SECONDS is set to 0", async () => {
process.env.CACHE_SECONDS = "0";
const { req, res } = faker({}, data_stats);
await api(req, res);
expect(res.setHeader.mock.calls).toEqual([
["Content-Type", "image/svg+xml"],
[
"Cache-Control",
"no-cache, no-store, must-revalidate, max-age=0, s-maxage=0",
],
["Pragma", "no-cache"],
["Expires", "0"],
]);
});
it("should set proper cache with clamped values", async () => {
{
let { req, res } = faker({ cache_seconds: 200_000 }, data_stats);
await api(req, res);
expect(res.setHeader.mock.calls).toEqual([
["Content-Type", "image/svg+xml"],
[
"Cache-Control",
`max-age=${CACHE_TTL.STATS_CARD.MAX}, ` +
`s-maxage=${CACHE_TTL.STATS_CARD.MAX}, ` +
`stale-while-revalidate=${DURATIONS.ONE_DAY}`,
],
]);
}
// note i'm using block scoped vars
{
let { req, res } = faker({ cache_seconds: 0 }, data_stats);
await api(req, res);
expect(res.setHeader.mock.calls).toEqual([
["Content-Type", "image/svg+xml"],
[
"Cache-Control",
`max-age=${CACHE_TTL.STATS_CARD.MIN}, ` +
`s-maxage=${CACHE_TTL.STATS_CARD.MIN}, ` +
`stale-while-revalidate=${DURATIONS.ONE_DAY}`,
],
]);
}
{
let { req, res } = faker({ cache_seconds: -10_000 }, data_stats);
await api(req, res);
expect(res.setHeader.mock.calls).toEqual([
["Content-Type", "image/svg+xml"],
[
"Cache-Control",
`max-age=${CACHE_TTL.STATS_CARD.MIN}, ` +
`s-maxage=${CACHE_TTL.STATS_CARD.MIN}, ` +
`stale-while-revalidate=${DURATIONS.ONE_DAY}`,
],
]);
}
});
it("should allow changing ring_color", async () => {
const { req, res } = faker(
{ {
username: "anuraghazra", username: "anuraghazra",
hide: "issues,prs,contribs",
show_icons: true,
hide_border: true,
line_height: 100,
title_color: "fff",
ring_color: "0000ff",
icon_color: "fff",
text_color: "fff",
bg_color: "fff",
}, },
null, data_stats,
);
await api(req, res);
expect(res.setHeader).toHaveBeenCalledWith("Content-Type", "image/svg+xml");
expect(res.send).toHaveBeenCalledWith(
renderStatsCard(stats, {
hide: ["issues", "prs", "contribs"],
show_icons: true,
hide_border: true,
line_height: 100,
title_color: "fff",
ring_color: "0000ff",
icon_color: "fff",
text_color: "fff",
bg_color: "fff",
}),
); );
expect(res.setHeader.mock.calls).toEqual([
["Cache-Control", errorCacheHeader],
["Content-Type", "image/svg+xml"],
]);
expect(res.end).toHaveBeenCalledExactlyOnceWith("temporary-error-svg");
expect(storeRequestMock).toHaveBeenCalledExactlyOnceWith(req);
}); });
it("should not persist permanent stats errors returned by core", async () => { it("should render error card when wrong locale is provided", async () => {
apiMock.mockResolvedValue({ const { req, res } = faker({ locale: "asdf" }, data_stats);
status: "error - permanent",
content: "permanent-error-svg",
});
const req = createRequest("username=anuraghazra"); await api(req, res);
const res = createResponse();
await router(req, res); expect(res.setHeader).toHaveBeenCalledWith("Content-Type", "image/svg+xml");
expect(res.send).toHaveBeenCalledWith(
expect(getUserAccessByNameMock).toHaveBeenCalledWith("anuraghazra"); renderError({
expect(apiMock).toHaveBeenCalledWith( message: "Something went wrong",
{ secondaryMessage: "Language not found",
username: "anuraghazra", }),
},
null,
); );
expect(res.setHeader.mock.calls).toEqual([
["Cache-Control", defaultCacheHeader],
["Content-Type", "image/svg+xml"],
]);
expect(res.end).toHaveBeenCalledExactlyOnceWith("permanent-error-svg");
expect(storeRequestMock).not.toHaveBeenCalled();
}); });
it("should reject blacklisted usernames before calling core logic", async () => { it("should render error card when include_all_commits true and upstream API fails", async () => {
const req = createRequest("username=renovate-bot"); mock
const res = createResponse(); .onGet(
"https://api.github.com/search/commits?per_page=1&q=author:anuraghazra",
)
.reply(200, { error: "Some test error message" });
await router(req, res); const { req, res } = faker(
{ username: "anuraghazra", include_all_commits: true },
expect(apiMock).not.toHaveBeenCalled(); data_stats,
expect(getUserAccessByNameMock).not.toHaveBeenCalled();
expect(res.setHeader.mock.calls).toEqual([
["Cache-Control", defaultCacheHeader],
["Content-Type", "image/svg+xml"],
]);
expect(res.end).toHaveBeenCalledExactlyOnceWith(
"render-error:This username is blacklisted",
); );
expect(storeRequestMock).not.toHaveBeenCalled();
});
it("should reject non-whitelisted usernames before calling core logic", async () => { await api(req, res);
getConfigMock.mockReturnValue({ whitelist: ["allowed-user"] });
const req = createRequest("username=blocked-user"); expect(res.setHeader).toHaveBeenCalledWith("Content-Type", "image/svg+xml");
const res = createResponse(); expect(res.send).toHaveBeenCalledWith(
renderError({
await router(req, res); message: "Could not fetch data from GitHub REST API.",
secondaryMessage: "Please try again later",
expect(apiMock).not.toHaveBeenCalled(); }),
expect(getUserAccessByNameMock).not.toHaveBeenCalled(); );
expect(res.setHeader.mock.calls).toEqual([ // Received SVG output should not contain string "https://tiny.one/readme-stats"
["Cache-Control", defaultCacheHeader], expect(res.send.mock.calls[0][0]).not.toContain(
["Content-Type", "image/svg+xml"], "https://tiny.one/readme-stats",
]);
expect(res.end).toHaveBeenCalledExactlyOnceWith(
"render-error:This username is not whitelisted",
); );
expect(storeRequestMock).not.toHaveBeenCalled();
}); });
}); });
+66 -25
View File
@@ -1,40 +1,81 @@
import axios from "axios"; import axios from "axios";
import MockAdapter from "axios-mock-adapter"; import MockAdapter from "axios-mock-adapter";
import { beforeAll, bench, describe, vi } from "vitest"; import { bench, describe, vi } from "vitest";
import { data_stats } from "../utils.js"; import api from "../../api-renamed/index.js";
const stats = {
name: "Anurag Hazra",
totalStars: 100,
totalCommits: 200,
totalIssues: 300,
totalPRs: 400,
totalPRsMerged: 320,
mergedPRsPercentage: 80,
totalReviews: 50,
totalDiscussionsStarted: 10,
totalDiscussionsAnswered: 40,
contributedTo: 50,
rank: null,
};
const data_stats = {
data: {
user: {
name: stats.name,
repositoriesContributedTo: { totalCount: stats.contributedTo },
commits: {
totalCommitContributions: stats.totalCommits,
},
reviews: {
totalPullRequestReviewContributions: stats.totalReviews,
},
pullRequests: { totalCount: stats.totalPRs },
mergedPullRequests: { totalCount: stats.totalPRsMerged },
openIssues: { totalCount: stats.totalIssues },
closedIssues: { totalCount: 0 },
followers: { totalCount: 0 },
repositoryDiscussions: { totalCount: stats.totalDiscussionsStarted },
repositoryDiscussionComments: {
totalCount: stats.totalDiscussionsAnswered,
},
repositories: {
totalCount: 1,
nodes: [{ stargazers: { totalCount: 100 } }],
pageInfo: {
hasNextPage: false,
endCursor: "cursor",
},
},
},
},
};
const mock = new MockAdapter(axios); const mock = new MockAdapter(axios);
const createResponse = () => ({ const faker = (query, data) => {
end: vi.fn(), const req = {
setHeader: vi.fn(), query: {
}); username: "anuraghazra",
...query,
},
};
const res = {
setHeader: vi.fn(),
send: vi.fn(),
};
mock.onPost("https://api.github.com/graphql").replyOnce(200, data);
let router; return { req, res };
};
beforeAll(async () => { describe("/api", () => {
vi.stubEnv("CACHE_SECONDS", "");
vi.stubEnv("GIST_WHITELIST", "");
vi.stubEnv("POSTGRES_URL", "");
vi.stubEnv("WHITELIST", "");
({ default: router } = await import("../../router.js"));
mock.onPost("https://api.github.com/graphql").reply(200, data_stats);
});
describe("bench /api", () => {
bench( bench(
"base", "base",
async () => { async () => {
const req = { const { req, res } = faker({}, data_stats);
headers: {},
url: "/api?username=anuraghazra",
};
const res = createResponse();
await router(req, res); await api(req, res);
}, },
{ warmupIterations: 50 }, { warmupIterations: 50 },
); );
@@ -0,0 +1,22 @@
import { bench, describe } from "vitest";
import { calculateRank } from "../../src/calculateRank.js";
describe("calculateRank", () => {
bench(
"base",
async () => {
calculateRank({
all_commits: false,
commits: 1300,
prs: 1500,
issues: 4500,
reviews: 1000,
repos: 0,
stars: 600000,
followers: 50000,
});
},
{ warmupIterations: 50 },
);
});
+39 -27
View File
@@ -1,42 +1,54 @@
import axios from "axios"; import axios from "axios";
import MockAdapter from "axios-mock-adapter"; import MockAdapter from "axios-mock-adapter";
import { beforeAll, bench, describe, vi } from "vitest"; import { bench, describe, vi } from "vitest";
import { happy_path_gist_data } from "../utils.js"; import gist from "../../api-renamed/gist.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 mock = new MockAdapter(axios); const mock = new MockAdapter(axios);
mock.onPost("https://api.github.com/graphql").reply(200, gist_data);
const createResponse = () => ({ describe("test /api/gist", () => {
end: vi.fn(),
setHeader: vi.fn(),
});
let router;
beforeAll(async () => {
vi.stubEnv("CACHE_SECONDS", "");
vi.stubEnv("GIST_WHITELIST", "");
vi.stubEnv("POSTGRES_URL", "");
vi.stubEnv("WHITELIST", "");
({ default: router } = await import("../../router.js"));
mock
.onPost("https://api.github.com/graphql")
.reply(200, happy_path_gist_data);
});
describe("bench /api/gist", () => {
bench( bench(
"base", "base",
async () => { async () => {
const req = { const req = {
headers: {}, query: {
url: "/api/gist?id=happy-gist-id", id: "bbfce31e0217a3689c8d961a356cb10d",
},
};
const res = {
setHeader: vi.fn(),
send: vi.fn(),
}; };
const res = createResponse();
await router(req, res); await gist(req, res);
}, },
{ warmupIterations: 50 }, { warmupIterations: 50 },
); );
+38 -25
View File
@@ -1,40 +1,53 @@
import axios from "axios"; import axios from "axios";
import MockAdapter from "axios-mock-adapter"; import MockAdapter from "axios-mock-adapter";
import { beforeAll, bench, describe, vi } from "vitest"; import { bench, describe, vi } from "vitest";
import { data_user } from "../utils.js"; import pin from "../../api-renamed/pin.js";
const data_repo = {
repository: {
username: "anuraghazra",
name: "convoychat",
stargazers: {
totalCount: 38000,
},
description: "Help us take over the world! React + TS + GraphQL Chat App",
primaryLanguage: {
color: "#2b7489",
id: "MDg6TGFuZ3VhZ2UyODc=",
name: "TypeScript",
},
forkCount: 100,
isTemplate: false,
},
};
const data_user = {
data: {
user: { repository: data_repo.repository },
organization: null,
},
};
const mock = new MockAdapter(axios); const mock = new MockAdapter(axios);
mock.onPost("https://api.github.com/graphql").reply(200, data_user);
const createResponse = () => ({ describe("/api/pin", () => {
end: vi.fn(),
setHeader: vi.fn(),
});
let router;
beforeAll(async () => {
vi.stubEnv("CACHE_SECONDS", "");
vi.stubEnv("GIST_WHITELIST", "");
vi.stubEnv("POSTGRES_URL", "");
vi.stubEnv("WHITELIST", "");
({ default: router } = await import("../../router.js"));
mock.onPost("https://api.github.com/graphql").reply(200, data_user);
});
describe("bench /api/pin", () => {
bench( bench(
"base", "base",
async () => { async () => {
const req = { const req = {
headers: {}, query: {
url: "/api/pin?username=anuraghazra&repo=convoychat", username: "anuraghazra",
repo: "convoychat",
},
};
const res = {
setHeader: vi.fn(),
send: vi.fn(),
}; };
const res = createResponse();
await router(req, res); await pin(req, res);
}, },
{ warmupIterations: 50 }, { warmupIterations: 50 },
); );
@@ -4,6 +4,8 @@ import { calculateRank } from "../src/calculateRank.js";
import { approxNumber } from "./utils.js"; import { approxNumber } from "./utils.js";
import "@testing-library/jest-dom/vitest";
describe("Test calculateRank", () => { describe("Test calculateRank", () => {
it("new user gets C rank", () => { it("new user gets C rank", () => {
expect( expect(
@@ -6,6 +6,8 @@ import { Card } from "../src/common/Card.js";
import { getCardColors } from "../src/common/color.js"; import { getCardColors } from "../src/common/color.js";
import { icons } from "../src/common/icons.js"; import { icons } from "../src/common/icons.js";
import "@testing-library/jest-dom/vitest";
describe("Card", () => { describe("Card", () => {
it("should hide border", () => { it("should hide border", () => {
const card = new Card({}); const card = new Card({});
+123 -245
View File
@@ -3,343 +3,221 @@
*/ */
import axios from "axios"; import axios from "axios";
import MockAdapter from "axios-mock-adapter"; import { beforeAll, describe, expect, test } from "vitest";
import { afterAll, beforeAll, describe, expect, test, vi } from "vitest";
import { renderGistCard } from "../../src/cards/gist.js";
import { renderRepoCard } from "../../src/cards/repo.js";
import { renderStatsCard } from "../../src/cards/stats.js";
import { renderTopLanguages } from "../../src/cards/top-languages.js";
import { renderWakatimeCard } from "../../src/cards/wakatime.js";
const REPO = "curly-fiesta"; const REPO = "curly-fiesta";
const USER = "catelinemnemosyne"; const USER = "catelinemnemosyne";
const STATS_CARD_USER = "e2eninja"; const STATS_CARD_USER = "e2eninja";
const GIST_ID = "372cef55fd897b31909fdeb3a7262758"; const GIST_ID = "372cef55fd897b31909fdeb3a7262758";
const STATS_MOCK_RESPONSE = { const STATS_DATA = {
data: { name: "CodeNinja",
user: { totalPRs: 1,
name: "CodeNinja", totalReviews: 0,
login: STATS_CARD_USER, totalCommits: 3,
repositoriesContributedTo: { totalCount: 0 }, totalIssues: 1,
commits: { totalStars: 1,
totalCommitContributions: 3, contributedTo: 0,
}, rank: {
reviews: { level: "C",
totalPullRequestReviewContributions: 0, percentile: 98.73972605284538,
},
pullRequests: { totalCount: 1 },
openIssues: { totalCount: 1 },
closedIssues: { totalCount: 0 },
followers: { totalCount: 0 },
repositories: {
totalCount: 1,
nodes: [{ name: REPO, stargazers: { totalCount: 1 } }],
pageInfo: {
hasNextPage: false,
endCursor: "cursor",
},
},
},
}, },
}; };
const COMMITS_SEARCH_MOCK_RESPONSE = { const LANGS_DATA = {
total_count: 3, HTML: {
}; color: "#e34c26",
name: "HTML",
const TOP_LANGS_MOCK_RESPONSE = { size: 1721,
data: { },
user: { CSS: {
repositories: { color: "#663399",
nodes: [ name: "CSS",
{ size: 930,
name: REPO, },
languages: { JavaScript: {
edges: [ color: "#f1e05a",
{ name: "JavaScript",
size: 1721, size: 1912,
node: {
color: "#e34c26",
name: "HTML",
},
},
{
size: 930,
node: {
color: "#663399",
name: "CSS",
},
},
{
size: 1912,
node: {
color: "#f1e05a",
name: "JavaScript",
},
},
],
},
},
],
},
},
}, },
}; };
const WAKATIME_MOCK_RESPONSE = { const WAKATIME_DATA = {
data: { human_readable_range: "last week",
human_readable_range: "last week", is_already_updating: false,
is_already_updating: false, is_coding_activity_visible: true,
is_coding_activity_visible: true, is_including_today: false,
is_including_today: false, is_other_usage_visible: false,
is_other_usage_visible: false, is_stuck: false,
is_stuck: false, is_up_to_date: false,
is_up_to_date: false, is_up_to_date_pending_future: false,
is_up_to_date_pending_future: false, percent_calculated: 0,
percent_calculated: 0, range: "all_time",
range: "all_time", status: "pending_update",
status: "pending_update", timeout: 15,
timeout: 15, username: USER,
username: USER, writes_only: false,
writes_only: false,
},
}; };
const REPO_MOCK_RESPONSE = { const REPOSITORY_DATA = {
data: { name: REPO,
user: { nameWithOwner: `${USER}/cra-test`,
repository: { isPrivate: false,
name: REPO, isArchived: false,
nameWithOwner: `${USER}/cra-test`, isTemplate: false,
isPrivate: false, stargazers: {
isArchived: false, totalCount: 1,
isTemplate: false,
stargazers: {
totalCount: 1,
},
description: "Simple cra test repo.",
primaryLanguage: {
color: "#f1e05a",
id: "MDg6TGFuZ3VhZ2UxNDA=",
name: "JavaScript",
},
forkCount: 0,
},
},
organization: null,
}, },
description: "Simple cra test repo.",
primaryLanguage: {
color: "#f1e05a",
id: "MDg6TGFuZ3VhZ2UxNDA=",
name: "JavaScript",
},
forkCount: 0,
starCount: 1,
}; };
const GIST_MOCK_RESPONSE = { /**
data: { * @typedef {import("../../src/fetchers/types").GistData} GistData Gist data type.
viewer: { */
gist: {
description: /**
"Trying to access this path on Windows 10 ver. 1803+ will breaks NTFS", * @type {GistData}
owner: { */
login: "qwerty541", const GIST_DATA = {
}, name: "link.txt",
stargazerCount: 1, nameWithOwner: "qwerty541/link.txt",
forks: { description:
totalCount: 0, "Trying to access this path on Windows 10 ver. 1803+ will breaks NTFS",
}, language: "Text",
files: [ starsCount: 1,
{ forksCount: 0,
name: "link.txt",
language: {
name: "Text",
},
size: 1,
},
],
},
},
},
}; };
const CACHE_BURST_STRING = `v=${new Date().getTime()}`; const CACHE_BURST_STRING = `v=${new Date().getTime()}`;
const mock = new MockAdapter(axios, { onNoMatch: "passthrough" });
const createResponse = () => ({
end: vi.fn(),
setHeader: vi.fn(),
});
let router;
/**
* Renders a card locally through the backend router.
* @param {string} url Card URL to render through the router.
* @returns {Promise<string>} Rendered SVG markup.
*/
async function getLocalSvg(url) {
const req = {
headers: {},
url,
};
const res = createResponse();
await router(req, res);
expect(res.end).toHaveBeenCalledOnce();
return res.end.mock.calls[0][0];
}
beforeAll(async () => {
vi.stubEnv("CACHE_SECONDS", "");
vi.stubEnv("GIST_WHITELIST", "");
vi.stubEnv("POSTGRES_URL", "");
vi.stubEnv("WHITELIST", "");
vi.stubEnv("PAT_1", "dummyPAT1");
vi.stubEnv("PAT_2", "dummyPAT2");
({ default: router } = await import("../../router.js"));
mock.onPost("https://api.github.com/graphql").reply((config) => {
const { query, variables } = JSON.parse(config.data);
if (
query.includes("query userInfo") &&
variables?.login === STATS_CARD_USER
) {
return [200, STATS_MOCK_RESPONSE];
}
if (query.includes("query userInfo") && variables?.login === USER) {
return [200, TOP_LANGS_MOCK_RESPONSE];
}
if (query.includes("query getRepo")) {
return [200, REPO_MOCK_RESPONSE];
}
if (query.includes("query gistInfo")) {
return [200, GIST_MOCK_RESPONSE];
}
return [500, { error: "Unhandled GraphQL request in e2e test" }];
});
mock
.onGet(
`https://api.github.com/search/commits?per_page=1&q=author:${STATS_CARD_USER}`,
)
.reply(200, COMMITS_SEARCH_MOCK_RESPONSE);
mock
.onGet(
`https://wakatime.com/api/v1/users/${USER}/stats?is_including_today=true`,
)
.reply(200, WAKATIME_MOCK_RESPONSE);
});
afterAll(() => {
mock.restore();
vi.unstubAllEnvs();
});
describe("Fetch Cards", () => { describe("Fetch Cards", () => {
const VERCEL_PREVIEW_URL = "https://github-stats-extended-preview.vercel.app"; let VERCEL_PREVIEW_URL = "https://github-stats-extended.vercel.app";
beforeAll(() => {
process.env.NODE_ENV = "development";
});
test("retrieve stats card", async () => { test("retrieve stats card", async () => {
expect(VERCEL_PREVIEW_URL).toBeDefined(); expect(VERCEL_PREVIEW_URL).toBeDefined();
const cardPath = `/api?username=${STATS_CARD_USER}&include_all_commits=true&${CACHE_BURST_STRING}`;
// Check if the Vercel preview instance stats card function is up and running. // Check if the Vercel preview instance stats card function is up and running.
await expect( await expect(
axios.get(`${VERCEL_PREVIEW_URL}${cardPath}`), axios.get(`${VERCEL_PREVIEW_URL}/api?username=${STATS_CARD_USER}`),
).resolves.not.toThrow(); ).resolves.not.toThrow();
// Get local stats card. // Get local stats card.
const localStatsCardSVG = await getLocalSvg(cardPath); const localStatsCardSVG = renderStatsCard(STATS_DATA, {
include_all_commits: true,
});
// Get the Vercel preview stats card response. // Get the Vercel preview stats card response.
const serverStatsSvg = await axios.get(`${VERCEL_PREVIEW_URL}${cardPath}`); const serverStatsSvg = await axios.get(
`${VERCEL_PREVIEW_URL}/api?username=${STATS_CARD_USER}&include_all_commits=true&${CACHE_BURST_STRING}`,
);
// Check if stats card from deployment matches the stats card from local. // Check if stats card from deployment matches the stats card from local.
expect(serverStatsSvg.data).toEqual(localStatsCardSVG); expect(serverStatsSvg.data).toEqual(localStatsCardSVG);
}, 20000); }, 15000);
test("retrieve language card", async () => { test("retrieve language card", async () => {
expect(VERCEL_PREVIEW_URL).toBeDefined(); expect(VERCEL_PREVIEW_URL).toBeDefined();
const cardPath = `/api/top-langs?username=${USER}&${CACHE_BURST_STRING}`;
// Check if the Vercel preview instance language card function is up and running. // Check if the Vercel preview instance language card function is up and running.
console.log(
`${VERCEL_PREVIEW_URL}/api/top-langs/?username=${USER}&${CACHE_BURST_STRING}`,
);
await expect( await expect(
axios.get(`${VERCEL_PREVIEW_URL}${cardPath}`), axios.get(
`${VERCEL_PREVIEW_URL}/api/top-langs/?username=${USER}&${CACHE_BURST_STRING}`,
),
).resolves.not.toThrow(); ).resolves.not.toThrow();
// Get local language card. // Get local language card.
const localLanguageCardSVG = await getLocalSvg(cardPath); const localLanguageCardSVG = renderTopLanguages(LANGS_DATA);
// Get the Vercel preview language card response. // Get the Vercel preview language card response.
const serverLanguageSVG = await axios.get( const severLanguageSVG = await axios.get(
`${VERCEL_PREVIEW_URL}${cardPath}`, `${VERCEL_PREVIEW_URL}/api/top-langs/?username=${USER}&${CACHE_BURST_STRING}`,
); );
// Check if language card from deployment matches the local language card. // Check if language card from deployment matches the local language card.
expect(serverLanguageSVG.data).toEqual(localLanguageCardSVG); expect(severLanguageSVG.data).toEqual(localLanguageCardSVG);
}, 20000); }, 15000);
test("retrieve WakaTime card", async () => { test("retrieve WakaTime card", async () => {
expect(VERCEL_PREVIEW_URL).toBeDefined(); expect(VERCEL_PREVIEW_URL).toBeDefined();
const cardPath = `/api/wakatime?username=${USER}&${CACHE_BURST_STRING}`;
// Check if the Vercel preview instance WakaTime function is up and running. // Check if the Vercel preview instance WakaTime function is up and running.
await expect( await expect(
axios.get(`${VERCEL_PREVIEW_URL}${cardPath}`), axios.get(`${VERCEL_PREVIEW_URL}/api/wakatime?username=${USER}`),
).resolves.not.toThrow(); ).resolves.not.toThrow();
// Get local WakaTime card. // Get local WakaTime card.
const localWakaCardSVG = await getLocalSvg(cardPath); const localWakaCardSVG = renderWakatimeCard(WAKATIME_DATA);
// Get the Vercel preview WakaTime card response. // Get the Vercel preview WakaTime card response.
const serverWakaTimeSvg = await axios.get( const serverWakaTimeSvg = await axios.get(
`${VERCEL_PREVIEW_URL}${cardPath}`, `${VERCEL_PREVIEW_URL}/api/wakatime?username=${USER}&${CACHE_BURST_STRING}`,
); );
// Check if WakaTime card from deployment matches the local WakaTime card. // Check if WakaTime card from deployment matches the local WakaTime card.
expect(serverWakaTimeSvg.data).toEqual(localWakaCardSVG); expect(serverWakaTimeSvg.data).toEqual(localWakaCardSVG);
}, 20000); }, 15000);
test("retrieve repo card", async () => { test("retrieve repo card", async () => {
expect(VERCEL_PREVIEW_URL).toBeDefined(); expect(VERCEL_PREVIEW_URL).toBeDefined();
const cardPath = `/api/pin?username=${USER}&repo=${REPO}&${CACHE_BURST_STRING}`;
// Check if the Vercel preview instance Repo function is up and running. // Check if the Vercel preview instance Repo function is up and running.
await expect( await expect(
axios.get(`${VERCEL_PREVIEW_URL}${cardPath}`), axios.get(
`${VERCEL_PREVIEW_URL}/api/pin/?username=${USER}&repo=${REPO}&${CACHE_BURST_STRING}`,
),
).resolves.not.toThrow(); ).resolves.not.toThrow();
// Get local repo card. // Get local repo card.
const localRepoCardSVG = await getLocalSvg(cardPath); const localRepoCardSVG = renderRepoCard(REPOSITORY_DATA);
// Get the Vercel preview repo card response. // Get the Vercel preview repo card response.
const serverRepoSvg = await axios.get(`${VERCEL_PREVIEW_URL}${cardPath}`); const serverRepoSvg = await axios.get(
`${VERCEL_PREVIEW_URL}/api/pin/?username=${USER}&repo=${REPO}&${CACHE_BURST_STRING}`,
);
// Check if Repo card from deployment matches the local Repo card. // Check if Repo card from deployment matches the local Repo card.
expect(serverRepoSvg.data).toEqual(localRepoCardSVG); expect(serverRepoSvg.data).toEqual(localRepoCardSVG);
}, 20000); }, 15000);
test("retrieve gist card", async () => { test("retrieve gist card", async () => {
expect(VERCEL_PREVIEW_URL).toBeDefined(); expect(VERCEL_PREVIEW_URL).toBeDefined();
const cardPath = `/api/gist?id=${GIST_ID}&${CACHE_BURST_STRING}`;
// Check if the Vercel preview instance Gist function is up and running. // Check if the Vercel preview instance Gist function is up and running.
await expect( await expect(
axios.get(`${VERCEL_PREVIEW_URL}${cardPath}`), axios.get(
`${VERCEL_PREVIEW_URL}/api/gist?id=${GIST_ID}&${CACHE_BURST_STRING}`,
),
).resolves.not.toThrow(); ).resolves.not.toThrow();
// Get local gist card. // Get local gist card.
const localGistCardSVG = await getLocalSvg(cardPath); const localGistCardSVG = renderGistCard(GIST_DATA);
// Get the Vercel preview gist card response. // Get the Vercel preview gist card response.
const serverGistSvg = await axios.get(`${VERCEL_PREVIEW_URL}${cardPath}`); 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. // Check if Gist card from deployment matches the local Gist card.
expect(serverGistSvg.data).toEqual(localGistCardSVG); expect(serverGistSvg.data).toEqual(localGistCardSVG);
}, 20000); }, 15000);
}); });
@@ -4,6 +4,8 @@ import { afterEach, describe, expect, it } from "vitest";
import { fetchGist } from "../src/fetchers/gist.js"; import { fetchGist } from "../src/fetchers/gist.js";
import "@testing-library/jest-dom/vitest";
const gist_data = { const gist_data = {
data: { data: {
viewer: { viewer: {
@@ -4,6 +4,8 @@ import { afterEach, describe, expect, it } from "vitest";
import { fetchRepo } from "../src/fetchers/repo.js"; import { fetchRepo } from "../src/fetchers/repo.js";
import "@testing-library/jest-dom/vitest";
const data_repo = { const data_repo = {
repository: { repository: {
name: "convoychat", name: "convoychat",
@@ -1,15 +1,11 @@
import axios from "axios"; import axios from "axios";
import MockAdapter from "axios-mock-adapter"; import MockAdapter from "axios-mock-adapter";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { calculateRank } from "../src/calculateRank.js"; import { calculateRank } from "../src/calculateRank.js";
import { loadConfigFromEnv } from "../src/common/config.js";
import { fetchStats } from "../src/fetchers/stats.js"; import { fetchStats } from "../src/fetchers/stats.js";
vi.mock(import("../src/common/log.js"), async () => { import "@testing-library/jest-dom/vitest";
const { createLoggerMock } = await import("./utils.js");
return createLoggerMock();
});
// Test parameters. // Test parameters.
const data_stats = { const data_stats = {
@@ -111,7 +107,6 @@ const mock = new MockAdapter(axios);
beforeEach(() => { beforeEach(() => {
process.env.FETCH_MULTI_PAGE_STARS = "false"; // Set to `false` to fetch only one page of stars. process.env.FETCH_MULTI_PAGE_STARS = "false"; // Set to `false` to fetch only one page of stars.
loadConfigFromEnv();
mock.onPost("https://api.github.com/graphql").reply((cfg) => { mock.onPost("https://api.github.com/graphql").reply((cfg) => {
let req = JSON.parse(cfg.data); let req = JSON.parse(cfg.data);
@@ -318,7 +313,6 @@ describe("Test fetchStats", () => {
it("should fetch two pages of stars if 'FETCH_MULTI_PAGE_STARS' env variable is set to `true`", async () => { 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; process.env.FETCH_MULTI_PAGE_STARS = true;
loadConfigFromEnv();
let stats = await fetchStats("anuraghazra"); let stats = await fetchStats("anuraghazra");
const rank = calculateRank({ const rank = calculateRank({
@@ -355,7 +349,6 @@ describe("Test fetchStats", () => {
it("should fetch one page of stars if 'FETCH_MULTI_PAGE_STARS' env variable is set to `false`", async () => { 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"; process.env.FETCH_MULTI_PAGE_STARS = "false";
loadConfigFromEnv();
let stats = await fetchStats("anuraghazra"); let stats = await fetchStats("anuraghazra");
const rank = calculateRank({ const rank = calculateRank({
@@ -392,7 +385,6 @@ describe("Test fetchStats", () => {
it("should fetch one page of stars if 'FETCH_MULTI_PAGE_STARS' env variable is not set", async () => { 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; process.env.FETCH_MULTI_PAGE_STARS = undefined;
loadConfigFromEnv();
let stats = await fetchStats("anuraghazra"); let stats = await fetchStats("anuraghazra");
const rank = calculateRank({ const rank = calculateRank({
@@ -539,6 +531,7 @@ describe("Test fetchStats", () => {
}); });
it("should return correct data when user don't have any pull requests", async () => { it("should return correct data when user don't have any pull requests", async () => {
mock.reset();
mock mock
.onPost("https://api.github.com/graphql") .onPost("https://api.github.com/graphql")
.reply(200, data_without_pull_requests); .reply(200, data_without_pull_requests);
@@ -1,25 +1,17 @@
import axios from "axios"; import axios from "axios";
import MockAdapter from "axios-mock-adapter"; import MockAdapter from "axios-mock-adapter";
import { afterEach, describe, expect, it, vi } from "vitest"; import { afterEach, describe, expect, it } from "vitest";
import { fetchTopLanguages } from "../src/fetchers/top-languages.js"; import { fetchTopLanguages } from "../src/fetchers/top-languages.js";
import { approxNumber } from "./utils.js"; import { approxNumber } from "./utils.js";
vi.mock(import("../src/common/log.js"), async () => { import "@testing-library/jest-dom/vitest";
const { createLoggerMock } = await import("./utils.js");
return createLoggerMock();
});
const { logger } = await import("../src/index.js");
const loggerErrorSpy = vi.mocked(logger.error);
const mock = new MockAdapter(axios); const mock = new MockAdapter(axios);
afterEach(() => { afterEach(() => {
mock.reset(); mock.reset();
loggerErrorSpy.mockClear();
}); });
const data_langs = { const data_langs = {
@@ -159,8 +151,6 @@ describe("FetchTopLanguages", () => {
await expect(fetchTopLanguages("anuraghazra")).rejects.toThrow( await expect(fetchTopLanguages("anuraghazra")).rejects.toThrow(
"Could not resolve to a User with the login of 'noname'.", "Could not resolve to a User with the login of 'noname'.",
); );
expect(loggerErrorSpy).toHaveBeenCalledOnce();
}); });
it("should throw other errors with their message", async () => { it("should throw other errors with their message", async () => {
@@ -171,8 +161,6 @@ describe("FetchTopLanguages", () => {
await expect(fetchTopLanguages("anuraghazra")).rejects.toThrow( await expect(fetchTopLanguages("anuraghazra")).rejects.toThrow(
"Some test GraphQL error", "Some test GraphQL error",
); );
expect(loggerErrorSpy).toHaveBeenCalledOnce();
}); });
it("should throw error with specific message when error does not contain message property", async () => { it("should throw error with specific message when error does not contain message property", async () => {
@@ -183,7 +171,5 @@ describe("FetchTopLanguages", () => {
await expect(fetchTopLanguages("anuraghazra")).rejects.toThrow( await expect(fetchTopLanguages("anuraghazra")).rejects.toThrow(
"Something went wrong while trying to retrieve the language data using the GraphQL API.", "Something went wrong while trying to retrieve the language data using the GraphQL API.",
); );
expect(loggerErrorSpy).toHaveBeenCalledOnce();
}); });
}); });
@@ -4,6 +4,8 @@ import { afterEach, describe, expect, it } from "vitest";
import { fetchWakatimeStats } from "../src/fetchers/wakatime.js"; import { fetchWakatimeStats } from "../src/fetchers/wakatime.js";
import "@testing-library/jest-dom/vitest";
const mock = new MockAdapter(axios); const mock = new MockAdapter(axios);
afterEach(() => { afterEach(() => {
@@ -72,12 +72,7 @@ describe("Test fmt.js", () => {
it("wrapTextMultiline: should not wrap small texts", () => { it("wrapTextMultiline: should not wrap small texts", () => {
{ {
let multiLineText = wrapTextMultiline( let multiLineText = wrapTextMultiline("Small text should not wrap");
"Small text should not wrap",
130,
11,
3,
);
expect(multiLineText).toEqual(["Small text should not wrap"]); expect(multiLineText).toEqual(["Small text should not wrap"]);
} }
}); });
@@ -85,31 +80,26 @@ describe("Test fmt.js", () => {
it("wrapTextMultiline: should wrap large texts", () => { it("wrapTextMultiline: should wrap large texts", () => {
let multiLineText = wrapTextMultiline( let multiLineText = wrapTextMultiline(
"Hello world long long long text", "Hello world long long long text",
130, 20,
11,
3, 3,
); );
expect(multiLineText).toEqual(["Hello world long long", "long text"]); expect(multiLineText).toEqual(["Hello world long", "long long text"]);
}); });
it("wrapTextMultiline: should wrap large texts and limit max lines", () => { it("wrapTextMultiline: should wrap large texts and limit max lines", () => {
let multiLineText = wrapTextMultiline( let multiLineText = wrapTextMultiline(
"Hello world long long long text", "Hello world long long long text",
53, 10,
11,
2, 2,
); );
expect(multiLineText).toEqual(["Hello", "world long..."]); expect(multiLineText).toEqual(["Hello", "world long..."]);
}); });
it("wrapTextMultiline: should handle chinese characters", () => { it("wrapTextMultiline: should wrap chinese by punctuation", () => {
let multiLineText = wrapTextMultiline( let multiLineText = wrapTextMultiline(
"专门为刚开始刷题的同学准备的算法基地,没有最细只有更细,立志用动画将晦涩难懂的算法说的通俗易懂!", "专门为刚开始刷题的同学准备的算法基地,没有最细只有更细,立志用动画将晦涩难懂的算法说的通俗易懂!",
130,
11,
3,
); );
expect(multiLineText.length).toEqual(3); expect(multiLineText.length).toEqual(3);
expect(multiLineText[0].length).toEqual(11 * 8); // &#xxxxx; x 8 expect(multiLineText[0].length).toEqual(18 * 8); // &#xxxxx; x 8
}); });
}); });
+142 -130
View File
@@ -1,149 +1,161 @@
// @ts-check // @ts-check
import { getConfig, gist } from "@stats-organization/github-readme-stats-core"; import axios from "axios";
import { beforeEach, describe, expect, it, vi } from "vitest"; import MockAdapter from "axios-mock-adapter";
import { afterEach, describe, expect, it, vi } from "vitest";
import router from "../router.js"; import gist from "../api-renamed/gist.js";
import { renderGistCard } from "../src/cards/gist.js";
import { CACHE_TTL, DURATIONS } from "../src/common/cache.js"; import { CACHE_TTL, DURATIONS } from "../src/common/cache.js";
import { getUserAccessByName, storeRequest } from "../src/common/database.js"; import { renderError } from "../src/common/render.js";
vi.mock(import("@stats-organization/github-readme-stats-core"), async () => { import { gist_data } from "./test-data/gist-data.js";
const { mockCore } = await import("./utils.js");
return mockCore(); import "@testing-library/jest-dom/vitest";
const gist_not_found_data = {
data: {
viewer: {
gist: null,
},
},
};
const mock = new MockAdapter(axios);
afterEach(() => {
mock.reset();
}); });
vi.mock(import("../src/common/database.js"), async (importOriginal) => ({ describe("Test /api/gist", () => {
...(await importOriginal()), it("should test the request", async () => {
storeRequest: vi.fn(), const req = {
getUserAccessByName: vi.fn(), query: {
})); id: "bbfce31e0217a3689c8d961a356cb10d",
},
};
const res = {
setHeader: vi.fn(),
send: vi.fn(),
};
mock.onPost("https://api.github.com/graphql").reply(200, gist_data);
const gistMock = vi.mocked(gist); await gist(req, res);
const getConfigMock = vi.mocked(getConfig);
const storeRequestMock = vi.mocked(storeRequest);
const getUserAccessByNameMock = vi.mocked(getUserAccessByName);
const createRequest = (search) => ({ expect(res.setHeader).toHaveBeenCalledWith("Content-Type", "image/svg+xml");
headers: {}, expect(res.send).toHaveBeenCalledWith(
url: `/api/gist?${search}`, renderGistCard({
}); name: gist_data.data.viewer.gist.files[0].name,
nameWithOwner: `${gist_data.data.viewer.gist.owner.login}/${gist_data.data.viewer.gist.files[0].name}`,
const createResponse = () => ({ description: gist_data.data.viewer.gist.description,
end: vi.fn(), language: gist_data.data.viewer.gist.files[0].language.name,
setHeader: vi.fn(), starsCount: gist_data.data.viewer.gist.stargazerCount,
}); forksCount: gist_data.data.viewer.gist.forks.totalCount,
}),
const defaultCacheHeader = );
`max-age=${CACHE_TTL.GIST_CARD.DEFAULT}, ` + });
`s-maxage=${CACHE_TTL.GIST_CARD.DEFAULT}, ` +
`stale-while-revalidate=${DURATIONS.ONE_DAY}`;
const errorCacheHeader =
`max-age=${CACHE_TTL.ERROR}, ` +
`s-maxage=${CACHE_TTL.ERROR}, ` +
`stale-while-revalidate=${DURATIONS.ONE_DAY}`;
beforeEach(() => {
gistMock.mockReset();
getConfigMock.mockReset().mockReturnValue({});
storeRequestMock.mockReset().mockResolvedValue(undefined);
getUserAccessByNameMock.mockReset().mockResolvedValue(null);
// CACHE_SECONDS is not set here, this is just to safeguard against CACHE_SECONDS being set externally
delete process.env.CACHE_SECONDS;
});
describe("Test /api/gist backend routing", () => { it("should get the query options", async () => {
it("happy path should pass query params, respond with gist content and persist request", async () => { const req = {
gistMock.mockResolvedValue({ query: {
status: "success", id: "bbfce31e0217a3689c8d961a356cb10d",
content: "mock-gist-svg", title_color: "fff",
}); icon_color: "fff",
text_color: "fff",
const req = createRequest("id=bbfce31e0217a3689c8d961a356cb10d&theme=dark"); bg_color: "fff",
const res = createResponse(); show_owner: true,
},
await router(req, res); };
const res = {
expect(gistMock).toHaveBeenCalledWith({ setHeader: vi.fn(),
id: "bbfce31e0217a3689c8d961a356cb10d", send: vi.fn(),
theme: "dark", };
}); mock.onPost("https://api.github.com/graphql").reply(200, gist_data);
expect(getUserAccessByNameMock).not.toHaveBeenCalled();
expect(req.query).toEqual({ await gist(req, res);
id: "bbfce31e0217a3689c8d961a356cb10d",
theme: "dark", expect(res.setHeader).toHaveBeenCalledWith("Content-Type", "image/svg+xml");
}); expect(res.send).toHaveBeenCalledWith(
expect(res.setHeader.mock.calls).toEqual([ renderGistCard(
["Cache-Control", defaultCacheHeader], {
["Content-Type", "image/svg+xml"], name: gist_data.data.viewer.gist.files[0].name,
]); nameWithOwner: `${gist_data.data.viewer.gist.owner.login}/${gist_data.data.viewer.gist.files[0].name}`,
expect(res.end).toHaveBeenCalledExactlyOnceWith("mock-gist-svg"); description: gist_data.data.viewer.gist.description,
expect(storeRequestMock).toHaveBeenCalledExactlyOnceWith(req); language: gist_data.data.viewer.gist.files[0].language.name,
starsCount: gist_data.data.viewer.gist.stargazerCount,
forksCount: gist_data.data.viewer.gist.forks.totalCount,
},
{ ...req.query },
),
);
}); });
it("should use the shorter error cache for temporary gist errors", async () => {
gistMock.mockResolvedValue({
status: "error - temporary",
content: "temporary-error-svg",
});
const req = createRequest("id=bbfce31e0217a3689c8d961a356cb10d"); it("should render error if gist is not found", async () => {
const res = createResponse(); const req = {
query: {
await router(req, res); id: "bbfce31e0217a3689c8d961a356cb10d",
},
expect(gistMock).toHaveBeenCalledWith({ };
id: "bbfce31e0217a3689c8d961a356cb10d", const res = {
}); setHeader: vi.fn(),
expect(getUserAccessByNameMock).not.toHaveBeenCalled(); send: vi.fn(),
expect(res.setHeader.mock.calls).toEqual([ };
["Cache-Control", errorCacheHeader], mock
["Content-Type", "image/svg+xml"], .onPost("https://api.github.com/graphql")
]); .reply(200, gist_not_found_data);
expect(res.end).toHaveBeenCalledExactlyOnceWith("temporary-error-svg");
expect(storeRequestMock).toHaveBeenCalledExactlyOnceWith(req); await gist(req, res);
expect(res.setHeader).toHaveBeenCalledWith("Content-Type", "image/svg+xml");
expect(res.send).toHaveBeenCalledWith(
renderError({ message: "Gist not found" }),
);
}); });
it("should not persist permanent gist errors returned by core", async () => {
gistMock.mockResolvedValue({
status: "error - permanent",
content: "permanent-error-svg",
});
const req = createRequest("id=bbfce31e0217a3689c8d961a356cb10d");
const res = createResponse();
await router(req, res); it("should render error if wrong locale is provided", async () => {
const req = {
expect(gistMock).toHaveBeenCalledWith({ query: {
id: "bbfce31e0217a3689c8d961a356cb10d", id: "bbfce31e0217a3689c8d961a356cb10d",
}); locale: "asdf",
expect(getUserAccessByNameMock).not.toHaveBeenCalled(); },
expect(res.setHeader.mock.calls).toEqual([ };
["Cache-Control", defaultCacheHeader], const res = {
["Content-Type", "image/svg+xml"], setHeader: vi.fn(),
]); send: vi.fn(),
expect(res.end).toHaveBeenCalledExactlyOnceWith("permanent-error-svg"); };
expect(storeRequestMock).not.toHaveBeenCalled(); mock.onPost("https://api.github.com/graphql").reply(200, gist_data);
await gist(req, res);
expect(res.setHeader).toHaveBeenCalledWith("Content-Type", "image/svg+xml");
expect(res.send).toHaveBeenCalledWith(
renderError({
message: "Something went wrong",
secondaryMessage: "Language not found",
}),
);
}); });
it("should reject non-whitelisted gist ids before calling core logic", async () => {
getConfigMock.mockReturnValue({ gistWhitelist: ["allowed-gist-id"] });
const req = createRequest("id=blocked-gist-id");
const res = createResponse();
await router(req, res);
expect(gistMock).not.toHaveBeenCalled(); it("should have proper cache", async () => {
expect(getUserAccessByNameMock).not.toHaveBeenCalled(); const req = {
expect(res.setHeader.mock.calls).toEqual([ query: {
["Cache-Control", defaultCacheHeader], id: "bbfce31e0217a3689c8d961a356cb10d",
["Content-Type", "image/svg+xml"], },
]); };
expect(res.end).toHaveBeenCalledExactlyOnceWith( const res = {
"render-error:This gist ID is not whitelisted", setHeader: vi.fn(),
send: vi.fn(),
};
mock.onPost("https://api.github.com/graphql").reply(200, gist_data);
await gist(req, res);
expect(res.setHeader).toHaveBeenCalledWith("Content-Type", "image/svg+xml");
expect(res.setHeader).toHaveBeenCalledWith(
"Cache-Control",
`max-age=${CACHE_TTL.GIST_CARD.DEFAULT}, ` +
`s-maxage=${CACHE_TTL.GIST_CARD.DEFAULT}, ` +
`stale-while-revalidate=${DURATIONS.ONE_DAY}`,
); );
expect(storeRequestMock).not.toHaveBeenCalled();
}); });
}); });
+13 -33
View File
@@ -4,15 +4,9 @@
import axios from "axios"; import axios from "axios";
import MockAdapter from "axios-mock-adapter"; import MockAdapter from "axios-mock-adapter";
import { import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
afterAll,
afterEach, import patInfo, { RATE_LIMIT_SECONDS } from "../api-renamed/status/pat-info.js";
beforeAll,
describe,
expect,
it,
vi,
} from "vitest";
const mock = new MockAdapter(axios); const mock = new MockAdapter(axios);
@@ -63,35 +57,20 @@ const bad_credentials_error = {
message: "Bad credentials", message: "Bad credentials",
}; };
let RATE_LIMIT_SECONDS, patInfo;
beforeAll(async () => {
vi.stubEnv("PAT_1", "testPAT1");
vi.stubEnv("PAT_2", "testPAT2");
vi.stubEnv("PAT_3", "testPAT3");
vi.stubEnv("PAT_4", "testPAT4");
const { logger } =
await import("@stats-organization/github-readme-stats-core");
vi.spyOn(logger, "log").mockImplementation(() => {});
vi.spyOn(logger, "error").mockImplementation(() => {});
({ RATE_LIMIT_SECONDS, default: patInfo } =
await import("../api-renamed/status/pat-info.js"));
});
afterEach(() => { afterEach(() => {
mock.reset(); mock.reset();
vi.unstubAllEnvs();
// modules may cache environment variables, so we need to reset them
vi.resetModules();
});
afterAll(() => {
vi.restoreAllMocks();
}); });
describe("Test /api/status/pat-info", () => { describe("Test /api/status/pat-info", () => {
beforeAll(() => {
// reset patenv first so that they are not populated 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 () => { it("should return only 'validPATs' if all PATs are valid", async () => {
mock mock
.onPost("https://api.github.com/graphql") .onPost("https://api.github.com/graphql")
@@ -264,6 +243,7 @@ describe("Test /api/status/pat-info", () => {
}); });
it("should have proper cache when error is thrown", async () => { it("should have proper cache when error is thrown", async () => {
mock.reset();
mock.onPost("https://api.github.com/graphql").networkError(); mock.onPost("https://api.github.com/graphql").networkError();
const { req, res } = faker({}, {}); const { req, res } = faker({}, {});
+162 -71
View File
@@ -1,84 +1,175 @@
// @ts-check // @ts-check
import { pin } from "@stats-organization/github-readme-stats-core"; import axios from "axios";
import { beforeEach, describe, expect, it, vi } from "vitest"; import MockAdapter from "axios-mock-adapter";
import { afterEach, describe, expect, it, vi } from "vitest";
import router from "../router.js"; import pin from "../api-renamed/pin.js";
import { renderRepoCard } from "../src/cards/repo.js";
import { CACHE_TTL, DURATIONS } from "../src/common/cache.js"; import { CACHE_TTL, DURATIONS } from "../src/common/cache.js";
import { getUserAccessByName, storeRequest } from "../src/common/database.js"; import { renderError } from "../src/common/render.js";
vi.mock(import("@stats-organization/github-readme-stats-core"), async () => { import { data_repo, data_user } from "./test-data/pin-data.js";
const { mockCore } = await import("./utils.js");
return mockCore(); import "@testing-library/jest-dom/vitest";
const mock = new MockAdapter(axios);
afterEach(() => {
mock.reset();
}); });
vi.mock(import("../src/common/database.js"), async (importOriginal) => ({ describe("Test /api/pin", () => {
...(await importOriginal()), it("should test the request", async () => {
storeRequest: vi.fn(), const req = {
getUserAccessByName: vi.fn(), query: {
}));
const pinMock = vi.mocked(pin);
const storeRequestMock = vi.mocked(storeRequest);
const getUserAccessByNameMock = vi.mocked(getUserAccessByName);
const createRequest = (search = "") => ({
headers: {},
url: `/api/pin?${search}`,
});
const createResponse = () => ({
end: vi.fn(),
setHeader: vi.fn(),
});
const defaultCacheHeader =
`max-age=${CACHE_TTL.PIN_CARD.DEFAULT}, ` +
`s-maxage=${CACHE_TTL.PIN_CARD.DEFAULT}, ` +
`stale-while-revalidate=${DURATIONS.ONE_DAY}`;
beforeEach(() => {
pinMock.mockReset();
storeRequestMock.mockReset().mockResolvedValue(undefined);
getUserAccessByNameMock.mockReset().mockResolvedValue(null);
// CACHE_SECONDS is not set here, this is just to safeguard against CACHE_SECONDS being set externally
delete process.env.CACHE_SECONDS;
});
describe("Test /api/pin backend routing", () => {
it("happy path should pass query params and user PAT, respond with pin content and persist request", async () => {
getUserAccessByNameMock.mockResolvedValue({ token: "user-pat" });
pinMock.mockResolvedValue({
status: "success",
content: "mock-pin-svg",
});
const req = createRequest(
"username=anuraghazra&repo=convoychat&theme=dark",
);
const res = createResponse();
await router(req, res);
expect(getUserAccessByNameMock).toHaveBeenCalledWith("anuraghazra");
expect(pinMock).toHaveBeenCalledWith(
{
username: "anuraghazra", username: "anuraghazra",
repo: "convoychat", repo: "convoychat",
theme: "dark",
}, },
"user-pat", };
const res = {
setHeader: vi.fn(),
send: vi.fn(),
};
mock.onPost("https://api.github.com/graphql").reply(200, data_user);
await pin(req, res);
expect(res.setHeader).toHaveBeenCalledWith("Content-Type", "image/svg+xml");
expect(res.send).toHaveBeenCalledWith(
// @ts-ignore
renderRepoCard({
...data_repo.repository,
starCount: data_repo.repository.stargazers.totalCount,
}),
);
});
it("should get the query options", async () => {
const req = {
query: {
username: "anuraghazra",
repo: "convoychat",
title_color: "fff",
icon_color: "fff",
text_color: "fff",
bg_color: "fff",
full_name: "1",
},
};
const res = {
setHeader: vi.fn(),
send: vi.fn(),
};
mock.onPost("https://api.github.com/graphql").reply(200, data_user);
await pin(req, res);
expect(res.setHeader).toHaveBeenCalledWith("Content-Type", "image/svg+xml");
expect(res.send).toHaveBeenCalledWith(
renderRepoCard(
// @ts-ignore
{
...data_repo.repository,
starCount: data_repo.repository.stargazers.totalCount,
},
{ ...req.query },
),
);
});
it("should render error card if user repo not found", async () => {
const req = {
query: {
username: "anuraghazra",
repo: "convoychat",
},
};
const res = {
setHeader: vi.fn(),
send: vi.fn(),
};
mock
.onPost("https://api.github.com/graphql")
.reply(200, { data: { user: { repository: null }, organization: null } });
await pin(req, res);
expect(res.setHeader).toHaveBeenCalledWith("Content-Type", "image/svg+xml");
expect(res.send).toHaveBeenCalledWith(
renderError({ message: "User Repository Not found" }),
);
});
it("should render error card if org repo not found", async () => {
const req = {
query: {
username: "anuraghazra",
repo: "convoychat",
},
};
const res = {
setHeader: vi.fn(),
send: vi.fn(),
};
mock
.onPost("https://api.github.com/graphql")
.reply(200, { data: { user: null, organization: { repository: null } } });
await pin(req, res);
expect(res.setHeader).toHaveBeenCalledWith("Content-Type", "image/svg+xml");
expect(res.send).toHaveBeenCalledWith(
renderError({ message: "Organization Repository Not found" }),
);
});
it("should render error card if wrong locale provided", async () => {
const req = {
query: {
username: "anuraghazra",
repo: "convoychat",
locale: "asdf",
},
};
const res = {
setHeader: vi.fn(),
send: vi.fn(),
};
mock.onPost("https://api.github.com/graphql").reply(200, data_user);
await pin(req, res);
expect(res.setHeader).toHaveBeenCalledWith("Content-Type", "image/svg+xml");
expect(res.send).toHaveBeenCalledWith(
renderError({
message: "Something went wrong",
secondaryMessage: "Language not found",
}),
);
});
it("should have proper cache", async () => {
const req = {
query: {
username: "anuraghazra",
repo: "convoychat",
},
};
const res = {
setHeader: vi.fn(),
send: vi.fn(),
};
mock.onPost("https://api.github.com/graphql").reply(200, data_user);
await pin(req, res);
expect(res.setHeader).toHaveBeenCalledWith("Content-Type", "image/svg+xml");
expect(res.setHeader).toHaveBeenCalledWith(
"Cache-Control",
`max-age=${CACHE_TTL.PIN_CARD.DEFAULT}, ` +
`s-maxage=${CACHE_TTL.PIN_CARD.DEFAULT}, ` +
`stale-while-revalidate=${DURATIONS.ONE_DAY}`,
); );
expect(req.query).toEqual({
username: "anuraghazra",
repo: "convoychat",
theme: "dark",
});
expect(res.setHeader.mock.calls).toEqual([
["Cache-Control", defaultCacheHeader],
["Content-Type", "image/svg+xml"],
]);
expect(res.end).toHaveBeenCalledExactlyOnceWith("mock-pin-svg");
expect(storeRequestMock).toHaveBeenCalledExactlyOnceWith(req);
}); });
}); });
@@ -0,0 +1,41 @@
// @ts-check
import axios from "axios";
import MockAdapter from "axios-mock-adapter";
import { afterEach, describe, expect, it, vi } from "vitest";
import api from "../../api-renamed/index.js";
import { renderError } from "../../src/common/render.js";
import { data_stats } from "../test-data/api-data.js";
const mock = new MockAdapter(axios);
afterEach(() => {
mock.reset();
});
describe("Test /api/", () => {
it("should render error card if username not in whitelist", async () => {
const req = {
query: {
username: "renovate-bot",
},
};
const res = {
setHeader: vi.fn(),
send: vi.fn(),
};
mock.onPost("https://api.github.com/graphql").replyOnce(200, data_stats);
await api(req, res);
expect(res.setHeader).toHaveBeenCalledWith("Content-Type", "image/svg+xml");
expect(res.send).toHaveBeenCalledWith(
renderError({
message: "This username is not whitelisted",
secondaryMessage: "Please deploy your own instance",
renderOptions: { show_repo_link: false },
}),
);
});
});
@@ -0,0 +1,41 @@
// @ts-check
import axios from "axios";
import MockAdapter from "axios-mock-adapter";
import { afterEach, describe, expect, it, vi } from "vitest";
import gist from "../../api-renamed/gist.js";
import { renderError } from "../../src/common/render.js";
import { gist_data } from "../test-data/gist-data.js";
const mock = new MockAdapter(axios);
afterEach(() => {
mock.reset();
});
describe("Test /api/gist with gist whitelist", () => {
it("should render error card if id not in whitelist", async () => {
const req = {
query: {
id: "9bae0392ee3a26bac5cc388a6c8b1469",
},
};
const res = {
setHeader: vi.fn(),
send: vi.fn(),
};
mock.onPost("https://api.github.com/graphql").reply(200, gist_data);
await gist(req, res);
expect(res.setHeader).toHaveBeenCalledWith("Content-Type", "image/svg+xml");
expect(res.send).toHaveBeenCalledWith(
renderError({
message: "This gist ID is not whitelisted",
secondaryMessage: "Please deploy your own instance",
renderOptions: { show_repo_link: false },
}),
);
});
});
@@ -0,0 +1,64 @@
// @ts-check
import axios from "axios";
import MockAdapter from "axios-mock-adapter";
import { afterEach, describe, expect, it, vi } from "vitest";
import pin from "../../api-renamed/pin.js";
import { renderError } from "../../src/common/render.js";
import { data_user } from "../test-data/pin-data.js";
const mock = new MockAdapter(axios);
afterEach(() => {
mock.reset();
});
describe("Test /api/pin", () => {
it("should render error card if username not in whitelist", async () => {
const req = {
query: {
username: "renovate-bot",
repo: "convoychat",
},
};
const res = {
setHeader: vi.fn(),
send: vi.fn(),
};
mock.onPost("https://api.github.com/graphql").reply(200, data_user);
await pin(req, res);
expect(res.setHeader).toHaveBeenCalledWith("Content-Type", "image/svg+xml");
expect(res.send).toHaveBeenCalledWith(
renderError({
message: "This username is not whitelisted",
secondaryMessage: "Please deploy your own instance",
renderOptions: { show_repo_link: false },
}),
);
});
it("should render error card if missing required parameters", async () => {
const req = {
query: {},
};
const res = {
setHeader: vi.fn(),
send: vi.fn(),
};
mock.onPost("https://api.github.com/graphql").reply(200, data_user);
await pin(req, res);
expect(res.setHeader).toHaveBeenCalledWith("Content-Type", "image/svg+xml");
expect(res.send).toHaveBeenCalledWith(
renderError({
message: "This username is not whitelisted",
secondaryMessage: "Please deploy your own instance",
renderOptions: { show_repo_link: false },
}),
);
});
});
@@ -0,0 +1,41 @@
// @ts-check
import axios from "axios";
import MockAdapter from "axios-mock-adapter";
import { afterEach, describe, expect, it, vi } from "vitest";
import topLangs from "../../api-renamed/top-langs.js";
import { renderError } from "../../src/common/render.js";
import { data_langs } from "../test-data/langs-data.js";
const mock = new MockAdapter(axios);
afterEach(() => {
mock.reset();
});
describe("Test /api/top-langs", () => {
it("should render error card if username not in whitelist", async () => {
const req = {
query: {
username: "renovate-bot",
},
};
const res = {
setHeader: vi.fn(),
send: vi.fn(),
};
mock.onPost("https://api.github.com/graphql").reply(200, data_langs);
await topLangs(req, res);
expect(res.setHeader).toHaveBeenCalledWith("Content-Type", "image/svg+xml");
expect(res.send).toHaveBeenCalledWith(
renderError({
message: "This username is not whitelisted",
secondaryMessage: "Please deploy your own instance",
renderOptions: { show_repo_link: false },
}),
);
});
});

Some files were not shown because too many files have changed in this diff Show More