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:
|
To set up the project GitHub-Stats-Extended locally, run the following commands:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
./vercel-preparation.sh
|
||||||
pnpm install
|
pnpm install
|
||||||
pnpm run build:packages
|
pnpm --filter frontend run build
|
||||||
pnpm run dev:frontend
|
|
||||||
```
|
```
|
||||||
|
|
||||||
The easiest way to run and test the project is to deploy it to Vercel as described in the [deployment guide](../docs/deploy.md).
|
The easiest way to run and test the project is to deploy it to Vercel as described in the [deployment guide](../docs/deploy.md).
|
||||||
|
|||||||
@@ -14,11 +14,11 @@ runs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Install pnpm
|
- name: Install pnpm
|
||||||
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5
|
uses: pnpm/action-setup@v4
|
||||||
|
|
||||||
- name: Setup Node.js (via input)
|
- name: Setup Node.js (via input)
|
||||||
if: ${{ inputs.node-version }}
|
if: ${{ inputs.node-version }}
|
||||||
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
|
uses: actions/setup-node@v6
|
||||||
with:
|
with:
|
||||||
node-version: ${{ inputs.node-version }}
|
node-version: ${{ inputs.node-version }}
|
||||||
cache: "pnpm"
|
cache: "pnpm"
|
||||||
@@ -26,7 +26,7 @@ runs:
|
|||||||
|
|
||||||
- name: Setup Node.js (via .nvmrc)
|
- name: Setup Node.js (via .nvmrc)
|
||||||
if: ${{ !inputs.node-version }}
|
if: ${{ !inputs.node-version }}
|
||||||
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
|
uses: actions/setup-node@v6
|
||||||
with:
|
with:
|
||||||
node-version-file: ".nvmrc"
|
node-version-file: ".nvmrc"
|
||||||
cache: "pnpm"
|
cache: "pnpm"
|
||||||
|
|||||||
+9
-49
@@ -4,8 +4,9 @@ updates:
|
|||||||
- package-ecosystem: npm
|
- package-ecosystem: npm
|
||||||
directory: "/"
|
directory: "/"
|
||||||
schedule:
|
schedule:
|
||||||
interval: daily
|
interval: cron
|
||||||
open-pull-requests-limit: 20
|
cronjob: every day at 5am
|
||||||
|
open-pull-requests-limit: 10
|
||||||
commit-message:
|
commit-message:
|
||||||
prefix: "build(deps)"
|
prefix: "build(deps)"
|
||||||
prefix-development: "build(deps-dev)"
|
prefix-development: "build(deps-dev)"
|
||||||
@@ -14,56 +15,14 @@ updates:
|
|||||||
ignore:
|
ignore:
|
||||||
- dependency-name: "@types/node"
|
- dependency-name: "@types/node"
|
||||||
update-types: ["version-update:semver-major"]
|
update-types: ["version-update:semver-major"]
|
||||||
groups:
|
|
||||||
react:
|
|
||||||
patterns:
|
|
||||||
- "react"
|
|
||||||
- "react-dom"
|
|
||||||
- "react-*"
|
|
||||||
- "@types/react"
|
|
||||||
- "@types/react-dom"
|
|
||||||
redux:
|
|
||||||
patterns:
|
|
||||||
- "redux"
|
|
||||||
- "@reduxjs/*"
|
|
||||||
- "react-redux"
|
|
||||||
vite:
|
|
||||||
patterns:
|
|
||||||
- "vite"
|
|
||||||
- "@vitejs/*"
|
|
||||||
tailwind:
|
|
||||||
patterns:
|
|
||||||
- "tailwindcss"
|
|
||||||
- "@tailwindcss/*"
|
|
||||||
- "daisyui"
|
|
||||||
vitest:
|
|
||||||
patterns:
|
|
||||||
- "vitest"
|
|
||||||
- "@vitest/*"
|
|
||||||
eslint:
|
|
||||||
patterns:
|
|
||||||
- "eslint"
|
|
||||||
- "@eslint/*"
|
|
||||||
- "eslint-*"
|
|
||||||
- "typescript-eslint"
|
|
||||||
typescript:
|
|
||||||
patterns:
|
|
||||||
- "typescript"
|
|
||||||
- "@types/*"
|
|
||||||
playwright:
|
|
||||||
patterns:
|
|
||||||
- "@playwright/*"
|
|
||||||
axios:
|
|
||||||
patterns:
|
|
||||||
- "axios"
|
|
||||||
- "axios-*"
|
|
||||||
|
|
||||||
# Maintain dependencies for GitHub Actions
|
# Maintain dependencies for GitHub Actions
|
||||||
- package-ecosystem: github-actions
|
- package-ecosystem: github-actions
|
||||||
directory: "/"
|
directory: "/"
|
||||||
schedule:
|
schedule:
|
||||||
interval: daily
|
interval: cron
|
||||||
open-pull-requests-limit: 20
|
cronjob: every day at 5am
|
||||||
|
open-pull-requests-limit: 10
|
||||||
commit-message:
|
commit-message:
|
||||||
prefix: "ci(deps)"
|
prefix: "ci(deps)"
|
||||||
prefix-development: "ci(deps-dev)"
|
prefix-development: "ci(deps-dev)"
|
||||||
@@ -74,8 +33,9 @@ updates:
|
|||||||
- package-ecosystem: devcontainers
|
- package-ecosystem: devcontainers
|
||||||
directory: "/"
|
directory: "/"
|
||||||
schedule:
|
schedule:
|
||||||
interval: daily
|
interval: cron
|
||||||
open-pull-requests-limit: 20
|
cronjob: every day at 5am
|
||||||
|
open-pull-requests-limit: 10
|
||||||
commit-message:
|
commit-message:
|
||||||
prefix: "build(deps)"
|
prefix: "build(deps)"
|
||||||
prefix-development: "build(deps-dev)"
|
prefix-development: "build(deps-dev)"
|
||||||
|
|||||||
+29
-35
@@ -31,21 +31,26 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
uses: actions/checkout@v6
|
||||||
|
|
||||||
|
- name: Run vercel-preparation.sh
|
||||||
|
run: |
|
||||||
|
chmod +x ./vercel-preparation.sh
|
||||||
|
./vercel-preparation.sh
|
||||||
|
|
||||||
- name: Install Dependencies
|
- name: Install Dependencies
|
||||||
uses: ./.github/actions/install-dependencies
|
uses: ./.github/actions/install-dependencies
|
||||||
with:
|
with:
|
||||||
node-version: ${{ matrix.node }}
|
node-version: ${{ matrix.node }}
|
||||||
|
|
||||||
- name: Build packages
|
|
||||||
run: pnpm run build:packages
|
|
||||||
|
|
||||||
- name: Build frontend
|
- name: Build frontend
|
||||||
run: pnpm run build:frontend
|
run: pnpm --filter frontend run build
|
||||||
|
|
||||||
- name: Run tests
|
- name: Run frontend tests
|
||||||
run: pnpm run test
|
run: pnpm --filter frontend run test
|
||||||
|
|
||||||
|
- name: Run backend tests
|
||||||
|
run: pnpm --filter github-readme-stats run test
|
||||||
|
|
||||||
frontend-test-e2e:
|
frontend-test-e2e:
|
||||||
name: Frontend E2E test
|
name: Frontend E2E test
|
||||||
@@ -59,42 +64,21 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
uses: actions/checkout@v6
|
||||||
|
|
||||||
- name: Install Dependencies
|
- name: Install Dependencies
|
||||||
uses: ./.github/actions/install-dependencies
|
uses: ./.github/actions/install-dependencies
|
||||||
|
|
||||||
- name: Build packages
|
- name: Run vercel-preparation.sh
|
||||||
run: pnpm run build:packages
|
run: |
|
||||||
|
chmod +x ./vercel-preparation.sh
|
||||||
|
./vercel-preparation.sh
|
||||||
|
|
||||||
- name: Install Playwright Browsers
|
- name: Install Playwright Browsers
|
||||||
run: pnpm exec playwright install --with-deps
|
run: pnpm exec playwright install --with-deps
|
||||||
|
|
||||||
- name: Run Playwright tests
|
- name: Run Playwright tests
|
||||||
run: pnpm --filter ./apps/frontend/ run test:e2e
|
run: pnpm --filter frontend run test:e2e
|
||||||
|
|
||||||
backend-test-e2e:
|
|
||||||
name: Backend E2E test
|
|
||||||
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
|
|
||||||
continue-on-error: true
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Checkout code
|
|
||||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
|
||||||
|
|
||||||
- name: Install Dependencies
|
|
||||||
uses: ./.github/actions/install-dependencies
|
|
||||||
|
|
||||||
- name: Build packages
|
|
||||||
run: pnpm run build:packages
|
|
||||||
|
|
||||||
- name: Run backend end-to-end tests
|
|
||||||
run: pnpm --filter ./apps/backend/ run test:e2e
|
|
||||||
|
|
||||||
code-checks:
|
code-checks:
|
||||||
name: Code checks
|
name: Code checks
|
||||||
@@ -106,7 +90,17 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
uses: actions/checkout@v6
|
||||||
|
|
||||||
|
# Heads up!
|
||||||
|
#
|
||||||
|
# 1. Execution of this script is needed to resolve `.vercel` folder from `apps/frontend/src/components/Card/SVG.js`
|
||||||
|
# 2. This scripts removes `apps/backend/node_modules` breaking 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
|
- name: Install Dependencies
|
||||||
uses: ./.github/actions/install-dependencies
|
uses: ./.github/actions/install-dependencies
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ on:
|
|||||||
branches:
|
branches:
|
||||||
- master
|
- master
|
||||||
paths:
|
paths:
|
||||||
- "packages/core/src/themes/index.js"
|
- "apps/backend/themes/index.js"
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
permissions: {}
|
permissions: {}
|
||||||
@@ -30,17 +30,17 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
uses: actions/checkout@v6
|
||||||
|
|
||||||
- name: Install Dependencies
|
- name: Install Dependencies
|
||||||
uses: ./.github/actions/install-dependencies
|
uses: ./.github/actions/install-dependencies
|
||||||
|
|
||||||
- name: Generate readme
|
- name: Generate readme
|
||||||
run: |
|
run: |
|
||||||
pnpm --filter ./packages/core/ run theme-readme-gen
|
pnpm --filter github-readme-stats run theme-readme-gen
|
||||||
|
|
||||||
- name: Create Pull Request if themes README has changed
|
- name: Create Pull Request if themes README has changed
|
||||||
uses: peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1 # v8
|
uses: peter-evans/create-pull-request@v8
|
||||||
with:
|
with:
|
||||||
commit-message: "feat(backend): update themes README"
|
commit-message: "feat(backend): update themes README"
|
||||||
branch: "update_themes_readme/patch"
|
branch: "update_themes_readme/patch"
|
||||||
|
|||||||
@@ -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:
|
jobs:
|
||||||
triggerRepeatRecent:
|
triggerRepeatRecent:
|
||||||
if: |
|
|
||||||
github.repository == 'anuraghazra/github-readme-stats' ||
|
|
||||||
github.repository == 'stats-organization/github-readme-stats' ||
|
|
||||||
github.repository == 'stats-organization/github-stats-extended'
|
|
||||||
name: Trigger server to update cached data.
|
name: Trigger server to update cached data.
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Make Request
|
- name: Make Request
|
||||||
id: myRequest
|
id: myRequest
|
||||||
uses: fjogeleit/http-request-action@551353b829c3646756b2ec2b3694f819d7957495 # v2
|
uses: fjogeleit/http-request-action@v2
|
||||||
with:
|
with:
|
||||||
url: "https://github-stats-extended.vercel.app/api/repeat-recent"
|
url: "https://github-stats-extended.vercel.app/api/repeat-recent"
|
||||||
method: "POST"
|
method: "POST"
|
||||||
|
|||||||
@@ -38,16 +38,16 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
uses: actions/checkout@v6
|
||||||
|
|
||||||
- name: Install Dependencies
|
- name: Install Dependencies
|
||||||
uses: ./.github/actions/install-dependencies
|
uses: ./.github/actions/install-dependencies
|
||||||
|
|
||||||
- name: Run update-languages-json.js script
|
- name: Run update-languages-json.js script
|
||||||
run: pnpm --filter ./packages/core/ run generate-langs-json
|
run: pnpm --filter github-readme-stats run generate-langs-json
|
||||||
|
|
||||||
- name: Create Pull Request if upstream language file is changed
|
- name: Create Pull Request if upstream language file is changed
|
||||||
uses: peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1 # v8
|
uses: peter-evans/create-pull-request@v8
|
||||||
with:
|
with:
|
||||||
commit-message: "feat(backend): update languages JSON"
|
commit-message: "feat(backend): update languages JSON"
|
||||||
branch: "update_langs/patch"
|
branch: "update_langs/patch"
|
||||||
|
|||||||
@@ -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
|
node_modules
|
||||||
|
|
||||||
|
.env.local
|
||||||
|
.env.development.local
|
||||||
|
.env.test.local
|
||||||
|
.env.production.local
|
||||||
|
|
||||||
# OS
|
# OS
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
|
||||||
# Project
|
# Project
|
||||||
coverage
|
coverage
|
||||||
|
|
||||||
@@ -16,12 +26,11 @@ apps/backend/vercel_token
|
|||||||
apps/backend-copy
|
apps/backend-copy
|
||||||
|
|
||||||
apps/frontend/.env
|
apps/frontend/.env
|
||||||
|
apps/frontend/src/backend
|
||||||
|
apps/frontend/build
|
||||||
|
|
||||||
|
|
||||||
.turbo
|
|
||||||
build
|
|
||||||
build-ts
|
build-ts
|
||||||
*.tsbuildinfo
|
tsconfig.tsbuildinfo
|
||||||
|
|
||||||
# IDE
|
# IDE
|
||||||
.idea/
|
.idea/
|
||||||
|
|||||||
+2
-1
@@ -1,3 +1,4 @@
|
|||||||
pnpm lint-staged
|
pnpm lint-staged
|
||||||
pnpm run lint
|
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>
|
<a href="https://github-stats-extended.vercel.app/api?username=anuraghazra"><img src="https://github-stats-extended.vercel.app/api?username=anuraghazra"></a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
This project is the [extended, actively maintained successor](docs/fork.md) of [github-readme-stats](https://github.com/anuraghazra/github-readme-stats). It generates [various stats cards](#card-types), e.g. about your GitHub contributions, your top languages, etc. You can [customize](#advanced-customization) the cards via multiple parameters.
|
This project is an [extended version](docs/fork.md) of [github-readme-stats](https://github.com/anuraghazra/github-readme-stats). It generates [various stats cards](#card-types), e.g. about your GitHub contributions, your top languages, etc. You can [customize](#advanced-customization) the cards via multiple parameters.
|
||||||
|
|
||||||
# Table of Contents
|
# Table of Contents
|
||||||
- [Quick Start](#quick-start)
|
- [Quick Start](#quick-start)
|
||||||
- [Migration from github-readme-stats](#migration-from-github-readme-stats)
|
|
||||||
- [Card Types](#card-types)
|
- [Card Types](#card-types)
|
||||||
- [Advanced Customization](#advanced-customization)
|
- [Advanced Customization](#advanced-customization)
|
||||||
- [Run It Yourself](#run-it-yourself)
|
- [Self-Hosting](#self-hosting)
|
||||||
- [Acknowledgements](#acknowledgements)
|
- [Acknowledgements](#acknowledgements)
|
||||||
- [Contributing](#contributing)
|
- [Contributing](#contributing)
|
||||||
|
|
||||||
@@ -28,16 +27,6 @@ This project is the [extended, actively maintained successor](docs/fork.md) of [
|
|||||||
|
|
||||||
As more comfortable alternative, use the [GitHub-Stats-Extended Wizard](https://github-stats-extended.vercel.app/frontend) to create your custom stats card. Copy the generated markdown code and paste it into your [GitHub profile README](https://docs.github.com/en/account-and-profile/how-tos/profile-customization/managing-your-profile-readme#adding-a-profile-readme). Done!
|
As more comfortable alternative, use the [GitHub-Stats-Extended Wizard](https://github-stats-extended.vercel.app/frontend) to create your custom stats card. Copy the generated markdown code and paste it into your [GitHub profile README](https://docs.github.com/en/account-and-profile/how-tos/profile-customization/managing-your-profile-readme#adding-a-profile-readme). Done!
|
||||||
|
|
||||||
# Migration from github-readme-stats
|
|
||||||
To migrate from [github-readme-stats](https://github.com/anuraghazra/github-readme-stats) you only need to change the domain from `github-readme-stats.vercel.app` to `github-stats-extended.vercel.app`:
|
|
||||||
|
|
||||||
```diff
|
|
||||||
- https://github-readme-stats.vercel.app/api?username=octocat&theme=radical
|
|
||||||
+ https://github-stats-extended.vercel.app/api?username=octocat&theme=radical
|
|
||||||
```
|
|
||||||
|
|
||||||
GitHub-Stats-Extended aims to be fully compatible with github-readme-stats. For more details see [Compatibility Notes](docs/fork.md#compatibility-notes).
|
|
||||||
|
|
||||||
# Card Types
|
# Card Types
|
||||||
- Show your GitHub statistics:
|
- Show your GitHub statistics:
|
||||||
|
|
||||||
@@ -66,13 +55,13 @@ GitHub-Stats-Extended aims to be fully compatible with github-readme-stats. For
|
|||||||
# Advanced Customization
|
# Advanced Customization
|
||||||
The [GitHub-Stats-Extended Wizard](https://github-stats-extended.vercel.app/frontend) offers some essential customization options. For more advanced customization check out the [advanced documentation](docs/advanced_documentation.md).
|
The [GitHub-Stats-Extended Wizard](https://github-stats-extended.vercel.app/frontend) offers some essential customization options. For more advanced customization check out the [advanced documentation](docs/advanced_documentation.md).
|
||||||
|
|
||||||
# Run It Yourself
|
|
||||||
If you want to run GitHub-Stats-Extended on your own, there are two main deployment options: you can use [github-readme-stats-action](https://github.com/stats-organization/github-readme-stats-action) to generate cards in your own GitHub Actions workflow. Or you can self-host GitHub-Stats-Extended on Vercel.
|
|
||||||
|
|
||||||
See [Run It Yourself](docs/deploy.md) for detailed instructions.
|
|
||||||
|
|
||||||
# Acknowledgements
|
# Acknowledgements
|
||||||
This project is based on [github-readme-stats](https://github.com/anuraghazra/github-readme-stats). On top of that project's functionality GitHub-Stats-Extended adds several new features and improvements. See [Fork Information](docs/fork.md) for a list of changes. The frontend added to GitHub-Stats-Extended is based on [GitHub Trends](https://github.com/avgupta456/github-trends). Big thanks to [@anuraghazra](https://github.com/anuraghazra), [@avgupta456](https://github.com/avgupta456), [@rickstaa](https://github.com/rickstaa), [@qwerty541](https://github.com/qwerty541) and everyone else who worked on these projects! ❤️
|
This project is based on [github-readme-stats](https://github.com/anuraghazra/github-readme-stats). On top of their functionality I added several new features and improvements. See [Fork Information](docs/fork.md) for a list of changes. The frontend I added to the project is based on [GitHub Trends](https://github.com/avgupta456/github-trends). Big thanks to [@anuraghazra](https://github.com/anuraghazra), [@avgupta456](https://github.com/avgupta456), [@rickstaa](https://github.com/rickstaa), [@qwerty541](https://github.com/qwerty541) and everyone else who worked on these projects! ❤️
|
||||||
|
|
||||||
|
# Self-Hosting
|
||||||
|
Since the GitHub API only allows a limited number of requests per hour, the public instance of GitHub-Stats-Extended at https://github-stats-extended.vercel.app/api could possibly hit the rate limiter. If you host your own instance you do not have to worry about anything. Also, if you don't want to give my GitHub-Stats-Extended instance access to your private contributions but still want to include these contributions in your stats, you can simply host your own instance.
|
||||||
|
|
||||||
|
See [Deploy on your own](docs/deploy.md) for various deployment options.
|
||||||
|
|
||||||
# Contributing
|
# Contributing
|
||||||
Contributions are welcome!
|
Contributions are welcome!
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
"name": "GitHub Stats Extended Dev",
|
"name": "GitHub Stats Extended Dev",
|
||||||
"image": "mcr.microsoft.com/devcontainers/base:ubuntu",
|
"image": "mcr.microsoft.com/devcontainers/base:ubuntu",
|
||||||
"features": {
|
"features": {
|
||||||
"ghcr.io/devcontainers/features/node:1": { "version": "24" }
|
"ghcr.io/devcontainers/features/node:1": { "version": "22" }
|
||||||
},
|
},
|
||||||
"forwardPorts": [3000],
|
"forwardPorts": [3000],
|
||||||
"portsAttributes": {
|
"portsAttributes": {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"runtime": "nodejs24.x",
|
"runtime": "nodejs22.x",
|
||||||
"handler": "router.js",
|
"handler": "router.js",
|
||||||
"launcherType": "Nodejs"
|
"launcherType": "Nodejs"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,76 @@
|
|||||||
|
/* eslint-disable import-x/no-unresolved */
|
||||||
|
import { default as authenticate } from "./api-renamed/authenticate.js";
|
||||||
|
import { default as deleteUser } from "./api-renamed/delete-user.js";
|
||||||
|
import { default as downgrade } from "./api-renamed/downgrade.js";
|
||||||
|
import { default as gist } from "./api-renamed/gist.js";
|
||||||
|
import { default as api } from "./api-renamed/index.js";
|
||||||
|
import { default as pin } from "./api-renamed/pin.js";
|
||||||
|
import { default as repeatRecent } from "./api-renamed/repeat-recent.js";
|
||||||
|
import { default as patInfo } from "./api-renamed/status/pat-info.js";
|
||||||
|
import { default as statusUp } from "./api-renamed/status/up.js";
|
||||||
|
import { default as topLangs } from "./api-renamed/top-langs.js";
|
||||||
|
import { default as userAccess } from "./api-renamed/user-access.js";
|
||||||
|
import { default as wakatimeProxy } from "./api-renamed/wakatime-proxy.js";
|
||||||
|
import { default as wakatime } from "./api-renamed/wakatime.js";
|
||||||
|
|
||||||
|
export default async (req, res) => {
|
||||||
|
// remaining code expects express.js-like request and response objects
|
||||||
|
res.send = function (data) {
|
||||||
|
if (typeof data === "object") {
|
||||||
|
res.setHeader("Content-Type", "application/json");
|
||||||
|
res.end(JSON.stringify(data));
|
||||||
|
} else if (typeof data === "string") {
|
||||||
|
res.end(data);
|
||||||
|
} else {
|
||||||
|
res.end(String(data));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const url = new URL(req.url, "https://localhost");
|
||||||
|
req.query = Object.fromEntries(url.searchParams.entries());
|
||||||
|
|
||||||
|
switch (url.pathname) {
|
||||||
|
case "/api":
|
||||||
|
await api(req, res);
|
||||||
|
break;
|
||||||
|
case "/api/gist":
|
||||||
|
await gist(req, res);
|
||||||
|
break;
|
||||||
|
case "/api/pin":
|
||||||
|
await pin(req, res);
|
||||||
|
break;
|
||||||
|
case "/api/top-langs":
|
||||||
|
await topLangs(req, res);
|
||||||
|
break;
|
||||||
|
case "/api/wakatime":
|
||||||
|
await wakatime(req, res);
|
||||||
|
break;
|
||||||
|
case "/api/wakatime-proxy":
|
||||||
|
await wakatimeProxy(req, res);
|
||||||
|
break;
|
||||||
|
case "/api/repeat-recent":
|
||||||
|
await repeatRecent(req, res);
|
||||||
|
break;
|
||||||
|
case "/api/status/pat-info":
|
||||||
|
await patInfo(req, res);
|
||||||
|
break;
|
||||||
|
case "/api/status/up":
|
||||||
|
await statusUp(req, res);
|
||||||
|
break;
|
||||||
|
case "/api/authenticate":
|
||||||
|
await authenticate(req, res);
|
||||||
|
break;
|
||||||
|
case "/api/delete-user":
|
||||||
|
await deleteUser(req, res);
|
||||||
|
break;
|
||||||
|
case "/api/user-access":
|
||||||
|
await userAccess(req, res);
|
||||||
|
break;
|
||||||
|
case "/api/downgrade":
|
||||||
|
await downgrade(req, res);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
res.statusCode = 404;
|
||||||
|
res.end("Not Found");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -1,5 +1,4 @@
|
|||||||
import { logger } from "@stats-organization/github-readme-stats-core";
|
import { logger } from "../src/common/log.js";
|
||||||
|
|
||||||
import { authenticate } from "../src/users.js";
|
import { authenticate } from "../src/users.js";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { logger } from "@stats-organization/github-readme-stats-core";
|
|
||||||
|
|
||||||
import { deleteUser } from "../src/common/database.js";
|
import { deleteUser } from "../src/common/database.js";
|
||||||
|
import { logger } from "../src/common/log.js";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param {any} req The request.
|
* @param {any} req The request.
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { logger } from "@stats-organization/github-readme-stats-core";
|
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
|
|
||||||
import { deleteUser, getUserAccessByKey } from "../src/common/database.js";
|
import { deleteUser, getUserAccessByKey } from "../src/common/database.js";
|
||||||
|
import { logger } from "../src/common/log.js";
|
||||||
|
|
||||||
export default async (req, res) => {
|
export default async (req, res) => {
|
||||||
// We could optimize this method by doing both database operations in one statement, using "DELETE ... RETURNING ..."
|
// We could optimize this method by doing both database operations in one statement, using "DELETE ... RETURNING ..."
|
||||||
|
|||||||
@@ -0,0 +1,129 @@
|
|||||||
|
// @ts-check
|
||||||
|
|
||||||
|
import { renderGistCard } from "../src/cards/gist.js";
|
||||||
|
import { guardAccess } from "../src/common/access.js";
|
||||||
|
import {
|
||||||
|
CACHE_TTL,
|
||||||
|
resolveCacheSeconds,
|
||||||
|
setCacheHeaders,
|
||||||
|
setErrorCacheHeaders,
|
||||||
|
} from "../src/common/cache.js";
|
||||||
|
import { storeRequest } from "../src/common/database.js";
|
||||||
|
import {
|
||||||
|
MissingParamError,
|
||||||
|
retrieveSecondaryMessage,
|
||||||
|
} from "../src/common/error.js";
|
||||||
|
import { parseBoolean } from "../src/common/ops.js";
|
||||||
|
import { renderError } from "../src/common/render.js";
|
||||||
|
import { fetchGist } from "../src/fetchers/gist.js";
|
||||||
|
import { isLocaleAvailable } from "../src/translations.js";
|
||||||
|
|
||||||
|
// @ts-ignore
|
||||||
|
export default async (req, res) => {
|
||||||
|
const {
|
||||||
|
id,
|
||||||
|
title_color,
|
||||||
|
icon_color,
|
||||||
|
text_color,
|
||||||
|
bg_color,
|
||||||
|
theme,
|
||||||
|
cache_seconds,
|
||||||
|
locale,
|
||||||
|
border_radius,
|
||||||
|
border_color,
|
||||||
|
show_owner,
|
||||||
|
hide_border,
|
||||||
|
} = req.query;
|
||||||
|
|
||||||
|
res.setHeader("Content-Type", "image/svg+xml");
|
||||||
|
|
||||||
|
const access = guardAccess({
|
||||||
|
res,
|
||||||
|
id,
|
||||||
|
type: "gist",
|
||||||
|
colors: {
|
||||||
|
title_color,
|
||||||
|
text_color,
|
||||||
|
bg_color,
|
||||||
|
border_color,
|
||||||
|
theme,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!access.isPassed) {
|
||||||
|
return access.result;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (locale && !isLocaleAvailable(locale)) {
|
||||||
|
return res.send(
|
||||||
|
renderError({
|
||||||
|
message: "Something went wrong",
|
||||||
|
secondaryMessage: "Language not found",
|
||||||
|
renderOptions: {
|
||||||
|
title_color,
|
||||||
|
text_color,
|
||||||
|
bg_color,
|
||||||
|
border_color,
|
||||||
|
theme,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await storeRequest(req);
|
||||||
|
const gistData = await fetchGist(id);
|
||||||
|
const cacheSeconds = resolveCacheSeconds({
|
||||||
|
requested: parseInt(cache_seconds, 10),
|
||||||
|
def: CACHE_TTL.GIST_CARD.DEFAULT,
|
||||||
|
min: CACHE_TTL.GIST_CARD.MIN,
|
||||||
|
max: CACHE_TTL.GIST_CARD.MAX,
|
||||||
|
});
|
||||||
|
|
||||||
|
setCacheHeaders(res, cacheSeconds);
|
||||||
|
|
||||||
|
return res.send(
|
||||||
|
renderGistCard(gistData, {
|
||||||
|
title_color,
|
||||||
|
icon_color,
|
||||||
|
text_color,
|
||||||
|
bg_color,
|
||||||
|
theme,
|
||||||
|
border_radius,
|
||||||
|
border_color,
|
||||||
|
locale: locale ? locale.toLowerCase() : null,
|
||||||
|
show_owner: parseBoolean(show_owner),
|
||||||
|
hide_border: parseBoolean(hide_border),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
setErrorCacheHeaders(res);
|
||||||
|
if (err instanceof Error) {
|
||||||
|
return res.send(
|
||||||
|
renderError({
|
||||||
|
message: err.message,
|
||||||
|
secondaryMessage: retrieveSecondaryMessage(err),
|
||||||
|
renderOptions: {
|
||||||
|
title_color,
|
||||||
|
text_color,
|
||||||
|
bg_color,
|
||||||
|
border_color,
|
||||||
|
theme,
|
||||||
|
show_repo_link: !(err instanceof MissingParamError),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return res.send(
|
||||||
|
renderError({
|
||||||
|
message: "An unknown error occurred",
|
||||||
|
renderOptions: {
|
||||||
|
title_color,
|
||||||
|
text_color,
|
||||||
|
bg_color,
|
||||||
|
border_color,
|
||||||
|
theme,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -1,18 +1,26 @@
|
|||||||
// @ts-check
|
// @ts-check
|
||||||
|
|
||||||
import { renderStatsCard } from "../cards/stats.js";
|
import { renderStatsCard } from "../src/cards/stats.js";
|
||||||
|
import { guardAccess } from "../src/common/access.js";
|
||||||
|
import {
|
||||||
|
CACHE_TTL,
|
||||||
|
resolveCacheSeconds,
|
||||||
|
setCacheHeaders,
|
||||||
|
setErrorCacheHeaders,
|
||||||
|
} from "../src/common/cache.js";
|
||||||
|
import { storeRequest } from "../src/common/database.js";
|
||||||
import {
|
import {
|
||||||
MissingParamError,
|
MissingParamError,
|
||||||
retrieveSecondaryMessage,
|
retrieveSecondaryMessage,
|
||||||
} from "../common/error.js";
|
} from "../src/common/error.js";
|
||||||
import { parseArray, parseBoolean } from "../common/ops.js";
|
import { parseArray, parseBoolean } from "../src/common/ops.js";
|
||||||
import { renderError } from "../common/render.js";
|
import { renderError } from "../src/common/render.js";
|
||||||
import { fetchStats } from "../fetchers/stats.js";
|
import { fetchStats } from "../src/fetchers/stats.js";
|
||||||
import { isLocaleAvailable } from "../translations.js";
|
import { isLocaleAvailable } from "../src/translations.js";
|
||||||
|
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
export default async (
|
export default async (req, res) => {
|
||||||
{
|
const {
|
||||||
username,
|
username,
|
||||||
repo,
|
repo,
|
||||||
owner,
|
owner,
|
||||||
@@ -32,6 +40,7 @@ export default async (
|
|||||||
text_bold,
|
text_bold,
|
||||||
bg_color,
|
bg_color,
|
||||||
theme,
|
theme,
|
||||||
|
cache_seconds,
|
||||||
exclude_repo,
|
exclude_repo,
|
||||||
custom_title,
|
custom_title,
|
||||||
locale,
|
locale,
|
||||||
@@ -43,13 +52,28 @@ export default async (
|
|||||||
border_color,
|
border_color,
|
||||||
rank_icon,
|
rank_icon,
|
||||||
show,
|
show,
|
||||||
},
|
} = req.query;
|
||||||
pat = null,
|
res.setHeader("Content-Type", "image/svg+xml");
|
||||||
) => {
|
|
||||||
|
const access = guardAccess({
|
||||||
|
res,
|
||||||
|
id: username,
|
||||||
|
type: "username",
|
||||||
|
colors: {
|
||||||
|
title_color,
|
||||||
|
text_color,
|
||||||
|
bg_color,
|
||||||
|
border_color,
|
||||||
|
theme,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!access.isPassed) {
|
||||||
|
return access.result;
|
||||||
|
}
|
||||||
|
|
||||||
if (locale && !isLocaleAvailable(locale)) {
|
if (locale && !isLocaleAvailable(locale)) {
|
||||||
return {
|
return res.send(
|
||||||
status: "error - permanent",
|
renderError({
|
||||||
content: renderError({
|
|
||||||
message: "Something went wrong",
|
message: "Something went wrong",
|
||||||
secondaryMessage: "Language not found",
|
secondaryMessage: "Language not found",
|
||||||
renderOptions: {
|
renderOptions: {
|
||||||
@@ -60,7 +84,7 @@ export default async (
|
|||||||
theme,
|
theme,
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
};
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const safePattern = /^[-\w/.,]+$/;
|
const safePattern = /^[-\w/.,]+$/;
|
||||||
@@ -69,9 +93,8 @@ export default async (
|
|||||||
(repo && !safePattern.test(repo)) ||
|
(repo && !safePattern.test(repo)) ||
|
||||||
(owner && !safePattern.test(owner))
|
(owner && !safePattern.test(owner))
|
||||||
) {
|
) {
|
||||||
return {
|
return res.send(
|
||||||
status: "error - permanent",
|
renderError({
|
||||||
content: renderError({
|
|
||||||
message: "Something went wrong",
|
message: "Something went wrong",
|
||||||
secondaryMessage:
|
secondaryMessage:
|
||||||
"Username, repository or owner contains unsafe characters",
|
"Username, repository or owner contains unsafe characters",
|
||||||
@@ -83,10 +106,11 @@ export default async (
|
|||||||
theme,
|
theme,
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
};
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
await storeRequest(req);
|
||||||
const showStats = parseArray(show);
|
const showStats = parseArray(show);
|
||||||
const repoOwner = parseArray(owner);
|
const repoOwner = parseArray(owner);
|
||||||
let repository = parseArray(repo);
|
let repository = parseArray(repo);
|
||||||
@@ -111,12 +135,18 @@ export default async (
|
|||||||
showStats.includes("issues_authored"),
|
showStats.includes("issues_authored"),
|
||||||
showStats.includes("issues_commented"),
|
showStats.includes("issues_commented"),
|
||||||
parseArray(role),
|
parseArray(role),
|
||||||
pat,
|
|
||||||
);
|
);
|
||||||
|
const cacheSeconds = resolveCacheSeconds({
|
||||||
|
requested: parseInt(cache_seconds, 10),
|
||||||
|
def: CACHE_TTL.STATS_CARD.DEFAULT,
|
||||||
|
min: CACHE_TTL.STATS_CARD.MIN,
|
||||||
|
max: CACHE_TTL.STATS_CARD.MAX,
|
||||||
|
});
|
||||||
|
|
||||||
return {
|
setCacheHeaders(res, cacheSeconds);
|
||||||
status: "success",
|
|
||||||
content: renderStatsCard(
|
return res.send(
|
||||||
|
renderStatsCard(
|
||||||
stats,
|
stats,
|
||||||
{
|
{
|
||||||
hide: parseArray(hide),
|
hide: parseArray(hide),
|
||||||
@@ -149,12 +179,12 @@ export default async (
|
|||||||
repository,
|
repository,
|
||||||
repoOwner,
|
repoOwner,
|
||||||
),
|
),
|
||||||
};
|
);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
setErrorCacheHeaders(res);
|
||||||
if (err instanceof Error) {
|
if (err instanceof Error) {
|
||||||
return {
|
return res.send(
|
||||||
status: "error - temporary",
|
renderError({
|
||||||
content: renderError({
|
|
||||||
message: err.message,
|
message: err.message,
|
||||||
secondaryMessage: retrieveSecondaryMessage(err),
|
secondaryMessage: retrieveSecondaryMessage(err),
|
||||||
renderOptions: {
|
renderOptions: {
|
||||||
@@ -166,11 +196,10 @@ export default async (
|
|||||||
show_repo_link: !(err instanceof MissingParamError),
|
show_repo_link: !(err instanceof MissingParamError),
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
};
|
);
|
||||||
}
|
}
|
||||||
return {
|
return res.send(
|
||||||
status: "error - temporary",
|
renderError({
|
||||||
content: renderError({
|
|
||||||
message: "An unknown error occurred",
|
message: "An unknown error occurred",
|
||||||
renderOptions: {
|
renderOptions: {
|
||||||
title_color,
|
title_color,
|
||||||
@@ -180,6 +209,6 @@ export default async (
|
|||||||
theme,
|
theme,
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
};
|
);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -1,18 +1,26 @@
|
|||||||
// @ts-check
|
// @ts-check
|
||||||
|
|
||||||
import { renderRepoCard } from "../cards/repo.js";
|
import { renderRepoCard } from "../src/cards/repo.js";
|
||||||
|
import { guardAccess } from "../src/common/access.js";
|
||||||
|
import {
|
||||||
|
CACHE_TTL,
|
||||||
|
resolveCacheSeconds,
|
||||||
|
setCacheHeaders,
|
||||||
|
setErrorCacheHeaders,
|
||||||
|
} from "../src/common/cache.js";
|
||||||
|
import { storeRequest } from "../src/common/database.js";
|
||||||
import {
|
import {
|
||||||
MissingParamError,
|
MissingParamError,
|
||||||
retrieveSecondaryMessage,
|
retrieveSecondaryMessage,
|
||||||
} from "../common/error.js";
|
} from "../src/common/error.js";
|
||||||
import { parseArray, parseBoolean } from "../common/ops.js";
|
import { parseArray, parseBoolean } from "../src/common/ops.js";
|
||||||
import { renderError } from "../common/render.js";
|
import { renderError } from "../src/common/render.js";
|
||||||
import { fetchRepo } from "../fetchers/repo.js";
|
import { fetchRepo } from "../src/fetchers/repo.js";
|
||||||
import { isLocaleAvailable } from "../translations.js";
|
import { isLocaleAvailable } from "../src/translations.js";
|
||||||
|
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
export default async (
|
export default async (req, res) => {
|
||||||
{
|
const {
|
||||||
username,
|
username,
|
||||||
repo,
|
repo,
|
||||||
hide_border,
|
hide_border,
|
||||||
@@ -23,23 +31,39 @@ export default async (
|
|||||||
card_width,
|
card_width,
|
||||||
theme,
|
theme,
|
||||||
show_owner,
|
show_owner,
|
||||||
browser_rendering,
|
|
||||||
show,
|
show,
|
||||||
show_icons,
|
show_icons,
|
||||||
number_format,
|
number_format,
|
||||||
text_bold,
|
text_bold,
|
||||||
line_height,
|
line_height,
|
||||||
|
cache_seconds,
|
||||||
locale,
|
locale,
|
||||||
border_radius,
|
border_radius,
|
||||||
border_color,
|
border_color,
|
||||||
description_lines_count,
|
description_lines_count,
|
||||||
},
|
} = req.query;
|
||||||
pat = null,
|
|
||||||
) => {
|
res.setHeader("Content-Type", "image/svg+xml");
|
||||||
|
|
||||||
|
const access = guardAccess({
|
||||||
|
res,
|
||||||
|
id: username,
|
||||||
|
type: "username",
|
||||||
|
colors: {
|
||||||
|
title_color,
|
||||||
|
text_color,
|
||||||
|
bg_color,
|
||||||
|
border_color,
|
||||||
|
theme,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!access.isPassed) {
|
||||||
|
return access.result;
|
||||||
|
}
|
||||||
|
|
||||||
if (locale && !isLocaleAvailable(locale)) {
|
if (locale && !isLocaleAvailable(locale)) {
|
||||||
return {
|
return res.send(
|
||||||
status: "error - permanent",
|
renderError({
|
||||||
content: renderError({
|
|
||||||
message: "Something went wrong",
|
message: "Something went wrong",
|
||||||
secondaryMessage: "Language not found",
|
secondaryMessage: "Language not found",
|
||||||
renderOptions: {
|
renderOptions: {
|
||||||
@@ -50,7 +74,7 @@ export default async (
|
|||||||
theme,
|
theme,
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
};
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const safePattern = /^[-\w/.,]+$/;
|
const safePattern = /^[-\w/.,]+$/;
|
||||||
@@ -58,9 +82,8 @@ export default async (
|
|||||||
(username && !safePattern.test(username)) ||
|
(username && !safePattern.test(username)) ||
|
||||||
(repo && !safePattern.test(repo))
|
(repo && !safePattern.test(repo))
|
||||||
) {
|
) {
|
||||||
return {
|
return res.send(
|
||||||
status: "error - permanent",
|
renderError({
|
||||||
content: renderError({
|
|
||||||
message: "Something went wrong",
|
message: "Something went wrong",
|
||||||
secondaryMessage: "Username or repository contains unsafe characters",
|
secondaryMessage: "Username or repository contains unsafe characters",
|
||||||
renderOptions: {
|
renderOptions: {
|
||||||
@@ -71,10 +94,11 @@ export default async (
|
|||||||
theme,
|
theme,
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
};
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
await storeRequest(req);
|
||||||
const showStats = parseArray(show);
|
const showStats = parseArray(show);
|
||||||
const repoData = await fetchRepo(
|
const repoData = await fetchRepo(
|
||||||
username,
|
username,
|
||||||
@@ -84,12 +108,19 @@ export default async (
|
|||||||
showStats.includes("prs_reviewed"),
|
showStats.includes("prs_reviewed"),
|
||||||
showStats.includes("issues_authored"),
|
showStats.includes("issues_authored"),
|
||||||
showStats.includes("issues_commented"),
|
showStats.includes("issues_commented"),
|
||||||
pat,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
return {
|
const cacheSeconds = resolveCacheSeconds({
|
||||||
status: "success",
|
requested: parseInt(cache_seconds, 10),
|
||||||
content: renderRepoCard(repoData, {
|
def: CACHE_TTL.PIN_CARD.DEFAULT,
|
||||||
|
min: CACHE_TTL.PIN_CARD.MIN,
|
||||||
|
max: CACHE_TTL.PIN_CARD.MAX,
|
||||||
|
});
|
||||||
|
|
||||||
|
setCacheHeaders(res, cacheSeconds);
|
||||||
|
|
||||||
|
return res.send(
|
||||||
|
renderRepoCard(repoData, {
|
||||||
hide_border: parseBoolean(hide_border),
|
hide_border: parseBoolean(hide_border),
|
||||||
title_color,
|
title_color,
|
||||||
icon_color,
|
icon_color,
|
||||||
@@ -100,7 +131,6 @@ export default async (
|
|||||||
border_color,
|
border_color,
|
||||||
card_width_input: parseInt(card_width, 10),
|
card_width_input: parseInt(card_width, 10),
|
||||||
show_owner: parseBoolean(show_owner),
|
show_owner: parseBoolean(show_owner),
|
||||||
browser_rendering: parseBoolean(browser_rendering),
|
|
||||||
show: showStats,
|
show: showStats,
|
||||||
show_icons: parseBoolean(show_icons),
|
show_icons: parseBoolean(show_icons),
|
||||||
number_format,
|
number_format,
|
||||||
@@ -110,12 +140,12 @@ export default async (
|
|||||||
locale: locale ? locale.toLowerCase() : null,
|
locale: locale ? locale.toLowerCase() : null,
|
||||||
description_lines_count,
|
description_lines_count,
|
||||||
}),
|
}),
|
||||||
};
|
);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
setErrorCacheHeaders(res);
|
||||||
if (err instanceof Error) {
|
if (err instanceof Error) {
|
||||||
return {
|
return res.send(
|
||||||
status: "error - temporary",
|
renderError({
|
||||||
content: renderError({
|
|
||||||
message: err.message,
|
message: err.message,
|
||||||
secondaryMessage: retrieveSecondaryMessage(err),
|
secondaryMessage: retrieveSecondaryMessage(err),
|
||||||
renderOptions: {
|
renderOptions: {
|
||||||
@@ -127,11 +157,10 @@ export default async (
|
|||||||
show_repo_link: !(err instanceof MissingParamError),
|
show_repo_link: !(err instanceof MissingParamError),
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
};
|
);
|
||||||
}
|
}
|
||||||
return {
|
return res.send(
|
||||||
status: "error - temporary",
|
renderError({
|
||||||
content: renderError({
|
|
||||||
message: "An unknown error occurred",
|
message: "An unknown error occurred",
|
||||||
renderOptions: {
|
renderOptions: {
|
||||||
title_color,
|
title_color,
|
||||||
@@ -141,6 +170,6 @@ export default async (
|
|||||||
theme,
|
theme,
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
};
|
);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -6,12 +6,10 @@
|
|||||||
*
|
*
|
||||||
* @description This function is currently rate limited to 1 request per 3 minutes.
|
* @description This function is currently rate limited to 1 request per 3 minutes.
|
||||||
*/
|
*/
|
||||||
import {
|
|
||||||
dateDiff,
|
import { request } from "../../src/common/http.js";
|
||||||
getConfig,
|
import { logger } from "../../src/common/log.js";
|
||||||
logger,
|
import { dateDiff } from "../../src/common/ops.js";
|
||||||
request,
|
|
||||||
} from "@stats-organization/github-readme-stats-core";
|
|
||||||
|
|
||||||
export const RATE_LIMIT_SECONDS = 60 * 3; // 1 request per 3 minutes
|
export const RATE_LIMIT_SECONDS = 60 * 3; // 1 request per 3 minutes
|
||||||
|
|
||||||
@@ -41,7 +39,7 @@ const uptimeFetcher = (variables, token) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const getAllPATs = () => {
|
const getAllPATs = () => {
|
||||||
return getConfig().pats;
|
return Object.keys(process.env).filter((key) => /PAT_\d*$/.exec(key));
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -63,7 +61,7 @@ const getPATInfo = async (fetcher, variables) => {
|
|||||||
|
|
||||||
for (const pat of PATs) {
|
for (const pat of PATs) {
|
||||||
try {
|
try {
|
||||||
const response = await fetcher(variables, pat.value);
|
const response = await fetcher(variables, process.env[pat]);
|
||||||
const errors = response.data.errors;
|
const errors = response.data.errors;
|
||||||
const hasErrors = Boolean(errors);
|
const hasErrors = Boolean(errors);
|
||||||
const errorType = errors?.[0]?.type;
|
const errorType = errors?.[0]?.type;
|
||||||
@@ -73,7 +71,7 @@ const getPATInfo = async (fetcher, variables) => {
|
|||||||
|
|
||||||
// Store PATs with errors.
|
// Store PATs with errors.
|
||||||
if (hasErrors && errorType !== "RATE_LIMITED") {
|
if (hasErrors && errorType !== "RATE_LIMITED") {
|
||||||
details[pat.name] = {
|
details[pat] = {
|
||||||
status: "error",
|
status: "error",
|
||||||
error: {
|
error: {
|
||||||
type: errors[0].type,
|
type: errors[0].type,
|
||||||
@@ -84,13 +82,13 @@ const getPATInfo = async (fetcher, variables) => {
|
|||||||
} else if (isRateLimited) {
|
} else if (isRateLimited) {
|
||||||
const date1 = new Date();
|
const date1 = new Date();
|
||||||
const date2 = new Date(response.data?.data?.rateLimit?.resetAt);
|
const date2 = new Date(response.data?.data?.rateLimit?.resetAt);
|
||||||
details[pat.name] = {
|
details[pat] = {
|
||||||
status: "exhausted",
|
status: "exhausted",
|
||||||
remaining: 0,
|
remaining: 0,
|
||||||
resetIn: dateDiff(date2, date1) + " minutes",
|
resetIn: dateDiff(date2, date1) + " minutes",
|
||||||
};
|
};
|
||||||
} else {
|
} else {
|
||||||
details[pat.name] = {
|
details[pat] = {
|
||||||
status: "valid",
|
status: "valid",
|
||||||
remaining: response.data.data.rateLimit.remaining,
|
remaining: response.data.data.rateLimit.remaining,
|
||||||
};
|
};
|
||||||
@@ -99,11 +97,11 @@ const getPATInfo = async (fetcher, variables) => {
|
|||||||
// Store the PAT if it is expired.
|
// Store the PAT if it is expired.
|
||||||
const errorMessage = err.response?.data?.message?.toLowerCase();
|
const errorMessage = err.response?.data?.message?.toLowerCase();
|
||||||
if (errorMessage === "bad credentials") {
|
if (errorMessage === "bad credentials") {
|
||||||
details[pat.name] = {
|
details[pat] = {
|
||||||
status: "expired",
|
status: "expired",
|
||||||
};
|
};
|
||||||
} else if (errorMessage === "sorry. your account was suspended.") {
|
} else if (errorMessage === "sorry. your account was suspended.") {
|
||||||
details[pat.name] = {
|
details[pat] = {
|
||||||
status: "suspended",
|
status: "suspended",
|
||||||
};
|
};
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -7,11 +7,9 @@
|
|||||||
* @description This function is currently rate limited to 1 request per 3 minutes.
|
* @description This function is currently rate limited to 1 request per 3 minutes.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import {
|
import { request } from "../../src/common/http.js";
|
||||||
logger,
|
import { logger } from "../../src/common/log.js";
|
||||||
request,
|
import { default as retryer } from "../../src/common/retryer.js";
|
||||||
retryer,
|
|
||||||
} from "@stats-organization/github-readme-stats-core";
|
|
||||||
|
|
||||||
export const RATE_LIMIT_SECONDS = 60 * 3; // 1 request per 3 minutes
|
export const RATE_LIMIT_SECONDS = 60 * 3; // 1 request per 3 minutes
|
||||||
|
|
||||||
@@ -89,7 +87,7 @@ export default async (req, res) => {
|
|||||||
try {
|
try {
|
||||||
let PATsValid = true;
|
let PATsValid = true;
|
||||||
try {
|
try {
|
||||||
await retryer(uptimeFetcher, {});
|
await retryer(uptimeFetcher, null, {});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// Resolve eslint no-unused-vars
|
// Resolve eslint no-unused-vars
|
||||||
err;
|
err;
|
||||||
|
|||||||
@@ -1,18 +1,26 @@
|
|||||||
// @ts-check
|
// @ts-check
|
||||||
|
|
||||||
import { renderTopLanguages } from "../cards/top-languages.js";
|
import { renderTopLanguages } from "../src/cards/top-languages.js";
|
||||||
|
import { guardAccess } from "../src/common/access.js";
|
||||||
|
import {
|
||||||
|
CACHE_TTL,
|
||||||
|
resolveCacheSeconds,
|
||||||
|
setCacheHeaders,
|
||||||
|
setErrorCacheHeaders,
|
||||||
|
} from "../src/common/cache.js";
|
||||||
|
import { storeRequest } from "../src/common/database.js";
|
||||||
import {
|
import {
|
||||||
MissingParamError,
|
MissingParamError,
|
||||||
retrieveSecondaryMessage,
|
retrieveSecondaryMessage,
|
||||||
} from "../common/error.js";
|
} from "../src/common/error.js";
|
||||||
import { parseArray, parseBoolean } from "../common/ops.js";
|
import { parseArray, parseBoolean } from "../src/common/ops.js";
|
||||||
import { renderError } from "../common/render.js";
|
import { renderError } from "../src/common/render.js";
|
||||||
import { fetchTopLanguages } from "../fetchers/top-languages.js";
|
import { fetchTopLanguages } from "../src/fetchers/top-languages.js";
|
||||||
import { isLocaleAvailable } from "../translations.js";
|
import { isLocaleAvailable } from "../src/translations.js";
|
||||||
|
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
export default async (
|
export default async (req, res) => {
|
||||||
{
|
const {
|
||||||
username,
|
username,
|
||||||
hide,
|
hide,
|
||||||
hide_title,
|
hide_title,
|
||||||
@@ -23,6 +31,7 @@ export default async (
|
|||||||
bg_color,
|
bg_color,
|
||||||
prog_bar_bg_color,
|
prog_bar_bg_color,
|
||||||
theme,
|
theme,
|
||||||
|
cache_seconds,
|
||||||
layout,
|
layout,
|
||||||
langs_count,
|
langs_count,
|
||||||
exclude_repo,
|
exclude_repo,
|
||||||
@@ -35,15 +44,29 @@ export default async (
|
|||||||
role,
|
role,
|
||||||
disable_animations,
|
disable_animations,
|
||||||
hide_progress,
|
hide_progress,
|
||||||
hide_values,
|
|
||||||
stats_format,
|
stats_format,
|
||||||
},
|
} = req.query;
|
||||||
pat = null,
|
res.setHeader("Content-Type", "image/svg+xml");
|
||||||
) => {
|
|
||||||
|
const access = guardAccess({
|
||||||
|
res,
|
||||||
|
id: username,
|
||||||
|
type: "username",
|
||||||
|
colors: {
|
||||||
|
title_color,
|
||||||
|
text_color,
|
||||||
|
bg_color,
|
||||||
|
border_color,
|
||||||
|
theme,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!access.isPassed) {
|
||||||
|
return access.result;
|
||||||
|
}
|
||||||
|
|
||||||
if (locale && !isLocaleAvailable(locale)) {
|
if (locale && !isLocaleAvailable(locale)) {
|
||||||
return {
|
return res.send(
|
||||||
status: "error - permanent",
|
renderError({
|
||||||
content: renderError({
|
|
||||||
message: "Something went wrong",
|
message: "Something went wrong",
|
||||||
secondaryMessage: "Locale not found",
|
secondaryMessage: "Locale not found",
|
||||||
renderOptions: {
|
renderOptions: {
|
||||||
@@ -54,7 +77,7 @@ export default async (
|
|||||||
theme,
|
theme,
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
};
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
@@ -62,9 +85,8 @@ export default async (
|
|||||||
(typeof layout !== "string" ||
|
(typeof layout !== "string" ||
|
||||||
!["compact", "normal", "donut", "donut-vertical", "pie"].includes(layout))
|
!["compact", "normal", "donut", "donut-vertical", "pie"].includes(layout))
|
||||||
) {
|
) {
|
||||||
return {
|
return res.send(
|
||||||
status: "error - permanent",
|
renderError({
|
||||||
content: renderError({
|
|
||||||
message: "Something went wrong",
|
message: "Something went wrong",
|
||||||
secondaryMessage: "Incorrect layout input",
|
secondaryMessage: "Incorrect layout input",
|
||||||
renderOptions: {
|
renderOptions: {
|
||||||
@@ -75,7 +97,7 @@ export default async (
|
|||||||
theme,
|
theme,
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
};
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
@@ -83,9 +105,8 @@ export default async (
|
|||||||
(typeof stats_format !== "string" ||
|
(typeof stats_format !== "string" ||
|
||||||
!["bytes", "percentages"].includes(stats_format))
|
!["bytes", "percentages"].includes(stats_format))
|
||||||
) {
|
) {
|
||||||
return {
|
return res.send(
|
||||||
status: "error - permanent",
|
renderError({
|
||||||
content: renderError({
|
|
||||||
message: "Something went wrong",
|
message: "Something went wrong",
|
||||||
secondaryMessage: "Incorrect stats_format input",
|
secondaryMessage: "Incorrect stats_format input",
|
||||||
renderOptions: {
|
renderOptions: {
|
||||||
@@ -96,22 +117,29 @@ export default async (
|
|||||||
theme,
|
theme,
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
};
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
await storeRequest(req);
|
||||||
const topLangs = await fetchTopLanguages(
|
const topLangs = await fetchTopLanguages(
|
||||||
username,
|
username,
|
||||||
parseArray(exclude_repo),
|
parseArray(exclude_repo),
|
||||||
size_weight,
|
size_weight,
|
||||||
count_weight,
|
count_weight,
|
||||||
parseArray(role),
|
parseArray(role),
|
||||||
pat,
|
|
||||||
);
|
);
|
||||||
|
const cacheSeconds = resolveCacheSeconds({
|
||||||
|
requested: parseInt(cache_seconds, 10),
|
||||||
|
def: CACHE_TTL.TOP_LANGS_CARD.DEFAULT,
|
||||||
|
min: CACHE_TTL.TOP_LANGS_CARD.MIN,
|
||||||
|
max: CACHE_TTL.TOP_LANGS_CARD.MAX,
|
||||||
|
});
|
||||||
|
|
||||||
return {
|
setCacheHeaders(res, cacheSeconds);
|
||||||
status: "success",
|
|
||||||
content: renderTopLanguages(topLangs, {
|
return res.send(
|
||||||
|
renderTopLanguages(topLangs, {
|
||||||
custom_title,
|
custom_title,
|
||||||
hide_title: parseBoolean(hide_title),
|
hide_title: parseBoolean(hide_title),
|
||||||
hide_border: parseBoolean(hide_border),
|
hide_border: parseBoolean(hide_border),
|
||||||
@@ -129,15 +157,14 @@ export default async (
|
|||||||
locale: locale ? locale.toLowerCase() : null,
|
locale: locale ? locale.toLowerCase() : null,
|
||||||
disable_animations: parseBoolean(disable_animations),
|
disable_animations: parseBoolean(disable_animations),
|
||||||
hide_progress: parseBoolean(hide_progress),
|
hide_progress: parseBoolean(hide_progress),
|
||||||
hide_values: parseBoolean(hide_values),
|
|
||||||
stats_format,
|
stats_format,
|
||||||
}),
|
}),
|
||||||
};
|
);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
setErrorCacheHeaders(res);
|
||||||
if (err instanceof Error) {
|
if (err instanceof Error) {
|
||||||
return {
|
return res.send(
|
||||||
status: "error - temporary",
|
renderError({
|
||||||
content: renderError({
|
|
||||||
message: err.message,
|
message: err.message,
|
||||||
secondaryMessage: retrieveSecondaryMessage(err),
|
secondaryMessage: retrieveSecondaryMessage(err),
|
||||||
renderOptions: {
|
renderOptions: {
|
||||||
@@ -149,11 +176,10 @@ export default async (
|
|||||||
show_repo_link: !(err instanceof MissingParamError),
|
show_repo_link: !(err instanceof MissingParamError),
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
};
|
);
|
||||||
}
|
}
|
||||||
return {
|
return res.send(
|
||||||
status: "error - temporary",
|
renderError({
|
||||||
content: renderError({
|
|
||||||
message: "An unknown error occurred",
|
message: "An unknown error occurred",
|
||||||
renderOptions: {
|
renderOptions: {
|
||||||
title_color,
|
title_color,
|
||||||
@@ -163,6 +189,6 @@ export default async (
|
|||||||
theme,
|
theme,
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
};
|
);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -1,6 +1,5 @@
|
|||||||
import { logger } from "@stats-organization/github-readme-stats-core";
|
|
||||||
|
|
||||||
import { getUserAccessByKey } from "../src/common/database.js";
|
import { getUserAccessByKey } from "../src/common/database.js";
|
||||||
|
import { logger } from "../src/common/log.js";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param {any} req The request.
|
* @param {any} req The request.
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
import {
|
import { logger } from "../src/common/log.js";
|
||||||
fetchWakatimeStats,
|
import { fetchWakatimeStats } from "../src/fetchers/wakatime.js";
|
||||||
logger,
|
|
||||||
} from "@stats-organization/github-readme-stats-core";
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param {any} req The request.
|
* @param {any} req The request.
|
||||||
|
|||||||
@@ -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 express from "express";
|
||||||
|
|
||||||
import router from "./router.js";
|
import gistCard from "./api-renamed/gist.js";
|
||||||
|
import statsCard from "./api-renamed/index.js";
|
||||||
|
import repoCard from "./api-renamed/pin.js";
|
||||||
|
import langCard from "./api-renamed/top-langs.js";
|
||||||
|
import wakatimeCard from "./api-renamed/wakatime.js";
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
app.use(router);
|
router.get("/", statsCard);
|
||||||
|
router.get("/pin", repoCard);
|
||||||
|
router.get("/top-langs", langCard);
|
||||||
|
router.get("/wakatime", wakatimeCard);
|
||||||
|
router.get("/gist", gistCard);
|
||||||
|
|
||||||
|
app.use("/api", router);
|
||||||
|
|
||||||
const port = process.env.PORT || process.env.port || 9000;
|
const port = process.env.PORT || process.env.port || 9000;
|
||||||
app.listen(port, "0.0.0.0", () => {
|
app.listen(port, "0.0.0.0", () => {
|
||||||
|
|||||||
@@ -1 +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",
|
"type": "module",
|
||||||
"license": "MIT",
|
"homepage": "https://github-stats-extended.vercel.app/frontend",
|
||||||
"engines": {
|
"bugs": {
|
||||||
"node": "24.x"
|
"url": "https://github.com/stats-organization/github-stats-extended/issues"
|
||||||
|
},
|
||||||
|
"repository": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://github.com/stats-organization/github-stats-extended.git"
|
||||||
},
|
},
|
||||||
"private": true,
|
|
||||||
"main": "index.js",
|
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"test": "vitest",
|
"test": "vitest",
|
||||||
"test:update:snapshot": "vitest -u",
|
"test:update:snapshot": "vitest -u",
|
||||||
"test:e2e": "vitest --config vitest.config.e2e.ts",
|
"test:e2e": "vitest --config vitest.config.e2e.ts",
|
||||||
"bench": "vitest bench --run --config vitest.config.bench.ts",
|
"theme-readme-gen": "node scripts/generate-theme-doc",
|
||||||
"lint": "eslint",
|
"generate-langs-json": "node scripts/generate-langs-json",
|
||||||
"typecheck": "tsc -p tsconfig.typecheck.json"
|
"bench": "vitest bench --run --config vitest.config.bench.ts"
|
||||||
},
|
},
|
||||||
|
"author": "Anurag Hazra",
|
||||||
|
"license": "MIT",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"axios-mock-adapter": "2.1.0",
|
"@testing-library/dom": "^10.4.1",
|
||||||
"express": "5.2.1",
|
"@testing-library/jest-dom": "^6.9.1",
|
||||||
"jsdom": "catalog:default",
|
"@uppercod/css-to-object": "^1.1.1",
|
||||||
|
"@vitest/coverage-v8": "catalog:default",
|
||||||
|
"axios-mock-adapter": "^2.1.0",
|
||||||
|
"express": "^5.2.1",
|
||||||
|
"js-yaml": "^4.1.1",
|
||||||
|
"jsdom": "28.1.0",
|
||||||
"vitest": "catalog:default"
|
"vitest": "catalog:default"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"axios": "catalog:default",
|
"axios": "^1.13.5",
|
||||||
"@stats-organization/github-readme-stats-core": "workspace:^",
|
"emoji-name-map": "^2.0.3",
|
||||||
"pg": "^8.21.0"
|
"github-username-regex": "^1.0.0",
|
||||||
|
"pg": "^8.18.0",
|
||||||
|
"word-wrap": "^1.2.5"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "24.x"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 5.2 KiB |
@@ -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 fs from "fs";
|
||||||
|
|
||||||
import { themes } from "../src/themes/index.js";
|
import { themes } from "../themes/index.js";
|
||||||
|
|
||||||
const TARGET_FILE = "./src/themes/README.md";
|
const TARGET_FILE = "./themes/README.md";
|
||||||
const REPO_CARD_LINKS_FLAG = "<!-- REPO_CARD_LINKS -->";
|
const REPO_CARD_LINKS_FLAG = "<!-- REPO_CARD_LINKS -->";
|
||||||
const STAT_CARD_LINKS_FLAG = "<!-- STATS_CARD_LINKS -->";
|
const STAT_CARD_LINKS_FLAG = "<!-- STATS_CARD_LINKS -->";
|
||||||
|
|
||||||
@@ -8,23 +8,15 @@ import { icons } from "../common/icons.js";
|
|||||||
import languageColors from "../common/languageColors.json" with { type: "json" };
|
import languageColors from "../common/languageColors.json" with { type: "json" };
|
||||||
import { parseEmojis } from "../common/ops.js";
|
import { parseEmojis } from "../common/ops.js";
|
||||||
import {
|
import {
|
||||||
countWrappedLines,
|
|
||||||
createLanguageNode,
|
createLanguageNode,
|
||||||
flexLayout,
|
flexLayout,
|
||||||
iconWithLabel,
|
iconWithLabel,
|
||||||
measureText,
|
measureText,
|
||||||
wrappedTextNode,
|
|
||||||
wrappedTextStyles,
|
|
||||||
} from "../common/render.js";
|
} from "../common/render.js";
|
||||||
|
|
||||||
const ICON_SIZE = 16;
|
const ICON_SIZE = 16;
|
||||||
const CARD_DEFAULT_WIDTH = 400;
|
const CARD_DEFAULT_WIDTH = 400;
|
||||||
const X_OFFSET = 25;
|
|
||||||
const HEADER_MAX_LENGTH = 35;
|
const HEADER_MAX_LENGTH = 35;
|
||||||
const DESCRIPTION_BOX_WIDTH = CARD_DEFAULT_WIDTH - 2 * X_OFFSET;
|
|
||||||
const DESCRIPTION_FONT_SIZE = 13;
|
|
||||||
const DESCRIPTION_LINE_HEIGHT_PX = 16;
|
|
||||||
const DESCRIPTION_MAX_LINES = 10;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @typedef {import('./types').GistCardOptions} GistCardOptions Gist card options.
|
* @typedef {import('./types').GistCardOptions} GistCardOptions Gist card options.
|
||||||
@@ -50,7 +42,6 @@ const renderGistCard = (gistData, options = {}) => {
|
|||||||
border_radius,
|
border_radius,
|
||||||
border_color,
|
border_color,
|
||||||
show_owner = false,
|
show_owner = false,
|
||||||
browser_rendering = false,
|
|
||||||
hide_border = false,
|
hide_border = false,
|
||||||
} = options;
|
} = options;
|
||||||
|
|
||||||
@@ -65,50 +56,14 @@ const renderGistCard = (gistData, options = {}) => {
|
|||||||
theme,
|
theme,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const lineWidth = 59;
|
||||||
|
const linesLimit = 10;
|
||||||
const desc = parseEmojis(description || "No description provided");
|
const desc = parseEmojis(description || "No description provided");
|
||||||
|
const multiLineDescription = wrapTextMultiline(desc, lineWidth, linesLimit);
|
||||||
let descriptionLines, descriptionSvg;
|
const descriptionLines = multiLineDescription.length;
|
||||||
if (browser_rendering) {
|
const descriptionSvg = multiLineDescription
|
||||||
// The browser performs the actual text wrapping inside the foreignObject;
|
.map((line) => `<tspan dy="1.2em" x="25">${encodeHTML(line)}</tspan>`)
|
||||||
// we only estimate the line count server-side so the SVG can reserve enough
|
.join("");
|
||||||
// height. The estimate uses measureText for font-aware widths instead of a
|
|
||||||
// fixed character count.
|
|
||||||
descriptionLines = countWrappedLines(
|
|
||||||
desc,
|
|
||||||
DESCRIPTION_FONT_SIZE,
|
|
||||||
DESCRIPTION_BOX_WIDTH,
|
|
||||||
DESCRIPTION_MAX_LINES,
|
|
||||||
);
|
|
||||||
|
|
||||||
descriptionSvg = wrappedTextNode({
|
|
||||||
text: desc,
|
|
||||||
x: X_OFFSET,
|
|
||||||
y: -3,
|
|
||||||
width: DESCRIPTION_BOX_WIDTH,
|
|
||||||
height: descriptionLines * DESCRIPTION_LINE_HEIGHT_PX + 10, // 10px extra for "descenders" like g, j, q, p, y
|
|
||||||
lineCount: descriptionLines,
|
|
||||||
className: "description",
|
|
||||||
testId: "description-text",
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
const linesLimit = 10;
|
|
||||||
const multiLineDescription = wrapTextMultiline(
|
|
||||||
desc,
|
|
||||||
DESCRIPTION_BOX_WIDTH,
|
|
||||||
DESCRIPTION_FONT_SIZE,
|
|
||||||
linesLimit,
|
|
||||||
);
|
|
||||||
descriptionLines = multiLineDescription.length;
|
|
||||||
descriptionSvg = multiLineDescription
|
|
||||||
.map(
|
|
||||||
(line) =>
|
|
||||||
`<tspan dy="1.2em" x="${X_OFFSET}">${encodeHTML(line)}</tspan>`,
|
|
||||||
)
|
|
||||||
.join("");
|
|
||||||
descriptionSvg = `<text class="description" x="${X_OFFSET}" y="-5">
|
|
||||||
${descriptionSvg}
|
|
||||||
</text>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
const lineHeight = descriptionLines > 3 ? 12 : 10;
|
const lineHeight = descriptionLines > 3 ? 12 : 10;
|
||||||
const height =
|
const height =
|
||||||
@@ -166,17 +121,16 @@ const renderGistCard = (gistData, options = {}) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
card.setCSS(`
|
card.setCSS(`
|
||||||
.description {
|
.description { font: 400 13px 'Segoe UI', Ubuntu, Sans-Serif; fill: ${textColor} }
|
||||||
font: 400 ${DESCRIPTION_FONT_SIZE}px 'Segoe UI', Ubuntu, Sans-Serif;fill: ${textColor};
|
|
||||||
${browser_rendering ? wrappedTextStyles(textColor) : ""}
|
|
||||||
}
|
|
||||||
.gray { font: 400 12px 'Segoe UI', Ubuntu, Sans-Serif; fill: ${textColor} }
|
.gray { font: 400 12px 'Segoe UI', Ubuntu, Sans-Serif; fill: ${textColor} }
|
||||||
.icon { fill: ${iconColor} }
|
.icon { fill: ${iconColor} }
|
||||||
`);
|
`);
|
||||||
card.setHideBorder(hide_border);
|
card.setHideBorder(hide_border);
|
||||||
|
|
||||||
return card.render(`
|
return card.render(`
|
||||||
${descriptionSvg}
|
<text class="description" x="25" y="-5">
|
||||||
|
${descriptionSvg}
|
||||||
|
</text>
|
||||||
|
|
||||||
<g transform="translate(30, ${height - 75})">
|
<g transform="translate(30, ${height - 75})">
|
||||||
${starAndForkCount}
|
${starAndForkCount}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
export { renderRepoCard } from "./repo.js";
|
||||||
|
export { renderStatsCard } from "./stats.js";
|
||||||
|
export { renderTopLanguages } from "./top-languages.js";
|
||||||
|
export { renderWakatimeCard } from "./wakatime.js";
|
||||||
@@ -8,23 +8,17 @@ import { encodeHTML } from "../common/html.js";
|
|||||||
import { icons } from "../common/icons.js";
|
import { icons } from "../common/icons.js";
|
||||||
import { buildSearchFilter, clampValue, parseEmojis } from "../common/ops.js";
|
import { buildSearchFilter, clampValue, parseEmojis } from "../common/ops.js";
|
||||||
import {
|
import {
|
||||||
countWrappedLines,
|
|
||||||
createLanguageNode,
|
createLanguageNode,
|
||||||
flexLayout,
|
flexLayout,
|
||||||
iconWithLabel,
|
iconWithLabel,
|
||||||
measureText,
|
measureText,
|
||||||
wrappedTextNode,
|
|
||||||
wrappedTextStyles,
|
|
||||||
} from "../common/render.js";
|
} from "../common/render.js";
|
||||||
import { repoCardLocales } from "../translations.js";
|
import { repoCardLocales } from "../translations.js";
|
||||||
|
|
||||||
import { createTextNode } from "./stats.js";
|
import { createTextNode } from "./stats.js";
|
||||||
|
|
||||||
const ICON_SIZE = 16;
|
const ICON_SIZE = 16;
|
||||||
const CARD_DEFAULT_WIDTH = 400;
|
const DESCRIPTION_LINE_WIDTH = 59;
|
||||||
const X_OFFSET = 25;
|
|
||||||
const DESCRIPTION_FONT_SIZE = 13;
|
|
||||||
const DESCRIPTION_LINE_HEIGHT_PX = 16;
|
|
||||||
const DESCRIPTION_MAX_LINES = 3;
|
const DESCRIPTION_MAX_LINES = 3;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -85,7 +79,6 @@ const renderRepoCard = (repo, options = {}) => {
|
|||||||
bg_color,
|
bg_color,
|
||||||
card_width_input,
|
card_width_input,
|
||||||
show_owner = false,
|
show_owner = false,
|
||||||
browser_rendering = false,
|
|
||||||
show = [],
|
show = [],
|
||||||
show_icons = true,
|
show_icons = true,
|
||||||
number_format = "short",
|
number_format = "short",
|
||||||
@@ -103,8 +96,8 @@ const renderRepoCard = (repo, options = {}) => {
|
|||||||
card_width_input && !isNaN(card_width_input)
|
card_width_input && !isNaN(card_width_input)
|
||||||
? card_width_input
|
? card_width_input
|
||||||
: show.length >= 2
|
: show.length >= 2
|
||||||
? CARD_DEFAULT_WIDTH + 30
|
? 430
|
||||||
: CARD_DEFAULT_WIDTH;
|
: 400;
|
||||||
|
|
||||||
const i18n = new I18n({
|
const i18n = new I18n({
|
||||||
locale,
|
locale,
|
||||||
@@ -182,56 +175,23 @@ const renderRepoCard = (repo, options = {}) => {
|
|||||||
const header = show_owner ? nameWithOwner : name;
|
const header = show_owner ? nameWithOwner : name;
|
||||||
const langName = (primaryLanguage && primaryLanguage.name) || "Unspecified";
|
const langName = (primaryLanguage && primaryLanguage.name) || "Unspecified";
|
||||||
const langColor = (primaryLanguage && primaryLanguage.color) || "#333";
|
const langColor = (primaryLanguage && primaryLanguage.color) || "#333";
|
||||||
const desc = parseEmojis(description || "No description provided");
|
const descriptionMaxLines = description_lines_count
|
||||||
const descriptionBoxWidth = card_width - 2 * X_OFFSET;
|
? clampValue(description_lines_count, 1, DESCRIPTION_MAX_LINES)
|
||||||
|
: DESCRIPTION_MAX_LINES;
|
||||||
|
|
||||||
let descriptionLinesCount, descriptionSvg;
|
const desc = parseEmojis(description || "No description provided");
|
||||||
if (browser_rendering) {
|
const multiLineDescription = wrapTextMultiline(
|
||||||
// The browser performs the actual text wrapping inside the foreignObject;
|
desc,
|
||||||
// we only estimate the line count server-side so the SVG can reserve enough
|
Math.round((card_width - 400) / 5.93 + DESCRIPTION_LINE_WIDTH),
|
||||||
// height. The estimate uses measureText for font-aware widths instead of a
|
descriptionMaxLines,
|
||||||
// fixed character count.
|
);
|
||||||
descriptionLinesCount = description_lines_count
|
const descriptionLinesCount = description_lines_count
|
||||||
? clampValue(description_lines_count, 1, DESCRIPTION_MAX_LINES)
|
? clampValue(description_lines_count, 1, DESCRIPTION_MAX_LINES)
|
||||||
: countWrappedLines(
|
: multiLineDescription.length;
|
||||||
desc,
|
|
||||||
DESCRIPTION_FONT_SIZE,
|
const descriptionSvg = multiLineDescription
|
||||||
descriptionBoxWidth,
|
.map((line) => `<tspan dy="1.2em" x="25">${encodeHTML(line)}</tspan>`)
|
||||||
DESCRIPTION_MAX_LINES,
|
.join("");
|
||||||
);
|
|
||||||
descriptionSvg = wrappedTextNode({
|
|
||||||
text: desc,
|
|
||||||
x: X_OFFSET,
|
|
||||||
y: -3,
|
|
||||||
width: descriptionBoxWidth,
|
|
||||||
height: descriptionLinesCount * DESCRIPTION_LINE_HEIGHT_PX + 10, // 10px extra for "descenders" like g, j, q, p, y
|
|
||||||
lineCount: descriptionLinesCount,
|
|
||||||
className: "description",
|
|
||||||
testId: "description-text",
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
const descriptionMaxLines = description_lines_count
|
|
||||||
? clampValue(description_lines_count, 1, DESCRIPTION_MAX_LINES)
|
|
||||||
: DESCRIPTION_MAX_LINES;
|
|
||||||
const multiLineDescription = wrapTextMultiline(
|
|
||||||
desc,
|
|
||||||
descriptionBoxWidth,
|
|
||||||
DESCRIPTION_FONT_SIZE,
|
|
||||||
descriptionMaxLines,
|
|
||||||
);
|
|
||||||
descriptionLinesCount = description_lines_count
|
|
||||||
? clampValue(description_lines_count, 1, DESCRIPTION_MAX_LINES)
|
|
||||||
: multiLineDescription.length;
|
|
||||||
descriptionSvg = multiLineDescription
|
|
||||||
.map(
|
|
||||||
(line) =>
|
|
||||||
`<tspan dy="1.2em" x="${X_OFFSET}">${encodeHTML(line)}</tspan>`,
|
|
||||||
)
|
|
||||||
.join("");
|
|
||||||
descriptionSvg = `<text class="description" x="${X_OFFSET}" y="-5">
|
|
||||||
${descriptionSvg}
|
|
||||||
</text>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
const extraHeight = Object.keys(STATS).length
|
const extraHeight = Object.keys(STATS).length
|
||||||
? -7 + (Math.ceil(statItems.length / 2) + 1) * extraLHeight
|
? -7 + (Math.ceil(statItems.length / 2) + 1) * extraLHeight
|
||||||
@@ -313,14 +273,11 @@ const renderRepoCard = (repo, options = {}) => {
|
|||||||
card.setHideBorder(hide_border);
|
card.setHideBorder(hide_border);
|
||||||
card.setHideTitle(false);
|
card.setHideTitle(false);
|
||||||
card.setCSS(`
|
card.setCSS(`
|
||||||
.description {
|
.description { font: 400 13px 'Segoe UI', Ubuntu, Sans-Serif; fill: ${colors.textColor} }
|
||||||
font: 400 ${DESCRIPTION_FONT_SIZE}px 'Segoe UI', Ubuntu, Sans-Serif;fill: ${colors.textColor};
|
|
||||||
${browser_rendering ? wrappedTextStyles(colors.textColor) : ""}
|
|
||||||
}
|
|
||||||
.gray { font: 400 12px 'Segoe UI', Ubuntu, Sans-Serif; fill: ${colors.textColor} }
|
.gray { font: 400 12px 'Segoe UI', Ubuntu, Sans-Serif; fill: ${colors.textColor} }
|
||||||
.badge { font: 600 11px 'Segoe UI', Ubuntu, Sans-Serif; }
|
.badge { font: 600 11px 'Segoe UI', Ubuntu, Sans-Serif; }
|
||||||
.badge rect { opacity: 0.2 }
|
.badge rect { opacity: 0.2 }
|
||||||
|
|
||||||
.stat { font: 400 12px 'Segoe UI', Ubuntu, Sans-Serif; fill: ${colors.textColor} }
|
.stat { font: 400 12px 'Segoe UI', Ubuntu, Sans-Serif; fill: ${colors.textColor} }
|
||||||
.stagger {
|
.stagger {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
@@ -341,19 +298,21 @@ const renderRepoCard = (repo, options = {}) => {
|
|||||||
getBadgeSVG(
|
getBadgeSVG(
|
||||||
i18n.t("repocard.template"),
|
i18n.t("repocard.template"),
|
||||||
colors.textColor,
|
colors.textColor,
|
||||||
card_width - CARD_DEFAULT_WIDTH,
|
card_width - 400,
|
||||||
)
|
)
|
||||||
: isArchived
|
: isArchived
|
||||||
? // @ts-ignore
|
? // @ts-ignore
|
||||||
getBadgeSVG(
|
getBadgeSVG(
|
||||||
i18n.t("repocard.archived"),
|
i18n.t("repocard.archived"),
|
||||||
colors.textColor,
|
colors.textColor,
|
||||||
card_width - CARD_DEFAULT_WIDTH,
|
card_width - 400,
|
||||||
)
|
)
|
||||||
: ""
|
: ""
|
||||||
}
|
}
|
||||||
|
|
||||||
${descriptionSvg}
|
<text class="description" x="25" y="-5">
|
||||||
|
${descriptionSvg}
|
||||||
|
</text>
|
||||||
|
|
||||||
<g transform="translate(30, ${height - 75 - extraHeight})">
|
<g transform="translate(30, ${height - 75 - extraHeight})">
|
||||||
${starAndForkCount}
|
${starAndForkCount}
|
||||||
@@ -233,7 +233,7 @@ const getStyles = ({
|
|||||||
transform: rotate(-90deg);
|
transform: rotate(-90deg);
|
||||||
animation: rankAnimation 1s forwards ease-in-out;
|
animation: rankAnimation 1s forwards ease-in-out;
|
||||||
}
|
}
|
||||||
${getProgressAnimation({ progress })}
|
${process.env.NODE_ENV === "test" ? "" : getProgressAnimation({ progress })}
|
||||||
`;
|
`;
|
||||||
};
|
};
|
||||||
|
|
||||||
+11
-57
@@ -221,7 +221,6 @@ const getDisplayValue = (size, percentages, format) => {
|
|||||||
* @param {number} props.size Size of the programming language.
|
* @param {number} props.size Size of the programming language.
|
||||||
* @param {number} props.totalSize Total size of all languages.
|
* @param {number} props.totalSize Total size of all languages.
|
||||||
* @param {string} props.statsFormat Stats format.
|
* @param {string} props.statsFormat Stats format.
|
||||||
* @param {boolean=} props.hideValues Whether to hide stats values.
|
|
||||||
* @param {number} props.index Index of the programming language.
|
* @param {number} props.index Index of the programming language.
|
||||||
* @returns {string} Programming language SVG node.
|
* @returns {string} Programming language SVG node.
|
||||||
*/
|
*/
|
||||||
@@ -233,11 +232,10 @@ const createProgressTextNode = ({
|
|||||||
size,
|
size,
|
||||||
totalSize,
|
totalSize,
|
||||||
statsFormat,
|
statsFormat,
|
||||||
hideValues,
|
|
||||||
index,
|
index,
|
||||||
}) => {
|
}) => {
|
||||||
const staggerDelay = (index + 3) * 150;
|
const staggerDelay = (index + 3) * 150;
|
||||||
const paddingRight = hideValues ? CARD_PADDING * 2 : 95;
|
const paddingRight = 95;
|
||||||
const progressTextX = width - paddingRight + 10;
|
const progressTextX = width - paddingRight + 10;
|
||||||
const progressWidth = width - paddingRight;
|
const progressWidth = width - paddingRight;
|
||||||
|
|
||||||
@@ -247,7 +245,7 @@ const createProgressTextNode = ({
|
|||||||
return `
|
return `
|
||||||
<g class="stagger" style="animation-delay: ${staggerDelay}ms">
|
<g class="stagger" style="animation-delay: ${staggerDelay}ms">
|
||||||
<text data-testid="lang-name" x="2" y="15" class="lang-name">${name}</text>
|
<text data-testid="lang-name" x="2" y="15" class="lang-name">${name}</text>
|
||||||
${hideValues ? "" : `<text x="${progressTextX}" y="34" class="lang-name">${displayValue}</text>`}
|
<text x="${progressTextX}" y="34" class="lang-name">${displayValue}</text>
|
||||||
${createProgressNode({
|
${createProgressNode({
|
||||||
x: 0,
|
x: 0,
|
||||||
y: 25,
|
y: 25,
|
||||||
@@ -268,7 +266,6 @@ const createProgressTextNode = ({
|
|||||||
* @param {Lang} props.lang Programming language object.
|
* @param {Lang} props.lang Programming language object.
|
||||||
* @param {number} props.totalSize Total size of all languages.
|
* @param {number} props.totalSize Total size of all languages.
|
||||||
* @param {boolean=} props.hideProgress Whether to hide percentage.
|
* @param {boolean=} props.hideProgress Whether to hide percentage.
|
||||||
* @param {boolean=} props.hideValues Whether to hide stats values (percentages/bytes).
|
|
||||||
* @param {string=} props.statsFormat Stats format
|
* @param {string=} props.statsFormat Stats format
|
||||||
* @param {number} props.index Index of the programming language.
|
* @param {number} props.index Index of the programming language.
|
||||||
* @returns {string} Compact layout programming language SVG node.
|
* @returns {string} Compact layout programming language SVG node.
|
||||||
@@ -277,7 +274,6 @@ const createCompactLangNode = ({
|
|||||||
lang,
|
lang,
|
||||||
totalSize,
|
totalSize,
|
||||||
hideProgress,
|
hideProgress,
|
||||||
hideValues,
|
|
||||||
statsFormat = "percentages",
|
statsFormat = "percentages",
|
||||||
index,
|
index,
|
||||||
}) => {
|
}) => {
|
||||||
@@ -291,7 +287,7 @@ const createCompactLangNode = ({
|
|||||||
<g class="stagger" style="animation-delay: ${staggerDelay}ms">
|
<g class="stagger" style="animation-delay: ${staggerDelay}ms">
|
||||||
<circle cx="5" cy="6" r="5" fill="${color}" />
|
<circle cx="5" cy="6" r="5" fill="${color}" />
|
||||||
<text data-testid="lang-name" x="15" y="10" class='lang-name'>
|
<text data-testid="lang-name" x="15" y="10" class='lang-name'>
|
||||||
${lang.name} ${hideProgress || hideValues ? "" : displayValue}
|
${lang.name} ${hideProgress ? "" : displayValue}
|
||||||
</text>
|
</text>
|
||||||
</g>
|
</g>
|
||||||
`;
|
`;
|
||||||
@@ -304,7 +300,6 @@ const createCompactLangNode = ({
|
|||||||
* @param {Lang[]} props.langs Array of programming languages.
|
* @param {Lang[]} props.langs Array of programming languages.
|
||||||
* @param {number} props.totalSize Total size of all languages.
|
* @param {number} props.totalSize Total size of all languages.
|
||||||
* @param {boolean=} props.hideProgress Whether to hide percentage.
|
* @param {boolean=} props.hideProgress Whether to hide percentage.
|
||||||
* @param {boolean=} props.hideValues Whether to hide stats values.
|
|
||||||
* @param {string=} props.statsFormat Stats format
|
* @param {string=} props.statsFormat Stats format
|
||||||
* @returns {string} Programming languages SVG node.
|
* @returns {string} Programming languages SVG node.
|
||||||
*/
|
*/
|
||||||
@@ -312,7 +307,6 @@ const createLanguageTextNode = ({
|
|||||||
langs,
|
langs,
|
||||||
totalSize,
|
totalSize,
|
||||||
hideProgress,
|
hideProgress,
|
||||||
hideValues,
|
|
||||||
statsFormat,
|
statsFormat,
|
||||||
}) => {
|
}) => {
|
||||||
const longestLang = getLongestLang(langs);
|
const longestLang = getLongestLang(langs);
|
||||||
@@ -324,7 +318,6 @@ const createLanguageTextNode = ({
|
|||||||
lang,
|
lang,
|
||||||
totalSize,
|
totalSize,
|
||||||
hideProgress,
|
hideProgress,
|
||||||
hideValues,
|
|
||||||
statsFormat,
|
statsFormat,
|
||||||
index,
|
index,
|
||||||
}),
|
}),
|
||||||
@@ -351,23 +344,16 @@ const createLanguageTextNode = ({
|
|||||||
* @param {object} props Function properties.
|
* @param {object} props Function properties.
|
||||||
* @param {Lang[]} props.langs Array of programming languages.
|
* @param {Lang[]} props.langs Array of programming languages.
|
||||||
* @param {number} props.totalSize Total size of all languages.
|
* @param {number} props.totalSize Total size of all languages.
|
||||||
* @param {boolean=} props.hideValues Whether to hide stats values.
|
|
||||||
* @param {string} props.statsFormat Stats format
|
* @param {string} props.statsFormat Stats format
|
||||||
* @returns {string} Donut layout programming language SVG node.
|
* @returns {string} Donut layout programming language SVG node.
|
||||||
*/
|
*/
|
||||||
const createDonutLanguagesNode = ({
|
const createDonutLanguagesNode = ({ langs, totalSize, statsFormat }) => {
|
||||||
langs,
|
|
||||||
totalSize,
|
|
||||||
hideValues,
|
|
||||||
statsFormat,
|
|
||||||
}) => {
|
|
||||||
return flexLayout({
|
return flexLayout({
|
||||||
items: langs.map((lang, index) => {
|
items: langs.map((lang, index) => {
|
||||||
return createCompactLangNode({
|
return createCompactLangNode({
|
||||||
lang,
|
lang,
|
||||||
totalSize,
|
totalSize,
|
||||||
hideProgress: false,
|
hideProgress: false,
|
||||||
hideValues,
|
|
||||||
statsFormat,
|
statsFormat,
|
||||||
index,
|
index,
|
||||||
});
|
});
|
||||||
@@ -385,7 +371,6 @@ const createDonutLanguagesNode = ({
|
|||||||
* @param {number} totalLanguageSize Total size of all languages.
|
* @param {number} totalLanguageSize Total size of all languages.
|
||||||
* @param progBarBgColor Color of the background of progress bar.
|
* @param progBarBgColor Color of the background of progress bar.
|
||||||
* @param {string} statsFormat Stats format.
|
* @param {string} statsFormat Stats format.
|
||||||
* @param {boolean=} hideValues Whether to hide stats values.
|
|
||||||
* @returns {string} Normal layout card SVG object.
|
* @returns {string} Normal layout card SVG object.
|
||||||
*/
|
*/
|
||||||
const renderNormalLayout = (
|
const renderNormalLayout = (
|
||||||
@@ -394,7 +379,6 @@ const renderNormalLayout = (
|
|||||||
totalLanguageSize,
|
totalLanguageSize,
|
||||||
progBarBgColor,
|
progBarBgColor,
|
||||||
statsFormat,
|
statsFormat,
|
||||||
hideValues,
|
|
||||||
) => {
|
) => {
|
||||||
return flexLayout({
|
return flexLayout({
|
||||||
items: langs.map((lang, index) => {
|
items: langs.map((lang, index) => {
|
||||||
@@ -405,7 +389,6 @@ const renderNormalLayout = (
|
|||||||
size: lang.size,
|
size: lang.size,
|
||||||
totalSize: totalLanguageSize,
|
totalSize: totalLanguageSize,
|
||||||
statsFormat,
|
statsFormat,
|
||||||
hideValues,
|
|
||||||
index,
|
index,
|
||||||
progBarBgColor,
|
progBarBgColor,
|
||||||
});
|
});
|
||||||
@@ -423,7 +406,6 @@ const renderNormalLayout = (
|
|||||||
* @param {number} totalLanguageSize Total size of all languages.
|
* @param {number} totalLanguageSize Total size of all languages.
|
||||||
* @param {boolean=} hideProgress Whether to hide progress bar.
|
* @param {boolean=} hideProgress Whether to hide progress bar.
|
||||||
* @param {string} statsFormat Stats format.
|
* @param {string} statsFormat Stats format.
|
||||||
* @param {boolean=} hideValues Whether to hide stats values.
|
|
||||||
* @returns {string} Compact layout card SVG object.
|
* @returns {string} Compact layout card SVG object.
|
||||||
*/
|
*/
|
||||||
const renderCompactLayout = (
|
const renderCompactLayout = (
|
||||||
@@ -432,7 +414,6 @@ const renderCompactLayout = (
|
|||||||
totalLanguageSize,
|
totalLanguageSize,
|
||||||
hideProgress,
|
hideProgress,
|
||||||
statsFormat = "percentages",
|
statsFormat = "percentages",
|
||||||
hideValues,
|
|
||||||
) => {
|
) => {
|
||||||
const paddingRight = 50;
|
const paddingRight = 50;
|
||||||
const offsetWidth = width - paddingRight;
|
const offsetWidth = width - paddingRight;
|
||||||
@@ -480,7 +461,6 @@ const renderCompactLayout = (
|
|||||||
totalSize: totalLanguageSize,
|
totalSize: totalLanguageSize,
|
||||||
hideProgress,
|
hideProgress,
|
||||||
statsFormat,
|
statsFormat,
|
||||||
hideValues,
|
|
||||||
})}
|
})}
|
||||||
</g>
|
</g>
|
||||||
`;
|
`;
|
||||||
@@ -492,15 +472,9 @@ const renderCompactLayout = (
|
|||||||
* @param {Lang[]} langs Array of programming languages.
|
* @param {Lang[]} langs Array of programming languages.
|
||||||
* @param {number} totalLanguageSize Total size of all languages.
|
* @param {number} totalLanguageSize Total size of all languages.
|
||||||
* @param {string} statsFormat Stats format.
|
* @param {string} statsFormat Stats format.
|
||||||
* @param {boolean=} hideValues Whether to hide stats values.
|
|
||||||
* @returns {string} Compact layout card SVG object.
|
* @returns {string} Compact layout card SVG object.
|
||||||
*/
|
*/
|
||||||
const renderDonutVerticalLayout = (
|
const renderDonutVerticalLayout = (langs, totalLanguageSize, statsFormat) => {
|
||||||
langs,
|
|
||||||
totalLanguageSize,
|
|
||||||
statsFormat,
|
|
||||||
hideValues,
|
|
||||||
) => {
|
|
||||||
// Donut vertical chart radius and total length
|
// Donut vertical chart radius and total length
|
||||||
const radius = 80;
|
const radius = 80;
|
||||||
const totalCircleLength = getCircleLength(radius);
|
const totalCircleLength = getCircleLength(radius);
|
||||||
@@ -557,7 +531,6 @@ const renderDonutVerticalLayout = (
|
|||||||
totalSize: totalLanguageSize,
|
totalSize: totalLanguageSize,
|
||||||
hideProgress: false,
|
hideProgress: false,
|
||||||
statsFormat,
|
statsFormat,
|
||||||
hideValues,
|
|
||||||
})}
|
})}
|
||||||
</svg>
|
</svg>
|
||||||
</g>
|
</g>
|
||||||
@@ -571,10 +544,9 @@ const renderDonutVerticalLayout = (
|
|||||||
* @param {Lang[]} langs Array of programming languages.
|
* @param {Lang[]} langs Array of programming languages.
|
||||||
* @param {number} totalLanguageSize Total size of all languages.
|
* @param {number} totalLanguageSize Total size of all languages.
|
||||||
* @param {string} statsFormat Stats format.
|
* @param {string} statsFormat Stats format.
|
||||||
* @param {boolean=} hideValues Whether to hide stats values.
|
|
||||||
* @returns {string} Compact layout card SVG object.
|
* @returns {string} Compact layout card SVG object.
|
||||||
*/
|
*/
|
||||||
const renderPieLayout = (langs, totalLanguageSize, statsFormat, hideValues) => {
|
const renderPieLayout = (langs, totalLanguageSize, statsFormat) => {
|
||||||
// Pie chart radius and center coordinates
|
// Pie chart radius and center coordinates
|
||||||
const radius = 90;
|
const radius = 90;
|
||||||
const centerX = 150;
|
const centerX = 150;
|
||||||
@@ -656,7 +628,6 @@ const renderPieLayout = (langs, totalLanguageSize, statsFormat, hideValues) => {
|
|||||||
totalSize: totalLanguageSize,
|
totalSize: totalLanguageSize,
|
||||||
hideProgress: false,
|
hideProgress: false,
|
||||||
statsFormat,
|
statsFormat,
|
||||||
hideValues,
|
|
||||||
})}
|
})}
|
||||||
</svg>
|
</svg>
|
||||||
</g>
|
</g>
|
||||||
@@ -676,7 +647,7 @@ const renderPieLayout = (langs, totalLanguageSize, statsFormat, hideValues) => {
|
|||||||
const createDonutPaths = (cx, cy, radius, percentages) => {
|
const createDonutPaths = (cx, cy, radius, percentages) => {
|
||||||
const paths = [];
|
const paths = [];
|
||||||
let startAngle = 0;
|
let startAngle = 0;
|
||||||
let endAngle;
|
let endAngle = 0;
|
||||||
|
|
||||||
const totalPercent = percentages.reduce((acc, curr) => acc + curr, 0);
|
const totalPercent = percentages.reduce((acc, curr) => acc + curr, 0);
|
||||||
for (let i = 0; i < percentages.length; i++) {
|
for (let i = 0; i < percentages.length; i++) {
|
||||||
@@ -708,16 +679,9 @@ const createDonutPaths = (cx, cy, radius, percentages) => {
|
|||||||
* @param {number} width Card width.
|
* @param {number} width Card width.
|
||||||
* @param {number} totalLanguageSize Total size of all languages.
|
* @param {number} totalLanguageSize Total size of all languages.
|
||||||
* @param {string} statsFormat Stats format.
|
* @param {string} statsFormat Stats format.
|
||||||
* @param {boolean=} hideValues Whether to hide stats values.
|
|
||||||
* @returns {string} Donut layout card SVG object.
|
* @returns {string} Donut layout card SVG object.
|
||||||
*/
|
*/
|
||||||
const renderDonutLayout = (
|
const renderDonutLayout = (langs, width, totalLanguageSize, statsFormat) => {
|
||||||
langs,
|
|
||||||
width,
|
|
||||||
totalLanguageSize,
|
|
||||||
statsFormat,
|
|
||||||
hideValues,
|
|
||||||
) => {
|
|
||||||
const centerX = width / 3;
|
const centerX = width / 3;
|
||||||
const centerY = width / 3;
|
const centerY = width / 3;
|
||||||
const radius = centerX - 60;
|
const radius = centerX - 60;
|
||||||
@@ -760,7 +724,7 @@ const renderDonutLayout = (
|
|||||||
return `
|
return `
|
||||||
<g transform="translate(0, 0)">
|
<g transform="translate(0, 0)">
|
||||||
<g transform="translate(0, 0)">
|
<g transform="translate(0, 0)">
|
||||||
${createDonutLanguagesNode({ langs, totalSize: totalLanguageSize, hideValues, statsFormat })}
|
${createDonutLanguagesNode({ langs, totalSize: totalLanguageSize, statsFormat })}
|
||||||
</g>
|
</g>
|
||||||
|
|
||||||
<g transform="translate(125, ${donutCenterTranslation(langs.length)})">
|
<g transform="translate(125, ${donutCenterTranslation(langs.length)})">
|
||||||
@@ -836,7 +800,6 @@ const renderTopLanguages = (topLangs, options = {}) => {
|
|||||||
prog_bar_bg_color,
|
prog_bar_bg_color,
|
||||||
hide,
|
hide,
|
||||||
hide_progress,
|
hide_progress,
|
||||||
hide_values,
|
|
||||||
theme,
|
theme,
|
||||||
layout,
|
layout,
|
||||||
custom_title,
|
custom_title,
|
||||||
@@ -877,7 +840,7 @@ const renderTopLanguages = (topLangs, options = {}) => {
|
|||||||
theme,
|
theme,
|
||||||
});
|
});
|
||||||
|
|
||||||
let finalLayout;
|
let finalLayout = "";
|
||||||
if (langs.length === 0) {
|
if (langs.length === 0) {
|
||||||
height = COMPACT_LAYOUT_BASE_HEIGHT;
|
height = COMPACT_LAYOUT_BASE_HEIGHT;
|
||||||
finalLayout = noLanguagesDataNode({
|
finalLayout = noLanguagesDataNode({
|
||||||
@@ -887,19 +850,13 @@ const renderTopLanguages = (topLangs, options = {}) => {
|
|||||||
});
|
});
|
||||||
} else if (layout === "pie") {
|
} else if (layout === "pie") {
|
||||||
height = calculatePieLayoutHeight(langs.length);
|
height = calculatePieLayoutHeight(langs.length);
|
||||||
finalLayout = renderPieLayout(
|
finalLayout = renderPieLayout(langs, totalLanguageSize, stats_format);
|
||||||
langs,
|
|
||||||
totalLanguageSize,
|
|
||||||
stats_format,
|
|
||||||
hide_values,
|
|
||||||
);
|
|
||||||
} else if (layout === "donut-vertical") {
|
} else if (layout === "donut-vertical") {
|
||||||
height = calculateDonutVerticalLayoutHeight(langs.length);
|
height = calculateDonutVerticalLayoutHeight(langs.length);
|
||||||
finalLayout = renderDonutVerticalLayout(
|
finalLayout = renderDonutVerticalLayout(
|
||||||
langs,
|
langs,
|
||||||
totalLanguageSize,
|
totalLanguageSize,
|
||||||
stats_format,
|
stats_format,
|
||||||
hide_values,
|
|
||||||
);
|
);
|
||||||
} else if (layout === "compact" || hide_progress == true) {
|
} else if (layout === "compact" || hide_progress == true) {
|
||||||
height =
|
height =
|
||||||
@@ -911,7 +868,6 @@ const renderTopLanguages = (topLangs, options = {}) => {
|
|||||||
totalLanguageSize,
|
totalLanguageSize,
|
||||||
hide_progress,
|
hide_progress,
|
||||||
stats_format,
|
stats_format,
|
||||||
hide_values,
|
|
||||||
);
|
);
|
||||||
} else if (layout === "donut") {
|
} else if (layout === "donut") {
|
||||||
height = calculateDonutLayoutHeight(langs.length);
|
height = calculateDonutLayoutHeight(langs.length);
|
||||||
@@ -921,7 +877,6 @@ const renderTopLanguages = (topLangs, options = {}) => {
|
|||||||
width,
|
width,
|
||||||
totalLanguageSize,
|
totalLanguageSize,
|
||||||
stats_format,
|
stats_format,
|
||||||
hide_values,
|
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
finalLayout = renderNormalLayout(
|
finalLayout = renderNormalLayout(
|
||||||
@@ -930,7 +885,6 @@ const renderTopLanguages = (topLangs, options = {}) => {
|
|||||||
totalLanguageSize,
|
totalLanguageSize,
|
||||||
fallbackColor(prog_bar_bg_color, "#ddd"),
|
fallbackColor(prog_bar_bg_color, "#ddd"),
|
||||||
stats_format,
|
stats_format,
|
||||||
hide_values,
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
type ThemeNames = keyof typeof import("../themes/index.ts");
|
type ThemeNames = keyof typeof import("../../themes/index.js");
|
||||||
type RankIcon = "default" | "github" | "percentile";
|
type RankIcon = "default" | "github" | "percentile";
|
||||||
|
|
||||||
interface CommonOptions {
|
type CommonOptions = {
|
||||||
title_color: string;
|
title_color: string;
|
||||||
icon_color: string;
|
icon_color: string;
|
||||||
text_color: string;
|
text_color: string;
|
||||||
@@ -11,10 +11,10 @@ interface CommonOptions {
|
|||||||
border_color: string;
|
border_color: string;
|
||||||
locale: string;
|
locale: string;
|
||||||
hide_border: boolean;
|
hide_border: boolean;
|
||||||
}
|
};
|
||||||
|
|
||||||
export type StatCardOptions = CommonOptions & {
|
export type StatCardOptions = CommonOptions & {
|
||||||
hide: Array<string>;
|
hide: string[];
|
||||||
show_icons: boolean;
|
show_icons: boolean;
|
||||||
hide_title: boolean;
|
hide_title: boolean;
|
||||||
card_width: number;
|
card_width: number;
|
||||||
@@ -29,15 +29,14 @@ export type StatCardOptions = CommonOptions & {
|
|||||||
ring_color: string;
|
ring_color: string;
|
||||||
text_bold: boolean;
|
text_bold: boolean;
|
||||||
rank_icon: RankIcon;
|
rank_icon: RankIcon;
|
||||||
show: Array<string>;
|
show: string[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export type RepoCardOptions = CommonOptions & {
|
export type RepoCardOptions = CommonOptions & {
|
||||||
show_owner: boolean;
|
show_owner: boolean;
|
||||||
browser_rendering: boolean;
|
|
||||||
description_lines_count: number;
|
description_lines_count: number;
|
||||||
card_width_input;
|
card_width_input;
|
||||||
show: Array<string>;
|
show: string[];
|
||||||
show_icons: boolean;
|
show_icons: boolean;
|
||||||
number_format: string;
|
number_format: string;
|
||||||
text_bold: boolean;
|
text_bold: boolean;
|
||||||
@@ -48,20 +47,19 @@ export type RepoCardOptions = CommonOptions & {
|
|||||||
export type TopLangOptions = CommonOptions & {
|
export type TopLangOptions = CommonOptions & {
|
||||||
hide_title: boolean;
|
hide_title: boolean;
|
||||||
card_width: number;
|
card_width: number;
|
||||||
hide: Array<string>;
|
hide: string[];
|
||||||
layout: "compact" | "normal" | "donut" | "donut-vertical" | "pie";
|
layout: "compact" | "normal" | "donut" | "donut-vertical" | "pie";
|
||||||
custom_title: string;
|
custom_title: string;
|
||||||
langs_count: number;
|
langs_count: number;
|
||||||
disable_animations: boolean;
|
disable_animations: boolean;
|
||||||
hide_progress: boolean;
|
hide_progress: boolean;
|
||||||
hide_values: boolean;
|
|
||||||
prog_bar_bg_color: string;
|
prog_bar_bg_color: string;
|
||||||
stats_format: "percentages" | "bytes";
|
stats_format: "percentages" | "bytes";
|
||||||
};
|
};
|
||||||
|
|
||||||
export type WakaTimeOptions = CommonOptions & {
|
export type WakaTimeOptions = CommonOptions & {
|
||||||
hide_title: boolean;
|
hide_title: boolean;
|
||||||
hide: Array<string>;
|
hide: string[];
|
||||||
card_width: number;
|
card_width: number;
|
||||||
line_height: string;
|
line_height: string;
|
||||||
hide_progress: boolean;
|
hide_progress: boolean;
|
||||||
@@ -74,5 +72,4 @@ export type WakaTimeOptions = CommonOptions & {
|
|||||||
|
|
||||||
export type GistCardOptions = CommonOptions & {
|
export type GistCardOptions = CommonOptions & {
|
||||||
show_owner: boolean;
|
show_owner: boolean;
|
||||||
browser_rendering: boolean;
|
|
||||||
};
|
};
|
||||||
@@ -305,7 +305,7 @@ const renderWakatimeCard = (stats = {}, options = { hide: [] }) => {
|
|||||||
textColor,
|
textColor,
|
||||||
});
|
});
|
||||||
|
|
||||||
let finalLayout;
|
let finalLayout = "";
|
||||||
|
|
||||||
// RENDER COMPACT LAYOUT
|
// RENDER COMPACT LAYOUT
|
||||||
if (layout === "compact") {
|
if (layout === "compact") {
|
||||||
@@ -230,7 +230,7 @@ class Card {
|
|||||||
}
|
}
|
||||||
${this.css}
|
${this.css}
|
||||||
|
|
||||||
${this.getAnimations()}
|
${process.env.NODE_ENV === "test" ? "" : this.getAnimations()}
|
||||||
${
|
${
|
||||||
this.animations === false
|
this.animations === false
|
||||||
? `* { animation-duration: 0s !important; animation-delay: 0s !important; }`
|
? `* { animation-duration: 0s !important; animation-delay: 0s !important; }`
|
||||||
@@ -1,11 +1,8 @@
|
|||||||
// @ts-check
|
// @ts-check
|
||||||
|
|
||||||
import {
|
|
||||||
getConfig,
|
|
||||||
renderError,
|
|
||||||
} from "@stats-organization/github-readme-stats-core";
|
|
||||||
|
|
||||||
import { blacklist } from "./blacklist.js";
|
import { blacklist } from "./blacklist.js";
|
||||||
|
import { gistWhitelist, whitelist } from "./envs.js";
|
||||||
|
import { renderError } from "./render.js";
|
||||||
|
|
||||||
const NOT_WHITELISTED_USERNAME_MESSAGE = "This username is not whitelisted";
|
const NOT_WHITELISTED_USERNAME_MESSAGE = "This username is not whitelisted";
|
||||||
const NOT_WHITELISTED_GIST_MESSAGE = "This gist ID is not whitelisted";
|
const NOT_WHITELISTED_GIST_MESSAGE = "This gist ID is not whitelisted";
|
||||||
@@ -15,35 +12,36 @@ const BLACKLISTED_MESSAGE = "This username is blacklisted";
|
|||||||
* Guards access using whitelist/blacklist.
|
* Guards access using whitelist/blacklist.
|
||||||
*
|
*
|
||||||
* @param {Object} args The parameters object.
|
* @param {Object} args The parameters object.
|
||||||
|
* @param {any} args.res The response object.
|
||||||
* @param {string} args.id Resource identifier (username or gist id).
|
* @param {string} args.id Resource identifier (username or gist id).
|
||||||
* @param {"username"|"gist"|"wakatime"} args.type The type of identifier.
|
* @param {"username"|"gist"|"wakatime"} args.type The type of identifier.
|
||||||
* @param {{ title_color?: string, text_color?: string, bg_color?: string, border_color?: string, theme?: string }} args.colors Color options for the error card.
|
* @param {{ title_color?: string, text_color?: string, bg_color?: string, border_color?: string, theme?: string }} args.colors Color options for the error card.
|
||||||
* @returns {{ isPassed: boolean, result?: any }} The result object indicating success or failure.
|
* @returns {{ isPassed: boolean, result?: any }} The result object indicating success or failure.
|
||||||
*/
|
*/
|
||||||
const guardAccess = ({ id, type, colors }) => {
|
const guardAccess = ({ res, id, type, colors }) => {
|
||||||
if (!["username", "gist", "wakatime"].includes(type)) {
|
if (!["username", "gist", "wakatime"].includes(type)) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
'Invalid type. Expected "username", "gist", or "wakatime".',
|
'Invalid type. Expected "username", "gist", or "wakatime".',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const config = getConfig();
|
const currentWhitelist = type === "gist" ? gistWhitelist : whitelist;
|
||||||
const currentWhitelist =
|
|
||||||
type === "gist" ? config.gistWhitelist : config.whitelist;
|
|
||||||
const notWhitelistedMsg =
|
const notWhitelistedMsg =
|
||||||
type === "gist"
|
type === "gist"
|
||||||
? NOT_WHITELISTED_GIST_MESSAGE
|
? NOT_WHITELISTED_GIST_MESSAGE
|
||||||
: NOT_WHITELISTED_USERNAME_MESSAGE;
|
: NOT_WHITELISTED_USERNAME_MESSAGE;
|
||||||
|
|
||||||
if (Array.isArray(currentWhitelist) && !currentWhitelist.includes(id)) {
|
if (Array.isArray(currentWhitelist) && !currentWhitelist.includes(id)) {
|
||||||
const result = renderError({
|
const result = res.send(
|
||||||
message: notWhitelistedMsg,
|
renderError({
|
||||||
secondaryMessage: "Please deploy your own instance",
|
message: notWhitelistedMsg,
|
||||||
renderOptions: {
|
secondaryMessage: "Please deploy your own instance",
|
||||||
...colors,
|
renderOptions: {
|
||||||
show_repo_link: false,
|
...colors,
|
||||||
},
|
show_repo_link: false,
|
||||||
});
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
return { isPassed: false, result };
|
return { isPassed: false, result };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -52,14 +50,16 @@ const guardAccess = ({ id, type, colors }) => {
|
|||||||
currentWhitelist === undefined &&
|
currentWhitelist === undefined &&
|
||||||
blacklist.includes(id)
|
blacklist.includes(id)
|
||||||
) {
|
) {
|
||||||
const result = renderError({
|
const result = res.send(
|
||||||
message: BLACKLISTED_MESSAGE,
|
renderError({
|
||||||
secondaryMessage: "Please deploy your own instance",
|
message: BLACKLISTED_MESSAGE,
|
||||||
renderOptions: {
|
secondaryMessage: "Please deploy your own instance",
|
||||||
...colors,
|
renderOptions: {
|
||||||
show_repo_link: false,
|
...colors,
|
||||||
},
|
show_repo_link: false,
|
||||||
});
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
return { isPassed: false, result };
|
return { isPassed: false, result };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// @ts-check
|
// @ts-check
|
||||||
|
|
||||||
import { clampValue } from "@stats-organization/github-readme-stats-core";
|
import { clampValue } from "./ops.js";
|
||||||
|
|
||||||
const MIN = 60;
|
const MIN = 60;
|
||||||
const HOUR = 60 * MIN;
|
const HOUR = 60 * MIN;
|
||||||
@@ -106,7 +106,7 @@ const disableCaching = (res) => {
|
|||||||
* @param {number} cacheSeconds The cache seconds to set in the headers.
|
* @param {number} cacheSeconds The cache seconds to set in the headers.
|
||||||
*/
|
*/
|
||||||
const setCacheHeaders = (res, cacheSeconds) => {
|
const setCacheHeaders = (res, cacheSeconds) => {
|
||||||
if (cacheSeconds < 1) {
|
if (cacheSeconds < 1 || process.env.NODE_ENV === "development") {
|
||||||
disableCaching(res);
|
disableCaching(res);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -128,7 +128,10 @@ const setErrorCacheHeaders = (res) => {
|
|||||||
const envCacheSeconds = process.env.CACHE_SECONDS
|
const envCacheSeconds = process.env.CACHE_SECONDS
|
||||||
? parseInt(process.env.CACHE_SECONDS, 10)
|
? parseInt(process.env.CACHE_SECONDS, 10)
|
||||||
: NaN;
|
: NaN;
|
||||||
if (!isNaN(envCacheSeconds) && envCacheSeconds < 1) {
|
if (
|
||||||
|
(!isNaN(envCacheSeconds) && envCacheSeconds < 1) ||
|
||||||
|
process.env.NODE_ENV === "development"
|
||||||
|
) {
|
||||||
disableCaching(res);
|
disableCaching(res);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// @ts-check
|
// @ts-check
|
||||||
|
|
||||||
import { themes } from "../themes/index.js";
|
import { themes } from "../../themes/index.js";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Checks if a string is a valid hex color.
|
* Checks if a string is a valid hex color.
|
||||||
@@ -1,10 +1,14 @@
|
|||||||
export let pool = null;
|
/**
|
||||||
if (process.env.POSTGRES_URL) {
|
* In the browser this has to be mocked to avoid runtime errors
|
||||||
const { Pool } = await import("pg");
|
* @see apps/frontend/vite.config.ts
|
||||||
pool = new Pool({
|
*/
|
||||||
connectionString: process.env.POSTGRES_URL,
|
import { Pool } from "pg";
|
||||||
});
|
|
||||||
}
|
export const pool = process.env.POSTGRES_URL
|
||||||
|
? new Pool({
|
||||||
|
connectionString: process.env.POSTGRES_URL,
|
||||||
|
})
|
||||||
|
: null;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates all required tables if they do not exist.
|
* Creates all required tables if they do not exist.
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
// @ts-check
|
||||||
|
|
||||||
|
const whitelist = process.env.WHITELIST
|
||||||
|
? process.env.WHITELIST.split(",")
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
const gistWhitelist = process.env.GIST_WHITELIST
|
||||||
|
? process.env.GIST_WHITELIST.split(",")
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
const excludeRepositories = process.env.EXCLUDE_REPO
|
||||||
|
? process.env.EXCLUDE_REPO.split(",")
|
||||||
|
: [];
|
||||||
|
|
||||||
|
export { whitelist, gistWhitelist, excludeRepositories };
|
||||||
@@ -1,7 +1,8 @@
|
|||||||
// @ts-check
|
// @ts-check
|
||||||
|
|
||||||
|
import wrap from "word-wrap";
|
||||||
|
|
||||||
import { encodeHTML } from "./html.js";
|
import { encodeHTML } from "./html.js";
|
||||||
import { splitWrappedText } from "./render.js";
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Retrieves num with suffix k(thousands) precise to given decimal places.
|
* Retrieves num with suffix k(thousands) precise to given decimal places.
|
||||||
@@ -56,16 +57,26 @@ const formatBytes = (bytes) => {
|
|||||||
* Split text over multiple lines based on the card width.
|
* Split text over multiple lines based on the card width.
|
||||||
*
|
*
|
||||||
* @param {string} text Text to split.
|
* @param {string} text Text to split.
|
||||||
* @param {number} width Available wrap width in px.
|
* @param {number} width Line width in number of characters.
|
||||||
* @param {number} fontSize Font size in px.
|
|
||||||
* @param {number} maxLines Maximum number of lines.
|
* @param {number} maxLines Maximum number of lines.
|
||||||
* @returns {string[]} Array of lines.
|
* @returns {string[]} Array of lines.
|
||||||
*/
|
*/
|
||||||
const wrapTextMultiline = (text, width, fontSize, maxLines = 3) => {
|
const wrapTextMultiline = (text, width = 59, maxLines = 3) => {
|
||||||
const wrapped = splitWrappedText(text, fontSize, width);
|
const fullWidthComma = ",";
|
||||||
const lines = wrapped
|
const encoded = encodeHTML(text);
|
||||||
.map((line) => encodeHTML(line.trim()))
|
const isChinese = encoded.includes(fullWidthComma);
|
||||||
.slice(0, maxLines); // Only consider maxLines lines
|
|
||||||
|
let wrapped = [];
|
||||||
|
|
||||||
|
if (isChinese) {
|
||||||
|
wrapped = encoded.split(fullWidthComma); // Chinese full punctuation
|
||||||
|
} else {
|
||||||
|
wrapped = wrap(encoded, {
|
||||||
|
width,
|
||||||
|
}).split("\n"); // Split wrapped lines to get an array of lines
|
||||||
|
}
|
||||||
|
|
||||||
|
const lines = wrapped.map((line) => line.trim()).slice(0, maxLines); // Only consider maxLines lines
|
||||||
|
|
||||||
// Add "..." to the last line if the text exceeds maxLines
|
// Add "..." to the last line if the text exceeds maxLines
|
||||||
if (wrapped.length > maxLines) {
|
if (wrapped.length > maxLines) {
|
||||||
@@ -1,31 +1,5 @@
|
|||||||
// @ts-check
|
// @ts-check
|
||||||
|
|
||||||
/*
|
|
||||||
The icons in this file are based on https://github.com/primer/octicons which is released under the MIT license:
|
|
||||||
|
|
||||||
MIT License
|
|
||||||
|
|
||||||
Copyright (c) 2026 GitHub Inc.
|
|
||||||
|
|
||||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
||||||
of this software and associated documentation files (the "Software"), to deal
|
|
||||||
in the Software without restriction, including without limitation the rights
|
|
||||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
||||||
copies of the Software, and to permit persons to whom the Software is
|
|
||||||
furnished to do so, subject to the following conditions:
|
|
||||||
|
|
||||||
The above copyright notice and this permission notice shall be included in all
|
|
||||||
copies or substantial portions of the Software.
|
|
||||||
|
|
||||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
||||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
||||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
||||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
||||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
||||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
||||||
SOFTWARE.
|
|
||||||
*/
|
|
||||||
|
|
||||||
const icons = {
|
const icons = {
|
||||||
star: `<path fill-rule="evenodd" d="M8 .25a.75.75 0 01.673.418l1.882 3.815 4.21.612a.75.75 0 01.416 1.279l-3.046 2.97.719 4.192a.75.75 0 01-1.088.791L8 12.347l-3.766 1.98a.75.75 0 01-1.088-.79l.72-4.194L.818 6.374a.75.75 0 01.416-1.28l4.21-.611L7.327.668A.75.75 0 018 .25zm0 2.445L6.615 5.5a.75.75 0 01-.564.41l-3.097.45 2.24 2.184a.75.75 0 01.216.664l-.528 3.084 2.769-1.456a.75.75 0 01.698 0l2.77 1.456-.53-3.084a.75.75 0 01.216-.664l2.24-2.183-3.096-.45a.75.75 0 01-.564-.41L8 2.694v.001z"/>`,
|
star: `<path fill-rule="evenodd" d="M8 .25a.75.75 0 01.673.418l1.882 3.815 4.21.612a.75.75 0 01.416 1.279l-3.046 2.97.719 4.192a.75.75 0 01-1.088.791L8 12.347l-3.766 1.98a.75.75 0 01-1.088-.79l.72-4.194L.818 6.374a.75.75 0 01.416-1.28l4.21-.611L7.327.668A.75.75 0 018 .25zm0 2.445L6.615 5.5a.75.75 0 01-.564.41l-3.097.45 2.24 2.184a.75.75 0 01.216.664l-.528 3.084 2.769-1.456a.75.75 0 01.698 0l2.77 1.456-.53-3.084a.75.75 0 01.216-.664l2.24-2.183-3.096-.45a.75.75 0 01-.564-.41L8 2.694v.001z"/>`,
|
||||||
commits: `<path fill-rule="evenodd" d="M1.643 3.143L.427 1.927A.25.25 0 000 2.104V5.75c0 .138.112.25.25.25h3.646a.25.25 0 00.177-.427L2.715 4.215a6.5 6.5 0 11-1.18 4.458.75.75 0 10-1.493.154 8.001 8.001 0 101.6-5.684zM7.75 4a.75.75 0 01.75.75v2.992l2.028.812a.75.75 0 01-.557 1.392l-2.5-1A.75.75 0 017 8.25v-3.5A.75.75 0 017.75 4z"/>`,
|
commits: `<path fill-rule="evenodd" d="M1.643 3.143L.427 1.927A.25.25 0 000 2.104V5.75c0 .138.112.25.25.25h3.646a.25.25 0 00.177-.427L2.715 4.215a6.5 6.5 0 11-1.18 4.458.75.75 0 10-1.493.154 8.001 8.001 0 101.6-5.684zM7.75 4a.75.75 0 01.75.75v2.992l2.028.812a.75.75 0 01-.557 1.392l-2.5-1A.75.75 0 017 8.25v-3.5A.75.75 0 017.75 4z"/>`,
|
||||||
@@ -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",
|
"Faust": "#c37240",
|
||||||
"Fennel": "#fff3d7",
|
"Fennel": "#fff3d7",
|
||||||
"Filebench WML": "#F6B900",
|
"Filebench WML": "#F6B900",
|
||||||
"FlatBuffers": "#ed284a",
|
|
||||||
"Flix": "#d44a45",
|
"Flix": "#d44a45",
|
||||||
"Fluent": "#ffcc33",
|
"Fluent": "#ffcc33",
|
||||||
"Forth": "#341708",
|
"Forth": "#341708",
|
||||||
@@ -254,7 +253,6 @@
|
|||||||
"Hy": "#7790B2",
|
"Hy": "#7790B2",
|
||||||
"IDL": "#a3522f",
|
"IDL": "#a3522f",
|
||||||
"IGOR Pro": "#0000cc",
|
"IGOR Pro": "#0000cc",
|
||||||
"IL Assembly": "#512BD4",
|
|
||||||
"INI": "#d1dbe0",
|
"INI": "#d1dbe0",
|
||||||
"ISPC": "#2D68B1",
|
"ISPC": "#2D68B1",
|
||||||
"Idris": "#b30000",
|
"Idris": "#b30000",
|
||||||
@@ -325,7 +323,6 @@
|
|||||||
"LigoLANG": "#0e74ff",
|
"LigoLANG": "#0e74ff",
|
||||||
"LilyPond": "#9ccc7c",
|
"LilyPond": "#9ccc7c",
|
||||||
"Liquid": "#67b8de",
|
"Liquid": "#67b8de",
|
||||||
"Liquidsoap": "#990066",
|
|
||||||
"Literate Agda": "#315665",
|
"Literate Agda": "#315665",
|
||||||
"Literate CoffeeScript": "#244776",
|
"Literate CoffeeScript": "#244776",
|
||||||
"Literate Haskell": "#5e5086",
|
"Literate Haskell": "#5e5086",
|
||||||
@@ -351,7 +348,6 @@
|
|||||||
"Mask": "#f97732",
|
"Mask": "#f97732",
|
||||||
"Mathematical Programming System": "#0530ad",
|
"Mathematical Programming System": "#0530ad",
|
||||||
"Max": "#c4a79c",
|
"Max": "#c4a79c",
|
||||||
"MeTTa": "#6a5acd",
|
|
||||||
"Mercury": "#ff2b2b",
|
"Mercury": "#ff2b2b",
|
||||||
"Mermaid": "#ff3670",
|
"Mermaid": "#ff3670",
|
||||||
"Meson": "#007800",
|
"Meson": "#007800",
|
||||||
@@ -468,7 +464,6 @@
|
|||||||
"Quake": "#882233",
|
"Quake": "#882233",
|
||||||
"QuakeC": "#975777",
|
"QuakeC": "#975777",
|
||||||
"QuickBASIC": "#008080",
|
"QuickBASIC": "#008080",
|
||||||
"Quint": "#9d6ce5",
|
|
||||||
"R": "#198CE7",
|
"R": "#198CE7",
|
||||||
"RAML": "#77d9fb",
|
"RAML": "#77d9fb",
|
||||||
"RAScript": "#2C97FA",
|
"RAScript": "#2C97FA",
|
||||||
@@ -540,7 +535,6 @@
|
|||||||
"Snakemake": "#419179",
|
"Snakemake": "#419179",
|
||||||
"Solidity": "#AA6746",
|
"Solidity": "#AA6746",
|
||||||
"SourcePawn": "#f69e1d",
|
"SourcePawn": "#f69e1d",
|
||||||
"SpiceDB Schema": "#a5318a",
|
|
||||||
"Squirrel": "#800000",
|
"Squirrel": "#800000",
|
||||||
"Stan": "#b2011d",
|
"Stan": "#b2011d",
|
||||||
"Standard ML": "#dc566d",
|
"Standard ML": "#dc566d",
|
||||||
@@ -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
|
// @ts-check
|
||||||
|
|
||||||
import { getConfig } from "./config.js";
|
import { getUserAccessByName } from "./database.js";
|
||||||
import { CustomError } from "./error.js";
|
import { CustomError } from "./error.js";
|
||||||
import { logger } from "./log.js";
|
import { logger } from "./log.js";
|
||||||
|
|
||||||
@@ -27,16 +27,24 @@ function getRandomInt(max) {
|
|||||||
* Try to execute the fetcher function until it succeeds or the max number of retries is reached.
|
* Try to execute the fetcher function until it succeeds or the max number of retries is reached.
|
||||||
*
|
*
|
||||||
* @param {FetcherFunction} fetcher The fetcher function.
|
* @param {FetcherFunction} fetcher The fetcher function.
|
||||||
|
* @param {string?} username GitHub username of the user whose PAT to use, if available
|
||||||
* @param {any} variables Object with arguments to pass to the fetcher function.
|
* @param {any} variables Object with arguments to pass to the fetcher function.
|
||||||
* @param {string | null} pat Optional PAT override.
|
|
||||||
* @returns {Promise<any>} The response from the fetcher function.
|
* @returns {Promise<any>} The response from the fetcher function.
|
||||||
*/
|
*/
|
||||||
const retryer = async (fetcher, variables, pat = null) => {
|
const retryer = async (fetcher, username, variables) => {
|
||||||
|
let userPAT;
|
||||||
|
if (username) {
|
||||||
|
userPAT = await getUserAccessByName(username);
|
||||||
|
}
|
||||||
|
|
||||||
let PATs;
|
let PATs;
|
||||||
if (pat) {
|
if (userPAT?.token) {
|
||||||
PATs = [{ name: "user PAT from database", value: pat }];
|
PATs = [{ name: `USER_${username}`, value: userPAT.token }];
|
||||||
} else {
|
} else {
|
||||||
PATs = getConfig().pats;
|
const patNames = Object.keys(process.env).filter((key) =>
|
||||||
|
/PAT_\d*$/.exec(key),
|
||||||
|
);
|
||||||
|
PATs = patNames.map((name) => ({ name, value: process.env[name] }));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!PATs.length) {
|
if (!PATs.length) {
|
||||||
@@ -102,3 +110,4 @@ const retryer = async (fetcher, variables, pat = null) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export { retryer };
|
export { retryer };
|
||||||
|
export default retryer;
|
||||||
@@ -84,14 +84,13 @@ const calculatePrimaryLanguage = (files) => {
|
|||||||
* Fetch GitHub gist information by given username and ID.
|
* Fetch GitHub gist information by given username and ID.
|
||||||
*
|
*
|
||||||
* @param {string} id GitHub gist ID.
|
* @param {string} id GitHub gist ID.
|
||||||
* @param {string | null} pat Optional PAT override.
|
|
||||||
* @returns {Promise<GistData>} Gist data.
|
* @returns {Promise<GistData>} Gist data.
|
||||||
*/
|
*/
|
||||||
const fetchGist = async (id, pat = null) => {
|
const fetchGist = async (id) => {
|
||||||
if (!id) {
|
if (!id) {
|
||||||
throw new MissingParamError(["id"], "/api/gist?id=GIST_ID");
|
throw new MissingParamError(["id"], "/api/gist?id=GIST_ID");
|
||||||
}
|
}
|
||||||
const res = await retryer(fetcher, { gistName: id }, pat);
|
const res = await retryer(fetcher, null, { gistName: id });
|
||||||
if (res.data.errors) {
|
if (res.data.errors) {
|
||||||
throw new Error(res.data.errors[0].message);
|
throw new Error(res.data.errors[0].message);
|
||||||
}
|
}
|
||||||
@@ -76,7 +76,6 @@ const fetchRepo = async (
|
|||||||
include_prs_reviewed = false,
|
include_prs_reviewed = false,
|
||||||
include_issues_authored = false,
|
include_issues_authored = false,
|
||||||
include_issues_commented = false,
|
include_issues_commented = false,
|
||||||
pat = null,
|
|
||||||
) => {
|
) => {
|
||||||
let owner = username;
|
let owner = username;
|
||||||
if (reponame && reponame.includes("/")) {
|
if (reponame && reponame.includes("/")) {
|
||||||
@@ -101,7 +100,7 @@ const fetchRepo = async (
|
|||||||
throw new MissingParamError(["repo"], urlExample);
|
throw new MissingParamError(["repo"], urlExample);
|
||||||
}
|
}
|
||||||
|
|
||||||
let res = await retryer(fetcher, { login: owner, repo: reponame }, pat);
|
let res = await retryer(fetcher, username, { login: owner, repo: reponame });
|
||||||
|
|
||||||
const data = res.data.data;
|
const data = res.data.data;
|
||||||
|
|
||||||
@@ -125,7 +124,6 @@ const fetchRepo = async (
|
|||||||
include_prs_reviewed,
|
include_prs_reviewed,
|
||||||
include_issues_authored,
|
include_issues_authored,
|
||||||
include_issues_commented,
|
include_issues_commented,
|
||||||
pat,
|
|
||||||
);
|
);
|
||||||
return {
|
return {
|
||||||
...repoUserStats,
|
...repoUserStats,
|
||||||
@@ -150,7 +148,6 @@ const fetchRepo = async (
|
|||||||
include_prs_reviewed,
|
include_prs_reviewed,
|
||||||
include_issues_authored,
|
include_issues_authored,
|
||||||
include_issues_commented,
|
include_issues_commented,
|
||||||
pat,
|
|
||||||
);
|
);
|
||||||
return {
|
return {
|
||||||
...repoUserStats,
|
...repoUserStats,
|
||||||
@@ -4,7 +4,7 @@ import axios from "axios";
|
|||||||
import githubUsernameRegex from "github-username-regex";
|
import githubUsernameRegex from "github-username-regex";
|
||||||
|
|
||||||
import { calculateRank } from "../calculateRank.js";
|
import { calculateRank } from "../calculateRank.js";
|
||||||
import { getConfig } from "../common/config.js";
|
import { excludeRepositories } from "../common/envs.js";
|
||||||
import { CustomError, MissingParamError } from "../common/error.js";
|
import { CustomError, MissingParamError } from "../common/error.js";
|
||||||
import { wrapTextMultiline } from "../common/fmt.js";
|
import { wrapTextMultiline } from "../common/fmt.js";
|
||||||
import { request } from "../common/http.js";
|
import { request } from "../common/http.js";
|
||||||
@@ -106,8 +106,7 @@ const fetcher = (variables, token) => {
|
|||||||
* @param {boolean} variables.includeDiscussions Include discussions.
|
* @param {boolean} variables.includeDiscussions Include discussions.
|
||||||
* @param {boolean} variables.includeDiscussionsAnswers Include discussions answers.
|
* @param {boolean} variables.includeDiscussionsAnswers Include discussions answers.
|
||||||
* @param {string|undefined} variables.startTime Time to start the count of total commits.
|
* @param {string|undefined} variables.startTime Time to start the count of total commits.
|
||||||
* @param {string[]} variables.ownerAffiliations The owner affiliations to filter by. Default: OWNER.
|
* @param {string[]} ownerAffiliations The owner affiliations to filter by. Default: OWNER.
|
||||||
* @param {string | null} variables.pat PAT override or null.
|
|
||||||
* @returns {Promise<import('axios').AxiosResponse>} Axios response.
|
* @returns {Promise<import('axios').AxiosResponse>} Axios response.
|
||||||
*
|
*
|
||||||
* @description This function supports multi-page fetching if the 'FETCH_MULTI_PAGE_STARS' environment variable is set to true or a limit of fetches.
|
* @description This function supports multi-page fetching if the 'FETCH_MULTI_PAGE_STARS' environment variable is set to true or a limit of fetches.
|
||||||
@@ -119,7 +118,6 @@ const statsFetcher = async ({
|
|||||||
includeDiscussionsAnswers,
|
includeDiscussionsAnswers,
|
||||||
startTime,
|
startTime,
|
||||||
ownerAffiliations,
|
ownerAffiliations,
|
||||||
pat,
|
|
||||||
}) => {
|
}) => {
|
||||||
let stats;
|
let stats;
|
||||||
let hasNextPage = true;
|
let hasNextPage = true;
|
||||||
@@ -136,7 +134,7 @@ const statsFetcher = async ({
|
|||||||
startTime,
|
startTime,
|
||||||
ownerAffiliations,
|
ownerAffiliations,
|
||||||
};
|
};
|
||||||
let res = await retryer(fetcher, variables, pat);
|
let res = await retryer(fetcher, username, variables);
|
||||||
if (res.data.errors) {
|
if (res.data.errors) {
|
||||||
return res;
|
return res;
|
||||||
}
|
}
|
||||||
@@ -163,8 +161,8 @@ const statsFetcher = async ({
|
|||||||
);
|
);
|
||||||
|
|
||||||
hasNextPage =
|
hasNextPage =
|
||||||
(getConfig().fetchMultiPageStars === "true" ||
|
(process.env.FETCH_MULTI_PAGE_STARS === "true" ||
|
||||||
getConfig().fetchMultiPageStars > fetchedPages) &&
|
process.env.FETCH_MULTI_PAGE_STARS > fetchedPages) &&
|
||||||
repoNodes.length === repoNodesWithStars.length &&
|
repoNodes.length === repoNodesWithStars.length &&
|
||||||
res.data.data.user.repositories.pageInfo.hasNextPage;
|
res.data.data.user.repositories.pageInfo.hasNextPage;
|
||||||
|
|
||||||
@@ -209,7 +207,7 @@ const fetchTotalItems = (variables, token) => {
|
|||||||
* @description Done like this because the GitHub API does not provide a way to fetch all the commits. See
|
* @description Done like this because the GitHub API does not provide a way to fetch all the commits. See
|
||||||
* #92#issuecomment-661026467 and #211 for more information.
|
* #92#issuecomment-661026467 and #211 for more information.
|
||||||
*/
|
*/
|
||||||
const totalItemsFetcher = async (username, repo, owner, type, filter, pat) => {
|
const totalItemsFetcher = async (username, repo, owner, type, filter) => {
|
||||||
if (!githubUsernameRegex.test(username)) {
|
if (!githubUsernameRegex.test(username)) {
|
||||||
logger.log("Invalid username provided.");
|
logger.log("Invalid username provided.");
|
||||||
throw new Error("Invalid username provided.");
|
throw new Error("Invalid username provided.");
|
||||||
@@ -217,20 +215,16 @@ const totalItemsFetcher = async (username, repo, owner, type, filter, pat) => {
|
|||||||
|
|
||||||
let res;
|
let res;
|
||||||
try {
|
try {
|
||||||
res = await retryer(
|
res = await retryer(fetchTotalItems, username, {
|
||||||
fetchTotalItems,
|
login: username,
|
||||||
{
|
repo,
|
||||||
login: username,
|
owner,
|
||||||
repo,
|
type,
|
||||||
owner,
|
filter,
|
||||||
type,
|
});
|
||||||
filter,
|
|
||||||
},
|
|
||||||
pat,
|
|
||||||
);
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logger.log(err);
|
logger.log(err);
|
||||||
throw err;
|
throw new Error(err);
|
||||||
}
|
}
|
||||||
|
|
||||||
const totalCount = res.data.total_count;
|
const totalCount = res.data.total_count;
|
||||||
@@ -253,7 +247,6 @@ const fetchRepoUserStats = async (
|
|||||||
include_prs_reviewed,
|
include_prs_reviewed,
|
||||||
include_issues_authored,
|
include_issues_authored,
|
||||||
include_issues_commented,
|
include_issues_commented,
|
||||||
pat,
|
|
||||||
) => {
|
) => {
|
||||||
let stats = {};
|
let stats = {};
|
||||||
if (include_prs_authored) {
|
if (include_prs_authored) {
|
||||||
@@ -263,7 +256,6 @@ const fetchRepoUserStats = async (
|
|||||||
owner,
|
owner,
|
||||||
"issues",
|
"issues",
|
||||||
`author:${username}+type:pr`,
|
`author:${username}+type:pr`,
|
||||||
pat,
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (include_prs_commented) {
|
if (include_prs_commented) {
|
||||||
@@ -273,7 +265,6 @@ const fetchRepoUserStats = async (
|
|||||||
owner,
|
owner,
|
||||||
"issues",
|
"issues",
|
||||||
`commenter:${username}+-author:${username}+type:pr`,
|
`commenter:${username}+-author:${username}+type:pr`,
|
||||||
pat,
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (include_prs_reviewed) {
|
if (include_prs_reviewed) {
|
||||||
@@ -283,7 +274,6 @@ const fetchRepoUserStats = async (
|
|||||||
owner,
|
owner,
|
||||||
"issues",
|
"issues",
|
||||||
`reviewed-by:${username}+-author:${username}+type:pr`,
|
`reviewed-by:${username}+-author:${username}+type:pr`,
|
||||||
pat,
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (include_issues_authored) {
|
if (include_issues_authored) {
|
||||||
@@ -293,7 +283,6 @@ const fetchRepoUserStats = async (
|
|||||||
owner,
|
owner,
|
||||||
"issues",
|
"issues",
|
||||||
`author:${username}+type:issue`,
|
`author:${username}+type:issue`,
|
||||||
pat,
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (include_issues_commented) {
|
if (include_issues_commented) {
|
||||||
@@ -303,7 +292,6 @@ const fetchRepoUserStats = async (
|
|||||||
owner,
|
owner,
|
||||||
"issues",
|
"issues",
|
||||||
`commenter:${username}+-author:${username}+type:issue`,
|
`commenter:${username}+-author:${username}+type:issue`,
|
||||||
pat,
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return stats;
|
return stats;
|
||||||
@@ -338,7 +326,6 @@ const fetchStats = async (
|
|||||||
include_issues_authored = false,
|
include_issues_authored = false,
|
||||||
include_issues_commented = false,
|
include_issues_commented = false,
|
||||||
ownerAffiliations = [],
|
ownerAffiliations = [],
|
||||||
pat = null,
|
|
||||||
) => {
|
) => {
|
||||||
if (!username) {
|
if (!username) {
|
||||||
throw new MissingParamError(["username"]);
|
throw new MissingParamError(["username"]);
|
||||||
@@ -365,17 +352,14 @@ const fetchStats = async (
|
|||||||
};
|
};
|
||||||
ownerAffiliations = parseOwnerAffiliations(ownerAffiliations);
|
ownerAffiliations = parseOwnerAffiliations(ownerAffiliations);
|
||||||
|
|
||||||
let res = await statsFetcher(
|
let res = await statsFetcher({
|
||||||
{
|
username,
|
||||||
username,
|
includeMergedPullRequests: include_merged_pull_requests,
|
||||||
includeMergedPullRequests: include_merged_pull_requests,
|
includeDiscussions: include_discussions,
|
||||||
includeDiscussions: include_discussions,
|
includeDiscussionsAnswers: include_discussions_answers,
|
||||||
includeDiscussionsAnswers: include_discussions_answers,
|
startTime: commits_year ? `${commits_year}-01-01T00:00:00Z` : undefined,
|
||||||
startTime: commits_year ? `${commits_year}-01-01T00:00:00Z` : undefined,
|
ownerAffiliations,
|
||||||
ownerAffiliations,
|
});
|
||||||
},
|
|
||||||
pat,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Catch GraphQL errors.
|
// Catch GraphQL errors.
|
||||||
if (res.data.errors) {
|
if (res.data.errors) {
|
||||||
@@ -388,7 +372,7 @@ const fetchStats = async (
|
|||||||
}
|
}
|
||||||
if (res.data.errors[0].message) {
|
if (res.data.errors[0].message) {
|
||||||
throw new CustomError(
|
throw new CustomError(
|
||||||
wrapTextMultiline(res.data.errors[0].message, 525, 12)[0],
|
wrapTextMultiline(res.data.errors[0].message, 90, 1)[0],
|
||||||
res.statusText,
|
res.statusText,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -410,7 +394,6 @@ const fetchStats = async (
|
|||||||
owner,
|
owner,
|
||||||
"commits",
|
"commits",
|
||||||
`author:${username}`,
|
`author:${username}`,
|
||||||
pat,
|
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
stats.totalCommits = user.commits.totalCommitContributions;
|
stats.totalCommits = user.commits.totalCommitContributions;
|
||||||
@@ -424,7 +407,6 @@ const fetchStats = async (
|
|||||||
include_prs_reviewed,
|
include_prs_reviewed,
|
||||||
include_issues_authored,
|
include_issues_authored,
|
||||||
include_issues_commented,
|
include_issues_commented,
|
||||||
pat,
|
|
||||||
);
|
);
|
||||||
Object.assign(stats, repoUserStats);
|
Object.assign(stats, repoUserStats);
|
||||||
|
|
||||||
@@ -447,10 +429,7 @@ const fetchStats = async (
|
|||||||
stats.contributedTo = user.repositoriesContributedTo.totalCount;
|
stats.contributedTo = user.repositoriesContributedTo.totalCount;
|
||||||
|
|
||||||
// Retrieve stars while filtering out repositories to be hidden.
|
// Retrieve stars while filtering out repositories to be hidden.
|
||||||
const allExcludedRepos = [
|
const allExcludedRepos = [...exclude_repo, ...excludeRepositories];
|
||||||
...exclude_repo,
|
|
||||||
...getConfig().excludeRepositories,
|
|
||||||
];
|
|
||||||
let repoToHide = new Set(allExcludedRepos);
|
let repoToHide = new Set(allExcludedRepos);
|
||||||
|
|
||||||
stats.totalStars = user.repositories.nodes
|
stats.totalStars = user.repositories.nodes
|
||||||
+7
-16
@@ -1,6 +1,6 @@
|
|||||||
// @ts-check
|
// @ts-check
|
||||||
|
|
||||||
import { getConfig } from "../common/config.js";
|
import { excludeRepositories } from "../common/envs.js";
|
||||||
import { CustomError, MissingParamError } from "../common/error.js";
|
import { CustomError, MissingParamError } from "../common/error.js";
|
||||||
import { wrapTextMultiline } from "../common/fmt.js";
|
import { wrapTextMultiline } from "../common/fmt.js";
|
||||||
import { request } from "../common/http.js";
|
import { request } from "../common/http.js";
|
||||||
@@ -59,7 +59,6 @@ const fetcher = (variables, token) => {
|
|||||||
* @param {number} size_weight Weightage to be given to size.
|
* @param {number} size_weight Weightage to be given to size.
|
||||||
* @param {number} count_weight Weightage to be given to count.
|
* @param {number} count_weight Weightage to be given to count.
|
||||||
* @param {string[]} ownerAffiliations The owner affiliations to filter by. Default: OWNER.
|
* @param {string[]} ownerAffiliations The owner affiliations to filter by. Default: OWNER.
|
||||||
* @param {string|null} pat Optional PAT override.
|
|
||||||
* @returns {Promise<TopLangData>} Top languages data.
|
* @returns {Promise<TopLangData>} Top languages data.
|
||||||
*/
|
*/
|
||||||
const fetchTopLanguages = async (
|
const fetchTopLanguages = async (
|
||||||
@@ -68,21 +67,16 @@ const fetchTopLanguages = async (
|
|||||||
size_weight = 1,
|
size_weight = 1,
|
||||||
count_weight = 0,
|
count_weight = 0,
|
||||||
ownerAffiliations = [],
|
ownerAffiliations = [],
|
||||||
pat = null,
|
|
||||||
) => {
|
) => {
|
||||||
if (!username) {
|
if (!username) {
|
||||||
throw new MissingParamError(["username"]);
|
throw new MissingParamError(["username"]);
|
||||||
}
|
}
|
||||||
ownerAffiliations = parseOwnerAffiliations(ownerAffiliations);
|
ownerAffiliations = parseOwnerAffiliations(ownerAffiliations);
|
||||||
|
|
||||||
const res = await retryer(
|
const res = await retryer(fetcher, username, {
|
||||||
fetcher,
|
login: username,
|
||||||
{
|
ownerAffiliations,
|
||||||
login: username,
|
});
|
||||||
ownerAffiliations,
|
|
||||||
},
|
|
||||||
pat,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (res.data.errors) {
|
if (res.data.errors) {
|
||||||
logger.error(res.data.errors);
|
logger.error(res.data.errors);
|
||||||
@@ -94,7 +88,7 @@ const fetchTopLanguages = async (
|
|||||||
}
|
}
|
||||||
if (res.data.errors[0].message) {
|
if (res.data.errors[0].message) {
|
||||||
throw new CustomError(
|
throw new CustomError(
|
||||||
wrapTextMultiline(res.data.errors[0].message, 525, 12)[0],
|
wrapTextMultiline(res.data.errors[0].message, 90, 1)[0],
|
||||||
res.statusText,
|
res.statusText,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -107,10 +101,7 @@ const fetchTopLanguages = async (
|
|||||||
let repoNodes = res.data.data.user.repositories.nodes;
|
let repoNodes = res.data.data.user.repositories.nodes;
|
||||||
/** @type {Record<string, boolean>} */
|
/** @type {Record<string, boolean>} */
|
||||||
let repoToHide = {};
|
let repoToHide = {};
|
||||||
const allExcludedRepos = [
|
const allExcludedRepos = [...exclude_repo, ...excludeRepositories];
|
||||||
...exclude_repo,
|
|
||||||
...getConfig().excludeRepositories,
|
|
||||||
];
|
|
||||||
|
|
||||||
// populate repoToHide map for quick lookup
|
// populate repoToHide map for quick lookup
|
||||||
// while filtering out
|
// while filtering out
|
||||||
+20
-20
@@ -1,13 +1,13 @@
|
|||||||
export interface GistData {
|
export type GistData = {
|
||||||
name: string;
|
name: string;
|
||||||
nameWithOwner: string;
|
nameWithOwner: string;
|
||||||
description: string | null;
|
description: string | null;
|
||||||
language: string | null;
|
language: string | null;
|
||||||
starsCount: number;
|
starsCount: number;
|
||||||
forksCount: number;
|
forksCount: number;
|
||||||
}
|
};
|
||||||
|
|
||||||
export interface RepositoryData {
|
export type RepositoryData = {
|
||||||
name: string;
|
name: string;
|
||||||
nameWithOwner: string;
|
nameWithOwner: string;
|
||||||
isPrivate: boolean;
|
isPrivate: boolean;
|
||||||
@@ -27,9 +27,9 @@ export interface RepositoryData {
|
|||||||
totalPRsReviewed: number;
|
totalPRsReviewed: number;
|
||||||
totalIssuesAuthored: number;
|
totalIssuesAuthored: number;
|
||||||
totalIssuesCommented: number;
|
totalIssuesCommented: number;
|
||||||
}
|
};
|
||||||
|
|
||||||
export interface StatsData {
|
export type StatsData = {
|
||||||
name: string;
|
name: string;
|
||||||
totalPRs: number;
|
totalPRs: number;
|
||||||
totalPRsMerged: number;
|
totalPRsMerged: number;
|
||||||
@@ -47,18 +47,18 @@ export interface StatsData {
|
|||||||
totalIssuesAuthored: number;
|
totalIssuesAuthored: number;
|
||||||
totalIssuesCommented: number;
|
totalIssuesCommented: number;
|
||||||
rank: { level: string; percentile: number };
|
rank: { level: string; percentile: number };
|
||||||
}
|
};
|
||||||
|
|
||||||
export interface Lang {
|
export type Lang = {
|
||||||
name: string;
|
name: string;
|
||||||
color: string;
|
color: string;
|
||||||
size: number;
|
size: number;
|
||||||
}
|
};
|
||||||
|
|
||||||
export type TopLangData = Record<string, Lang>;
|
export type TopLangData = Record<string, Lang>;
|
||||||
|
|
||||||
export interface WakaTimeData {
|
export type WakaTimeData = {
|
||||||
categories: Array<{
|
categories: {
|
||||||
digital: string;
|
digital: string;
|
||||||
hours: number;
|
hours: number;
|
||||||
minutes: number;
|
minutes: number;
|
||||||
@@ -66,12 +66,12 @@ export interface WakaTimeData {
|
|||||||
percent: number;
|
percent: number;
|
||||||
text: string;
|
text: string;
|
||||||
total_seconds: number;
|
total_seconds: number;
|
||||||
}>;
|
}[];
|
||||||
daily_average: number;
|
daily_average: number;
|
||||||
daily_average_including_other_language: number;
|
daily_average_including_other_language: number;
|
||||||
days_including_holidays: number;
|
days_including_holidays: number;
|
||||||
days_minus_holidays: number;
|
days_minus_holidays: number;
|
||||||
editors: Array<{
|
editors: {
|
||||||
digital: string;
|
digital: string;
|
||||||
hours: number;
|
hours: number;
|
||||||
minutes: number;
|
minutes: number;
|
||||||
@@ -79,7 +79,7 @@ export interface WakaTimeData {
|
|||||||
percent: number;
|
percent: number;
|
||||||
text: string;
|
text: string;
|
||||||
total_seconds: number;
|
total_seconds: number;
|
||||||
}>;
|
}[];
|
||||||
holidays: number;
|
holidays: number;
|
||||||
human_readable_daily_average: string;
|
human_readable_daily_average: string;
|
||||||
human_readable_daily_average_including_other_language: string;
|
human_readable_daily_average_including_other_language: string;
|
||||||
@@ -92,7 +92,7 @@ export interface WakaTimeData {
|
|||||||
is_other_usage_visible: boolean;
|
is_other_usage_visible: boolean;
|
||||||
is_stuck: boolean;
|
is_stuck: boolean;
|
||||||
is_up_to_date: boolean;
|
is_up_to_date: boolean;
|
||||||
languages: Array<{
|
languages: {
|
||||||
digital: string;
|
digital: string;
|
||||||
hours: number;
|
hours: number;
|
||||||
minutes: number;
|
minutes: number;
|
||||||
@@ -100,8 +100,8 @@ export interface WakaTimeData {
|
|||||||
percent: number;
|
percent: number;
|
||||||
text: string;
|
text: string;
|
||||||
total_seconds: number;
|
total_seconds: number;
|
||||||
}>;
|
}[];
|
||||||
operating_systems: Array<{
|
operating_systems: {
|
||||||
digital: string;
|
digital: string;
|
||||||
hours: number;
|
hours: number;
|
||||||
minutes: number;
|
minutes: number;
|
||||||
@@ -109,7 +109,7 @@ export interface WakaTimeData {
|
|||||||
percent: number;
|
percent: number;
|
||||||
text: string;
|
text: string;
|
||||||
total_seconds: number;
|
total_seconds: number;
|
||||||
}>;
|
}[];
|
||||||
percent_calculated: number;
|
percent_calculated: number;
|
||||||
range: string;
|
range: string;
|
||||||
status: string;
|
status: string;
|
||||||
@@ -119,10 +119,10 @@ export interface WakaTimeData {
|
|||||||
user_id: string;
|
user_id: string;
|
||||||
username: string;
|
username: string;
|
||||||
writes_only: boolean;
|
writes_only: boolean;
|
||||||
}
|
};
|
||||||
|
|
||||||
export interface WakaTimeLang {
|
export type WakaTimeLang = {
|
||||||
name: string;
|
name: string;
|
||||||
text: string;
|
text: string;
|
||||||
percent: number;
|
percent: number;
|
||||||
}
|
};
|
||||||
@@ -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 };
|
return { userId, accessToken, needDowngrade };
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err.response) {
|
if (err.response) {
|
||||||
// eslint-disable-next-line preserve-caught-error
|
|
||||||
throw new Error(`OAuth Error: ${err.response.status}`);
|
throw new Error(`OAuth Error: ${err.response.status}`);
|
||||||
}
|
}
|
||||||
throw err;
|
throw err;
|
||||||
|
|||||||
-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>
|
</style>
|
||||||
|
|
||||||
@@ -241,24 +223,6 @@ exports[`Test Render WakaTime Card > should render correctly with compact layout
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
/* Animations */
|
|
||||||
@keyframes scaleInAnimation {
|
|
||||||
from {
|
|
||||||
transform: translate(-5px, 5px) scale(0);
|
|
||||||
}
|
|
||||||
to {
|
|
||||||
transform: translate(-5px, 5px) scale(1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@keyframes fadeInAnimation {
|
|
||||||
from {
|
|
||||||
opacity: 0;
|
|
||||||
}
|
|
||||||
to {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
@@ -411,24 +375,6 @@ exports[`Test Render WakaTime Card > should render correctly with compact layout
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
/* Animations */
|
|
||||||
@keyframes scaleInAnimation {
|
|
||||||
from {
|
|
||||||
transform: translate(-5px, 5px) scale(0);
|
|
||||||
}
|
|
||||||
to {
|
|
||||||
transform: translate(-5px, 5px) scale(1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@keyframes fadeInAnimation {
|
|
||||||
from {
|
|
||||||
opacity: 0;
|
|
||||||
}
|
|
||||||
to {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
@@ -581,24 +527,6 @@ exports[`Test Render WakaTime Card > should render correctly with percent displa
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
/* Animations */
|
|
||||||
@keyframes scaleInAnimation {
|
|
||||||
from {
|
|
||||||
transform: translate(-5px, 5px) scale(0);
|
|
||||||
}
|
|
||||||
to {
|
|
||||||
transform: translate(-5px, 5px) scale(1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@keyframes fadeInAnimation {
|
|
||||||
from {
|
|
||||||
opacity: 0;
|
|
||||||
}
|
|
||||||
to {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||||
|
|
||||||
|
exports[`Test /api/wakatime > should render error if user data is not accessible 1`] = `
|
||||||
|
"
|
||||||
|
<svg
|
||||||
|
width="495"
|
||||||
|
height="150"
|
||||||
|
viewBox="0 0 495 150"
|
||||||
|
fill="none"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
role="img"
|
||||||
|
aria-labelledby="descId"
|
||||||
|
>
|
||||||
|
<title id="titleId"></title>
|
||||||
|
<desc id="descId"></desc>
|
||||||
|
<style>
|
||||||
|
.header {
|
||||||
|
font: 600 18px 'Segoe UI', Ubuntu, Sans-Serif;
|
||||||
|
fill: #2f80ed;
|
||||||
|
animation: fadeInAnimation 0.8s ease-in-out forwards;
|
||||||
|
}
|
||||||
|
@supports(-moz-appearance: auto) {
|
||||||
|
/* Selector detects Firefox */
|
||||||
|
.header { font-size: 15.5px; }
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.stat {
|
||||||
|
font: 600 14px 'Segoe UI', Ubuntu, "Helvetica Neue", Sans-Serif; fill: #434d58;
|
||||||
|
}
|
||||||
|
@supports(-moz-appearance: auto) {
|
||||||
|
/* Selector detects Firefox */
|
||||||
|
.stat { font-size:12px; }
|
||||||
|
}
|
||||||
|
.stagger {
|
||||||
|
opacity: 0;
|
||||||
|
animation: fadeInAnimation 0.3s ease-in-out forwards;
|
||||||
|
}
|
||||||
|
.not_bold { font-weight: 400 }
|
||||||
|
.bold { font-weight: 700 }
|
||||||
|
|
||||||
|
@keyframes slideInAnimation {
|
||||||
|
from {
|
||||||
|
width: 0;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
width: calc(100%-100px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@keyframes growWidthAnimation {
|
||||||
|
from {
|
||||||
|
width: 0;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.lang-name { font: 400 11px 'Segoe UI', Ubuntu, Sans-Serif; fill: #434d58 }
|
||||||
|
#rect-mask rect{
|
||||||
|
animation: slideInAnimation 1s ease-in-out forwards;
|
||||||
|
}
|
||||||
|
.lang-progress{
|
||||||
|
animation: growWidthAnimation 0.6s ease-in-out forwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
</style>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
<rect
|
||||||
|
data-testid="card-bg"
|
||||||
|
x="0.5"
|
||||||
|
y="0.5"
|
||||||
|
rx="4.5"
|
||||||
|
height="99%"
|
||||||
|
stroke="#e4e2e2"
|
||||||
|
width="494"
|
||||||
|
fill="#fffefe"
|
||||||
|
stroke-opacity="1"
|
||||||
|
/>
|
||||||
|
|
||||||
|
|
||||||
|
<g
|
||||||
|
data-testid="card-title"
|
||||||
|
transform="translate(25, 35)"
|
||||||
|
>
|
||||||
|
<g transform="translate(0, 0)">
|
||||||
|
<text
|
||||||
|
x="0"
|
||||||
|
y="0"
|
||||||
|
class="header"
|
||||||
|
data-testid="header"
|
||||||
|
>WakaTime Stats</text>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
|
||||||
|
<g
|
||||||
|
data-testid="main-card-body"
|
||||||
|
transform="translate(0, 55)"
|
||||||
|
>
|
||||||
|
|
||||||
|
<svg x="0" y="0" width="100%">
|
||||||
|
<g transform="translate(0, 0)">
|
||||||
|
<text x="25" y="11" class="stat bold" fill="#434d58">WakaTime user profile not public</text>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
"
|
||||||
|
`;
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
process.env.GIST_WHITELIST = "bbfce31e0217a3689c8d961a356cb10d";
|
||||||
|
process.env.WHITELIST = "anuraghazra";
|
||||||
+299
-135
@@ -1,181 +1,345 @@
|
|||||||
// @ts-check
|
// @ts-check
|
||||||
|
|
||||||
import { api, getConfig } from "@stats-organization/github-readme-stats-core";
|
import axios from "axios";
|
||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
import MockAdapter from "axios-mock-adapter";
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
import router from "../router.js";
|
import api from "../api-renamed/index.js";
|
||||||
|
import { calculateRank } from "../src/calculateRank.js";
|
||||||
|
import { renderStatsCard } from "../src/cards/stats.js";
|
||||||
import { CACHE_TTL, DURATIONS } from "../src/common/cache.js";
|
import { CACHE_TTL, DURATIONS } from "../src/common/cache.js";
|
||||||
import { getUserAccessByName, storeRequest } from "../src/common/database.js";
|
import { renderError } from "../src/common/render.js";
|
||||||
|
|
||||||
vi.mock(import("@stats-organization/github-readme-stats-core"), async () => {
|
import { data_stats, stats } from "./test-data/api-data.js";
|
||||||
const { mockCore } = await import("./utils.js");
|
|
||||||
return mockCore();
|
stats.rank = calculateRank({
|
||||||
|
all_commits: false,
|
||||||
|
commits: stats.totalCommits,
|
||||||
|
prs: stats.totalPRs,
|
||||||
|
reviews: stats.totalReviews,
|
||||||
|
issues: stats.totalIssues,
|
||||||
|
repos: 1,
|
||||||
|
stars: stats.totalStars,
|
||||||
|
followers: 0,
|
||||||
});
|
});
|
||||||
|
|
||||||
vi.mock(import("../src/common/database.js"), async (importOriginal) => ({
|
const error = {
|
||||||
...(await importOriginal()),
|
errors: [
|
||||||
storeRequest: vi.fn(),
|
{
|
||||||
getUserAccessByName: vi.fn(),
|
type: "NOT_FOUND",
|
||||||
}));
|
path: ["user"],
|
||||||
|
locations: [],
|
||||||
|
message: "Could not fetch user",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
const apiMock = vi.mocked(api);
|
const mock = new MockAdapter(axios);
|
||||||
const getConfigMock = vi.mocked(getConfig);
|
|
||||||
const storeRequestMock = vi.mocked(storeRequest);
|
|
||||||
const getUserAccessByNameMock = vi.mocked(getUserAccessByName);
|
|
||||||
|
|
||||||
const createRequest = (search = "") => ({
|
// @ts-ignore
|
||||||
headers: {},
|
const faker = (query, data) => {
|
||||||
url: `/api?${search}`,
|
const req = {
|
||||||
});
|
query: {
|
||||||
|
username: "anuraghazra",
|
||||||
|
...query,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const res = {
|
||||||
|
setHeader: vi.fn(),
|
||||||
|
send: vi.fn(),
|
||||||
|
};
|
||||||
|
mock.onPost("https://api.github.com/graphql").replyOnce(200, data);
|
||||||
|
|
||||||
const createResponse = () => ({
|
return { req, res };
|
||||||
end: vi.fn(),
|
};
|
||||||
setHeader: vi.fn(),
|
|
||||||
});
|
|
||||||
|
|
||||||
const defaultCacheHeader =
|
|
||||||
`max-age=${CACHE_TTL.STATS_CARD.DEFAULT}, ` +
|
|
||||||
`s-maxage=${CACHE_TTL.STATS_CARD.DEFAULT}, ` +
|
|
||||||
`stale-while-revalidate=${DURATIONS.ONE_DAY}`;
|
|
||||||
|
|
||||||
const errorCacheHeader =
|
|
||||||
`max-age=${CACHE_TTL.ERROR}, ` +
|
|
||||||
`s-maxage=${CACHE_TTL.ERROR}, ` +
|
|
||||||
`stale-while-revalidate=${DURATIONS.ONE_DAY}`;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
apiMock.mockReset();
|
process.env.CACHE_SECONDS = undefined;
|
||||||
getConfigMock.mockReset().mockReturnValue({});
|
|
||||||
storeRequestMock.mockReset().mockResolvedValue(undefined);
|
|
||||||
getUserAccessByNameMock.mockReset().mockResolvedValue(null);
|
|
||||||
// CACHE_SECONDS is not set here, this is just to safeguard against CACHE_SECONDS being set externally
|
|
||||||
delete process.env.CACHE_SECONDS;
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("Test /api backend routing", () => {
|
afterEach(() => {
|
||||||
it("happy path should pass query params and user PAT, respond with stats content and persist request", async () => {
|
mock.reset();
|
||||||
getUserAccessByNameMock.mockResolvedValue({ token: "user-pat" });
|
});
|
||||||
apiMock.mockResolvedValue({
|
|
||||||
status: "success",
|
|
||||||
content: "mock-stats-svg",
|
|
||||||
});
|
|
||||||
|
|
||||||
const req = createRequest(
|
describe("Test /api/", () => {
|
||||||
"username=anuraghazra&theme=dark&hide=issues,prs,contribs",
|
it("should test the request", async () => {
|
||||||
|
const { req, res } = faker({}, data_stats);
|
||||||
|
|
||||||
|
await api(req, res);
|
||||||
|
|
||||||
|
expect(res.setHeader).toHaveBeenCalledWith("Content-Type", "image/svg+xml");
|
||||||
|
expect(res.send).toHaveBeenCalledWith(
|
||||||
|
renderStatsCard(stats, { ...req.query }),
|
||||||
);
|
);
|
||||||
const res = createResponse();
|
});
|
||||||
|
|
||||||
await router(req, res);
|
it("should render error card on error", async () => {
|
||||||
|
const { req, res } = faker({}, error);
|
||||||
|
|
||||||
expect(getUserAccessByNameMock).toHaveBeenCalledWith("anuraghazra");
|
await api(req, res);
|
||||||
expect(apiMock).toHaveBeenCalledWith(
|
|
||||||
|
expect(res.setHeader).toHaveBeenCalledWith("Content-Type", "image/svg+xml");
|
||||||
|
expect(res.send).toHaveBeenCalledWith(
|
||||||
|
renderError({
|
||||||
|
message: error.errors[0].message,
|
||||||
|
secondaryMessage:
|
||||||
|
"Make sure the provided username is not an organization",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should render error card in same theme as requested card", async () => {
|
||||||
|
const { req, res } = faker({ theme: "merko" }, error);
|
||||||
|
|
||||||
|
await api(req, res);
|
||||||
|
|
||||||
|
expect(res.setHeader).toHaveBeenCalledWith("Content-Type", "image/svg+xml");
|
||||||
|
expect(res.send).toHaveBeenCalledWith(
|
||||||
|
renderError({
|
||||||
|
message: error.errors[0].message,
|
||||||
|
secondaryMessage:
|
||||||
|
"Make sure the provided username is not an organization",
|
||||||
|
renderOptions: { theme: "merko" },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should get the query options", async () => {
|
||||||
|
const { req, res } = faker(
|
||||||
{
|
{
|
||||||
username: "anuraghazra",
|
username: "anuraghazra",
|
||||||
theme: "dark",
|
|
||||||
hide: "issues,prs,contribs",
|
hide: "issues,prs,contribs",
|
||||||
|
show_icons: true,
|
||||||
|
hide_border: true,
|
||||||
|
line_height: 100,
|
||||||
|
title_color: "fff",
|
||||||
|
icon_color: "fff",
|
||||||
|
text_color: "fff",
|
||||||
|
bg_color: "fff",
|
||||||
},
|
},
|
||||||
"user-pat",
|
data_stats,
|
||||||
|
);
|
||||||
|
|
||||||
|
await api(req, res);
|
||||||
|
|
||||||
|
expect(res.setHeader).toHaveBeenCalledWith("Content-Type", "image/svg+xml");
|
||||||
|
expect(res.send).toHaveBeenCalledWith(
|
||||||
|
renderStatsCard(stats, {
|
||||||
|
hide: ["issues", "prs", "contribs"],
|
||||||
|
show_icons: true,
|
||||||
|
hide_border: true,
|
||||||
|
line_height: 100,
|
||||||
|
title_color: "fff",
|
||||||
|
icon_color: "fff",
|
||||||
|
text_color: "fff",
|
||||||
|
bg_color: "fff",
|
||||||
|
}),
|
||||||
);
|
);
|
||||||
expect(req.query).toEqual({
|
|
||||||
username: "anuraghazra",
|
|
||||||
theme: "dark",
|
|
||||||
hide: "issues,prs,contribs",
|
|
||||||
});
|
|
||||||
expect(res.setHeader.mock.calls).toEqual([
|
|
||||||
["Cache-Control", defaultCacheHeader],
|
|
||||||
["Content-Type", "image/svg+xml"],
|
|
||||||
]);
|
|
||||||
expect(res.end).toHaveBeenCalledExactlyOnceWith("mock-stats-svg");
|
|
||||||
expect(storeRequestMock).toHaveBeenCalledExactlyOnceWith(req);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should use the shorter error cache for temporary stats errors", async () => {
|
it("should have proper cache", async () => {
|
||||||
apiMock.mockResolvedValue({
|
const { req, res } = faker({}, data_stats);
|
||||||
status: "error - temporary",
|
|
||||||
content: "temporary-error-svg",
|
|
||||||
});
|
|
||||||
|
|
||||||
const req = createRequest("username=anuraghazra");
|
await api(req, res);
|
||||||
const res = createResponse();
|
|
||||||
|
|
||||||
await router(req, res);
|
expect(res.setHeader.mock.calls).toEqual([
|
||||||
|
["Content-Type", "image/svg+xml"],
|
||||||
|
[
|
||||||
|
"Cache-Control",
|
||||||
|
`max-age=${CACHE_TTL.STATS_CARD.DEFAULT}, ` +
|
||||||
|
`s-maxage=${CACHE_TTL.STATS_CARD.DEFAULT}, ` +
|
||||||
|
`stale-while-revalidate=${DURATIONS.ONE_DAY}`,
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
expect(getUserAccessByNameMock).toHaveBeenCalledWith("anuraghazra");
|
it("should set proper cache", async () => {
|
||||||
expect(apiMock).toHaveBeenCalledWith(
|
const cache_seconds = DURATIONS.TWELVE_HOURS;
|
||||||
|
const { req, res } = faker({ cache_seconds }, data_stats);
|
||||||
|
await api(req, res);
|
||||||
|
|
||||||
|
expect(res.setHeader.mock.calls).toEqual([
|
||||||
|
["Content-Type", "image/svg+xml"],
|
||||||
|
[
|
||||||
|
"Cache-Control",
|
||||||
|
`max-age=${cache_seconds}, ` +
|
||||||
|
`s-maxage=${cache_seconds}, ` +
|
||||||
|
`stale-while-revalidate=${DURATIONS.ONE_DAY}`,
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should set shorter cache when error", async () => {
|
||||||
|
const { req, res } = faker({}, error);
|
||||||
|
await api(req, res);
|
||||||
|
|
||||||
|
expect(res.setHeader.mock.calls).toEqual([
|
||||||
|
["Content-Type", "image/svg+xml"],
|
||||||
|
[
|
||||||
|
"Cache-Control",
|
||||||
|
`max-age=${CACHE_TTL.ERROR}, ` +
|
||||||
|
`s-maxage=${CACHE_TTL.ERROR}, ` +
|
||||||
|
`stale-while-revalidate=${DURATIONS.ONE_DAY}`,
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should properly set cache using CACHE_SECONDS env variable", async () => {
|
||||||
|
const cacheSeconds = "10000";
|
||||||
|
process.env.CACHE_SECONDS = cacheSeconds;
|
||||||
|
|
||||||
|
const { req, res } = faker({}, data_stats);
|
||||||
|
await api(req, res);
|
||||||
|
|
||||||
|
expect(res.setHeader.mock.calls).toEqual([
|
||||||
|
["Content-Type", "image/svg+xml"],
|
||||||
|
[
|
||||||
|
"Cache-Control",
|
||||||
|
`max-age=${cacheSeconds}, ` +
|
||||||
|
`s-maxage=${cacheSeconds}, ` +
|
||||||
|
`stale-while-revalidate=${DURATIONS.ONE_DAY}`,
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should disable cache when CACHE_SECONDS is set to 0", async () => {
|
||||||
|
process.env.CACHE_SECONDS = "0";
|
||||||
|
|
||||||
|
const { req, res } = faker({}, data_stats);
|
||||||
|
await api(req, res);
|
||||||
|
|
||||||
|
expect(res.setHeader.mock.calls).toEqual([
|
||||||
|
["Content-Type", "image/svg+xml"],
|
||||||
|
[
|
||||||
|
"Cache-Control",
|
||||||
|
"no-cache, no-store, must-revalidate, max-age=0, s-maxage=0",
|
||||||
|
],
|
||||||
|
["Pragma", "no-cache"],
|
||||||
|
["Expires", "0"],
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should set proper cache with clamped values", async () => {
|
||||||
|
{
|
||||||
|
let { req, res } = faker({ cache_seconds: 200_000 }, data_stats);
|
||||||
|
await api(req, res);
|
||||||
|
|
||||||
|
expect(res.setHeader.mock.calls).toEqual([
|
||||||
|
["Content-Type", "image/svg+xml"],
|
||||||
|
[
|
||||||
|
"Cache-Control",
|
||||||
|
`max-age=${CACHE_TTL.STATS_CARD.MAX}, ` +
|
||||||
|
`s-maxage=${CACHE_TTL.STATS_CARD.MAX}, ` +
|
||||||
|
`stale-while-revalidate=${DURATIONS.ONE_DAY}`,
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// note i'm using block scoped vars
|
||||||
|
{
|
||||||
|
let { req, res } = faker({ cache_seconds: 0 }, data_stats);
|
||||||
|
await api(req, res);
|
||||||
|
|
||||||
|
expect(res.setHeader.mock.calls).toEqual([
|
||||||
|
["Content-Type", "image/svg+xml"],
|
||||||
|
[
|
||||||
|
"Cache-Control",
|
||||||
|
`max-age=${CACHE_TTL.STATS_CARD.MIN}, ` +
|
||||||
|
`s-maxage=${CACHE_TTL.STATS_CARD.MIN}, ` +
|
||||||
|
`stale-while-revalidate=${DURATIONS.ONE_DAY}`,
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
let { req, res } = faker({ cache_seconds: -10_000 }, data_stats);
|
||||||
|
await api(req, res);
|
||||||
|
|
||||||
|
expect(res.setHeader.mock.calls).toEqual([
|
||||||
|
["Content-Type", "image/svg+xml"],
|
||||||
|
[
|
||||||
|
"Cache-Control",
|
||||||
|
`max-age=${CACHE_TTL.STATS_CARD.MIN}, ` +
|
||||||
|
`s-maxage=${CACHE_TTL.STATS_CARD.MIN}, ` +
|
||||||
|
`stale-while-revalidate=${DURATIONS.ONE_DAY}`,
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should allow changing ring_color", async () => {
|
||||||
|
const { req, res } = faker(
|
||||||
{
|
{
|
||||||
username: "anuraghazra",
|
username: "anuraghazra",
|
||||||
|
hide: "issues,prs,contribs",
|
||||||
|
show_icons: true,
|
||||||
|
hide_border: true,
|
||||||
|
line_height: 100,
|
||||||
|
title_color: "fff",
|
||||||
|
ring_color: "0000ff",
|
||||||
|
icon_color: "fff",
|
||||||
|
text_color: "fff",
|
||||||
|
bg_color: "fff",
|
||||||
},
|
},
|
||||||
null,
|
data_stats,
|
||||||
|
);
|
||||||
|
|
||||||
|
await api(req, res);
|
||||||
|
|
||||||
|
expect(res.setHeader).toHaveBeenCalledWith("Content-Type", "image/svg+xml");
|
||||||
|
expect(res.send).toHaveBeenCalledWith(
|
||||||
|
renderStatsCard(stats, {
|
||||||
|
hide: ["issues", "prs", "contribs"],
|
||||||
|
show_icons: true,
|
||||||
|
hide_border: true,
|
||||||
|
line_height: 100,
|
||||||
|
title_color: "fff",
|
||||||
|
ring_color: "0000ff",
|
||||||
|
icon_color: "fff",
|
||||||
|
text_color: "fff",
|
||||||
|
bg_color: "fff",
|
||||||
|
}),
|
||||||
);
|
);
|
||||||
expect(res.setHeader.mock.calls).toEqual([
|
|
||||||
["Cache-Control", errorCacheHeader],
|
|
||||||
["Content-Type", "image/svg+xml"],
|
|
||||||
]);
|
|
||||||
expect(res.end).toHaveBeenCalledExactlyOnceWith("temporary-error-svg");
|
|
||||||
expect(storeRequestMock).toHaveBeenCalledExactlyOnceWith(req);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should not persist permanent stats errors returned by core", async () => {
|
it("should render error card when wrong locale is provided", async () => {
|
||||||
apiMock.mockResolvedValue({
|
const { req, res } = faker({ locale: "asdf" }, data_stats);
|
||||||
status: "error - permanent",
|
|
||||||
content: "permanent-error-svg",
|
|
||||||
});
|
|
||||||
|
|
||||||
const req = createRequest("username=anuraghazra");
|
await api(req, res);
|
||||||
const res = createResponse();
|
|
||||||
|
|
||||||
await router(req, res);
|
expect(res.setHeader).toHaveBeenCalledWith("Content-Type", "image/svg+xml");
|
||||||
|
expect(res.send).toHaveBeenCalledWith(
|
||||||
expect(getUserAccessByNameMock).toHaveBeenCalledWith("anuraghazra");
|
renderError({
|
||||||
expect(apiMock).toHaveBeenCalledWith(
|
message: "Something went wrong",
|
||||||
{
|
secondaryMessage: "Language not found",
|
||||||
username: "anuraghazra",
|
}),
|
||||||
},
|
|
||||||
null,
|
|
||||||
);
|
);
|
||||||
expect(res.setHeader.mock.calls).toEqual([
|
|
||||||
["Cache-Control", defaultCacheHeader],
|
|
||||||
["Content-Type", "image/svg+xml"],
|
|
||||||
]);
|
|
||||||
expect(res.end).toHaveBeenCalledExactlyOnceWith("permanent-error-svg");
|
|
||||||
expect(storeRequestMock).not.toHaveBeenCalled();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should reject blacklisted usernames before calling core logic", async () => {
|
it("should render error card when include_all_commits true and upstream API fails", async () => {
|
||||||
const req = createRequest("username=renovate-bot");
|
mock
|
||||||
const res = createResponse();
|
.onGet(
|
||||||
|
"https://api.github.com/search/commits?per_page=1&q=author:anuraghazra",
|
||||||
|
)
|
||||||
|
.reply(200, { error: "Some test error message" });
|
||||||
|
|
||||||
await router(req, res);
|
const { req, res } = faker(
|
||||||
|
{ username: "anuraghazra", include_all_commits: true },
|
||||||
expect(apiMock).not.toHaveBeenCalled();
|
data_stats,
|
||||||
expect(getUserAccessByNameMock).not.toHaveBeenCalled();
|
|
||||||
expect(res.setHeader.mock.calls).toEqual([
|
|
||||||
["Cache-Control", defaultCacheHeader],
|
|
||||||
["Content-Type", "image/svg+xml"],
|
|
||||||
]);
|
|
||||||
expect(res.end).toHaveBeenCalledExactlyOnceWith(
|
|
||||||
"render-error:This username is blacklisted",
|
|
||||||
);
|
);
|
||||||
expect(storeRequestMock).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should reject non-whitelisted usernames before calling core logic", async () => {
|
await api(req, res);
|
||||||
getConfigMock.mockReturnValue({ whitelist: ["allowed-user"] });
|
|
||||||
|
|
||||||
const req = createRequest("username=blocked-user");
|
expect(res.setHeader).toHaveBeenCalledWith("Content-Type", "image/svg+xml");
|
||||||
const res = createResponse();
|
expect(res.send).toHaveBeenCalledWith(
|
||||||
|
renderError({
|
||||||
await router(req, res);
|
message: "Could not fetch data from GitHub REST API.",
|
||||||
|
secondaryMessage: "Please try again later",
|
||||||
expect(apiMock).not.toHaveBeenCalled();
|
}),
|
||||||
expect(getUserAccessByNameMock).not.toHaveBeenCalled();
|
);
|
||||||
expect(res.setHeader.mock.calls).toEqual([
|
// Received SVG output should not contain string "https://tiny.one/readme-stats"
|
||||||
["Cache-Control", defaultCacheHeader],
|
expect(res.send.mock.calls[0][0]).not.toContain(
|
||||||
["Content-Type", "image/svg+xml"],
|
"https://tiny.one/readme-stats",
|
||||||
]);
|
|
||||||
expect(res.end).toHaveBeenCalledExactlyOnceWith(
|
|
||||||
"render-error:This username is not whitelisted",
|
|
||||||
);
|
);
|
||||||
expect(storeRequestMock).not.toHaveBeenCalled();
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,40 +1,81 @@
|
|||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
import MockAdapter from "axios-mock-adapter";
|
import MockAdapter from "axios-mock-adapter";
|
||||||
import { beforeAll, bench, describe, vi } from "vitest";
|
import { bench, describe, vi } from "vitest";
|
||||||
|
|
||||||
import { data_stats } from "../utils.js";
|
import api from "../../api-renamed/index.js";
|
||||||
|
|
||||||
|
const stats = {
|
||||||
|
name: "Anurag Hazra",
|
||||||
|
totalStars: 100,
|
||||||
|
totalCommits: 200,
|
||||||
|
totalIssues: 300,
|
||||||
|
totalPRs: 400,
|
||||||
|
totalPRsMerged: 320,
|
||||||
|
mergedPRsPercentage: 80,
|
||||||
|
totalReviews: 50,
|
||||||
|
totalDiscussionsStarted: 10,
|
||||||
|
totalDiscussionsAnswered: 40,
|
||||||
|
contributedTo: 50,
|
||||||
|
rank: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
const data_stats = {
|
||||||
|
data: {
|
||||||
|
user: {
|
||||||
|
name: stats.name,
|
||||||
|
repositoriesContributedTo: { totalCount: stats.contributedTo },
|
||||||
|
commits: {
|
||||||
|
totalCommitContributions: stats.totalCommits,
|
||||||
|
},
|
||||||
|
reviews: {
|
||||||
|
totalPullRequestReviewContributions: stats.totalReviews,
|
||||||
|
},
|
||||||
|
pullRequests: { totalCount: stats.totalPRs },
|
||||||
|
mergedPullRequests: { totalCount: stats.totalPRsMerged },
|
||||||
|
openIssues: { totalCount: stats.totalIssues },
|
||||||
|
closedIssues: { totalCount: 0 },
|
||||||
|
followers: { totalCount: 0 },
|
||||||
|
repositoryDiscussions: { totalCount: stats.totalDiscussionsStarted },
|
||||||
|
repositoryDiscussionComments: {
|
||||||
|
totalCount: stats.totalDiscussionsAnswered,
|
||||||
|
},
|
||||||
|
repositories: {
|
||||||
|
totalCount: 1,
|
||||||
|
nodes: [{ stargazers: { totalCount: 100 } }],
|
||||||
|
pageInfo: {
|
||||||
|
hasNextPage: false,
|
||||||
|
endCursor: "cursor",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
const mock = new MockAdapter(axios);
|
const mock = new MockAdapter(axios);
|
||||||
|
|
||||||
const createResponse = () => ({
|
const faker = (query, data) => {
|
||||||
end: vi.fn(),
|
const req = {
|
||||||
setHeader: vi.fn(),
|
query: {
|
||||||
});
|
username: "anuraghazra",
|
||||||
|
...query,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const res = {
|
||||||
|
setHeader: vi.fn(),
|
||||||
|
send: vi.fn(),
|
||||||
|
};
|
||||||
|
mock.onPost("https://api.github.com/graphql").replyOnce(200, data);
|
||||||
|
|
||||||
let router;
|
return { req, res };
|
||||||
|
};
|
||||||
|
|
||||||
beforeAll(async () => {
|
describe("/api", () => {
|
||||||
vi.stubEnv("CACHE_SECONDS", "");
|
|
||||||
vi.stubEnv("GIST_WHITELIST", "");
|
|
||||||
vi.stubEnv("POSTGRES_URL", "");
|
|
||||||
vi.stubEnv("WHITELIST", "");
|
|
||||||
|
|
||||||
({ default: router } = await import("../../router.js"));
|
|
||||||
|
|
||||||
mock.onPost("https://api.github.com/graphql").reply(200, data_stats);
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("bench /api", () => {
|
|
||||||
bench(
|
bench(
|
||||||
"base",
|
"base",
|
||||||
async () => {
|
async () => {
|
||||||
const req = {
|
const { req, res } = faker({}, data_stats);
|
||||||
headers: {},
|
|
||||||
url: "/api?username=anuraghazra",
|
|
||||||
};
|
|
||||||
const res = createResponse();
|
|
||||||
|
|
||||||
await router(req, res);
|
await api(req, res);
|
||||||
},
|
},
|
||||||
{ warmupIterations: 50 },
|
{ warmupIterations: 50 },
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import { bench, describe } from "vitest";
|
||||||
|
|
||||||
|
import { calculateRank } from "../../src/calculateRank.js";
|
||||||
|
|
||||||
|
describe("calculateRank", () => {
|
||||||
|
bench(
|
||||||
|
"base",
|
||||||
|
async () => {
|
||||||
|
calculateRank({
|
||||||
|
all_commits: false,
|
||||||
|
commits: 1300,
|
||||||
|
prs: 1500,
|
||||||
|
issues: 4500,
|
||||||
|
reviews: 1000,
|
||||||
|
repos: 0,
|
||||||
|
stars: 600000,
|
||||||
|
followers: 50000,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
{ warmupIterations: 50 },
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -1,42 +1,54 @@
|
|||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
import MockAdapter from "axios-mock-adapter";
|
import MockAdapter from "axios-mock-adapter";
|
||||||
import { beforeAll, bench, describe, vi } from "vitest";
|
import { bench, describe, vi } from "vitest";
|
||||||
|
|
||||||
import { happy_path_gist_data } from "../utils.js";
|
import gist from "../../api-renamed/gist.js";
|
||||||
|
|
||||||
|
const gist_data = {
|
||||||
|
data: {
|
||||||
|
viewer: {
|
||||||
|
gist: {
|
||||||
|
description:
|
||||||
|
"List of countries and territories in English and Spanish: name, continent, capital, dial code, country codes, TLD, and area in sq km. Lista de países y territorios en Inglés y Español: nombre, continente, capital, código de teléfono, códigos de país, dominio y área en km cuadrados. Updated 2023",
|
||||||
|
owner: {
|
||||||
|
login: "Yizack",
|
||||||
|
},
|
||||||
|
stargazerCount: 33,
|
||||||
|
forks: {
|
||||||
|
totalCount: 11,
|
||||||
|
},
|
||||||
|
files: [
|
||||||
|
{
|
||||||
|
name: "countries.json",
|
||||||
|
language: {
|
||||||
|
name: "JSON",
|
||||||
|
},
|
||||||
|
size: 85858,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
const mock = new MockAdapter(axios);
|
const mock = new MockAdapter(axios);
|
||||||
|
mock.onPost("https://api.github.com/graphql").reply(200, gist_data);
|
||||||
|
|
||||||
const createResponse = () => ({
|
describe("test /api/gist", () => {
|
||||||
end: vi.fn(),
|
|
||||||
setHeader: vi.fn(),
|
|
||||||
});
|
|
||||||
|
|
||||||
let router;
|
|
||||||
|
|
||||||
beforeAll(async () => {
|
|
||||||
vi.stubEnv("CACHE_SECONDS", "");
|
|
||||||
vi.stubEnv("GIST_WHITELIST", "");
|
|
||||||
vi.stubEnv("POSTGRES_URL", "");
|
|
||||||
vi.stubEnv("WHITELIST", "");
|
|
||||||
|
|
||||||
({ default: router } = await import("../../router.js"));
|
|
||||||
|
|
||||||
mock
|
|
||||||
.onPost("https://api.github.com/graphql")
|
|
||||||
.reply(200, happy_path_gist_data);
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("bench /api/gist", () => {
|
|
||||||
bench(
|
bench(
|
||||||
"base",
|
"base",
|
||||||
async () => {
|
async () => {
|
||||||
const req = {
|
const req = {
|
||||||
headers: {},
|
query: {
|
||||||
url: "/api/gist?id=happy-gist-id",
|
id: "bbfce31e0217a3689c8d961a356cb10d",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const res = {
|
||||||
|
setHeader: vi.fn(),
|
||||||
|
send: vi.fn(),
|
||||||
};
|
};
|
||||||
const res = createResponse();
|
|
||||||
|
|
||||||
await router(req, res);
|
await gist(req, res);
|
||||||
},
|
},
|
||||||
{ warmupIterations: 50 },
|
{ warmupIterations: 50 },
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,40 +1,53 @@
|
|||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
import MockAdapter from "axios-mock-adapter";
|
import MockAdapter from "axios-mock-adapter";
|
||||||
import { beforeAll, bench, describe, vi } from "vitest";
|
import { bench, describe, vi } from "vitest";
|
||||||
|
|
||||||
import { data_user } from "../utils.js";
|
import pin from "../../api-renamed/pin.js";
|
||||||
|
|
||||||
|
const data_repo = {
|
||||||
|
repository: {
|
||||||
|
username: "anuraghazra",
|
||||||
|
name: "convoychat",
|
||||||
|
stargazers: {
|
||||||
|
totalCount: 38000,
|
||||||
|
},
|
||||||
|
description: "Help us take over the world! React + TS + GraphQL Chat App",
|
||||||
|
primaryLanguage: {
|
||||||
|
color: "#2b7489",
|
||||||
|
id: "MDg6TGFuZ3VhZ2UyODc=",
|
||||||
|
name: "TypeScript",
|
||||||
|
},
|
||||||
|
forkCount: 100,
|
||||||
|
isTemplate: false,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const data_user = {
|
||||||
|
data: {
|
||||||
|
user: { repository: data_repo.repository },
|
||||||
|
organization: null,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
const mock = new MockAdapter(axios);
|
const mock = new MockAdapter(axios);
|
||||||
|
mock.onPost("https://api.github.com/graphql").reply(200, data_user);
|
||||||
|
|
||||||
const createResponse = () => ({
|
describe("/api/pin", () => {
|
||||||
end: vi.fn(),
|
|
||||||
setHeader: vi.fn(),
|
|
||||||
});
|
|
||||||
|
|
||||||
let router;
|
|
||||||
|
|
||||||
beforeAll(async () => {
|
|
||||||
vi.stubEnv("CACHE_SECONDS", "");
|
|
||||||
vi.stubEnv("GIST_WHITELIST", "");
|
|
||||||
vi.stubEnv("POSTGRES_URL", "");
|
|
||||||
vi.stubEnv("WHITELIST", "");
|
|
||||||
|
|
||||||
({ default: router } = await import("../../router.js"));
|
|
||||||
|
|
||||||
mock.onPost("https://api.github.com/graphql").reply(200, data_user);
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("bench /api/pin", () => {
|
|
||||||
bench(
|
bench(
|
||||||
"base",
|
"base",
|
||||||
async () => {
|
async () => {
|
||||||
const req = {
|
const req = {
|
||||||
headers: {},
|
query: {
|
||||||
url: "/api/pin?username=anuraghazra&repo=convoychat",
|
username: "anuraghazra",
|
||||||
|
repo: "convoychat",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const res = {
|
||||||
|
setHeader: vi.fn(),
|
||||||
|
send: vi.fn(),
|
||||||
};
|
};
|
||||||
const res = createResponse();
|
|
||||||
|
|
||||||
await router(req, res);
|
await pin(req, res);
|
||||||
},
|
},
|
||||||
{ warmupIterations: 50 },
|
{ warmupIterations: 50 },
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import { calculateRank } from "../src/calculateRank.js";
|
|||||||
|
|
||||||
import { approxNumber } from "./utils.js";
|
import { approxNumber } from "./utils.js";
|
||||||
|
|
||||||
|
import "@testing-library/jest-dom/vitest";
|
||||||
|
|
||||||
describe("Test calculateRank", () => {
|
describe("Test calculateRank", () => {
|
||||||
it("new user gets C rank", () => {
|
it("new user gets C rank", () => {
|
||||||
expect(
|
expect(
|
||||||
@@ -6,6 +6,8 @@ import { Card } from "../src/common/Card.js";
|
|||||||
import { getCardColors } from "../src/common/color.js";
|
import { getCardColors } from "../src/common/color.js";
|
||||||
import { icons } from "../src/common/icons.js";
|
import { icons } from "../src/common/icons.js";
|
||||||
|
|
||||||
|
import "@testing-library/jest-dom/vitest";
|
||||||
|
|
||||||
describe("Card", () => {
|
describe("Card", () => {
|
||||||
it("should hide border", () => {
|
it("should hide border", () => {
|
||||||
const card = new Card({});
|
const card = new Card({});
|
||||||
+123
-245
@@ -3,343 +3,221 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
import MockAdapter from "axios-mock-adapter";
|
import { beforeAll, describe, expect, test } from "vitest";
|
||||||
import { afterAll, beforeAll, describe, expect, test, vi } from "vitest";
|
|
||||||
|
import { renderGistCard } from "../../src/cards/gist.js";
|
||||||
|
import { renderRepoCard } from "../../src/cards/repo.js";
|
||||||
|
import { renderStatsCard } from "../../src/cards/stats.js";
|
||||||
|
import { renderTopLanguages } from "../../src/cards/top-languages.js";
|
||||||
|
import { renderWakatimeCard } from "../../src/cards/wakatime.js";
|
||||||
|
|
||||||
const REPO = "curly-fiesta";
|
const REPO = "curly-fiesta";
|
||||||
const USER = "catelinemnemosyne";
|
const USER = "catelinemnemosyne";
|
||||||
const STATS_CARD_USER = "e2eninja";
|
const STATS_CARD_USER = "e2eninja";
|
||||||
const GIST_ID = "372cef55fd897b31909fdeb3a7262758";
|
const GIST_ID = "372cef55fd897b31909fdeb3a7262758";
|
||||||
|
|
||||||
const STATS_MOCK_RESPONSE = {
|
const STATS_DATA = {
|
||||||
data: {
|
name: "CodeNinja",
|
||||||
user: {
|
totalPRs: 1,
|
||||||
name: "CodeNinja",
|
totalReviews: 0,
|
||||||
login: STATS_CARD_USER,
|
totalCommits: 3,
|
||||||
repositoriesContributedTo: { totalCount: 0 },
|
totalIssues: 1,
|
||||||
commits: {
|
totalStars: 1,
|
||||||
totalCommitContributions: 3,
|
contributedTo: 0,
|
||||||
},
|
rank: {
|
||||||
reviews: {
|
level: "C",
|
||||||
totalPullRequestReviewContributions: 0,
|
percentile: 98.73972605284538,
|
||||||
},
|
|
||||||
pullRequests: { totalCount: 1 },
|
|
||||||
openIssues: { totalCount: 1 },
|
|
||||||
closedIssues: { totalCount: 0 },
|
|
||||||
followers: { totalCount: 0 },
|
|
||||||
repositories: {
|
|
||||||
totalCount: 1,
|
|
||||||
nodes: [{ name: REPO, stargazers: { totalCount: 1 } }],
|
|
||||||
pageInfo: {
|
|
||||||
hasNextPage: false,
|
|
||||||
endCursor: "cursor",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const COMMITS_SEARCH_MOCK_RESPONSE = {
|
const LANGS_DATA = {
|
||||||
total_count: 3,
|
HTML: {
|
||||||
};
|
color: "#e34c26",
|
||||||
|
name: "HTML",
|
||||||
const TOP_LANGS_MOCK_RESPONSE = {
|
size: 1721,
|
||||||
data: {
|
},
|
||||||
user: {
|
CSS: {
|
||||||
repositories: {
|
color: "#663399",
|
||||||
nodes: [
|
name: "CSS",
|
||||||
{
|
size: 930,
|
||||||
name: REPO,
|
},
|
||||||
languages: {
|
JavaScript: {
|
||||||
edges: [
|
color: "#f1e05a",
|
||||||
{
|
name: "JavaScript",
|
||||||
size: 1721,
|
size: 1912,
|
||||||
node: {
|
|
||||||
color: "#e34c26",
|
|
||||||
name: "HTML",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
size: 930,
|
|
||||||
node: {
|
|
||||||
color: "#663399",
|
|
||||||
name: "CSS",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
size: 1912,
|
|
||||||
node: {
|
|
||||||
color: "#f1e05a",
|
|
||||||
name: "JavaScript",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const WAKATIME_MOCK_RESPONSE = {
|
const WAKATIME_DATA = {
|
||||||
data: {
|
human_readable_range: "last week",
|
||||||
human_readable_range: "last week",
|
is_already_updating: false,
|
||||||
is_already_updating: false,
|
is_coding_activity_visible: true,
|
||||||
is_coding_activity_visible: true,
|
is_including_today: false,
|
||||||
is_including_today: false,
|
is_other_usage_visible: false,
|
||||||
is_other_usage_visible: false,
|
is_stuck: false,
|
||||||
is_stuck: false,
|
is_up_to_date: false,
|
||||||
is_up_to_date: false,
|
is_up_to_date_pending_future: false,
|
||||||
is_up_to_date_pending_future: false,
|
percent_calculated: 0,
|
||||||
percent_calculated: 0,
|
range: "all_time",
|
||||||
range: "all_time",
|
status: "pending_update",
|
||||||
status: "pending_update",
|
timeout: 15,
|
||||||
timeout: 15,
|
username: USER,
|
||||||
username: USER,
|
writes_only: false,
|
||||||
writes_only: false,
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const REPO_MOCK_RESPONSE = {
|
const REPOSITORY_DATA = {
|
||||||
data: {
|
name: REPO,
|
||||||
user: {
|
nameWithOwner: `${USER}/cra-test`,
|
||||||
repository: {
|
isPrivate: false,
|
||||||
name: REPO,
|
isArchived: false,
|
||||||
nameWithOwner: `${USER}/cra-test`,
|
isTemplate: false,
|
||||||
isPrivate: false,
|
stargazers: {
|
||||||
isArchived: false,
|
totalCount: 1,
|
||||||
isTemplate: false,
|
|
||||||
stargazers: {
|
|
||||||
totalCount: 1,
|
|
||||||
},
|
|
||||||
description: "Simple cra test repo.",
|
|
||||||
primaryLanguage: {
|
|
||||||
color: "#f1e05a",
|
|
||||||
id: "MDg6TGFuZ3VhZ2UxNDA=",
|
|
||||||
name: "JavaScript",
|
|
||||||
},
|
|
||||||
forkCount: 0,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
organization: null,
|
|
||||||
},
|
},
|
||||||
|
description: "Simple cra test repo.",
|
||||||
|
primaryLanguage: {
|
||||||
|
color: "#f1e05a",
|
||||||
|
id: "MDg6TGFuZ3VhZ2UxNDA=",
|
||||||
|
name: "JavaScript",
|
||||||
|
},
|
||||||
|
forkCount: 0,
|
||||||
|
starCount: 1,
|
||||||
};
|
};
|
||||||
|
|
||||||
const GIST_MOCK_RESPONSE = {
|
/**
|
||||||
data: {
|
* @typedef {import("../../src/fetchers/types").GistData} GistData Gist data type.
|
||||||
viewer: {
|
*/
|
||||||
gist: {
|
|
||||||
description:
|
/**
|
||||||
"Trying to access this path on Windows 10 ver. 1803+ will breaks NTFS",
|
* @type {GistData}
|
||||||
owner: {
|
*/
|
||||||
login: "qwerty541",
|
const GIST_DATA = {
|
||||||
},
|
name: "link.txt",
|
||||||
stargazerCount: 1,
|
nameWithOwner: "qwerty541/link.txt",
|
||||||
forks: {
|
description:
|
||||||
totalCount: 0,
|
"Trying to access this path on Windows 10 ver. 1803+ will breaks NTFS",
|
||||||
},
|
language: "Text",
|
||||||
files: [
|
starsCount: 1,
|
||||||
{
|
forksCount: 0,
|
||||||
name: "link.txt",
|
|
||||||
language: {
|
|
||||||
name: "Text",
|
|
||||||
},
|
|
||||||
size: 1,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const CACHE_BURST_STRING = `v=${new Date().getTime()}`;
|
const CACHE_BURST_STRING = `v=${new Date().getTime()}`;
|
||||||
|
|
||||||
const mock = new MockAdapter(axios, { onNoMatch: "passthrough" });
|
|
||||||
|
|
||||||
const createResponse = () => ({
|
|
||||||
end: vi.fn(),
|
|
||||||
setHeader: vi.fn(),
|
|
||||||
});
|
|
||||||
|
|
||||||
let router;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Renders a card locally through the backend router.
|
|
||||||
* @param {string} url Card URL to render through the router.
|
|
||||||
* @returns {Promise<string>} Rendered SVG markup.
|
|
||||||
*/
|
|
||||||
async function getLocalSvg(url) {
|
|
||||||
const req = {
|
|
||||||
headers: {},
|
|
||||||
url,
|
|
||||||
};
|
|
||||||
const res = createResponse();
|
|
||||||
|
|
||||||
await router(req, res);
|
|
||||||
|
|
||||||
expect(res.end).toHaveBeenCalledOnce();
|
|
||||||
return res.end.mock.calls[0][0];
|
|
||||||
}
|
|
||||||
|
|
||||||
beforeAll(async () => {
|
|
||||||
vi.stubEnv("CACHE_SECONDS", "");
|
|
||||||
vi.stubEnv("GIST_WHITELIST", "");
|
|
||||||
vi.stubEnv("POSTGRES_URL", "");
|
|
||||||
vi.stubEnv("WHITELIST", "");
|
|
||||||
|
|
||||||
vi.stubEnv("PAT_1", "dummyPAT1");
|
|
||||||
vi.stubEnv("PAT_2", "dummyPAT2");
|
|
||||||
|
|
||||||
({ default: router } = await import("../../router.js"));
|
|
||||||
|
|
||||||
mock.onPost("https://api.github.com/graphql").reply((config) => {
|
|
||||||
const { query, variables } = JSON.parse(config.data);
|
|
||||||
|
|
||||||
if (
|
|
||||||
query.includes("query userInfo") &&
|
|
||||||
variables?.login === STATS_CARD_USER
|
|
||||||
) {
|
|
||||||
return [200, STATS_MOCK_RESPONSE];
|
|
||||||
}
|
|
||||||
|
|
||||||
if (query.includes("query userInfo") && variables?.login === USER) {
|
|
||||||
return [200, TOP_LANGS_MOCK_RESPONSE];
|
|
||||||
}
|
|
||||||
|
|
||||||
if (query.includes("query getRepo")) {
|
|
||||||
return [200, REPO_MOCK_RESPONSE];
|
|
||||||
}
|
|
||||||
|
|
||||||
if (query.includes("query gistInfo")) {
|
|
||||||
return [200, GIST_MOCK_RESPONSE];
|
|
||||||
}
|
|
||||||
|
|
||||||
return [500, { error: "Unhandled GraphQL request in e2e test" }];
|
|
||||||
});
|
|
||||||
|
|
||||||
mock
|
|
||||||
.onGet(
|
|
||||||
`https://api.github.com/search/commits?per_page=1&q=author:${STATS_CARD_USER}`,
|
|
||||||
)
|
|
||||||
.reply(200, COMMITS_SEARCH_MOCK_RESPONSE);
|
|
||||||
|
|
||||||
mock
|
|
||||||
.onGet(
|
|
||||||
`https://wakatime.com/api/v1/users/${USER}/stats?is_including_today=true`,
|
|
||||||
)
|
|
||||||
.reply(200, WAKATIME_MOCK_RESPONSE);
|
|
||||||
});
|
|
||||||
|
|
||||||
afterAll(() => {
|
|
||||||
mock.restore();
|
|
||||||
vi.unstubAllEnvs();
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("Fetch Cards", () => {
|
describe("Fetch Cards", () => {
|
||||||
const VERCEL_PREVIEW_URL = "https://github-stats-extended-preview.vercel.app";
|
let VERCEL_PREVIEW_URL = "https://github-stats-extended.vercel.app";
|
||||||
|
|
||||||
|
beforeAll(() => {
|
||||||
|
process.env.NODE_ENV = "development";
|
||||||
|
});
|
||||||
|
|
||||||
test("retrieve stats card", async () => {
|
test("retrieve stats card", async () => {
|
||||||
expect(VERCEL_PREVIEW_URL).toBeDefined();
|
expect(VERCEL_PREVIEW_URL).toBeDefined();
|
||||||
|
|
||||||
const cardPath = `/api?username=${STATS_CARD_USER}&include_all_commits=true&${CACHE_BURST_STRING}`;
|
|
||||||
|
|
||||||
// Check if the Vercel preview instance stats card function is up and running.
|
// Check if the Vercel preview instance stats card function is up and running.
|
||||||
await expect(
|
await expect(
|
||||||
axios.get(`${VERCEL_PREVIEW_URL}${cardPath}`),
|
axios.get(`${VERCEL_PREVIEW_URL}/api?username=${STATS_CARD_USER}`),
|
||||||
).resolves.not.toThrow();
|
).resolves.not.toThrow();
|
||||||
|
|
||||||
// Get local stats card.
|
// Get local stats card.
|
||||||
const localStatsCardSVG = await getLocalSvg(cardPath);
|
const localStatsCardSVG = renderStatsCard(STATS_DATA, {
|
||||||
|
include_all_commits: true,
|
||||||
|
});
|
||||||
|
|
||||||
// Get the Vercel preview stats card response.
|
// Get the Vercel preview stats card response.
|
||||||
const serverStatsSvg = await axios.get(`${VERCEL_PREVIEW_URL}${cardPath}`);
|
const serverStatsSvg = await axios.get(
|
||||||
|
`${VERCEL_PREVIEW_URL}/api?username=${STATS_CARD_USER}&include_all_commits=true&${CACHE_BURST_STRING}`,
|
||||||
|
);
|
||||||
|
|
||||||
// Check if stats card from deployment matches the stats card from local.
|
// Check if stats card from deployment matches the stats card from local.
|
||||||
expect(serverStatsSvg.data).toEqual(localStatsCardSVG);
|
expect(serverStatsSvg.data).toEqual(localStatsCardSVG);
|
||||||
}, 20000);
|
}, 15000);
|
||||||
|
|
||||||
test("retrieve language card", async () => {
|
test("retrieve language card", async () => {
|
||||||
expect(VERCEL_PREVIEW_URL).toBeDefined();
|
expect(VERCEL_PREVIEW_URL).toBeDefined();
|
||||||
|
|
||||||
const cardPath = `/api/top-langs?username=${USER}&${CACHE_BURST_STRING}`;
|
|
||||||
|
|
||||||
// Check if the Vercel preview instance language card function is up and running.
|
// Check if the Vercel preview instance language card function is up and running.
|
||||||
|
console.log(
|
||||||
|
`${VERCEL_PREVIEW_URL}/api/top-langs/?username=${USER}&${CACHE_BURST_STRING}`,
|
||||||
|
);
|
||||||
await expect(
|
await expect(
|
||||||
axios.get(`${VERCEL_PREVIEW_URL}${cardPath}`),
|
axios.get(
|
||||||
|
`${VERCEL_PREVIEW_URL}/api/top-langs/?username=${USER}&${CACHE_BURST_STRING}`,
|
||||||
|
),
|
||||||
).resolves.not.toThrow();
|
).resolves.not.toThrow();
|
||||||
|
|
||||||
// Get local language card.
|
// Get local language card.
|
||||||
const localLanguageCardSVG = await getLocalSvg(cardPath);
|
const localLanguageCardSVG = renderTopLanguages(LANGS_DATA);
|
||||||
|
|
||||||
// Get the Vercel preview language card response.
|
// Get the Vercel preview language card response.
|
||||||
const serverLanguageSVG = await axios.get(
|
const severLanguageSVG = await axios.get(
|
||||||
`${VERCEL_PREVIEW_URL}${cardPath}`,
|
`${VERCEL_PREVIEW_URL}/api/top-langs/?username=${USER}&${CACHE_BURST_STRING}`,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Check if language card from deployment matches the local language card.
|
// Check if language card from deployment matches the local language card.
|
||||||
expect(serverLanguageSVG.data).toEqual(localLanguageCardSVG);
|
expect(severLanguageSVG.data).toEqual(localLanguageCardSVG);
|
||||||
}, 20000);
|
}, 15000);
|
||||||
|
|
||||||
test("retrieve WakaTime card", async () => {
|
test("retrieve WakaTime card", async () => {
|
||||||
expect(VERCEL_PREVIEW_URL).toBeDefined();
|
expect(VERCEL_PREVIEW_URL).toBeDefined();
|
||||||
|
|
||||||
const cardPath = `/api/wakatime?username=${USER}&${CACHE_BURST_STRING}`;
|
|
||||||
|
|
||||||
// Check if the Vercel preview instance WakaTime function is up and running.
|
// Check if the Vercel preview instance WakaTime function is up and running.
|
||||||
await expect(
|
await expect(
|
||||||
axios.get(`${VERCEL_PREVIEW_URL}${cardPath}`),
|
axios.get(`${VERCEL_PREVIEW_URL}/api/wakatime?username=${USER}`),
|
||||||
).resolves.not.toThrow();
|
).resolves.not.toThrow();
|
||||||
|
|
||||||
// Get local WakaTime card.
|
// Get local WakaTime card.
|
||||||
const localWakaCardSVG = await getLocalSvg(cardPath);
|
const localWakaCardSVG = renderWakatimeCard(WAKATIME_DATA);
|
||||||
|
|
||||||
// Get the Vercel preview WakaTime card response.
|
// Get the Vercel preview WakaTime card response.
|
||||||
const serverWakaTimeSvg = await axios.get(
|
const serverWakaTimeSvg = await axios.get(
|
||||||
`${VERCEL_PREVIEW_URL}${cardPath}`,
|
`${VERCEL_PREVIEW_URL}/api/wakatime?username=${USER}&${CACHE_BURST_STRING}`,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Check if WakaTime card from deployment matches the local WakaTime card.
|
// Check if WakaTime card from deployment matches the local WakaTime card.
|
||||||
expect(serverWakaTimeSvg.data).toEqual(localWakaCardSVG);
|
expect(serverWakaTimeSvg.data).toEqual(localWakaCardSVG);
|
||||||
}, 20000);
|
}, 15000);
|
||||||
|
|
||||||
test("retrieve repo card", async () => {
|
test("retrieve repo card", async () => {
|
||||||
expect(VERCEL_PREVIEW_URL).toBeDefined();
|
expect(VERCEL_PREVIEW_URL).toBeDefined();
|
||||||
|
|
||||||
const cardPath = `/api/pin?username=${USER}&repo=${REPO}&${CACHE_BURST_STRING}`;
|
|
||||||
|
|
||||||
// Check if the Vercel preview instance Repo function is up and running.
|
// Check if the Vercel preview instance Repo function is up and running.
|
||||||
await expect(
|
await expect(
|
||||||
axios.get(`${VERCEL_PREVIEW_URL}${cardPath}`),
|
axios.get(
|
||||||
|
`${VERCEL_PREVIEW_URL}/api/pin/?username=${USER}&repo=${REPO}&${CACHE_BURST_STRING}`,
|
||||||
|
),
|
||||||
).resolves.not.toThrow();
|
).resolves.not.toThrow();
|
||||||
|
|
||||||
// Get local repo card.
|
// Get local repo card.
|
||||||
const localRepoCardSVG = await getLocalSvg(cardPath);
|
const localRepoCardSVG = renderRepoCard(REPOSITORY_DATA);
|
||||||
|
|
||||||
// Get the Vercel preview repo card response.
|
// Get the Vercel preview repo card response.
|
||||||
const serverRepoSvg = await axios.get(`${VERCEL_PREVIEW_URL}${cardPath}`);
|
const serverRepoSvg = await axios.get(
|
||||||
|
`${VERCEL_PREVIEW_URL}/api/pin/?username=${USER}&repo=${REPO}&${CACHE_BURST_STRING}`,
|
||||||
|
);
|
||||||
|
|
||||||
// Check if Repo card from deployment matches the local Repo card.
|
// Check if Repo card from deployment matches the local Repo card.
|
||||||
expect(serverRepoSvg.data).toEqual(localRepoCardSVG);
|
expect(serverRepoSvg.data).toEqual(localRepoCardSVG);
|
||||||
}, 20000);
|
}, 15000);
|
||||||
|
|
||||||
test("retrieve gist card", async () => {
|
test("retrieve gist card", async () => {
|
||||||
expect(VERCEL_PREVIEW_URL).toBeDefined();
|
expect(VERCEL_PREVIEW_URL).toBeDefined();
|
||||||
|
|
||||||
const cardPath = `/api/gist?id=${GIST_ID}&${CACHE_BURST_STRING}`;
|
|
||||||
|
|
||||||
// Check if the Vercel preview instance Gist function is up and running.
|
// Check if the Vercel preview instance Gist function is up and running.
|
||||||
await expect(
|
await expect(
|
||||||
axios.get(`${VERCEL_PREVIEW_URL}${cardPath}`),
|
axios.get(
|
||||||
|
`${VERCEL_PREVIEW_URL}/api/gist?id=${GIST_ID}&${CACHE_BURST_STRING}`,
|
||||||
|
),
|
||||||
).resolves.not.toThrow();
|
).resolves.not.toThrow();
|
||||||
|
|
||||||
// Get local gist card.
|
// Get local gist card.
|
||||||
const localGistCardSVG = await getLocalSvg(cardPath);
|
const localGistCardSVG = renderGistCard(GIST_DATA);
|
||||||
|
|
||||||
// Get the Vercel preview gist card response.
|
// Get the Vercel preview gist card response.
|
||||||
const serverGistSvg = await axios.get(`${VERCEL_PREVIEW_URL}${cardPath}`);
|
const serverGistSvg = await axios.get(
|
||||||
|
`${VERCEL_PREVIEW_URL}/api/gist?id=${GIST_ID}&${CACHE_BURST_STRING}`,
|
||||||
|
);
|
||||||
|
|
||||||
// Check if Gist card from deployment matches the local Gist card.
|
// Check if Gist card from deployment matches the local Gist card.
|
||||||
expect(serverGistSvg.data).toEqual(localGistCardSVG);
|
expect(serverGistSvg.data).toEqual(localGistCardSVG);
|
||||||
}, 20000);
|
}, 15000);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import { afterEach, describe, expect, it } from "vitest";
|
|||||||
|
|
||||||
import { fetchGist } from "../src/fetchers/gist.js";
|
import { fetchGist } from "../src/fetchers/gist.js";
|
||||||
|
|
||||||
|
import "@testing-library/jest-dom/vitest";
|
||||||
|
|
||||||
const gist_data = {
|
const gist_data = {
|
||||||
data: {
|
data: {
|
||||||
viewer: {
|
viewer: {
|
||||||
@@ -4,6 +4,8 @@ import { afterEach, describe, expect, it } from "vitest";
|
|||||||
|
|
||||||
import { fetchRepo } from "../src/fetchers/repo.js";
|
import { fetchRepo } from "../src/fetchers/repo.js";
|
||||||
|
|
||||||
|
import "@testing-library/jest-dom/vitest";
|
||||||
|
|
||||||
const data_repo = {
|
const data_repo = {
|
||||||
repository: {
|
repository: {
|
||||||
name: "convoychat",
|
name: "convoychat",
|
||||||
@@ -1,15 +1,11 @@
|
|||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
import MockAdapter from "axios-mock-adapter";
|
import MockAdapter from "axios-mock-adapter";
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||||
|
|
||||||
import { calculateRank } from "../src/calculateRank.js";
|
import { calculateRank } from "../src/calculateRank.js";
|
||||||
import { loadConfigFromEnv } from "../src/common/config.js";
|
|
||||||
import { fetchStats } from "../src/fetchers/stats.js";
|
import { fetchStats } from "../src/fetchers/stats.js";
|
||||||
|
|
||||||
vi.mock(import("../src/common/log.js"), async () => {
|
import "@testing-library/jest-dom/vitest";
|
||||||
const { createLoggerMock } = await import("./utils.js");
|
|
||||||
return createLoggerMock();
|
|
||||||
});
|
|
||||||
|
|
||||||
// Test parameters.
|
// Test parameters.
|
||||||
const data_stats = {
|
const data_stats = {
|
||||||
@@ -111,7 +107,6 @@ const mock = new MockAdapter(axios);
|
|||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
process.env.FETCH_MULTI_PAGE_STARS = "false"; // Set to `false` to fetch only one page of stars.
|
process.env.FETCH_MULTI_PAGE_STARS = "false"; // Set to `false` to fetch only one page of stars.
|
||||||
loadConfigFromEnv();
|
|
||||||
mock.onPost("https://api.github.com/graphql").reply((cfg) => {
|
mock.onPost("https://api.github.com/graphql").reply((cfg) => {
|
||||||
let req = JSON.parse(cfg.data);
|
let req = JSON.parse(cfg.data);
|
||||||
|
|
||||||
@@ -318,7 +313,6 @@ describe("Test fetchStats", () => {
|
|||||||
|
|
||||||
it("should fetch two pages of stars if 'FETCH_MULTI_PAGE_STARS' env variable is set to `true`", async () => {
|
it("should fetch two pages of stars if 'FETCH_MULTI_PAGE_STARS' env variable is set to `true`", async () => {
|
||||||
process.env.FETCH_MULTI_PAGE_STARS = true;
|
process.env.FETCH_MULTI_PAGE_STARS = true;
|
||||||
loadConfigFromEnv();
|
|
||||||
|
|
||||||
let stats = await fetchStats("anuraghazra");
|
let stats = await fetchStats("anuraghazra");
|
||||||
const rank = calculateRank({
|
const rank = calculateRank({
|
||||||
@@ -355,7 +349,6 @@ describe("Test fetchStats", () => {
|
|||||||
|
|
||||||
it("should fetch one page of stars if 'FETCH_MULTI_PAGE_STARS' env variable is set to `false`", async () => {
|
it("should fetch one page of stars if 'FETCH_MULTI_PAGE_STARS' env variable is set to `false`", async () => {
|
||||||
process.env.FETCH_MULTI_PAGE_STARS = "false";
|
process.env.FETCH_MULTI_PAGE_STARS = "false";
|
||||||
loadConfigFromEnv();
|
|
||||||
|
|
||||||
let stats = await fetchStats("anuraghazra");
|
let stats = await fetchStats("anuraghazra");
|
||||||
const rank = calculateRank({
|
const rank = calculateRank({
|
||||||
@@ -392,7 +385,6 @@ describe("Test fetchStats", () => {
|
|||||||
|
|
||||||
it("should fetch one page of stars if 'FETCH_MULTI_PAGE_STARS' env variable is not set", async () => {
|
it("should fetch one page of stars if 'FETCH_MULTI_PAGE_STARS' env variable is not set", async () => {
|
||||||
process.env.FETCH_MULTI_PAGE_STARS = undefined;
|
process.env.FETCH_MULTI_PAGE_STARS = undefined;
|
||||||
loadConfigFromEnv();
|
|
||||||
|
|
||||||
let stats = await fetchStats("anuraghazra");
|
let stats = await fetchStats("anuraghazra");
|
||||||
const rank = calculateRank({
|
const rank = calculateRank({
|
||||||
@@ -539,6 +531,7 @@ describe("Test fetchStats", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("should return correct data when user don't have any pull requests", async () => {
|
it("should return correct data when user don't have any pull requests", async () => {
|
||||||
|
mock.reset();
|
||||||
mock
|
mock
|
||||||
.onPost("https://api.github.com/graphql")
|
.onPost("https://api.github.com/graphql")
|
||||||
.reply(200, data_without_pull_requests);
|
.reply(200, data_without_pull_requests);
|
||||||
+2
-16
@@ -1,25 +1,17 @@
|
|||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
import MockAdapter from "axios-mock-adapter";
|
import MockAdapter from "axios-mock-adapter";
|
||||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, describe, expect, it } from "vitest";
|
||||||
|
|
||||||
import { fetchTopLanguages } from "../src/fetchers/top-languages.js";
|
import { fetchTopLanguages } from "../src/fetchers/top-languages.js";
|
||||||
|
|
||||||
import { approxNumber } from "./utils.js";
|
import { approxNumber } from "./utils.js";
|
||||||
|
|
||||||
vi.mock(import("../src/common/log.js"), async () => {
|
import "@testing-library/jest-dom/vitest";
|
||||||
const { createLoggerMock } = await import("./utils.js");
|
|
||||||
return createLoggerMock();
|
|
||||||
});
|
|
||||||
|
|
||||||
const { logger } = await import("../src/index.js");
|
|
||||||
|
|
||||||
const loggerErrorSpy = vi.mocked(logger.error);
|
|
||||||
|
|
||||||
const mock = new MockAdapter(axios);
|
const mock = new MockAdapter(axios);
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
mock.reset();
|
mock.reset();
|
||||||
loggerErrorSpy.mockClear();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const data_langs = {
|
const data_langs = {
|
||||||
@@ -159,8 +151,6 @@ describe("FetchTopLanguages", () => {
|
|||||||
await expect(fetchTopLanguages("anuraghazra")).rejects.toThrow(
|
await expect(fetchTopLanguages("anuraghazra")).rejects.toThrow(
|
||||||
"Could not resolve to a User with the login of 'noname'.",
|
"Could not resolve to a User with the login of 'noname'.",
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(loggerErrorSpy).toHaveBeenCalledOnce();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should throw other errors with their message", async () => {
|
it("should throw other errors with their message", async () => {
|
||||||
@@ -171,8 +161,6 @@ describe("FetchTopLanguages", () => {
|
|||||||
await expect(fetchTopLanguages("anuraghazra")).rejects.toThrow(
|
await expect(fetchTopLanguages("anuraghazra")).rejects.toThrow(
|
||||||
"Some test GraphQL error",
|
"Some test GraphQL error",
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(loggerErrorSpy).toHaveBeenCalledOnce();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should throw error with specific message when error does not contain message property", async () => {
|
it("should throw error with specific message when error does not contain message property", async () => {
|
||||||
@@ -183,7 +171,5 @@ describe("FetchTopLanguages", () => {
|
|||||||
await expect(fetchTopLanguages("anuraghazra")).rejects.toThrow(
|
await expect(fetchTopLanguages("anuraghazra")).rejects.toThrow(
|
||||||
"Something went wrong while trying to retrieve the language data using the GraphQL API.",
|
"Something went wrong while trying to retrieve the language data using the GraphQL API.",
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(loggerErrorSpy).toHaveBeenCalledOnce();
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -4,6 +4,8 @@ import { afterEach, describe, expect, it } from "vitest";
|
|||||||
|
|
||||||
import { fetchWakatimeStats } from "../src/fetchers/wakatime.js";
|
import { fetchWakatimeStats } from "../src/fetchers/wakatime.js";
|
||||||
|
|
||||||
|
import "@testing-library/jest-dom/vitest";
|
||||||
|
|
||||||
const mock = new MockAdapter(axios);
|
const mock = new MockAdapter(axios);
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
@@ -72,12 +72,7 @@ describe("Test fmt.js", () => {
|
|||||||
|
|
||||||
it("wrapTextMultiline: should not wrap small texts", () => {
|
it("wrapTextMultiline: should not wrap small texts", () => {
|
||||||
{
|
{
|
||||||
let multiLineText = wrapTextMultiline(
|
let multiLineText = wrapTextMultiline("Small text should not wrap");
|
||||||
"Small text should not wrap",
|
|
||||||
130,
|
|
||||||
11,
|
|
||||||
3,
|
|
||||||
);
|
|
||||||
expect(multiLineText).toEqual(["Small text should not wrap"]);
|
expect(multiLineText).toEqual(["Small text should not wrap"]);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -85,31 +80,26 @@ describe("Test fmt.js", () => {
|
|||||||
it("wrapTextMultiline: should wrap large texts", () => {
|
it("wrapTextMultiline: should wrap large texts", () => {
|
||||||
let multiLineText = wrapTextMultiline(
|
let multiLineText = wrapTextMultiline(
|
||||||
"Hello world long long long text",
|
"Hello world long long long text",
|
||||||
130,
|
20,
|
||||||
11,
|
|
||||||
3,
|
3,
|
||||||
);
|
);
|
||||||
expect(multiLineText).toEqual(["Hello world long long", "long text"]);
|
expect(multiLineText).toEqual(["Hello world long", "long long text"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("wrapTextMultiline: should wrap large texts and limit max lines", () => {
|
it("wrapTextMultiline: should wrap large texts and limit max lines", () => {
|
||||||
let multiLineText = wrapTextMultiline(
|
let multiLineText = wrapTextMultiline(
|
||||||
"Hello world long long long text",
|
"Hello world long long long text",
|
||||||
53,
|
10,
|
||||||
11,
|
|
||||||
2,
|
2,
|
||||||
);
|
);
|
||||||
expect(multiLineText).toEqual(["Hello", "world long..."]);
|
expect(multiLineText).toEqual(["Hello", "world long..."]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("wrapTextMultiline: should handle chinese characters", () => {
|
it("wrapTextMultiline: should wrap chinese by punctuation", () => {
|
||||||
let multiLineText = wrapTextMultiline(
|
let multiLineText = wrapTextMultiline(
|
||||||
"专门为刚开始刷题的同学准备的算法基地,没有最细只有更细,立志用动画将晦涩难懂的算法说的通俗易懂!",
|
"专门为刚开始刷题的同学准备的算法基地,没有最细只有更细,立志用动画将晦涩难懂的算法说的通俗易懂!",
|
||||||
130,
|
|
||||||
11,
|
|
||||||
3,
|
|
||||||
);
|
);
|
||||||
expect(multiLineText.length).toEqual(3);
|
expect(multiLineText.length).toEqual(3);
|
||||||
expect(multiLineText[0].length).toEqual(11 * 8); // &#xxxxx; x 8
|
expect(multiLineText[0].length).toEqual(18 * 8); // &#xxxxx; x 8
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
+142
-130
@@ -1,149 +1,161 @@
|
|||||||
// @ts-check
|
// @ts-check
|
||||||
|
|
||||||
import { getConfig, gist } from "@stats-organization/github-readme-stats-core";
|
import axios from "axios";
|
||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
import MockAdapter from "axios-mock-adapter";
|
||||||
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
import router from "../router.js";
|
import gist from "../api-renamed/gist.js";
|
||||||
|
import { renderGistCard } from "../src/cards/gist.js";
|
||||||
import { CACHE_TTL, DURATIONS } from "../src/common/cache.js";
|
import { CACHE_TTL, DURATIONS } from "../src/common/cache.js";
|
||||||
import { getUserAccessByName, storeRequest } from "../src/common/database.js";
|
import { renderError } from "../src/common/render.js";
|
||||||
|
|
||||||
vi.mock(import("@stats-organization/github-readme-stats-core"), async () => {
|
import { gist_data } from "./test-data/gist-data.js";
|
||||||
const { mockCore } = await import("./utils.js");
|
|
||||||
return mockCore();
|
import "@testing-library/jest-dom/vitest";
|
||||||
|
|
||||||
|
const gist_not_found_data = {
|
||||||
|
data: {
|
||||||
|
viewer: {
|
||||||
|
gist: null,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const mock = new MockAdapter(axios);
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
mock.reset();
|
||||||
});
|
});
|
||||||
|
|
||||||
vi.mock(import("../src/common/database.js"), async (importOriginal) => ({
|
describe("Test /api/gist", () => {
|
||||||
...(await importOriginal()),
|
it("should test the request", async () => {
|
||||||
storeRequest: vi.fn(),
|
const req = {
|
||||||
getUserAccessByName: vi.fn(),
|
query: {
|
||||||
}));
|
id: "bbfce31e0217a3689c8d961a356cb10d",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const res = {
|
||||||
|
setHeader: vi.fn(),
|
||||||
|
send: vi.fn(),
|
||||||
|
};
|
||||||
|
mock.onPost("https://api.github.com/graphql").reply(200, gist_data);
|
||||||
|
|
||||||
const gistMock = vi.mocked(gist);
|
await gist(req, res);
|
||||||
const getConfigMock = vi.mocked(getConfig);
|
|
||||||
const storeRequestMock = vi.mocked(storeRequest);
|
|
||||||
const getUserAccessByNameMock = vi.mocked(getUserAccessByName);
|
|
||||||
|
|
||||||
const createRequest = (search) => ({
|
expect(res.setHeader).toHaveBeenCalledWith("Content-Type", "image/svg+xml");
|
||||||
headers: {},
|
expect(res.send).toHaveBeenCalledWith(
|
||||||
url: `/api/gist?${search}`,
|
renderGistCard({
|
||||||
});
|
name: gist_data.data.viewer.gist.files[0].name,
|
||||||
|
nameWithOwner: `${gist_data.data.viewer.gist.owner.login}/${gist_data.data.viewer.gist.files[0].name}`,
|
||||||
const createResponse = () => ({
|
description: gist_data.data.viewer.gist.description,
|
||||||
end: vi.fn(),
|
language: gist_data.data.viewer.gist.files[0].language.name,
|
||||||
setHeader: vi.fn(),
|
starsCount: gist_data.data.viewer.gist.stargazerCount,
|
||||||
});
|
forksCount: gist_data.data.viewer.gist.forks.totalCount,
|
||||||
|
}),
|
||||||
const defaultCacheHeader =
|
);
|
||||||
`max-age=${CACHE_TTL.GIST_CARD.DEFAULT}, ` +
|
});
|
||||||
`s-maxage=${CACHE_TTL.GIST_CARD.DEFAULT}, ` +
|
|
||||||
`stale-while-revalidate=${DURATIONS.ONE_DAY}`;
|
|
||||||
|
|
||||||
const errorCacheHeader =
|
|
||||||
`max-age=${CACHE_TTL.ERROR}, ` +
|
|
||||||
`s-maxage=${CACHE_TTL.ERROR}, ` +
|
|
||||||
`stale-while-revalidate=${DURATIONS.ONE_DAY}`;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
gistMock.mockReset();
|
|
||||||
getConfigMock.mockReset().mockReturnValue({});
|
|
||||||
storeRequestMock.mockReset().mockResolvedValue(undefined);
|
|
||||||
getUserAccessByNameMock.mockReset().mockResolvedValue(null);
|
|
||||||
// CACHE_SECONDS is not set here, this is just to safeguard against CACHE_SECONDS being set externally
|
|
||||||
delete process.env.CACHE_SECONDS;
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("Test /api/gist backend routing", () => {
|
it("should get the query options", async () => {
|
||||||
it("happy path should pass query params, respond with gist content and persist request", async () => {
|
const req = {
|
||||||
gistMock.mockResolvedValue({
|
query: {
|
||||||
status: "success",
|
id: "bbfce31e0217a3689c8d961a356cb10d",
|
||||||
content: "mock-gist-svg",
|
title_color: "fff",
|
||||||
});
|
icon_color: "fff",
|
||||||
|
text_color: "fff",
|
||||||
const req = createRequest("id=bbfce31e0217a3689c8d961a356cb10d&theme=dark");
|
bg_color: "fff",
|
||||||
const res = createResponse();
|
show_owner: true,
|
||||||
|
},
|
||||||
await router(req, res);
|
};
|
||||||
|
const res = {
|
||||||
expect(gistMock).toHaveBeenCalledWith({
|
setHeader: vi.fn(),
|
||||||
id: "bbfce31e0217a3689c8d961a356cb10d",
|
send: vi.fn(),
|
||||||
theme: "dark",
|
};
|
||||||
});
|
mock.onPost("https://api.github.com/graphql").reply(200, gist_data);
|
||||||
expect(getUserAccessByNameMock).not.toHaveBeenCalled();
|
|
||||||
expect(req.query).toEqual({
|
await gist(req, res);
|
||||||
id: "bbfce31e0217a3689c8d961a356cb10d",
|
|
||||||
theme: "dark",
|
expect(res.setHeader).toHaveBeenCalledWith("Content-Type", "image/svg+xml");
|
||||||
});
|
expect(res.send).toHaveBeenCalledWith(
|
||||||
expect(res.setHeader.mock.calls).toEqual([
|
renderGistCard(
|
||||||
["Cache-Control", defaultCacheHeader],
|
{
|
||||||
["Content-Type", "image/svg+xml"],
|
name: gist_data.data.viewer.gist.files[0].name,
|
||||||
]);
|
nameWithOwner: `${gist_data.data.viewer.gist.owner.login}/${gist_data.data.viewer.gist.files[0].name}`,
|
||||||
expect(res.end).toHaveBeenCalledExactlyOnceWith("mock-gist-svg");
|
description: gist_data.data.viewer.gist.description,
|
||||||
expect(storeRequestMock).toHaveBeenCalledExactlyOnceWith(req);
|
language: gist_data.data.viewer.gist.files[0].language.name,
|
||||||
|
starsCount: gist_data.data.viewer.gist.stargazerCount,
|
||||||
|
forksCount: gist_data.data.viewer.gist.forks.totalCount,
|
||||||
|
},
|
||||||
|
{ ...req.query },
|
||||||
|
),
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should use the shorter error cache for temporary gist errors", async () => {
|
|
||||||
gistMock.mockResolvedValue({
|
|
||||||
status: "error - temporary",
|
|
||||||
content: "temporary-error-svg",
|
|
||||||
});
|
|
||||||
|
|
||||||
const req = createRequest("id=bbfce31e0217a3689c8d961a356cb10d");
|
it("should render error if gist is not found", async () => {
|
||||||
const res = createResponse();
|
const req = {
|
||||||
|
query: {
|
||||||
await router(req, res);
|
id: "bbfce31e0217a3689c8d961a356cb10d",
|
||||||
|
},
|
||||||
expect(gistMock).toHaveBeenCalledWith({
|
};
|
||||||
id: "bbfce31e0217a3689c8d961a356cb10d",
|
const res = {
|
||||||
});
|
setHeader: vi.fn(),
|
||||||
expect(getUserAccessByNameMock).not.toHaveBeenCalled();
|
send: vi.fn(),
|
||||||
expect(res.setHeader.mock.calls).toEqual([
|
};
|
||||||
["Cache-Control", errorCacheHeader],
|
mock
|
||||||
["Content-Type", "image/svg+xml"],
|
.onPost("https://api.github.com/graphql")
|
||||||
]);
|
.reply(200, gist_not_found_data);
|
||||||
expect(res.end).toHaveBeenCalledExactlyOnceWith("temporary-error-svg");
|
|
||||||
expect(storeRequestMock).toHaveBeenCalledExactlyOnceWith(req);
|
await gist(req, res);
|
||||||
|
|
||||||
|
expect(res.setHeader).toHaveBeenCalledWith("Content-Type", "image/svg+xml");
|
||||||
|
expect(res.send).toHaveBeenCalledWith(
|
||||||
|
renderError({ message: "Gist not found" }),
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should not persist permanent gist errors returned by core", async () => {
|
|
||||||
gistMock.mockResolvedValue({
|
|
||||||
status: "error - permanent",
|
|
||||||
content: "permanent-error-svg",
|
|
||||||
});
|
|
||||||
|
|
||||||
const req = createRequest("id=bbfce31e0217a3689c8d961a356cb10d");
|
|
||||||
const res = createResponse();
|
|
||||||
|
|
||||||
await router(req, res);
|
it("should render error if wrong locale is provided", async () => {
|
||||||
|
const req = {
|
||||||
expect(gistMock).toHaveBeenCalledWith({
|
query: {
|
||||||
id: "bbfce31e0217a3689c8d961a356cb10d",
|
id: "bbfce31e0217a3689c8d961a356cb10d",
|
||||||
});
|
locale: "asdf",
|
||||||
expect(getUserAccessByNameMock).not.toHaveBeenCalled();
|
},
|
||||||
expect(res.setHeader.mock.calls).toEqual([
|
};
|
||||||
["Cache-Control", defaultCacheHeader],
|
const res = {
|
||||||
["Content-Type", "image/svg+xml"],
|
setHeader: vi.fn(),
|
||||||
]);
|
send: vi.fn(),
|
||||||
expect(res.end).toHaveBeenCalledExactlyOnceWith("permanent-error-svg");
|
};
|
||||||
expect(storeRequestMock).not.toHaveBeenCalled();
|
mock.onPost("https://api.github.com/graphql").reply(200, gist_data);
|
||||||
|
|
||||||
|
await gist(req, res);
|
||||||
|
|
||||||
|
expect(res.setHeader).toHaveBeenCalledWith("Content-Type", "image/svg+xml");
|
||||||
|
expect(res.send).toHaveBeenCalledWith(
|
||||||
|
renderError({
|
||||||
|
message: "Something went wrong",
|
||||||
|
secondaryMessage: "Language not found",
|
||||||
|
}),
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should reject non-whitelisted gist ids before calling core logic", async () => {
|
|
||||||
getConfigMock.mockReturnValue({ gistWhitelist: ["allowed-gist-id"] });
|
|
||||||
|
|
||||||
const req = createRequest("id=blocked-gist-id");
|
|
||||||
const res = createResponse();
|
|
||||||
|
|
||||||
await router(req, res);
|
|
||||||
|
|
||||||
expect(gistMock).not.toHaveBeenCalled();
|
it("should have proper cache", async () => {
|
||||||
expect(getUserAccessByNameMock).not.toHaveBeenCalled();
|
const req = {
|
||||||
expect(res.setHeader.mock.calls).toEqual([
|
query: {
|
||||||
["Cache-Control", defaultCacheHeader],
|
id: "bbfce31e0217a3689c8d961a356cb10d",
|
||||||
["Content-Type", "image/svg+xml"],
|
},
|
||||||
]);
|
};
|
||||||
expect(res.end).toHaveBeenCalledExactlyOnceWith(
|
const res = {
|
||||||
"render-error:This gist ID is not whitelisted",
|
setHeader: vi.fn(),
|
||||||
|
send: vi.fn(),
|
||||||
|
};
|
||||||
|
mock.onPost("https://api.github.com/graphql").reply(200, gist_data);
|
||||||
|
|
||||||
|
await gist(req, res);
|
||||||
|
|
||||||
|
expect(res.setHeader).toHaveBeenCalledWith("Content-Type", "image/svg+xml");
|
||||||
|
expect(res.setHeader).toHaveBeenCalledWith(
|
||||||
|
"Cache-Control",
|
||||||
|
`max-age=${CACHE_TTL.GIST_CARD.DEFAULT}, ` +
|
||||||
|
`s-maxage=${CACHE_TTL.GIST_CARD.DEFAULT}, ` +
|
||||||
|
`stale-while-revalidate=${DURATIONS.ONE_DAY}`,
|
||||||
);
|
);
|
||||||
expect(storeRequestMock).not.toHaveBeenCalled();
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -4,15 +4,9 @@
|
|||||||
|
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
import MockAdapter from "axios-mock-adapter";
|
import MockAdapter from "axios-mock-adapter";
|
||||||
import {
|
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
|
||||||
afterAll,
|
|
||||||
afterEach,
|
import patInfo, { RATE_LIMIT_SECONDS } from "../api-renamed/status/pat-info.js";
|
||||||
beforeAll,
|
|
||||||
describe,
|
|
||||||
expect,
|
|
||||||
it,
|
|
||||||
vi,
|
|
||||||
} from "vitest";
|
|
||||||
|
|
||||||
const mock = new MockAdapter(axios);
|
const mock = new MockAdapter(axios);
|
||||||
|
|
||||||
@@ -63,35 +57,20 @@ const bad_credentials_error = {
|
|||||||
message: "Bad credentials",
|
message: "Bad credentials",
|
||||||
};
|
};
|
||||||
|
|
||||||
let RATE_LIMIT_SECONDS, patInfo;
|
|
||||||
|
|
||||||
beforeAll(async () => {
|
|
||||||
vi.stubEnv("PAT_1", "testPAT1");
|
|
||||||
vi.stubEnv("PAT_2", "testPAT2");
|
|
||||||
vi.stubEnv("PAT_3", "testPAT3");
|
|
||||||
vi.stubEnv("PAT_4", "testPAT4");
|
|
||||||
|
|
||||||
const { logger } =
|
|
||||||
await import("@stats-organization/github-readme-stats-core");
|
|
||||||
vi.spyOn(logger, "log").mockImplementation(() => {});
|
|
||||||
vi.spyOn(logger, "error").mockImplementation(() => {});
|
|
||||||
|
|
||||||
({ RATE_LIMIT_SECONDS, default: patInfo } =
|
|
||||||
await import("../api-renamed/status/pat-info.js"));
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
mock.reset();
|
mock.reset();
|
||||||
vi.unstubAllEnvs();
|
|
||||||
// modules may cache environment variables, so we need to reset them
|
|
||||||
vi.resetModules();
|
|
||||||
});
|
|
||||||
|
|
||||||
afterAll(() => {
|
|
||||||
vi.restoreAllMocks();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("Test /api/status/pat-info", () => {
|
describe("Test /api/status/pat-info", () => {
|
||||||
|
beforeAll(() => {
|
||||||
|
// reset patenv first so that they are not populated with local envs
|
||||||
|
process.env = {};
|
||||||
|
process.env.PAT_1 = "testPAT1";
|
||||||
|
process.env.PAT_2 = "testPAT2";
|
||||||
|
process.env.PAT_3 = "testPAT3";
|
||||||
|
process.env.PAT_4 = "testPAT4";
|
||||||
|
});
|
||||||
|
|
||||||
it("should return only 'validPATs' if all PATs are valid", async () => {
|
it("should return only 'validPATs' if all PATs are valid", async () => {
|
||||||
mock
|
mock
|
||||||
.onPost("https://api.github.com/graphql")
|
.onPost("https://api.github.com/graphql")
|
||||||
@@ -264,6 +243,7 @@ describe("Test /api/status/pat-info", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("should have proper cache when error is thrown", async () => {
|
it("should have proper cache when error is thrown", async () => {
|
||||||
|
mock.reset();
|
||||||
mock.onPost("https://api.github.com/graphql").networkError();
|
mock.onPost("https://api.github.com/graphql").networkError();
|
||||||
|
|
||||||
const { req, res } = faker({}, {});
|
const { req, res } = faker({}, {});
|
||||||
|
|||||||
+162
-71
@@ -1,84 +1,175 @@
|
|||||||
// @ts-check
|
// @ts-check
|
||||||
|
|
||||||
import { pin } from "@stats-organization/github-readme-stats-core";
|
import axios from "axios";
|
||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
import MockAdapter from "axios-mock-adapter";
|
||||||
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
import router from "../router.js";
|
import pin from "../api-renamed/pin.js";
|
||||||
|
import { renderRepoCard } from "../src/cards/repo.js";
|
||||||
import { CACHE_TTL, DURATIONS } from "../src/common/cache.js";
|
import { CACHE_TTL, DURATIONS } from "../src/common/cache.js";
|
||||||
import { getUserAccessByName, storeRequest } from "../src/common/database.js";
|
import { renderError } from "../src/common/render.js";
|
||||||
|
|
||||||
vi.mock(import("@stats-organization/github-readme-stats-core"), async () => {
|
import { data_repo, data_user } from "./test-data/pin-data.js";
|
||||||
const { mockCore } = await import("./utils.js");
|
|
||||||
return mockCore();
|
import "@testing-library/jest-dom/vitest";
|
||||||
|
|
||||||
|
const mock = new MockAdapter(axios);
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
mock.reset();
|
||||||
});
|
});
|
||||||
|
|
||||||
vi.mock(import("../src/common/database.js"), async (importOriginal) => ({
|
describe("Test /api/pin", () => {
|
||||||
...(await importOriginal()),
|
it("should test the request", async () => {
|
||||||
storeRequest: vi.fn(),
|
const req = {
|
||||||
getUserAccessByName: vi.fn(),
|
query: {
|
||||||
}));
|
|
||||||
|
|
||||||
const pinMock = vi.mocked(pin);
|
|
||||||
const storeRequestMock = vi.mocked(storeRequest);
|
|
||||||
const getUserAccessByNameMock = vi.mocked(getUserAccessByName);
|
|
||||||
|
|
||||||
const createRequest = (search = "") => ({
|
|
||||||
headers: {},
|
|
||||||
url: `/api/pin?${search}`,
|
|
||||||
});
|
|
||||||
|
|
||||||
const createResponse = () => ({
|
|
||||||
end: vi.fn(),
|
|
||||||
setHeader: vi.fn(),
|
|
||||||
});
|
|
||||||
|
|
||||||
const defaultCacheHeader =
|
|
||||||
`max-age=${CACHE_TTL.PIN_CARD.DEFAULT}, ` +
|
|
||||||
`s-maxage=${CACHE_TTL.PIN_CARD.DEFAULT}, ` +
|
|
||||||
`stale-while-revalidate=${DURATIONS.ONE_DAY}`;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
pinMock.mockReset();
|
|
||||||
storeRequestMock.mockReset().mockResolvedValue(undefined);
|
|
||||||
getUserAccessByNameMock.mockReset().mockResolvedValue(null);
|
|
||||||
// CACHE_SECONDS is not set here, this is just to safeguard against CACHE_SECONDS being set externally
|
|
||||||
delete process.env.CACHE_SECONDS;
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("Test /api/pin backend routing", () => {
|
|
||||||
it("happy path should pass query params and user PAT, respond with pin content and persist request", async () => {
|
|
||||||
getUserAccessByNameMock.mockResolvedValue({ token: "user-pat" });
|
|
||||||
pinMock.mockResolvedValue({
|
|
||||||
status: "success",
|
|
||||||
content: "mock-pin-svg",
|
|
||||||
});
|
|
||||||
|
|
||||||
const req = createRequest(
|
|
||||||
"username=anuraghazra&repo=convoychat&theme=dark",
|
|
||||||
);
|
|
||||||
const res = createResponse();
|
|
||||||
|
|
||||||
await router(req, res);
|
|
||||||
|
|
||||||
expect(getUserAccessByNameMock).toHaveBeenCalledWith("anuraghazra");
|
|
||||||
expect(pinMock).toHaveBeenCalledWith(
|
|
||||||
{
|
|
||||||
username: "anuraghazra",
|
username: "anuraghazra",
|
||||||
repo: "convoychat",
|
repo: "convoychat",
|
||||||
theme: "dark",
|
|
||||||
},
|
},
|
||||||
"user-pat",
|
};
|
||||||
|
const res = {
|
||||||
|
setHeader: vi.fn(),
|
||||||
|
send: vi.fn(),
|
||||||
|
};
|
||||||
|
mock.onPost("https://api.github.com/graphql").reply(200, data_user);
|
||||||
|
|
||||||
|
await pin(req, res);
|
||||||
|
|
||||||
|
expect(res.setHeader).toHaveBeenCalledWith("Content-Type", "image/svg+xml");
|
||||||
|
expect(res.send).toHaveBeenCalledWith(
|
||||||
|
// @ts-ignore
|
||||||
|
renderRepoCard({
|
||||||
|
...data_repo.repository,
|
||||||
|
starCount: data_repo.repository.stargazers.totalCount,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should get the query options", async () => {
|
||||||
|
const req = {
|
||||||
|
query: {
|
||||||
|
username: "anuraghazra",
|
||||||
|
repo: "convoychat",
|
||||||
|
title_color: "fff",
|
||||||
|
icon_color: "fff",
|
||||||
|
text_color: "fff",
|
||||||
|
bg_color: "fff",
|
||||||
|
full_name: "1",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const res = {
|
||||||
|
setHeader: vi.fn(),
|
||||||
|
send: vi.fn(),
|
||||||
|
};
|
||||||
|
mock.onPost("https://api.github.com/graphql").reply(200, data_user);
|
||||||
|
|
||||||
|
await pin(req, res);
|
||||||
|
|
||||||
|
expect(res.setHeader).toHaveBeenCalledWith("Content-Type", "image/svg+xml");
|
||||||
|
expect(res.send).toHaveBeenCalledWith(
|
||||||
|
renderRepoCard(
|
||||||
|
// @ts-ignore
|
||||||
|
{
|
||||||
|
...data_repo.repository,
|
||||||
|
starCount: data_repo.repository.stargazers.totalCount,
|
||||||
|
},
|
||||||
|
{ ...req.query },
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should render error card if user repo not found", async () => {
|
||||||
|
const req = {
|
||||||
|
query: {
|
||||||
|
username: "anuraghazra",
|
||||||
|
repo: "convoychat",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const res = {
|
||||||
|
setHeader: vi.fn(),
|
||||||
|
send: vi.fn(),
|
||||||
|
};
|
||||||
|
mock
|
||||||
|
.onPost("https://api.github.com/graphql")
|
||||||
|
.reply(200, { data: { user: { repository: null }, organization: null } });
|
||||||
|
|
||||||
|
await pin(req, res);
|
||||||
|
|
||||||
|
expect(res.setHeader).toHaveBeenCalledWith("Content-Type", "image/svg+xml");
|
||||||
|
expect(res.send).toHaveBeenCalledWith(
|
||||||
|
renderError({ message: "User Repository Not found" }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should render error card if org repo not found", async () => {
|
||||||
|
const req = {
|
||||||
|
query: {
|
||||||
|
username: "anuraghazra",
|
||||||
|
repo: "convoychat",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const res = {
|
||||||
|
setHeader: vi.fn(),
|
||||||
|
send: vi.fn(),
|
||||||
|
};
|
||||||
|
mock
|
||||||
|
.onPost("https://api.github.com/graphql")
|
||||||
|
.reply(200, { data: { user: null, organization: { repository: null } } });
|
||||||
|
|
||||||
|
await pin(req, res);
|
||||||
|
|
||||||
|
expect(res.setHeader).toHaveBeenCalledWith("Content-Type", "image/svg+xml");
|
||||||
|
expect(res.send).toHaveBeenCalledWith(
|
||||||
|
renderError({ message: "Organization Repository Not found" }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should render error card if wrong locale provided", async () => {
|
||||||
|
const req = {
|
||||||
|
query: {
|
||||||
|
username: "anuraghazra",
|
||||||
|
repo: "convoychat",
|
||||||
|
locale: "asdf",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const res = {
|
||||||
|
setHeader: vi.fn(),
|
||||||
|
send: vi.fn(),
|
||||||
|
};
|
||||||
|
mock.onPost("https://api.github.com/graphql").reply(200, data_user);
|
||||||
|
|
||||||
|
await pin(req, res);
|
||||||
|
|
||||||
|
expect(res.setHeader).toHaveBeenCalledWith("Content-Type", "image/svg+xml");
|
||||||
|
expect(res.send).toHaveBeenCalledWith(
|
||||||
|
renderError({
|
||||||
|
message: "Something went wrong",
|
||||||
|
secondaryMessage: "Language not found",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should have proper cache", async () => {
|
||||||
|
const req = {
|
||||||
|
query: {
|
||||||
|
username: "anuraghazra",
|
||||||
|
repo: "convoychat",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const res = {
|
||||||
|
setHeader: vi.fn(),
|
||||||
|
send: vi.fn(),
|
||||||
|
};
|
||||||
|
mock.onPost("https://api.github.com/graphql").reply(200, data_user);
|
||||||
|
|
||||||
|
await pin(req, res);
|
||||||
|
|
||||||
|
expect(res.setHeader).toHaveBeenCalledWith("Content-Type", "image/svg+xml");
|
||||||
|
expect(res.setHeader).toHaveBeenCalledWith(
|
||||||
|
"Cache-Control",
|
||||||
|
`max-age=${CACHE_TTL.PIN_CARD.DEFAULT}, ` +
|
||||||
|
`s-maxage=${CACHE_TTL.PIN_CARD.DEFAULT}, ` +
|
||||||
|
`stale-while-revalidate=${DURATIONS.ONE_DAY}`,
|
||||||
);
|
);
|
||||||
expect(req.query).toEqual({
|
|
||||||
username: "anuraghazra",
|
|
||||||
repo: "convoychat",
|
|
||||||
theme: "dark",
|
|
||||||
});
|
|
||||||
expect(res.setHeader.mock.calls).toEqual([
|
|
||||||
["Cache-Control", defaultCacheHeader],
|
|
||||||
["Content-Type", "image/svg+xml"],
|
|
||||||
]);
|
|
||||||
expect(res.end).toHaveBeenCalledExactlyOnceWith("mock-pin-svg");
|
|
||||||
expect(storeRequestMock).toHaveBeenCalledExactlyOnceWith(req);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
// @ts-check
|
||||||
|
|
||||||
|
import axios from "axios";
|
||||||
|
import MockAdapter from "axios-mock-adapter";
|
||||||
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
import api from "../../api-renamed/index.js";
|
||||||
|
import { renderError } from "../../src/common/render.js";
|
||||||
|
import { data_stats } from "../test-data/api-data.js";
|
||||||
|
|
||||||
|
const mock = new MockAdapter(axios);
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
mock.reset();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Test /api/", () => {
|
||||||
|
it("should render error card if username not in whitelist", async () => {
|
||||||
|
const req = {
|
||||||
|
query: {
|
||||||
|
username: "renovate-bot",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const res = {
|
||||||
|
setHeader: vi.fn(),
|
||||||
|
send: vi.fn(),
|
||||||
|
};
|
||||||
|
mock.onPost("https://api.github.com/graphql").replyOnce(200, data_stats);
|
||||||
|
|
||||||
|
await api(req, res);
|
||||||
|
|
||||||
|
expect(res.setHeader).toHaveBeenCalledWith("Content-Type", "image/svg+xml");
|
||||||
|
expect(res.send).toHaveBeenCalledWith(
|
||||||
|
renderError({
|
||||||
|
message: "This username is not whitelisted",
|
||||||
|
secondaryMessage: "Please deploy your own instance",
|
||||||
|
renderOptions: { show_repo_link: false },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
// @ts-check
|
||||||
|
|
||||||
|
import axios from "axios";
|
||||||
|
import MockAdapter from "axios-mock-adapter";
|
||||||
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
import gist from "../../api-renamed/gist.js";
|
||||||
|
import { renderError } from "../../src/common/render.js";
|
||||||
|
import { gist_data } from "../test-data/gist-data.js";
|
||||||
|
|
||||||
|
const mock = new MockAdapter(axios);
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
mock.reset();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Test /api/gist with gist whitelist", () => {
|
||||||
|
it("should render error card if id not in whitelist", async () => {
|
||||||
|
const req = {
|
||||||
|
query: {
|
||||||
|
id: "9bae0392ee3a26bac5cc388a6c8b1469",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const res = {
|
||||||
|
setHeader: vi.fn(),
|
||||||
|
send: vi.fn(),
|
||||||
|
};
|
||||||
|
mock.onPost("https://api.github.com/graphql").reply(200, gist_data);
|
||||||
|
|
||||||
|
await gist(req, res);
|
||||||
|
|
||||||
|
expect(res.setHeader).toHaveBeenCalledWith("Content-Type", "image/svg+xml");
|
||||||
|
expect(res.send).toHaveBeenCalledWith(
|
||||||
|
renderError({
|
||||||
|
message: "This gist ID is not whitelisted",
|
||||||
|
secondaryMessage: "Please deploy your own instance",
|
||||||
|
renderOptions: { show_repo_link: false },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
// @ts-check
|
||||||
|
|
||||||
|
import axios from "axios";
|
||||||
|
import MockAdapter from "axios-mock-adapter";
|
||||||
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
import pin from "../../api-renamed/pin.js";
|
||||||
|
import { renderError } from "../../src/common/render.js";
|
||||||
|
import { data_user } from "../test-data/pin-data.js";
|
||||||
|
|
||||||
|
const mock = new MockAdapter(axios);
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
mock.reset();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Test /api/pin", () => {
|
||||||
|
it("should render error card if username not in whitelist", async () => {
|
||||||
|
const req = {
|
||||||
|
query: {
|
||||||
|
username: "renovate-bot",
|
||||||
|
repo: "convoychat",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const res = {
|
||||||
|
setHeader: vi.fn(),
|
||||||
|
send: vi.fn(),
|
||||||
|
};
|
||||||
|
mock.onPost("https://api.github.com/graphql").reply(200, data_user);
|
||||||
|
|
||||||
|
await pin(req, res);
|
||||||
|
|
||||||
|
expect(res.setHeader).toHaveBeenCalledWith("Content-Type", "image/svg+xml");
|
||||||
|
expect(res.send).toHaveBeenCalledWith(
|
||||||
|
renderError({
|
||||||
|
message: "This username is not whitelisted",
|
||||||
|
secondaryMessage: "Please deploy your own instance",
|
||||||
|
renderOptions: { show_repo_link: false },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should render error card if missing required parameters", async () => {
|
||||||
|
const req = {
|
||||||
|
query: {},
|
||||||
|
};
|
||||||
|
const res = {
|
||||||
|
setHeader: vi.fn(),
|
||||||
|
send: vi.fn(),
|
||||||
|
};
|
||||||
|
mock.onPost("https://api.github.com/graphql").reply(200, data_user);
|
||||||
|
|
||||||
|
await pin(req, res);
|
||||||
|
|
||||||
|
expect(res.setHeader).toHaveBeenCalledWith("Content-Type", "image/svg+xml");
|
||||||
|
expect(res.send).toHaveBeenCalledWith(
|
||||||
|
renderError({
|
||||||
|
message: "This username is not whitelisted",
|
||||||
|
secondaryMessage: "Please deploy your own instance",
|
||||||
|
renderOptions: { show_repo_link: false },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
// @ts-check
|
||||||
|
|
||||||
|
import axios from "axios";
|
||||||
|
import MockAdapter from "axios-mock-adapter";
|
||||||
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
import topLangs from "../../api-renamed/top-langs.js";
|
||||||
|
import { renderError } from "../../src/common/render.js";
|
||||||
|
import { data_langs } from "../test-data/langs-data.js";
|
||||||
|
|
||||||
|
const mock = new MockAdapter(axios);
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
mock.reset();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Test /api/top-langs", () => {
|
||||||
|
it("should render error card if username not in whitelist", async () => {
|
||||||
|
const req = {
|
||||||
|
query: {
|
||||||
|
username: "renovate-bot",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const res = {
|
||||||
|
setHeader: vi.fn(),
|
||||||
|
send: vi.fn(),
|
||||||
|
};
|
||||||
|
mock.onPost("https://api.github.com/graphql").reply(200, data_langs);
|
||||||
|
|
||||||
|
await topLangs(req, res);
|
||||||
|
|
||||||
|
expect(res.setHeader).toHaveBeenCalledWith("Content-Type", "image/svg+xml");
|
||||||
|
expect(res.send).toHaveBeenCalledWith(
|
||||||
|
renderError({
|
||||||
|
message: "This username is not whitelisted",
|
||||||
|
secondaryMessage: "Please deploy your own instance",
|
||||||
|
renderOptions: { show_repo_link: false },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user