Compare commits

..
109 changed files with 5717 additions and 8399 deletions
+1 -2
View File
@@ -22,8 +22,7 @@
"extensions": [
"yzhang.markdown-all-in-one",
"esbenp.prettier-vscode",
"dbaeumer.vscode-eslint",
"github.vscode-github-actions"
"dbaeumer.vscode-eslint"
]
}
},
-1
View File
@@ -1 +0,0 @@
host.env
+1
View File
@@ -0,0 +1 @@
dist/* linguist-vendored=false
-34
View File
@@ -1,34 +0,0 @@
name: Push Docker
on:
push:
branches:
- 'main'
- 'develop'
jobs:
docker:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Buildx
uses: docker/setup-buildx-action@v3
- name: Login to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Build image
uses: docker/build-push-action@v5
with:
context: .
push: true
platforms: linux/amd64,linux/arm64
tags: |
${{ vars.DOCKERHUB_TARGET }}:develop
-35
View File
@@ -1,35 +0,0 @@
name: Push Docker
on:
push:
tags:
- '[0-9.]+-[0-9]+-[0-9]+'
# for this project, we do "YYYY-MM-DD" for version numbers, due to unversioned upstream
jobs:
docker:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Buildx
uses: docker/setup-buildx-action@v3
- name: Login to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Build image
uses: docker/build-push-action@v5
with:
context: .
push: true
platforms: linux/amd64,linux/arm64
tags: |
${{ vars.DOCKERHUB_TARGET }}:latest
${{ vars.DOCKERHUB_TARGET }}:${{ github.ref_name }}
-37
View File
@@ -1,37 +0,0 @@
name: Sync GitHub
on:
push:
branches:
- '**'
jobs:
sync:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Configure Git
run: |
git config --global user.name "Andrew Sync"
git config --global user.email "sync@digitaladapt.com"
- name: Add GitHub Remote
env:
SYNC_TOKEN: ${{ secrets.SYNC_GITHUB_TOKEN }}
SYNC_TARGET: ${{ vars.SYNC_GITHUB_TARGET }}
run: |
git remote add github "https://digitaladapt:${SYNC_TOKEN}@github.com/$SYNC_TARGET"
- name: Push Current Branch
run: |
git push github HEAD:${GITHUB_REF_NAME}
- name: Push Tags
run: |
git push github --tags
-10
View File
@@ -19,13 +19,3 @@ updates:
commit-message:
prefix: "ci(deps)"
prefix-development: "ci(deps-dev)"
# Maintain dependencies for Devcontainers
- package-ecosystem: devcontainers
directory: "/"
schedule:
interval: weekly
open-pull-requests-limit: 10
commit-message:
prefix: "build(deps)"
prefix-development: "build(deps-dev)"
+1 -3
View File
@@ -24,14 +24,12 @@ permissions:
jobs:
CodeQL-Build:
if: github.repository == 'anuraghazra/github-readme-stats'
# CodeQL runs on ubuntu-latest, windows-latest, and macos-latest
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
+2 -2
View File
@@ -10,10 +10,10 @@ jobs:
if: github.repository == 'anuraghazra/github-readme-stats'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- name: Deployment Prep
run: python ./.github/workflows/deploy-prep.py
- uses: stefanzweifel/git-auto-commit-action@28e16e81777b558cc906c8750092100bbb34c5e3 # v7.0.0
- uses: stefanzweifel/git-auto-commit-action@778341af668090896ca464160c2def5d1d1a3eb0 # v6.0.1
with:
branch: vercel
create_branch: true
+10 -15
View File
@@ -1,31 +1,26 @@
name: Test Deployment
on:
# Temporarily disabled automatic triggers; manual-only for now.
workflow_dispatch:
# Original trigger (restore to re-enable):
# deployment_status:
deployment_status:
permissions: read-all
jobs:
e2eTests:
# Temporarily disabled; set to the original condition to re-enable.
# if:
# github.repository == 'anuraghazra/github-readme-stats' &&
# github.event_name == 'deployment_status' &&
# github.event.deployment_status.state == 'success'
if: false
name: Perform e2e tests
if:
github.repository == 'anuraghazra/github-readme-stats' &&
github.event_name == 'deployment_status' &&
github.event.deployment_status.state == 'success'
name: Perform 2e2 tests
runs-on: ubuntu-latest
strategy:
matrix:
node-version: [22.x]
steps:
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- name: Setup Node
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0
with:
node-version: ${{ matrix.node-version }}
cache: npm
@@ -37,5 +32,5 @@ jobs:
- name: Run end-to-end tests.
run: npm run test:e2e
# env:
# VERCEL_PREVIEW_URL: ${{ github.event.deployment_status.target_url }}
env:
VERCEL_PREVIEW_URL: ${{ github.event.deployment_status.target_url }}
+1 -1
View File
@@ -27,7 +27,7 @@ jobs:
runs-on: ubuntu-latest
steps:
# NOTE: Retrieve issue templates.
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- name: Run empty issues closer action
uses: rickstaa/empty-issues-closer-action@e96914613221511279ca25f50fd4acc85e331d99 # v1.1.74
+2 -2
View File
@@ -30,10 +30,10 @@ jobs:
node-version: [22.x]
steps:
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- name: Setup Node
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0
with:
node-version: ${{ matrix.node-version }}
cache: npm
+3 -3
View File
@@ -22,12 +22,12 @@ jobs:
steps:
- name: "Checkout code"
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
with:
persist-credentials: false
- name: "Run analysis"
uses: ossf/scorecard-action@4eaacf0543bb3f2c246792bd56e8cdeffafb205a # v2.4.3
uses: ossf/scorecard-action@05b42c624433fc40578a4040d5cf5e36ddca8cde # v2.4.2
with:
results_file: results.sarif
results_format: sarif
@@ -36,7 +36,7 @@ jobs:
# Upload the results as artifacts (optional). Commenting out will disable uploads of run results in SARIF
# format to the repository Actions tab.
- name: "Upload artifact"
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: SARIF file
path: results.sarif
+3 -5
View File
@@ -1,7 +1,5 @@
name: Theme preview
on:
# Temporary disabled due to paused themes addition.
# See: https://github.com/anuraghazra/github-readme-stats/issues/3404
# pull_request_target:
# types: [opened, edited, reopened, synchronize]
# branches:
@@ -33,15 +31,15 @@ jobs:
node-version: [22.x]
steps:
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- name: Setup Node
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0
with:
node-version: ${{ matrix.node-version }}
cache: npm
- uses: bahmutov/npm-install@3e063b974f0d209807684aa23e534b3dde517fd9 # v1.11.2
- uses: bahmutov/npm-install@3714964fb879ebbbc108e167f0f3a0c81ec075c9 # v1.10.10
with:
useLockFile: false
+3 -5
View File
@@ -1,7 +1,5 @@
name: Close stale theme pull requests that have the 'invalid' label.
on:
# Temporary disabled due to paused themes addition.
# See: https://github.com/anuraghazra/github-readme-stats/issues/3404
# schedule:
# # ┌───────────── minute (0 - 59)
# # │ ┌───────────── hour (0 - 23)
@@ -39,15 +37,15 @@ jobs:
node-version: [22.x]
steps:
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- name: Setup Node
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0
with:
node-version: ${{ matrix.node-version }}
cache: npm
- uses: bahmutov/npm-install@3e063b974f0d209807684aa23e534b3dde517fd9 # v1.11.2
- uses: bahmutov/npm-install@3714964fb879ebbbc108e167f0f3a0c81ec075c9 # v1.10.10
with:
useLockFile: false
+2 -2
View File
@@ -18,10 +18,10 @@ jobs:
node-version: [22.x]
steps:
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- name: Setup Node
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0
with:
node-version: ${{ matrix.node-version }}
cache: npm
+6 -4
View File
@@ -1,7 +1,9 @@
name: Theme Pull Requests Closer
on:
- pull_request_target
pull_request:
types:
- labeled
permissions:
actions: read
@@ -19,11 +21,11 @@ permissions:
jobs:
close-prs:
if: github.repository == 'anuraghazra/github-readme-stats'
runs-on: ubuntu-latest
steps:
- name: Check out the code
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- name: Set up Git
run: |
@@ -32,7 +34,7 @@ jobs:
- name: Close Pull Requests
run: |
comment_message="We are currently pausing addition of new themes. If this theme is exclusively for your personal use, then instead of adding it to our theme collection, you can use card [customization options](https://github.com/anuraghazra/github-readme-stats?tab=readme-ov-file#customization)."
comment_message="We are currently pausing addition of new themes. If this theme is exclusively for your personal use, then instead of adding it to our theme collection, you can use card customization options."
for pr_number in $(gh pr list -l "themes" -q is:open --json number -q ".[].number"); do
gh pr close $pr_number -c "$comment_message"
+3 -3
View File
@@ -36,10 +36,10 @@ jobs:
node-version: [22.x]
steps:
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- name: Setup Node
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0
with:
node-version: ${{ matrix.node-version }}
cache: npm
@@ -53,7 +53,7 @@ jobs:
run: npm run generate-langs-json
- name: Create Pull Request if upstream language file is changed
uses: peter-evans/create-pull-request@98357b18bf14b5342f975ff684046ec3b2a07725 # v8.0.0
uses: peter-evans/create-pull-request@271a8d0340265f705b14b6d32b9829c1cb33d45e # v7.0.8
with:
commit-message: "refactor: update languages JSON"
branch: "update_langs/patch"
+1 -1
View File
@@ -1,5 +1,5 @@
.vercel
host.env
.env
node_modules
*.lock
.idea/
+2 -14
View File
@@ -1,15 +1,3 @@
.devcontainer
.github
.husky
.vscode
benchmarks
coverage
scripts
tests
.env
**/*.md
**/*.svg
.eslintrc.json
.prettierignore
.pretterrc.json
codecov.yml
package-lock.json
coverage
+1 -2
View File
@@ -3,7 +3,6 @@
"yzhang.markdown-all-in-one",
"esbenp.prettier-vscode",
"dbaeumer.vscode-eslint",
"ms-azuretools.vscode-containers",
"github.vscode-github-actions"
"ms-azuretools.vscode-containers"
]
}
-3
View File
@@ -2,7 +2,4 @@
"markdown.extension.toc.levels": "1..3",
"editor.formatOnSave": true,
"editor.defaultFormatter": "esbenp.prettier-vscode",
"[javascript]": {
"editor.tabSize": 2
}
}
+3 -4
View File
@@ -35,17 +35,16 @@ _(make sure you already have a [Vercel](https://vercel.com/) account)_
3. Run `npm install` in the repository root.
4. Run the command `vercel` in the root and follow the steps there.
5. Run the command `vercel dev` to start a development server at <http://localhost:3000>.
6. Create a `.env` file in the root and add the following line `NODE_ENV=development`, this will disable caching for local development.
7. The cards will then be available from this local endpoint (i.e. `http://localhost:3000/api?username=anuraghazra`).
6. The cards will then be available from this local endpoint (i.e. `http://localhost:3000/api?username=anuraghazra`).
> [!NOTE]
> [!NOTE]\
> You can debug the package code in [Vscode](https://code.visualstudio.com/) by using the [Node.js: Attach to process](https://code.visualstudio.com/docs/nodejs/nodejs-debugging#_setting-up-an-attach-configuration) debug option. You can also debug any tests using the [VSCode Jest extension](https://marketplace.visualstudio.com/items?itemName=Orta.vscode-jest). For more information, see https://github.com/jest-community/vscode-jest/issues/912.
## Themes Contribution
We're currently paused addition of new themes to decrease maintenance efforts. All pull requests related to new themes will be closed.
> [!NOTE]
> [!NOTE]\
> If you are considering contributing your theme just because you are using it personally, then instead of adding it to our theme collection, you can use card [customization options](./readme.md#customization).
## Translations Contribution
-4
View File
@@ -1,4 +0,0 @@
readme-stats.example.com {
reverse_proxy readme_stats:9000
}
-13
View File
@@ -1,13 +0,0 @@
FROM node:lts-alpine
# setup folder within the docker container with this app's files
WORKDIR /app
COPY . .
# add express.js as dependency and download node modules
RUN npm install express.js
# start the app, on the supplied port
EXPOSE $port
CMD [ "node", "express.js" ]
+50 -70
View File
@@ -1,23 +1,14 @@
// @ts-check
import { renderError } from "../src/common/render.js";
import {
clampValue,
CONSTANTS,
renderError,
parseBoolean,
} from "../src/common/utils.js";
import { gistWhitelist } from "../src/common/whitelist.js";
import { isLocaleAvailable } from "../src/translations.js";
import { renderGistCard } from "../src/cards/gist.js";
import { fetchGist } from "../src/fetchers/gist.js";
import {
CACHE_TTL,
resolveCacheSeconds,
setCacheHeaders,
setErrorCacheHeaders,
} from "../src/common/cache.js";
import { guardAccess } from "../src/common/access.js";
import {
MissingParamError,
retrieveSecondaryMessage,
} from "../src/common/error.js";
import { parseBoolean } from "../src/common/ops.js";
// @ts-ignore
export default async (req, res) => {
const {
id,
@@ -36,48 +27,51 @@ export default async (req, res) => {
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)) {
if (gistWhitelist && !gistWhitelist.includes(id)) {
return res.send(
renderError({
message: "Something went wrong",
secondaryMessage: "Language not found",
renderOptions: {
renderError(
"This gist ID is not whitelisted",
"Please deploy your own instance",
{
title_color,
text_color,
bg_color,
border_color,
theme,
show_repo_link: false,
},
),
);
}
if (locale && !isLocaleAvailable(locale)) {
return res.send(
renderError("Something went wrong", "Language not found", {
title_color,
text_color,
bg_color,
border_color,
theme,
}),
);
}
try {
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);
let cacheSeconds = clampValue(
parseInt(cache_seconds || CONSTANTS.TWO_DAY, 10),
CONSTANTS.TWO_DAY,
CONSTANTS.SIX_DAY,
);
cacheSeconds = process.env.CACHE_SECONDS
? parseInt(process.env.CACHE_SECONDS, 10) || cacheSeconds
: cacheSeconds;
res.setHeader(
"Cache-Control",
`max-age=${cacheSeconds}, s-maxage=${cacheSeconds}`,
);
return res.send(
renderGistCard(gistData, {
@@ -94,33 +88,19 @@ export default async (req, res) => {
}),
);
} 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),
},
}),
);
}
res.setHeader(
"Cache-Control",
`max-age=${CONSTANTS.ERROR_CACHE_SECONDS / 2}, s-maxage=${
CONSTANTS.ERROR_CACHE_SECONDS
}, stale-while-revalidate=${CONSTANTS.ONE_DAY}`,
); // Use lower cache period for errors.
return res.send(
renderError({
message: "An unknown error occurred",
renderOptions: {
title_color,
text_color,
bg_color,
border_color,
theme,
},
renderError(err.message, err.secondaryMessage, {
title_color,
text_color,
bg_color,
border_color,
theme,
}),
);
}
+68 -74
View File
@@ -1,23 +1,16 @@
// @ts-check
import { renderStatsCard } from "../src/cards/stats.js";
import { guardAccess } from "../src/common/access.js";
import { blacklist } from "../src/common/blacklist.js";
import { whitelist } from "../src/common/whitelist.js";
import {
CACHE_TTL,
resolveCacheSeconds,
setCacheHeaders,
setErrorCacheHeaders,
} from "../src/common/cache.js";
import {
MissingParamError,
retrieveSecondaryMessage,
} from "../src/common/error.js";
import { parseArray, parseBoolean } from "../src/common/ops.js";
import { renderError } from "../src/common/render.js";
clampValue,
CONSTANTS,
parseArray,
parseBoolean,
renderError,
} from "../src/common/utils.js";
import { fetchStats } from "../src/fetchers/stats.js";
import { isLocaleAvailable } from "../src/translations.js";
// @ts-ignore
export default async (req, res) => {
const {
username,
@@ -28,7 +21,6 @@ export default async (req, res) => {
hide_rank,
show_icons,
include_all_commits,
commits_year,
line_height,
title_color,
ring_color,
@@ -44,41 +36,54 @@ export default async (req, res) => {
disable_animations,
border_radius,
number_format,
number_precision,
border_color,
rank_icon,
show,
} = req.query;
res.setHeader("Content-Type", "image/svg+xml");
const access = guardAccess({
res,
id: username,
type: "username",
colors: {
title_color,
text_color,
bg_color,
border_color,
theme,
},
});
if (!access.isPassed) {
return access.result;
}
if (locale && !isLocaleAvailable(locale)) {
if (whitelist && !whitelist.includes(username)) {
return res.send(
renderError({
message: "Something went wrong",
secondaryMessage: "Language not found",
renderOptions: {
renderError(
"This username is not whitelisted",
"Please deploy your own instance",
{
title_color,
text_color,
bg_color,
border_color,
theme,
show_repo_link: false,
},
),
);
}
if (whitelist === undefined && blacklist.includes(username)) {
return res.send(
renderError(
"This username is blacklisted",
"Please deploy your own instance",
{
title_color,
text_color,
bg_color,
border_color,
theme,
show_repo_link: false,
},
),
);
}
if (locale && !isLocaleAvailable(locale)) {
return res.send(
renderError("Something went wrong", "Language not found", {
title_color,
text_color,
bg_color,
border_color,
theme,
}),
);
}
@@ -93,16 +98,21 @@ export default async (req, res) => {
showStats.includes("prs_merged_percentage"),
showStats.includes("discussions_started"),
showStats.includes("discussions_answered"),
parseInt(commits_year, 10),
);
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,
});
setCacheHeaders(res, cacheSeconds);
let cacheSeconds = clampValue(
parseInt(cache_seconds || CONSTANTS.CARD_CACHE_SECONDS, 10),
CONSTANTS.TWELVE_HOURS,
CONSTANTS.TWO_DAY,
);
cacheSeconds = process.env.CACHE_SECONDS
? parseInt(process.env.CACHE_SECONDS, 10) || cacheSeconds
: cacheSeconds;
res.setHeader(
"Cache-Control",
`max-age=${cacheSeconds}, s-maxage=${cacheSeconds}, stale-while-revalidate=${CONSTANTS.ONE_DAY}`,
);
return res.send(
renderStatsCard(stats, {
@@ -113,7 +123,6 @@ export default async (req, res) => {
card_width: parseInt(card_width, 10),
hide_rank: parseBoolean(hide_rank),
include_all_commits: parseBoolean(include_all_commits),
commits_year: parseInt(commits_year, 10),
line_height,
title_color,
ring_color,
@@ -126,7 +135,6 @@ export default async (req, res) => {
border_radius,
border_color,
number_format,
number_precision: parseInt(number_precision, 10),
locale: locale ? locale.toLowerCase() : null,
disable_animations: parseBoolean(disable_animations),
rank_icon,
@@ -134,33 +142,19 @@ export default async (req, res) => {
}),
);
} 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),
},
}),
);
}
res.setHeader(
"Cache-Control",
`max-age=${CONSTANTS.ERROR_CACHE_SECONDS / 2}, s-maxage=${
CONSTANTS.ERROR_CACHE_SECONDS
}, stale-while-revalidate=${CONSTANTS.ONE_DAY}`,
); // Use lower cache period for errors.
return res.send(
renderError({
message: "An unknown error occurred",
renderOptions: {
title_color,
text_color,
bg_color,
border_color,
theme,
},
renderError(err.message, err.secondaryMessage, {
title_color,
text_color,
bg_color,
border_color,
theme,
}),
);
}
+67 -69
View File
@@ -1,23 +1,15 @@
// @ts-check
import { renderRepoCard } from "../src/cards/repo.js";
import { guardAccess } from "../src/common/access.js";
import { blacklist } from "../src/common/blacklist.js";
import { whitelist } from "../src/common/whitelist.js";
import {
CACHE_TTL,
resolveCacheSeconds,
setCacheHeaders,
setErrorCacheHeaders,
} from "../src/common/cache.js";
import {
MissingParamError,
retrieveSecondaryMessage,
} from "../src/common/error.js";
import { parseBoolean } from "../src/common/ops.js";
import { renderError } from "../src/common/render.js";
clampValue,
CONSTANTS,
parseBoolean,
renderError,
} from "../src/common/utils.js";
import { fetchRepo } from "../src/fetchers/repo.js";
import { isLocaleAvailable } from "../src/translations.js";
// @ts-ignore
export default async (req, res) => {
const {
username,
@@ -38,48 +30,68 @@ export default async (req, res) => {
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 (whitelist && !whitelist.includes(username)) {
return res.send(
renderError({
message: "Something went wrong",
secondaryMessage: "Language not found",
renderOptions: {
renderError(
"This username is not whitelisted",
"Please deploy your own instance",
{
title_color,
text_color,
bg_color,
border_color,
theme,
show_repo_link: false,
},
),
);
}
if (whitelist === undefined && blacklist.includes(username)) {
return res.send(
renderError(
"This username is blacklisted",
"Please deploy your own instance",
{
title_color,
text_color,
bg_color,
border_color,
theme,
show_repo_link: false,
},
),
);
}
if (locale && !isLocaleAvailable(locale)) {
return res.send(
renderError("Something went wrong", "Language not found", {
title_color,
text_color,
bg_color,
border_color,
theme,
}),
);
}
try {
const repoData = await fetchRepo(username, repo);
const cacheSeconds = resolveCacheSeconds({
requested: parseInt(cache_seconds, 10),
def: CACHE_TTL.PIN_CARD.DEFAULT,
min: CACHE_TTL.PIN_CARD.MIN,
max: CACHE_TTL.PIN_CARD.MAX,
});
setCacheHeaders(res, cacheSeconds);
let cacheSeconds = clampValue(
parseInt(cache_seconds || CONSTANTS.PIN_CARD_CACHE_SECONDS, 10),
CONSTANTS.ONE_DAY,
CONSTANTS.TEN_DAY,
);
cacheSeconds = process.env.CACHE_SECONDS
? parseInt(process.env.CACHE_SECONDS, 10) || cacheSeconds
: cacheSeconds;
res.setHeader(
"Cache-Control",
`max-age=${cacheSeconds}, s-maxage=${cacheSeconds}`,
);
return res.send(
renderRepoCard(repoData, {
@@ -97,33 +109,19 @@ export default async (req, res) => {
}),
);
} 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),
},
}),
);
}
res.setHeader(
"Cache-Control",
`max-age=${CONSTANTS.ERROR_CACHE_SECONDS / 2}, s-maxage=${
CONSTANTS.ERROR_CACHE_SECONDS
}, stale-while-revalidate=${CONSTANTS.ONE_DAY}`,
); // Use lower cache period for errors.
return res.send(
renderError({
message: "An unknown error occurred",
renderOptions: {
title_color,
text_color,
bg_color,
border_color,
theme,
},
renderError(err.message, err.secondaryMessage, {
title_color,
text_color,
bg_color,
border_color,
theme,
}),
);
}
+10 -11
View File
@@ -1,5 +1,3 @@
// @ts-check
/**
* @file Contains a simple cloud function that can be used to check which PATs are no
* longer working. It returns a list of valid PATs, expired PATs and PATs with errors.
@@ -7,18 +5,20 @@
* @description This function is currently rate limited to 1 request per 5 minutes.
*/
import { request } from "../../src/common/http.js";
import { logger } from "../../src/common/log.js";
import { dateDiff } from "../../src/common/ops.js";
import { logger, request, dateDiff } from "../../src/common/utils.js";
export const RATE_LIMIT_SECONDS = 60 * 5; // 1 request per 5 minutes
/**
* @typedef {import('axios').AxiosRequestHeaders} AxiosRequestHeaders Axios request headers.
* @typedef {import('axios').AxiosResponse} AxiosResponse Axios response.
*/
/**
* Simple uptime check fetcher for the PATs.
*
* @param {any} variables Fetcher variables.
* @param {AxiosRequestHeaders} variables Fetcher variables.
* @param {string} token GitHub token.
* @returns {Promise<import('axios').AxiosResponse>} The response.
* @returns {Promise<AxiosResponse>} The response.
*/
const uptimeFetcher = (variables, token) => {
return request(
@@ -43,7 +43,7 @@ const getAllPATs = () => {
};
/**
* @typedef {(variables: any, token: string) => Promise<import('axios').AxiosResponse>} Fetcher The fetcher function.
* @typedef {(variables: AxiosRequestHeaders, token: string) => Promise<AxiosResponse>} Fetcher The fetcher function.
* @typedef {{validPATs: string[], expiredPATs: string[], exhaustedPATs: string[], suspendedPATs: string[], errorPATs: string[], details: any}} PATInfo The PAT info.
*/
@@ -51,11 +51,10 @@ const getAllPATs = () => {
* Check whether any of the PATs is expired.
*
* @param {Fetcher} fetcher The fetcher function.
* @param {any} variables Fetcher variables.
* @param {AxiosRequestHeaders} variables Fetcher variables.
* @returns {Promise<PATInfo>} The response.
*/
const getPATInfo = async (fetcher, variables) => {
/** @type {Record<string, any>} */
const details = {};
const PATs = getAllPATs();
+8 -6
View File
@@ -1,5 +1,3 @@
// @ts-check
/**
* @file Contains a simple cloud function that can be used to check if the PATs are still
* functional.
@@ -7,18 +5,22 @@
* @description This function is currently rate limited to 1 request per 5 minutes.
*/
import { request } from "../../src/common/http.js";
import retryer from "../../src/common/retryer.js";
import { logger } from "../../src/common/log.js";
import { logger, request } from "../../src/common/utils.js";
export const RATE_LIMIT_SECONDS = 60 * 5; // 1 request per 5 minutes
/**
* @typedef {import('axios').AxiosRequestHeaders} AxiosRequestHeaders Axios request headers.
* @typedef {import('axios').AxiosResponse} AxiosResponse Axios response.
*/
/**
* Simple uptime check fetcher for the PATs.
*
* @param {any} variables Fetcher variables.
* @param {AxiosRequestHeaders} variables Fetcher variables.
* @param {string} token GitHub token.
* @returns {Promise<import('axios').AxiosResponse>} The response.
* @returns {Promise<AxiosResponse>} The response.
*/
const uptimeFetcher = (variables, token) => {
return request(
+63 -92
View File
@@ -1,23 +1,16 @@
// @ts-check
import { renderTopLanguages } from "../src/cards/top-languages.js";
import { guardAccess } from "../src/common/access.js";
import { blacklist } from "../src/common/blacklist.js";
import { whitelist } from "../src/common/whitelist.js";
import {
CACHE_TTL,
resolveCacheSeconds,
setCacheHeaders,
setErrorCacheHeaders,
} from "../src/common/cache.js";
import {
MissingParamError,
retrieveSecondaryMessage,
} from "../src/common/error.js";
import { parseArray, parseBoolean } from "../src/common/ops.js";
import { renderError } from "../src/common/render.js";
clampValue,
CONSTANTS,
parseArray,
parseBoolean,
renderError,
} from "../src/common/utils.js";
import { fetchTopLanguages } from "../src/fetchers/top-languages.js";
import { isLocaleAvailable } from "../src/translations.js";
// @ts-ignore
export default async (req, res) => {
const {
username,
@@ -45,55 +38,51 @@ export default async (req, res) => {
} = req.query;
res.setHeader("Content-Type", "image/svg+xml");
const access = guardAccess({
res,
id: username,
type: "username",
colors: {
title_color,
text_color,
bg_color,
border_color,
theme,
},
});
if (!access.isPassed) {
return access.result;
}
if (locale && !isLocaleAvailable(locale)) {
if (whitelist && !whitelist.includes(username)) {
return res.send(
renderError({
message: "Something went wrong",
secondaryMessage: "Locale not found",
renderOptions: {
renderError(
"This username is not whitelisted",
"Please deploy your own instance",
{
title_color,
text_color,
bg_color,
border_color,
theme,
show_repo_link: false,
},
}),
),
);
}
if (whitelist === undefined && blacklist.includes(username)) {
return res.send(
renderError(
"This username is blacklisted",
"Please deploy your own instance",
{
title_color,
text_color,
bg_color,
border_color,
theme,
show_repo_link: false,
},
),
);
}
if (locale && !isLocaleAvailable(locale)) {
return res.send(renderError("Something went wrong", "Locale not found"));
}
if (
layout !== undefined &&
(typeof layout !== "string" ||
!["compact", "normal", "donut", "donut-vertical", "pie"].includes(layout))
) {
return res.send(
renderError({
message: "Something went wrong",
secondaryMessage: "Incorrect layout input",
renderOptions: {
title_color,
text_color,
bg_color,
border_color,
theme,
},
}),
renderError("Something went wrong", "Incorrect layout input"),
);
}
@@ -103,17 +92,7 @@ export default async (req, res) => {
!["bytes", "percentages"].includes(stats_format))
) {
return res.send(
renderError({
message: "Something went wrong",
secondaryMessage: "Incorrect stats_format input",
renderOptions: {
title_color,
text_color,
bg_color,
border_color,
theme,
},
}),
renderError("Something went wrong", "Incorrect stats_format input"),
);
}
@@ -124,14 +103,20 @@ export default async (req, res) => {
size_weight,
count_weight,
);
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,
});
setCacheHeaders(res, cacheSeconds);
let cacheSeconds = clampValue(
parseInt(cache_seconds || CONSTANTS.TOP_LANGS_CACHE_SECONDS, 10),
CONSTANTS.TWO_DAY,
CONSTANTS.TEN_DAY,
);
cacheSeconds = process.env.CACHE_SECONDS
? parseInt(process.env.CACHE_SECONDS, 10) || cacheSeconds
: cacheSeconds;
res.setHeader(
"Cache-Control",
`max-age=${cacheSeconds / 2}, s-maxage=${cacheSeconds}`,
);
return res.send(
renderTopLanguages(topLangs, {
@@ -155,33 +140,19 @@ export default async (req, res) => {
}),
);
} 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),
},
}),
);
}
res.setHeader(
"Cache-Control",
`max-age=${CONSTANTS.ERROR_CACHE_SECONDS / 2}, s-maxage=${
CONSTANTS.ERROR_CACHE_SECONDS
}, stale-while-revalidate=${CONSTANTS.ONE_DAY}`,
); // Use lower cache period for errors.
return res.send(
renderError({
message: "An unknown error occurred",
renderOptions: {
title_color,
text_color,
bg_color,
border_color,
theme,
},
renderError(err.message, err.secondaryMessage, {
title_color,
text_color,
bg_color,
border_color,
theme,
}),
);
}
+53 -72
View File
@@ -1,30 +1,21 @@
// @ts-check
import { renderWakatimeCard } from "../src/cards/wakatime.js";
import { renderError } from "../src/common/render.js";
import {
clampValue,
CONSTANTS,
parseArray,
parseBoolean,
renderError,
} from "../src/common/utils.js";
import { whitelist } from "../src/common/whitelist.js";
import { fetchWakatimeStats } from "../src/fetchers/wakatime.js";
import { isLocaleAvailable } from "../src/translations.js";
import {
CACHE_TTL,
resolveCacheSeconds,
setCacheHeaders,
setErrorCacheHeaders,
} from "../src/common/cache.js";
import { guardAccess } from "../src/common/access.js";
import {
MissingParamError,
retrieveSecondaryMessage,
} from "../src/common/error.js";
import { parseArray, parseBoolean } from "../src/common/ops.js";
// @ts-ignore
export default async (req, res) => {
const {
username,
title_color,
icon_color,
hide_border,
card_width,
line_height,
text_color,
bg_color,
@@ -46,55 +37,59 @@ export default async (req, res) => {
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)) {
if (whitelist && !whitelist.includes(username)) {
return res.send(
renderError({
message: "Something went wrong",
secondaryMessage: "Language not found",
renderOptions: {
renderError(
"This username is not whitelisted",
"Please deploy your own instance",
{
title_color,
text_color,
bg_color,
border_color,
theme,
show_repo_link: false,
},
),
);
}
if (locale && !isLocaleAvailable(locale)) {
return res.send(
renderError("Something went wrong", "Language not found", {
title_color,
text_color,
bg_color,
border_color,
theme,
}),
);
}
try {
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);
let cacheSeconds = clampValue(
parseInt(cache_seconds || CONSTANTS.CARD_CACHE_SECONDS, 10),
CONSTANTS.SIX_HOURS,
CONSTANTS.TWO_DAY,
);
cacheSeconds = process.env.CACHE_SECONDS
? parseInt(process.env.CACHE_SECONDS, 10) || cacheSeconds
: cacheSeconds;
res.setHeader(
"Cache-Control",
`max-age=${
cacheSeconds / 2
}, s-maxage=${cacheSeconds}, stale-while-revalidate=${CONSTANTS.ONE_DAY}`,
);
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,
@@ -113,33 +108,19 @@ export default async (req, res) => {
}),
);
} 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),
},
}),
);
}
res.setHeader(
"Cache-Control",
`max-age=${CONSTANTS.ERROR_CACHE_SECONDS / 2}, s-maxage=${
CONSTANTS.ERROR_CACHE_SECONDS
}, stale-while-revalidate=${CONSTANTS.ONE_DAY}`,
); // Use lower cache period for errors.
return res.send(
renderError({
message: "An unknown error occurred",
renderOptions: {
title_color,
text_color,
bg_color,
border_color,
theme,
},
renderError(err.message, err.secondaryMessage, {
title_color,
text_color,
bg_color,
border_color,
theme,
}),
);
}
-46
View File
@@ -1,46 +0,0 @@
networks:
public:
external: true
services:
readme-stats:
build: .
container_name: readme-stats
env_file:
- .env
expose:
- ${port}
healthcheck:
test: wget --no-verbose -O - --tries=1 http://localhost:$$port/?username=$$GH_UN | grep 'GitHub Stats' || exit 1
interval: 60m # hourly
retries: 3
start_interval: 1s
start_period: 10s
timeout: 5s
init: true
labels:
- "com.centurylinklabs.watchtower.enable=false" # built, disable watchtower
- homepage.group=Sites
- homepage.name=Github Readme Stats
- homepage.icon=github
- homepage.href=https://readme.digitaladapt.com
- kuma.__docker
logging:
driver: syslog
options:
tag: readme_stats
networks:
- public
restart: always
user: ${USER_ID}:${GROUP_ID}
volumes:
- /etc/localtime:/etc/localtime:ro # read-only
# allow watchtower to monitor for update to base image, since our container is built
readme_stats_monitor:
container_name: readme_stats_monitor
entrypoint: "/bin/true"
image: node:lts-alpine
restart: no
user: ${USER_ID}:${GROUP_ID}
-30
View File
@@ -1,30 +0,0 @@
services:
readme_stats:
build: .
container_name: readme_stats
restart: unless-stopped
env_file:
- .env
expose:
- $port
healthcheck:
test: wget --no-verbose -O - --tries=1 http://localhost:$$port/?username=$$GH_UN | grep 'GitHub Stats' || exit 1
interval: 60m # hourly
retries: 3
start_interval: 1s
start_period: 10s
timeout: 5s
init: true
caddy:
image: caddy
restart: unless-stopped
ports:
- "80:80"
- "443:443"
- "443:443/udp"
depends_on:
- readme_stats
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile:ro
-7
View File
@@ -1,7 +0,0 @@
# github token
PAT_1=<YOUR_GITHUB_TOKEN>
# github username
GH_UN=<YOUR_GITHUB_USERNAME>
# port to listen on
port=9000
+6 -21
View File
@@ -7,25 +7,10 @@ import gistCard from "./api/gist.js";
import express from "express";
const app = express();
const router = express.Router();
app.listen(process.env.port || 9000);
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 server = app.listen(port, "0.0.0.0", () => {
console.log(`Server running on port ${port}`);
});
/* stop gracefully when requested */
process.on("SIGTERM", () => {
console.log("stopping, received SIGTERM signal");
server.close(() => {
process.exit();
});
});
app.get("/", statsCard);
app.get("/pin", repoCard);
app.get("/top-langs", langCard);
app.get("/wakatime", wakatimeCard);
app.get("/gist", gistCard);
+13 -10
View File
@@ -1,13 +1,16 @@
export default {
clearMocks: true,
transform: {},
testEnvironment: "jsdom",
coverageProvider: "v8",
testPathIgnorePatterns: ["<rootDir>/node_modules/", "<rootDir>/tests/e2e/"],
modulePathIgnorePatterns: ["<rootDir>/node_modules/", "<rootDir>/tests/e2e/"],
coveragePathIgnorePatterns: [
"<rootDir>/node_modules/",
"<rootDir>/tests/e2e/",
],
// Jest-bench need its own test environment to function
testEnvironment: "jest-bench/environment",
testEnvironmentOptions: {
// still Jest-bench environment will run your environment if you specify it here
testEnvironment: "jest-environment-node",
testEnvironmentOptions: {
// specify any option for your environment
},
},
// always include "default" reporter along with Jest-bench reporter
// for error reporting
reporters: ["default", "jest-bench/reporter"],
// will pick up "*.bench.js" file.
testRegex: "(\\.bench)\\.(ts|tsx|js)$",
};
+3794 -4804
View File
File diff suppressed because it is too large Load Diff
+16 -15
View File
@@ -36,35 +36,36 @@
"author": "Anurag Hazra",
"license": "MIT",
"devDependencies": {
"@actions/core": "^2.0.1",
"@actions/core": "^1.11.1",
"@actions/github": "^6.0.1",
"@eslint/eslintrc": "^3.3.3",
"@eslint/js": "^9.39.2",
"@eslint/eslintrc": "^3.3.1",
"@eslint/js": "^9.35.0",
"@testing-library/dom": "^10.4.1",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/jest-dom": "^6.8.0",
"@uppercod/css-to-object": "^1.1.1",
"axios-mock-adapter": "^2.1.0",
"color-contrast-checker": "^2.1.0",
"eslint": "^9.39.2",
"eslint": "^9.35.0",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-jsdoc": "^61.5.0",
"express": "^5.2.1",
"globals": "^16.5.0",
"eslint-plugin-jsdoc": "^57.0.8",
"globals": "^16.4.0",
"hjson": "^3.2.2",
"husky": "^9.1.7",
"jest": "^30.2.0",
"jest-environment-jsdom": "^30.2.0",
"js-yaml": "^4.1.1",
"lint-staged": "^16.2.7",
"jest": "^29.7.0",
"jest-bench": "^29.7.1",
"jest-environment-jsdom": "^30.1.2",
"js-yaml": "^4.1.0",
"lint-staged": "^16.1.6",
"lodash.snakecase": "^4.1.1",
"parse-diff": "^0.11.1",
"prettier": "^3.7.3"
"prettier": "^3.6.2"
},
"dependencies": {
"axios": "^1.13.1",
"dotenv": "^17.2.3",
"axios": "^1.12.2",
"dotenv": "^17.2.2",
"emoji-name-map": "^2.0.3",
"github-username-regex": "^1.0.0",
"upgrade": "^1.1.0",
"word-wrap": "^1.2.5"
},
"lint-staged": {
-35
View File
@@ -1,35 +0,0 @@
Get dynamically generated GitHub stats on your READMEs, with the ease of docker!
Fork of [https://github.com/anuraghazra/github-readme-stats](https://github.com/anuraghazra/github-readme-stats), gently wrapped in docker.
**Steps to setup:**
* clone this repo into a folder.
* copy docker-compose.yml.example to docker-compose.yml.
* copy env.example to .env and update.
* copy Caddyfile.example to Caddyfile and update.
* and start with `docker compose up -d`.
docker-compose.yml.example has a very simple Caddy reverse proxy, for automatic https.
Files have example in their name, so that when you update, your local settings won't be overridden.
```
git clone https://code.digitaladapt.com/andrew/github-readme-stats.git
cd github-readme-stats
cp docker-compose.yml.example docker-compose.yml
cp env.example .env
vim .env
# add your github token
cp Caddyfile.example Caddyfile
vim Caddyfile
# configure your domain
docker compose up -d
```
You might ask, why should I trust your docker image?
The answer is, you don't have to, there isn't an image,
just a few commands in the Dockerfile, which you can and should look at.
+84 -202
View File
@@ -1,8 +1,8 @@
<div align="center">
<img src="https://res.cloudinary.com/anuraghazra/image/upload/v1594908242/logo_ccswme.svg" width="100px" alt="GitHub Readme Stats" />
<h1 style="font-size: 28px; margin: 10px 0;">GitHub Readme Stats</h1>
<p>Get dynamically generated GitHub stats on your READMEs!</p>
</div>
<p align="center">
<img width="100px" src="https://res.cloudinary.com/anuraghazra/image/upload/v1594908242/logo_ccswme.svg" align="center" alt="GitHub Readme Stats" />
<h2 align="center">GitHub Readme Stats</h2>
<p align="center">Get dynamically generated GitHub stats on your READMEs!</p>
</p>
<p align="center">
<a href="https://github.com/anuraghazra/github-readme-stats/actions">
@@ -52,7 +52,6 @@
- [Hiding individual stats](#hiding-individual-stats)
- [Showing additional individual stats](#showing-additional-individual-stats)
- [Showing icons](#showing-icons)
- [Showing commits count for specified year](#showing-commits-count-for-specified-year)
- [Themes](#themes)
- [Customization](#customization)
- [GitHub Extra Pins](#github-extra-pins)
@@ -85,25 +84,25 @@
- [Stats and top languages cards](#stats-and-top-languages-cards)
- [Pinning repositories](#pinning-repositories)
- [Deploy on your own](#deploy-on-your-own)
- [GitHub Actions (Recommended)](#github-actions-recommended)
- [Self-hosted (Vercel/Other) (Recommended)](#self-hosted-vercelother-recommended)
- [First step: get your Personal Access Token (PAT)](#first-step-get-your-personal-access-token-pat)
- [On Vercel](#on-vercel)
- [First step: get your Personal Access Token (PAT)](#first-step-get-your-personal-access-token-pat)
- [Classic token](#classic-token)
- [Fine-grained token](#fine-grained-token)
- [On Vercel](#on-vercel)
- [:film\_projector: Check Out Step By Step Video Tutorial By @codeSTACKr](#film_projector-check-out-step-by-step-video-tutorial-by-codestackr)
- [On other platforms](#on-other-platforms)
- [Available environment variables](#available-environment-variables)
- [On other platforms](#on-other-platforms)
- [Available environment variables](#available-environment-variables)
- [Keep your fork up to date](#keep-your-fork-up-to-date)
- [:sparkling\_heart: Support the project](#sparkling_heart-support-the-project)
</details>
# Important Notices <!-- omit in toc -->
> [!IMPORTANT]
> The public Vercel instance at `https://github-readme-stats.vercel.app/api` is best-effort and can be unreliable due to rate limits and traffic spikes (see [#1471](https://github.com/anuraghazra/github-readme-stats/issues/1471)). We use caching to improve stability (see [common options](#common-options)), but for reliable cards we recommend [self-hosting](#deploy-on-your-own) (Vercel or other) or using the [GitHub Actions workflow](#github-actions-recommended) to generate cards in your [profile repository](https://docs.github.com/en/account-and-profile/how-tos/profile-customization/managing-your-profile-readme).
> [!IMPORTANT]\
> Since the GitHub API only [allows 5k requests per hour per user account](https://docs.github.com/en/graphql/overview/resource-limitations), the public Vercel instance hosted on `https://github-readme-stats.vercel.app/api` could possibly hit the rate limiter (see [#1471](https://github.com/anuraghazra/github-readme-stats/issues/1471)). We use caching to prevent this from happening (see https://github.com/anuraghazra/github-readme-stats#common-options). You can turn off these rate limit protections by deploying [your own Vercel instance](#disable-rate-limit-protections).
<img alt="Uptime Badge" src="https://img.shields.io/endpoint?url=https%3A%2F%2Fgithub-readme-stats-git-monitoring-github-readme-stats-team.vercel.app%2Fapi%2Fstatus%2Fup%3Ftype%3Dshields">
> [!IMPORTANT]
> [!IMPORTANT]\
> We're a small team, and to prioritize, we rely on upvotes :+1:. We use the Top Issues dashboard for tracking community demand (see [#1935](https://github.com/anuraghazra/github-readme-stats/issues/1935)). Do not hesitate to upvote the issues and pull requests you are interested in. We will work on the most upvoted first.
# GitHub Stats Card
@@ -116,10 +115,10 @@ Change the `?username=` value to your GitHub username.
[![Anurag's GitHub stats](https://github-readme-stats.vercel.app/api?username=anuraghazra)](https://github.com/anuraghazra/github-readme-stats)
```
> [!WARNING]
> [!WARNING]\
> By default, the stats card only shows statistics like stars, commits, and pull requests from public repositories. To show private statistics on the stats card, you should [deploy your own instance](#deploy-on-your-own) using your own GitHub API token.
> [!NOTE]
> [!NOTE]\
> Available ranks are S (top 1%), A+ (12.5%), A (25%), A- (37.5%), B+ (50%), B (62.5%), B- (75%), C+ (87.5%) and C (everyone). This ranking scheme is based on the [Japanese academic grading](https://wikipedia.org/wiki/Academic_grading_in_Japan) system. The global percentile is calculated as a weighted sum of percentiles for each statistic (number of commits, pull requests, reviews, issues, stars, and followers), based on the cumulative distribution function of the [exponential](https://wikipedia.org/wiki/exponential_distribution) and the [log-normal](https://wikipedia.org/wiki/Log-normal_distribution) distributions. The implementation can be investigated at [src/calculateRank.js](https://github.com/anuraghazra/github-readme-stats/blob/master/src/calculateRank.js). The circle around the rank shows 100 minus the global percentile.
### Hiding individual stats
@@ -150,14 +149,6 @@ To enable icons, you can pass `&show_icons=true` in the query param, like so:
![Anurag's GitHub stats](https://github-readme-stats.vercel.app/api?username=anuraghazra&show_icons=true)
```
### Showing commits count for specified year
You can specify a year and fetch only the commits that were made in that year by passing `&commits_year=YYYY` to the parameter.
```md
![Anurag's GitHub stats](https://github-readme-stats.vercel.app/api?username=anuraghazra&commits_year=2020)
```
### Themes
With inbuilt themes, you can customize the look of the card without doing any [manual customization](#customization).
@@ -284,8 +275,8 @@ You can customize the appearance of all your cards however you wish with URL par
| `locale` | Sets the language in the card, you can check full list of available locales [here](#available-locales). | enum | `en` |
| `border_radius` | Corner rounding on the card. | number | `4.5` |
> [!WARNING]
> We use caching to decrease the load on our servers (see <https://github.com/anuraghazra/github-readme-stats/issues/1471#issuecomment-1271551425>). Our cards have the following default cache hours: stats card - 24 hours, top languages card - 144 hours (6 days), pin card - 240 hours (10 days), gist card - 48 hours (2 days), and wakatime card - 24 hours. If you want the data on your cards to be updated more often you can [deploy your own instance](#deploy-on-your-own) and set [environment variable](#available-environment-variables) `CACHE_SECONDS` to a value of your choosing.
> [!WARNING]\
> We use caching to decrease the load on our servers (see <https://github.com/anuraghazra/github-readme-stats/issues/1471#issuecomment-1271551425>). Our cards have the following default cache hours: stats card - 24 hours, top languages card - 144 hours (6 days), pin card - 240 hours (10 days), gist card - 48 hours (2 days). If you want the data on your statistics card to be updated more often you can [deploy your own instance](#deploy-on-your-own) and set [environment variable](#disable-rate-limit-protections) `CACHE_SECONDS` to a value of your choosing.
##### Gradient in bg\_color
@@ -302,62 +293,50 @@ Here is a list of all available locales:
| Code | Locale |
| --- | --- |
| `ar` | Arabic |
| `az` | Azerbaijani |
| `bn` | Bengali |
| `bg` | Bulgarian |
| `my` | Burmese |
| `ca` | Catalan |
| `cn` | Chinese |
| `zh-tw` | Chinese (Taiwan) |
| `ar` | Arabic |
| `cs` | Czech |
| `nl` | Dutch |
| `en` | English |
| `fil` | Filipino |
| `fi` | Finnish |
| `fr` | French |
| `de` | German |
| `el` | Greek |
| `en` | English |
| `bn` | Bengali |
| `es` | Spanish |
| `fr` | French |
| `hu` | Hungarian |
| `fi` | Finnish |
| `sr` | Serbian |
</td><td>
| Code | Locale |
| --- | --- |
| `he` | Hebrew |
| `hi` | Hindi |
| `hu` | Hungarian |
| `id` | Indonesian |
| `it` | Italian |
| `ja` | Japanese |
| `kr` | Korean |
| `ml` | Malayalam |
| `np` | Nepali |
| `no` | Norwegian |
| `fa` | Persian (Farsi) |
| `pl` | Polish |
| `pt-br` | Portuguese (Brazil) |
| `nl` | Dutch |
| `pt-pt` | Portuguese (Portugal) |
| `pt-br` | Portuguese (Brazil) |
| `np` | Nepali |
| `el` | Greek |
| `ru` | Russian |
| `uk-ua` | Ukrainian |
| `ro` | Romanian |
</td><td>
| Code | Locale |
| --- | --- |
| `ru` | Russian |
| `sa` | Sanskrit |
| `sr` | Serbian (Cyrillic) |
| `sr-latn` | Serbian (Latin) |
| `id` | Indonesian |
| `ml` | Malayalam |
| `my` | Burmese |
| `sk` | Slovak |
| `es` | Spanish |
| `sw` | Swahili |
| `se` | Swedish |
| `ta` | Tamil |
| `th` | Thai |
| `tr` | Turkish |
| `uk-ua` | Ukrainian |
| `ur` | Urdu |
| `pl` | Polish |
| `uz` | Uzbek |
| `vi` | Vietnamese |
| `se` | Swedish |
| `az` | Azerbaijani |
| `no` | Norwegian |
</td></tr>
</table>
@@ -382,14 +361,9 @@ If we don't support your language, please consider contributing! You can find mo
| `disable_animations` | Disables all animations in the card. | boolean | `false` |
| `ring_color` | Color of the rank circle. | string (hex color) | `2f80ed` |
| `number_format` | Switches between two available formats for displaying the card values `short` (i.e. `6.6k`) and `long` (i.e. `6626`). | enum | `short` |
| `number_precision` | Enforce the number of digits after the decimal point for `short` number format. Must be an integer between 0 and 2. Will be ignored for `long` number format. | integer (0, 1 or 2) | `null` |
| `show` | Shows [additional items](#showing-additional-individual-stats) on stats card (i.e. `reviews`, `discussions_started`, `discussions_answered`, `prs_merged` or `prs_merged_percentage`). | string (comma-separated values) | `null` |
| `commits_year` | Filters and counts only commits made in the specified year. | integer _(YYYY)_ | `<current year> (one year to date)` |
> [!WARNING]
> Custom title should be URI-escaped, as specified in [Percent Encoding](https://en.wikipedia.org/wiki/Percent-encoding) (i.e: `Anurag's GitHub Stats` should become `Anurag%27s%20GitHub%20Stats`). You can use [urlencoder.org](https://www.urlencoder.org/) to help you do this automatically.
> [!NOTE]
> [!NOTE]\
> When hide\_rank=`true`, the minimum card width is 270 px + the title length and padding.
***
@@ -461,16 +435,16 @@ Use `show_owner` query option to include the gist's owner username
The top languages card shows a GitHub user's most frequently used languages.
> [!WARNING]
> [!WARNING]\
> By default, the language card shows language results only from public repositories. To include languages used in private repositories, you should [deploy your own instance](#deploy-on-your-own) using your own GitHub API token.
> [!NOTE]
> [!NOTE]\
> Top Languages does not indicate the user's skill level or anything like that; it's a GitHub metric to determine which languages have the most code on GitHub. It is a new feature of github-readme-stats.
> [!WARNING]
> [!WARNING]\
> This card shows language usage only inside your own non-forked repositories, not depending on who the author of the commits is. It does not include your contributions into another users/organizations repositories. Currently there are no way to get this data from GitHub API. If you want this behavior to be improved you can support [this feature request](https://github.com/orgs/community/discussions/18230) created by [@rickstaa](https://github.com/rickstaa) inside GitHub Community.
> [!WARNING]
> [!WARNING]\
> Currently this card shows data only about first 100 repositories. This is because GitHub API limitations which cause downtimes of public instances (see [#1471](https://github.com/anuraghazra/github-readme-stats/issues/1471)). In future this behavior will be improved by releasing GitHub action or providing environment variables for user's own instances.
### Usage
@@ -502,8 +476,10 @@ You can customize the appearance and behavior of the top languages card using th
| `count_weight` | Configures language stats algorithm (see [Language stats algorithm](#language-stats-algorithm)). | integer | `0` |
| `stats_format` | Switches between two available formats for language's stats `percentages` and `bytes`. | enum | `percentages` |
> [!WARNING]
> Language names and custom title should be URI-escaped, as specified in [Percent Encoding](https://en.wikipedia.org/wiki/Percent-encoding) (i.e: `c++` should become `c%2B%2B`, `jupyter notebook` should become `jupyter%20notebook`, `Most Used Languages` should become `Most%20Used%20Languages`, etc.) You can use [urlencoder.org](https://www.urlencoder.org/) to help you do this automatically.
> [!WARNING]\
> Language names should be URI-escaped, as specified in [Percent Encoding](https://en.wikipedia.org/wiki/Percent-encoding)
> (i.e: `c++` should become `c%2B%2B`, `jupyter notebook` should become `jupyter%20notebook`, etc.) You can use
> [urlencoder.org](https://www.urlencoder.org/) to help you do this automatically.
### Language stats algorithm
@@ -627,12 +603,9 @@ You can use the `&stats_format=bytes` option to display the stats in bytes inste
# WakaTime Stats Card
> [!WARNING]
> [!WARNING]\
> Please be aware that we currently only show data from WakaTime profiles that are public. You therefore have to make sure that **BOTH** `Display code time publicly` and `Display languages, editors, os, categories publicly` are enabled.
> [!WARNING]
> In case you just created a new WakaTime account, then it might take up to 24 hours until your stats will become visible on the WakaTime stats card.
Change the `?username=` value to your [WakaTime](https://wakatime.com) username.
```md
@@ -647,7 +620,6 @@ You can customize the appearance and behavior of the WakaTime stats card using t
| --- | --- | --- | --- |
| `hide` | Hides the languages specified from the card. | string (comma-separated values) | `null` |
| `hide_title` | Hides the title of your card. | boolean | `false` |
| `card_width` | Sets the card's width manually. | number | `495` |
| `line_height` | Sets the line height between text. | integer | `25` |
| `hide_progress` | Hides the progress bar and percentage. | boolean | `false` |
| `custom_title` | Sets a custom title for the card. | string | `WakaTime Stats` |
@@ -657,9 +629,6 @@ You can customize the appearance and behavior of the WakaTime stats card using t
| `display_format` | Sets the WakaTime stats display format. Choose `time` to display time-based stats or `percent` to show percentages. | enum | `time` |
| `disable_animations` | Disables all animations in the card. | boolean | `false` |
> [!WARNING]
> Custom title should be URI-escaped, as specified in [Percent Encoding](https://en.wikipedia.org/wiki/Percent-encoding) (i.e: `WakaTime Stats` should become `WakaTime%20Stats`). You can use [urlencoder.org](https://www.urlencoder.org/) to help you do this automatically.
### Demo
![Harlok's WakaTime stats](https://github-readme-stats.vercel.app/api/wakatime?username=ffflabs)
@@ -796,101 +765,50 @@ By default, GitHub does not lay out the cards side by side. To do that, you can
</details>
# Deploy on your own (recommended)
# Deploy on your own
Because the public endpoint is [not reliable](#Important-Notices), we recommend self-deployment via GitHub Actions or your own hosted instance. GitHub Actions is the simplest setup with static SVGs stored in your repo but less frequent updates, while self-hosting takes more work and can serve fresher stats (with caching).
## First step: get your Personal Access Token (PAT)
## GitHub Actions
Selecting the right scopes for your token is important in case you want to display private contributions on your stats card.
GitHub Actions generates static SVGs and avoids per-request API calls. By default it uses `GITHUB_TOKEN` (public stats only), for private stats, set a [PAT](https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/managing-your-personal-access-tokens) as a secret and pass it to the action instead.
### Classic token
Create `/.github/workflows/grs.yml` in your profile repo (`USERNAME/USERNAME`):
Steps:
- Go to [Account -> Settings -> Developer Settings -> Personal access tokens -> Tokens (classic)](https://github.com/settings/tokens).
- Click on `Generate new token -> Generate new token (classic)`.
- Scopes to selected:
- repo
- read:user
- Click on `Generate token` and copy it.
```yaml
name: Update README cards
on:
schedule:
- cron: "0 3 * * *"
workflow_dispatch:
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Generate stats card
uses: readme-tools/github-readme-stats-action@v1
with:
card: stats
options: username=${{ github.repository_owner }}&show_icons=true
path: profile/stats.svg
token: ${{ secrets.GITHUB_TOKEN }}
- name: Commit cards
run: |
git config user.name "github-actions"
git config user.email "github-actions@users.noreply.github.com"
git add profile/*.svg
git commit -m "Update README cards" || exit 0
git push
```
Then embed from your profile README:
```md
![Stats](./profile/stats.svg)
```
See more options and examples in the [GitHub Readme Stats Action README](https://github.com/readme-tools/github-readme-stats-action#readme).
## Self-hosted (Vercel/Other)
Running your own instance avoids public rate limits and gives you full control over caching, tokens, and private stats.
### First step: get your Personal Access Token (PAT)
For deploying your own instance of GitHub Readme Stats, you will need to create a GitHub Personal Access Token (PAT). Below are the steps to create one and the scopes you need to select for both classic and fine-grained tokens.
Selecting the right scopes for your token is important in case you want to display private contributions on your cards.
#### Classic token
* Go to [Account -> Settings -> Developer Settings -> Personal access tokens -> Tokens (classic)](https://github.com/settings/tokens).
* Click on `Generate new token -> Generate new token (classic)`.
* Scopes to select:
* repo
* read:user
* Click on `Generate token` and copy it.
#### Fine-grained token
### Fine-grained token
> [!WARNING]\
> This limits the scope to issues in your repositories and includes only public commits.
> This limits the number of issues to the number of issues on your repositories only and only takes public commits into account.
* Go to [Account -> Settings -> Developer Settings -> Personal access tokens -> Fine-grained tokens](https://github.com/settings/tokens).
* Click on `Generate new token -> Generate new token`.
* Select an expiration date
* Select `All repositories`
* Scopes to select in `Repository permission`:
* Commit statuses: read-only
* Contents: read-only
* Issues: read-only
* Metadata: read-only
* Pull requests: read-only
* Click on `Generate token` and copy it.
Steps:
- Go to [Account -> Settings -> Developer Settings -> Personal access tokens -> Fine-grained tokens](https://github.com/settings/tokens).
- Click on `Generate new token -> Generate new token`.
- Select on expiration date (nothing do less datas)
- Select `All repositories`
- Scopes to selected in `Repository permission`:
- Commit statuses : read-only
- Contents : read-only
- Issues : read-only
- Metadata : read-only
- Pull requests : read-only
- Click on `Generate token` and copy it.
### On Vercel
## On Vercel
### :film\_projector: [Check Out Step By Step Video Tutorial By @codeSTACKr](https://youtu.be/n6d4KHSKqGk?t=107)
Since the GitHub API only allows 5k requests per hour, my `https://github-readme-stats.vercel.app/api` could possibly hit the rate limiter. If you host it on your own Vercel server, then you do not have to worry about anything. Click on the deploy button to get started!
> [!NOTE]
> [!NOTE]\
> Since [#58](https://github.com/anuraghazra/github-readme-stats/pull/58), we should be able to handle more than 5k requests and have fewer issues with downtime :grin:.
> [!NOTE]
> [!NOTE]\
> If you are on the [Pro (i.e. paid)](https://vercel.com/pricing) Vercel plan, the [maxDuration](https://vercel.com/docs/concepts/projects/project-configuration#value-definition) value found in the [vercel.json](https://github.com/anuraghazra/github-readme-stats/blob/master/vercel.json) can be increased when your Vercel instance frequently times out during the card request. You are advised to keep this value lower than `30` seconds to prevent high memory usage.
[![Deploy to Vercel](https://vercel.com/button)](https://vercel.com/import/project?template=https://github.com/anuraghazra/github-readme-stats)
@@ -917,16 +835,16 @@ Since the GitHub API only allows 5k requests per hour, my `https://github-readme
</details>
### On other platforms
## On other platforms
> [!WARNING]
> [!WARNING]\
> This way of using GRS is not officially supported and was added to cater to some particular use cases where Vercel could not be used (e.g. [#2341](https://github.com/anuraghazra/github-readme-stats/discussions/2341)). The support for this method, therefore, is limited.
<details>
<summary><b>:hammer_and_wrench: Step-by-step guide for deploying on other platforms</b></summary>
1. Fork or clone this repo as per your needs
2. Move `express` from the devDependencies to the dependencies section of `package.json`
2. Add `express` to the dependencies section of `package.json`
<https://github.com/anuraghazra/github-readme-stats/blob/ba7c2f8b55eac8452e479c8bd38b044d204d0424/package.json#L54-L61>
3. Run `npm i` if needed (initial setup)
4. Run `node express.js` to start the server, or set the entry point to `express.js` in `package.json` if you're deploying on a managed service
@@ -934,52 +852,16 @@ Since the GitHub API only allows 5k requests per hour, my `https://github-readme
5. You're done 🎉
</details>
### Available environment variables
## Available environment variables
GitHub Readme Stats provides several environment variables that can be used to customize the behavior of your self-hosted instance. These include:
<table>
<thead>
<tr>
<th>Name</th>
<th>Description</th>
<th>Supported values</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>CACHE_SECONDS</code></td>
<td>Sets the cache duration in seconds for the generated cards. This variable takes precedence over the default cache timings for the public instance. If this variable is not set, the default cache duration is 24 hours (86,400 seconds).</td>
<td>Any positive integer or <code>0</code> to disable caching</td>
</tr>
<tr>
<td><code>WHITELIST</code></td>
<td>A comma-separated list of GitHub usernames that are allowed to access your instance. If this variable is not set, all usernames are allowed.</td>
<td>Comma-separated GitHub usernames</td>
</tr>
<tr>
<td><code>GIST_WHITELIST</code></td>
<td>A comma-separated list of GitHub Gist IDs that are allowed to be accessed on your instance. If this variable is not set, all Gist IDs are allowed.</td>
<td>Comma-separated GitHub Gist IDs</td>
</tr>
<tr>
<td><code>EXCLUDE_REPO</code></td>
<td>A comma-separated list of repositories that will be excluded from stats and top languages cards on your instance. This allows repository exclusion without exposing repository names in public URLs. This enhances privacy for self-hosted instances that include private repositories in stats cards.</td>
<td>Comma-separated repository names</td>
</tr>
<tr>
<td><code>FETCH_MULTI_PAGE_STARS</code></td>
<td>Enables fetching all starred repositories for accurate star counts, especially for users with more than 100 repositories. This may increase response times and API points usage, so it is disabled on the public instance.</td>
<td><code>true</code> or <code>false</code></td>
</tr>
</tbody>
</table>
* `CACHE_SECONDS`: This takes precedence over our cache minimum and maximum values and can circumvent these values for self-hosted instances.
* `WHITELIST`: A comma-separated list of GitHub usernames that are allowed to access your instance. If this variable is not set, all usernames are allowed.
* `GIST_WHITELIST`: A comma-separated list of GitHub gist IDs that are allowed to be accessed on your instance. If this variable is not set, all gist IDs are allowed.
See [the Vercel documentation](https://vercel.com/docs/concepts/projects/environment-variables) on adding these environment variables to your Vercel instance.
> [!WARNING]
> Please remember to redeploy your instance after making any changes to the environment variables so that the updates take effect. The changes will not be applied to the previous deployments.
## Keep your fork up to date
You can keep your fork, and thus your private Vercel instance up to date with the upstream using GitHub's [Sync Fork button](https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/working-with-forks/syncing-a-fork). You can also use the [pull](https://github.com/wei/pull) package created by [@wei](https://github.com/wei) to automate this process.
+1 -1
View File
@@ -12,7 +12,7 @@ import Hjson from "hjson";
import snakeCase from "lodash.snakecase";
import parse from "parse-diff";
import { inspect } from "util";
import { isValidHexColor, isValidGradient } from "../src/common/color.js";
import { isValidHexColor, isValidGradient } from "../src/common/utils.js";
import { themes } from "../themes/index.js";
import { getGithubToken, getRepoInfo } from "./helpers.js";
+1 -1
View File
@@ -31,7 +31,7 @@ function log_normal_cdf(x) {
* @param {number} params.repos Total number of repos.
* @param {number} params.stars The number of stars.
* @param {number} params.followers The number of followers.
* @returns {{ level: string, percentile: number }} The users rank.
* @returns {{level: string, percentile: number}}} The users rank.
*/
function calculateRank({
all_commits,
+6 -6
View File
@@ -1,17 +1,18 @@
// @ts-check
import {
getCardColors,
parseEmojis,
wrapTextMultiline,
encodeHTML,
kFormatter,
measureText,
flexLayout,
iconWithLabel,
createLanguageNode,
} from "../common/render.js";
} from "../common/utils.js";
import Card from "../common/Card.js";
import { getCardColors } from "../common/color.js";
import { kFormatter, wrapTextMultiline } from "../common/fmt.js";
import { encodeHTML } from "../common/html.js";
import { icons } from "../common/icons.js";
import { parseEmojis } from "../common/ops.js";
/** Import language colors.
*
@@ -95,7 +96,6 @@ const renderGistCard = (gistData, options = {}) => {
);
const languageName = language || "Unspecified";
// @ts-ignore
const languageColor = languageColors[languageName] || "#858585";
const svgLanguage = createLanguageNode(languageName, languageColor);
+7 -6
View File
@@ -1,18 +1,19 @@
// @ts-check
import { Card } from "../common/Card.js";
import { getCardColors } from "../common/color.js";
import { kFormatter, wrapTextMultiline } from "../common/fmt.js";
import { encodeHTML } from "../common/html.js";
import { I18n } from "../common/I18n.js";
import { icons } from "../common/icons.js";
import { clampValue, parseEmojis } from "../common/ops.js";
import {
encodeHTML,
flexLayout,
getCardColors,
kFormatter,
measureText,
parseEmojis,
wrapTextMultiline,
iconWithLabel,
createLanguageNode,
} from "../common/render.js";
clampValue,
} from "../common/utils.js";
import { repoCardLocales } from "../translations.js";
const ICON_SIZE = 16;
+59 -117
View File
@@ -1,14 +1,16 @@
// @ts-check
import { Card } from "../common/Card.js";
import { getCardColors } from "../common/color.js";
import { CustomError } from "../common/error.js";
import { kFormatter } from "../common/fmt.js";
import { I18n } from "../common/I18n.js";
import { icons, rankIcon } from "../common/icons.js";
import { clampValue } from "../common/ops.js";
import { flexLayout, measureText } from "../common/render.js";
import { statCardLocales, wakatimeCardLocales } from "../translations.js";
import {
CustomError,
clampValue,
flexLayout,
getCardColors,
kFormatter,
measureText,
} from "../common/utils.js";
import { statCardLocales } from "../translations.js";
const CARD_MIN_WIDTH = 287;
const CARD_DEFAULT_WIDTH = 287;
@@ -17,55 +19,20 @@ const RANK_CARD_DEFAULT_WIDTH = 450;
const RANK_ONLY_CARD_MIN_WIDTH = 290;
const RANK_ONLY_CARD_DEFAULT_WIDTH = 290;
/**
* Long locales that need more space for text. Keep sorted alphabetically.
*
* @type {(keyof typeof wakatimeCardLocales["wakatimecard.title"])[]}
*/
const LONG_LOCALES = [
"az",
"bg",
"cs",
"de",
"el",
"es",
"fil",
"fi",
"fr",
"hu",
"id",
"ja",
"ml",
"my",
"nl",
"pl",
"pt-br",
"pt-pt",
"ru",
"sr",
"sr-latn",
"sw",
"ta",
"uk-ua",
"uz",
"zh-tw",
];
/**
* Create a stats card text item.
*
* @param {object} params Object that contains the createTextNode parameters.
* @param {string} params.icon The icon to display.
* @param {string} params.label The label to display.
* @param {number} params.value The value to display.
* @param {string} params.id The id of the stat.
* @param {string=} params.unitSymbol The unit symbol of the stat.
* @param {number} params.index The index of the stat.
* @param {boolean} params.showIcons Whether to show icons.
* @param {number} params.shiftValuePos Number of pixels the value has to be shifted to the right.
* @param {boolean} params.bold Whether to bold the label.
* @param {string} params.numberFormat The format of numbers on card.
* @param {number=} params.numberPrecision The precision of numbers on card.
* @param {object} createTextNodeParams Object that contains the createTextNode parameters.
* @param {string} createTextNodeParams.icon The icon to display.
* @param {string} createTextNodeParams.label The label to display.
* @param {number} createTextNodeParams.value The value to display.
* @param {string} createTextNodeParams.id The id of the stat.
* @param {string=} createTextNodeParams.unitSymbol The unit symbol of the stat.
* @param {number} createTextNodeParams.index The index of the stat.
* @param {boolean} createTextNodeParams.showIcons Whether to show icons.
* @param {number} createTextNodeParams.shiftValuePos Number of pixels the value has to be shifted to the right.
* @param {boolean} createTextNodeParams.bold Whether to bold the label.
* @param {string} createTextNodeParams.number_format The format of numbers on card.
* @returns {string} The stats card text item SVG object.
*/
const createTextNode = ({
@@ -78,17 +45,10 @@ const createTextNode = ({
showIcons,
shiftValuePos,
bold,
numberFormat,
numberPrecision,
number_format,
}) => {
const precision =
typeof numberPrecision === "number" && !isNaN(numberPrecision)
? clampValue(numberPrecision, 0, 2)
: undefined;
const kValue =
numberFormat.toLowerCase() === "long" || id === "prs_merged_percentage"
? value
: kFormatter(value, precision);
number_format.toLowerCase() === "long" ? value : kFormatter(value);
const staggerDelay = (index + 3) * 150;
const labelOffset = showIcons ? `x="25"` : "";
@@ -227,21 +187,6 @@ const getStyles = ({
`;
};
/**
* Return the label for commits according to the selected options
*
* @param {boolean} include_all_commits Option to include all years
* @param {number|undefined} commits_year Option to include only selected year
* @param {I18n} i18n The I18n instance.
* @returns {string} The label corresponding to the options.
*/
const getTotalCommitsYearLabel = (include_all_commits, commits_year, i18n) =>
include_all_commits
? ""
: commits_year
? ` (${commits_year})`
: ` (${i18n.t("wakatimecard.lastyear")})`;
/**
* @typedef {import('../fetchers/types').StatsData} StatsData
* @typedef {import('./types').StatCardOptions} StatCardOptions
@@ -277,7 +222,6 @@ const renderStatsCard = (stats, options = {}) => {
card_width,
hide_rank = false,
include_all_commits = false,
commits_year,
line_height = 25,
title_color,
ring_color,
@@ -290,7 +234,6 @@ const renderStatsCard = (stats, options = {}) => {
border_radius,
border_color,
number_format = "short",
number_precision,
locale,
disable_animations = false,
rank_icon = "default",
@@ -314,10 +257,7 @@ const renderStatsCard = (stats, options = {}) => {
const apostrophe = /s$/i.test(name.trim()) ? "" : "s";
const i18n = new I18n({
locale,
translations: {
...statCardLocales({ name, apostrophe }),
...wakatimeCardLocales,
},
translations: statCardLocales({ name, apostrophe }),
});
// Meta data for creating text nodes with createTextNode function
@@ -331,11 +271,9 @@ const renderStatsCard = (stats, options = {}) => {
};
STATS.commits = {
icon: icons.commits,
label: `${i18n.t("statcard.commits")}${getTotalCommitsYearLabel(
include_all_commits,
commits_year,
i18n,
)}`,
label: `${i18n.t("statcard.commits")}${
include_all_commits ? "" : ` (${new Date().getFullYear()})`
}`,
value: totalCommits,
id: "commits",
};
@@ -359,11 +297,7 @@ const renderStatsCard = (stats, options = {}) => {
STATS.prs_merged_percentage = {
icon: icons.prs_merged_percentage,
label: i18n.t("statcard.prs-merged-percentage"),
value: mergedPRsPercentage.toFixed(
typeof number_precision === "number" && !isNaN(number_precision)
? clampValue(number_precision, 0, 2)
: 2,
),
value: mergedPRsPercentage.toFixed(2),
id: "prs_merged_percentage",
unitSymbol: "%",
};
@@ -409,31 +343,43 @@ const renderStatsCard = (stats, options = {}) => {
id: "contribs",
};
// @ts-ignore
const isLongLocale = locale ? LONG_LOCALES.includes(locale) : false;
const longLocales = [
"cn",
"es",
"fr",
"pt-br",
"ru",
"uk-ua",
"id",
"ml",
"my",
"pl",
"de",
"nl",
"zh-tw",
"uz",
"sr",
];
const isLongLocale = locale ? longLocales.includes(locale) : false;
// filter out hidden stats defined by user & create the text nodes
const statItems = Object.keys(STATS)
.filter((key) => !hide.includes(key))
.map((key, index) => {
// @ts-ignore
const stats = STATS[key];
.map((key, index) =>
// create the text nodes, and pass index so that we can calculate the line spacing
return createTextNode({
icon: stats.icon,
label: stats.label,
value: stats.value,
id: stats.id,
unitSymbol: stats.unitSymbol,
createTextNode({
icon: STATS[key].icon,
label: STATS[key].label,
value: STATS[key].value,
id: STATS[key].id,
unitSymbol: STATS[key].unitSymbol,
index,
showIcons: show_icons,
shiftValuePos: 79.01 + (isLongLocale ? 50 : 0),
bold: text_bold,
numberFormat: number_format,
numberPrecision: number_precision,
});
});
number_format,
}),
);
if (statItems.length === 0 && hide_rank) {
throw new CustomError(
@@ -568,16 +514,12 @@ const renderStatsCard = (stats, options = {}) => {
const labels = Object.keys(STATS)
.filter((key) => !hide.includes(key))
.map((key) => {
// @ts-ignore
const stats = STATS[key];
if (key === "commits") {
return `${i18n.t("statcard.commits")} ${getTotalCommitsYearLabel(
include_all_commits,
commits_year,
i18n,
)} : ${stats.value}`;
return `${i18n.t("statcard.commits")} ${
include_all_commits ? "" : `in ${new Date().getFullYear()}`
} : ${STATS[key].value}`;
}
return `${stats.label}: ${stats.value}`;
return `${STATS[key].label}: ${STATS[key].value}`;
})
.join(", ");
+7 -8
View File
@@ -1,15 +1,16 @@
// @ts-check
import { Card } from "../common/Card.js";
import { getCardColors } from "../common/color.js";
import { formatBytes } from "../common/fmt.js";
import { createProgressNode } from "../common/createProgressNode.js";
import { I18n } from "../common/I18n.js";
import { chunkArray, clampValue, lowercaseTrim } from "../common/ops.js";
import {
createProgressNode,
chunkArray,
clampValue,
flexLayout,
getCardColors,
lowercaseTrim,
measureText,
} from "../common/render.js";
formatBytes,
} from "../common/utils.js";
import { langCardLocales } from "../translations.js";
const DEFAULT_CARD_WIDTH = 300;
@@ -179,7 +180,6 @@ const trimTopLanguages = (topLangs, langs_count, hide) => {
// while filtering out
if (hide) {
hide.forEach((langName) => {
// @ts-ignore
langsToHide[lowercaseTrim(langName)] = true;
});
}
@@ -188,7 +188,6 @@ const trimTopLanguages = (topLangs, langs_count, hide) => {
langs = langs
.sort((a, b) => b.size - a.size)
.filter((lang) => {
// @ts-ignore
return !langsToHide[lowercaseTrim(lang.name)];
})
.slice(0, langsCount);
-3
View File
@@ -20,12 +20,10 @@ export type StatCardOptions = CommonOptions & {
card_width: number;
hide_rank: boolean;
include_all_commits: boolean;
commits_year: number;
line_height: number | string;
custom_title: string;
disable_animations: boolean;
number_format: string;
number_precision: number;
ring_color: string;
text_bold: boolean;
rank_icon: RankIcon;
@@ -52,7 +50,6 @@ export type TopLangOptions = CommonOptions & {
export type WakaTimeOptions = CommonOptions & {
hide_title: boolean;
hide: string[];
card_width: number;
line_height: string;
hide_progress: boolean;
custom_title: string;
+30 -61
View File
@@ -1,10 +1,13 @@
// @ts-check
import { Card } from "../common/Card.js";
import { getCardColors } from "../common/color.js";
import { createProgressNode } from "../common/createProgressNode.js";
import { I18n } from "../common/I18n.js";
import { clampValue, lowercaseTrim } from "../common/ops.js";
import { createProgressNode, flexLayout } from "../common/render.js";
import {
clampValue,
flexLayout,
getCardColors,
lowercaseTrim,
} from "../common/utils.js";
import { wakatimeCardLocales } from "../translations.js";
/** Import language colors.
@@ -18,15 +21,6 @@ import { createRequire } from "module";
const require = createRequire(import.meta.url);
const languageColors = require("../common/languageColors.json"); // now works
const DEFAULT_CARD_WIDTH = 495;
const MIN_CARD_WIDTH = 250;
const COMPACT_LAYOUT_MIN_WIDTH = 400;
const DEFAULT_LINE_HEIGHT = 25;
const PROGRESSBAR_PADDING = 130;
const HIDDEN_PROGRESSBAR_PADDING = 170;
const COMPACT_LAYOUT_PROGRESSBAR_PADDING = 25;
const TOTAL_TEXT_WIDTH = 275;
/**
* Creates the no coding activity SVG node.
*
@@ -70,7 +64,6 @@ const formatLanguageValue = ({ display_format, lang }) => {
* @returns {string} The compact layout language SVG node.
*/
const createCompactLangNode = ({ lang, x, y, display_format }) => {
// @ts-ignore
const color = languageColors[lang.name] || "#858585";
const value = formatLanguageValue({ display_format, lang });
@@ -91,21 +84,22 @@ const createCompactLangNode = ({ lang, x, y, display_format }) => {
* @param {WakaTimeLang[]} args.langs The language objects.
* @param {number} args.y The y position of the language node.
* @param {"time" | "percent"} args.display_format The display format of the language node.
* @param {number} args.card_width Width in px of the card.
* @returns {string[]} The language text node items.
*/
const createLanguageTextNode = ({ langs, y, display_format, card_width }) => {
const LEFT_X = 25;
const RIGHT_X_BASE = 230;
const rightOffset = (card_width - DEFAULT_CARD_WIDTH) / 2;
const RIGHT_X = RIGHT_X_BASE + rightOffset;
const createLanguageTextNode = ({ langs, y, display_format }) => {
return langs.map((lang, index) => {
const isLeft = index % 2 === 0;
if (index % 2 === 0) {
return createCompactLangNode({
lang,
x: 25,
y: 12.5 * index + y,
display_format,
});
}
return createCompactLangNode({
lang,
x: isLeft ? LEFT_X : RIGHT_X,
y: y + DEFAULT_LINE_HEIGHT * Math.floor(index / 2),
x: 230,
y: 12.5 + 12.5 * index,
display_format,
});
});
@@ -123,7 +117,6 @@ const createLanguageTextNode = ({ langs, y, display_format, card_width }) => {
* @param {boolean=} args.hideProgress Whether to hide the progress bar.
* @param {string} args.progressBarColor The color of the progress bar.
* @param {string} args.progressBarBackgroundColor The color of the progress bar background.
* @param {number} args.progressBarWidth The width of the progress bar.
* @returns {string} The text SVG node.
*/
const createTextNode = ({
@@ -135,9 +128,9 @@ const createTextNode = ({
hideProgress,
progressBarColor,
progressBarBackgroundColor,
progressBarWidth,
}) => {
const staggerDelay = (index + 3) * 150;
const cardProgress = hideProgress
? null
: createProgressNode({
@@ -145,7 +138,7 @@ const createTextNode = ({
y: 4,
progress: percent,
color: progressBarColor,
width: progressBarWidth,
width: 220,
// @ts-ignore
name: label,
progressBarBackgroundColor,
@@ -157,7 +150,7 @@ const createTextNode = ({
<text class="stat bold" y="12.5" data-testid="${id}">${label}:</text>
<text
class="stat"
x="${hideProgress ? HIDDEN_PROGRESSBAR_PADDING : PROGRESSBAR_PADDING + progressBarWidth}"
x="${hideProgress ? 170 : 350}"
y="12.5"
>${value}</text>
${cardProgress}
@@ -213,24 +206,6 @@ const getStyles = ({
`;
};
/**
* Normalize incoming width (string or number) and clamp to minimum.
*
* @param {Object} args The function arguments.
* @param {WakaTimeOptions["layout"] | undefined} args.layout The incoming layout value.
* @param {number|undefined} args.value The incoming width value.
* @returns {number} The normalized width value.
*/
const normalizeCardWidth = ({ value, layout }) => {
if (value === undefined || value === null || isNaN(value)) {
return DEFAULT_CARD_WIDTH;
}
return Math.max(
layout === "compact" ? COMPACT_LAYOUT_MIN_WIDTH : MIN_CARD_WIDTH,
value,
);
};
/**
* @typedef {import('../fetchers/types').WakaTimeData} WakaTimeData
* @typedef {import('./types').WakaTimeOptions} WakaTimeOptions
@@ -248,9 +223,8 @@ const renderWakatimeCard = (stats = {}, options = { hide: [] }) => {
const {
hide_title = false,
hide_border = false,
card_width,
hide,
line_height = DEFAULT_LINE_HEIGHT,
line_height = 25,
title_color,
icon_color,
text_color,
@@ -267,8 +241,6 @@ const renderWakatimeCard = (stats = {}, options = { hide: [] }) => {
disable_animations,
} = options;
const normalizedWidth = normalizeCardWidth({ value: card_width, layout });
const shouldHideLangs = Array.isArray(hide) && hide.length > 0;
if (shouldHideLangs) {
const languagesToHide = new Set(hide.map((lang) => lowercaseTrim(lang)));
@@ -317,22 +289,21 @@ const renderWakatimeCard = (stats = {}, options = { hide: [] }) => {
let finalLayout = "";
let width = 440;
// RENDER COMPACT LAYOUT
if (layout === "compact") {
const width = normalizedWidth - 5;
height =
90 + Math.round(filteredLanguages.length / 2) * DEFAULT_LINE_HEIGHT;
width = width + 50;
height = 90 + Math.round(filteredLanguages.length / 2) * 25;
// progressOffset holds the previous language's width and used to offset the next language
// so that we can stack them one after another, like this: [--][----][---]
let progressOffset = 0;
const compactProgressBar = filteredLanguages
.map((language) => {
const progress =
((width - COMPACT_LAYOUT_PROGRESSBAR_PADDING) * language.percent) /
100;
// const progress = (width * lang.percent) / 100;
const progress = ((width - 25) * language.percent) / 100;
// @ts-ignore
const languageColor = languageColors[language.name] || "#858585";
const output = `
@@ -353,7 +324,7 @@ const renderWakatimeCard = (stats = {}, options = { hide: [] }) => {
finalLayout = `
<mask id="rect-mask">
<rect x="${COMPACT_LAYOUT_PROGRESSBAR_PADDING}" y="0" width="${width - 2 * COMPACT_LAYOUT_PROGRESSBAR_PADDING}" height="8" fill="white" rx="5" />
<rect x="25" y="0" width="${width - 50}" height="8" fill="white" rx="5" />
</mask>
${compactProgressBar}
${
@@ -362,7 +333,6 @@ const renderWakatimeCard = (stats = {}, options = { hide: [] }) => {
y: 25,
langs: filteredLanguages,
display_format,
card_width: normalizedWidth,
}).join("")
: noCodingActivityNode({
// @ts-ignore
@@ -390,7 +360,6 @@ const renderWakatimeCard = (stats = {}, options = { hide: [] }) => {
// @ts-ignore
progressBarBackgroundColor: textColor,
hideProgress: hide_progress,
progressBarWidth: normalizedWidth - TOTAL_TEXT_WIDTH,
});
})
: [
@@ -423,7 +392,7 @@ const renderWakatimeCard = (stats = {}, options = { hide: [] }) => {
const card = new Card({
customTitle: custom_title,
defaultTitle: titleText,
width: normalizedWidth,
width: 495,
height,
border_radius,
colors: {
+15 -17
View File
@@ -1,25 +1,23 @@
// @ts-check
import { encodeHTML } from "./html.js";
import { flexLayout } from "./render.js";
import { encodeHTML, flexLayout } from "./utils.js";
class Card {
/**
* Creates a new card instance.
*
* @param {object} args Card arguments.
* @param {number=} args.width Card width.
* @param {number=} args.height Card height.
* @param {number=} args.border_radius Card border radius.
* @param {string=} args.customTitle Card custom title.
* @param {string=} args.defaultTitle Card default title.
* @param {string=} args.titlePrefixIcon Card title prefix icon.
* @param {object} [args.colors={}] Card colors arguments.
* @param {string=} args.colors.titleColor Card title color.
* @param {string=} args.colors.textColor Card text color.
* @param {string=} args.colors.iconColor Card icon color.
* @param {string|string[]=} args.colors.bgColor Card background color.
* @param {string=} args.colors.borderColor Card border color.
* @param {number?=} args.width Card width.
* @param {number?=} args.height Card height.
* @param {number?=} args.border_radius Card border radius.
* @param {string?=} args.customTitle Card custom title.
* @param {string?=} args.defaultTitle Card default title.
* @param {string?=} args.titlePrefixIcon Card title prefix icon.
* @param {object?=} args.colors Card colors arguments.
* @param {string} args.colors.titleColor Card title color.
* @param {string} args.colors.textColor Card text color.
* @param {string} args.colors.iconColor Card icon color.
* @param {string|Array} args.colors.bgColor Card background color.
* @param {string} args.colors.borderColor Card border color.
* @returns {Card} Card instance.
*/
constructor({
width = 100,
@@ -140,7 +138,7 @@ class Card {
transform="translate(${this.paddingX}, ${this.paddingY})"
>
${flexLayout({
items: [this.titlePrefixIcon ? prefixIcon : "", titleText],
items: [this.titlePrefixIcon && prefixIcon, titleText],
gap: 25,
}).join("")}
</g>
+1 -3
View File
@@ -1,5 +1,3 @@
// @ts-check
const FALLBACK_LOCALE = "en";
/**
@@ -11,7 +9,7 @@ class I18n {
*
* @param {Object} options Options.
* @param {string=} options.locale Locale.
* @param {any} options.translations Translations.
* @param {Object} options.translations Translations.
*/
constructor({ locale, translations }) {
this.locale = locale || FALLBACK_LOCALE;
-69
View File
@@ -1,69 +0,0 @@
// @ts-check
import { renderError } from "./render.js";
import { blacklist } from "./blacklist.js";
import { whitelist, gistWhitelist } from "./envs.js";
const NOT_WHITELISTED_USERNAME_MESSAGE = "This username is not whitelisted";
const NOT_WHITELISTED_GIST_MESSAGE = "This gist ID is not whitelisted";
const BLACKLISTED_MESSAGE = "This username is blacklisted";
/**
* Guards access using whitelist/blacklist.
*
* @param {Object} args The parameters object.
* @param {any} args.res The response object.
* @param {string} args.id Resource identifier (username or gist id).
* @param {"username"|"gist"|"wakatime"} args.type The type of identifier.
* @param {{ title_color?: string, text_color?: string, bg_color?: string, border_color?: string, theme?: string }} args.colors Color options for the error card.
* @returns {{ isPassed: boolean, result?: any }} The result object indicating success or failure.
*/
const guardAccess = ({ res, id, type, colors }) => {
if (!["username", "gist", "wakatime"].includes(type)) {
throw new Error(
'Invalid type. Expected "username", "gist", or "wakatime".',
);
}
const currentWhitelist = type === "gist" ? gistWhitelist : whitelist;
const notWhitelistedMsg =
type === "gist"
? NOT_WHITELISTED_GIST_MESSAGE
: NOT_WHITELISTED_USERNAME_MESSAGE;
if (Array.isArray(currentWhitelist) && !currentWhitelist.includes(id)) {
const result = res.send(
renderError({
message: notWhitelistedMsg,
secondaryMessage: "Please deploy your own instance",
renderOptions: {
...colors,
show_repo_link: false,
},
}),
);
return { isPassed: false, result };
}
if (
type === "username" &&
currentWhitelist === undefined &&
blacklist.includes(id)
) {
const result = res.send(
renderError({
message: BLACKLISTED_MESSAGE,
secondaryMessage: "Please deploy your own instance",
renderOptions: {
...colors,
show_repo_link: false,
},
}),
);
return { isPassed: false, result };
}
return { isPassed: true };
};
export { guardAccess };
-153
View File
@@ -1,153 +0,0 @@
// @ts-check
import { clampValue } from "./ops.js";
const MIN = 60;
const HOUR = 60 * MIN;
const DAY = 24 * HOUR;
/**
* Common durations in seconds.
*/
const DURATIONS = {
ONE_MINUTE: MIN,
FIVE_MINUTES: 5 * MIN,
TEN_MINUTES: 10 * MIN,
FIFTEEN_MINUTES: 15 * MIN,
THIRTY_MINUTES: 30 * MIN,
TWO_HOURS: 2 * HOUR,
FOUR_HOURS: 4 * HOUR,
SIX_HOURS: 6 * HOUR,
EIGHT_HOURS: 8 * HOUR,
TWELVE_HOURS: 12 * HOUR,
ONE_DAY: DAY,
TWO_DAY: 2 * DAY,
SIX_DAY: 6 * DAY,
TEN_DAY: 10 * DAY,
};
/**
* Common cache TTL values in seconds.
*/
const CACHE_TTL = {
STATS_CARD: {
DEFAULT: DURATIONS.ONE_DAY,
MIN: DURATIONS.TWELVE_HOURS,
MAX: DURATIONS.TWO_DAY,
},
TOP_LANGS_CARD: {
DEFAULT: DURATIONS.SIX_DAY,
MIN: DURATIONS.TWO_DAY,
MAX: DURATIONS.TEN_DAY,
},
PIN_CARD: {
DEFAULT: DURATIONS.TEN_DAY,
MIN: DURATIONS.ONE_DAY,
MAX: DURATIONS.TEN_DAY,
},
GIST_CARD: {
DEFAULT: DURATIONS.TWO_DAY,
MIN: DURATIONS.ONE_DAY,
MAX: DURATIONS.TEN_DAY,
},
WAKATIME_CARD: {
DEFAULT: DURATIONS.ONE_DAY,
MIN: DURATIONS.TWELVE_HOURS,
MAX: DURATIONS.TWO_DAY,
},
ERROR: DURATIONS.TEN_MINUTES,
};
/**
* Resolves the cache seconds based on the requested, default, min, and max values.
*
* @param {Object} args The parameters object.
* @param {number} args.requested The requested cache seconds.
* @param {number} args.def The default cache seconds.
* @param {number} args.min The minimum cache seconds.
* @param {number} args.max The maximum cache seconds.
* @returns {number} The resolved cache seconds.
*/
const resolveCacheSeconds = ({ requested, def, min, max }) => {
let cacheSeconds = clampValue(isNaN(requested) ? def : requested, min, max);
if (process.env.CACHE_SECONDS) {
const envCacheSeconds = parseInt(process.env.CACHE_SECONDS, 10);
if (!isNaN(envCacheSeconds)) {
cacheSeconds = envCacheSeconds;
}
}
return cacheSeconds;
};
/**
* Disables caching by setting appropriate headers on the response object.
*
* @param {any} res The response object.
*/
const disableCaching = (res) => {
// Disable caching for browsers, shared caches/CDNs, and GitHub Camo.
res.setHeader(
"Cache-Control",
"no-cache, no-store, must-revalidate, max-age=0, s-maxage=0",
);
res.setHeader("Pragma", "no-cache");
res.setHeader("Expires", "0");
};
/**
* Sets the Cache-Control headers on the response object.
*
* @param {any} res The response object.
* @param {number} cacheSeconds The cache seconds to set in the headers.
*/
const setCacheHeaders = (res, cacheSeconds) => {
if (cacheSeconds < 1 || process.env.NODE_ENV === "development") {
disableCaching(res);
return;
}
res.setHeader(
"Cache-Control",
`max-age=${cacheSeconds}, ` +
`s-maxage=${cacheSeconds}, ` +
`stale-while-revalidate=${DURATIONS.ONE_DAY}`,
);
};
/**
* Sets the Cache-Control headers for error responses on the response object.
*
* @param {any} res The response object.
*/
const setErrorCacheHeaders = (res) => {
const envCacheSeconds = process.env.CACHE_SECONDS
? parseInt(process.env.CACHE_SECONDS, 10)
: NaN;
if (
(!isNaN(envCacheSeconds) && envCacheSeconds < 1) ||
process.env.NODE_ENV === "development"
) {
disableCaching(res);
return;
}
// Use lower cache period for errors.
res.setHeader(
"Cache-Control",
`max-age=${CACHE_TTL.ERROR}, ` +
`s-maxage=${CACHE_TTL.ERROR}, ` +
`stale-while-revalidate=${DURATIONS.ONE_DAY}`,
);
};
export {
resolveCacheSeconds,
setCacheHeaders,
setErrorCacheHeaders,
DURATIONS,
CACHE_TTL,
};
-144
View File
@@ -1,144 +0,0 @@
// @ts-check
import { themes } from "../../themes/index.js";
/**
* Checks if a string is a valid hex color.
*
* @param {string} hexColor String to check.
* @returns {boolean} True if the given string is a valid hex color.
*/
const isValidHexColor = (hexColor) => {
return new RegExp(
/^([A-Fa-f0-9]{8}|[A-Fa-f0-9]{6}|[A-Fa-f0-9]{3}|[A-Fa-f0-9]{4})$/,
).test(hexColor);
};
/**
* Check if the given string is a valid gradient.
*
* @param {string[]} colors Array of colors.
* @returns {boolean} True if the given string is a valid gradient.
*/
const isValidGradient = (colors) => {
return (
colors.length > 2 &&
colors.slice(1).every((color) => isValidHexColor(color))
);
};
/**
* Retrieves a gradient if color has more than one valid hex codes else a single color.
*
* @param {string} color The color to parse.
* @param {string | string[]} fallbackColor The fallback color.
* @returns {string | string[]} The gradient or color.
*/
const fallbackColor = (color, fallbackColor) => {
let gradient = null;
let colors = color ? color.split(",") : [];
if (colors.length > 1 && isValidGradient(colors)) {
gradient = colors;
}
return (
(gradient ? gradient : isValidHexColor(color) && `#${color}`) ||
fallbackColor
);
};
/**
* Object containing card colors.
* @typedef {{
* titleColor: string;
* iconColor: string;
* textColor: string;
* bgColor: string | string[];
* borderColor: string;
* ringColor: string;
* }} CardColors
*/
/**
* Returns theme based colors with proper overrides and defaults.
*
* @param {Object} args Function arguments.
* @param {string=} args.title_color Card title color.
* @param {string=} args.text_color Card text color.
* @param {string=} args.icon_color Card icon color.
* @param {string=} args.bg_color Card background color.
* @param {string=} args.border_color Card border color.
* @param {string=} args.ring_color Card ring color.
* @param {string=} args.theme Card theme.
* @returns {CardColors} Card colors.
*/
const getCardColors = ({
title_color,
text_color,
icon_color,
bg_color,
border_color,
ring_color,
theme,
}) => {
const defaultTheme = themes["default"];
const isThemeProvided = theme !== null && theme !== undefined;
// @ts-ignore
const selectedTheme = isThemeProvided ? themes[theme] : defaultTheme;
const defaultBorderColor =
"border_color" in selectedTheme
? selectedTheme.border_color
: // @ts-ignore
defaultTheme.border_color;
// get the color provided by the user else the theme color
// finally if both colors are invalid fallback to default theme
const titleColor = fallbackColor(
title_color || selectedTheme.title_color,
"#" + defaultTheme.title_color,
);
// get the color provided by the user else the theme color
// finally if both colors are invalid we use the titleColor
const ringColor = fallbackColor(
// @ts-ignore
ring_color || selectedTheme.ring_color,
titleColor,
);
const iconColor = fallbackColor(
icon_color || selectedTheme.icon_color,
"#" + defaultTheme.icon_color,
);
const textColor = fallbackColor(
text_color || selectedTheme.text_color,
"#" + defaultTheme.text_color,
);
const bgColor = fallbackColor(
bg_color || selectedTheme.bg_color,
"#" + defaultTheme.bg_color,
);
const borderColor = fallbackColor(
border_color || defaultBorderColor,
"#" + defaultBorderColor,
);
if (
typeof titleColor !== "string" ||
typeof textColor !== "string" ||
typeof ringColor !== "string" ||
typeof iconColor !== "string" ||
typeof borderColor !== "string"
) {
throw new Error(
"Unexpected behavior, all colors except background should be string.",
);
}
return { titleColor, iconColor, textColor, bgColor, borderColor, ringColor };
};
export { isValidHexColor, isValidGradient, getCardColors };
+46
View File
@@ -0,0 +1,46 @@
// @ts-check
import { clampValue } from "./utils.js";
/**
* Create a node to indicate progress in percentage along a horizontal line.
*
* @param {Object} createProgressNodeParams Object that contains the createProgressNode parameters.
* @param {number} createProgressNodeParams.x X-axis position.
* @param {number} createProgressNodeParams.y Y-axis position.
* @param {number} createProgressNodeParams.width Width of progress bar.
* @param {string} createProgressNodeParams.color Progress color.
* @param {number} createProgressNodeParams.progress Progress value.
* @param {string} createProgressNodeParams.progressBarBackgroundColor Progress bar bg color.
* @param {number} createProgressNodeParams.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 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>
`;
};
export { createProgressNode };
export default createProgressNode;
-84
View File
@@ -1,84 +0,0 @@
// @ts-check
/**
* @type {string} A general message to ask user to try again later.
*/
const TRY_AGAIN_LATER = "Please try again later";
/**
* @type {Object<string, string>} A map of error types to secondary error messages.
*/
const SECONDARY_ERROR_MESSAGES = {
MAX_RETRY:
"You can deploy own instance or wait until public will be no longer limited",
NO_TOKENS:
"Please add an env variable called PAT_1 with your GitHub API token in vercel",
USER_NOT_FOUND: "Make sure the provided username is not an organization",
GRAPHQL_ERROR: TRY_AGAIN_LATER,
GITHUB_REST_API_ERROR: TRY_AGAIN_LATER,
WAKATIME_USER_NOT_FOUND: "Make sure you have a public WakaTime profile",
};
/**
* Custom error class to handle custom GRS errors.
*/
class CustomError extends Error {
/**
* Custom error constructor.
*
* @param {string} message Error message.
* @param {string} type Error type.
*/
constructor(message, type) {
super(message);
this.type = type;
this.secondaryMessage = SECONDARY_ERROR_MESSAGES[type] || type;
}
static MAX_RETRY = "MAX_RETRY";
static NO_TOKENS = "NO_TOKENS";
static USER_NOT_FOUND = "USER_NOT_FOUND";
static GRAPHQL_ERROR = "GRAPHQL_ERROR";
static GITHUB_REST_API_ERROR = "GITHUB_REST_API_ERROR";
static WAKATIME_ERROR = "WAKATIME_ERROR";
}
/**
* Missing query parameter class.
*/
class MissingParamError extends Error {
/**
* Missing query parameter error constructor.
*
* @param {string[]} missedParams An array of missing parameters names.
* @param {string=} secondaryMessage Optional secondary message to display.
*/
constructor(missedParams, secondaryMessage) {
const msg = `Missing params ${missedParams
.map((p) => `"${p}"`)
.join(", ")} make sure you pass the parameters in URL`;
super(msg);
this.missedParams = missedParams;
this.secondaryMessage = secondaryMessage;
}
}
/**
* Retrieve secondary message from an error object.
*
* @param {Error} err The error object.
* @returns {string|undefined} The secondary message if available, otherwise undefined.
*/
const retrieveSecondaryMessage = (err) => {
return "secondaryMessage" in err && typeof err.secondaryMessage === "string"
? err.secondaryMessage
: undefined;
};
export {
CustomError,
MissingParamError,
SECONDARY_ERROR_MESSAGES,
TRY_AGAIN_LATER,
retrieveSecondaryMessage,
};
-90
View File
@@ -1,90 +0,0 @@
// @ts-check
import wrap from "word-wrap";
import { encodeHTML } from "./html.js";
/**
* Retrieves num with suffix k(thousands) precise to given decimal places.
*
* @param {number} num The number to format.
* @param {number=} precision The number of decimal places to include.
* @returns {string|number} The formatted number.
*/
const kFormatter = (num, precision) => {
const abs = Math.abs(num);
const sign = Math.sign(num);
if (typeof precision === "number" && !isNaN(precision)) {
return (sign * (abs / 1000)).toFixed(precision) + "k";
}
if (abs < 1000) {
return sign * abs;
}
return sign * parseFloat((abs / 1000).toFixed(1)) + "k";
};
/**
* Convert bytes to a human-readable string representation.
*
* @param {number} bytes The number of bytes to convert.
* @returns {string} The human-readable representation of bytes.
* @throws {Error} If bytes is negative or too large.
*/
const formatBytes = (bytes) => {
if (bytes < 0) {
throw new Error("Bytes must be a non-negative number");
}
if (bytes === 0) {
return "0 B";
}
const sizes = ["B", "KB", "MB", "GB", "TB", "PB", "EB"];
const base = 1024;
const i = Math.floor(Math.log(bytes) / Math.log(base));
if (i >= sizes.length) {
throw new Error("Bytes is too large to convert to a human-readable string");
}
return `${(bytes / Math.pow(base, i)).toFixed(1)} ${sizes[i]}`;
};
/**
* Split text over multiple lines based on the card width.
*
* @param {string} text Text to split.
* @param {number} width Line width in number of characters.
* @param {number} maxLines Maximum number of lines.
* @returns {string[]} Array of lines.
*/
const wrapTextMultiline = (text, width = 59, maxLines = 3) => {
const fullWidthComma = "";
const encoded = encodeHTML(text);
const isChinese = encoded.includes(fullWidthComma);
let wrapped = [];
if (isChinese) {
wrapped = encoded.split(fullWidthComma); // Chinese full punctuation
} else {
wrapped = wrap(encoded, {
width,
}).split("\n"); // Split wrapped lines to get an array of lines
}
const lines = wrapped.map((line) => line.trim()).slice(0, maxLines); // Only consider maxLines lines
// Add "..." to the last line if the text exceeds maxLines
if (wrapped.length > maxLines) {
lines[maxLines - 1] += "...";
}
// Remove empty lines if text fits in less than maxLines lines
const multiLineText = lines.filter(Boolean);
return multiLineText;
};
export { kFormatter, formatBytes, wrapTextMultiline };
-19
View File
@@ -1,19 +0,0 @@
// @ts-check
/**
* Encode string as HTML.
*
* @see https://stackoverflow.com/a/48073476/10629172
*
* @param {string} str String to encode.
* @returns {string} Encoded string.
*/
const encodeHTML = (str) => {
return str
.replace(/[\u00A0-\u9999<>&](?!#)/gim, (i) => {
return "&#" + i.charCodeAt(0) + ";";
})
.replace(/\u0008/gim, "");
};
export { encodeHTML };
-21
View File
@@ -1,21 +0,0 @@
// @ts-check
import axios from "axios";
/**
* Send GraphQL request to GitHub API.
*
* @param {import('axios').AxiosRequestConfig['data']} data Request data.
* @param {import('axios').AxiosRequestConfig['headers']} headers Request headers.
* @returns {Promise<any>} Request response.
*/
const request = (data, headers) => {
return axios({
url: "https://api.github.com/graphql",
method: "post",
headers,
data,
});
};
export { request };
-2
View File
@@ -1,5 +1,3 @@
// @ts-check
const icons = {
star: `<path fill-rule="evenodd" d="M8 .25a.75.75 0 01.673.418l1.882 3.815 4.21.612a.75.75 0 01.416 1.279l-3.046 2.97.719 4.192a.75.75 0 01-1.088.791L8 12.347l-3.766 1.98a.75.75 0 01-1.088-.79l.72-4.194L.818 6.374a.75.75 0 01.416-1.28l4.21-.611L7.327.668A.75.75 0 018 .25zm0 2.445L6.615 5.5a.75.75 0 01-.564.41l-3.097.45 2.24 2.184a.75.75 0 01.216.664l-.528 3.084 2.769-1.456a.75.75 0 01.698 0l2.77 1.456-.53-3.084a.75.75 0 01.216-.664l2.24-2.183-3.096-.45a.75.75 0 01-.564-.41L8 2.694v.001z"/>`,
commits: `<path fill-rule="evenodd" d="M1.643 3.143L.427 1.927A.25.25 0 000 2.104V5.75c0 .138.112.25.25.25h3.646a.25.25 0 00.177-.427L2.715 4.215a6.5 6.5 0 11-1.18 4.458.75.75 0 10-1.493.154 8.001 8.001 0 101.6-5.684zM7.75 4a.75.75 0 01.75.75v2.992l2.028.812a.75.75 0 01-.557 1.392l-2.5-1A.75.75 0 017 8.25v-3.5A.75.75 0 017.75 4z"/>`,
+20 -3
View File
@@ -1,13 +1,30 @@
// @ts-check
export { blacklist } from "./blacklist.js";
export { Card } from "./Card.js";
export { createProgressNode } from "./createProgressNode.js";
export { I18n } from "./I18n.js";
export { icons } from "./icons.js";
export { retryer } from "./retryer.js";
export {
ERROR_CARD_LENGTH,
renderError,
encodeHTML,
kFormatter,
isValidHexColor,
parseBoolean,
parseArray,
clampValue,
isValidGradient,
fallbackColor,
request,
flexLayout,
getCardColors,
wrapTextMultiline,
logger,
CONSTANTS,
CustomError,
MissingParamError,
measureText,
} from "./render.js";
lowercaseTrim,
chunkArray,
parseEmojis,
} from "./utils.js";
+1 -9
View File
@@ -31,7 +31,6 @@
"Apollo Guidance Computer": "#0B3D91",
"AppleScript": "#101F1F",
"Arc": "#aa2afe",
"ArkTS": "#0080ff",
"AsciiDoc": "#73a0c5",
"AspectJ": "#a957b0",
"Assembly": "#6E4C13",
@@ -65,7 +64,6 @@
"BrighterScript": "#66AABB",
"Brightscript": "#662D91",
"Browserslist": "#ffd539",
"Bru": "#F4AA41",
"BuildStream": "#006bff",
"C": "#555555",
"C#": "#178600",
@@ -86,7 +84,6 @@
"Cairo": "#ff4a48",
"Cairo Zero": "#ff4a48",
"CameLIGO": "#3be133",
"Cangjie": "#00868B",
"Cap'n Proto": "#c42727",
"Carbon": "#222222",
"Ceylon": "#dfa535",
@@ -169,7 +166,6 @@
"Faust": "#c37240",
"Fennel": "#fff3d7",
"Filebench WML": "#F6B900",
"Flix": "#d44a45",
"Fluent": "#ffcc33",
"Forth": "#341708",
"Fortran": "#4d41b1",
@@ -200,7 +196,6 @@
"Gerber Image": "#d20b00",
"Gherkin": "#5B2063",
"Git Attributes": "#F44D27",
"Git Commit": "#F44D27",
"Git Config": "#F44D27",
"Git Revision List": "#F44D27",
"Gleam": "#ffaff3",
@@ -246,7 +241,6 @@
"HiveQL": "#dce200",
"HolyC": "#ffefaf",
"Hosts File": "#308888",
"Hurl": "#FF0288",
"Hy": "#7790B2",
"IDL": "#a3522f",
"IGOR Pro": "#0000cc",
@@ -298,7 +292,6 @@
"KiCad Layout": "#2f4aab",
"KiCad Legacy Layout": "#2f4aab",
"KiCad Schematic": "#2f4aab",
"KoLmafia ASH": "#B9D9B9",
"Koka": "#215166",
"Kotlin": "#A97BFF",
"LFE": "#4C3023",
@@ -338,6 +331,7 @@
"Markdown": "#083fa1",
"Marko": "#42bff2",
"Mask": "#f97732",
"Mathematica": "#dd1100",
"Max": "#c4a79c",
"Mercury": "#ff2b2b",
"Mermaid": "#ff3670",
@@ -553,7 +547,6 @@
"Talon": "#333333",
"Tcl": "#e4cc98",
"TeX": "#3D6117",
"Teal": "#00B1BC",
"Terra": "#00004c",
"Terraform Template": "#7b42bb",
"TextGrid": "#c8506d",
@@ -601,7 +594,6 @@
"Wikitext": "#fc5757",
"Windows Registry Entries": "#52d5ff",
"Witcher Script": "#ff0000",
"Wolfram Language": "#dd1100",
"Wollok": "#a23738",
"World of Warcraft Addon Data": "#f7e43f",
"Wren": "#383838",
-14
View File
@@ -1,14 +0,0 @@
// @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 };
export default logger;
-124
View File
@@ -1,124 +0,0 @@
// @ts-check
import toEmoji from "emoji-name-map";
/**
* Returns boolean if value is either "true" or "false" else the value as it is.
*
* @param {string | boolean} value The value to parse.
* @returns {boolean | undefined } The parsed value.
*/
const parseBoolean = (value) => {
if (typeof value === "boolean") {
return value;
}
if (typeof value === "string") {
if (value.toLowerCase() === "true") {
return true;
} else if (value.toLowerCase() === "false") {
return false;
}
}
return undefined;
};
/**
* Parse string to array of strings.
*
* @param {string} str The string to parse.
* @returns {string[]} The array of strings.
*/
const parseArray = (str) => {
if (!str) {
return [];
}
return str.split(",");
};
/**
* Clamp the given number between the given range.
*
* @param {number} number The number to clamp.
* @param {number} min The minimum value.
* @param {number} max The maximum value.
* @returns {number} The clamped number.
*/
const clampValue = (number, min, max) => {
// @ts-ignore
if (Number.isNaN(parseInt(number, 10))) {
return min;
}
return Math.max(min, Math.min(number, max));
};
/**
* Lowercase and trim string.
*
* @param {string} name String to lowercase and trim.
* @returns {string} Lowercased and trimmed string.
*/
const lowercaseTrim = (name) => name.toLowerCase().trim();
/**
* Split array of languages in two columns.
*
* @template T Language object.
* @param {Array<T>} arr Array of languages.
* @param {number} perChunk Number of languages per column.
* @returns {Array<T>} Array of languages split in two columns.
*/
const chunkArray = (arr, perChunk) => {
return arr.reduce((resultArray, item, index) => {
const chunkIndex = Math.floor(index / perChunk);
if (!resultArray[chunkIndex]) {
// @ts-ignore
resultArray[chunkIndex] = []; // start a new chunk
}
// @ts-ignore
resultArray[chunkIndex].push(item);
return resultArray;
}, []);
};
/**
* Parse emoji from string.
*
* @param {string} str String to parse emoji from.
* @returns {string} String with emoji parsed.
*/
const parseEmojis = (str) => {
if (!str) {
throw new Error("[parseEmoji]: str argument not provided");
}
return str.replace(/:\w+:/gm, (emoji) => {
return toEmoji.get(emoji) || "";
});
};
/**
* Get diff in minutes between two dates.
*
* @param {Date} d1 First date.
* @param {Date} d2 Second date.
* @returns {number} Number of minutes between the two dates.
*/
const dateDiff = (d1, d2) => {
const date1 = new Date(d1);
const date2 = new Date(d2);
const diff = date1.getTime() - date2.getTime();
return Math.round(diff / (1000 * 60));
};
export {
parseBoolean,
parseArray,
clampValue,
lowercaseTrim,
chunkArray,
parseEmojis,
dateDiff,
};
-239
View File
@@ -1,239 +0,0 @@
// @ts-check
import { SECONDARY_ERROR_MESSAGES, TRY_AGAIN_LATER } from "./error.js";
import { getCardColors } from "./color.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 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://tiny.one/readme-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,
};
+12 -33
View File
@@ -1,7 +1,4 @@
// @ts-check
import { CustomError } from "./error.js";
import { logger } from "./log.js";
import { CustomError, logger } from "./utils.js";
// Script variables.
@@ -13,50 +10,41 @@ const RETRIES = process.env.NODE_ENV === "test" ? 7 : PATs;
/**
* @typedef {import("axios").AxiosResponse} AxiosResponse Axios response.
* @typedef {(variables: any, token: string, retriesForTests?: number) => Promise<AxiosResponse>} FetcherFunction Fetcher function.
* @typedef {(variables: object, token: string) => Promise<AxiosResponse>} FetcherFunction Fetcher function.
*/
/**
* Try to execute the fetcher function until it succeeds or the max number of retries is reached.
*
* @param {FetcherFunction} fetcher The fetcher function.
* @param {any} variables Object with arguments to pass to the fetcher function.
* @param {object} variables Object with arguments to pass to the fetcher function.
* @param {number} retries How many times to retry.
* @returns {Promise<any>} The response from the fetcher function.
* @returns {Promise<T>} The response from the fetcher function.
*/
const retryer = async (fetcher, variables, retries = 0) => {
if (!RETRIES) {
throw new CustomError("No GitHub API tokens found", CustomError.NO_TOKENS);
}
if (retries > RETRIES) {
throw new CustomError(
"Downtime due to GitHub API rate limiting",
CustomError.MAX_RETRY,
);
}
try {
// try to fetch with the first token since RETRIES is 0 index i'm adding +1
let response = await fetcher(
variables,
// @ts-ignore
process.env[`PAT_${retries + 1}`],
// used in tests for faking rate limit
retries,
);
// react on both type and message-based rate-limit signals.
// https://github.com/anuraghazra/github-readme-stats/issues/4425
const errors = response?.data?.errors;
const errorType = errors?.[0]?.type;
const errorMsg = errors?.[0]?.message || "";
const isRateLimited =
(errors && errorType === "RATE_LIMITED") || /rate limit/i.test(errorMsg);
// prettier-ignore
const isRateExceeded = response.data.errors && response.data.errors[0].type === "RATE_LIMITED";
// if rate limit is hit increase the RETRIES and recursively call the retryer
// with username, and current RETRIES
if (isRateLimited) {
if (isRateExceeded) {
logger.log(`PAT_${retries + 1} Failed`);
retries++;
// directly return from the function
@@ -66,30 +54,21 @@ const retryer = async (fetcher, variables, retries = 0) => {
// finally return the response
return response;
} catch (err) {
/** @type {any} */
const e = err;
// network/unexpected error → let caller treat as failure
if (!e?.response) {
throw e;
}
// prettier-ignore
// also checking for bad credentials if any tokens gets invalidated
const isBadCredential =
e?.response?.data?.message === "Bad credentials";
const isBadCredential = err.response.data && err.response.data.message === "Bad credentials";
const isAccountSuspended =
e?.response?.data?.message === "Sorry. Your account was suspended.";
err.response.data &&
err.response.data.message === "Sorry. Your account was suspended.";
if (isBadCredential || isAccountSuspended) {
logger.log(`PAT_${retries + 1} Failed`);
retries++;
// directly return from the function
return retryer(fetcher, variables, retries);
} else {
return err.response;
}
// HTTP error with a response → return it for caller-side handling
return e.response;
}
};
+655
View File
@@ -0,0 +1,655 @@
// @ts-check
import axios from "axios";
import toEmoji from "emoji-name-map";
import wrap from "word-wrap";
import { themes } from "../../themes/index.js";
const TRY_AGAIN_LATER = "Please try again later";
const SECONDARY_ERROR_MESSAGES = {
MAX_RETRY:
"You can deploy own instance or wait until public will be no longer limited",
NO_TOKENS:
"Please add an env variable called PAT_1 with your GitHub API token in vercel",
USER_NOT_FOUND: "Make sure the provided username is not an organization",
GRAPHQL_ERROR: TRY_AGAIN_LATER,
GITHUB_REST_API_ERROR: TRY_AGAIN_LATER,
WAKATIME_USER_NOT_FOUND: "Make sure you have a public WakaTime profile",
};
/**
* Custom error class to handle custom GRS errors.
*/
class CustomError extends Error {
/**
* @param {string} message Error message.
* @param {string} type Error type.
*/
constructor(message, type) {
super(message);
this.type = type;
this.secondaryMessage = SECONDARY_ERROR_MESSAGES[type] || type;
}
static MAX_RETRY = "MAX_RETRY";
static NO_TOKENS = "NO_TOKENS";
static USER_NOT_FOUND = "USER_NOT_FOUND";
static GRAPHQL_ERROR = "GRAPHQL_ERROR";
static GITHUB_REST_API_ERROR = "GITHUB_REST_API_ERROR";
static WAKATIME_ERROR = "WAKATIME_ERROR";
}
/**
* 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>
`;
};
/**
* 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("");
};
/**
* Retrieves num with suffix k(thousands) precise to 1 decimal if greater than 999.
*
* @param {number} num The number to format.
* @returns {string|number} The formatted number.
*/
const kFormatter = (num) => {
return Math.abs(num) > 999
? Math.sign(num) * parseFloat((Math.abs(num) / 1000).toFixed(1)) + "k"
: Math.sign(num) * Math.abs(num);
};
/**
* Checks if a string is a valid hex color.
*
* @param {string} hexColor String to check.
* @returns {boolean} True if the given string is a valid hex color.
*/
const isValidHexColor = (hexColor) => {
return new RegExp(
/^([A-Fa-f0-9]{8}|[A-Fa-f0-9]{6}|[A-Fa-f0-9]{3}|[A-Fa-f0-9]{4})$/,
).test(hexColor);
};
/**
* Returns boolean if value is either "true" or "false" else the value as it is.
*
* @param {string | boolean} value The value to parse.
* @returns {boolean | undefined } The parsed value.
*/
const parseBoolean = (value) => {
if (typeof value === "boolean") {
return value;
}
if (typeof value === "string") {
if (value.toLowerCase() === "true") {
return true;
} else if (value.toLowerCase() === "false") {
return false;
}
}
return undefined;
};
/**
* Parse string to array of strings.
*
* @param {string} str The string to parse.
* @returns {string[]} The array of strings.
*/
const parseArray = (str) => {
if (!str) {
return [];
}
return str.split(",");
};
/**
* Clamp the given number between the given range.
*
* @param {number} number The number to clamp.
* @param {number} min The minimum value.
* @param {number} max The maximum value.
* @returns {number} The clamped number.
*/
const clampValue = (number, min, max) => {
// @ts-ignore
if (Number.isNaN(parseInt(number, 10))) {
return min;
}
return Math.max(min, Math.min(number, max));
};
/**
* Check if the given string is a valid gradient.
*
* @param {string[]} colors Array of colors.
* @returns {boolean} True if the given string is a valid gradient.
*/
const isValidGradient = (colors) => {
return (
colors.length > 2 &&
colors.slice(1).every((color) => isValidHexColor(color))
);
};
/**
* Retrieves a gradient if color has more than one valid hex codes else a single color.
*
* @param {string} color The color to parse.
* @param {string | string[]} fallbackColor The fallback color.
* @returns {string | string[]} The gradient or color.
*/
const fallbackColor = (color, fallbackColor) => {
let gradient = null;
let colors = color ? color.split(",") : [];
if (colors.length > 1 && isValidGradient(colors)) {
gradient = colors;
}
return (
(gradient ? gradient : isValidHexColor(color) && `#${color}`) ||
fallbackColor
);
};
/**
* @typedef {import('axios').AxiosRequestConfig['data']} AxiosRequestConfigData Axios request data.
* @typedef {import('axios').AxiosRequestConfig['headers']} AxiosRequestConfigHeaders Axios request headers.
*/
/**
* Send GraphQL request to GitHub API.
*
* @param {AxiosRequestConfigData} data Request data.
* @param {AxiosRequestConfigHeaders} headers Request headers.
* @returns {Promise<any>} Request response.
*/
const request = (data, headers) => {
return axios({
url: "https://api.github.com/graphql",
method: "post",
headers,
data,
});
};
/**
* Object containing card colors.
* @typedef {{
* titleColor: string;
* iconColor: string;
* textColor: string;
* bgColor: string | string[];
* borderColor: string;
* ringColor: string;
* }} CardColors
*/
/**
* Returns theme based colors with proper overrides and defaults.
*
* @param {Object} args Function arguments.
* @param {string=} args.title_color Card title color.
* @param {string=} args.text_color Card text color.
* @param {string=} args.icon_color Card icon color.
* @param {string=} args.bg_color Card background color.
* @param {string=} args.border_color Card border color.
* @param {string=} args.ring_color Card ring color.
* @param {string=} args.theme Card theme.
* @param {string=} args.fallbackTheme Fallback theme.
* @returns {CardColors} Card colors.
*/
const getCardColors = ({
title_color,
text_color,
icon_color,
bg_color,
border_color,
ring_color,
theme,
fallbackTheme = "default",
}) => {
const defaultTheme = themes[fallbackTheme];
const selectedTheme = themes[theme] || defaultTheme;
const defaultBorderColor =
selectedTheme.border_color || defaultTheme.border_color;
// get the color provided by the user else the theme color
// finally if both colors are invalid fallback to default theme
const titleColor = fallbackColor(
title_color || selectedTheme.title_color,
"#" + defaultTheme.title_color,
);
// get the color provided by the user else the theme color
// finally if both colors are invalid we use the titleColor
const ringColor = fallbackColor(
ring_color || selectedTheme.ring_color,
titleColor,
);
const iconColor = fallbackColor(
icon_color || selectedTheme.icon_color,
"#" + defaultTheme.icon_color,
);
const textColor = fallbackColor(
text_color || selectedTheme.text_color,
"#" + defaultTheme.text_color,
);
const bgColor = fallbackColor(
bg_color || selectedTheme.bg_color,
"#" + defaultTheme.bg_color,
);
const borderColor = fallbackColor(
border_color || defaultBorderColor,
"#" + defaultBorderColor,
);
if (
typeof titleColor !== "string" ||
typeof textColor !== "string" ||
typeof ringColor !== "string" ||
typeof iconColor !== "string" ||
typeof borderColor !== "string"
) {
throw new Error(
"Unexpected behavior, all colors except background should be string.",
);
}
return { titleColor, iconColor, textColor, bgColor, borderColor, ringColor };
};
// Script parameters.
const ERROR_CARD_LENGTH = 576.5;
/**
* Encode string as HTML.
*
* @see https://stackoverflow.com/a/48073476/10629172
*
* @param {string} str String to encode.
* @returns {string} Encoded string.
*/
const encodeHTML = (str) => {
return str
.replace(/[\u00A0-\u9999<>&](?!#)/gim, (i) => {
return "&#" + i.charCodeAt(0) + ";";
})
.replace(/\u0008/gim, "");
};
const UPSTREAM_API_ERRORS = [
TRY_AGAIN_LATER,
SECONDARY_ERROR_MESSAGES.MAX_RETRY,
];
/**
* Renders error message on the card.
*
* @param {string} message Main error message.
* @param {string} secondaryMessage The secondary error message.
* @param {object} options Function options.
* @param {string=} options.title_color Card title color.
* @param {string=} options.text_color Card text color.
* @param {string=} options.bg_color Card background color.
* @param {string=} options.border_color Card border color.
* @param {string=} options.theme Card theme.
* @param {boolean=} options.show_repo_link Whether to show repo link or not.
* @returns {string} The SVG markup.
*/
const renderError = (message, secondaryMessage = "", options = {}) => {
const {
title_color,
text_color,
bg_color,
border_color,
theme = "default",
show_repo_link = true,
} = options;
// 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://tiny.one/readme-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>
`;
};
/**
* Split text over multiple lines based on the card width.
*
* @param {string} text Text to split.
* @param {number} width Line width in number of characters.
* @param {number} maxLines Maximum number of lines.
* @returns {string[]} Array of lines.
*/
const wrapTextMultiline = (text, width = 59, maxLines = 3) => {
const fullWidthComma = "";
const encoded = encodeHTML(text);
const isChinese = encoded.includes(fullWidthComma);
let wrapped = [];
if (isChinese) {
wrapped = encoded.split(fullWidthComma); // Chinese full punctuation
} else {
wrapped = wrap(encoded, {
width,
}).split("\n"); // Split wrapped lines to get an array of lines
}
const lines = wrapped.map((line) => line.trim()).slice(0, maxLines); // Only consider maxLines lines
// Add "..." to the last line if the text exceeds maxLines
if (wrapped.length > maxLines) {
lines[maxLines - 1] += "...";
}
// Remove empty lines if text fits in less than maxLines lines
const multiLineText = lines.filter(Boolean);
return multiLineText;
};
const noop = () => {};
// return console instance based on the environment
const logger =
process.env.NODE_ENV === "test" ? { log: noop, error: noop } : console;
const MIN = 60;
const HOUR = 60 * MIN;
const DAY = 24 * HOUR;
const CONSTANTS = {
ONE_MINUTE: MIN,
FIVE_MINUTES: 5 * MIN,
TEN_MINUTES: 10 * MIN,
FIFTEEN_MINUTES: 15 * MIN,
THIRTY_MINUTES: 30 * MIN,
TWO_HOURS: 2 * HOUR,
FOUR_HOURS: 4 * HOUR,
SIX_HOURS: 6 * HOUR,
EIGHT_HOURS: 8 * HOUR,
TWELVE_HOURS: 12 * HOUR,
ONE_DAY: DAY,
TWO_DAY: 2 * DAY,
SIX_DAY: 6 * DAY,
TEN_DAY: 10 * DAY,
CARD_CACHE_SECONDS: DAY,
TOP_LANGS_CACHE_SECONDS: 6 * DAY,
PIN_CARD_CACHE_SECONDS: 10 * DAY,
ERROR_CACHE_SECONDS: 10 * MIN,
};
/**
* Missing query parameter class.
*/
class MissingParamError extends Error {
/**
* Missing query parameter error constructor.
*
* @param {string[]} missedParams An array of missing parameters names.
* @param {string=} secondaryMessage Optional secondary message to display.
*/
constructor(missedParams, secondaryMessage) {
const msg = `Missing params ${missedParams
.map((p) => `"${p}"`)
.join(", ")} make sure you pass the parameters in URL`;
super(msg);
this.missedParams = missedParams;
this.secondaryMessage = secondaryMessage;
}
}
/**
* 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
);
};
/**
* Lowercase and trim string.
*
* @param {string} name String to lowercase and trim.
* @returns {string} Lowercased and trimmed string.
*/
const lowercaseTrim = (name) => name.toLowerCase().trim();
/**
* Split array of languages in two columns.
*
* @template T Language object.
* @param {Array<T>} arr Array of languages.
* @param {number} perChunk Number of languages per column.
* @returns {Array<T>} Array of languages split in two columns.
*/
const chunkArray = (arr, perChunk) => {
return arr.reduce((resultArray, item, index) => {
const chunkIndex = Math.floor(index / perChunk);
if (!resultArray[chunkIndex]) {
// @ts-ignore
resultArray[chunkIndex] = []; // start a new chunk
}
// @ts-ignore
resultArray[chunkIndex].push(item);
return resultArray;
}, []);
};
/**
* Parse emoji from string.
*
* @param {string} str String to parse emoji from.
* @returns {string} String with emoji parsed.
*/
const parseEmojis = (str) => {
if (!str) {
throw new Error("[parseEmoji]: str argument not provided");
}
return str.replace(/:\w+:/gm, (emoji) => {
return toEmoji.get(emoji) || "";
});
};
/**
* Get diff in minutes between two dates.
*
* @param {Date} d1 First date.
* @param {Date} d2 Second date.
* @returns {number} Number of minutes between the two dates.
*/
const dateDiff = (d1, d2) => {
const date1 = new Date(d1);
const date2 = new Date(d2);
const diff = date1.getTime() - date2.getTime();
return Math.round(diff / (1000 * 60));
};
/**
* Convert bytes to a human-readable string representation.
*
* @param {number} bytes The number of bytes to convert.
* @returns {string} The human-readable representation of bytes.
* @throws {Error} If bytes is negative or too large.
*/
const formatBytes = (bytes) => {
if (bytes < 0) {
throw new Error("Bytes must be a non-negative number");
}
if (bytes === 0) {
return "0 B";
}
const sizes = ["B", "KB", "MB", "GB", "TB", "PB", "EB"];
const base = 1024;
const i = Math.floor(Math.log(bytes) / Math.log(base));
if (i >= sizes.length) {
throw new Error("Bytes is too large to convert to a human-readable string");
}
return `${(bytes / Math.pow(base, i)).toFixed(1)} ${sizes[i]}`;
};
export {
ERROR_CARD_LENGTH,
renderError,
createLanguageNode,
iconWithLabel,
encodeHTML,
kFormatter,
isValidHexColor,
parseBoolean,
parseArray,
clampValue,
isValidGradient,
fallbackColor,
request,
flexLayout,
getCardColors,
wrapTextMultiline,
logger,
CONSTANTS,
CustomError,
MissingParamError,
measureText,
lowercaseTrim,
chunkArray,
parseEmojis,
dateDiff,
formatBytes,
};
@@ -1,5 +1,3 @@
// @ts-check
const whitelist = process.env.WHITELIST
? process.env.WHITELIST.split(",")
: undefined;
@@ -8,8 +6,5 @@ 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 };
export { whitelist, gistWhitelist };
export default whitelist;
+8 -8
View File
@@ -1,8 +1,12 @@
// @ts-check
import { request, MissingParamError } from "../common/utils.js";
import { retryer } from "../common/retryer.js";
import { MissingParamError } from "../common/error.js";
import { request } from "../common/http.js";
/**
* @typedef {import('axios').AxiosRequestHeaders} AxiosRequestHeaders Axios request headers.
* @typedef {import('axios').AxiosResponse} AxiosResponse Axios response.
*/
const QUERY = `
query gistInfo($gistName: String!) {
@@ -31,9 +35,9 @@ query gistInfo($gistName: String!) {
/**
* Gist data fetcher.
*
* @param {object} variables Fetcher variables.
* @param {AxiosRequestHeaders} variables Fetcher variables.
* @param {string} token GitHub token.
* @returns {Promise<import('axios').AxiosResponse>} The response.
* @returns {Promise<AxiosResponse>} The response.
*/
const fetcher = async (variables, token) => {
return await request(
@@ -53,9 +57,7 @@ const fetcher = async (variables, token) => {
* @returns {string} Primary language.
*/
const calculatePrimaryLanguage = (files) => {
/** @type {Record<string, number>} */
const languages = {};
for (const file of files) {
if (file.language) {
if (languages[file.language.name]) {
@@ -65,14 +67,12 @@ const calculatePrimaryLanguage = (files) => {
}
}
}
let primaryLanguage = Object.keys(languages)[0];
for (const language in languages) {
if (languages[language] > languages[primaryLanguage]) {
primaryLanguage = language;
}
}
return primaryLanguage;
};
+8 -5
View File
@@ -1,15 +1,18 @@
// @ts-check
import { MissingParamError } from "../common/error.js";
import { request } from "../common/http.js";
import { retryer } from "../common/retryer.js";
import { MissingParamError, request } from "../common/utils.js";
/**
* @typedef {import('axios').AxiosRequestHeaders} AxiosRequestHeaders Axios request headers.
* @typedef {import('axios').AxiosResponse} AxiosResponse Axios response.
*/
/**
* Repo data fetcher.
*
* @param {object} variables Fetcher variables.
* @param {AxiosRequestHeaders} variables Fetcher variables.
* @param {string} token GitHub token.
* @returns {Promise<import('axios').AxiosResponse>} The response.
* @returns {Promise<AxiosResponse>} The response.
*/
const fetcher = (variables, token) => {
return request(
+39 -47
View File
@@ -1,15 +1,16 @@
// @ts-check
import axios from "axios";
import * as dotenv from "dotenv";
import githubUsernameRegex from "github-username-regex";
import { calculateRank } from "../calculateRank.js";
import { retryer } from "../common/retryer.js";
import { logger } from "../common/log.js";
import { excludeRepositories } from "../common/envs.js";
import { CustomError, MissingParamError } from "../common/error.js";
import { wrapTextMultiline } from "../common/fmt.js";
import { request } from "../common/http.js";
import {
CustomError,
logger,
MissingParamError,
request,
wrapTextMultiline,
} from "../common/utils.js";
dotenv.config();
@@ -39,14 +40,12 @@ const GRAPHQL_REPOS_QUERY = `
`;
const GRAPHQL_STATS_QUERY = `
query userInfo($login: String!, $after: String, $includeMergedPullRequests: Boolean!, $includeDiscussions: Boolean!, $includeDiscussionsAnswers: Boolean!, $startTime: DateTime = null) {
query userInfo($login: String!, $after: String, $includeMergedPullRequests: Boolean!, $includeDiscussions: Boolean!, $includeDiscussionsAnswers: Boolean!) {
user(login: $login) {
name
login
commits: contributionsCollection (from: $startTime) {
contributionsCollection {
totalCommitContributions,
}
reviews: contributionsCollection {
totalPullRequestReviewContributions
}
repositoriesContributedTo(first: 1, contributionTypes: [COMMIT, ISSUE, PULL_REQUEST, REPOSITORY]) {
@@ -78,12 +77,16 @@ const GRAPHQL_STATS_QUERY = `
}
`;
/**
* @typedef {import('axios').AxiosResponse} AxiosResponse Axios response.
*/
/**
* Stats fetcher object.
*
* @param {object & { after: string | null }} variables Fetcher variables.
* @param {object} variables Fetcher variables.
* @param {string} token GitHub token.
* @returns {Promise<import('axios').AxiosResponse>} Axios response.
* @returns {Promise<AxiosResponse>} Axios response.
*/
const fetcher = (variables, token) => {
const query = variables.after ? GRAPHQL_REPOS_QUERY : GRAPHQL_STATS_QUERY;
@@ -106,8 +109,7 @@ const fetcher = (variables, token) => {
* @param {boolean} variables.includeMergedPullRequests Include merged pull requests.
* @param {boolean} variables.includeDiscussions Include discussions.
* @param {boolean} variables.includeDiscussionsAnswers Include discussions answers.
* @param {string|undefined} variables.startTime Time to start the count of total commits.
* @returns {Promise<import('axios').AxiosResponse>} Axios response.
* @returns {Promise<AxiosResponse>} Axios response.
*
* @description This function supports multi-page fetching if the 'FETCH_MULTI_PAGE_STARS' environment variable is set to true.
*/
@@ -116,7 +118,6 @@ const statsFetcher = async ({
includeMergedPullRequests,
includeDiscussions,
includeDiscussionsAnswers,
startTime,
}) => {
let stats;
let hasNextPage = true;
@@ -129,7 +130,6 @@ const statsFetcher = async ({
includeMergedPullRequests,
includeDiscussions,
includeDiscussionsAnswers,
startTime,
};
let res = await retryer(fetcher, variables);
if (res.data.errors) {
@@ -158,27 +158,6 @@ const statsFetcher = async ({
return stats;
};
/**
* Fetch total commits using the REST API.
*
* @param {object} variables Fetcher variables.
* @param {string} token GitHub token.
* @returns {Promise<import('axios').AxiosResponse>} Axios response.
*
* @see https://developer.github.com/v3/search/#search-commits
*/
const fetchTotalCommits = (variables, token) => {
return axios({
method: "get",
url: `https://api.github.com/search/commits?q=author:${variables.login}`,
headers: {
"Content-Type": "application/json",
Accept: "application/vnd.github.cloak-preview",
Authorization: `token ${token}`,
},
});
};
/**
* Fetch all the commits for all the repositories of a given username.
*
@@ -194,6 +173,19 @@ const totalCommitsFetcher = async (username) => {
throw new Error("Invalid username provided.");
}
// https://developer.github.com/v3/search/#search-commits
const fetchTotalCommits = (variables, token) => {
return axios({
method: "get",
url: `https://api.github.com/search/commits?q=author:${variables.login}`,
headers: {
"Content-Type": "application/json",
Accept: "application/vnd.github.cloak-preview",
Authorization: `token ${token}`,
},
});
};
let res;
try {
res = await retryer(fetchTotalCommits, { login: username });
@@ -212,6 +204,10 @@ const totalCommitsFetcher = async (username) => {
return totalCount;
};
/**
* @typedef {import("./types").StatsData} StatsData Stats data.
*/
/**
* Fetch stats for a given username.
*
@@ -221,8 +217,7 @@ const totalCommitsFetcher = async (username) => {
* @param {boolean} include_merged_pull_requests Include merged pull requests.
* @param {boolean} include_discussions Include discussions.
* @param {boolean} include_discussions_answers Include discussions answers.
* @param {number|undefined} commits_year Year to count total commits
* @returns {Promise<import("./types").StatsData>} Stats data.
* @returns {Promise<StatsData>} Stats data.
*/
const fetchStats = async (
username,
@@ -231,7 +226,6 @@ const fetchStats = async (
include_merged_pull_requests = false,
include_discussions = false,
include_discussions_answers = false,
commits_year,
) => {
if (!username) {
throw new MissingParamError(["username"]);
@@ -257,7 +251,6 @@ const fetchStats = async (
includeMergedPullRequests: include_merged_pull_requests,
includeDiscussions: include_discussions,
includeDiscussionsAnswers: include_discussions_answers,
startTime: commits_year ? `${commits_year}-01-01T00:00:00Z` : undefined,
});
// Catch GraphQL errors.
@@ -289,17 +282,17 @@ const fetchStats = async (
if (include_all_commits) {
stats.totalCommits = await totalCommitsFetcher(username);
} else {
stats.totalCommits = user.commits.totalCommitContributions;
stats.totalCommits = user.contributionsCollection.totalCommitContributions;
}
stats.totalPRs = user.pullRequests.totalCount;
if (include_merged_pull_requests) {
stats.totalPRsMerged = user.mergedPullRequests.totalCount;
stats.mergedPRsPercentage =
(user.mergedPullRequests.totalCount / user.pullRequests.totalCount) *
100 || 0;
(user.mergedPullRequests.totalCount / user.pullRequests.totalCount) * 100;
}
stats.totalReviews = user.reviews.totalPullRequestReviewContributions;
stats.totalReviews =
user.contributionsCollection.totalPullRequestReviewContributions;
stats.totalIssues = user.openIssues.totalCount + user.closedIssues.totalCount;
if (include_discussions) {
stats.totalDiscussionsStarted = user.repositoryDiscussions.totalCount;
@@ -311,8 +304,7 @@ const fetchStats = async (
stats.contributedTo = user.repositoriesContributedTo.totalCount;
// Retrieve stars while filtering out repositories to be hidden.
const allExcludedRepos = [...exclude_repo, ...excludeRepositories];
let repoToHide = new Set(allExcludedRepos);
let repoToHide = new Set(exclude_repo);
stats.totalStars = user.repositories.nodes
.filter((data) => {
+16 -12
View File
@@ -1,18 +1,24 @@
// @ts-check
import { retryer } from "../common/retryer.js";
import { logger } from "../common/log.js";
import { excludeRepositories } from "../common/envs.js";
import { CustomError, MissingParamError } from "../common/error.js";
import { wrapTextMultiline } from "../common/fmt.js";
import { request } from "../common/http.js";
import {
CustomError,
logger,
MissingParamError,
request,
wrapTextMultiline,
} from "../common/utils.js";
/**
* @typedef {import("axios").AxiosRequestHeaders} AxiosRequestHeaders Axios request headers.
* @typedef {import("axios").AxiosResponse} AxiosResponse Axios response.
*/
/**
* Top languages fetcher object.
*
* @param {any} variables Fetcher variables.
* @param {AxiosRequestHeaders} variables Fetcher variables.
* @param {string} token GitHub token.
* @returns {Promise<import("axios").AxiosResponse>} Languages fetcher response.
* @returns {Promise<AxiosResponse>} Languages fetcher response.
*/
const fetcher = (variables, token) => {
return request(
@@ -92,14 +98,12 @@ const fetchTopLanguages = async (
}
let repoNodes = res.data.data.user.repositories.nodes;
/** @type {Record<string, boolean>} */
let repoToHide = {};
const allExcludedRepos = [...exclude_repo, ...excludeRepositories];
// populate repoToHide map for quick lookup
// while filtering out
if (allExcludedRepos) {
allExcludedRepos.forEach((repoName) => {
if (exclude_repo) {
exclude_repo.forEach((repoName) => {
repoToHide[repoName] = true;
});
}
+2 -4
View File
@@ -1,13 +1,11 @@
// @ts-check
import axios from "axios";
import { CustomError, MissingParamError } from "../common/error.js";
import { CustomError, MissingParamError } from "../common/utils.js";
/**
* WakaTime data fetcher.
*
* @param {{username: string, api_domain: string }} props Fetcher props.
* @returns {Promise<import("./types").WakaTimeData>} WakaTime data response.
* @returns {Promise<WakaTimeData>} WakaTime data response.
*/
const fetchWakatimeStats = async ({ username, api_domain }) => {
if (!username) {
+68 -332
View File
File diff suppressed because it is too large Load Diff
@@ -1,4 +1,4 @@
// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Test Render WakaTime Card should render correctly 1`] = `
"
+60 -122
View File
@@ -1,24 +1,12 @@
// @ts-check
import {
afterEach,
beforeEach,
describe,
expect,
it,
jest,
} from "@jest/globals";
import { jest } from "@jest/globals";
import axios from "axios";
import MockAdapter from "axios-mock-adapter";
import api from "../api/index.js";
import { calculateRank } from "../src/calculateRank.js";
import { renderStatsCard } from "../src/cards/stats.js";
import { renderError } from "../src/common/render.js";
import { CACHE_TTL, DURATIONS } from "../src/common/cache.js";
import { CONSTANTS, renderError } from "../src/common/utils.js";
import { expect, it, describe, afterEach } from "@jest/globals";
/**
* @type {import("../src/fetchers/stats").StatsData}
*/
const stats = {
name: "Anurag Hazra",
totalStars: 100,
@@ -31,7 +19,7 @@ const stats = {
totalDiscussionsStarted: 10,
totalDiscussionsAnswered: 40,
contributedTo: 50,
rank: { level: "DEV", percentile: 0 },
rank: null,
};
stats.rank = calculateRank({
@@ -50,10 +38,8 @@ const data_stats = {
user: {
name: stats.name,
repositoriesContributedTo: { totalCount: stats.contributedTo },
commits: {
contributionsCollection: {
totalCommitContributions: stats.totalCommits,
},
reviews: {
totalPullRequestReviewContributions: stats.totalReviews,
},
pullRequests: { totalCount: stats.totalPRs },
@@ -90,7 +76,6 @@ const error = {
const mock = new MockAdapter(axios);
// @ts-ignore
const faker = (query, data) => {
const req = {
query: {
@@ -107,10 +92,6 @@ const faker = (query, data) => {
return { req, res };
};
beforeEach(() => {
process.env.CACHE_SECONDS = undefined;
});
afterEach(() => {
mock.reset();
});
@@ -121,10 +102,8 @@ describe("Test /api/", () => {
await api(req, res);
expect(res.setHeader).toHaveBeenCalledWith("Content-Type", "image/svg+xml");
expect(res.send).toHaveBeenCalledWith(
renderStatsCard(stats, { ...req.query }),
);
expect(res.setHeader).toBeCalledWith("Content-Type", "image/svg+xml");
expect(res.send).toBeCalledWith(renderStatsCard(stats, { ...req.query }));
});
it("should render error card on error", async () => {
@@ -132,13 +111,12 @@ describe("Test /api/", () => {
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",
}),
expect(res.setHeader).toBeCalledWith("Content-Type", "image/svg+xml");
expect(res.send).toBeCalledWith(
renderError(
error.errors[0].message,
"Make sure the provided username is not an organization",
),
);
});
@@ -147,14 +125,13 @@ describe("Test /api/", () => {
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" },
}),
expect(res.setHeader).toBeCalledWith("Content-Type", "image/svg+xml");
expect(res.send).toBeCalledWith(
renderError(
error.errors[0].message,
"Make sure the provided username is not an organization",
{ theme: "merko" },
),
);
});
@@ -176,8 +153,8 @@ describe("Test /api/", () => {
await api(req, res);
expect(res.setHeader).toHaveBeenCalledWith("Content-Type", "image/svg+xml");
expect(res.send).toHaveBeenCalledWith(
expect(res.setHeader).toBeCalledWith("Content-Type", "image/svg+xml");
expect(res.send).toBeCalledWith(
renderStatsCard(stats, {
hide: ["issues", "prs", "contribs"],
show_icons: true,
@@ -200,15 +177,15 @@ describe("Test /api/", () => {
["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}`,
`max-age=${CONSTANTS.CARD_CACHE_SECONDS}, s-maxage=${
CONSTANTS.CARD_CACHE_SECONDS
}, stale-while-revalidate=${CONSTANTS.ONE_DAY}`,
],
]);
});
it("should set proper cache", async () => {
const cache_seconds = DURATIONS.TWELVE_HOURS;
const cache_seconds = CONSTANTS.TWELVE_HOURS;
const { req, res } = faker({ cache_seconds }, data_stats);
await api(req, res);
@@ -216,9 +193,11 @@ describe("Test /api/", () => {
["Content-Type", "image/svg+xml"],
[
"Cache-Control",
`max-age=${cache_seconds}, ` +
`s-maxage=${cache_seconds}, ` +
`stale-while-revalidate=${DURATIONS.ONE_DAY}`,
`max-age=${
cache_seconds
}, s-maxage=${cache_seconds}, stale-while-revalidate=${
CONSTANTS.ONE_DAY
}`,
],
]);
});
@@ -231,60 +210,25 @@ describe("Test /api/", () => {
["Content-Type", "image/svg+xml"],
[
"Cache-Control",
`max-age=${CACHE_TTL.ERROR}, ` +
`s-maxage=${CACHE_TTL.ERROR}, ` +
`stale-while-revalidate=${DURATIONS.ONE_DAY}`,
`max-age=${CONSTANTS.ERROR_CACHE_SECONDS / 2}, s-maxage=${
CONSTANTS.ERROR_CACHE_SECONDS
}, stale-while-revalidate=${CONSTANTS.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);
let { req, res } = faker({ cache_seconds: 200000 }, 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}`,
`max-age=${CONSTANTS.TWO_DAY}, s-maxage=${
CONSTANTS.TWO_DAY
}, stale-while-revalidate=${CONSTANTS.ONE_DAY}`,
],
]);
}
@@ -298,24 +242,24 @@ describe("Test /api/", () => {
["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}`,
`max-age=${CONSTANTS.ONE_DAY}, s-maxage=${
CONSTANTS.ONE_DAY
}, stale-while-revalidate=${CONSTANTS.ONE_DAY}`,
],
]);
}
{
let { req, res } = faker({ cache_seconds: -10_000 }, data_stats);
let { req, res } = faker({ cache_seconds: -10000 }, 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}`,
`max-age=${CONSTANTS.TWELVE_HOURS}, s-maxage=${
CONSTANTS.TWELVE_HOURS
}, stale-while-revalidate=${CONSTANTS.ONE_DAY}`,
],
]);
}
@@ -340,8 +284,8 @@ describe("Test /api/", () => {
await api(req, res);
expect(res.setHeader).toHaveBeenCalledWith("Content-Type", "image/svg+xml");
expect(res.send).toHaveBeenCalledWith(
expect(res.setHeader).toBeCalledWith("Content-Type", "image/svg+xml");
expect(res.send).toBeCalledWith(
renderStatsCard(stats, {
hide: ["issues", "prs", "contribs"],
show_icons: true,
@@ -361,13 +305,13 @@ describe("Test /api/", () => {
await api(req, res);
expect(res.setHeader).toHaveBeenCalledWith("Content-Type", "image/svg+xml");
expect(res.send).toHaveBeenCalledWith(
renderError({
message: "This username is blacklisted",
secondaryMessage: "Please deploy your own instance",
renderOptions: { show_repo_link: false },
}),
expect(res.setHeader).toBeCalledWith("Content-Type", "image/svg+xml");
expect(res.send).toBeCalledWith(
renderError(
"This username is blacklisted",
"Please deploy your own instance",
{ show_repo_link: false },
),
);
});
@@ -376,12 +320,9 @@ describe("Test /api/", () => {
await api(req, res);
expect(res.setHeader).toHaveBeenCalledWith("Content-Type", "image/svg+xml");
expect(res.send).toHaveBeenCalledWith(
renderError({
message: "Something went wrong",
secondaryMessage: "Language not found",
}),
expect(res.setHeader).toBeCalledWith("Content-Type", "image/svg+xml");
expect(res.send).toBeCalledWith(
renderError("Something went wrong", "Language not found"),
);
});
@@ -397,12 +338,9 @@ describe("Test /api/", () => {
await api(req, res);
expect(res.setHeader).toHaveBeenCalledWith("Content-Type", "image/svg+xml");
expect(res.send).toHaveBeenCalledWith(
renderError({
message: "Could not fetch total commits.",
secondaryMessage: "Please try again later",
}),
expect(res.setHeader).toBeCalledWith("Content-Type", "image/svg+xml");
expect(res.send).toBeCalledWith(
renderError("Could not fetch total commits.", "Please try again later"),
);
// Received SVG output should not contain string "https://tiny.one/readme-stats"
expect(res.send.mock.calls[0][0]).not.toContain(
+6 -8
View File
@@ -1,8 +1,8 @@
import { benchmarkSuite } from "jest-bench";
import api from "../../api/index.js";
import axios from "axios";
import MockAdapter from "axios-mock-adapter";
import { it, jest } from "@jest/globals";
import { runAndLogStats } from "./utils.js";
import { jest } from "@jest/globals";
const stats = {
name: "Anurag Hazra",
@@ -24,10 +24,8 @@ const data_stats = {
user: {
name: stats.name,
repositoriesContributedTo: { totalCount: stats.contributedTo },
commits: {
contributionsCollection: {
totalCommitContributions: stats.totalCommits,
},
reviews: {
totalPullRequestReviewContributions: stats.totalReviews,
},
pullRequests: { totalCount: stats.totalPRs },
@@ -69,10 +67,10 @@ const faker = (query, data) => {
return { req, res };
};
it("test /api", async () => {
await runAndLogStats("test /api", async () => {
benchmarkSuite("test /api", {
["simple request"]: async () => {
const { req, res } = faker({}, data_stats);
await api(req, res);
});
},
});
+4 -5
View File
@@ -1,9 +1,8 @@
import { benchmarkSuite } from "jest-bench";
import { calculateRank } from "../../src/calculateRank.js";
import { it } from "@jest/globals";
import { runAndLogStats } from "./utils.js";
it("calculateRank", async () => {
await runAndLogStats("calculateRank", () => {
benchmarkSuite("calculateRank", {
["calculateRank"]: () => {
calculateRank({
all_commits: false,
commits: 1300,
@@ -14,5 +13,5 @@ it("calculateRank", async () => {
stars: 600000,
followers: 50000,
});
});
},
});
+5 -5
View File
@@ -1,8 +1,8 @@
import { benchmarkSuite } from "jest-bench";
import gist from "../../api/gist.js";
import axios from "axios";
import MockAdapter from "axios-mock-adapter";
import { it, jest } from "@jest/globals";
import { runAndLogStats } from "./utils.js";
import { jest } from "@jest/globals";
const gist_data = {
data: {
@@ -34,8 +34,8 @@ const gist_data = {
const mock = new MockAdapter(axios);
mock.onPost("https://api.github.com/graphql").reply(200, gist_data);
it("test /api/gist", async () => {
await runAndLogStats("test /api/gist", async () => {
benchmarkSuite("test /api/gist", {
["simple request"]: async () => {
const req = {
query: {
id: "bbfce31e0217a3689c8d961a356cb10d",
@@ -47,5 +47,5 @@ it("test /api/gist", async () => {
};
await gist(req, res);
});
},
});
+5 -5
View File
@@ -1,8 +1,8 @@
import { benchmarkSuite } from "jest-bench";
import pin from "../../api/pin.js";
import axios from "axios";
import MockAdapter from "axios-mock-adapter";
import { it, jest } from "@jest/globals";
import { runAndLogStats } from "./utils.js";
import { jest } from "@jest/globals";
const data_repo = {
repository: {
@@ -32,8 +32,8 @@ const data_user = {
const mock = new MockAdapter(axios);
mock.onPost("https://api.github.com/graphql").reply(200, data_user);
it("test /api/pin", async () => {
await runAndLogStats("test /api/pin", async () => {
benchmarkSuite("test /api/pin", {
["simple request"]: async () => {
const req = {
query: {
username: "anuraghazra",
@@ -46,5 +46,5 @@ it("test /api/pin", async () => {
};
await pin(req, res);
});
},
});
-133
View File
@@ -1,133 +0,0 @@
// @ts-check
const DEFAULT_RUNS = 1000;
const DEFAULT_WARMUPS = 50;
/**
* Formats a duration in nanoseconds to a compact human-readable string.
*
* @param {bigint} ns Duration in nanoseconds.
* @returns {string} Formatted time string.
*/
const formatTime = (ns) => {
if (ns < 1_000n) {
return `${ns}ns`;
}
if (ns < 1_000_000n) {
return `${Number(ns) / 1_000}µs`;
}
if (ns < 1_000_000_000n) {
return `${(Number(ns) / 1_000_000).toFixed(3)}ms`;
}
return `${(Number(ns) / 1_000_000_000).toFixed(3)}s`;
};
/**
* Measures synchronous or async function execution time.
*
* @param {Function} fn Function to measure.
* @returns {Promise<bigint>} elapsed nanoseconds
*/
const measurePerformance = async (fn) => {
const start = process.hrtime.bigint();
const ret = fn();
if (ret instanceof Promise) {
await ret;
}
const end = process.hrtime.bigint();
return end - start;
};
/**
* Computes basic & extended statistics.
*
* @param {bigint[]} samples Array of samples in nanoseconds.
* @returns {object} Stats
*/
const computeStats = (samples) => {
const sorted = [...samples].sort((a, b) => (a < b ? -1 : 1));
const toNumber = (b) => Number(b); // safe for typical short benches
const n = sorted.length;
const sum = sorted.reduce((a, b) => a + b, 0n);
const avg = Number(sum) / n;
const median =
n % 2
? toNumber(sorted[(n - 1) / 2])
: (toNumber(sorted[n / 2 - 1]) + toNumber(sorted[n / 2])) / 2;
const p = (q) => {
const idx = Math.min(n - 1, Math.floor((q / 100) * n));
return toNumber(sorted[idx]);
};
const min = toNumber(sorted[0]);
const max = toNumber(sorted[n - 1]);
const variance =
sorted.reduce((acc, v) => acc + (toNumber(v) - avg) ** 2, 0) / n;
const stdev = Math.sqrt(variance);
return {
runs: n,
min,
max,
average: avg,
median,
p75: p(75),
p95: p(95),
p99: p(99),
stdev,
totalTime: toNumber(sum),
};
};
/**
* Benchmark a function.
*
* @param {string} fnName Name of the function (for logging).
* @param {Function} fn Function to benchmark.
* @param {object} [opts] Options.
* @param {number} [opts.runs] Number of measured runs.
* @param {number} [opts.warmup] Warm-up iterations (not measured).
* @param {boolean} [opts.trimOutliers] Drop top & bottom 1% before stats.
* @returns {Promise<object>} Stats (nanoseconds for core metrics).
*/
export const runAndLogStats = async (
fnName,
fn,
{ runs = DEFAULT_RUNS, warmup = DEFAULT_WARMUPS, trimOutliers = false } = {},
) => {
if (runs <= 0) {
throw new Error("Number of runs must be positive.");
}
// Warm-up
for (let i = 0; i < warmup; i++) {
const ret = fn();
if (ret instanceof Promise) {
await ret;
}
}
const samples = [];
for (let i = 0; i < runs; i++) {
samples.push(await measurePerformance(fn));
}
let processed = samples;
if (trimOutliers && samples.length > 10) {
const sorted = [...samples].sort((a, b) => (a < b ? -1 : 1));
const cut = Math.max(1, Math.floor(sorted.length * 0.01));
processed = sorted.slice(cut, sorted.length - cut);
}
const stats = computeStats(processed);
const fmt = (ns) => formatTime(BigInt(Math.round(ns)));
console.log(
`${fnName} | runs=${stats.runs} avg=${fmt(stats.average)} median=${fmt(
stats.median,
)} p95=${fmt(stats.p95)} min=${fmt(stats.min)} max=${fmt(
stats.max,
)} stdev=${fmt(stats.stdev)}`,
);
return stats;
};
+1 -1
View File
@@ -1,6 +1,6 @@
import { describe, expect, it } from "@jest/globals";
import "@testing-library/jest-dom";
import { calculateRank } from "../src/calculateRank.js";
import { expect, it, describe } from "@jest/globals";
describe("Test calculateRank", () => {
it("new user gets C rank", () => {
+2 -2
View File
@@ -1,10 +1,10 @@
import { describe, expect, it } from "@jest/globals";
import { queryByTestId } from "@testing-library/dom";
import "@testing-library/jest-dom";
import { cssToObject } from "@uppercod/css-to-object";
import { Card } from "../src/common/Card.js";
import { icons } from "../src/common/icons.js";
import { getCardColors } from "../src/common/color.js";
import { getCardColors } from "../src/common/utils.js";
import { expect, it, describe } from "@jest/globals";
describe("Card", () => {
it("should hide border", () => {
-76
View File
@@ -1,76 +0,0 @@
import { getCardColors } from "../src/common/color";
import { describe, expect, it } from "@jest/globals";
describe("Test color.js", () => {
it("getCardColors: should return expected values", () => {
let colors = getCardColors({
title_color: "f00",
text_color: "0f0",
ring_color: "0000ff",
icon_color: "00f",
bg_color: "fff",
border_color: "fff",
theme: "dark",
});
expect(colors).toStrictEqual({
titleColor: "#f00",
textColor: "#0f0",
iconColor: "#00f",
ringColor: "#0000ff",
bgColor: "#fff",
borderColor: "#fff",
});
});
it("getCardColors: should fallback to default colors if color is invalid", () => {
let colors = getCardColors({
title_color: "invalidcolor",
text_color: "0f0",
icon_color: "00f",
bg_color: "fff",
border_color: "invalidColor",
theme: "dark",
});
expect(colors).toStrictEqual({
titleColor: "#2f80ed",
textColor: "#0f0",
iconColor: "#00f",
ringColor: "#2f80ed",
bgColor: "#fff",
borderColor: "#e4e2e2",
});
});
it("getCardColors: should fallback to specified theme colors if is not defined", () => {
let colors = getCardColors({
theme: "dark",
});
expect(colors).toStrictEqual({
titleColor: "#fff",
textColor: "#9f9f9f",
ringColor: "#fff",
iconColor: "#79ff97",
bgColor: "#151515",
borderColor: "#e4e2e2",
});
});
it("getCardColors: should return ring color equal to title color if not ring color is defined", () => {
let colors = getCardColors({
title_color: "f00",
text_color: "0f0",
icon_color: "00f",
bg_color: "fff",
border_color: "fff",
theme: "dark",
});
expect(colors).toStrictEqual({
titleColor: "#f00",
textColor: "#0f0",
iconColor: "#00f",
ringColor: "#f00",
bgColor: "#fff",
borderColor: "#fff",
});
});
});
+3 -3
View File
@@ -4,13 +4,13 @@
import dotenv from "dotenv";
dotenv.config();
import { beforeAll, describe, expect, test } from "@jest/globals";
import axios from "axios";
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";
import { renderGistCard } from "../../src/cards/gist.js";
import { expect, describe, beforeAll, test } from "@jest/globals";
const REPO = "curly-fiesta";
const USER = "catelinemnemosyne";
@@ -96,7 +96,7 @@ const GIST_DATA = {
name: "link.txt",
nameWithOwner: "qwerty541/link.txt",
description:
"Trying to access this path on Windows 10 ver. 1803+ will breaks NTFS",
"Trying to access this path on Windown 10 ver. 1803+ will breaks NTFS",
language: "Text",
starsCount: 1,
forksCount: 0,
+1 -1
View File
@@ -1,7 +1,7 @@
import { afterEach, describe, expect, it } from "@jest/globals";
import "@testing-library/jest-dom";
import axios from "axios";
import MockAdapter from "axios-mock-adapter";
import { expect, it, describe, afterEach } from "@jest/globals";
import { fetchGist } from "../src/fetchers/gist.js";
const gist_data = {
+1 -1
View File
@@ -1,8 +1,8 @@
import { afterEach, describe, expect, it } from "@jest/globals";
import "@testing-library/jest-dom";
import axios from "axios";
import MockAdapter from "axios-mock-adapter";
import { fetchRepo } from "../src/fetchers/repo.js";
import { expect, it, describe, afterEach } from "@jest/globals";
const data_repo = {
repository: {
+3 -98
View File
@@ -1,9 +1,9 @@
import { afterEach, beforeEach, describe, expect, it } from "@jest/globals";
import "@testing-library/jest-dom";
import axios from "axios";
import MockAdapter from "axios-mock-adapter";
import { calculateRank } from "../src/calculateRank.js";
import { fetchStats } from "../src/fetchers/stats.js";
import { expect, it, describe, beforeEach, afterEach } from "@jest/globals";
// Test parameters.
const data_stats = {
@@ -11,10 +11,8 @@ const data_stats = {
user: {
name: "Anurag Hazra",
repositoriesContributedTo: { totalCount: 61 },
commits: {
contributionsCollection: {
totalCommitContributions: 100,
},
reviews: {
totalPullRequestReviewContributions: 50,
},
pullRequests: { totalCount: 300 },
@@ -40,19 +38,6 @@ const data_stats = {
},
};
const data_year2003 = JSON.parse(JSON.stringify(data_stats));
data_year2003.data.user.commits.totalCommitContributions = 428;
const data_without_pull_requests = {
data: {
user: {
...data_stats.data.user,
pullRequests: { totalCount: 0 },
mergedPullRequests: { totalCount: 0 },
},
},
};
const data_repo = {
data: {
user: {
@@ -106,18 +91,9 @@ const mock = new MockAdapter(axios);
beforeEach(() => {
process.env.FETCH_MULTI_PAGE_STARS = "false"; // Set to `false` to fetch only one page of stars.
mock.onPost("https://api.github.com/graphql").reply((cfg) => {
let req = JSON.parse(cfg.data);
if (
req.variables &&
req.variables.startTime &&
req.variables.startTime.startsWith("2003")
) {
return [200, data_year2003];
}
return [
200,
req.query.includes("totalCommitContributions") ? data_stats : data_repo,
cfg.data.includes("contributionsCollection") ? data_stats : data_repo,
];
});
});
@@ -433,75 +409,4 @@ describe("Test fetchStats", () => {
rank,
});
});
it("should get commits of provided year", async () => {
let stats = await fetchStats(
"anuraghazra",
false,
[],
false,
false,
false,
2003,
);
const rank = calculateRank({
all_commits: false,
commits: 428,
prs: 300,
reviews: 50,
issues: 200,
repos: 5,
stars: 300,
followers: 100,
});
expect(stats).toStrictEqual({
contributedTo: 61,
name: "Anurag Hazra",
totalCommits: 428,
totalIssues: 200,
totalPRs: 300,
totalPRsMerged: 0,
mergedPRsPercentage: 0,
totalReviews: 50,
totalStars: 300,
totalDiscussionsStarted: 0,
totalDiscussionsAnswered: 0,
rank,
});
});
it("should return correct data when user don't have any pull requests", async () => {
mock.reset();
mock
.onPost("https://api.github.com/graphql")
.reply(200, data_without_pull_requests);
const stats = await fetchStats("anuraghazra", false, [], true);
const rank = calculateRank({
all_commits: false,
commits: 100,
prs: 0,
reviews: 50,
issues: 200,
repos: 5,
stars: 300,
followers: 100,
});
expect(stats).toStrictEqual({
contributedTo: 61,
name: "Anurag Hazra",
totalCommits: 100,
totalIssues: 200,
totalPRs: 0,
totalPRsMerged: 0,
mergedPRsPercentage: 0,
totalReviews: 50,
totalStars: 300,
totalDiscussionsStarted: 0,
totalDiscussionsAnswered: 0,
rank,
});
});
});
+1 -1
View File
@@ -1,8 +1,8 @@
import { afterEach, describe, expect, it } from "@jest/globals";
import "@testing-library/jest-dom";
import axios from "axios";
import MockAdapter from "axios-mock-adapter";
import { fetchTopLanguages } from "../src/fetchers/top-languages.js";
import { expect, it, describe, afterEach } from "@jest/globals";
const mock = new MockAdapter(axios);
+1 -1
View File
@@ -1,8 +1,8 @@
import { afterEach, describe, expect, it } from "@jest/globals";
import "@testing-library/jest-dom";
import axios from "axios";
import MockAdapter from "axios-mock-adapter";
import { fetchWakatimeStats } from "../src/fetchers/wakatime.js";
import { expect, it, describe, afterEach } from "@jest/globals";
const mock = new MockAdapter(axios);
+2 -2
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from "@jest/globals";
import { flexLayout } from "../src/common/render.js";
import { flexLayout } from "../src/common/utils.js";
import { expect, it, describe } from "@jest/globals";
describe("flexLayout", () => {
it("should work with row & col layouts", () => {
-104
View File
@@ -1,104 +0,0 @@
import { describe, expect, it } from "@jest/globals";
import {
formatBytes,
kFormatter,
wrapTextMultiline,
} from "../src/common/fmt.js";
describe("Test fmt.js", () => {
it("kFormatter: should format numbers correctly by default", () => {
expect(kFormatter(1)).toBe(1);
expect(kFormatter(-1)).toBe(-1);
expect(kFormatter(500)).toBe(500);
expect(kFormatter(1000)).toBe("1k");
expect(kFormatter(1200)).toBe("1.2k");
expect(kFormatter(10000)).toBe("10k");
expect(kFormatter(12345)).toBe("12.3k");
expect(kFormatter(99900)).toBe("99.9k");
expect(kFormatter(9900000)).toBe("9900k");
});
it("kFormatter: should format numbers correctly with 0 decimal precision", () => {
expect(kFormatter(1, 0)).toBe("0k");
expect(kFormatter(-1, 0)).toBe("-0k");
expect(kFormatter(500, 0)).toBe("1k");
expect(kFormatter(1000, 0)).toBe("1k");
expect(kFormatter(1200, 0)).toBe("1k");
expect(kFormatter(10000, 0)).toBe("10k");
expect(kFormatter(12345, 0)).toBe("12k");
expect(kFormatter(99000, 0)).toBe("99k");
expect(kFormatter(99900, 0)).toBe("100k");
expect(kFormatter(9900000, 0)).toBe("9900k");
});
it("kFormatter: should format numbers correctly with 1 decimal precision", () => {
expect(kFormatter(1, 1)).toBe("0.0k");
expect(kFormatter(-1, 1)).toBe("-0.0k");
expect(kFormatter(500, 1)).toBe("0.5k");
expect(kFormatter(1000, 1)).toBe("1.0k");
expect(kFormatter(1200, 1)).toBe("1.2k");
expect(kFormatter(10000, 1)).toBe("10.0k");
expect(kFormatter(12345, 1)).toBe("12.3k");
expect(kFormatter(99900, 1)).toBe("99.9k");
expect(kFormatter(9900000, 1)).toBe("9900.0k");
});
it("kFormatter: should format numbers correctly with 2 decimal precision", () => {
expect(kFormatter(1, 2)).toBe("0.00k");
expect(kFormatter(-1, 2)).toBe("-0.00k");
expect(kFormatter(500, 2)).toBe("0.50k");
expect(kFormatter(1000, 2)).toBe("1.00k");
expect(kFormatter(1200, 2)).toBe("1.20k");
expect(kFormatter(10000, 2)).toBe("10.00k");
expect(kFormatter(12345, 2)).toBe("12.35k");
expect(kFormatter(99900, 2)).toBe("99.90k");
expect(kFormatter(9900000, 2)).toBe("9900.00k");
});
it("formatBytes: should return expected values", () => {
expect(formatBytes(0)).toBe("0 B");
expect(formatBytes(100)).toBe("100.0 B");
expect(formatBytes(1024)).toBe("1.0 KB");
expect(formatBytes(1024 * 1024)).toBe("1.0 MB");
expect(formatBytes(1024 * 1024 * 1024)).toBe("1.0 GB");
expect(formatBytes(1024 * 1024 * 1024 * 1024)).toBe("1.0 TB");
expect(formatBytes(1024 * 1024 * 1024 * 1024 * 1024)).toBe("1.0 PB");
expect(formatBytes(1024 * 1024 * 1024 * 1024 * 1024 * 1024)).toBe("1.0 EB");
expect(formatBytes(1234 * 1024)).toBe("1.2 MB");
expect(formatBytes(123.4 * 1024)).toBe("123.4 KB");
});
it("wrapTextMultiline: should not wrap small texts", () => {
{
let multiLineText = wrapTextMultiline("Small text should not wrap");
expect(multiLineText).toEqual(["Small text should not wrap"]);
}
});
it("wrapTextMultiline: should wrap large texts", () => {
let multiLineText = wrapTextMultiline(
"Hello world long long long text",
20,
3,
);
expect(multiLineText).toEqual(["Hello world long", "long long text"]);
});
it("wrapTextMultiline: should wrap large texts and limit max lines", () => {
let multiLineText = wrapTextMultiline(
"Hello world long long long text",
10,
2,
);
expect(multiLineText).toEqual(["Hello", "world long..."]);
});
it("wrapTextMultiline: should wrap chinese by punctuation", () => {
let multiLineText = wrapTextMultiline(
"专门为刚开始刷题的同学准备的算法基地,没有最细只有更细,立志用动画将晦涩难懂的算法说的通俗易懂!",
);
expect(multiLineText.length).toEqual(3);
expect(multiLineText[0].length).toEqual(18 * 8); // &#xxxxx; x 8
});
});
+22 -32
View File
@@ -1,13 +1,11 @@
// @ts-check
import { afterEach, describe, expect, it, jest } from "@jest/globals";
import { jest } from "@jest/globals";
import "@testing-library/jest-dom";
import axios from "axios";
import MockAdapter from "axios-mock-adapter";
import gist from "../api/gist.js";
import { expect, it, describe, afterEach } from "@jest/globals";
import { renderGistCard } from "../src/cards/gist.js";
import { renderError } from "../src/common/render.js";
import { CACHE_TTL, DURATIONS } from "../src/common/cache.js";
import { CONSTANTS, renderError } from "../src/common/utils.js";
import gist from "../api/gist.js";
const gist_data = {
data: {
@@ -65,8 +63,8 @@ describe("Test /api/gist", () => {
await gist(req, res);
expect(res.setHeader).toHaveBeenCalledWith("Content-Type", "image/svg+xml");
expect(res.send).toHaveBeenCalledWith(
expect(res.setHeader).toBeCalledWith("Content-Type", "image/svg+xml");
expect(res.send).toBeCalledWith(
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}`,
@@ -97,8 +95,8 @@ describe("Test /api/gist", () => {
await gist(req, res);
expect(res.setHeader).toHaveBeenCalledWith("Content-Type", "image/svg+xml");
expect(res.send).toHaveBeenCalledWith(
expect(res.setHeader).toBeCalledWith("Content-Type", "image/svg+xml");
expect(res.send).toBeCalledWith(
renderGistCard(
{
name: gist_data.data.viewer.gist.files[0].name,
@@ -124,13 +122,12 @@ describe("Test /api/gist", () => {
await gist(req, res);
expect(res.setHeader).toHaveBeenCalledWith("Content-Type", "image/svg+xml");
expect(res.send).toHaveBeenCalledWith(
renderError({
message: 'Missing params "id" make sure you pass the parameters in URL',
secondaryMessage: "/api/gist?id=GIST_ID",
renderOptions: { show_repo_link: false },
}),
expect(res.setHeader).toBeCalledWith("Content-Type", "image/svg+xml");
expect(res.send).toBeCalledWith(
renderError(
'Missing params "id" make sure you pass the parameters in URL',
"/api/gist?id=GIST_ID",
),
);
});
@@ -150,10 +147,8 @@ describe("Test /api/gist", () => {
await gist(req, res);
expect(res.setHeader).toHaveBeenCalledWith("Content-Type", "image/svg+xml");
expect(res.send).toHaveBeenCalledWith(
renderError({ message: "Gist not found" }),
);
expect(res.setHeader).toBeCalledWith("Content-Type", "image/svg+xml");
expect(res.send).toBeCalledWith(renderError("Gist not found"));
});
it("should render error if wrong locale is provided", async () => {
@@ -170,12 +165,9 @@ describe("Test /api/gist", () => {
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",
}),
expect(res.setHeader).toBeCalledWith("Content-Type", "image/svg+xml");
expect(res.send).toBeCalledWith(
renderError("Something went wrong", "Language not found"),
);
});
@@ -193,12 +185,10 @@ describe("Test /api/gist", () => {
await gist(req, res);
expect(res.setHeader).toHaveBeenCalledWith("Content-Type", "image/svg+xml");
expect(res.setHeader).toHaveBeenCalledWith(
expect(res.setHeader).toBeCalledWith("Content-Type", "image/svg+xml");
expect(res.setHeader).toBeCalledWith(
"Cache-Control",
`max-age=${CACHE_TTL.GIST_CARD.DEFAULT}, ` +
`s-maxage=${CACHE_TTL.GIST_CARD.DEFAULT}, ` +
`stale-while-revalidate=${DURATIONS.ONE_DAY}`,
`max-age=${CONSTANTS.TWO_DAY}, s-maxage=${CONSTANTS.TWO_DAY}`,
);
});
});
-10
View File
@@ -1,10 +0,0 @@
import { describe, expect, it } from "@jest/globals";
import { encodeHTML } from "../src/common/html.js";
describe("Test html.js", () => {
it("should test encodeHTML", () => {
expect(encodeHTML(`<html>hello world<,.#4^&^@%!))`)).toBe(
"&#60;html&#62;hello world&#60;,.#4^&#38;^@%!))",
);
});
});
+1 -1
View File
@@ -1,4 +1,4 @@
import { describe, expect, it } from "@jest/globals";
import { expect, it, describe } from "@jest/globals";
import { I18n } from "../src/common/I18n.js";
import { statCardLocales } from "../src/translations.js";
-89
View File
@@ -1,89 +0,0 @@
import { describe, expect, it } from "@jest/globals";
import {
parseBoolean,
parseArray,
clampValue,
lowercaseTrim,
chunkArray,
parseEmojis,
dateDiff,
} from "../src/common/ops.js";
describe("Test ops.js", () => {
it("should test parseBoolean", () => {
expect(parseBoolean(true)).toBe(true);
expect(parseBoolean(false)).toBe(false);
expect(parseBoolean("true")).toBe(true);
expect(parseBoolean("false")).toBe(false);
expect(parseBoolean("True")).toBe(true);
expect(parseBoolean("False")).toBe(false);
expect(parseBoolean("TRUE")).toBe(true);
expect(parseBoolean("FALSE")).toBe(false);
expect(parseBoolean("1")).toBe(undefined);
expect(parseBoolean("0")).toBe(undefined);
expect(parseBoolean("")).toBe(undefined);
// @ts-ignore
expect(parseBoolean(undefined)).toBe(undefined);
});
it("should test parseArray", () => {
expect(parseArray("a,b,c")).toEqual(["a", "b", "c"]);
expect(parseArray("a, b, c")).toEqual(["a", " b", " c"]); // preserves spaces
expect(parseArray("")).toEqual([]);
// @ts-ignore
expect(parseArray(undefined)).toEqual([]);
});
it("should test clampValue", () => {
expect(clampValue(5, 1, 10)).toBe(5);
expect(clampValue(0, 1, 10)).toBe(1);
expect(clampValue(15, 1, 10)).toBe(10);
// string inputs are coerced numerically by Math.min/Math.max
// @ts-ignore
expect(clampValue("7", 1, 10)).toBe(7);
// non-numeric and NaN fall back to min
// @ts-ignore
expect(clampValue("abc", 1, 10)).toBe(1);
expect(clampValue(NaN, 2, 5)).toBe(2);
});
it("should test lowercaseTrim", () => {
expect(lowercaseTrim(" Hello World ")).toBe("hello world");
expect(lowercaseTrim("already lower")).toBe("already lower");
});
it("should test chunkArray", () => {
expect(chunkArray([1, 2, 3, 4, 5], 2)).toEqual([[1, 2], [3, 4], [5]]);
expect(chunkArray([1, 2, 3, 4, 5], 1)).toEqual([[1], [2], [3], [4], [5]]);
expect(chunkArray([1, 2, 3, 4, 5], 10)).toEqual([[1, 2, 3, 4, 5]]);
});
it("should test parseEmojis", () => {
// unknown emoji name is stripped
expect(parseEmojis("Hello :nonexistent:")).toBe("Hello ");
// common emoji names should be replaced (at least token removed)
const out = parseEmojis("I :heart: OSS");
expect(out).not.toContain(":heart:");
expect(out.startsWith("I ")).toBe(true);
expect(out.endsWith(" OSS")).toBe(true);
expect(() => parseEmojis("")).toThrow(/parseEmoji/);
// @ts-ignore
expect(() => parseEmojis()).toThrow(/parseEmoji/);
});
it("should test dateDiff", () => {
const a = new Date("2020-01-01T00:10:00Z");
const b = new Date("2020-01-01T00:00:00Z");
expect(dateDiff(a, b)).toBe(10);
const c = new Date("2020-01-01T00:00:00Z");
const d = new Date("2020-01-01T00:10:30Z");
// rounds to nearest minute
expect(dateDiff(c, d)).toBe(-10);
});
});
+10 -31
View File
@@ -1,21 +1,14 @@
/**
* @file Tests for the status/pat-info cloud function.
*/
import dotenv from "dotenv";
dotenv.config();
import {
afterEach,
beforeAll,
describe,
expect,
it,
jest,
} from "@jest/globals";
import { jest } from "@jest/globals";
import axios from "axios";
import MockAdapter from "axios-mock-adapter";
import patInfo, { RATE_LIMIT_SECONDS } from "../api/status/pat-info.js";
import { expect, it, describe, afterEach, beforeAll } from "@jest/globals";
const mock = new MockAdapter(axios);
@@ -90,11 +83,8 @@ describe("Test /api/status/pat-info", () => {
const { req, res } = faker({}, {});
await patInfo(req, res);
expect(res.setHeader).toHaveBeenCalledWith(
"Content-Type",
"application/json",
);
expect(res.send).toHaveBeenCalledWith(
expect(res.setHeader).toBeCalledWith("Content-Type", "application/json");
expect(res.send).toBeCalledWith(
JSON.stringify(
{
validPATs: ["PAT_2", "PAT_3", "PAT_4"],
@@ -138,11 +128,8 @@ describe("Test /api/status/pat-info", () => {
const { req, res } = faker({}, {});
await patInfo(req, res);
expect(res.setHeader).toHaveBeenCalledWith(
"Content-Type",
"application/json",
);
expect(res.send).toHaveBeenCalledWith(
expect(res.setHeader).toBeCalledWith("Content-Type", "application/json");
expect(res.send).toBeCalledWith(
JSON.stringify(
{
validPATs: ["PAT_2", "PAT_3", "PAT_4"],
@@ -188,11 +175,8 @@ describe("Test /api/status/pat-info", () => {
const { req, res } = faker({}, {});
await patInfo(req, res);
expect(res.setHeader).toHaveBeenCalledWith(
"Content-Type",
"application/json",
);
expect(res.send).toHaveBeenCalledWith(
expect(res.setHeader).toBeCalledWith("Content-Type", "application/json");
expect(res.send).toBeCalledWith(
JSON.stringify(
{
validPATs: ["PAT_2", "PAT_3", "PAT_4"],
@@ -230,13 +214,8 @@ describe("Test /api/status/pat-info", () => {
const { req, res } = faker({}, {});
await patInfo(req, res);
expect(res.setHeader).toHaveBeenCalledWith(
"Content-Type",
"application/json",
);
expect(res.send).toHaveBeenCalledWith(
"Something went wrong: Network Error",
);
expect(res.setHeader).toBeCalledWith("Content-Type", "application/json");
expect(res.send).toBeCalledWith("Something went wrong: Network Error");
});
it("should have proper cache when no error is thrown", async () => {
+33 -44
View File
@@ -1,13 +1,11 @@
// @ts-check
import { afterEach, describe, expect, it, jest } from "@jest/globals";
import { jest } from "@jest/globals";
import "@testing-library/jest-dom";
import axios from "axios";
import MockAdapter from "axios-mock-adapter";
import pin from "../api/pin.js";
import { renderRepoCard } from "../src/cards/repo.js";
import { renderError } from "../src/common/render.js";
import { CACHE_TTL, DURATIONS } from "../src/common/cache.js";
import { CONSTANTS, renderError } from "../src/common/utils.js";
import { expect, it, describe, afterEach } from "@jest/globals";
const data_repo = {
repository: {
@@ -56,9 +54,8 @@ describe("Test /api/pin", () => {
await pin(req, res);
expect(res.setHeader).toHaveBeenCalledWith("Content-Type", "image/svg+xml");
expect(res.send).toHaveBeenCalledWith(
// @ts-ignore
expect(res.setHeader).toBeCalledWith("Content-Type", "image/svg+xml");
expect(res.send).toBeCalledWith(
renderRepoCard({
...data_repo.repository,
starCount: data_repo.repository.stargazers.totalCount,
@@ -86,10 +83,9 @@ describe("Test /api/pin", () => {
await pin(req, res);
expect(res.setHeader).toHaveBeenCalledWith("Content-Type", "image/svg+xml");
expect(res.send).toHaveBeenCalledWith(
expect(res.setHeader).toBeCalledWith("Content-Type", "image/svg+xml");
expect(res.send).toBeCalledWith(
renderRepoCard(
// @ts-ignore
{
...data_repo.repository,
starCount: data_repo.repository.stargazers.totalCount,
@@ -116,10 +112,8 @@ describe("Test /api/pin", () => {
await pin(req, res);
expect(res.setHeader).toHaveBeenCalledWith("Content-Type", "image/svg+xml");
expect(res.send).toHaveBeenCalledWith(
renderError({ message: "User Repository Not found" }),
);
expect(res.setHeader).toBeCalledWith("Content-Type", "image/svg+xml");
expect(res.send).toBeCalledWith(renderError("User Repository Not found"));
});
it("should render error card if org repo not found", async () => {
@@ -139,9 +133,9 @@ describe("Test /api/pin", () => {
await pin(req, res);
expect(res.setHeader).toHaveBeenCalledWith("Content-Type", "image/svg+xml");
expect(res.send).toHaveBeenCalledWith(
renderError({ message: "Organization Repository Not found" }),
expect(res.setHeader).toBeCalledWith("Content-Type", "image/svg+xml");
expect(res.send).toBeCalledWith(
renderError("Organization Repository Not found"),
);
});
@@ -160,13 +154,13 @@ describe("Test /api/pin", () => {
await pin(req, res);
expect(res.setHeader).toHaveBeenCalledWith("Content-Type", "image/svg+xml");
expect(res.send).toHaveBeenCalledWith(
renderError({
message: "This username is blacklisted",
secondaryMessage: "Please deploy your own instance",
renderOptions: { show_repo_link: false },
}),
expect(res.setHeader).toBeCalledWith("Content-Type", "image/svg+xml");
expect(res.send).toBeCalledWith(
renderError(
"This username is blacklisted",
"Please deploy your own instance",
{ show_repo_link: false },
),
);
});
@@ -186,12 +180,9 @@ describe("Test /api/pin", () => {
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",
}),
expect(res.setHeader).toBeCalledWith("Content-Type", "image/svg+xml");
expect(res.send).toBeCalledWith(
renderError("Something went wrong", "Language not found"),
);
});
@@ -206,14 +197,12 @@ describe("Test /api/pin", () => {
await pin(req, res);
expect(res.setHeader).toHaveBeenCalledWith("Content-Type", "image/svg+xml");
expect(res.send).toHaveBeenCalledWith(
renderError({
message:
'Missing params "username", "repo" make sure you pass the parameters in URL',
secondaryMessage: "/api/pin?username=USERNAME&amp;repo=REPO_NAME",
renderOptions: { show_repo_link: false },
}),
expect(res.setHeader).toBeCalledWith("Content-Type", "image/svg+xml");
expect(res.send).toBeCalledWith(
renderError(
'Missing params "username", "repo" make sure you pass the parameters in URL',
"/api/pin?username=USERNAME&amp;repo=REPO_NAME",
),
);
});
@@ -232,12 +221,12 @@ describe("Test /api/pin", () => {
await pin(req, res);
expect(res.setHeader).toHaveBeenCalledWith("Content-Type", "image/svg+xml");
expect(res.setHeader).toHaveBeenCalledWith(
expect(res.setHeader).toBeCalledWith("Content-Type", "image/svg+xml");
expect(res.setHeader).toBeCalledWith(
"Cache-Control",
`max-age=${CACHE_TTL.PIN_CARD.DEFAULT}, ` +
`s-maxage=${CACHE_TTL.PIN_CARD.DEFAULT}, ` +
`stale-while-revalidate=${DURATIONS.ONE_DAY}`,
`max-age=${CONSTANTS.PIN_CARD_CACHE_SECONDS}, s-maxage=${
CONSTANTS.PIN_CARD_CACHE_SECONDS
}`,
);
});
});
-27
View File
@@ -1,27 +0,0 @@
// @ts-check
import { describe, expect, it } from "@jest/globals";
import { queryByTestId } from "@testing-library/dom";
import "@testing-library/jest-dom/jest-globals";
import { renderError } from "../src/common/render.js";
describe("Test render.js", () => {
it("should test renderError", () => {
document.body.innerHTML = renderError({ message: "Something went wrong" });
expect(
queryByTestId(document.body, "message")?.children[0],
).toHaveTextContent(/Something went wrong/gim);
expect(
queryByTestId(document.body, "message")?.children[1],
).toBeEmptyDOMElement();
// Secondary message
document.body.innerHTML = renderError({
message: "Something went wrong",
secondaryMessage: "Secondary Message",
});
expect(
queryByTestId(document.body, "message")?.children[1],
).toHaveTextContent(/Secondary Message/gim);
});
});
+2 -2
View File
@@ -1,9 +1,9 @@
import { renderGistCard } from "../src/cards/gist.js";
import { describe, expect, it } from "@jest/globals";
import { queryByTestId } from "@testing-library/dom";
import "@testing-library/jest-dom";
import { cssToObject } from "@uppercod/css-to-object";
import { renderGistCard } from "../src/cards/gist.js";
import { themes } from "../themes/index.js";
import "@testing-library/jest-dom";
/**
* @type {import("../src/fetchers/gist").GistData}

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