Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5c4ea2a797 | ||
|
|
7d666e2510 | ||
|
|
96a18562f0 | ||
|
|
b64d601d47 | ||
|
|
26aec2a4af | ||
|
|
ad23ab39ed | ||
|
|
d982bef3c5 | ||
|
|
2463f0900d | ||
|
|
23ba727b9a | ||
|
|
6c625fb76b | ||
|
|
0aafbbbdb5 | ||
|
|
8e6e0fac47 |
@@ -5,9 +5,9 @@
|
||||
To set up the project GitHub-Stats-Extended locally, run the following commands:
|
||||
|
||||
```bash
|
||||
./vercel-preparation.sh
|
||||
pnpm install
|
||||
pnpm run build:packages
|
||||
pnpm run dev:frontend
|
||||
pnpm --filter frontend run build
|
||||
```
|
||||
|
||||
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:
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5
|
||||
uses: pnpm/action-setup@v4
|
||||
|
||||
- name: Setup Node.js (via input)
|
||||
if: ${{ inputs.node-version }}
|
||||
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: ${{ inputs.node-version }}
|
||||
cache: "pnpm"
|
||||
@@ -26,7 +26,7 @@ runs:
|
||||
|
||||
- name: Setup Node.js (via .nvmrc)
|
||||
if: ${{ !inputs.node-version }}
|
||||
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version-file: ".nvmrc"
|
||||
cache: "pnpm"
|
||||
|
||||
+9
-49
@@ -4,8 +4,9 @@ updates:
|
||||
- package-ecosystem: npm
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: daily
|
||||
open-pull-requests-limit: 20
|
||||
interval: cron
|
||||
cronjob: every day at 5am
|
||||
open-pull-requests-limit: 10
|
||||
commit-message:
|
||||
prefix: "build(deps)"
|
||||
prefix-development: "build(deps-dev)"
|
||||
@@ -14,56 +15,14 @@ updates:
|
||||
ignore:
|
||||
- dependency-name: "@types/node"
|
||||
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
|
||||
- package-ecosystem: github-actions
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: daily
|
||||
open-pull-requests-limit: 20
|
||||
interval: cron
|
||||
cronjob: every day at 5am
|
||||
open-pull-requests-limit: 10
|
||||
commit-message:
|
||||
prefix: "ci(deps)"
|
||||
prefix-development: "ci(deps-dev)"
|
||||
@@ -74,8 +33,9 @@ updates:
|
||||
- package-ecosystem: devcontainers
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: daily
|
||||
open-pull-requests-limit: 20
|
||||
interval: cron
|
||||
cronjob: every day at 5am
|
||||
open-pull-requests-limit: 10
|
||||
commit-message:
|
||||
prefix: "build(deps)"
|
||||
prefix-development: "build(deps-dev)"
|
||||
|
||||
+29
-35
@@ -31,21 +31,26 @@ jobs:
|
||||
|
||||
steps:
|
||||
- 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
|
||||
uses: ./.github/actions/install-dependencies
|
||||
with:
|
||||
node-version: ${{ matrix.node }}
|
||||
|
||||
- name: Build packages
|
||||
run: pnpm run build:packages
|
||||
|
||||
- name: Build frontend
|
||||
run: pnpm run build:frontend
|
||||
run: pnpm --filter frontend run build
|
||||
|
||||
- name: Run tests
|
||||
run: pnpm run test
|
||||
- name: Run frontend tests
|
||||
run: pnpm --filter frontend run test
|
||||
|
||||
- name: Run backend tests
|
||||
run: pnpm --filter github-readme-stats run test
|
||||
|
||||
frontend-test-e2e:
|
||||
name: Frontend E2E test
|
||||
@@ -59,42 +64,21 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Install Dependencies
|
||||
uses: ./.github/actions/install-dependencies
|
||||
|
||||
- name: Build packages
|
||||
run: pnpm run build:packages
|
||||
- name: Run vercel-preparation.sh
|
||||
run: |
|
||||
chmod +x ./vercel-preparation.sh
|
||||
./vercel-preparation.sh
|
||||
|
||||
- name: Install Playwright Browsers
|
||||
run: pnpm exec playwright install --with-deps
|
||||
|
||||
- name: Run Playwright tests
|
||||
run: pnpm --filter ./apps/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
|
||||
run: pnpm --filter frontend run test:e2e
|
||||
|
||||
code-checks:
|
||||
name: Code checks
|
||||
@@ -106,7 +90,17 @@ jobs:
|
||||
|
||||
steps:
|
||||
- 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 ESLint’s 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
|
||||
uses: ./.github/actions/install-dependencies
|
||||
|
||||
@@ -4,7 +4,7 @@ on:
|
||||
branches:
|
||||
- master
|
||||
paths:
|
||||
- "packages/core/src/themes/index.js"
|
||||
- "apps/backend/themes/index.js"
|
||||
workflow_dispatch:
|
||||
|
||||
permissions: {}
|
||||
@@ -30,17 +30,17 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Install Dependencies
|
||||
uses: ./.github/actions/install-dependencies
|
||||
|
||||
- name: Generate readme
|
||||
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
|
||||
uses: peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1 # v8
|
||||
uses: peter-evans/create-pull-request@v8
|
||||
with:
|
||||
commit-message: "feat(backend): update themes README"
|
||||
branch: "update_themes_readme/patch"
|
||||
|
||||
@@ -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
|
||||
@@ -15,16 +15,12 @@ on:
|
||||
|
||||
jobs:
|
||||
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.
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Make Request
|
||||
id: myRequest
|
||||
uses: fjogeleit/http-request-action@551353b829c3646756b2ec2b3694f819d7957495 # v2
|
||||
uses: fjogeleit/http-request-action@v2
|
||||
with:
|
||||
url: "https://github-stats-extended.vercel.app/api/repeat-recent"
|
||||
method: "POST"
|
||||
|
||||
@@ -38,16 +38,16 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Install Dependencies
|
||||
uses: ./.github/actions/install-dependencies
|
||||
|
||||
- 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
|
||||
uses: peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1 # v8
|
||||
uses: peter-evans/create-pull-request@v8
|
||||
with:
|
||||
commit-message: "feat(backend): update languages JSON"
|
||||
branch: "update_langs/patch"
|
||||
|
||||
@@ -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
@@ -1,8 +1,18 @@
|
||||
node_modules
|
||||
|
||||
.env.local
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
|
||||
# Logs
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
|
||||
# Project
|
||||
coverage
|
||||
|
||||
@@ -16,12 +26,11 @@ apps/backend/vercel_token
|
||||
apps/backend-copy
|
||||
|
||||
apps/frontend/.env
|
||||
apps/frontend/src/backend
|
||||
apps/frontend/build
|
||||
|
||||
|
||||
.turbo
|
||||
build
|
||||
build-ts
|
||||
*.tsbuildinfo
|
||||
tsconfig.tsbuildinfo
|
||||
|
||||
# IDE
|
||||
.idea/
|
||||
|
||||
+2
-1
@@ -1,3 +1,4 @@
|
||||
pnpm lint-staged
|
||||
pnpm run lint
|
||||
pnpm test
|
||||
# TODO enable
|
||||
# npm test
|
||||
|
||||
@@ -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>
|
||||
</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
|
||||
- [Quick Start](#quick-start)
|
||||
- [Migration from github-readme-stats](#migration-from-github-readme-stats)
|
||||
- [Card Types](#card-types)
|
||||
- [Advanced Customization](#advanced-customization)
|
||||
- [Run It Yourself](#run-it-yourself)
|
||||
- [Self-Hosting](#self-hosting)
|
||||
- [Acknowledgements](#acknowledgements)
|
||||
- [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!
|
||||
|
||||
# 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
|
||||
- Show your GitHub statistics:
|
||||
|
||||
@@ -66,13 +55,13 @@ GitHub-Stats-Extended aims to be fully compatible with github-readme-stats. For
|
||||
# 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).
|
||||
|
||||
# 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
|
||||
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
|
||||
Contributions are welcome!
|
||||
@@ -2,7 +2,7 @@
|
||||
"name": "GitHub Stats Extended Dev",
|
||||
"image": "mcr.microsoft.com/devcontainers/base:ubuntu",
|
||||
"features": {
|
||||
"ghcr.io/devcontainers/features/node:1": { "version": "24" }
|
||||
"ghcr.io/devcontainers/features/node:1": { "version": "22" }
|
||||
},
|
||||
"forwardPorts": [3000],
|
||||
"portsAttributes": {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"runtime": "nodejs24.x",
|
||||
"runtime": "nodejs22.x",
|
||||
"handler": "router.js",
|
||||
"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,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";
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { logger } from "@stats-organization/github-readme-stats-core";
|
||||
|
||||
import { deleteUser } from "../src/common/database.js";
|
||||
import { logger } from "../src/common/log.js";
|
||||
|
||||
/**
|
||||
* @param {any} req The request.
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { logger } from "@stats-organization/github-readme-stats-core";
|
||||
import axios from "axios";
|
||||
|
||||
import { deleteUser, getUserAccessByKey } from "../src/common/database.js";
|
||||
import { logger } from "../src/common/log.js";
|
||||
|
||||
export default async (req, res) => {
|
||||
// We could optimize this method by doing both database operations in one statement, using "DELETE ... RETURNING ..."
|
||||
|
||||
@@ -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
|
||||
|
||||
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 {
|
||||
MissingParamError,
|
||||
retrieveSecondaryMessage,
|
||||
} from "../common/error.js";
|
||||
import { parseArray, parseBoolean } from "../common/ops.js";
|
||||
import { renderError } from "../common/render.js";
|
||||
import { fetchStats } from "../fetchers/stats.js";
|
||||
import { isLocaleAvailable } from "../translations.js";
|
||||
} from "../src/common/error.js";
|
||||
import { parseArray, parseBoolean } from "../src/common/ops.js";
|
||||
import { renderError } from "../src/common/render.js";
|
||||
import { fetchStats } from "../src/fetchers/stats.js";
|
||||
import { isLocaleAvailable } from "../src/translations.js";
|
||||
|
||||
// @ts-ignore
|
||||
export default async (
|
||||
{
|
||||
export default async (req, res) => {
|
||||
const {
|
||||
username,
|
||||
repo,
|
||||
owner,
|
||||
@@ -32,6 +40,7 @@ export default async (
|
||||
text_bold,
|
||||
bg_color,
|
||||
theme,
|
||||
cache_seconds,
|
||||
exclude_repo,
|
||||
custom_title,
|
||||
locale,
|
||||
@@ -43,13 +52,28 @@ export default async (
|
||||
border_color,
|
||||
rank_icon,
|
||||
show,
|
||||
},
|
||||
pat = null,
|
||||
) => {
|
||||
} = req.query;
|
||||
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)) {
|
||||
return {
|
||||
status: "error - permanent",
|
||||
content: renderError({
|
||||
return res.send(
|
||||
renderError({
|
||||
message: "Something went wrong",
|
||||
secondaryMessage: "Language not found",
|
||||
renderOptions: {
|
||||
@@ -60,7 +84,7 @@ export default async (
|
||||
theme,
|
||||
},
|
||||
}),
|
||||
};
|
||||
);
|
||||
}
|
||||
|
||||
const safePattern = /^[-\w/.,]+$/;
|
||||
@@ -69,9 +93,8 @@ export default async (
|
||||
(repo && !safePattern.test(repo)) ||
|
||||
(owner && !safePattern.test(owner))
|
||||
) {
|
||||
return {
|
||||
status: "error - permanent",
|
||||
content: renderError({
|
||||
return res.send(
|
||||
renderError({
|
||||
message: "Something went wrong",
|
||||
secondaryMessage:
|
||||
"Username, repository or owner contains unsafe characters",
|
||||
@@ -83,10 +106,11 @@ export default async (
|
||||
theme,
|
||||
},
|
||||
}),
|
||||
};
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
await storeRequest(req);
|
||||
const showStats = parseArray(show);
|
||||
const repoOwner = parseArray(owner);
|
||||
let repository = parseArray(repo);
|
||||
@@ -111,12 +135,18 @@ export default async (
|
||||
showStats.includes("issues_authored"),
|
||||
showStats.includes("issues_commented"),
|
||||
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 {
|
||||
status: "success",
|
||||
content: renderStatsCard(
|
||||
setCacheHeaders(res, cacheSeconds);
|
||||
|
||||
return res.send(
|
||||
renderStatsCard(
|
||||
stats,
|
||||
{
|
||||
hide: parseArray(hide),
|
||||
@@ -149,12 +179,12 @@ export default async (
|
||||
repository,
|
||||
repoOwner,
|
||||
),
|
||||
};
|
||||
);
|
||||
} catch (err) {
|
||||
setErrorCacheHeaders(res);
|
||||
if (err instanceof Error) {
|
||||
return {
|
||||
status: "error - temporary",
|
||||
content: renderError({
|
||||
return res.send(
|
||||
renderError({
|
||||
message: err.message,
|
||||
secondaryMessage: retrieveSecondaryMessage(err),
|
||||
renderOptions: {
|
||||
@@ -166,11 +196,10 @@ export default async (
|
||||
show_repo_link: !(err instanceof MissingParamError),
|
||||
},
|
||||
}),
|
||||
};
|
||||
);
|
||||
}
|
||||
return {
|
||||
status: "error - temporary",
|
||||
content: renderError({
|
||||
return res.send(
|
||||
renderError({
|
||||
message: "An unknown error occurred",
|
||||
renderOptions: {
|
||||
title_color,
|
||||
@@ -180,6 +209,6 @@ export default async (
|
||||
theme,
|
||||
},
|
||||
}),
|
||||
};
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -1,18 +1,26 @@
|
||||
// @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 {
|
||||
MissingParamError,
|
||||
retrieveSecondaryMessage,
|
||||
} from "../common/error.js";
|
||||
import { parseArray, parseBoolean } from "../common/ops.js";
|
||||
import { renderError } from "../common/render.js";
|
||||
import { fetchRepo } from "../fetchers/repo.js";
|
||||
import { isLocaleAvailable } from "../translations.js";
|
||||
} from "../src/common/error.js";
|
||||
import { parseArray, parseBoolean } from "../src/common/ops.js";
|
||||
import { renderError } from "../src/common/render.js";
|
||||
import { fetchRepo } from "../src/fetchers/repo.js";
|
||||
import { isLocaleAvailable } from "../src/translations.js";
|
||||
|
||||
// @ts-ignore
|
||||
export default async (
|
||||
{
|
||||
export default async (req, res) => {
|
||||
const {
|
||||
username,
|
||||
repo,
|
||||
hide_border,
|
||||
@@ -23,23 +31,39 @@ export default async (
|
||||
card_width,
|
||||
theme,
|
||||
show_owner,
|
||||
browser_rendering,
|
||||
show,
|
||||
show_icons,
|
||||
number_format,
|
||||
text_bold,
|
||||
line_height,
|
||||
cache_seconds,
|
||||
locale,
|
||||
border_radius,
|
||||
border_color,
|
||||
description_lines_count,
|
||||
},
|
||||
pat = null,
|
||||
) => {
|
||||
} = req.query;
|
||||
|
||||
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)) {
|
||||
return {
|
||||
status: "error - permanent",
|
||||
content: renderError({
|
||||
return res.send(
|
||||
renderError({
|
||||
message: "Something went wrong",
|
||||
secondaryMessage: "Language not found",
|
||||
renderOptions: {
|
||||
@@ -50,7 +74,7 @@ export default async (
|
||||
theme,
|
||||
},
|
||||
}),
|
||||
};
|
||||
);
|
||||
}
|
||||
|
||||
const safePattern = /^[-\w/.,]+$/;
|
||||
@@ -58,9 +82,8 @@ export default async (
|
||||
(username && !safePattern.test(username)) ||
|
||||
(repo && !safePattern.test(repo))
|
||||
) {
|
||||
return {
|
||||
status: "error - permanent",
|
||||
content: renderError({
|
||||
return res.send(
|
||||
renderError({
|
||||
message: "Something went wrong",
|
||||
secondaryMessage: "Username or repository contains unsafe characters",
|
||||
renderOptions: {
|
||||
@@ -71,10 +94,11 @@ export default async (
|
||||
theme,
|
||||
},
|
||||
}),
|
||||
};
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
await storeRequest(req);
|
||||
const showStats = parseArray(show);
|
||||
const repoData = await fetchRepo(
|
||||
username,
|
||||
@@ -84,12 +108,19 @@ export default async (
|
||||
showStats.includes("prs_reviewed"),
|
||||
showStats.includes("issues_authored"),
|
||||
showStats.includes("issues_commented"),
|
||||
pat,
|
||||
);
|
||||
|
||||
return {
|
||||
status: "success",
|
||||
content: renderRepoCard(repoData, {
|
||||
const cacheSeconds = resolveCacheSeconds({
|
||||
requested: parseInt(cache_seconds, 10),
|
||||
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),
|
||||
title_color,
|
||||
icon_color,
|
||||
@@ -100,7 +131,6 @@ export default async (
|
||||
border_color,
|
||||
card_width_input: parseInt(card_width, 10),
|
||||
show_owner: parseBoolean(show_owner),
|
||||
browser_rendering: parseBoolean(browser_rendering),
|
||||
show: showStats,
|
||||
show_icons: parseBoolean(show_icons),
|
||||
number_format,
|
||||
@@ -110,12 +140,12 @@ export default async (
|
||||
locale: locale ? locale.toLowerCase() : null,
|
||||
description_lines_count,
|
||||
}),
|
||||
};
|
||||
);
|
||||
} catch (err) {
|
||||
setErrorCacheHeaders(res);
|
||||
if (err instanceof Error) {
|
||||
return {
|
||||
status: "error - temporary",
|
||||
content: renderError({
|
||||
return res.send(
|
||||
renderError({
|
||||
message: err.message,
|
||||
secondaryMessage: retrieveSecondaryMessage(err),
|
||||
renderOptions: {
|
||||
@@ -127,11 +157,10 @@ export default async (
|
||||
show_repo_link: !(err instanceof MissingParamError),
|
||||
},
|
||||
}),
|
||||
};
|
||||
);
|
||||
}
|
||||
return {
|
||||
status: "error - temporary",
|
||||
content: renderError({
|
||||
return res.send(
|
||||
renderError({
|
||||
message: "An unknown error occurred",
|
||||
renderOptions: {
|
||||
title_color,
|
||||
@@ -141,6 +170,6 @@ export default async (
|
||||
theme,
|
||||
},
|
||||
}),
|
||||
};
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -6,12 +6,10 @@
|
||||
*
|
||||
* @description This function is currently rate limited to 1 request per 3 minutes.
|
||||
*/
|
||||
import {
|
||||
dateDiff,
|
||||
getConfig,
|
||||
logger,
|
||||
request,
|
||||
} from "@stats-organization/github-readme-stats-core";
|
||||
|
||||
import { request } from "../../src/common/http.js";
|
||||
import { logger } from "../../src/common/log.js";
|
||||
import { dateDiff } from "../../src/common/ops.js";
|
||||
|
||||
export const RATE_LIMIT_SECONDS = 60 * 3; // 1 request per 3 minutes
|
||||
|
||||
@@ -41,7 +39,7 @@ const uptimeFetcher = (variables, token) => {
|
||||
};
|
||||
|
||||
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) {
|
||||
try {
|
||||
const response = await fetcher(variables, pat.value);
|
||||
const response = await fetcher(variables, process.env[pat]);
|
||||
const errors = response.data.errors;
|
||||
const hasErrors = Boolean(errors);
|
||||
const errorType = errors?.[0]?.type;
|
||||
@@ -73,7 +71,7 @@ const getPATInfo = async (fetcher, variables) => {
|
||||
|
||||
// Store PATs with errors.
|
||||
if (hasErrors && errorType !== "RATE_LIMITED") {
|
||||
details[pat.name] = {
|
||||
details[pat] = {
|
||||
status: "error",
|
||||
error: {
|
||||
type: errors[0].type,
|
||||
@@ -84,13 +82,13 @@ const getPATInfo = async (fetcher, variables) => {
|
||||
} else if (isRateLimited) {
|
||||
const date1 = new Date();
|
||||
const date2 = new Date(response.data?.data?.rateLimit?.resetAt);
|
||||
details[pat.name] = {
|
||||
details[pat] = {
|
||||
status: "exhausted",
|
||||
remaining: 0,
|
||||
resetIn: dateDiff(date2, date1) + " minutes",
|
||||
};
|
||||
} else {
|
||||
details[pat.name] = {
|
||||
details[pat] = {
|
||||
status: "valid",
|
||||
remaining: response.data.data.rateLimit.remaining,
|
||||
};
|
||||
@@ -99,11 +97,11 @@ const getPATInfo = async (fetcher, variables) => {
|
||||
// Store the PAT if it is expired.
|
||||
const errorMessage = err.response?.data?.message?.toLowerCase();
|
||||
if (errorMessage === "bad credentials") {
|
||||
details[pat.name] = {
|
||||
details[pat] = {
|
||||
status: "expired",
|
||||
};
|
||||
} else if (errorMessage === "sorry. your account was suspended.") {
|
||||
details[pat.name] = {
|
||||
details[pat] = {
|
||||
status: "suspended",
|
||||
};
|
||||
} else {
|
||||
|
||||
@@ -7,11 +7,9 @@
|
||||
* @description This function is currently rate limited to 1 request per 3 minutes.
|
||||
*/
|
||||
|
||||
import {
|
||||
logger,
|
||||
request,
|
||||
retryer,
|
||||
} from "@stats-organization/github-readme-stats-core";
|
||||
import { request } from "../../src/common/http.js";
|
||||
import { logger } from "../../src/common/log.js";
|
||||
import { default as retryer } from "../../src/common/retryer.js";
|
||||
|
||||
export const RATE_LIMIT_SECONDS = 60 * 3; // 1 request per 3 minutes
|
||||
|
||||
@@ -89,7 +87,7 @@ export default async (req, res) => {
|
||||
try {
|
||||
let PATsValid = true;
|
||||
try {
|
||||
await retryer(uptimeFetcher, {});
|
||||
await retryer(uptimeFetcher, null, {});
|
||||
} catch (err) {
|
||||
// Resolve eslint no-unused-vars
|
||||
err;
|
||||
|
||||
@@ -1,18 +1,26 @@
|
||||
// @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 {
|
||||
MissingParamError,
|
||||
retrieveSecondaryMessage,
|
||||
} from "../common/error.js";
|
||||
import { parseArray, parseBoolean } from "../common/ops.js";
|
||||
import { renderError } from "../common/render.js";
|
||||
import { fetchTopLanguages } from "../fetchers/top-languages.js";
|
||||
import { isLocaleAvailable } from "../translations.js";
|
||||
} from "../src/common/error.js";
|
||||
import { parseArray, parseBoolean } from "../src/common/ops.js";
|
||||
import { renderError } from "../src/common/render.js";
|
||||
import { fetchTopLanguages } from "../src/fetchers/top-languages.js";
|
||||
import { isLocaleAvailable } from "../src/translations.js";
|
||||
|
||||
// @ts-ignore
|
||||
export default async (
|
||||
{
|
||||
export default async (req, res) => {
|
||||
const {
|
||||
username,
|
||||
hide,
|
||||
hide_title,
|
||||
@@ -23,6 +31,7 @@ export default async (
|
||||
bg_color,
|
||||
prog_bar_bg_color,
|
||||
theme,
|
||||
cache_seconds,
|
||||
layout,
|
||||
langs_count,
|
||||
exclude_repo,
|
||||
@@ -35,15 +44,29 @@ export default async (
|
||||
role,
|
||||
disable_animations,
|
||||
hide_progress,
|
||||
hide_values,
|
||||
stats_format,
|
||||
},
|
||||
pat = null,
|
||||
) => {
|
||||
} = req.query;
|
||||
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)) {
|
||||
return {
|
||||
status: "error - permanent",
|
||||
content: renderError({
|
||||
return res.send(
|
||||
renderError({
|
||||
message: "Something went wrong",
|
||||
secondaryMessage: "Locale not found",
|
||||
renderOptions: {
|
||||
@@ -54,7 +77,7 @@ export default async (
|
||||
theme,
|
||||
},
|
||||
}),
|
||||
};
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
@@ -62,9 +85,8 @@ export default async (
|
||||
(typeof layout !== "string" ||
|
||||
!["compact", "normal", "donut", "donut-vertical", "pie"].includes(layout))
|
||||
) {
|
||||
return {
|
||||
status: "error - permanent",
|
||||
content: renderError({
|
||||
return res.send(
|
||||
renderError({
|
||||
message: "Something went wrong",
|
||||
secondaryMessage: "Incorrect layout input",
|
||||
renderOptions: {
|
||||
@@ -75,7 +97,7 @@ export default async (
|
||||
theme,
|
||||
},
|
||||
}),
|
||||
};
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
@@ -83,9 +105,8 @@ export default async (
|
||||
(typeof stats_format !== "string" ||
|
||||
!["bytes", "percentages"].includes(stats_format))
|
||||
) {
|
||||
return {
|
||||
status: "error - permanent",
|
||||
content: renderError({
|
||||
return res.send(
|
||||
renderError({
|
||||
message: "Something went wrong",
|
||||
secondaryMessage: "Incorrect stats_format input",
|
||||
renderOptions: {
|
||||
@@ -96,22 +117,29 @@ export default async (
|
||||
theme,
|
||||
},
|
||||
}),
|
||||
};
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
await storeRequest(req);
|
||||
const topLangs = await fetchTopLanguages(
|
||||
username,
|
||||
parseArray(exclude_repo),
|
||||
size_weight,
|
||||
count_weight,
|
||||
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 {
|
||||
status: "success",
|
||||
content: renderTopLanguages(topLangs, {
|
||||
setCacheHeaders(res, cacheSeconds);
|
||||
|
||||
return res.send(
|
||||
renderTopLanguages(topLangs, {
|
||||
custom_title,
|
||||
hide_title: parseBoolean(hide_title),
|
||||
hide_border: parseBoolean(hide_border),
|
||||
@@ -129,15 +157,14 @@ export default async (
|
||||
locale: locale ? locale.toLowerCase() : null,
|
||||
disable_animations: parseBoolean(disable_animations),
|
||||
hide_progress: parseBoolean(hide_progress),
|
||||
hide_values: parseBoolean(hide_values),
|
||||
stats_format,
|
||||
}),
|
||||
};
|
||||
);
|
||||
} catch (err) {
|
||||
setErrorCacheHeaders(res);
|
||||
if (err instanceof Error) {
|
||||
return {
|
||||
status: "error - temporary",
|
||||
content: renderError({
|
||||
return res.send(
|
||||
renderError({
|
||||
message: err.message,
|
||||
secondaryMessage: retrieveSecondaryMessage(err),
|
||||
renderOptions: {
|
||||
@@ -149,11 +176,10 @@ export default async (
|
||||
show_repo_link: !(err instanceof MissingParamError),
|
||||
},
|
||||
}),
|
||||
};
|
||||
);
|
||||
}
|
||||
return {
|
||||
status: "error - temporary",
|
||||
content: renderError({
|
||||
return res.send(
|
||||
renderError({
|
||||
message: "An unknown error occurred",
|
||||
renderOptions: {
|
||||
title_color,
|
||||
@@ -163,6 +189,6 @@ export default async (
|
||||
theme,
|
||||
},
|
||||
}),
|
||||
};
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -1,6 +1,5 @@
|
||||
import { logger } from "@stats-organization/github-readme-stats-core";
|
||||
|
||||
import { getUserAccessByKey } from "../src/common/database.js";
|
||||
import { logger } from "../src/common/log.js";
|
||||
|
||||
/**
|
||||
* @param {any} req The request.
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import {
|
||||
fetchWakatimeStats,
|
||||
logger,
|
||||
} from "@stats-organization/github-readme-stats-core";
|
||||
import { logger } from "../src/common/log.js";
|
||||
import { fetchWakatimeStats } from "../src/fetchers/wakatime.js";
|
||||
|
||||
/**
|
||||
* @param {any} req The request.
|
||||
|
||||
@@ -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
@@ -1,10 +1,21 @@
|
||||
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 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;
|
||||
app.listen(port, "0.0.0.0", () => {
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
export { default as router } from "./router.js";
|
||||
+38
-15
@@ -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",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "24.x"
|
||||
"homepage": "https://github-stats-extended.vercel.app/frontend",
|
||||
"bugs": {
|
||||
"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": {
|
||||
"test": "vitest",
|
||||
"test:update:snapshot": "vitest -u",
|
||||
"test:e2e": "vitest --config vitest.config.e2e.ts",
|
||||
"bench": "vitest bench --run --config vitest.config.bench.ts",
|
||||
"lint": "eslint",
|
||||
"typecheck": "tsc -p tsconfig.typecheck.json"
|
||||
"theme-readme-gen": "node scripts/generate-theme-doc",
|
||||
"generate-langs-json": "node scripts/generate-langs-json",
|
||||
"bench": "vitest bench --run --config vitest.config.bench.ts"
|
||||
},
|
||||
"author": "Anurag Hazra",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"axios-mock-adapter": "2.1.0",
|
||||
"express": "5.2.1",
|
||||
"jsdom": "catalog:default",
|
||||
"@testing-library/dom": "^10.4.1",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@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"
|
||||
},
|
||||
"dependencies": {
|
||||
"axios": "catalog:default",
|
||||
"@stats-organization/github-readme-stats-core": "workspace:^",
|
||||
"pg": "^8.21.0"
|
||||
"axios": "^1.13.5",
|
||||
"emoji-name-map": "^2.0.3",
|
||||
"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 |
@@ -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;
|
||||
}
|
||||
};
|
||||
+2
-2
@@ -1,8 +1,8 @@
|
||||
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 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 { parseEmojis } from "../common/ops.js";
|
||||
import {
|
||||
countWrappedLines,
|
||||
createLanguageNode,
|
||||
flexLayout,
|
||||
iconWithLabel,
|
||||
measureText,
|
||||
wrappedTextNode,
|
||||
wrappedTextStyles,
|
||||
} from "../common/render.js";
|
||||
|
||||
const ICON_SIZE = 16;
|
||||
const CARD_DEFAULT_WIDTH = 400;
|
||||
const X_OFFSET = 25;
|
||||
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.
|
||||
@@ -50,7 +42,6 @@ const renderGistCard = (gistData, options = {}) => {
|
||||
border_radius,
|
||||
border_color,
|
||||
show_owner = false,
|
||||
browser_rendering = false,
|
||||
hide_border = false,
|
||||
} = options;
|
||||
|
||||
@@ -65,50 +56,14 @@ const renderGistCard = (gistData, options = {}) => {
|
||||
theme,
|
||||
});
|
||||
|
||||
const lineWidth = 59;
|
||||
const linesLimit = 10;
|
||||
const desc = parseEmojis(description || "No description provided");
|
||||
|
||||
let descriptionLines, descriptionSvg;
|
||||
if (browser_rendering) {
|
||||
// The browser performs the actual text wrapping inside the foreignObject;
|
||||
// we only estimate the line count server-side so the SVG can reserve enough
|
||||
// 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 multiLineDescription = wrapTextMultiline(desc, lineWidth, linesLimit);
|
||||
const descriptionLines = multiLineDescription.length;
|
||||
const descriptionSvg = multiLineDescription
|
||||
.map((line) => `<tspan dy="1.2em" x="25">${encodeHTML(line)}</tspan>`)
|
||||
.join("");
|
||||
|
||||
const lineHeight = descriptionLines > 3 ? 12 : 10;
|
||||
const height =
|
||||
@@ -166,17 +121,16 @@ const renderGistCard = (gistData, options = {}) => {
|
||||
});
|
||||
|
||||
card.setCSS(`
|
||||
.description {
|
||||
font: 400 ${DESCRIPTION_FONT_SIZE}px 'Segoe UI', Ubuntu, Sans-Serif;fill: ${textColor};
|
||||
${browser_rendering ? wrappedTextStyles(textColor) : ""}
|
||||
}
|
||||
.description { font: 400 13px 'Segoe UI', Ubuntu, Sans-Serif; fill: ${textColor} }
|
||||
.gray { font: 400 12px 'Segoe UI', Ubuntu, Sans-Serif; fill: ${textColor} }
|
||||
.icon { fill: ${iconColor} }
|
||||
`);
|
||||
card.setHideBorder(hide_border);
|
||||
|
||||
return card.render(`
|
||||
${descriptionSvg}
|
||||
<text class="description" x="25" y="-5">
|
||||
${descriptionSvg}
|
||||
</text>
|
||||
|
||||
<g transform="translate(30, ${height - 75})">
|
||||
${starAndForkCount}
|
||||
@@ -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 { buildSearchFilter, clampValue, parseEmojis } from "../common/ops.js";
|
||||
import {
|
||||
countWrappedLines,
|
||||
createLanguageNode,
|
||||
flexLayout,
|
||||
iconWithLabel,
|
||||
measureText,
|
||||
wrappedTextNode,
|
||||
wrappedTextStyles,
|
||||
} from "../common/render.js";
|
||||
import { repoCardLocales } from "../translations.js";
|
||||
|
||||
import { createTextNode } from "./stats.js";
|
||||
|
||||
const ICON_SIZE = 16;
|
||||
const CARD_DEFAULT_WIDTH = 400;
|
||||
const X_OFFSET = 25;
|
||||
const DESCRIPTION_FONT_SIZE = 13;
|
||||
const DESCRIPTION_LINE_HEIGHT_PX = 16;
|
||||
const DESCRIPTION_LINE_WIDTH = 59;
|
||||
const DESCRIPTION_MAX_LINES = 3;
|
||||
|
||||
/**
|
||||
@@ -85,7 +79,6 @@ const renderRepoCard = (repo, options = {}) => {
|
||||
bg_color,
|
||||
card_width_input,
|
||||
show_owner = false,
|
||||
browser_rendering = false,
|
||||
show = [],
|
||||
show_icons = true,
|
||||
number_format = "short",
|
||||
@@ -103,8 +96,8 @@ const renderRepoCard = (repo, options = {}) => {
|
||||
card_width_input && !isNaN(card_width_input)
|
||||
? card_width_input
|
||||
: show.length >= 2
|
||||
? CARD_DEFAULT_WIDTH + 30
|
||||
: CARD_DEFAULT_WIDTH;
|
||||
? 430
|
||||
: 400;
|
||||
|
||||
const i18n = new I18n({
|
||||
locale,
|
||||
@@ -182,56 +175,23 @@ const renderRepoCard = (repo, options = {}) => {
|
||||
const header = show_owner ? nameWithOwner : name;
|
||||
const langName = (primaryLanguage && primaryLanguage.name) || "Unspecified";
|
||||
const langColor = (primaryLanguage && primaryLanguage.color) || "#333";
|
||||
const desc = parseEmojis(description || "No description provided");
|
||||
const descriptionBoxWidth = card_width - 2 * X_OFFSET;
|
||||
const descriptionMaxLines = description_lines_count
|
||||
? clampValue(description_lines_count, 1, DESCRIPTION_MAX_LINES)
|
||||
: DESCRIPTION_MAX_LINES;
|
||||
|
||||
let descriptionLinesCount, descriptionSvg;
|
||||
if (browser_rendering) {
|
||||
// The browser performs the actual text wrapping inside the foreignObject;
|
||||
// we only estimate the line count server-side so the SVG can reserve enough
|
||||
// height. The estimate uses measureText for font-aware widths instead of a
|
||||
// fixed character count.
|
||||
descriptionLinesCount = description_lines_count
|
||||
? clampValue(description_lines_count, 1, DESCRIPTION_MAX_LINES)
|
||||
: countWrappedLines(
|
||||
desc,
|
||||
DESCRIPTION_FONT_SIZE,
|
||||
descriptionBoxWidth,
|
||||
DESCRIPTION_MAX_LINES,
|
||||
);
|
||||
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 desc = parseEmojis(description || "No description provided");
|
||||
const multiLineDescription = wrapTextMultiline(
|
||||
desc,
|
||||
Math.round((card_width - 400) / 5.93 + DESCRIPTION_LINE_WIDTH),
|
||||
descriptionMaxLines,
|
||||
);
|
||||
const descriptionLinesCount = description_lines_count
|
||||
? clampValue(description_lines_count, 1, DESCRIPTION_MAX_LINES)
|
||||
: multiLineDescription.length;
|
||||
|
||||
const descriptionSvg = multiLineDescription
|
||||
.map((line) => `<tspan dy="1.2em" x="25">${encodeHTML(line)}</tspan>`)
|
||||
.join("");
|
||||
|
||||
const extraHeight = Object.keys(STATS).length
|
||||
? -7 + (Math.ceil(statItems.length / 2) + 1) * extraLHeight
|
||||
@@ -313,14 +273,11 @@ const renderRepoCard = (repo, options = {}) => {
|
||||
card.setHideBorder(hide_border);
|
||||
card.setHideTitle(false);
|
||||
card.setCSS(`
|
||||
.description {
|
||||
font: 400 ${DESCRIPTION_FONT_SIZE}px 'Segoe UI', Ubuntu, Sans-Serif;fill: ${colors.textColor};
|
||||
${browser_rendering ? wrappedTextStyles(colors.textColor) : ""}
|
||||
}
|
||||
.description { font: 400 13px '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 rect { opacity: 0.2 }
|
||||
|
||||
|
||||
.stat { font: 400 12px 'Segoe UI', Ubuntu, Sans-Serif; fill: ${colors.textColor} }
|
||||
.stagger {
|
||||
opacity: 0;
|
||||
@@ -341,19 +298,21 @@ const renderRepoCard = (repo, options = {}) => {
|
||||
getBadgeSVG(
|
||||
i18n.t("repocard.template"),
|
||||
colors.textColor,
|
||||
card_width - CARD_DEFAULT_WIDTH,
|
||||
card_width - 400,
|
||||
)
|
||||
: isArchived
|
||||
? // @ts-ignore
|
||||
getBadgeSVG(
|
||||
i18n.t("repocard.archived"),
|
||||
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})">
|
||||
${starAndForkCount}
|
||||
@@ -233,7 +233,7 @@ const getStyles = ({
|
||||
transform: rotate(-90deg);
|
||||
animation: rankAnimation 1s forwards ease-in-out;
|
||||
}
|
||||
${getProgressAnimation({ progress })}
|
||||
${process.env.NODE_ENV === "test" ? "" : getProgressAnimation({ progress })}
|
||||
`;
|
||||
};
|
||||
|
||||
+11
-57
@@ -221,7 +221,6 @@ const getDisplayValue = (size, percentages, format) => {
|
||||
* @param {number} props.size Size of the programming language.
|
||||
* @param {number} props.totalSize Total size of all languages.
|
||||
* @param {string} props.statsFormat Stats format.
|
||||
* @param {boolean=} props.hideValues Whether to hide stats values.
|
||||
* @param {number} props.index Index of the programming language.
|
||||
* @returns {string} Programming language SVG node.
|
||||
*/
|
||||
@@ -233,11 +232,10 @@ const createProgressTextNode = ({
|
||||
size,
|
||||
totalSize,
|
||||
statsFormat,
|
||||
hideValues,
|
||||
index,
|
||||
}) => {
|
||||
const staggerDelay = (index + 3) * 150;
|
||||
const paddingRight = hideValues ? CARD_PADDING * 2 : 95;
|
||||
const paddingRight = 95;
|
||||
const progressTextX = width - paddingRight + 10;
|
||||
const progressWidth = width - paddingRight;
|
||||
|
||||
@@ -247,7 +245,7 @@ const createProgressTextNode = ({
|
||||
return `
|
||||
<g class="stagger" style="animation-delay: ${staggerDelay}ms">
|
||||
<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({
|
||||
x: 0,
|
||||
y: 25,
|
||||
@@ -268,7 +266,6 @@ const createProgressTextNode = ({
|
||||
* @param {Lang} props.lang Programming language object.
|
||||
* @param {number} props.totalSize Total size of all languages.
|
||||
* @param {boolean=} props.hideProgress Whether to hide percentage.
|
||||
* @param {boolean=} props.hideValues Whether to hide stats values (percentages/bytes).
|
||||
* @param {string=} props.statsFormat Stats format
|
||||
* @param {number} props.index Index of the programming language.
|
||||
* @returns {string} Compact layout programming language SVG node.
|
||||
@@ -277,7 +274,6 @@ const createCompactLangNode = ({
|
||||
lang,
|
||||
totalSize,
|
||||
hideProgress,
|
||||
hideValues,
|
||||
statsFormat = "percentages",
|
||||
index,
|
||||
}) => {
|
||||
@@ -291,7 +287,7 @@ const createCompactLangNode = ({
|
||||
<g class="stagger" style="animation-delay: ${staggerDelay}ms">
|
||||
<circle cx="5" cy="6" r="5" fill="${color}" />
|
||||
<text data-testid="lang-name" x="15" y="10" class='lang-name'>
|
||||
${lang.name} ${hideProgress || hideValues ? "" : displayValue}
|
||||
${lang.name} ${hideProgress ? "" : displayValue}
|
||||
</text>
|
||||
</g>
|
||||
`;
|
||||
@@ -304,7 +300,6 @@ const createCompactLangNode = ({
|
||||
* @param {Lang[]} props.langs Array of programming languages.
|
||||
* @param {number} props.totalSize Total size of all languages.
|
||||
* @param {boolean=} props.hideProgress Whether to hide percentage.
|
||||
* @param {boolean=} props.hideValues Whether to hide stats values.
|
||||
* @param {string=} props.statsFormat Stats format
|
||||
* @returns {string} Programming languages SVG node.
|
||||
*/
|
||||
@@ -312,7 +307,6 @@ const createLanguageTextNode = ({
|
||||
langs,
|
||||
totalSize,
|
||||
hideProgress,
|
||||
hideValues,
|
||||
statsFormat,
|
||||
}) => {
|
||||
const longestLang = getLongestLang(langs);
|
||||
@@ -324,7 +318,6 @@ const createLanguageTextNode = ({
|
||||
lang,
|
||||
totalSize,
|
||||
hideProgress,
|
||||
hideValues,
|
||||
statsFormat,
|
||||
index,
|
||||
}),
|
||||
@@ -351,23 +344,16 @@ const createLanguageTextNode = ({
|
||||
* @param {object} props Function properties.
|
||||
* @param {Lang[]} props.langs Array of programming languages.
|
||||
* @param {number} props.totalSize Total size of all languages.
|
||||
* @param {boolean=} props.hideValues Whether to hide stats values.
|
||||
* @param {string} props.statsFormat Stats format
|
||||
* @returns {string} Donut layout programming language SVG node.
|
||||
*/
|
||||
const createDonutLanguagesNode = ({
|
||||
langs,
|
||||
totalSize,
|
||||
hideValues,
|
||||
statsFormat,
|
||||
}) => {
|
||||
const createDonutLanguagesNode = ({ langs, totalSize, statsFormat }) => {
|
||||
return flexLayout({
|
||||
items: langs.map((lang, index) => {
|
||||
return createCompactLangNode({
|
||||
lang,
|
||||
totalSize,
|
||||
hideProgress: false,
|
||||
hideValues,
|
||||
statsFormat,
|
||||
index,
|
||||
});
|
||||
@@ -385,7 +371,6 @@ const createDonutLanguagesNode = ({
|
||||
* @param {number} totalLanguageSize Total size of all languages.
|
||||
* @param progBarBgColor Color of the background of progress bar.
|
||||
* @param {string} statsFormat Stats format.
|
||||
* @param {boolean=} hideValues Whether to hide stats values.
|
||||
* @returns {string} Normal layout card SVG object.
|
||||
*/
|
||||
const renderNormalLayout = (
|
||||
@@ -394,7 +379,6 @@ const renderNormalLayout = (
|
||||
totalLanguageSize,
|
||||
progBarBgColor,
|
||||
statsFormat,
|
||||
hideValues,
|
||||
) => {
|
||||
return flexLayout({
|
||||
items: langs.map((lang, index) => {
|
||||
@@ -405,7 +389,6 @@ const renderNormalLayout = (
|
||||
size: lang.size,
|
||||
totalSize: totalLanguageSize,
|
||||
statsFormat,
|
||||
hideValues,
|
||||
index,
|
||||
progBarBgColor,
|
||||
});
|
||||
@@ -423,7 +406,6 @@ const renderNormalLayout = (
|
||||
* @param {number} totalLanguageSize Total size of all languages.
|
||||
* @param {boolean=} hideProgress Whether to hide progress bar.
|
||||
* @param {string} statsFormat Stats format.
|
||||
* @param {boolean=} hideValues Whether to hide stats values.
|
||||
* @returns {string} Compact layout card SVG object.
|
||||
*/
|
||||
const renderCompactLayout = (
|
||||
@@ -432,7 +414,6 @@ const renderCompactLayout = (
|
||||
totalLanguageSize,
|
||||
hideProgress,
|
||||
statsFormat = "percentages",
|
||||
hideValues,
|
||||
) => {
|
||||
const paddingRight = 50;
|
||||
const offsetWidth = width - paddingRight;
|
||||
@@ -480,7 +461,6 @@ const renderCompactLayout = (
|
||||
totalSize: totalLanguageSize,
|
||||
hideProgress,
|
||||
statsFormat,
|
||||
hideValues,
|
||||
})}
|
||||
</g>
|
||||
`;
|
||||
@@ -492,15 +472,9 @@ const renderCompactLayout = (
|
||||
* @param {Lang[]} langs Array of programming languages.
|
||||
* @param {number} totalLanguageSize Total size of all languages.
|
||||
* @param {string} statsFormat Stats format.
|
||||
* @param {boolean=} hideValues Whether to hide stats values.
|
||||
* @returns {string} Compact layout card SVG object.
|
||||
*/
|
||||
const renderDonutVerticalLayout = (
|
||||
langs,
|
||||
totalLanguageSize,
|
||||
statsFormat,
|
||||
hideValues,
|
||||
) => {
|
||||
const renderDonutVerticalLayout = (langs, totalLanguageSize, statsFormat) => {
|
||||
// Donut vertical chart radius and total length
|
||||
const radius = 80;
|
||||
const totalCircleLength = getCircleLength(radius);
|
||||
@@ -557,7 +531,6 @@ const renderDonutVerticalLayout = (
|
||||
totalSize: totalLanguageSize,
|
||||
hideProgress: false,
|
||||
statsFormat,
|
||||
hideValues,
|
||||
})}
|
||||
</svg>
|
||||
</g>
|
||||
@@ -571,10 +544,9 @@ const renderDonutVerticalLayout = (
|
||||
* @param {Lang[]} langs Array of programming languages.
|
||||
* @param {number} totalLanguageSize Total size of all languages.
|
||||
* @param {string} statsFormat Stats format.
|
||||
* @param {boolean=} hideValues Whether to hide stats values.
|
||||
* @returns {string} Compact layout card SVG object.
|
||||
*/
|
||||
const renderPieLayout = (langs, totalLanguageSize, statsFormat, hideValues) => {
|
||||
const renderPieLayout = (langs, totalLanguageSize, statsFormat) => {
|
||||
// Pie chart radius and center coordinates
|
||||
const radius = 90;
|
||||
const centerX = 150;
|
||||
@@ -656,7 +628,6 @@ const renderPieLayout = (langs, totalLanguageSize, statsFormat, hideValues) => {
|
||||
totalSize: totalLanguageSize,
|
||||
hideProgress: false,
|
||||
statsFormat,
|
||||
hideValues,
|
||||
})}
|
||||
</svg>
|
||||
</g>
|
||||
@@ -676,7 +647,7 @@ const renderPieLayout = (langs, totalLanguageSize, statsFormat, hideValues) => {
|
||||
const createDonutPaths = (cx, cy, radius, percentages) => {
|
||||
const paths = [];
|
||||
let startAngle = 0;
|
||||
let endAngle;
|
||||
let endAngle = 0;
|
||||
|
||||
const totalPercent = percentages.reduce((acc, curr) => acc + curr, 0);
|
||||
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} totalLanguageSize Total size of all languages.
|
||||
* @param {string} statsFormat Stats format.
|
||||
* @param {boolean=} hideValues Whether to hide stats values.
|
||||
* @returns {string} Donut layout card SVG object.
|
||||
*/
|
||||
const renderDonutLayout = (
|
||||
langs,
|
||||
width,
|
||||
totalLanguageSize,
|
||||
statsFormat,
|
||||
hideValues,
|
||||
) => {
|
||||
const renderDonutLayout = (langs, width, totalLanguageSize, statsFormat) => {
|
||||
const centerX = width / 3;
|
||||
const centerY = width / 3;
|
||||
const radius = centerX - 60;
|
||||
@@ -760,7 +724,7 @@ const renderDonutLayout = (
|
||||
return `
|
||||
<g transform="translate(0, 0)">
|
||||
<g transform="translate(0, 0)">
|
||||
${createDonutLanguagesNode({ langs, totalSize: totalLanguageSize, hideValues, statsFormat })}
|
||||
${createDonutLanguagesNode({ langs, totalSize: totalLanguageSize, statsFormat })}
|
||||
</g>
|
||||
|
||||
<g transform="translate(125, ${donutCenterTranslation(langs.length)})">
|
||||
@@ -836,7 +800,6 @@ const renderTopLanguages = (topLangs, options = {}) => {
|
||||
prog_bar_bg_color,
|
||||
hide,
|
||||
hide_progress,
|
||||
hide_values,
|
||||
theme,
|
||||
layout,
|
||||
custom_title,
|
||||
@@ -877,7 +840,7 @@ const renderTopLanguages = (topLangs, options = {}) => {
|
||||
theme,
|
||||
});
|
||||
|
||||
let finalLayout;
|
||||
let finalLayout = "";
|
||||
if (langs.length === 0) {
|
||||
height = COMPACT_LAYOUT_BASE_HEIGHT;
|
||||
finalLayout = noLanguagesDataNode({
|
||||
@@ -887,19 +850,13 @@ const renderTopLanguages = (topLangs, options = {}) => {
|
||||
});
|
||||
} else if (layout === "pie") {
|
||||
height = calculatePieLayoutHeight(langs.length);
|
||||
finalLayout = renderPieLayout(
|
||||
langs,
|
||||
totalLanguageSize,
|
||||
stats_format,
|
||||
hide_values,
|
||||
);
|
||||
finalLayout = renderPieLayout(langs, totalLanguageSize, stats_format);
|
||||
} else if (layout === "donut-vertical") {
|
||||
height = calculateDonutVerticalLayoutHeight(langs.length);
|
||||
finalLayout = renderDonutVerticalLayout(
|
||||
langs,
|
||||
totalLanguageSize,
|
||||
stats_format,
|
||||
hide_values,
|
||||
);
|
||||
} else if (layout === "compact" || hide_progress == true) {
|
||||
height =
|
||||
@@ -911,7 +868,6 @@ const renderTopLanguages = (topLangs, options = {}) => {
|
||||
totalLanguageSize,
|
||||
hide_progress,
|
||||
stats_format,
|
||||
hide_values,
|
||||
);
|
||||
} else if (layout === "donut") {
|
||||
height = calculateDonutLayoutHeight(langs.length);
|
||||
@@ -921,7 +877,6 @@ const renderTopLanguages = (topLangs, options = {}) => {
|
||||
width,
|
||||
totalLanguageSize,
|
||||
stats_format,
|
||||
hide_values,
|
||||
);
|
||||
} else {
|
||||
finalLayout = renderNormalLayout(
|
||||
@@ -930,7 +885,6 @@ const renderTopLanguages = (topLangs, options = {}) => {
|
||||
totalLanguageSize,
|
||||
fallbackColor(prog_bar_bg_color, "#ddd"),
|
||||
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";
|
||||
|
||||
interface CommonOptions {
|
||||
type CommonOptions = {
|
||||
title_color: string;
|
||||
icon_color: string;
|
||||
text_color: string;
|
||||
@@ -11,10 +11,10 @@ interface CommonOptions {
|
||||
border_color: string;
|
||||
locale: string;
|
||||
hide_border: boolean;
|
||||
}
|
||||
};
|
||||
|
||||
export type StatCardOptions = CommonOptions & {
|
||||
hide: Array<string>;
|
||||
hide: string[];
|
||||
show_icons: boolean;
|
||||
hide_title: boolean;
|
||||
card_width: number;
|
||||
@@ -29,15 +29,14 @@ export type StatCardOptions = CommonOptions & {
|
||||
ring_color: string;
|
||||
text_bold: boolean;
|
||||
rank_icon: RankIcon;
|
||||
show: Array<string>;
|
||||
show: string[];
|
||||
};
|
||||
|
||||
export type RepoCardOptions = CommonOptions & {
|
||||
show_owner: boolean;
|
||||
browser_rendering: boolean;
|
||||
description_lines_count: number;
|
||||
card_width_input;
|
||||
show: Array<string>;
|
||||
show: string[];
|
||||
show_icons: boolean;
|
||||
number_format: string;
|
||||
text_bold: boolean;
|
||||
@@ -48,20 +47,19 @@ export type RepoCardOptions = CommonOptions & {
|
||||
export type TopLangOptions = CommonOptions & {
|
||||
hide_title: boolean;
|
||||
card_width: number;
|
||||
hide: Array<string>;
|
||||
hide: string[];
|
||||
layout: "compact" | "normal" | "donut" | "donut-vertical" | "pie";
|
||||
custom_title: string;
|
||||
langs_count: number;
|
||||
disable_animations: boolean;
|
||||
hide_progress: boolean;
|
||||
hide_values: boolean;
|
||||
prog_bar_bg_color: string;
|
||||
stats_format: "percentages" | "bytes";
|
||||
};
|
||||
|
||||
export type WakaTimeOptions = CommonOptions & {
|
||||
hide_title: boolean;
|
||||
hide: Array<string>;
|
||||
hide: string[];
|
||||
card_width: number;
|
||||
line_height: string;
|
||||
hide_progress: boolean;
|
||||
@@ -74,5 +72,4 @@ export type WakaTimeOptions = CommonOptions & {
|
||||
|
||||
export type GistCardOptions = CommonOptions & {
|
||||
show_owner: boolean;
|
||||
browser_rendering: boolean;
|
||||
};
|
||||
@@ -305,7 +305,7 @@ const renderWakatimeCard = (stats = {}, options = { hide: [] }) => {
|
||||
textColor,
|
||||
});
|
||||
|
||||
let finalLayout;
|
||||
let finalLayout = "";
|
||||
|
||||
// RENDER COMPACT LAYOUT
|
||||
if (layout === "compact") {
|
||||
@@ -230,7 +230,7 @@ class Card {
|
||||
}
|
||||
${this.css}
|
||||
|
||||
${this.getAnimations()}
|
||||
${process.env.NODE_ENV === "test" ? "" : this.getAnimations()}
|
||||
${
|
||||
this.animations === false
|
||||
? `* { animation-duration: 0s !important; animation-delay: 0s !important; }`
|
||||
@@ -1,11 +1,8 @@
|
||||
// @ts-check
|
||||
|
||||
import {
|
||||
getConfig,
|
||||
renderError,
|
||||
} from "@stats-organization/github-readme-stats-core";
|
||||
|
||||
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_GIST_MESSAGE = "This gist ID is not whitelisted";
|
||||
@@ -15,35 +12,36 @@ const BLACKLISTED_MESSAGE = "This username is blacklisted";
|
||||
* Guards access using whitelist/blacklist.
|
||||
*
|
||||
* @param {Object} args The parameters object.
|
||||
* @param {any} args.res The response object.
|
||||
* @param {string} args.id Resource identifier (username or gist id).
|
||||
* @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.
|
||||
* @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)) {
|
||||
throw new Error(
|
||||
'Invalid type. Expected "username", "gist", or "wakatime".',
|
||||
);
|
||||
}
|
||||
|
||||
const config = getConfig();
|
||||
const currentWhitelist =
|
||||
type === "gist" ? config.gistWhitelist : config.whitelist;
|
||||
const currentWhitelist = type === "gist" ? gistWhitelist : whitelist;
|
||||
const notWhitelistedMsg =
|
||||
type === "gist"
|
||||
? NOT_WHITELISTED_GIST_MESSAGE
|
||||
: NOT_WHITELISTED_USERNAME_MESSAGE;
|
||||
|
||||
if (Array.isArray(currentWhitelist) && !currentWhitelist.includes(id)) {
|
||||
const result = renderError({
|
||||
message: notWhitelistedMsg,
|
||||
secondaryMessage: "Please deploy your own instance",
|
||||
renderOptions: {
|
||||
...colors,
|
||||
show_repo_link: false,
|
||||
},
|
||||
});
|
||||
const result = res.send(
|
||||
renderError({
|
||||
message: notWhitelistedMsg,
|
||||
secondaryMessage: "Please deploy your own instance",
|
||||
renderOptions: {
|
||||
...colors,
|
||||
show_repo_link: false,
|
||||
},
|
||||
}),
|
||||
);
|
||||
return { isPassed: false, result };
|
||||
}
|
||||
|
||||
@@ -52,14 +50,16 @@ const guardAccess = ({ id, type, colors }) => {
|
||||
currentWhitelist === undefined &&
|
||||
blacklist.includes(id)
|
||||
) {
|
||||
const result = renderError({
|
||||
message: BLACKLISTED_MESSAGE,
|
||||
secondaryMessage: "Please deploy your own instance",
|
||||
renderOptions: {
|
||||
...colors,
|
||||
show_repo_link: false,
|
||||
},
|
||||
});
|
||||
const result = res.send(
|
||||
renderError({
|
||||
message: BLACKLISTED_MESSAGE,
|
||||
secondaryMessage: "Please deploy your own instance",
|
||||
renderOptions: {
|
||||
...colors,
|
||||
show_repo_link: false,
|
||||
},
|
||||
}),
|
||||
);
|
||||
return { isPassed: false, result };
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// @ts-check
|
||||
|
||||
import { clampValue } from "@stats-organization/github-readme-stats-core";
|
||||
import { clampValue } from "./ops.js";
|
||||
|
||||
const MIN = 60;
|
||||
const HOUR = 60 * MIN;
|
||||
@@ -106,7 +106,7 @@ const disableCaching = (res) => {
|
||||
* @param {number} cacheSeconds The cache seconds to set in the headers.
|
||||
*/
|
||||
const setCacheHeaders = (res, cacheSeconds) => {
|
||||
if (cacheSeconds < 1) {
|
||||
if (cacheSeconds < 1 || process.env.NODE_ENV === "development") {
|
||||
disableCaching(res);
|
||||
return;
|
||||
}
|
||||
@@ -128,7 +128,10 @@ const setErrorCacheHeaders = (res) => {
|
||||
const envCacheSeconds = process.env.CACHE_SECONDS
|
||||
? parseInt(process.env.CACHE_SECONDS, 10)
|
||||
: NaN;
|
||||
if (!isNaN(envCacheSeconds) && envCacheSeconds < 1) {
|
||||
if (
|
||||
(!isNaN(envCacheSeconds) && envCacheSeconds < 1) ||
|
||||
process.env.NODE_ENV === "development"
|
||||
) {
|
||||
disableCaching(res);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// @ts-check
|
||||
|
||||
import { themes } from "../themes/index.js";
|
||||
import { themes } from "../../themes/index.js";
|
||||
|
||||
/**
|
||||
* Checks if a string is a valid hex color.
|
||||
@@ -1,10 +1,14 @@
|
||||
export let pool = null;
|
||||
if (process.env.POSTGRES_URL) {
|
||||
const { Pool } = await import("pg");
|
||||
pool = new Pool({
|
||||
connectionString: process.env.POSTGRES_URL,
|
||||
});
|
||||
}
|
||||
/**
|
||||
* In the browser this has to be mocked to avoid runtime errors
|
||||
* @see apps/frontend/vite.config.ts
|
||||
*/
|
||||
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.
|
||||
|
||||
@@ -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
|
||||
|
||||
import wrap from "word-wrap";
|
||||
|
||||
import { encodeHTML } from "./html.js";
|
||||
import { splitWrappedText } from "./render.js";
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*
|
||||
* @param {string} text Text to split.
|
||||
* @param {number} width Available wrap width in px.
|
||||
* @param {number} fontSize Font size in px.
|
||||
* @param {number} width Line width in number of characters.
|
||||
* @param {number} maxLines Maximum number of lines.
|
||||
* @returns {string[]} Array of lines.
|
||||
*/
|
||||
const wrapTextMultiline = (text, width, fontSize, maxLines = 3) => {
|
||||
const wrapped = splitWrappedText(text, fontSize, width);
|
||||
const lines = wrapped
|
||||
.map((line) => encodeHTML(line.trim()))
|
||||
.slice(0, maxLines); // Only consider maxLines lines
|
||||
const wrapTextMultiline = (text, width = 59, maxLines = 3) => {
|
||||
const fullWidthComma = ",";
|
||||
const encoded = encodeHTML(text);
|
||||
const isChinese = encoded.includes(fullWidthComma);
|
||||
|
||||
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
|
||||
if (wrapped.length > maxLines) {
|
||||
@@ -1,31 +1,5 @@
|
||||
// @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 = {
|
||||
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"/>`,
|
||||
@@ -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";
|
||||
-6
@@ -171,7 +171,6 @@
|
||||
"Faust": "#c37240",
|
||||
"Fennel": "#fff3d7",
|
||||
"Filebench WML": "#F6B900",
|
||||
"FlatBuffers": "#ed284a",
|
||||
"Flix": "#d44a45",
|
||||
"Fluent": "#ffcc33",
|
||||
"Forth": "#341708",
|
||||
@@ -254,7 +253,6 @@
|
||||
"Hy": "#7790B2",
|
||||
"IDL": "#a3522f",
|
||||
"IGOR Pro": "#0000cc",
|
||||
"IL Assembly": "#512BD4",
|
||||
"INI": "#d1dbe0",
|
||||
"ISPC": "#2D68B1",
|
||||
"Idris": "#b30000",
|
||||
@@ -325,7 +323,6 @@
|
||||
"LigoLANG": "#0e74ff",
|
||||
"LilyPond": "#9ccc7c",
|
||||
"Liquid": "#67b8de",
|
||||
"Liquidsoap": "#990066",
|
||||
"Literate Agda": "#315665",
|
||||
"Literate CoffeeScript": "#244776",
|
||||
"Literate Haskell": "#5e5086",
|
||||
@@ -351,7 +348,6 @@
|
||||
"Mask": "#f97732",
|
||||
"Mathematical Programming System": "#0530ad",
|
||||
"Max": "#c4a79c",
|
||||
"MeTTa": "#6a5acd",
|
||||
"Mercury": "#ff2b2b",
|
||||
"Mermaid": "#ff3670",
|
||||
"Meson": "#007800",
|
||||
@@ -468,7 +464,6 @@
|
||||
"Quake": "#882233",
|
||||
"QuakeC": "#975777",
|
||||
"QuickBASIC": "#008080",
|
||||
"Quint": "#9d6ce5",
|
||||
"R": "#198CE7",
|
||||
"RAML": "#77d9fb",
|
||||
"RAScript": "#2C97FA",
|
||||
@@ -540,7 +535,6 @@
|
||||
"Snakemake": "#419179",
|
||||
"Solidity": "#AA6746",
|
||||
"SourcePawn": "#f69e1d",
|
||||
"SpiceDB Schema": "#a5318a",
|
||||
"Squirrel": "#800000",
|
||||
"Stan": "#b2011d",
|
||||
"Standard ML": "#dc566d",
|
||||
@@ -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 };
|
||||
@@ -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
|
||||
|
||||
import { getConfig } from "./config.js";
|
||||
import { getUserAccessByName } from "./database.js";
|
||||
import { CustomError } from "./error.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.
|
||||
*
|
||||
* @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 {string | null} pat Optional PAT override.
|
||||
* @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;
|
||||
if (pat) {
|
||||
PATs = [{ name: "user PAT from database", value: pat }];
|
||||
if (userPAT?.token) {
|
||||
PATs = [{ name: `USER_${username}`, value: userPAT.token }];
|
||||
} 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) {
|
||||
@@ -102,3 +110,4 @@ const retryer = async (fetcher, variables, pat = null) => {
|
||||
};
|
||||
|
||||
export { retryer };
|
||||
export default retryer;
|
||||
@@ -84,14 +84,13 @@ const calculatePrimaryLanguage = (files) => {
|
||||
* Fetch GitHub gist information by given username and ID.
|
||||
*
|
||||
* @param {string} id GitHub gist ID.
|
||||
* @param {string | null} pat Optional PAT override.
|
||||
* @returns {Promise<GistData>} Gist data.
|
||||
*/
|
||||
const fetchGist = async (id, pat = null) => {
|
||||
const fetchGist = async (id) => {
|
||||
if (!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) {
|
||||
throw new Error(res.data.errors[0].message);
|
||||
}
|
||||
@@ -76,7 +76,6 @@ const fetchRepo = async (
|
||||
include_prs_reviewed = false,
|
||||
include_issues_authored = false,
|
||||
include_issues_commented = false,
|
||||
pat = null,
|
||||
) => {
|
||||
let owner = username;
|
||||
if (reponame && reponame.includes("/")) {
|
||||
@@ -101,7 +100,7 @@ const fetchRepo = async (
|
||||
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;
|
||||
|
||||
@@ -125,7 +124,6 @@ const fetchRepo = async (
|
||||
include_prs_reviewed,
|
||||
include_issues_authored,
|
||||
include_issues_commented,
|
||||
pat,
|
||||
);
|
||||
return {
|
||||
...repoUserStats,
|
||||
@@ -150,7 +148,6 @@ const fetchRepo = async (
|
||||
include_prs_reviewed,
|
||||
include_issues_authored,
|
||||
include_issues_commented,
|
||||
pat,
|
||||
);
|
||||
return {
|
||||
...repoUserStats,
|
||||
@@ -4,7 +4,7 @@ import axios from "axios";
|
||||
import githubUsernameRegex from "github-username-regex";
|
||||
|
||||
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 { wrapTextMultiline } from "../common/fmt.js";
|
||||
import { request } from "../common/http.js";
|
||||
@@ -106,8 +106,7 @@ const fetcher = (variables, token) => {
|
||||
* @param {boolean} variables.includeDiscussions Include discussions.
|
||||
* @param {boolean} variables.includeDiscussionsAnswers Include discussions answers.
|
||||
* @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 | null} variables.pat PAT override or null.
|
||||
* @param {string[]} ownerAffiliations The owner affiliations to filter by. Default: OWNER.
|
||||
* @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.
|
||||
@@ -119,7 +118,6 @@ const statsFetcher = async ({
|
||||
includeDiscussionsAnswers,
|
||||
startTime,
|
||||
ownerAffiliations,
|
||||
pat,
|
||||
}) => {
|
||||
let stats;
|
||||
let hasNextPage = true;
|
||||
@@ -136,7 +134,7 @@ const statsFetcher = async ({
|
||||
startTime,
|
||||
ownerAffiliations,
|
||||
};
|
||||
let res = await retryer(fetcher, variables, pat);
|
||||
let res = await retryer(fetcher, username, variables);
|
||||
if (res.data.errors) {
|
||||
return res;
|
||||
}
|
||||
@@ -163,8 +161,8 @@ const statsFetcher = async ({
|
||||
);
|
||||
|
||||
hasNextPage =
|
||||
(getConfig().fetchMultiPageStars === "true" ||
|
||||
getConfig().fetchMultiPageStars > fetchedPages) &&
|
||||
(process.env.FETCH_MULTI_PAGE_STARS === "true" ||
|
||||
process.env.FETCH_MULTI_PAGE_STARS > fetchedPages) &&
|
||||
repoNodes.length === repoNodesWithStars.length &&
|
||||
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
|
||||
* #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)) {
|
||||
logger.log("Invalid username provided.");
|
||||
throw new Error("Invalid username provided.");
|
||||
@@ -217,20 +215,16 @@ const totalItemsFetcher = async (username, repo, owner, type, filter, pat) => {
|
||||
|
||||
let res;
|
||||
try {
|
||||
res = await retryer(
|
||||
fetchTotalItems,
|
||||
{
|
||||
login: username,
|
||||
repo,
|
||||
owner,
|
||||
type,
|
||||
filter,
|
||||
},
|
||||
pat,
|
||||
);
|
||||
res = await retryer(fetchTotalItems, username, {
|
||||
login: username,
|
||||
repo,
|
||||
owner,
|
||||
type,
|
||||
filter,
|
||||
});
|
||||
} catch (err) {
|
||||
logger.log(err);
|
||||
throw err;
|
||||
throw new Error(err);
|
||||
}
|
||||
|
||||
const totalCount = res.data.total_count;
|
||||
@@ -253,7 +247,6 @@ const fetchRepoUserStats = async (
|
||||
include_prs_reviewed,
|
||||
include_issues_authored,
|
||||
include_issues_commented,
|
||||
pat,
|
||||
) => {
|
||||
let stats = {};
|
||||
if (include_prs_authored) {
|
||||
@@ -263,7 +256,6 @@ const fetchRepoUserStats = async (
|
||||
owner,
|
||||
"issues",
|
||||
`author:${username}+type:pr`,
|
||||
pat,
|
||||
);
|
||||
}
|
||||
if (include_prs_commented) {
|
||||
@@ -273,7 +265,6 @@ const fetchRepoUserStats = async (
|
||||
owner,
|
||||
"issues",
|
||||
`commenter:${username}+-author:${username}+type:pr`,
|
||||
pat,
|
||||
);
|
||||
}
|
||||
if (include_prs_reviewed) {
|
||||
@@ -283,7 +274,6 @@ const fetchRepoUserStats = async (
|
||||
owner,
|
||||
"issues",
|
||||
`reviewed-by:${username}+-author:${username}+type:pr`,
|
||||
pat,
|
||||
);
|
||||
}
|
||||
if (include_issues_authored) {
|
||||
@@ -293,7 +283,6 @@ const fetchRepoUserStats = async (
|
||||
owner,
|
||||
"issues",
|
||||
`author:${username}+type:issue`,
|
||||
pat,
|
||||
);
|
||||
}
|
||||
if (include_issues_commented) {
|
||||
@@ -303,7 +292,6 @@ const fetchRepoUserStats = async (
|
||||
owner,
|
||||
"issues",
|
||||
`commenter:${username}+-author:${username}+type:issue`,
|
||||
pat,
|
||||
);
|
||||
}
|
||||
return stats;
|
||||
@@ -338,7 +326,6 @@ const fetchStats = async (
|
||||
include_issues_authored = false,
|
||||
include_issues_commented = false,
|
||||
ownerAffiliations = [],
|
||||
pat = null,
|
||||
) => {
|
||||
if (!username) {
|
||||
throw new MissingParamError(["username"]);
|
||||
@@ -365,17 +352,14 @@ const fetchStats = async (
|
||||
};
|
||||
ownerAffiliations = parseOwnerAffiliations(ownerAffiliations);
|
||||
|
||||
let res = await statsFetcher(
|
||||
{
|
||||
username,
|
||||
includeMergedPullRequests: include_merged_pull_requests,
|
||||
includeDiscussions: include_discussions,
|
||||
includeDiscussionsAnswers: include_discussions_answers,
|
||||
startTime: commits_year ? `${commits_year}-01-01T00:00:00Z` : undefined,
|
||||
ownerAffiliations,
|
||||
},
|
||||
pat,
|
||||
);
|
||||
let res = await statsFetcher({
|
||||
username,
|
||||
includeMergedPullRequests: include_merged_pull_requests,
|
||||
includeDiscussions: include_discussions,
|
||||
includeDiscussionsAnswers: include_discussions_answers,
|
||||
startTime: commits_year ? `${commits_year}-01-01T00:00:00Z` : undefined,
|
||||
ownerAffiliations,
|
||||
});
|
||||
|
||||
// Catch GraphQL errors.
|
||||
if (res.data.errors) {
|
||||
@@ -388,7 +372,7 @@ const fetchStats = async (
|
||||
}
|
||||
if (res.data.errors[0].message) {
|
||||
throw new CustomError(
|
||||
wrapTextMultiline(res.data.errors[0].message, 525, 12)[0],
|
||||
wrapTextMultiline(res.data.errors[0].message, 90, 1)[0],
|
||||
res.statusText,
|
||||
);
|
||||
}
|
||||
@@ -410,7 +394,6 @@ const fetchStats = async (
|
||||
owner,
|
||||
"commits",
|
||||
`author:${username}`,
|
||||
pat,
|
||||
);
|
||||
} else {
|
||||
stats.totalCommits = user.commits.totalCommitContributions;
|
||||
@@ -424,7 +407,6 @@ const fetchStats = async (
|
||||
include_prs_reviewed,
|
||||
include_issues_authored,
|
||||
include_issues_commented,
|
||||
pat,
|
||||
);
|
||||
Object.assign(stats, repoUserStats);
|
||||
|
||||
@@ -447,10 +429,7 @@ const fetchStats = async (
|
||||
stats.contributedTo = user.repositoriesContributedTo.totalCount;
|
||||
|
||||
// Retrieve stars while filtering out repositories to be hidden.
|
||||
const allExcludedRepos = [
|
||||
...exclude_repo,
|
||||
...getConfig().excludeRepositories,
|
||||
];
|
||||
const allExcludedRepos = [...exclude_repo, ...excludeRepositories];
|
||||
let repoToHide = new Set(allExcludedRepos);
|
||||
|
||||
stats.totalStars = user.repositories.nodes
|
||||
+7
-16
@@ -1,6 +1,6 @@
|
||||
// @ts-check
|
||||
|
||||
import { getConfig } from "../common/config.js";
|
||||
import { excludeRepositories } from "../common/envs.js";
|
||||
import { CustomError, MissingParamError } from "../common/error.js";
|
||||
import { wrapTextMultiline } from "../common/fmt.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} count_weight Weightage to be given to count.
|
||||
* @param {string[]} ownerAffiliations The owner affiliations to filter by. Default: OWNER.
|
||||
* @param {string|null} pat Optional PAT override.
|
||||
* @returns {Promise<TopLangData>} Top languages data.
|
||||
*/
|
||||
const fetchTopLanguages = async (
|
||||
@@ -68,21 +67,16 @@ const fetchTopLanguages = async (
|
||||
size_weight = 1,
|
||||
count_weight = 0,
|
||||
ownerAffiliations = [],
|
||||
pat = null,
|
||||
) => {
|
||||
if (!username) {
|
||||
throw new MissingParamError(["username"]);
|
||||
}
|
||||
ownerAffiliations = parseOwnerAffiliations(ownerAffiliations);
|
||||
|
||||
const res = await retryer(
|
||||
fetcher,
|
||||
{
|
||||
login: username,
|
||||
ownerAffiliations,
|
||||
},
|
||||
pat,
|
||||
);
|
||||
const res = await retryer(fetcher, username, {
|
||||
login: username,
|
||||
ownerAffiliations,
|
||||
});
|
||||
|
||||
if (res.data.errors) {
|
||||
logger.error(res.data.errors);
|
||||
@@ -94,7 +88,7 @@ const fetchTopLanguages = async (
|
||||
}
|
||||
if (res.data.errors[0].message) {
|
||||
throw new CustomError(
|
||||
wrapTextMultiline(res.data.errors[0].message, 525, 12)[0],
|
||||
wrapTextMultiline(res.data.errors[0].message, 90, 1)[0],
|
||||
res.statusText,
|
||||
);
|
||||
}
|
||||
@@ -107,10 +101,7 @@ const fetchTopLanguages = async (
|
||||
let repoNodes = res.data.data.user.repositories.nodes;
|
||||
/** @type {Record<string, boolean>} */
|
||||
let repoToHide = {};
|
||||
const allExcludedRepos = [
|
||||
...exclude_repo,
|
||||
...getConfig().excludeRepositories,
|
||||
];
|
||||
const allExcludedRepos = [...exclude_repo, ...excludeRepositories];
|
||||
|
||||
// populate repoToHide map for quick lookup
|
||||
// while filtering out
|
||||
+20
-20
@@ -1,13 +1,13 @@
|
||||
export interface GistData {
|
||||
export type GistData = {
|
||||
name: string;
|
||||
nameWithOwner: string;
|
||||
description: string | null;
|
||||
language: string | null;
|
||||
starsCount: number;
|
||||
forksCount: number;
|
||||
}
|
||||
};
|
||||
|
||||
export interface RepositoryData {
|
||||
export type RepositoryData = {
|
||||
name: string;
|
||||
nameWithOwner: string;
|
||||
isPrivate: boolean;
|
||||
@@ -27,9 +27,9 @@ export interface RepositoryData {
|
||||
totalPRsReviewed: number;
|
||||
totalIssuesAuthored: number;
|
||||
totalIssuesCommented: number;
|
||||
}
|
||||
};
|
||||
|
||||
export interface StatsData {
|
||||
export type StatsData = {
|
||||
name: string;
|
||||
totalPRs: number;
|
||||
totalPRsMerged: number;
|
||||
@@ -47,18 +47,18 @@ export interface StatsData {
|
||||
totalIssuesAuthored: number;
|
||||
totalIssuesCommented: number;
|
||||
rank: { level: string; percentile: number };
|
||||
}
|
||||
};
|
||||
|
||||
export interface Lang {
|
||||
export type Lang = {
|
||||
name: string;
|
||||
color: string;
|
||||
size: number;
|
||||
}
|
||||
};
|
||||
|
||||
export type TopLangData = Record<string, Lang>;
|
||||
|
||||
export interface WakaTimeData {
|
||||
categories: Array<{
|
||||
export type WakaTimeData = {
|
||||
categories: {
|
||||
digital: string;
|
||||
hours: number;
|
||||
minutes: number;
|
||||
@@ -66,12 +66,12 @@ export interface WakaTimeData {
|
||||
percent: number;
|
||||
text: string;
|
||||
total_seconds: number;
|
||||
}>;
|
||||
}[];
|
||||
daily_average: number;
|
||||
daily_average_including_other_language: number;
|
||||
days_including_holidays: number;
|
||||
days_minus_holidays: number;
|
||||
editors: Array<{
|
||||
editors: {
|
||||
digital: string;
|
||||
hours: number;
|
||||
minutes: number;
|
||||
@@ -79,7 +79,7 @@ export interface WakaTimeData {
|
||||
percent: number;
|
||||
text: string;
|
||||
total_seconds: number;
|
||||
}>;
|
||||
}[];
|
||||
holidays: number;
|
||||
human_readable_daily_average: string;
|
||||
human_readable_daily_average_including_other_language: string;
|
||||
@@ -92,7 +92,7 @@ export interface WakaTimeData {
|
||||
is_other_usage_visible: boolean;
|
||||
is_stuck: boolean;
|
||||
is_up_to_date: boolean;
|
||||
languages: Array<{
|
||||
languages: {
|
||||
digital: string;
|
||||
hours: number;
|
||||
minutes: number;
|
||||
@@ -100,8 +100,8 @@ export interface WakaTimeData {
|
||||
percent: number;
|
||||
text: string;
|
||||
total_seconds: number;
|
||||
}>;
|
||||
operating_systems: Array<{
|
||||
}[];
|
||||
operating_systems: {
|
||||
digital: string;
|
||||
hours: number;
|
||||
minutes: number;
|
||||
@@ -109,7 +109,7 @@ export interface WakaTimeData {
|
||||
percent: number;
|
||||
text: string;
|
||||
total_seconds: number;
|
||||
}>;
|
||||
}[];
|
||||
percent_calculated: number;
|
||||
range: string;
|
||||
status: string;
|
||||
@@ -119,10 +119,10 @@ export interface WakaTimeData {
|
||||
user_id: string;
|
||||
username: string;
|
||||
writes_only: boolean;
|
||||
}
|
||||
};
|
||||
|
||||
export interface WakaTimeLang {
|
||||
export type WakaTimeLang = {
|
||||
name: string;
|
||||
text: string;
|
||||
percent: number;
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from "./common/index.js";
|
||||
export * from "./cards/index.js";
|
||||
@@ -80,7 +80,6 @@ async function githubAuthenticate(code, privateAccess) {
|
||||
return { userId, accessToken, needDowngrade };
|
||||
} catch (err) {
|
||||
if (err.response) {
|
||||
// eslint-disable-next-line preserve-caught-error
|
||||
throw new Error(`OAuth Error: ${err.response.status}`);
|
||||
}
|
||||
throw err;
|
||||
|
||||
-72
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -1,181 +1,345 @@
|
||||
// @ts-check
|
||||
|
||||
import { api, getConfig } from "@stats-organization/github-readme-stats-core";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import axios from "axios";
|
||||
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 { getUserAccessByName, storeRequest } from "../src/common/database.js";
|
||||
import { renderError } from "../src/common/render.js";
|
||||
|
||||
vi.mock(import("@stats-organization/github-readme-stats-core"), async () => {
|
||||
const { mockCore } = await import("./utils.js");
|
||||
return mockCore();
|
||||
import { data_stats, stats } from "./test-data/api-data.js";
|
||||
|
||||
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) => ({
|
||||
...(await importOriginal()),
|
||||
storeRequest: vi.fn(),
|
||||
getUserAccessByName: vi.fn(),
|
||||
}));
|
||||
const error = {
|
||||
errors: [
|
||||
{
|
||||
type: "NOT_FOUND",
|
||||
path: ["user"],
|
||||
locations: [],
|
||||
message: "Could not fetch user",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const apiMock = vi.mocked(api);
|
||||
const getConfigMock = vi.mocked(getConfig);
|
||||
const storeRequestMock = vi.mocked(storeRequest);
|
||||
const getUserAccessByNameMock = vi.mocked(getUserAccessByName);
|
||||
const mock = new MockAdapter(axios);
|
||||
|
||||
const createRequest = (search = "") => ({
|
||||
headers: {},
|
||||
url: `/api?${search}`,
|
||||
});
|
||||
// @ts-ignore
|
||||
const faker = (query, data) => {
|
||||
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 = () => ({
|
||||
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}`;
|
||||
return { req, res };
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
apiMock.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;
|
||||
process.env.CACHE_SECONDS = undefined;
|
||||
});
|
||||
|
||||
describe("Test /api backend routing", () => {
|
||||
it("happy path should pass query params and user PAT, respond with stats content and persist request", async () => {
|
||||
getUserAccessByNameMock.mockResolvedValue({ token: "user-pat" });
|
||||
apiMock.mockResolvedValue({
|
||||
status: "success",
|
||||
content: "mock-stats-svg",
|
||||
});
|
||||
afterEach(() => {
|
||||
mock.reset();
|
||||
});
|
||||
|
||||
const req = createRequest(
|
||||
"username=anuraghazra&theme=dark&hide=issues,prs,contribs",
|
||||
describe("Test /api/", () => {
|
||||
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");
|
||||
expect(apiMock).toHaveBeenCalledWith(
|
||||
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",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
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",
|
||||
theme: "dark",
|
||||
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 () => {
|
||||
apiMock.mockResolvedValue({
|
||||
status: "error - temporary",
|
||||
content: "temporary-error-svg",
|
||||
});
|
||||
it("should have proper cache", async () => {
|
||||
const { req, res } = faker({}, data_stats);
|
||||
|
||||
const req = createRequest("username=anuraghazra");
|
||||
const res = createResponse();
|
||||
await api(req, res);
|
||||
|
||||
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");
|
||||
expect(apiMock).toHaveBeenCalledWith(
|
||||
it("should set proper cache", async () => {
|
||||
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",
|
||||
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 () => {
|
||||
apiMock.mockResolvedValue({
|
||||
status: "error - permanent",
|
||||
content: "permanent-error-svg",
|
||||
});
|
||||
it("should render error card when wrong locale is provided", async () => {
|
||||
const { req, res } = faker({ locale: "asdf" }, data_stats);
|
||||
|
||||
const req = createRequest("username=anuraghazra");
|
||||
const res = createResponse();
|
||||
await api(req, res);
|
||||
|
||||
await router(req, res);
|
||||
|
||||
expect(getUserAccessByNameMock).toHaveBeenCalledWith("anuraghazra");
|
||||
expect(apiMock).toHaveBeenCalledWith(
|
||||
{
|
||||
username: "anuraghazra",
|
||||
},
|
||||
null,
|
||||
expect(res.setHeader).toHaveBeenCalledWith("Content-Type", "image/svg+xml");
|
||||
expect(res.send).toHaveBeenCalledWith(
|
||||
renderError({
|
||||
message: "Something went wrong",
|
||||
secondaryMessage: "Language not found",
|
||||
}),
|
||||
);
|
||||
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 () => {
|
||||
const req = createRequest("username=renovate-bot");
|
||||
const res = createResponse();
|
||||
it("should render error card when include_all_commits true and upstream API fails", async () => {
|
||||
mock
|
||||
.onGet(
|
||||
"https://api.github.com/search/commits?per_page=1&q=author:anuraghazra",
|
||||
)
|
||||
.reply(200, { error: "Some test error message" });
|
||||
|
||||
await router(req, res);
|
||||
|
||||
expect(apiMock).not.toHaveBeenCalled();
|
||||
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",
|
||||
const { req, res } = faker(
|
||||
{ username: "anuraghazra", include_all_commits: true },
|
||||
data_stats,
|
||||
);
|
||||
expect(storeRequestMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should reject non-whitelisted usernames before calling core logic", async () => {
|
||||
getConfigMock.mockReturnValue({ whitelist: ["allowed-user"] });
|
||||
await api(req, res);
|
||||
|
||||
const req = createRequest("username=blocked-user");
|
||||
const res = createResponse();
|
||||
|
||||
await router(req, res);
|
||||
|
||||
expect(apiMock).not.toHaveBeenCalled();
|
||||
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 not whitelisted",
|
||||
expect(res.setHeader).toHaveBeenCalledWith("Content-Type", "image/svg+xml");
|
||||
expect(res.send).toHaveBeenCalledWith(
|
||||
renderError({
|
||||
message: "Could not fetch data from GitHub REST API.",
|
||||
secondaryMessage: "Please try again later",
|
||||
}),
|
||||
);
|
||||
// Received SVG output should not contain string "https://tiny.one/readme-stats"
|
||||
expect(res.send.mock.calls[0][0]).not.toContain(
|
||||
"https://tiny.one/readme-stats",
|
||||
);
|
||||
expect(storeRequestMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,40 +1,81 @@
|
||||
import axios from "axios";
|
||||
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 createResponse = () => ({
|
||||
end: vi.fn(),
|
||||
setHeader: vi.fn(),
|
||||
});
|
||||
const faker = (query, data) => {
|
||||
const req = {
|
||||
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 () => {
|
||||
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", () => {
|
||||
describe("/api", () => {
|
||||
bench(
|
||||
"base",
|
||||
async () => {
|
||||
const req = {
|
||||
headers: {},
|
||||
url: "/api?username=anuraghazra",
|
||||
};
|
||||
const res = createResponse();
|
||||
const { req, res } = faker({}, data_stats);
|
||||
|
||||
await router(req, res);
|
||||
await api(req, res);
|
||||
},
|
||||
{ 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 },
|
||||
);
|
||||
});
|
||||
@@ -1,42 +1,54 @@
|
||||
import axios from "axios";
|
||||
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);
|
||||
mock.onPost("https://api.github.com/graphql").reply(200, gist_data);
|
||||
|
||||
const createResponse = () => ({
|
||||
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", () => {
|
||||
describe("test /api/gist", () => {
|
||||
bench(
|
||||
"base",
|
||||
async () => {
|
||||
const req = {
|
||||
headers: {},
|
||||
url: "/api/gist?id=happy-gist-id",
|
||||
query: {
|
||||
id: "bbfce31e0217a3689c8d961a356cb10d",
|
||||
},
|
||||
};
|
||||
const res = {
|
||||
setHeader: vi.fn(),
|
||||
send: vi.fn(),
|
||||
};
|
||||
const res = createResponse();
|
||||
|
||||
await router(req, res);
|
||||
await gist(req, res);
|
||||
},
|
||||
{ warmupIterations: 50 },
|
||||
);
|
||||
|
||||
@@ -1,40 +1,53 @@
|
||||
import axios from "axios";
|
||||
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);
|
||||
mock.onPost("https://api.github.com/graphql").reply(200, data_user);
|
||||
|
||||
const createResponse = () => ({
|
||||
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", () => {
|
||||
describe("/api/pin", () => {
|
||||
bench(
|
||||
"base",
|
||||
async () => {
|
||||
const req = {
|
||||
headers: {},
|
||||
url: "/api/pin?username=anuraghazra&repo=convoychat",
|
||||
query: {
|
||||
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 },
|
||||
);
|
||||
|
||||
@@ -4,6 +4,8 @@ import { calculateRank } from "../src/calculateRank.js";
|
||||
|
||||
import { approxNumber } from "./utils.js";
|
||||
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
|
||||
describe("Test calculateRank", () => {
|
||||
it("new user gets C rank", () => {
|
||||
expect(
|
||||
@@ -6,6 +6,8 @@ import { Card } from "../src/common/Card.js";
|
||||
import { getCardColors } from "../src/common/color.js";
|
||||
import { icons } from "../src/common/icons.js";
|
||||
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
|
||||
describe("Card", () => {
|
||||
it("should hide border", () => {
|
||||
const card = new Card({});
|
||||
+123
-245
@@ -3,343 +3,221 @@
|
||||
*/
|
||||
|
||||
import axios from "axios";
|
||||
import MockAdapter from "axios-mock-adapter";
|
||||
import { afterAll, beforeAll, describe, expect, test, vi } from "vitest";
|
||||
import { beforeAll, describe, expect, test } 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 USER = "catelinemnemosyne";
|
||||
const STATS_CARD_USER = "e2eninja";
|
||||
const GIST_ID = "372cef55fd897b31909fdeb3a7262758";
|
||||
|
||||
const STATS_MOCK_RESPONSE = {
|
||||
data: {
|
||||
user: {
|
||||
name: "CodeNinja",
|
||||
login: STATS_CARD_USER,
|
||||
repositoriesContributedTo: { totalCount: 0 },
|
||||
commits: {
|
||||
totalCommitContributions: 3,
|
||||
},
|
||||
reviews: {
|
||||
totalPullRequestReviewContributions: 0,
|
||||
},
|
||||
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 STATS_DATA = {
|
||||
name: "CodeNinja",
|
||||
totalPRs: 1,
|
||||
totalReviews: 0,
|
||||
totalCommits: 3,
|
||||
totalIssues: 1,
|
||||
totalStars: 1,
|
||||
contributedTo: 0,
|
||||
rank: {
|
||||
level: "C",
|
||||
percentile: 98.73972605284538,
|
||||
},
|
||||
};
|
||||
|
||||
const COMMITS_SEARCH_MOCK_RESPONSE = {
|
||||
total_count: 3,
|
||||
};
|
||||
|
||||
const TOP_LANGS_MOCK_RESPONSE = {
|
||||
data: {
|
||||
user: {
|
||||
repositories: {
|
||||
nodes: [
|
||||
{
|
||||
name: REPO,
|
||||
languages: {
|
||||
edges: [
|
||||
{
|
||||
size: 1721,
|
||||
node: {
|
||||
color: "#e34c26",
|
||||
name: "HTML",
|
||||
},
|
||||
},
|
||||
{
|
||||
size: 930,
|
||||
node: {
|
||||
color: "#663399",
|
||||
name: "CSS",
|
||||
},
|
||||
},
|
||||
{
|
||||
size: 1912,
|
||||
node: {
|
||||
color: "#f1e05a",
|
||||
name: "JavaScript",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
const LANGS_DATA = {
|
||||
HTML: {
|
||||
color: "#e34c26",
|
||||
name: "HTML",
|
||||
size: 1721,
|
||||
},
|
||||
CSS: {
|
||||
color: "#663399",
|
||||
name: "CSS",
|
||||
size: 930,
|
||||
},
|
||||
JavaScript: {
|
||||
color: "#f1e05a",
|
||||
name: "JavaScript",
|
||||
size: 1912,
|
||||
},
|
||||
};
|
||||
|
||||
const WAKATIME_MOCK_RESPONSE = {
|
||||
data: {
|
||||
human_readable_range: "last week",
|
||||
is_already_updating: false,
|
||||
is_coding_activity_visible: true,
|
||||
is_including_today: false,
|
||||
is_other_usage_visible: false,
|
||||
is_stuck: false,
|
||||
is_up_to_date: false,
|
||||
is_up_to_date_pending_future: false,
|
||||
percent_calculated: 0,
|
||||
range: "all_time",
|
||||
status: "pending_update",
|
||||
timeout: 15,
|
||||
username: USER,
|
||||
writes_only: false,
|
||||
},
|
||||
const WAKATIME_DATA = {
|
||||
human_readable_range: "last week",
|
||||
is_already_updating: false,
|
||||
is_coding_activity_visible: true,
|
||||
is_including_today: false,
|
||||
is_other_usage_visible: false,
|
||||
is_stuck: false,
|
||||
is_up_to_date: false,
|
||||
is_up_to_date_pending_future: false,
|
||||
percent_calculated: 0,
|
||||
range: "all_time",
|
||||
status: "pending_update",
|
||||
timeout: 15,
|
||||
username: USER,
|
||||
writes_only: false,
|
||||
};
|
||||
|
||||
const REPO_MOCK_RESPONSE = {
|
||||
data: {
|
||||
user: {
|
||||
repository: {
|
||||
name: REPO,
|
||||
nameWithOwner: `${USER}/cra-test`,
|
||||
isPrivate: false,
|
||||
isArchived: false,
|
||||
isTemplate: false,
|
||||
stargazers: {
|
||||
totalCount: 1,
|
||||
},
|
||||
description: "Simple cra test repo.",
|
||||
primaryLanguage: {
|
||||
color: "#f1e05a",
|
||||
id: "MDg6TGFuZ3VhZ2UxNDA=",
|
||||
name: "JavaScript",
|
||||
},
|
||||
forkCount: 0,
|
||||
},
|
||||
},
|
||||
organization: null,
|
||||
const REPOSITORY_DATA = {
|
||||
name: REPO,
|
||||
nameWithOwner: `${USER}/cra-test`,
|
||||
isPrivate: false,
|
||||
isArchived: false,
|
||||
isTemplate: false,
|
||||
stargazers: {
|
||||
totalCount: 1,
|
||||
},
|
||||
description: "Simple cra test repo.",
|
||||
primaryLanguage: {
|
||||
color: "#f1e05a",
|
||||
id: "MDg6TGFuZ3VhZ2UxNDA=",
|
||||
name: "JavaScript",
|
||||
},
|
||||
forkCount: 0,
|
||||
starCount: 1,
|
||||
};
|
||||
|
||||
const GIST_MOCK_RESPONSE = {
|
||||
data: {
|
||||
viewer: {
|
||||
gist: {
|
||||
description:
|
||||
"Trying to access this path on Windows 10 ver. 1803+ will breaks NTFS",
|
||||
owner: {
|
||||
login: "qwerty541",
|
||||
},
|
||||
stargazerCount: 1,
|
||||
forks: {
|
||||
totalCount: 0,
|
||||
},
|
||||
files: [
|
||||
{
|
||||
name: "link.txt",
|
||||
language: {
|
||||
name: "Text",
|
||||
},
|
||||
size: 1,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
/**
|
||||
* @typedef {import("../../src/fetchers/types").GistData} GistData Gist data type.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @type {GistData}
|
||||
*/
|
||||
const GIST_DATA = {
|
||||
name: "link.txt",
|
||||
nameWithOwner: "qwerty541/link.txt",
|
||||
description:
|
||||
"Trying to access this path on Windows 10 ver. 1803+ will breaks NTFS",
|
||||
language: "Text",
|
||||
starsCount: 1,
|
||||
forksCount: 0,
|
||||
};
|
||||
|
||||
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", () => {
|
||||
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 () => {
|
||||
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.
|
||||
await expect(
|
||||
axios.get(`${VERCEL_PREVIEW_URL}${cardPath}`),
|
||||
axios.get(`${VERCEL_PREVIEW_URL}/api?username=${STATS_CARD_USER}`),
|
||||
).resolves.not.toThrow();
|
||||
|
||||
// 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.
|
||||
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.
|
||||
expect(serverStatsSvg.data).toEqual(localStatsCardSVG);
|
||||
}, 20000);
|
||||
}, 15000);
|
||||
|
||||
test("retrieve language card", async () => {
|
||||
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.
|
||||
console.log(
|
||||
`${VERCEL_PREVIEW_URL}/api/top-langs/?username=${USER}&${CACHE_BURST_STRING}`,
|
||||
);
|
||||
await expect(
|
||||
axios.get(`${VERCEL_PREVIEW_URL}${cardPath}`),
|
||||
axios.get(
|
||||
`${VERCEL_PREVIEW_URL}/api/top-langs/?username=${USER}&${CACHE_BURST_STRING}`,
|
||||
),
|
||||
).resolves.not.toThrow();
|
||||
|
||||
// Get local language card.
|
||||
const localLanguageCardSVG = await getLocalSvg(cardPath);
|
||||
const localLanguageCardSVG = renderTopLanguages(LANGS_DATA);
|
||||
|
||||
// Get the Vercel preview language card response.
|
||||
const serverLanguageSVG = await axios.get(
|
||||
`${VERCEL_PREVIEW_URL}${cardPath}`,
|
||||
const severLanguageSVG = await axios.get(
|
||||
`${VERCEL_PREVIEW_URL}/api/top-langs/?username=${USER}&${CACHE_BURST_STRING}`,
|
||||
);
|
||||
|
||||
// Check if language card from deployment matches the local language card.
|
||||
expect(serverLanguageSVG.data).toEqual(localLanguageCardSVG);
|
||||
}, 20000);
|
||||
expect(severLanguageSVG.data).toEqual(localLanguageCardSVG);
|
||||
}, 15000);
|
||||
|
||||
test("retrieve WakaTime card", async () => {
|
||||
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.
|
||||
await expect(
|
||||
axios.get(`${VERCEL_PREVIEW_URL}${cardPath}`),
|
||||
axios.get(`${VERCEL_PREVIEW_URL}/api/wakatime?username=${USER}`),
|
||||
).resolves.not.toThrow();
|
||||
|
||||
// Get local WakaTime card.
|
||||
const localWakaCardSVG = await getLocalSvg(cardPath);
|
||||
const localWakaCardSVG = renderWakatimeCard(WAKATIME_DATA);
|
||||
|
||||
// Get the Vercel preview WakaTime card response.
|
||||
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.
|
||||
expect(serverWakaTimeSvg.data).toEqual(localWakaCardSVG);
|
||||
}, 20000);
|
||||
}, 15000);
|
||||
|
||||
test("retrieve repo card", async () => {
|
||||
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.
|
||||
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();
|
||||
|
||||
// Get local repo card.
|
||||
const localRepoCardSVG = await getLocalSvg(cardPath);
|
||||
const localRepoCardSVG = renderRepoCard(REPOSITORY_DATA);
|
||||
|
||||
// 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.
|
||||
expect(serverRepoSvg.data).toEqual(localRepoCardSVG);
|
||||
}, 20000);
|
||||
}, 15000);
|
||||
|
||||
test("retrieve gist card", async () => {
|
||||
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.
|
||||
await expect(
|
||||
axios.get(`${VERCEL_PREVIEW_URL}${cardPath}`),
|
||||
axios.get(
|
||||
`${VERCEL_PREVIEW_URL}/api/gist?id=${GIST_ID}&${CACHE_BURST_STRING}`,
|
||||
),
|
||||
).resolves.not.toThrow();
|
||||
|
||||
// Get local gist card.
|
||||
const localGistCardSVG = await getLocalSvg(cardPath);
|
||||
const localGistCardSVG = renderGistCard(GIST_DATA);
|
||||
|
||||
// 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.
|
||||
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 "@testing-library/jest-dom/vitest";
|
||||
|
||||
const gist_data = {
|
||||
data: {
|
||||
viewer: {
|
||||
@@ -4,6 +4,8 @@ import { afterEach, describe, expect, it } from "vitest";
|
||||
|
||||
import { fetchRepo } from "../src/fetchers/repo.js";
|
||||
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
|
||||
const data_repo = {
|
||||
repository: {
|
||||
name: "convoychat",
|
||||
@@ -1,15 +1,11 @@
|
||||
import axios from "axios";
|
||||
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 { loadConfigFromEnv } from "../src/common/config.js";
|
||||
import { fetchStats } from "../src/fetchers/stats.js";
|
||||
|
||||
vi.mock(import("../src/common/log.js"), async () => {
|
||||
const { createLoggerMock } = await import("./utils.js");
|
||||
return createLoggerMock();
|
||||
});
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
|
||||
// Test parameters.
|
||||
const data_stats = {
|
||||
@@ -111,7 +107,6 @@ const mock = new MockAdapter(axios);
|
||||
|
||||
beforeEach(() => {
|
||||
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) => {
|
||||
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 () => {
|
||||
process.env.FETCH_MULTI_PAGE_STARS = true;
|
||||
loadConfigFromEnv();
|
||||
|
||||
let stats = await fetchStats("anuraghazra");
|
||||
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 () => {
|
||||
process.env.FETCH_MULTI_PAGE_STARS = "false";
|
||||
loadConfigFromEnv();
|
||||
|
||||
let stats = await fetchStats("anuraghazra");
|
||||
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 () => {
|
||||
process.env.FETCH_MULTI_PAGE_STARS = undefined;
|
||||
loadConfigFromEnv();
|
||||
|
||||
let stats = await fetchStats("anuraghazra");
|
||||
const rank = calculateRank({
|
||||
@@ -539,6 +531,7 @@ describe("Test fetchStats", () => {
|
||||
});
|
||||
|
||||
it("should return correct data when user don't have any pull requests", async () => {
|
||||
mock.reset();
|
||||
mock
|
||||
.onPost("https://api.github.com/graphql")
|
||||
.reply(200, data_without_pull_requests);
|
||||
+2
-16
@@ -1,25 +1,17 @@
|
||||
import axios from "axios";
|
||||
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 { approxNumber } from "./utils.js";
|
||||
|
||||
vi.mock(import("../src/common/log.js"), async () => {
|
||||
const { createLoggerMock } = await import("./utils.js");
|
||||
return createLoggerMock();
|
||||
});
|
||||
|
||||
const { logger } = await import("../src/index.js");
|
||||
|
||||
const loggerErrorSpy = vi.mocked(logger.error);
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
|
||||
const mock = new MockAdapter(axios);
|
||||
|
||||
afterEach(() => {
|
||||
mock.reset();
|
||||
loggerErrorSpy.mockClear();
|
||||
});
|
||||
|
||||
const data_langs = {
|
||||
@@ -159,8 +151,6 @@ describe("FetchTopLanguages", () => {
|
||||
await expect(fetchTopLanguages("anuraghazra")).rejects.toThrow(
|
||||
"Could not resolve to a User with the login of 'noname'.",
|
||||
);
|
||||
|
||||
expect(loggerErrorSpy).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("should throw other errors with their message", async () => {
|
||||
@@ -171,8 +161,6 @@ describe("FetchTopLanguages", () => {
|
||||
await expect(fetchTopLanguages("anuraghazra")).rejects.toThrow(
|
||||
"Some test GraphQL error",
|
||||
);
|
||||
|
||||
expect(loggerErrorSpy).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
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(
|
||||
"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 "@testing-library/jest-dom/vitest";
|
||||
|
||||
const mock = new MockAdapter(axios);
|
||||
|
||||
afterEach(() => {
|
||||
@@ -72,12 +72,7 @@ describe("Test fmt.js", () => {
|
||||
|
||||
it("wrapTextMultiline: should not wrap small texts", () => {
|
||||
{
|
||||
let multiLineText = wrapTextMultiline(
|
||||
"Small text should not wrap",
|
||||
130,
|
||||
11,
|
||||
3,
|
||||
);
|
||||
let multiLineText = wrapTextMultiline("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", () => {
|
||||
let multiLineText = wrapTextMultiline(
|
||||
"Hello world long long long text",
|
||||
130,
|
||||
11,
|
||||
20,
|
||||
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", () => {
|
||||
let multiLineText = wrapTextMultiline(
|
||||
"Hello world long long long text",
|
||||
53,
|
||||
11,
|
||||
10,
|
||||
2,
|
||||
);
|
||||
expect(multiLineText).toEqual(["Hello", "world long..."]);
|
||||
});
|
||||
|
||||
it("wrapTextMultiline: should handle chinese characters", () => {
|
||||
it("wrapTextMultiline: should wrap chinese by punctuation", () => {
|
||||
let multiLineText = wrapTextMultiline(
|
||||
"专门为刚开始刷题的同学准备的算法基地,没有最细只有更细,立志用动画将晦涩难懂的算法说的通俗易懂!",
|
||||
130,
|
||||
11,
|
||||
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
@@ -1,149 +1,161 @@
|
||||
// @ts-check
|
||||
|
||||
import { getConfig, gist } from "@stats-organization/github-readme-stats-core";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import axios from "axios";
|
||||
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 { getUserAccessByName, storeRequest } from "../src/common/database.js";
|
||||
import { renderError } from "../src/common/render.js";
|
||||
|
||||
vi.mock(import("@stats-organization/github-readme-stats-core"), async () => {
|
||||
const { mockCore } = await import("./utils.js");
|
||||
return mockCore();
|
||||
import { gist_data } from "./test-data/gist-data.js";
|
||||
|
||||
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) => ({
|
||||
...(await importOriginal()),
|
||||
storeRequest: vi.fn(),
|
||||
getUserAccessByName: vi.fn(),
|
||||
}));
|
||||
describe("Test /api/gist", () => {
|
||||
it("should test the request", async () => {
|
||||
const req = {
|
||||
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);
|
||||
const getConfigMock = vi.mocked(getConfig);
|
||||
const storeRequestMock = vi.mocked(storeRequest);
|
||||
const getUserAccessByNameMock = vi.mocked(getUserAccessByName);
|
||||
await gist(req, res);
|
||||
|
||||
const createRequest = (search) => ({
|
||||
headers: {},
|
||||
url: `/api/gist?${search}`,
|
||||
});
|
||||
|
||||
const createResponse = () => ({
|
||||
end: vi.fn(),
|
||||
setHeader: vi.fn(),
|
||||
});
|
||||
|
||||
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;
|
||||
});
|
||||
expect(res.setHeader).toHaveBeenCalledWith("Content-Type", "image/svg+xml");
|
||||
expect(res.send).toHaveBeenCalledWith(
|
||||
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}`,
|
||||
description: gist_data.data.viewer.gist.description,
|
||||
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,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
describe("Test /api/gist backend routing", () => {
|
||||
it("happy path should pass query params, respond with gist content and persist request", async () => {
|
||||
gistMock.mockResolvedValue({
|
||||
status: "success",
|
||||
content: "mock-gist-svg",
|
||||
});
|
||||
|
||||
const req = createRequest("id=bbfce31e0217a3689c8d961a356cb10d&theme=dark");
|
||||
const res = createResponse();
|
||||
|
||||
await router(req, res);
|
||||
|
||||
expect(gistMock).toHaveBeenCalledWith({
|
||||
id: "bbfce31e0217a3689c8d961a356cb10d",
|
||||
theme: "dark",
|
||||
});
|
||||
expect(getUserAccessByNameMock).not.toHaveBeenCalled();
|
||||
expect(req.query).toEqual({
|
||||
id: "bbfce31e0217a3689c8d961a356cb10d",
|
||||
theme: "dark",
|
||||
});
|
||||
expect(res.setHeader.mock.calls).toEqual([
|
||||
["Cache-Control", defaultCacheHeader],
|
||||
["Content-Type", "image/svg+xml"],
|
||||
]);
|
||||
expect(res.end).toHaveBeenCalledExactlyOnceWith("mock-gist-svg");
|
||||
expect(storeRequestMock).toHaveBeenCalledExactlyOnceWith(req);
|
||||
it("should get the query options", async () => {
|
||||
const req = {
|
||||
query: {
|
||||
id: "bbfce31e0217a3689c8d961a356cb10d",
|
||||
title_color: "fff",
|
||||
icon_color: "fff",
|
||||
text_color: "fff",
|
||||
bg_color: "fff",
|
||||
show_owner: true,
|
||||
},
|
||||
};
|
||||
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(
|
||||
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}`,
|
||||
description: gist_data.data.viewer.gist.description,
|
||||
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");
|
||||
const res = createResponse();
|
||||
|
||||
await router(req, res);
|
||||
|
||||
expect(gistMock).toHaveBeenCalledWith({
|
||||
id: "bbfce31e0217a3689c8d961a356cb10d",
|
||||
});
|
||||
expect(getUserAccessByNameMock).not.toHaveBeenCalled();
|
||||
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 render error if gist is not found", async () => {
|
||||
const req = {
|
||||
query: {
|
||||
id: "bbfce31e0217a3689c8d961a356cb10d",
|
||||
},
|
||||
};
|
||||
const res = {
|
||||
setHeader: vi.fn(),
|
||||
send: vi.fn(),
|
||||
};
|
||||
mock
|
||||
.onPost("https://api.github.com/graphql")
|
||||
.reply(200, gist_not_found_data);
|
||||
|
||||
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);
|
||||
|
||||
expect(gistMock).toHaveBeenCalledWith({
|
||||
id: "bbfce31e0217a3689c8d961a356cb10d",
|
||||
});
|
||||
expect(getUserAccessByNameMock).not.toHaveBeenCalled();
|
||||
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 render error if wrong locale is provided", async () => {
|
||||
const req = {
|
||||
query: {
|
||||
id: "bbfce31e0217a3689c8d961a356cb10d",
|
||||
locale: "asdf",
|
||||
},
|
||||
};
|
||||
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: "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();
|
||||
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 gist ID is not whitelisted",
|
||||
it("should have proper cache", async () => {
|
||||
const req = {
|
||||
query: {
|
||||
id: "bbfce31e0217a3689c8d961a356cb10d",
|
||||
},
|
||||
};
|
||||
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.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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,15 +4,9 @@
|
||||
|
||||
import axios from "axios";
|
||||
import MockAdapter from "axios-mock-adapter";
|
||||
import {
|
||||
afterAll,
|
||||
afterEach,
|
||||
beforeAll,
|
||||
describe,
|
||||
expect,
|
||||
it,
|
||||
vi,
|
||||
} from "vitest";
|
||||
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import patInfo, { RATE_LIMIT_SECONDS } from "../api-renamed/status/pat-info.js";
|
||||
|
||||
const mock = new MockAdapter(axios);
|
||||
|
||||
@@ -63,35 +57,20 @@ const bad_credentials_error = {
|
||||
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(() => {
|
||||
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", () => {
|
||||
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 () => {
|
||||
mock
|
||||
.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 () => {
|
||||
mock.reset();
|
||||
mock.onPost("https://api.github.com/graphql").networkError();
|
||||
|
||||
const { req, res } = faker({}, {});
|
||||
|
||||
+162
-71
@@ -1,84 +1,175 @@
|
||||
// @ts-check
|
||||
|
||||
import { pin } from "@stats-organization/github-readme-stats-core";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import axios from "axios";
|
||||
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 { getUserAccessByName, storeRequest } from "../src/common/database.js";
|
||||
import { renderError } from "../src/common/render.js";
|
||||
|
||||
vi.mock(import("@stats-organization/github-readme-stats-core"), async () => {
|
||||
const { mockCore } = await import("./utils.js");
|
||||
return mockCore();
|
||||
import { data_repo, data_user } from "./test-data/pin-data.js";
|
||||
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
|
||||
const mock = new MockAdapter(axios);
|
||||
|
||||
afterEach(() => {
|
||||
mock.reset();
|
||||
});
|
||||
|
||||
vi.mock(import("../src/common/database.js"), async (importOriginal) => ({
|
||||
...(await importOriginal()),
|
||||
storeRequest: vi.fn(),
|
||||
getUserAccessByName: vi.fn(),
|
||||
}));
|
||||
|
||||
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(
|
||||
{
|
||||
describe("Test /api/pin", () => {
|
||||
it("should test the request", async () => {
|
||||
const req = {
|
||||
query: {
|
||||
username: "anuraghazra",
|
||||
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
Reference in New Issue
Block a user