├── .nvmrc ├── .husky ├── .gitignore └── pre-commit ├── .prettierignore ├── src ├── index.js ├── cards │ ├── index.js │ ├── types.d.ts │ ├── gist.js │ └── repo.js ├── common │ ├── blacklist.js │ ├── index.js │ ├── log.js │ ├── envs.js │ ├── html.js │ ├── http.js │ ├── I18n.js │ ├── access.js │ ├── error.js │ ├── fmt.js │ ├── retryer.js │ ├── ops.js │ ├── cache.js │ └── color.js ├── fetchers │ ├── wakatime.js │ ├── types.d.ts │ ├── gist.js │ ├── repo.js │ └── top-languages.js └── calculateRank.js ├── .prettierrc.json ├── jest.e2e.config.js ├── .vercelignore ├── .vscode ├── settings.json └── extensions.json ├── codecov.yml ├── .gitignore ├── .github ├── workflows │ ├── deploy-prep.py │ ├── deploy-prep.yml │ ├── label-pr.yml │ ├── test.yml │ ├── codeql-analysis.yml │ ├── prs-cache-clean.yml │ ├── e2e-test.yml │ ├── preview-theme.yml │ ├── theme-prs-closer.yml │ ├── top-issues-dashboard.yml │ ├── empty-issues-closer.yml │ ├── ossf-analysis.yml │ ├── generate-theme-doc.yml │ ├── stale-theme-pr-closer.yml │ └── update-langs.yml ├── CODEOWNERS ├── ISSUE_TEMPLATE │ ├── config.yml │ ├── feature_request.yml │ └── bug_report.yml ├── stale.yml ├── FUNDING.yml ├── dependabot.yml └── labeler.yml ├── vercel.json ├── tests ├── html.test.js ├── bench │ ├── calculateRank.bench.js │ ├── pin.bench.js │ ├── gist.bench.js │ ├── api.bench.js │ └── utils.js ├── render.test.js ├── i18n.test.js ├── flexLayout.test.js ├── color.test.js ├── retryer.test.js ├── calculateRank.test.js ├── ops.test.js ├── renderWakatimeCard.test.js ├── fetchRepo.test.js ├── fetchGist.test.js ├── fetchWakatime.test.js ├── fmt.test.js ├── wakatime.test.js ├── fetchTopLanguages.test.js └── card.test.js ├── jest.config.js ├── jest.bench.config.js ├── scripts ├── push-theme-readme.sh ├── generate-langs-json.js ├── helpers.js ├── generate-theme-doc.js └── close-stale-theme-prs.js ├── express.js ├── .devcontainer └── devcontainer.json ├── LICENSE ├── SECURITY.md ├── eslint.config.mjs ├── package.json ├── api ├── gist.js ├── status │ ├── up.js │ └── pat-info.js ├── pin.js ├── wakatime.js ├── index.js └── top-langs.js ├── CODE_OF_CONDUCT.md ├── powered-by-vercel.svg └── CONTRIBUTING.md /.nvmrc: -------------------------------------------------------------------------------- 1 | 22 -------------------------------------------------------------------------------- /.husky/.gitignore: -------------------------------------------------------------------------------- 1 | _ 2 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | npm test 2 | npm run lint 3 | npx lint-staged 4 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | *.json 3 | *.md 4 | coverage 5 | .vercel 6 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | export * from "./common/index.js"; 2 | export * from "./cards/index.js"; 3 | -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "trailingComma": "all", 3 | "useTabs": false, 4 | "endOfLine": "auto", 5 | "proseWrap": "always" 6 | } -------------------------------------------------------------------------------- /jest.e2e.config.js: -------------------------------------------------------------------------------- 1 | export default { 2 | clearMocks: true, 3 | transform: {}, 4 | testEnvironment: "node", 5 | coverageProvider: "v8", 6 | testMatch: ["/tests/e2e/**/*.test.js"], 7 | }; 8 | -------------------------------------------------------------------------------- /src/cards/index.js: -------------------------------------------------------------------------------- 1 | export { renderRepoCard } from "./repo.js"; 2 | export { renderStatsCard } from "./stats.js"; 3 | export { renderTopLanguages } from "./top-languages.js"; 4 | export { renderWakatimeCard } from "./wakatime.js"; 5 | -------------------------------------------------------------------------------- /src/common/blacklist.js: -------------------------------------------------------------------------------- 1 | const blacklist = [ 2 | "renovate-bot", 3 | "technote-space", 4 | "sw-yx", 5 | "YourUsername", 6 | "[YourUsername]", 7 | ]; 8 | 9 | export { blacklist }; 10 | export default blacklist; 11 | -------------------------------------------------------------------------------- /.vercelignore: -------------------------------------------------------------------------------- 1 | .devcontainer 2 | .github 3 | .husky 4 | .vscode 5 | benchmarks 6 | coverage 7 | scripts 8 | tests 9 | .env 10 | **/*.md 11 | **/*.svg 12 | .eslintrc.json 13 | .prettierignore 14 | .pretterrc.json 15 | codecov.yml 16 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "markdown.extension.toc.levels": "1..3", 3 | "editor.formatOnSave": true, 4 | "editor.defaultFormatter": "esbenp.prettier-vscode", 5 | "[javascript]": { 6 | "editor.tabSize": 2 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /codecov.yml: -------------------------------------------------------------------------------- 1 | codecov: 2 | require_ci_to_pass: yes 3 | 4 | coverage: 5 | precision: 2 6 | round: down 7 | range: "70...100" 8 | 9 | status: 10 | project: 11 | default: 12 | threshold: 5 13 | patch: false 14 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .vercel 2 | .env 3 | node_modules 4 | *.lock 5 | .idea/ 6 | coverage 7 | benchmarks 8 | vercel_token 9 | 10 | # IDE 11 | .vscode/* 12 | !.vscode/extensions.json 13 | !.vscode/settings.json 14 | *.code-workspace 15 | 16 | .vercel 17 | -------------------------------------------------------------------------------- /.github/workflows/deploy-prep.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | file = open('./vercel.json', 'r') 4 | str = file.read() 5 | file = open('./vercel.json', 'w') 6 | 7 | str = str.replace('"maxDuration": 10', '"maxDuration": 15') 8 | 9 | file.write(str) 10 | file.close() 11 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": [ 3 | "yzhang.markdown-all-in-one", 4 | "esbenp.prettier-vscode", 5 | "dbaeumer.vscode-eslint", 6 | "ms-azuretools.vscode-containers", 7 | "github.vscode-github-actions" 8 | ] 9 | } 10 | -------------------------------------------------------------------------------- /vercel.json: -------------------------------------------------------------------------------- 1 | { 2 | "functions": { 3 | "api/*.js": { 4 | "memory": 128, 5 | "maxDuration": 10 6 | } 7 | }, 8 | "redirects": [ 9 | { 10 | "source": "/", 11 | "destination": "https://github.com/anuraghazra/github-readme-stats" 12 | } 13 | ] 14 | } -------------------------------------------------------------------------------- /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | # This file is used to define code owners for the repository. 2 | # Code owners are automatically requested for review when someone opens a pull request that modifies code they own. 3 | 4 | # Assign @qwerty541 as the owner for package.json and package-lock.json 5 | package.json @qwerty541 6 | package-lock.json @qwerty541 7 | -------------------------------------------------------------------------------- /src/common/index.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | export { blacklist } from "./blacklist.js"; 4 | export { Card } from "./Card.js"; 5 | export { I18n } from "./I18n.js"; 6 | export { icons } from "./icons.js"; 7 | export { retryer } from "./retryer.js"; 8 | export { 9 | ERROR_CARD_LENGTH, 10 | renderError, 11 | flexLayout, 12 | measureText, 13 | } from "./render.js"; 14 | -------------------------------------------------------------------------------- /src/common/log.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | const noop = () => {}; 4 | 5 | /** 6 | * Return console instance based on the environment. 7 | * 8 | * @type {Console | {log: () => void, error: () => void}} 9 | */ 10 | const logger = 11 | process.env.NODE_ENV === "test" ? { log: noop, error: noop } : console; 12 | 13 | export { logger }; 14 | export default logger; 15 | -------------------------------------------------------------------------------- /tests/html.test.js: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from "@jest/globals"; 2 | import { encodeHTML } from "../src/common/html.js"; 3 | 4 | describe("Test html.js", () => { 5 | it("should test encodeHTML", () => { 6 | expect(encodeHTML(`hello world<,.#4^&^@%!))`)).toBe( 7 | "<html>hello world<,.#4^&^@%!))", 8 | ); 9 | }); 10 | }); 11 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | export default { 2 | clearMocks: true, 3 | transform: {}, 4 | testEnvironment: "jsdom", 5 | coverageProvider: "v8", 6 | testPathIgnorePatterns: ["/node_modules/", "/tests/e2e/"], 7 | modulePathIgnorePatterns: ["/node_modules/", "/tests/e2e/"], 8 | coveragePathIgnorePatterns: [ 9 | "/node_modules/", 10 | "/tests/E2E/", 11 | ], 12 | }; 13 | -------------------------------------------------------------------------------- /src/common/envs.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | const whitelist = process.env.WHITELIST 4 | ? process.env.WHITELIST.split(",") 5 | : undefined; 6 | 7 | const gistWhitelist = process.env.GIST_WHITELIST 8 | ? process.env.GIST_WHITELIST.split(",") 9 | : undefined; 10 | 11 | const excludeRepositories = process.env.EXCLUDE_REPO 12 | ? process.env.EXCLUDE_REPO.split(",") 13 | : []; 14 | 15 | export { whitelist, gistWhitelist, excludeRepositories }; 16 | -------------------------------------------------------------------------------- /jest.bench.config.js: -------------------------------------------------------------------------------- 1 | export default { 2 | clearMocks: true, 3 | transform: {}, 4 | testEnvironment: "jsdom", 5 | coverageProvider: "v8", 6 | testPathIgnorePatterns: ["/node_modules/", "/tests/e2e/"], 7 | modulePathIgnorePatterns: ["/node_modules/", "/tests/e2e/"], 8 | coveragePathIgnorePatterns: [ 9 | "/node_modules/", 10 | "/tests/e2e/", 11 | ], 12 | testRegex: "(\\.bench)\\.(ts|tsx|js)$", 13 | }; 14 | -------------------------------------------------------------------------------- /src/common/html.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | /** 4 | * Encode string as HTML. 5 | * 6 | * @see https://stackoverflow.com/a/48073476/10629172 7 | * 8 | * @param {string} str String to encode. 9 | * @returns {string} Encoded string. 10 | */ 11 | const encodeHTML = (str) => { 12 | return str 13 | .replace(/[\u00A0-\u9999<>&](?!#)/gim, (i) => { 14 | return "&#" + i.charCodeAt(0) + ";"; 15 | }) 16 | .replace(/\u0008/gim, ""); 17 | }; 18 | 19 | export { encodeHTML }; 20 | -------------------------------------------------------------------------------- /tests/bench/calculateRank.bench.js: -------------------------------------------------------------------------------- 1 | import { calculateRank } from "../../src/calculateRank.js"; 2 | import { it } from "@jest/globals"; 3 | import { runAndLogStats } from "./utils.js"; 4 | 5 | it("calculateRank", async () => { 6 | await runAndLogStats("calculateRank", () => { 7 | calculateRank({ 8 | all_commits: false, 9 | commits: 1300, 10 | prs: 1500, 11 | issues: 4500, 12 | reviews: 1000, 13 | repos: 0, 14 | stars: 600000, 15 | followers: 50000, 16 | }); 17 | }); 18 | }); 19 | -------------------------------------------------------------------------------- /src/common/http.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | import axios from "axios"; 4 | 5 | /** 6 | * Send GraphQL request to GitHub API. 7 | * 8 | * @param {import('axios').AxiosRequestConfig['data']} data Request data. 9 | * @param {import('axios').AxiosRequestConfig['headers']} headers Request headers. 10 | * @returns {Promise} Request response. 11 | */ 12 | const request = (data, headers) => { 13 | return axios({ 14 | url: "https://api.github.com/graphql", 15 | method: "post", 16 | headers, 17 | data, 18 | }); 19 | }; 20 | 21 | export { request }; 22 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | blank_issues_enabled: true 2 | contact_links: 3 | - name: Question 4 | url: https://github.com/anuraghazra/github-readme-stats/discussions 5 | about: Please ask and answer questions here. 6 | - name: Error 7 | url: https://github.com/anuraghazra/github-readme-stats/issues/1772 8 | about: 9 | Before opening a bug report, please check the 'Common Error Codes' issue. 10 | - name: FAQ 11 | url: https://github.com/anuraghazra/github-readme-stats/discussions/1770 12 | about: Please first check the FAQ before asking a question. 13 | -------------------------------------------------------------------------------- /scripts/push-theme-readme.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -x 3 | set -e 4 | 5 | export BRANCH_NAME=updated-theme-readme 6 | git --version 7 | git config --global user.email "no-reply@githubreadmestats.com" 8 | git config --global user.name "GitHub Readme Stats Bot" 9 | git config --global --add safe.directory ${GITHUB_WORKSPACE} 10 | git branch -d $BRANCH_NAME || true 11 | git checkout -b $BRANCH_NAME 12 | git add --all 13 | git commit --no-verify --message "docs(theme): auto update theme readme" 14 | git remote add origin-$BRANCH_NAME https://${PERSONAL_TOKEN}@github.com/${GH_REPO}.git 15 | git push --force --quiet --set-upstream origin-$BRANCH_NAME $BRANCH_NAME 16 | -------------------------------------------------------------------------------- /.github/workflows/deploy-prep.yml: -------------------------------------------------------------------------------- 1 | name: Deployment Prep 2 | on: 3 | workflow_dispatch: 4 | push: 5 | branches: 6 | - master 7 | 8 | jobs: 9 | config: 10 | if: github.repository == 'anuraghazra/github-readme-stats' 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 14 | - name: Deployment Prep 15 | run: python ./.github/workflows/deploy-prep.py 16 | - uses: stefanzweifel/git-auto-commit-action@28e16e81777b558cc906c8750092100bbb34c5e3 # v7.0.0 17 | with: 18 | branch: vercel 19 | create_branch: true 20 | push_options: "--force" 21 | -------------------------------------------------------------------------------- /.github/workflows/label-pr.yml: -------------------------------------------------------------------------------- 1 | name: "Pull Request Labeler" 2 | on: 3 | - pull_request_target 4 | 5 | permissions: 6 | actions: read 7 | checks: read 8 | contents: read 9 | deployments: read 10 | issues: read 11 | discussions: read 12 | packages: read 13 | pages: read 14 | pull-requests: write 15 | repository-projects: read 16 | security-events: read 17 | statuses: read 18 | 19 | jobs: 20 | triage: 21 | if: github.repository == 'anuraghazra/github-readme-stats' 22 | runs-on: ubuntu-latest 23 | steps: 24 | - uses: actions/labeler@634933edcd8ababfe52f92936142cc22ac488b1b # v6.0.1 25 | with: 26 | repo-token: "${{ secrets.GITHUB_TOKEN }}" 27 | sync-labels: true 28 | -------------------------------------------------------------------------------- /express.js: -------------------------------------------------------------------------------- 1 | import "dotenv/config"; 2 | import statsCard from "./api/index.js"; 3 | import repoCard from "./api/pin.js"; 4 | import langCard from "./api/top-langs.js"; 5 | import wakatimeCard from "./api/wakatime.js"; 6 | import gistCard from "./api/gist.js"; 7 | import express from "express"; 8 | 9 | const app = express(); 10 | const router = express.Router(); 11 | 12 | router.get("/", statsCard); 13 | router.get("/pin", repoCard); 14 | router.get("/top-langs", langCard); 15 | router.get("/wakatime", wakatimeCard); 16 | router.get("/gist", gistCard); 17 | 18 | app.use("/api", router); 19 | 20 | const port = process.env.PORT || process.env.port || 9000; 21 | app.listen(port, "0.0.0.0", () => { 22 | console.log(`Server running on port ${port}`); 23 | }); 24 | -------------------------------------------------------------------------------- /.github/stale.yml: -------------------------------------------------------------------------------- 1 | # Number of days of inactivity before an issue becomes stale 2 | daysUntilStale: 30 3 | # Number of days of inactivity before a stale issue is closed 4 | daysUntilClose: 7 5 | # Issues with these labels will never be considered stale 6 | exemptLabels: 7 | - feature 8 | - enhancement 9 | - help wanted 10 | - bug 11 | 12 | # Label to use when marking an issue as stale 13 | staleLabel: stale 14 | # Comment to post when marking an issue as stale. Set to `false` to disable 15 | markComment: > 16 | This issue has been automatically marked as stale because it has not had 17 | recent activity. It will be closed if no further activity occurs. Thank you 18 | for your contributions. 19 | # Comment to post when closing a stale issue. Set to `false` to disable 20 | closeComment: false 21 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: [anuraghazra] # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2] 4 | patreon: # Replace with a single Patreon username 5 | open_collective: # Replace with a single Open Collective username 6 | ko_fi: # Replace with a single Ko-fi username 7 | tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel 8 | community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry 9 | liberapay: # Replace with a single Liberapay username 10 | issuehunt: # Replace with a single IssueHunt username 11 | otechie: # Replace with a single Otechie username 12 | custom: [ 13 | "https://www.paypal.me/anuraghazra", 14 | "https://www.buymeacoffee.com/anuraghazra", 15 | ] # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2'] 16 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | # Maintain dependencies for npm 4 | - package-ecosystem: npm 5 | directory: "/" 6 | schedule: 7 | interval: weekly 8 | open-pull-requests-limit: 10 9 | commit-message: 10 | prefix: "build(deps)" 11 | prefix-development: "build(deps-dev)" 12 | 13 | # Maintain dependencies for GitHub Actions 14 | - package-ecosystem: github-actions 15 | directory: "/" 16 | schedule: 17 | interval: weekly 18 | open-pull-requests-limit: 10 19 | commit-message: 20 | prefix: "ci(deps)" 21 | prefix-development: "ci(deps-dev)" 22 | 23 | # Maintain dependencies for Devcontainers 24 | - package-ecosystem: devcontainers 25 | directory: "/" 26 | schedule: 27 | interval: weekly 28 | open-pull-requests-limit: 10 29 | commit-message: 30 | prefix: "build(deps)" 31 | prefix-development: "build(deps-dev)" 32 | -------------------------------------------------------------------------------- /scripts/generate-langs-json.js: -------------------------------------------------------------------------------- 1 | import axios from "axios"; 2 | import fs from "fs"; 3 | import jsYaml from "js-yaml"; 4 | 5 | const LANGS_FILEPATH = "./src/common/languageColors.json"; 6 | 7 | //Retrieve languages from github linguist repository yaml file 8 | //@ts-ignore 9 | axios 10 | .get( 11 | "https://raw.githubusercontent.com/github/linguist/master/lib/linguist/languages.yml", 12 | ) 13 | .then((response) => { 14 | //and convert them to a JS Object 15 | const languages = jsYaml.load(response.data); 16 | 17 | const languageColors = {}; 18 | 19 | //Filter only language colors from the whole file 20 | Object.keys(languages).forEach((lang) => { 21 | languageColors[lang] = languages[lang].color; 22 | }); 23 | 24 | //Debug Print 25 | //console.dir(languageColors); 26 | fs.writeFileSync( 27 | LANGS_FILEPATH, 28 | JSON.stringify(languageColors, null, " "), 29 | ); 30 | }); 31 | -------------------------------------------------------------------------------- /tests/render.test.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | import { describe, expect, it } from "@jest/globals"; 4 | import { queryByTestId } from "@testing-library/dom"; 5 | import "@testing-library/jest-dom/jest-globals"; 6 | import { renderError } from "../src/common/render.js"; 7 | 8 | describe("Test render.js", () => { 9 | it("should test renderError", () => { 10 | document.body.innerHTML = renderError({ message: "Something went wrong" }); 11 | expect( 12 | queryByTestId(document.body, "message")?.children[0], 13 | ).toHaveTextContent(/Something went wrong/gim); 14 | expect( 15 | queryByTestId(document.body, "message")?.children[1], 16 | ).toBeEmptyDOMElement(); 17 | 18 | // Secondary message 19 | document.body.innerHTML = renderError({ 20 | message: "Something went wrong", 21 | secondaryMessage: "Secondary Message", 22 | }); 23 | expect( 24 | queryByTestId(document.body, "message")?.children[1], 25 | ).toHaveTextContent(/Secondary Message/gim); 26 | }); 27 | }); 28 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.yml: -------------------------------------------------------------------------------- 1 | name: Feature request 2 | description: Suggest an idea for this project. 3 | labels: 4 | - "enhancement" 5 | body: 6 | - type: textarea 7 | attributes: 8 | label: Is your feature request related to a problem? Please describe. 9 | description: 10 | A clear and concise description of what the problem is. Ex. I'm always 11 | frustrated when [...] 12 | validations: 13 | required: true 14 | - type: textarea 15 | attributes: 16 | label: Describe the solution you'd like 17 | description: A clear and concise description of what you want to happen. 18 | - type: textarea 19 | attributes: 20 | label: Describe alternatives you've considered 21 | description: 22 | A clear and concise description of any alternative solutions or features 23 | you've considered. 24 | - type: textarea 25 | attributes: 26 | label: Additional context 27 | description: 28 | Add any other context or screenshots about the feature request here. 29 | -------------------------------------------------------------------------------- /src/common/I18n.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | const FALLBACK_LOCALE = "en"; 4 | 5 | /** 6 | * I18n translation class. 7 | */ 8 | class I18n { 9 | /** 10 | * Constructor. 11 | * 12 | * @param {Object} options Options. 13 | * @param {string=} options.locale Locale. 14 | * @param {any} options.translations Translations. 15 | */ 16 | constructor({ locale, translations }) { 17 | this.locale = locale || FALLBACK_LOCALE; 18 | this.translations = translations; 19 | } 20 | 21 | /** 22 | * Get translation. 23 | * 24 | * @param {string} str String to translate. 25 | * @returns {string} Translated string. 26 | */ 27 | t(str) { 28 | if (!this.translations[str]) { 29 | throw new Error(`${str} Translation string not found`); 30 | } 31 | 32 | if (!this.translations[str][this.locale]) { 33 | throw new Error( 34 | `'${str}' translation not found for locale '${this.locale}'`, 35 | ); 36 | } 37 | 38 | return this.translations[str][this.locale]; 39 | } 40 | } 41 | 42 | export { I18n }; 43 | export default I18n; 44 | -------------------------------------------------------------------------------- /scripts/helpers.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @file Contains helper functions used in the scripts. 3 | */ 4 | 5 | import { getInput } from "@actions/core"; 6 | 7 | const OWNER = "anuraghazra"; 8 | const REPO = "github-readme-stats"; 9 | 10 | /** 11 | * Retrieve information about the repository that ran the action. 12 | * 13 | * @param {Object} ctx Action context. 14 | * @returns {Object} Repository information. 15 | */ 16 | export const getRepoInfo = (ctx) => { 17 | try { 18 | return { 19 | owner: ctx.repo.owner, 20 | repo: ctx.repo.repo, 21 | }; 22 | } catch (error) { 23 | // Resolve eslint no-unused-vars 24 | error; 25 | 26 | return { 27 | owner: OWNER, 28 | repo: REPO, 29 | }; 30 | } 31 | }; 32 | 33 | /** 34 | * Retrieve github token and throw error if it is not found. 35 | * 36 | * @returns {string} GitHub token. 37 | */ 38 | export const getGithubToken = () => { 39 | const token = getInput("github_token") || process.env.GITHUB_TOKEN; 40 | if (!token) { 41 | throw Error("Could not find github token"); 42 | } 43 | return token; 44 | }; 45 | -------------------------------------------------------------------------------- /.devcontainer/devcontainer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "GitHub Readme Stats Dev", 3 | "image": "mcr.microsoft.com/devcontainers/base:ubuntu", 4 | "features": { 5 | "ghcr.io/devcontainers/features/node:1": { "version": "22" } 6 | }, 7 | "forwardPorts": [3000], 8 | "portsAttributes": { 9 | "3000": { "label": "HTTP" } 10 | }, 11 | "appPort": [], 12 | 13 | // Use 'postCreateCommand' to run commands after the container is created. 14 | "postCreateCommand": "npm install -g vercel", 15 | 16 | // Use 'postStartCommand' to run commands after the container is started. 17 | "postStartCommand": "hostname dev && npm install", 18 | 19 | // Configure tool-specific properties. 20 | "customizations": { 21 | "vscode": { 22 | "extensions": [ 23 | "yzhang.markdown-all-in-one", 24 | "esbenp.prettier-vscode", 25 | "dbaeumer.vscode-eslint", 26 | "github.vscode-github-actions" 27 | ] 28 | } 29 | }, 30 | 31 | "remoteUser": "root", 32 | "privileged": true 33 | } 34 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Anurag Hazra 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/fetchers/wakatime.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | import axios from "axios"; 4 | import { CustomError, MissingParamError } from "../common/error.js"; 5 | 6 | /** 7 | * WakaTime data fetcher. 8 | * 9 | * @param {{username: string, api_domain: string }} props Fetcher props. 10 | * @returns {Promise} WakaTime data response. 11 | */ 12 | const fetchWakatimeStats = async ({ username, api_domain }) => { 13 | if (!username) { 14 | throw new MissingParamError(["username"]); 15 | } 16 | 17 | try { 18 | const { data } = await axios.get( 19 | `https://${ 20 | api_domain ? api_domain.replace(/\/$/gi, "") : "wakatime.com" 21 | }/api/v1/users/${username}/stats?is_including_today=true`, 22 | ); 23 | 24 | return data.data; 25 | } catch (err) { 26 | if (err.response.status < 200 || err.response.status > 299) { 27 | throw new CustomError( 28 | `Could not resolve to a User with the login of '${username}'`, 29 | "WAKATIME_USER_NOT_FOUND", 30 | ); 31 | } 32 | throw err; 33 | } 34 | }; 35 | 36 | export { fetchWakatimeStats }; 37 | export default fetchWakatimeStats; 38 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | on: 3 | push: 4 | branches: 5 | - master 6 | pull_request: 7 | branches: 8 | - master 9 | 10 | permissions: read-all 11 | 12 | jobs: 13 | build: 14 | name: Perform tests 15 | runs-on: ubuntu-latest 16 | strategy: 17 | matrix: 18 | node-version: [22.x] 19 | 20 | steps: 21 | - uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 22 | 23 | - name: Setup Node 24 | uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0 25 | with: 26 | node-version: ${{ matrix.node-version }} 27 | cache: npm 28 | 29 | - name: Install & Test 30 | run: | 31 | npm ci 32 | npm run test 33 | 34 | - name: Run ESLint 35 | run: | 36 | npm run lint 37 | 38 | - name: Run bench tests 39 | run: | 40 | npm run bench 41 | 42 | - name: Run Prettier 43 | run: | 44 | npm run format:check 45 | 46 | - name: Code Coverage 47 | uses: codecov/codecov-action@4fe8c5f003fae66aa5ebb77cfd3e7bfbbda0b6b0 # v3.1.5 48 | -------------------------------------------------------------------------------- /.github/workflows/codeql-analysis.yml: -------------------------------------------------------------------------------- 1 | name: "Static code analysis workflow (CodeQL)" 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | pull_request: 8 | branches: 9 | - master 10 | 11 | permissions: 12 | actions: read 13 | checks: read 14 | contents: read 15 | deployments: read 16 | issues: read 17 | discussions: read 18 | packages: read 19 | pages: read 20 | pull-requests: read 21 | repository-projects: read 22 | security-events: write 23 | statuses: read 24 | 25 | jobs: 26 | CodeQL-Build: 27 | if: github.repository == 'anuraghazra/github-readme-stats' 28 | 29 | # CodeQL runs on ubuntu-latest, windows-latest, and macos-latest 30 | runs-on: ubuntu-latest 31 | 32 | steps: 33 | - name: Checkout repository 34 | uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 35 | 36 | # Initializes the CodeQL tools for scanning. 37 | - name: Initialize CodeQL 38 | uses: github/codeql-action/init@46a6823b81f2d7c67ddf123851eea88365bc8a67 # v2.13.5 39 | with: 40 | languages: javascript 41 | 42 | - name: Perform CodeQL Analysis 43 | uses: github/codeql-action/analyze@46a6823b81f2d7c67ddf123851eea88365bc8a67 # v2.13.5 44 | -------------------------------------------------------------------------------- /tests/i18n.test.js: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from "@jest/globals"; 2 | import { I18n } from "../src/common/I18n.js"; 3 | import { statCardLocales } from "../src/translations.js"; 4 | 5 | describe("I18n", () => { 6 | it("should return translated string", () => { 7 | const i18n = new I18n({ 8 | locale: "en", 9 | translations: statCardLocales({ name: "Anurag Hazra", apostrophe: "s" }), 10 | }); 11 | expect(i18n.t("statcard.title")).toBe("Anurag Hazra's GitHub Stats"); 12 | }); 13 | 14 | it("should throw error if translation string not found", () => { 15 | const i18n = new I18n({ 16 | locale: "en", 17 | translations: statCardLocales({ name: "Anurag Hazra", apostrophe: "s" }), 18 | }); 19 | expect(() => i18n.t("statcard.title1")).toThrow( 20 | "statcard.title1 Translation string not found", 21 | ); 22 | }); 23 | 24 | it("should throw error if translation not found for locale", () => { 25 | const i18n = new I18n({ 26 | locale: "asdf", 27 | translations: statCardLocales({ name: "Anurag Hazra", apostrophe: "s" }), 28 | }); 29 | expect(() => i18n.t("statcard.title")).toThrow( 30 | "'statcard.title' translation not found for locale 'asdf'", 31 | ); 32 | }); 33 | }); 34 | -------------------------------------------------------------------------------- /.github/workflows/prs-cache-clean.yml: -------------------------------------------------------------------------------- 1 | name: Cleanup closed pull requests cache 2 | on: 3 | pull_request: 4 | types: 5 | - closed 6 | 7 | permissions: 8 | actions: write 9 | checks: read 10 | contents: read 11 | deployments: read 12 | issues: read 13 | discussions: read 14 | packages: read 15 | pages: read 16 | pull-requests: read 17 | repository-projects: read 18 | security-events: read 19 | statuses: read 20 | 21 | jobs: 22 | cleanup: 23 | runs-on: ubuntu-latest 24 | steps: 25 | - name: Cleanup 26 | run: | 27 | gh extension install actions/gh-actions-cache 28 | 29 | REPO=${{ github.repository }} 30 | BRANCH="refs/pull/${{ github.event.pull_request.number }}/merge" 31 | 32 | echo "Fetching list of cache key" 33 | cacheKeysForPR=$(gh actions-cache list -R $REPO -B $BRANCH | cut -f 1 ) 34 | 35 | ## Setting this to not fail the workflow while deleting cache keys. 36 | set +e 37 | echo "Deleting caches..." 38 | for cacheKey in $cacheKeysForPR 39 | do 40 | gh actions-cache delete $cacheKey -R $REPO -B $BRANCH --confirm 41 | done 42 | echo "Done" 43 | env: 44 | GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} 45 | -------------------------------------------------------------------------------- /.github/workflows/e2e-test.yml: -------------------------------------------------------------------------------- 1 | name: Test Deployment 2 | on: 3 | # Temporarily disabled automatic triggers; manual-only for now. 4 | workflow_dispatch: 5 | # Original trigger (restore to re-enable): 6 | # deployment_status: 7 | 8 | permissions: read-all 9 | 10 | jobs: 11 | e2eTests: 12 | # Temporarily disabled; set to the original condition to re-enable. 13 | # if: 14 | # github.repository == 'anuraghazra/github-readme-stats' && 15 | # github.event_name == 'deployment_status' && 16 | # github.event.deployment_status.state == 'success' 17 | if: false 18 | name: Perform 2e2 tests 19 | runs-on: ubuntu-latest 20 | strategy: 21 | matrix: 22 | node-version: [22.x] 23 | 24 | steps: 25 | - uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 26 | 27 | - name: Setup Node 28 | uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0 29 | with: 30 | node-version: ${{ matrix.node-version }} 31 | cache: npm 32 | 33 | - name: Install dependencies 34 | run: npm ci 35 | env: 36 | CI: true 37 | 38 | - name: Run end-to-end tests. 39 | run: npm run test:e2e 40 | # env: 41 | # VERCEL_PREVIEW_URL: ${{ github.event.deployment_status.target_url }} 42 | -------------------------------------------------------------------------------- /tests/bench/pin.bench.js: -------------------------------------------------------------------------------- 1 | import pin from "../../api/pin.js"; 2 | import axios from "axios"; 3 | import MockAdapter from "axios-mock-adapter"; 4 | import { it, jest } from "@jest/globals"; 5 | import { runAndLogStats } from "./utils.js"; 6 | 7 | const data_repo = { 8 | repository: { 9 | username: "anuraghazra", 10 | name: "convoychat", 11 | stargazers: { 12 | totalCount: 38000, 13 | }, 14 | description: "Help us take over the world! React + TS + GraphQL Chat App", 15 | primaryLanguage: { 16 | color: "#2b7489", 17 | id: "MDg6TGFuZ3VhZ2UyODc=", 18 | name: "TypeScript", 19 | }, 20 | forkCount: 100, 21 | isTemplate: false, 22 | }, 23 | }; 24 | 25 | const data_user = { 26 | data: { 27 | user: { repository: data_repo.repository }, 28 | organization: null, 29 | }, 30 | }; 31 | 32 | const mock = new MockAdapter(axios); 33 | mock.onPost("https://api.github.com/graphql").reply(200, data_user); 34 | 35 | it("test /api/pin", async () => { 36 | await runAndLogStats("test /api/pin", async () => { 37 | const req = { 38 | query: { 39 | username: "anuraghazra", 40 | repo: "convoychat", 41 | }, 42 | }; 43 | const res = { 44 | setHeader: jest.fn(), 45 | send: jest.fn(), 46 | }; 47 | 48 | await pin(req, res); 49 | }); 50 | }); 51 | -------------------------------------------------------------------------------- /.github/workflows/preview-theme.yml: -------------------------------------------------------------------------------- 1 | name: Theme preview 2 | on: 3 | # Temporary disabled due to paused themes addition. 4 | # See: https://github.com/anuraghazra/github-readme-stats/issues/3404 5 | # pull_request_target: 6 | # types: [opened, edited, reopened, synchronize] 7 | # branches: 8 | # - master 9 | # paths: 10 | # - "themes/index.js" 11 | workflow_dispatch: 12 | 13 | permissions: 14 | actions: read 15 | checks: read 16 | contents: read 17 | deployments: read 18 | issues: read 19 | discussions: read 20 | packages: read 21 | pages: read 22 | pull-requests: write 23 | repository-projects: read 24 | security-events: read 25 | statuses: read 26 | 27 | jobs: 28 | previewTheme: 29 | name: Install & Preview 30 | runs-on: ubuntu-latest 31 | strategy: 32 | matrix: 33 | node-version: [22.x] 34 | 35 | steps: 36 | - uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 37 | 38 | - name: Setup Node 39 | uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0 40 | with: 41 | node-version: ${{ matrix.node-version }} 42 | cache: npm 43 | 44 | - uses: bahmutov/npm-install@3e063b974f0d209807684aa23e534b3dde517fd9 # v1.11.2 45 | with: 46 | useLockFile: false 47 | 48 | - run: npm run preview-theme 49 | env: 50 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 51 | -------------------------------------------------------------------------------- /.github/workflows/theme-prs-closer.yml: -------------------------------------------------------------------------------- 1 | name: Theme Pull Requests Closer 2 | 3 | on: 4 | - pull_request_target 5 | 6 | permissions: 7 | actions: read 8 | checks: read 9 | contents: read 10 | deployments: read 11 | issues: read 12 | discussions: read 13 | packages: read 14 | pages: read 15 | pull-requests: write 16 | repository-projects: read 17 | security-events: read 18 | statuses: read 19 | 20 | jobs: 21 | close-prs: 22 | if: github.repository == 'anuraghazra/github-readme-stats' 23 | runs-on: ubuntu-latest 24 | steps: 25 | - name: Check out the code 26 | uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 27 | 28 | - name: Set up Git 29 | run: | 30 | git config user.name "github-actions[bot]" 31 | git config user.email "github-actions[bot]@users.noreply.github.com" 32 | 33 | - name: Close Pull Requests 34 | run: | 35 | 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)." 36 | 37 | for pr_number in $(gh pr list -l "themes" -q is:open --json number -q ".[].number"); do 38 | gh pr close $pr_number -c "$comment_message" 39 | done 40 | env: 41 | GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} 42 | -------------------------------------------------------------------------------- /tests/flexLayout.test.js: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from "@jest/globals"; 2 | import { flexLayout } from "../src/common/render.js"; 3 | 4 | describe("flexLayout", () => { 5 | it("should work with row & col layouts", () => { 6 | const layout = flexLayout({ 7 | items: ["1", "2"], 8 | gap: 60, 9 | }); 10 | 11 | expect(layout).toStrictEqual([ 12 | `1`, 13 | `2`, 14 | ]); 15 | 16 | const columns = flexLayout({ 17 | items: ["1", "2"], 18 | gap: 60, 19 | direction: "column", 20 | }); 21 | 22 | expect(columns).toStrictEqual([ 23 | `1`, 24 | `2`, 25 | ]); 26 | }); 27 | 28 | it("should work with sizes", () => { 29 | const layout = flexLayout({ 30 | items: [ 31 | "1", 32 | "2", 33 | "3", 34 | "4", 35 | ], 36 | gap: 20, 37 | sizes: [200, 100, 55, 25], 38 | }); 39 | 40 | expect(layout).toStrictEqual([ 41 | `1`, 42 | `2`, 43 | `3`, 44 | `4`, 45 | ]); 46 | }); 47 | }); 48 | -------------------------------------------------------------------------------- /tests/bench/gist.bench.js: -------------------------------------------------------------------------------- 1 | import gist from "../../api/gist.js"; 2 | import axios from "axios"; 3 | import MockAdapter from "axios-mock-adapter"; 4 | import { it, jest } from "@jest/globals"; 5 | import { runAndLogStats } from "./utils.js"; 6 | 7 | const gist_data = { 8 | data: { 9 | viewer: { 10 | gist: { 11 | description: 12 | "List of countries and territories in English and Spanish: name, continent, capital, dial code, country codes, TLD, and area in sq km. Lista de países y territorios en Inglés y Español: nombre, continente, capital, código de teléfono, códigos de país, dominio y área en km cuadrados. Updated 2023", 13 | owner: { 14 | login: "Yizack", 15 | }, 16 | stargazerCount: 33, 17 | forks: { 18 | totalCount: 11, 19 | }, 20 | files: [ 21 | { 22 | name: "countries.json", 23 | language: { 24 | name: "JSON", 25 | }, 26 | size: 85858, 27 | }, 28 | ], 29 | }, 30 | }, 31 | }, 32 | }; 33 | 34 | const mock = new MockAdapter(axios); 35 | mock.onPost("https://api.github.com/graphql").reply(200, gist_data); 36 | 37 | it("test /api/gist", async () => { 38 | await runAndLogStats("test /api/gist", async () => { 39 | const req = { 40 | query: { 41 | id: "bbfce31e0217a3689c8d961a356cb10d", 42 | }, 43 | }; 44 | const res = { 45 | setHeader: jest.fn(), 46 | send: jest.fn(), 47 | }; 48 | 49 | await gist(req, res); 50 | }); 51 | }); 52 | -------------------------------------------------------------------------------- /.github/workflows/top-issues-dashboard.yml: -------------------------------------------------------------------------------- 1 | name: Update top issues dashboard 2 | on: 3 | schedule: 4 | # ┌───────────── minute (0 - 59) 5 | # │ ┌───────────── hour (0 - 23) 6 | # │ │ ┌───────────── day of the month (1 - 31) 7 | # │ │ │ ┌───────────── month (1 - 12 or JAN-DEC) 8 | # │ │ │ │ ┌───────────── day of the week (0 - 6 or SUN-SAT) 9 | # │ │ │ │ │ 10 | # │ │ │ │ │ 11 | # │ │ │ │ │ 12 | # * * * * * 13 | - cron: "0 0 */3 * *" 14 | workflow_dispatch: 15 | 16 | permissions: 17 | actions: read 18 | checks: read 19 | contents: read 20 | deployments: read 21 | issues: write 22 | discussions: read 23 | packages: read 24 | pages: read 25 | pull-requests: write 26 | repository-projects: read 27 | security-events: read 28 | statuses: read 29 | 30 | jobs: 31 | showAndLabelTopIssues: 32 | if: github.repository == 'anuraghazra/github-readme-stats' 33 | name: Update top issues Dashboard. 34 | runs-on: ubuntu-latest 35 | steps: 36 | - name: Run top issues action 37 | uses: rickstaa/top-issues-action@7e8dda5d5ae3087670f9094b9724a9a091fc3ba1 # v1.3.101 38 | env: 39 | github_token: ${{ secrets.GITHUB_TOKEN }} 40 | with: 41 | top_list_size: 10 42 | filter: "1772" 43 | label: true 44 | dashboard: true 45 | dashboard_show_total_reactions: true 46 | top_issues: true 47 | top_bugs: true 48 | top_features: true 49 | top_pull_requests: true 50 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | # GitHub Readme Stats Security Policies and Procedures 2 | 3 | This document outlines security procedures and general policies for the 4 | GitHub Readme Stats project. 5 | 6 | - [Reporting a Vulnerability](#reporting-a-vulnerability) 7 | - [Disclosure Policy](#disclosure-policy) 8 | 9 | ## Reporting a Vulnerability 10 | 11 | The GitHub Readme Stats team and community take all security vulnerabilities 12 | seriously. Thank you for improving the security of our open source 13 | software. We appreciate your efforts and responsible disclosure and will 14 | make every effort to acknowledge your contributions. 15 | 16 | Report security vulnerabilities by emailing the GitHub Readme Stats team at: 17 | 18 | ``` 19 | hazru.anurag@gmail.com 20 | ``` 21 | 22 | The lead maintainer will acknowledge your email within 24 hours, and will 23 | send a more detailed response within 48 hours indicating the next steps in 24 | handling your report. After the initial reply to your report, the security 25 | team will endeavor to keep you informed of the progress towards a fix and 26 | full announcement, and may ask for additional information or guidance. 27 | 28 | Report security vulnerabilities in third-party modules to the person or 29 | team maintaining the module. 30 | 31 | ## Disclosure Policy 32 | 33 | When the security team receives a security bug report, they will assign it 34 | to a primary handler. This person will coordinate the fix and release 35 | process, involving the following steps: 36 | 37 | * Confirm the problem. 38 | * Audit code to find any potential similar problems. 39 | * Prepare fixes and release them as fast as possible. 40 | -------------------------------------------------------------------------------- /.github/workflows/empty-issues-closer.yml: -------------------------------------------------------------------------------- 1 | name: Close empty issues and templates 2 | on: 3 | issues: 4 | types: 5 | - reopened 6 | - opened 7 | - edited 8 | 9 | permissions: 10 | actions: read 11 | checks: read 12 | contents: read 13 | deployments: read 14 | issues: write 15 | discussions: read 16 | packages: read 17 | pages: read 18 | pull-requests: read 19 | repository-projects: read 20 | security-events: read 21 | statuses: read 22 | 23 | jobs: 24 | closeEmptyIssuesAndTemplates: 25 | if: github.repository == 'anuraghazra/github-readme-stats' 26 | name: Close empty issues 27 | runs-on: ubuntu-latest 28 | steps: 29 | # NOTE: Retrieve issue templates. 30 | - uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 31 | 32 | - name: Run empty issues closer action 33 | uses: rickstaa/empty-issues-closer-action@e96914613221511279ca25f50fd4acc85e331d99 # v1.1.74 34 | env: 35 | github_token: ${{ secrets.GITHUB_TOKEN }} 36 | with: 37 | close_comment: 38 | Closing this issue because it appears to be empty. Please update the 39 | issue for it to be reopened. 40 | open_comment: 41 | Reopening this issue because the author provided more information. 42 | check_templates: true 43 | template_close_comment: 44 | Closing this issue since the issue template was not filled in. 45 | Please provide us with more information to have this issue reopened. 46 | template_open_comment: 47 | Reopening this issue because the author provided more information. 48 | -------------------------------------------------------------------------------- /.github/workflows/ossf-analysis.yml: -------------------------------------------------------------------------------- 1 | name: OSSF Scorecard analysis workflow 2 | on: 3 | push: 4 | branches: 5 | - master 6 | pull_request: 7 | branches: 8 | - master 9 | 10 | permissions: read-all 11 | 12 | jobs: 13 | analysis: 14 | if: github.repository == 'anuraghazra/github-readme-stats' 15 | name: Scorecard analysis 16 | runs-on: ubuntu-latest 17 | permissions: 18 | # Needed if using Code scanning alerts 19 | security-events: write 20 | # Needed for GitHub OIDC token if publish_results is true 21 | id-token: write 22 | 23 | steps: 24 | - name: "Checkout code" 25 | uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 26 | with: 27 | persist-credentials: false 28 | 29 | - name: "Run analysis" 30 | uses: ossf/scorecard-action@4eaacf0543bb3f2c246792bd56e8cdeffafb205a # v2.4.3 31 | with: 32 | results_file: results.sarif 33 | results_format: sarif 34 | publish_results: true 35 | 36 | # Upload the results as artifacts (optional). Commenting out will disable uploads of run results in SARIF 37 | # format to the repository Actions tab. 38 | - name: "Upload artifact" 39 | uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 40 | with: 41 | name: SARIF file 42 | path: results.sarif 43 | retention-days: 5 44 | 45 | # required for Code scanning alerts 46 | - name: "Upload SARIF results to code scanning" 47 | uses: github/codeql-action/upload-sarif@fdcae64e1484d349b3366718cdfef3d404390e85 # v2.22.1 48 | with: 49 | sarif_file: results.sarif 50 | -------------------------------------------------------------------------------- /.github/workflows/generate-theme-doc.yml: -------------------------------------------------------------------------------- 1 | name: Generate Theme Readme 2 | on: 3 | push: 4 | branches: 5 | - master 6 | paths: 7 | - "themes/index.js" 8 | workflow_dispatch: 9 | 10 | permissions: 11 | actions: read 12 | checks: read 13 | contents: write 14 | deployments: read 15 | issues: read 16 | discussions: read 17 | packages: read 18 | pages: read 19 | pull-requests: read 20 | repository-projects: read 21 | security-events: read 22 | statuses: read 23 | 24 | jobs: 25 | generateThemeDoc: 26 | runs-on: ubuntu-latest 27 | name: Generate theme doc 28 | strategy: 29 | matrix: 30 | node-version: [22.x] 31 | 32 | steps: 33 | - uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 34 | 35 | - name: Setup Node 36 | uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0 37 | with: 38 | node-version: ${{ matrix.node-version }} 39 | cache: npm 40 | 41 | # Fix the unsafe repo error which was introduced by the CVE-2022-24765 git patches. 42 | - name: Fix unsafe repo error 43 | run: git config --global --add safe.directory ${{ github.workspace }} 44 | 45 | - name: npm install, generate readme 46 | run: | 47 | npm ci 48 | npm run theme-readme-gen 49 | env: 50 | CI: true 51 | 52 | - name: Run Script 53 | uses: skx/github-action-tester@e29768ff4ff67be9d1fdbccd8836ab83233bebb1 # v0.10.0 54 | with: 55 | script: ./scripts/push-theme-readme.sh 56 | env: 57 | CI: true 58 | PERSONAL_TOKEN: ${{ secrets.PERSONAL_TOKEN }} 59 | GH_REPO: ${{ secrets.GH_REPO }} 60 | -------------------------------------------------------------------------------- /src/cards/types.d.ts: -------------------------------------------------------------------------------- 1 | type ThemeNames = keyof typeof import("../../themes/index.js"); 2 | type RankIcon = "default" | "github" | "percentile"; 3 | 4 | export type CommonOptions = { 5 | title_color: string; 6 | icon_color: string; 7 | text_color: string; 8 | bg_color: string; 9 | theme: ThemeNames; 10 | border_radius: number; 11 | border_color: string; 12 | locale: string; 13 | hide_border: boolean; 14 | }; 15 | 16 | export type StatCardOptions = CommonOptions & { 17 | hide: string[]; 18 | show_icons: boolean; 19 | hide_title: boolean; 20 | card_width: number; 21 | hide_rank: boolean; 22 | include_all_commits: boolean; 23 | commits_year: number; 24 | line_height: number | string; 25 | custom_title: string; 26 | disable_animations: boolean; 27 | number_format: string; 28 | number_precision: number; 29 | ring_color: string; 30 | text_bold: boolean; 31 | rank_icon: RankIcon; 32 | show: string[]; 33 | }; 34 | 35 | export type RepoCardOptions = CommonOptions & { 36 | show_owner: boolean; 37 | description_lines_count: number; 38 | }; 39 | 40 | export type TopLangOptions = CommonOptions & { 41 | hide_title: boolean; 42 | card_width: number; 43 | hide: string[]; 44 | layout: "compact" | "normal" | "donut" | "donut-vertical" | "pie"; 45 | custom_title: string; 46 | langs_count: number; 47 | disable_animations: boolean; 48 | hide_progress: boolean; 49 | stats_format: "percentages" | "bytes"; 50 | }; 51 | 52 | export type WakaTimeOptions = CommonOptions & { 53 | hide_title: boolean; 54 | hide: string[]; 55 | card_width: number; 56 | line_height: string; 57 | hide_progress: boolean; 58 | custom_title: string; 59 | layout: "compact" | "normal"; 60 | langs_count: number; 61 | display_format: "time" | "percent"; 62 | disable_animations: boolean; 63 | }; 64 | 65 | export type GistCardOptions = CommonOptions & { 66 | show_owner: boolean; 67 | }; 68 | -------------------------------------------------------------------------------- /.github/workflows/stale-theme-pr-closer.yml: -------------------------------------------------------------------------------- 1 | name: Close stale theme pull requests that have the 'invalid' label. 2 | on: 3 | # Temporary disabled due to paused themes addition. 4 | # See: https://github.com/anuraghazra/github-readme-stats/issues/3404 5 | # schedule: 6 | # # ┌───────────── minute (0 - 59) 7 | # # │ ┌───────────── hour (0 - 23) 8 | # # │ │ ┌───────────── day of the month (1 - 31) 9 | # # │ │ │ ┌───────────── month (1 - 12 or JAN-DEC) 10 | # # │ │ │ │ ┌───────────── day of the week (0 - 6 or SUN-SAT) 11 | # # │ │ │ │ │ 12 | # # │ │ │ │ │ 13 | # # │ │ │ │ │ 14 | # # * * * * * 15 | # - cron: "0 0 */7 * *" 16 | workflow_dispatch: 17 | 18 | permissions: 19 | actions: read 20 | checks: read 21 | contents: read 22 | deployments: read 23 | issues: read 24 | discussions: read 25 | packages: read 26 | pages: read 27 | pull-requests: write 28 | repository-projects: read 29 | security-events: read 30 | statuses: read 31 | 32 | jobs: 33 | closeOldThemePrs: 34 | if: github.repository == 'anuraghazra/github-readme-stats' 35 | name: Close stale 'invalid' theme PRs 36 | runs-on: ubuntu-latest 37 | strategy: 38 | matrix: 39 | node-version: [22.x] 40 | 41 | steps: 42 | - uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 43 | 44 | - name: Setup Node 45 | uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0 46 | with: 47 | node-version: ${{ matrix.node-version }} 48 | cache: npm 49 | 50 | - uses: bahmutov/npm-install@3e063b974f0d209807684aa23e534b3dde517fd9 # v1.11.2 51 | with: 52 | useLockFile: false 53 | 54 | - run: npm run close-stale-theme-prs 55 | env: 56 | STALE_DAYS: 20 57 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 58 | -------------------------------------------------------------------------------- /tests/bench/api.bench.js: -------------------------------------------------------------------------------- 1 | import api from "../../api/index.js"; 2 | import axios from "axios"; 3 | import MockAdapter from "axios-mock-adapter"; 4 | import { it, jest } from "@jest/globals"; 5 | import { runAndLogStats } from "./utils.js"; 6 | 7 | const stats = { 8 | name: "Anurag Hazra", 9 | totalStars: 100, 10 | totalCommits: 200, 11 | totalIssues: 300, 12 | totalPRs: 400, 13 | totalPRsMerged: 320, 14 | mergedPRsPercentage: 80, 15 | totalReviews: 50, 16 | totalDiscussionsStarted: 10, 17 | totalDiscussionsAnswered: 40, 18 | contributedTo: 50, 19 | rank: null, 20 | }; 21 | 22 | const data_stats = { 23 | data: { 24 | user: { 25 | name: stats.name, 26 | repositoriesContributedTo: { totalCount: stats.contributedTo }, 27 | commits: { 28 | totalCommitContributions: stats.totalCommits, 29 | }, 30 | reviews: { 31 | totalPullRequestReviewContributions: stats.totalReviews, 32 | }, 33 | pullRequests: { totalCount: stats.totalPRs }, 34 | mergedPullRequests: { totalCount: stats.totalPRsMerged }, 35 | openIssues: { totalCount: stats.totalIssues }, 36 | closedIssues: { totalCount: 0 }, 37 | followers: { totalCount: 0 }, 38 | repositoryDiscussions: { totalCount: stats.totalDiscussionsStarted }, 39 | repositoryDiscussionComments: { 40 | totalCount: stats.totalDiscussionsAnswered, 41 | }, 42 | repositories: { 43 | totalCount: 1, 44 | nodes: [{ stargazers: { totalCount: 100 } }], 45 | pageInfo: { 46 | hasNextPage: false, 47 | endCursor: "cursor", 48 | }, 49 | }, 50 | }, 51 | }, 52 | }; 53 | 54 | const mock = new MockAdapter(axios); 55 | 56 | const faker = (query, data) => { 57 | const req = { 58 | query: { 59 | username: "anuraghazra", 60 | ...query, 61 | }, 62 | }; 63 | const res = { 64 | setHeader: jest.fn(), 65 | send: jest.fn(), 66 | }; 67 | mock.onPost("https://api.github.com/graphql").replyOnce(200, data); 68 | 69 | return { req, res }; 70 | }; 71 | 72 | it("test /api", async () => { 73 | await runAndLogStats("test /api", async () => { 74 | const { req, res } = faker({}, data_stats); 75 | 76 | await api(req, res); 77 | }); 78 | }); 79 | -------------------------------------------------------------------------------- /tests/color.test.js: -------------------------------------------------------------------------------- 1 | import { getCardColors } from "../src/common/color"; 2 | import { describe, expect, it } from "@jest/globals"; 3 | 4 | describe("Test color.js", () => { 5 | it("getCardColors: should return expected values", () => { 6 | let colors = getCardColors({ 7 | title_color: "f00", 8 | text_color: "0f0", 9 | ring_color: "0000ff", 10 | icon_color: "00f", 11 | bg_color: "fff", 12 | border_color: "fff", 13 | theme: "dark", 14 | }); 15 | expect(colors).toStrictEqual({ 16 | titleColor: "#f00", 17 | textColor: "#0f0", 18 | iconColor: "#00f", 19 | ringColor: "#0000ff", 20 | bgColor: "#fff", 21 | borderColor: "#fff", 22 | }); 23 | }); 24 | 25 | it("getCardColors: should fallback to default colors if color is invalid", () => { 26 | let colors = getCardColors({ 27 | title_color: "invalidcolor", 28 | text_color: "0f0", 29 | icon_color: "00f", 30 | bg_color: "fff", 31 | border_color: "invalidColor", 32 | theme: "dark", 33 | }); 34 | expect(colors).toStrictEqual({ 35 | titleColor: "#2f80ed", 36 | textColor: "#0f0", 37 | iconColor: "#00f", 38 | ringColor: "#2f80ed", 39 | bgColor: "#fff", 40 | borderColor: "#e4e2e2", 41 | }); 42 | }); 43 | 44 | it("getCardColors: should fallback to specified theme colors if is not defined", () => { 45 | let colors = getCardColors({ 46 | theme: "dark", 47 | }); 48 | expect(colors).toStrictEqual({ 49 | titleColor: "#fff", 50 | textColor: "#9f9f9f", 51 | ringColor: "#fff", 52 | iconColor: "#79ff97", 53 | bgColor: "#151515", 54 | borderColor: "#e4e2e2", 55 | }); 56 | }); 57 | 58 | it("getCardColors: should return ring color equal to title color if not ring color is defined", () => { 59 | let colors = getCardColors({ 60 | title_color: "f00", 61 | text_color: "0f0", 62 | icon_color: "00f", 63 | bg_color: "fff", 64 | border_color: "fff", 65 | theme: "dark", 66 | }); 67 | expect(colors).toStrictEqual({ 68 | titleColor: "#f00", 69 | textColor: "#0f0", 70 | iconColor: "#00f", 71 | ringColor: "#f00", 72 | bgColor: "#fff", 73 | borderColor: "#fff", 74 | }); 75 | }); 76 | }); 77 | -------------------------------------------------------------------------------- /.github/workflows/update-langs.yml: -------------------------------------------------------------------------------- 1 | name: Update supported languages 2 | on: 3 | schedule: 4 | # ┌───────────── minute (0 - 59) 5 | # │ ┌───────────── hour (0 - 23) 6 | # │ │ ┌───────────── day of the month (1 - 31) 7 | # │ │ │ ┌───────────── month (1 - 12 or JAN-DEC) 8 | # │ │ │ │ ┌───────────── day of the week (0 - 6 or SUN-SAT) 9 | # │ │ │ │ │ 10 | # │ │ │ │ │ 11 | # │ │ │ │ │ 12 | # * * * * * 13 | - cron: "0 0 */30 * *" 14 | 15 | permissions: 16 | actions: read 17 | checks: read 18 | contents: write 19 | deployments: read 20 | issues: read 21 | discussions: read 22 | packages: read 23 | pages: read 24 | pull-requests: write 25 | repository-projects: read 26 | security-events: read 27 | statuses: read 28 | 29 | jobs: 30 | updateLanguages: 31 | if: github.repository == 'anuraghazra/github-readme-stats' 32 | name: Update supported languages 33 | runs-on: ubuntu-latest 34 | strategy: 35 | matrix: 36 | node-version: [22.x] 37 | 38 | steps: 39 | - uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 40 | 41 | - name: Setup Node 42 | uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0 43 | with: 44 | node-version: ${{ matrix.node-version }} 45 | cache: npm 46 | 47 | - name: Install dependencies 48 | run: npm ci 49 | env: 50 | CI: true 51 | 52 | - name: Run update-languages-json.js script 53 | run: npm run generate-langs-json 54 | 55 | - name: Create Pull Request if upstream language file is changed 56 | uses: peter-evans/create-pull-request@84ae59a2cdc2258d6fa0732dd66352dddae2a412 # v7.0.9 57 | with: 58 | commit-message: "refactor: update languages JSON" 59 | branch: "update_langs/patch" 60 | delete-branch: true 61 | title: Update languages JSON 62 | body: 63 | "The 64 | [update-langs](https://github.com/anuraghazra/github-readme-stats/actions/workflows/update-langs.yaml) 65 | action found new/updated languages in the [upstream languages JSON 66 | file](https://raw.githubusercontent.com/github/linguist/master/lib/linguist/languages.yml)." 67 | labels: "ci, lang-card" 68 | -------------------------------------------------------------------------------- /src/common/access.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | import { renderError } from "./render.js"; 4 | import { blacklist } from "./blacklist.js"; 5 | import { whitelist, gistWhitelist } from "./envs.js"; 6 | 7 | const NOT_WHITELISTED_USERNAME_MESSAGE = "This username is not whitelisted"; 8 | const NOT_WHITELISTED_GIST_MESSAGE = "This gist ID is not whitelisted"; 9 | const BLACKLISTED_MESSAGE = "This username is blacklisted"; 10 | 11 | /** 12 | * Guards access using whitelist/blacklist. 13 | * 14 | * @param {Object} args The parameters object. 15 | * @param {any} args.res The response object. 16 | * @param {string} args.id Resource identifier (username or gist id). 17 | * @param {"username"|"gist"|"wakatime"} args.type The type of identifier. 18 | * @param {{ title_color?: string, text_color?: string, bg_color?: string, border_color?: string, theme?: string }} args.colors Color options for the error card. 19 | * @returns {{ isPassed: boolean, result?: any }} The result object indicating success or failure. 20 | */ 21 | const guardAccess = ({ res, id, type, colors }) => { 22 | if (!["username", "gist", "wakatime"].includes(type)) { 23 | throw new Error( 24 | 'Invalid type. Expected "username", "gist", or "wakatime".', 25 | ); 26 | } 27 | 28 | const currentWhitelist = type === "gist" ? gistWhitelist : whitelist; 29 | const notWhitelistedMsg = 30 | type === "gist" 31 | ? NOT_WHITELISTED_GIST_MESSAGE 32 | : NOT_WHITELISTED_USERNAME_MESSAGE; 33 | 34 | if (Array.isArray(currentWhitelist) && !currentWhitelist.includes(id)) { 35 | const result = res.send( 36 | renderError({ 37 | message: notWhitelistedMsg, 38 | secondaryMessage: "Please deploy your own instance", 39 | renderOptions: { 40 | ...colors, 41 | show_repo_link: false, 42 | }, 43 | }), 44 | ); 45 | return { isPassed: false, result }; 46 | } 47 | 48 | if ( 49 | type === "username" && 50 | currentWhitelist === undefined && 51 | blacklist.includes(id) 52 | ) { 53 | const result = res.send( 54 | renderError({ 55 | message: BLACKLISTED_MESSAGE, 56 | secondaryMessage: "Please deploy your own instance", 57 | renderOptions: { 58 | ...colors, 59 | show_repo_link: false, 60 | }, 61 | }), 62 | ); 63 | return { isPassed: false, result }; 64 | } 65 | 66 | return { isPassed: true }; 67 | }; 68 | 69 | export { guardAccess }; 70 | -------------------------------------------------------------------------------- /eslint.config.mjs: -------------------------------------------------------------------------------- 1 | import globals from "globals"; 2 | import path from "node:path"; 3 | import { fileURLToPath } from "node:url"; 4 | import js from "@eslint/js"; 5 | import { FlatCompat } from "@eslint/eslintrc"; 6 | import jsdoc from "eslint-plugin-jsdoc"; 7 | 8 | const __filename = fileURLToPath(import.meta.url); 9 | const __dirname = path.dirname(__filename); 10 | const compat = new FlatCompat({ 11 | baseDirectory: __dirname, 12 | recommendedConfig: js.configs.recommended, 13 | allConfig: js.configs.all, 14 | }); 15 | 16 | export default [ 17 | ...compat.extends("prettier"), 18 | { 19 | languageOptions: { 20 | globals: { 21 | ...globals.node, 22 | ...globals.browser, 23 | }, 24 | 25 | ecmaVersion: 2022, 26 | sourceType: "module", 27 | }, 28 | plugins: { 29 | jsdoc, 30 | }, 31 | rules: { 32 | "no-unexpected-multiline": "error", 33 | "accessor-pairs": [ 34 | "error", 35 | { 36 | getWithoutSet: false, 37 | setWithoutGet: true, 38 | }, 39 | ], 40 | "block-scoped-var": "warn", 41 | "consistent-return": "error", 42 | curly: "error", 43 | "no-alert": "error", 44 | "no-caller": "error", 45 | "no-warning-comments": [ 46 | "warn", 47 | { 48 | terms: ["TODO", "FIXME"], 49 | location: "start", 50 | }, 51 | ], 52 | "no-with": "warn", 53 | radix: "warn", 54 | "no-delete-var": "error", 55 | "no-undef-init": "off", 56 | "no-undef": "error", 57 | "no-undefined": "off", 58 | "no-unused-vars": "warn", 59 | "no-use-before-define": "error", 60 | "constructor-super": "error", 61 | "no-class-assign": "error", 62 | "no-const-assign": "error", 63 | "no-dupe-class-members": "error", 64 | "no-this-before-super": "error", 65 | "object-shorthand": ["warn"], 66 | "no-mixed-spaces-and-tabs": "warn", 67 | "no-multiple-empty-lines": "warn", 68 | "no-negated-condition": "warn", 69 | "no-unneeded-ternary": "warn", 70 | "keyword-spacing": [ 71 | "error", 72 | { 73 | before: true, 74 | after: true, 75 | }, 76 | ], 77 | "jsdoc/require-returns": "warn", 78 | "jsdoc/require-returns-description": "warn", 79 | "jsdoc/require-param-description": "warn", 80 | "jsdoc/require-jsdoc": "warn", 81 | }, 82 | }, 83 | ]; 84 | -------------------------------------------------------------------------------- /.github/labeler.yml: -------------------------------------------------------------------------------- 1 | themes: 2 | - changed-files: 3 | - any-glob-to-any-file: 4 | - themes/index.js 5 | 6 | card-i18n: 7 | - changed-files: 8 | - any-glob-to-any-file: 9 | - src/translations.js 10 | - src/common/I18n.js 11 | 12 | documentation: 13 | - changed-files: 14 | - any-glob-to-any-file: 15 | - readme.md 16 | - CONTRIBUTING.md 17 | - CODE_OF_CONDUCT.md 18 | - SECURITY.md 19 | 20 | dependencies: 21 | - changed-files: 22 | - any-glob-to-any-file: 23 | - package.json 24 | - package-lock.json 25 | 26 | lang-card: 27 | - changed-files: 28 | - any-glob-to-any-file: 29 | - api/top-langs.js 30 | - src/cards/top-languages.js 31 | - src/fetchers/top-languages.js 32 | - tests/fetchTopLanguages.test.js 33 | - tests/renderTopLanguagesCard.test.js 34 | - tests/top-langs.test.js 35 | 36 | repo-card: 37 | - changed-files: 38 | - any-glob-to-any-file: 39 | - api/pin.js 40 | - src/cards/repo.js 41 | - src/fetchers/repo.js 42 | - tests/fetchRepo.test.js 43 | - tests/renderRepoCard.test.js 44 | - tests/pin.test.js 45 | 46 | stats-card: 47 | - changed-files: 48 | - any-glob-to-any-file: 49 | - api/index.js 50 | - src/cards/stats.js 51 | - src/fetchers/stats.js 52 | - tests/fetchStats.test.js 53 | - tests/renderStatsCard.test.js 54 | - tests/api.test.js 55 | 56 | wakatime-card: 57 | - changed-files: 58 | - any-glob-to-any-file: 59 | - api/wakatime.js 60 | - src/cards/wakatime.js 61 | - src/fetchers/wakatime.js 62 | - tests/fetchWakatime.test.js 63 | - tests/renderWakatimeCard.test.js 64 | - tests/wakatime.test.js 65 | 66 | gist-card: 67 | - changed-files: 68 | - any-glob-to-any-file: 69 | - api/gist.js 70 | - src/cards/gist.js 71 | - src/fetchers/gist.js 72 | - tests/fetchGist.test.js 73 | - tests/renderGistCard.test.js 74 | - tests/gist.test.js 75 | 76 | ranks: 77 | - changed-files: 78 | - any-glob-to-any-file: 79 | - src/calculateRank.js 80 | 81 | ci: 82 | - changed-files: 83 | - any-glob-to-any-file: 84 | - .github/workflows/* 85 | - scripts/* 86 | 87 | infrastructure: 88 | - changed-files: 89 | - any-glob-to-any-file: 90 | - .eslintrc.json 91 | -------------------------------------------------------------------------------- /src/common/error.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | /** 4 | * @type {string} A general message to ask user to try again later. 5 | */ 6 | const TRY_AGAIN_LATER = "Please try again later"; 7 | 8 | /** 9 | * @type {Object} A map of error types to secondary error messages. 10 | */ 11 | const SECONDARY_ERROR_MESSAGES = { 12 | MAX_RETRY: 13 | "You can deploy own instance or wait until public will be no longer limited", 14 | NO_TOKENS: 15 | "Please add an env variable called PAT_1 with your GitHub API token in vercel", 16 | USER_NOT_FOUND: "Make sure the provided username is not an organization", 17 | GRAPHQL_ERROR: TRY_AGAIN_LATER, 18 | GITHUB_REST_API_ERROR: TRY_AGAIN_LATER, 19 | WAKATIME_USER_NOT_FOUND: "Make sure you have a public WakaTime profile", 20 | }; 21 | 22 | /** 23 | * Custom error class to handle custom GRS errors. 24 | */ 25 | class CustomError extends Error { 26 | /** 27 | * Custom error constructor. 28 | * 29 | * @param {string} message Error message. 30 | * @param {string} type Error type. 31 | */ 32 | constructor(message, type) { 33 | super(message); 34 | this.type = type; 35 | this.secondaryMessage = SECONDARY_ERROR_MESSAGES[type] || type; 36 | } 37 | 38 | static MAX_RETRY = "MAX_RETRY"; 39 | static NO_TOKENS = "NO_TOKENS"; 40 | static USER_NOT_FOUND = "USER_NOT_FOUND"; 41 | static GRAPHQL_ERROR = "GRAPHQL_ERROR"; 42 | static GITHUB_REST_API_ERROR = "GITHUB_REST_API_ERROR"; 43 | static WAKATIME_ERROR = "WAKATIME_ERROR"; 44 | } 45 | 46 | /** 47 | * Missing query parameter class. 48 | */ 49 | class MissingParamError extends Error { 50 | /** 51 | * Missing query parameter error constructor. 52 | * 53 | * @param {string[]} missedParams An array of missing parameters names. 54 | * @param {string=} secondaryMessage Optional secondary message to display. 55 | */ 56 | constructor(missedParams, secondaryMessage) { 57 | const msg = `Missing params ${missedParams 58 | .map((p) => `"${p}"`) 59 | .join(", ")} make sure you pass the parameters in URL`; 60 | super(msg); 61 | this.missedParams = missedParams; 62 | this.secondaryMessage = secondaryMessage; 63 | } 64 | } 65 | 66 | /** 67 | * Retrieve secondary message from an error object. 68 | * 69 | * @param {Error} err The error object. 70 | * @returns {string|undefined} The secondary message if available, otherwise undefined. 71 | */ 72 | const retrieveSecondaryMessage = (err) => { 73 | return "secondaryMessage" in err && typeof err.secondaryMessage === "string" 74 | ? err.secondaryMessage 75 | : undefined; 76 | }; 77 | 78 | export { 79 | CustomError, 80 | MissingParamError, 81 | SECONDARY_ERROR_MESSAGES, 82 | TRY_AGAIN_LATER, 83 | retrieveSecondaryMessage, 84 | }; 85 | -------------------------------------------------------------------------------- /tests/retryer.test.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | import { describe, expect, it, jest } from "@jest/globals"; 4 | import "@testing-library/jest-dom"; 5 | import { RETRIES, retryer } from "../src/common/retryer.js"; 6 | import { logger } from "../src/common/log.js"; 7 | 8 | const fetcher = jest.fn((variables, token) => { 9 | logger.log(variables, token); 10 | return new Promise((res) => res({ data: "ok" })); 11 | }); 12 | 13 | const fetcherFail = jest.fn(() => { 14 | return new Promise((res) => 15 | res({ data: { errors: [{ type: "RATE_LIMITED" }] } }), 16 | ); 17 | }); 18 | 19 | const fetcherFailOnSecondTry = jest.fn((_vars, _token, retries) => { 20 | return new Promise((res) => { 21 | // faking rate limit 22 | // @ts-ignore 23 | if (retries < 1) { 24 | return res({ data: { errors: [{ type: "RATE_LIMITED" }] } }); 25 | } 26 | return res({ data: "ok" }); 27 | }); 28 | }); 29 | 30 | const fetcherFailWithMessageBasedRateLimitErr = jest.fn( 31 | (_vars, _token, retries) => { 32 | return new Promise((res) => { 33 | // faking rate limit 34 | // @ts-ignore 35 | if (retries < 1) { 36 | return res({ 37 | data: { 38 | errors: [ 39 | { 40 | type: "ASDF", 41 | message: "API rate limit already exceeded for user ID 11111111", 42 | }, 43 | ], 44 | }, 45 | }); 46 | } 47 | return res({ data: "ok" }); 48 | }); 49 | }, 50 | ); 51 | 52 | describe("Test Retryer", () => { 53 | it("retryer should return value and have zero retries on first try", async () => { 54 | let res = await retryer(fetcher, {}); 55 | 56 | expect(fetcher).toHaveBeenCalledTimes(1); 57 | expect(res).toStrictEqual({ data: "ok" }); 58 | }); 59 | 60 | it("retryer should return value and have 2 retries", async () => { 61 | let res = await retryer(fetcherFailOnSecondTry, {}); 62 | 63 | expect(fetcherFailOnSecondTry).toHaveBeenCalledTimes(2); 64 | expect(res).toStrictEqual({ data: "ok" }); 65 | }); 66 | 67 | it("retryer should return value and have 2 retries with message based rate limit error", async () => { 68 | let res = await retryer(fetcherFailWithMessageBasedRateLimitErr, {}); 69 | 70 | expect(fetcherFailWithMessageBasedRateLimitErr).toHaveBeenCalledTimes(2); 71 | expect(res).toStrictEqual({ data: "ok" }); 72 | }); 73 | 74 | it("retryer should throw specific error if maximum retries reached", async () => { 75 | try { 76 | await retryer(fetcherFail, {}); 77 | } catch (err) { 78 | expect(fetcherFail).toHaveBeenCalledTimes(RETRIES + 1); 79 | // @ts-ignore 80 | expect(err.message).toBe("Downtime due to GitHub API rate limiting"); 81 | } 82 | }); 83 | }); 84 | -------------------------------------------------------------------------------- /src/calculateRank.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Calculates the exponential cdf. 3 | * 4 | * @param {number} x The value. 5 | * @returns {number} The exponential cdf. 6 | */ 7 | function exponential_cdf(x) { 8 | return 1 - 2 ** -x; 9 | } 10 | 11 | /** 12 | * Calculates the log normal cdf. 13 | * 14 | * @param {number} x The value. 15 | * @returns {number} The log normal cdf. 16 | */ 17 | function log_normal_cdf(x) { 18 | // approximation 19 | return x / (1 + x); 20 | } 21 | 22 | /** 23 | * Calculates the users rank. 24 | * 25 | * @param {object} params Parameters on which the user's rank depends. 26 | * @param {boolean} params.all_commits Whether `include_all_commits` was used. 27 | * @param {number} params.commits Number of commits. 28 | * @param {number} params.prs The number of pull requests. 29 | * @param {number} params.issues The number of issues. 30 | * @param {number} params.reviews The number of reviews. 31 | * @param {number} params.repos Total number of repos. 32 | * @param {number} params.stars The number of stars. 33 | * @param {number} params.followers The number of followers. 34 | * @returns {{ level: string, percentile: number }} The users rank. 35 | */ 36 | function calculateRank({ 37 | all_commits, 38 | commits, 39 | prs, 40 | issues, 41 | reviews, 42 | // eslint-disable-next-line no-unused-vars 43 | repos, // unused 44 | stars, 45 | followers, 46 | }) { 47 | const COMMITS_MEDIAN = all_commits ? 1000 : 250, 48 | COMMITS_WEIGHT = 2; 49 | const PRS_MEDIAN = 50, 50 | PRS_WEIGHT = 3; 51 | const ISSUES_MEDIAN = 25, 52 | ISSUES_WEIGHT = 1; 53 | const REVIEWS_MEDIAN = 2, 54 | REVIEWS_WEIGHT = 1; 55 | const STARS_MEDIAN = 50, 56 | STARS_WEIGHT = 4; 57 | const FOLLOWERS_MEDIAN = 10, 58 | FOLLOWERS_WEIGHT = 1; 59 | 60 | const TOTAL_WEIGHT = 61 | COMMITS_WEIGHT + 62 | PRS_WEIGHT + 63 | ISSUES_WEIGHT + 64 | REVIEWS_WEIGHT + 65 | STARS_WEIGHT + 66 | FOLLOWERS_WEIGHT; 67 | 68 | const THRESHOLDS = [1, 12.5, 25, 37.5, 50, 62.5, 75, 87.5, 100]; 69 | const LEVELS = ["S", "A+", "A", "A-", "B+", "B", "B-", "C+", "C"]; 70 | 71 | const rank = 72 | 1 - 73 | (COMMITS_WEIGHT * exponential_cdf(commits / COMMITS_MEDIAN) + 74 | PRS_WEIGHT * exponential_cdf(prs / PRS_MEDIAN) + 75 | ISSUES_WEIGHT * exponential_cdf(issues / ISSUES_MEDIAN) + 76 | REVIEWS_WEIGHT * exponential_cdf(reviews / REVIEWS_MEDIAN) + 77 | STARS_WEIGHT * log_normal_cdf(stars / STARS_MEDIAN) + 78 | FOLLOWERS_WEIGHT * log_normal_cdf(followers / FOLLOWERS_MEDIAN)) / 79 | TOTAL_WEIGHT; 80 | 81 | const level = LEVELS[THRESHOLDS.findIndex((t) => rank * 100 <= t)]; 82 | 83 | return { level, percentile: rank * 100 }; 84 | } 85 | 86 | export { calculateRank }; 87 | export default calculateRank; 88 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.yml: -------------------------------------------------------------------------------- 1 | name: Bug report 2 | description: Create a report to help us improve. 3 | labels: 4 | - "bug" 5 | body: 6 | - type: markdown 7 | attributes: 8 | value: | 9 | :warning: PLEASE FIRST READ THE FAQ [(#1770)](https://github.com/anuraghazra/github-readme-stats/discussions/1770) AND COMMON ERROR CODES [(#1772)](https://github.com/anuraghazra/github-readme-stats/issues/1772)!!! 10 | - type: textarea 11 | attributes: 12 | label: Describe the bug 13 | description: A clear and concise description of what the bug is. 14 | validations: 15 | required: true 16 | - type: textarea 17 | attributes: 18 | label: Expected behavior 19 | description: 20 | A clear and concise description of what you expected to happen. 21 | - type: textarea 22 | attributes: 23 | label: Screenshots / Live demo link 24 | description: If applicable, add screenshots to help explain your problem. 25 | placeholder: Paste the github-readme-stats link as markdown image 26 | - type: textarea 27 | attributes: 28 | label: Additional context 29 | description: Add any other context about the problem here. 30 | - type: markdown 31 | attributes: 32 | value: | 33 | --- 34 | ### FAQ (Snippet) 35 | 36 | Below are some questions that are found in the FAQ. The full FAQ can be found in [#1770](https://github.com/anuraghazra/github-readme-stats/discussions/1770). 37 | 38 | #### Q: My card displays an error 39 | 40 | **Ans:** First, check the common error codes (i.e. https://github.com/anuraghazra/github-readme-stats/issues/1772) and existing issues before creating a new one. 41 | 42 | #### Q: How to hide jupyter Notebook? 43 | 44 | **Ans:** `&hide=jupyter%20notebook`. 45 | 46 | #### Q: I could not figure out how to deploy on my own vercel instance 47 | 48 | **Ans:** Please check: 49 | - Docs: https://github.com/anuraghazra/github-readme-stats/#deploy-on-your-own-vercel-instance 50 | - YT tutorial by codeSTACKr: https://www.youtube.com/watch?v=n6d4KHSKqGk&feature=youtu.be&t=107 51 | 52 | #### Q: Language Card is incorrect 53 | 54 | **Ans:** Please read these issues/comments before opening any issues regarding language card stats: 55 | - https://github.com/anuraghazra/github-readme-stats/issues/136#issuecomment-665164174 56 | - https://github.com/anuraghazra/github-readme-stats/issues/136#issuecomment-665172181 57 | 58 | #### Q: How to count private stats? 59 | 60 | **Ans:** We can only count private commits & we cannot access any other private info of any users, so it's impossible. The only way is to deploy on your own instance & use your own PAT (Personal Access Token). 61 | -------------------------------------------------------------------------------- /src/common/fmt.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | import wrap from "word-wrap"; 4 | import { encodeHTML } from "./html.js"; 5 | 6 | /** 7 | * Retrieves num with suffix k(thousands) precise to given decimal places. 8 | * 9 | * @param {number} num The number to format. 10 | * @param {number=} precision The number of decimal places to include. 11 | * @returns {string|number} The formatted number. 12 | */ 13 | const kFormatter = (num, precision) => { 14 | const abs = Math.abs(num); 15 | const sign = Math.sign(num); 16 | 17 | if (typeof precision === "number" && !isNaN(precision)) { 18 | return (sign * (abs / 1000)).toFixed(precision) + "k"; 19 | } 20 | 21 | if (abs < 1000) { 22 | return sign * abs; 23 | } 24 | 25 | return sign * parseFloat((abs / 1000).toFixed(1)) + "k"; 26 | }; 27 | 28 | /** 29 | * Convert bytes to a human-readable string representation. 30 | * 31 | * @param {number} bytes The number of bytes to convert. 32 | * @returns {string} The human-readable representation of bytes. 33 | * @throws {Error} If bytes is negative or too large. 34 | */ 35 | const formatBytes = (bytes) => { 36 | if (bytes < 0) { 37 | throw new Error("Bytes must be a non-negative number"); 38 | } 39 | 40 | if (bytes === 0) { 41 | return "0 B"; 42 | } 43 | 44 | const sizes = ["B", "KB", "MB", "GB", "TB", "PB", "EB"]; 45 | const base = 1024; 46 | const i = Math.floor(Math.log(bytes) / Math.log(base)); 47 | 48 | if (i >= sizes.length) { 49 | throw new Error("Bytes is too large to convert to a human-readable string"); 50 | } 51 | 52 | return `${(bytes / Math.pow(base, i)).toFixed(1)} ${sizes[i]}`; 53 | }; 54 | 55 | /** 56 | * Split text over multiple lines based on the card width. 57 | * 58 | * @param {string} text Text to split. 59 | * @param {number} width Line width in number of characters. 60 | * @param {number} maxLines Maximum number of lines. 61 | * @returns {string[]} Array of lines. 62 | */ 63 | const wrapTextMultiline = (text, width = 59, maxLines = 3) => { 64 | const fullWidthComma = ","; 65 | const encoded = encodeHTML(text); 66 | const isChinese = encoded.includes(fullWidthComma); 67 | 68 | let wrapped = []; 69 | 70 | if (isChinese) { 71 | wrapped = encoded.split(fullWidthComma); // Chinese full punctuation 72 | } else { 73 | wrapped = wrap(encoded, { 74 | width, 75 | }).split("\n"); // Split wrapped lines to get an array of lines 76 | } 77 | 78 | const lines = wrapped.map((line) => line.trim()).slice(0, maxLines); // Only consider maxLines lines 79 | 80 | // Add "..." to the last line if the text exceeds maxLines 81 | if (wrapped.length > maxLines) { 82 | lines[maxLines - 1] += "..."; 83 | } 84 | 85 | // Remove empty lines if text fits in less than maxLines lines 86 | const multiLineText = lines.filter(Boolean); 87 | return multiLineText; 88 | }; 89 | 90 | export { kFormatter, formatBytes, wrapTextMultiline }; 91 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "github-readme-stats", 3 | "version": "1.0.0", 4 | "description": "Dynamically generate stats for your GitHub readme", 5 | "keywords": [ 6 | "github-readme-stats", 7 | "readme-stats", 8 | "cards", 9 | "card-generator" 10 | ], 11 | "main": "src/index.js", 12 | "type": "module", 13 | "homepage": "https://github.com/anuraghazra/github-readme-stats", 14 | "bugs": { 15 | "url": "https://github.com/anuraghazra/github-readme-stats/issues" 16 | }, 17 | "repository": { 18 | "type": "git", 19 | "url": "https://github.com/anuraghazra/github-readme-stats.git" 20 | }, 21 | "scripts": { 22 | "test": "node --experimental-vm-modules node_modules/jest/bin/jest.js --coverage", 23 | "test:watch": "node --experimental-vm-modules node_modules/jest/bin/jest.js --watch", 24 | "test:update:snapshot": "node --experimental-vm-modules node_modules/jest/bin/jest.js -u", 25 | "test:e2e": "node --experimental-vm-modules node_modules/jest/bin/jest.js --config jest.e2e.config.js", 26 | "theme-readme-gen": "node scripts/generate-theme-doc", 27 | "preview-theme": "node scripts/preview-theme", 28 | "close-stale-theme-prs": "node scripts/close-stale-theme-prs", 29 | "generate-langs-json": "node scripts/generate-langs-json", 30 | "format": "prettier --write .", 31 | "format:check": "prettier --check .", 32 | "prepare": "husky", 33 | "lint": "npx eslint --max-warnings 0 \"./src/**/*.js\" \"./scripts/**/*.js\" \"./tests/**/*.js\" \"./api/**/*.js\" \"./themes/**/*.js\"", 34 | "bench": "node --experimental-vm-modules node_modules/jest/bin/jest.js --config jest.bench.config.js" 35 | }, 36 | "author": "Anurag Hazra", 37 | "license": "MIT", 38 | "devDependencies": { 39 | "@actions/core": "^1.11.1", 40 | "@actions/github": "^6.0.1", 41 | "@eslint/eslintrc": "^3.3.3", 42 | "@eslint/js": "^9.39.0", 43 | "@testing-library/dom": "^10.4.1", 44 | "@testing-library/jest-dom": "^6.9.1", 45 | "@uppercod/css-to-object": "^1.1.1", 46 | "axios-mock-adapter": "^2.1.0", 47 | "color-contrast-checker": "^2.1.0", 48 | "eslint": "^9.39.0", 49 | "eslint-config-prettier": "^10.1.8", 50 | "eslint-plugin-jsdoc": "^61.4.2", 51 | "express": "^5.2.1", 52 | "globals": "^16.5.0", 53 | "hjson": "^3.2.2", 54 | "husky": "^9.1.7", 55 | "jest": "^30.2.0", 56 | "jest-environment-jsdom": "^30.2.0", 57 | "js-yaml": "^4.1.1", 58 | "lint-staged": "^16.2.7", 59 | "lodash.snakecase": "^4.1.1", 60 | "parse-diff": "^0.11.1", 61 | "prettier": "^3.7.3" 62 | }, 63 | "dependencies": { 64 | "axios": "^1.13.1", 65 | "dotenv": "^17.2.3", 66 | "emoji-name-map": "^2.0.3", 67 | "github-username-regex": "^1.0.0", 68 | "word-wrap": "^1.2.5" 69 | }, 70 | "lint-staged": { 71 | "*.{js,css,md}": "prettier --write" 72 | }, 73 | "engines": { 74 | "node": ">=22" 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /tests/calculateRank.test.js: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from "@jest/globals"; 2 | import "@testing-library/jest-dom"; 3 | import { calculateRank } from "../src/calculateRank.js"; 4 | 5 | describe("Test calculateRank", () => { 6 | it("new user gets C rank", () => { 7 | expect( 8 | calculateRank({ 9 | all_commits: false, 10 | commits: 0, 11 | prs: 0, 12 | issues: 0, 13 | reviews: 0, 14 | repos: 0, 15 | stars: 0, 16 | followers: 0, 17 | }), 18 | ).toStrictEqual({ level: "C", percentile: 100 }); 19 | }); 20 | 21 | it("beginner user gets B- rank", () => { 22 | expect( 23 | calculateRank({ 24 | all_commits: false, 25 | commits: 125, 26 | prs: 25, 27 | issues: 10, 28 | reviews: 5, 29 | repos: 0, 30 | stars: 25, 31 | followers: 5, 32 | }), 33 | ).toStrictEqual({ level: "B-", percentile: 65.02918514848255 }); 34 | }); 35 | 36 | it("median user gets B+ rank", () => { 37 | expect( 38 | calculateRank({ 39 | all_commits: false, 40 | commits: 250, 41 | prs: 50, 42 | issues: 25, 43 | reviews: 10, 44 | repos: 0, 45 | stars: 50, 46 | followers: 10, 47 | }), 48 | ).toStrictEqual({ level: "B+", percentile: 46.09375 }); 49 | }); 50 | 51 | it("average user gets B+ rank (include_all_commits)", () => { 52 | expect( 53 | calculateRank({ 54 | all_commits: true, 55 | commits: 1000, 56 | prs: 50, 57 | issues: 25, 58 | reviews: 10, 59 | repos: 0, 60 | stars: 50, 61 | followers: 10, 62 | }), 63 | ).toStrictEqual({ level: "B+", percentile: 46.09375 }); 64 | }); 65 | 66 | it("advanced user gets A rank", () => { 67 | expect( 68 | calculateRank({ 69 | all_commits: false, 70 | commits: 500, 71 | prs: 100, 72 | issues: 50, 73 | reviews: 20, 74 | repos: 0, 75 | stars: 200, 76 | followers: 40, 77 | }), 78 | ).toStrictEqual({ level: "A", percentile: 20.841471354166664 }); 79 | }); 80 | 81 | it("expert user gets A+ rank", () => { 82 | expect( 83 | calculateRank({ 84 | all_commits: false, 85 | commits: 1000, 86 | prs: 200, 87 | issues: 100, 88 | reviews: 40, 89 | repos: 0, 90 | stars: 800, 91 | followers: 160, 92 | }), 93 | ).toStrictEqual({ level: "A+", percentile: 5.575988339442828 }); 94 | }); 95 | 96 | it("sindresorhus gets S rank", () => { 97 | expect( 98 | calculateRank({ 99 | all_commits: false, 100 | commits: 1300, 101 | prs: 1500, 102 | issues: 4500, 103 | reviews: 1000, 104 | repos: 0, 105 | stars: 600000, 106 | followers: 50000, 107 | }), 108 | ).toStrictEqual({ level: "S", percentile: 0.4578556547153667 }); 109 | }); 110 | }); 111 | -------------------------------------------------------------------------------- /src/fetchers/types.d.ts: -------------------------------------------------------------------------------- 1 | export type GistData = { 2 | name: string; 3 | nameWithOwner: string; 4 | description: string | null; 5 | language: string | null; 6 | starsCount: number; 7 | forksCount: number; 8 | }; 9 | 10 | export type RepositoryData = { 11 | name: string; 12 | nameWithOwner: string; 13 | isPrivate: boolean; 14 | isArchived: boolean; 15 | isTemplate: boolean; 16 | stargazers: { totalCount: number }; 17 | description: string; 18 | primaryLanguage: { 19 | color: string; 20 | id: string; 21 | name: string; 22 | }; 23 | forkCount: number; 24 | starCount: number; 25 | }; 26 | 27 | export type StatsData = { 28 | name: string; 29 | totalPRs: number; 30 | totalPRsMerged: number; 31 | mergedPRsPercentage: number; 32 | totalReviews: number; 33 | totalCommits: number; 34 | totalIssues: number; 35 | totalStars: number; 36 | totalDiscussionsStarted: number; 37 | totalDiscussionsAnswered: number; 38 | contributedTo: number; 39 | rank: { level: string; percentile: number }; 40 | }; 41 | 42 | export type Lang = { 43 | name: string; 44 | color: string; 45 | size: number; 46 | }; 47 | 48 | export type TopLangData = Record; 49 | 50 | export type WakaTimeData = { 51 | categories: { 52 | digital: string; 53 | hours: number; 54 | minutes: number; 55 | name: string; 56 | percent: number; 57 | text: string; 58 | total_seconds: number; 59 | }[]; 60 | daily_average: number; 61 | daily_average_including_other_language: number; 62 | days_including_holidays: number; 63 | days_minus_holidays: number; 64 | editors: { 65 | digital: string; 66 | hours: number; 67 | minutes: number; 68 | name: string; 69 | percent: number; 70 | text: string; 71 | total_seconds: number; 72 | }[]; 73 | holidays: number; 74 | human_readable_daily_average: string; 75 | human_readable_daily_average_including_other_language: string; 76 | human_readable_total: string; 77 | human_readable_total_including_other_language: string; 78 | id: string; 79 | is_already_updating: boolean; 80 | is_coding_activity_visible: boolean; 81 | is_including_today: boolean; 82 | is_other_usage_visible: boolean; 83 | is_stuck: boolean; 84 | is_up_to_date: boolean; 85 | languages: { 86 | digital: string; 87 | hours: number; 88 | minutes: number; 89 | name: string; 90 | percent: number; 91 | text: string; 92 | total_seconds: number; 93 | }[]; 94 | operating_systems: { 95 | digital: string; 96 | hours: number; 97 | minutes: number; 98 | name: string; 99 | percent: number; 100 | text: string; 101 | total_seconds: number; 102 | }[]; 103 | percent_calculated: number; 104 | range: string; 105 | status: string; 106 | timeout: number; 107 | total_seconds: number; 108 | total_seconds_including_other_language: number; 109 | user_id: string; 110 | username: string; 111 | writes_only: boolean; 112 | }; 113 | 114 | export type WakaTimeLang = { 115 | name: string; 116 | text: string; 117 | percent: number; 118 | }; 119 | -------------------------------------------------------------------------------- /tests/ops.test.js: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from "@jest/globals"; 2 | import { 3 | parseBoolean, 4 | parseArray, 5 | clampValue, 6 | lowercaseTrim, 7 | chunkArray, 8 | parseEmojis, 9 | dateDiff, 10 | } from "../src/common/ops.js"; 11 | 12 | describe("Test ops.js", () => { 13 | it("should test parseBoolean", () => { 14 | expect(parseBoolean(true)).toBe(true); 15 | expect(parseBoolean(false)).toBe(false); 16 | 17 | expect(parseBoolean("true")).toBe(true); 18 | expect(parseBoolean("false")).toBe(false); 19 | expect(parseBoolean("True")).toBe(true); 20 | expect(parseBoolean("False")).toBe(false); 21 | expect(parseBoolean("TRUE")).toBe(true); 22 | expect(parseBoolean("FALSE")).toBe(false); 23 | 24 | expect(parseBoolean("1")).toBe(undefined); 25 | expect(parseBoolean("0")).toBe(undefined); 26 | expect(parseBoolean("")).toBe(undefined); 27 | // @ts-ignore 28 | expect(parseBoolean(undefined)).toBe(undefined); 29 | }); 30 | 31 | it("should test parseArray", () => { 32 | expect(parseArray("a,b,c")).toEqual(["a", "b", "c"]); 33 | expect(parseArray("a, b, c")).toEqual(["a", " b", " c"]); // preserves spaces 34 | expect(parseArray("")).toEqual([]); 35 | // @ts-ignore 36 | expect(parseArray(undefined)).toEqual([]); 37 | }); 38 | 39 | it("should test clampValue", () => { 40 | expect(clampValue(5, 1, 10)).toBe(5); 41 | expect(clampValue(0, 1, 10)).toBe(1); 42 | expect(clampValue(15, 1, 10)).toBe(10); 43 | 44 | // string inputs are coerced numerically by Math.min/Math.max 45 | // @ts-ignore 46 | expect(clampValue("7", 1, 10)).toBe(7); 47 | 48 | // non-numeric and NaN fall back to min 49 | // @ts-ignore 50 | expect(clampValue("abc", 1, 10)).toBe(1); 51 | expect(clampValue(NaN, 2, 5)).toBe(2); 52 | }); 53 | 54 | it("should test lowercaseTrim", () => { 55 | expect(lowercaseTrim(" Hello World ")).toBe("hello world"); 56 | expect(lowercaseTrim("already lower")).toBe("already lower"); 57 | }); 58 | 59 | it("should test chunkArray", () => { 60 | expect(chunkArray([1, 2, 3, 4, 5], 2)).toEqual([[1, 2], [3, 4], [5]]); 61 | expect(chunkArray([1, 2, 3, 4, 5], 1)).toEqual([[1], [2], [3], [4], [5]]); 62 | expect(chunkArray([1, 2, 3, 4, 5], 10)).toEqual([[1, 2, 3, 4, 5]]); 63 | }); 64 | 65 | it("should test parseEmojis", () => { 66 | // unknown emoji name is stripped 67 | expect(parseEmojis("Hello :nonexistent:")).toBe("Hello "); 68 | // common emoji names should be replaced (at least token removed) 69 | const out = parseEmojis("I :heart: OSS"); 70 | expect(out).not.toContain(":heart:"); 71 | expect(out.startsWith("I ")).toBe(true); 72 | expect(out.endsWith(" OSS")).toBe(true); 73 | 74 | expect(() => parseEmojis("")).toThrow(/parseEmoji/); 75 | // @ts-ignore 76 | expect(() => parseEmojis()).toThrow(/parseEmoji/); 77 | }); 78 | 79 | it("should test dateDiff", () => { 80 | const a = new Date("2020-01-01T00:10:00Z"); 81 | const b = new Date("2020-01-01T00:00:00Z"); 82 | expect(dateDiff(a, b)).toBe(10); 83 | 84 | const c = new Date("2020-01-01T00:00:00Z"); 85 | const d = new Date("2020-01-01T00:10:30Z"); 86 | // rounds to nearest minute 87 | expect(dateDiff(c, d)).toBe(-10); 88 | }); 89 | }); 90 | -------------------------------------------------------------------------------- /src/fetchers/gist.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | import { retryer } from "../common/retryer.js"; 4 | import { MissingParamError } from "../common/error.js"; 5 | import { request } from "../common/http.js"; 6 | 7 | const QUERY = ` 8 | query gistInfo($gistName: String!) { 9 | viewer { 10 | gist(name: $gistName) { 11 | description 12 | owner { 13 | login 14 | } 15 | stargazerCount 16 | forks { 17 | totalCount 18 | } 19 | files { 20 | name 21 | language { 22 | name 23 | } 24 | size 25 | } 26 | } 27 | } 28 | } 29 | `; 30 | 31 | /** 32 | * Gist data fetcher. 33 | * 34 | * @param {object} variables Fetcher variables. 35 | * @param {string} token GitHub token. 36 | * @returns {Promise} The response. 37 | */ 38 | const fetcher = async (variables, token) => { 39 | return await request( 40 | { query: QUERY, variables }, 41 | { Authorization: `token ${token}` }, 42 | ); 43 | }; 44 | 45 | /** 46 | * @typedef {{ name: string; language: { name: string; }, size: number }} GistFile Gist file. 47 | */ 48 | 49 | /** 50 | * This function calculates the primary language of a gist by files size. 51 | * 52 | * @param {GistFile[]} files Files. 53 | * @returns {string} Primary language. 54 | */ 55 | const calculatePrimaryLanguage = (files) => { 56 | /** @type {Record} */ 57 | const languages = {}; 58 | 59 | for (const file of files) { 60 | if (file.language) { 61 | if (languages[file.language.name]) { 62 | languages[file.language.name] += file.size; 63 | } else { 64 | languages[file.language.name] = file.size; 65 | } 66 | } 67 | } 68 | 69 | let primaryLanguage = Object.keys(languages)[0]; 70 | for (const language in languages) { 71 | if (languages[language] > languages[primaryLanguage]) { 72 | primaryLanguage = language; 73 | } 74 | } 75 | 76 | return primaryLanguage; 77 | }; 78 | 79 | /** 80 | * @typedef {import('./types').GistData} GistData Gist data. 81 | */ 82 | 83 | /** 84 | * Fetch GitHub gist information by given username and ID. 85 | * 86 | * @param {string} id GitHub gist ID. 87 | * @returns {Promise} Gist data. 88 | */ 89 | const fetchGist = async (id) => { 90 | if (!id) { 91 | throw new MissingParamError(["id"], "/api/gist?id=GIST_ID"); 92 | } 93 | const res = await retryer(fetcher, { gistName: id }); 94 | if (res.data.errors) { 95 | throw new Error(res.data.errors[0].message); 96 | } 97 | if (!res.data.data.viewer.gist) { 98 | throw new Error("Gist not found"); 99 | } 100 | const data = res.data.data.viewer.gist; 101 | return { 102 | name: data.files[Object.keys(data.files)[0]].name, 103 | nameWithOwner: `${data.owner.login}/${ 104 | data.files[Object.keys(data.files)[0]].name 105 | }`, 106 | description: data.description, 107 | language: calculatePrimaryLanguage(data.files), 108 | starsCount: data.stargazerCount, 109 | forksCount: data.forks.totalCount, 110 | }; 111 | }; 112 | 113 | export { fetchGist }; 114 | export default fetchGist; 115 | -------------------------------------------------------------------------------- /tests/renderWakatimeCard.test.js: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from "@jest/globals"; 2 | import { queryByTestId } from "@testing-library/dom"; 3 | import "@testing-library/jest-dom"; 4 | import { renderWakatimeCard } from "../src/cards/wakatime.js"; 5 | import { wakaTimeData } from "./fetchWakatime.test.js"; 6 | 7 | describe("Test Render WakaTime Card", () => { 8 | it("should render correctly", () => { 9 | const card = renderWakatimeCard(wakaTimeData.data); 10 | expect(card).toMatchSnapshot(); 11 | }); 12 | 13 | it("should render correctly with compact layout", () => { 14 | const card = renderWakatimeCard(wakaTimeData.data, { layout: "compact" }); 15 | 16 | expect(card).toMatchSnapshot(); 17 | }); 18 | 19 | it("should render correctly with compact layout when langs_count is set", () => { 20 | const card = renderWakatimeCard(wakaTimeData.data, { 21 | layout: "compact", 22 | langs_count: 2, 23 | }); 24 | 25 | expect(card).toMatchSnapshot(); 26 | }); 27 | 28 | it("should hide languages when hide is passed", () => { 29 | document.body.innerHTML = renderWakatimeCard(wakaTimeData.data, { 30 | hide: ["YAML", "Other"], 31 | }); 32 | 33 | expect(queryByTestId(document.body, /YAML/i)).toBeNull(); 34 | expect(queryByTestId(document.body, /Other/i)).toBeNull(); 35 | expect(queryByTestId(document.body, /TypeScript/i)).not.toBeNull(); 36 | }); 37 | 38 | it("should render translations", () => { 39 | document.body.innerHTML = renderWakatimeCard({}, { locale: "cn" }); 40 | expect(document.getElementsByClassName("header")[0].textContent).toBe( 41 | "WakaTime 周统计", 42 | ); 43 | expect( 44 | document.querySelector('g[transform="translate(0, 0)"]>text.stat.bold') 45 | .textContent, 46 | ).toBe("WakaTime 用户个人资料未公开"); 47 | }); 48 | 49 | it("should render without rounding", () => { 50 | document.body.innerHTML = renderWakatimeCard(wakaTimeData.data, { 51 | border_radius: "0", 52 | }); 53 | expect(document.querySelector("rect")).toHaveAttribute("rx", "0"); 54 | document.body.innerHTML = renderWakatimeCard(wakaTimeData.data, {}); 55 | expect(document.querySelector("rect")).toHaveAttribute("rx", "4.5"); 56 | }); 57 | 58 | it('should show "no coding activity this week" message when there has not been activity', () => { 59 | document.body.innerHTML = renderWakatimeCard( 60 | { 61 | ...wakaTimeData.data, 62 | languages: undefined, 63 | }, 64 | {}, 65 | ); 66 | expect(document.querySelector(".stat").textContent).toBe( 67 | "No coding activity this week", 68 | ); 69 | }); 70 | 71 | it('should show "no coding activity this week" message when using compact layout and there has not been activity', () => { 72 | document.body.innerHTML = renderWakatimeCard( 73 | { 74 | ...wakaTimeData.data, 75 | languages: undefined, 76 | }, 77 | { 78 | layout: "compact", 79 | }, 80 | ); 81 | expect(document.querySelector(".stat").textContent).toBe( 82 | "No coding activity this week", 83 | ); 84 | }); 85 | 86 | it("should render correctly with percent display format", () => { 87 | const card = renderWakatimeCard(wakaTimeData.data, { 88 | display_format: "percent", 89 | }); 90 | expect(card).toMatchSnapshot(); 91 | }); 92 | }); 93 | -------------------------------------------------------------------------------- /src/fetchers/repo.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | import { MissingParamError } from "../common/error.js"; 4 | import { request } from "../common/http.js"; 5 | import { retryer } from "../common/retryer.js"; 6 | 7 | /** 8 | * Repo data fetcher. 9 | * 10 | * @param {object} variables Fetcher variables. 11 | * @param {string} token GitHub token. 12 | * @returns {Promise} The response. 13 | */ 14 | const fetcher = (variables, token) => { 15 | return request( 16 | { 17 | query: ` 18 | fragment RepoInfo on Repository { 19 | name 20 | nameWithOwner 21 | isPrivate 22 | isArchived 23 | isTemplate 24 | stargazers { 25 | totalCount 26 | } 27 | description 28 | primaryLanguage { 29 | color 30 | id 31 | name 32 | } 33 | forkCount 34 | } 35 | query getRepo($login: String!, $repo: String!) { 36 | user(login: $login) { 37 | repository(name: $repo) { 38 | ...RepoInfo 39 | } 40 | } 41 | organization(login: $login) { 42 | repository(name: $repo) { 43 | ...RepoInfo 44 | } 45 | } 46 | } 47 | `, 48 | variables, 49 | }, 50 | { 51 | Authorization: `token ${token}`, 52 | }, 53 | ); 54 | }; 55 | 56 | const urlExample = "/api/pin?username=USERNAME&repo=REPO_NAME"; 57 | 58 | /** 59 | * @typedef {import("./types").RepositoryData} RepositoryData Repository data. 60 | */ 61 | 62 | /** 63 | * Fetch repository data. 64 | * 65 | * @param {string} username GitHub username. 66 | * @param {string} reponame GitHub repository name. 67 | * @returns {Promise} Repository data. 68 | */ 69 | const fetchRepo = async (username, reponame) => { 70 | if (!username && !reponame) { 71 | throw new MissingParamError(["username", "repo"], urlExample); 72 | } 73 | if (!username) { 74 | throw new MissingParamError(["username"], urlExample); 75 | } 76 | if (!reponame) { 77 | throw new MissingParamError(["repo"], urlExample); 78 | } 79 | 80 | let res = await retryer(fetcher, { login: username, repo: reponame }); 81 | 82 | const data = res.data.data; 83 | 84 | if (!data.user && !data.organization) { 85 | throw new Error("Not found"); 86 | } 87 | 88 | const isUser = data.organization === null && data.user; 89 | const isOrg = data.user === null && data.organization; 90 | 91 | if (isUser) { 92 | if (!data.user.repository || data.user.repository.isPrivate) { 93 | throw new Error("User Repository Not found"); 94 | } 95 | return { 96 | ...data.user.repository, 97 | starCount: data.user.repository.stargazers.totalCount, 98 | }; 99 | } 100 | 101 | if (isOrg) { 102 | if ( 103 | !data.organization.repository || 104 | data.organization.repository.isPrivate 105 | ) { 106 | throw new Error("Organization Repository Not found"); 107 | } 108 | return { 109 | ...data.organization.repository, 110 | starCount: data.organization.repository.stargazers.totalCount, 111 | }; 112 | } 113 | 114 | throw new Error("Unexpected behavior"); 115 | }; 116 | 117 | export { fetchRepo }; 118 | export default fetchRepo; 119 | -------------------------------------------------------------------------------- /src/common/retryer.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | import { CustomError } from "./error.js"; 4 | import { logger } from "./log.js"; 5 | 6 | // Script variables. 7 | 8 | // Count the number of GitHub API tokens available. 9 | const PATs = Object.keys(process.env).filter((key) => 10 | /PAT_\d*$/.exec(key), 11 | ).length; 12 | const RETRIES = process.env.NODE_ENV === "test" ? 7 : PATs; 13 | 14 | /** 15 | * @typedef {import("axios").AxiosResponse} AxiosResponse Axios response. 16 | * @typedef {(variables: any, token: string, retriesForTests?: number) => Promise} FetcherFunction Fetcher function. 17 | */ 18 | 19 | /** 20 | * Try to execute the fetcher function until it succeeds or the max number of retries is reached. 21 | * 22 | * @param {FetcherFunction} fetcher The fetcher function. 23 | * @param {any} variables Object with arguments to pass to the fetcher function. 24 | * @param {number} retries How many times to retry. 25 | * @returns {Promise} The response from the fetcher function. 26 | */ 27 | const retryer = async (fetcher, variables, retries = 0) => { 28 | if (!RETRIES) { 29 | throw new CustomError("No GitHub API tokens found", CustomError.NO_TOKENS); 30 | } 31 | 32 | if (retries > RETRIES) { 33 | throw new CustomError( 34 | "Downtime due to GitHub API rate limiting", 35 | CustomError.MAX_RETRY, 36 | ); 37 | } 38 | 39 | try { 40 | // try to fetch with the first token since RETRIES is 0 index i'm adding +1 41 | let response = await fetcher( 42 | variables, 43 | // @ts-ignore 44 | process.env[`PAT_${retries + 1}`], 45 | // used in tests for faking rate limit 46 | retries, 47 | ); 48 | 49 | // react on both type and message-based rate-limit signals. 50 | // https://github.com/anuraghazra/github-readme-stats/issues/4425 51 | const errors = response?.data?.errors; 52 | const errorType = errors?.[0]?.type; 53 | const errorMsg = errors?.[0]?.message || ""; 54 | const isRateLimited = 55 | (errors && errorType === "RATE_LIMITED") || /rate limit/i.test(errorMsg); 56 | 57 | // if rate limit is hit increase the RETRIES and recursively call the retryer 58 | // with username, and current RETRIES 59 | if (isRateLimited) { 60 | logger.log(`PAT_${retries + 1} Failed`); 61 | retries++; 62 | // directly return from the function 63 | return retryer(fetcher, variables, retries); 64 | } 65 | 66 | // finally return the response 67 | return response; 68 | } catch (err) { 69 | /** @type {any} */ 70 | const e = err; 71 | 72 | // network/unexpected error → let caller treat as failure 73 | if (!e?.response) { 74 | throw e; 75 | } 76 | 77 | // prettier-ignore 78 | // also checking for bad credentials if any tokens gets invalidated 79 | const isBadCredential = 80 | e?.response?.data?.message === "Bad credentials"; 81 | const isAccountSuspended = 82 | e?.response?.data?.message === "Sorry. Your account was suspended."; 83 | 84 | if (isBadCredential || isAccountSuspended) { 85 | logger.log(`PAT_${retries + 1} Failed`); 86 | retries++; 87 | // directly return from the function 88 | return retryer(fetcher, variables, retries); 89 | } 90 | 91 | // HTTP error with a response → return it for caller-side handling 92 | return e.response; 93 | } 94 | }; 95 | 96 | export { retryer, RETRIES }; 97 | export default retryer; 98 | -------------------------------------------------------------------------------- /tests/fetchRepo.test.js: -------------------------------------------------------------------------------- 1 | import { afterEach, describe, expect, it } from "@jest/globals"; 2 | import "@testing-library/jest-dom"; 3 | import axios from "axios"; 4 | import MockAdapter from "axios-mock-adapter"; 5 | import { fetchRepo } from "../src/fetchers/repo.js"; 6 | 7 | const data_repo = { 8 | repository: { 9 | name: "convoychat", 10 | stargazers: { totalCount: 38000 }, 11 | description: "Help us take over the world! React + TS + GraphQL Chat App", 12 | primaryLanguage: { 13 | color: "#2b7489", 14 | id: "MDg6TGFuZ3VhZ2UyODc=", 15 | name: "TypeScript", 16 | }, 17 | forkCount: 100, 18 | }, 19 | }; 20 | 21 | const data_user = { 22 | data: { 23 | user: { repository: data_repo.repository }, 24 | organization: null, 25 | }, 26 | }; 27 | 28 | const data_org = { 29 | data: { 30 | user: null, 31 | organization: { repository: data_repo.repository }, 32 | }, 33 | }; 34 | 35 | const mock = new MockAdapter(axios); 36 | 37 | afterEach(() => { 38 | mock.reset(); 39 | }); 40 | 41 | describe("Test fetchRepo", () => { 42 | it("should fetch correct user repo", async () => { 43 | mock.onPost("https://api.github.com/graphql").reply(200, data_user); 44 | 45 | let repo = await fetchRepo("anuraghazra", "convoychat"); 46 | 47 | expect(repo).toStrictEqual({ 48 | ...data_repo.repository, 49 | starCount: data_repo.repository.stargazers.totalCount, 50 | }); 51 | }); 52 | 53 | it("should fetch correct org repo", async () => { 54 | mock.onPost("https://api.github.com/graphql").reply(200, data_org); 55 | 56 | let repo = await fetchRepo("anuraghazra", "convoychat"); 57 | expect(repo).toStrictEqual({ 58 | ...data_repo.repository, 59 | starCount: data_repo.repository.stargazers.totalCount, 60 | }); 61 | }); 62 | 63 | it("should throw error if user is found but repo is null", async () => { 64 | mock 65 | .onPost("https://api.github.com/graphql") 66 | .reply(200, { data: { user: { repository: null }, organization: null } }); 67 | 68 | await expect(fetchRepo("anuraghazra", "convoychat")).rejects.toThrow( 69 | "User Repository Not found", 70 | ); 71 | }); 72 | 73 | it("should throw error if org is found but repo is null", async () => { 74 | mock 75 | .onPost("https://api.github.com/graphql") 76 | .reply(200, { data: { user: null, organization: { repository: null } } }); 77 | 78 | await expect(fetchRepo("anuraghazra", "convoychat")).rejects.toThrow( 79 | "Organization Repository Not found", 80 | ); 81 | }); 82 | 83 | it("should throw error if both user & org data not found", async () => { 84 | mock 85 | .onPost("https://api.github.com/graphql") 86 | .reply(200, { data: { user: null, organization: null } }); 87 | 88 | await expect(fetchRepo("anuraghazra", "convoychat")).rejects.toThrow( 89 | "Not found", 90 | ); 91 | }); 92 | 93 | it("should throw error if repository is private", async () => { 94 | mock.onPost("https://api.github.com/graphql").reply(200, { 95 | data: { 96 | user: { repository: { ...data_repo, isPrivate: true } }, 97 | organization: null, 98 | }, 99 | }); 100 | 101 | await expect(fetchRepo("anuraghazra", "convoychat")).rejects.toThrow( 102 | "User Repository Not found", 103 | ); 104 | }); 105 | }); 106 | -------------------------------------------------------------------------------- /src/common/ops.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | import toEmoji from "emoji-name-map"; 4 | 5 | /** 6 | * Returns boolean if value is either "true" or "false" else the value as it is. 7 | * 8 | * @param {string | boolean} value The value to parse. 9 | * @returns {boolean | undefined } The parsed value. 10 | */ 11 | const parseBoolean = (value) => { 12 | if (typeof value === "boolean") { 13 | return value; 14 | } 15 | 16 | if (typeof value === "string") { 17 | if (value.toLowerCase() === "true") { 18 | return true; 19 | } else if (value.toLowerCase() === "false") { 20 | return false; 21 | } 22 | } 23 | return undefined; 24 | }; 25 | 26 | /** 27 | * Parse string to array of strings. 28 | * 29 | * @param {string} str The string to parse. 30 | * @returns {string[]} The array of strings. 31 | */ 32 | const parseArray = (str) => { 33 | if (!str) { 34 | return []; 35 | } 36 | return str.split(","); 37 | }; 38 | 39 | /** 40 | * Clamp the given number between the given range. 41 | * 42 | * @param {number} number The number to clamp. 43 | * @param {number} min The minimum value. 44 | * @param {number} max The maximum value. 45 | * @returns {number} The clamped number. 46 | */ 47 | const clampValue = (number, min, max) => { 48 | // @ts-ignore 49 | if (Number.isNaN(parseInt(number, 10))) { 50 | return min; 51 | } 52 | return Math.max(min, Math.min(number, max)); 53 | }; 54 | 55 | /** 56 | * Lowercase and trim string. 57 | * 58 | * @param {string} name String to lowercase and trim. 59 | * @returns {string} Lowercased and trimmed string. 60 | */ 61 | const lowercaseTrim = (name) => name.toLowerCase().trim(); 62 | 63 | /** 64 | * Split array of languages in two columns. 65 | * 66 | * @template T Language object. 67 | * @param {Array} arr Array of languages. 68 | * @param {number} perChunk Number of languages per column. 69 | * @returns {Array} Array of languages split in two columns. 70 | */ 71 | const chunkArray = (arr, perChunk) => { 72 | return arr.reduce((resultArray, item, index) => { 73 | const chunkIndex = Math.floor(index / perChunk); 74 | 75 | if (!resultArray[chunkIndex]) { 76 | // @ts-ignore 77 | resultArray[chunkIndex] = []; // start a new chunk 78 | } 79 | 80 | // @ts-ignore 81 | resultArray[chunkIndex].push(item); 82 | 83 | return resultArray; 84 | }, []); 85 | }; 86 | 87 | /** 88 | * Parse emoji from string. 89 | * 90 | * @param {string} str String to parse emoji from. 91 | * @returns {string} String with emoji parsed. 92 | */ 93 | const parseEmojis = (str) => { 94 | if (!str) { 95 | throw new Error("[parseEmoji]: str argument not provided"); 96 | } 97 | return str.replace(/:\w+:/gm, (emoji) => { 98 | return toEmoji.get(emoji) || ""; 99 | }); 100 | }; 101 | 102 | /** 103 | * Get diff in minutes between two dates. 104 | * 105 | * @param {Date} d1 First date. 106 | * @param {Date} d2 Second date. 107 | * @returns {number} Number of minutes between the two dates. 108 | */ 109 | const dateDiff = (d1, d2) => { 110 | const date1 = new Date(d1); 111 | const date2 = new Date(d2); 112 | const diff = date1.getTime() - date2.getTime(); 113 | return Math.round(diff / (1000 * 60)); 114 | }; 115 | 116 | export { 117 | parseBoolean, 118 | parseArray, 119 | clampValue, 120 | lowercaseTrim, 121 | chunkArray, 122 | parseEmojis, 123 | dateDiff, 124 | }; 125 | -------------------------------------------------------------------------------- /api/gist.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | import { renderError } from "../src/common/render.js"; 4 | import { isLocaleAvailable } from "../src/translations.js"; 5 | import { renderGistCard } from "../src/cards/gist.js"; 6 | import { fetchGist } from "../src/fetchers/gist.js"; 7 | import { 8 | CACHE_TTL, 9 | resolveCacheSeconds, 10 | setCacheHeaders, 11 | setErrorCacheHeaders, 12 | } from "../src/common/cache.js"; 13 | import { guardAccess } from "../src/common/access.js"; 14 | import { 15 | MissingParamError, 16 | retrieveSecondaryMessage, 17 | } from "../src/common/error.js"; 18 | import { parseBoolean } from "../src/common/ops.js"; 19 | 20 | // @ts-ignore 21 | export default async (req, res) => { 22 | const { 23 | id, 24 | title_color, 25 | icon_color, 26 | text_color, 27 | bg_color, 28 | theme, 29 | cache_seconds, 30 | locale, 31 | border_radius, 32 | border_color, 33 | show_owner, 34 | hide_border, 35 | } = req.query; 36 | 37 | res.setHeader("Content-Type", "image/svg+xml"); 38 | 39 | const access = guardAccess({ 40 | res, 41 | id, 42 | type: "gist", 43 | colors: { 44 | title_color, 45 | text_color, 46 | bg_color, 47 | border_color, 48 | theme, 49 | }, 50 | }); 51 | if (!access.isPassed) { 52 | return access.result; 53 | } 54 | 55 | if (locale && !isLocaleAvailable(locale)) { 56 | return res.send( 57 | renderError({ 58 | message: "Something went wrong", 59 | secondaryMessage: "Language not found", 60 | renderOptions: { 61 | title_color, 62 | text_color, 63 | bg_color, 64 | border_color, 65 | theme, 66 | }, 67 | }), 68 | ); 69 | } 70 | 71 | try { 72 | const gistData = await fetchGist(id); 73 | const cacheSeconds = resolveCacheSeconds({ 74 | requested: parseInt(cache_seconds, 10), 75 | def: CACHE_TTL.GIST_CARD.DEFAULT, 76 | min: CACHE_TTL.GIST_CARD.MIN, 77 | max: CACHE_TTL.GIST_CARD.MAX, 78 | }); 79 | 80 | setCacheHeaders(res, cacheSeconds); 81 | 82 | return res.send( 83 | renderGistCard(gistData, { 84 | title_color, 85 | icon_color, 86 | text_color, 87 | bg_color, 88 | theme, 89 | border_radius, 90 | border_color, 91 | locale: locale ? locale.toLowerCase() : null, 92 | show_owner: parseBoolean(show_owner), 93 | hide_border: parseBoolean(hide_border), 94 | }), 95 | ); 96 | } catch (err) { 97 | setErrorCacheHeaders(res); 98 | if (err instanceof Error) { 99 | return res.send( 100 | renderError({ 101 | message: err.message, 102 | secondaryMessage: retrieveSecondaryMessage(err), 103 | renderOptions: { 104 | title_color, 105 | text_color, 106 | bg_color, 107 | border_color, 108 | theme, 109 | show_repo_link: !(err instanceof MissingParamError), 110 | }, 111 | }), 112 | ); 113 | } 114 | return res.send( 115 | renderError({ 116 | message: "An unknown error occurred", 117 | renderOptions: { 118 | title_color, 119 | text_color, 120 | bg_color, 121 | border_color, 122 | theme, 123 | }, 124 | }), 125 | ); 126 | } 127 | }; 128 | -------------------------------------------------------------------------------- /api/status/up.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | /** 4 | * @file Contains a simple cloud function that can be used to check if the PATs are still 5 | * functional. 6 | * 7 | * @description This function is currently rate limited to 1 request per 5 minutes. 8 | */ 9 | 10 | import { request } from "../../src/common/http.js"; 11 | import retryer from "../../src/common/retryer.js"; 12 | import { logger } from "../../src/common/log.js"; 13 | 14 | export const RATE_LIMIT_SECONDS = 60 * 5; // 1 request per 5 minutes 15 | 16 | /** 17 | * Simple uptime check fetcher for the PATs. 18 | * 19 | * @param {any} variables Fetcher variables. 20 | * @param {string} token GitHub token. 21 | * @returns {Promise} The response. 22 | */ 23 | const uptimeFetcher = (variables, token) => { 24 | return request( 25 | { 26 | query: ` 27 | query { 28 | rateLimit { 29 | remaining 30 | } 31 | } 32 | `, 33 | variables, 34 | }, 35 | { 36 | Authorization: `bearer ${token}`, 37 | }, 38 | ); 39 | }; 40 | 41 | /** 42 | * @typedef {{ 43 | * schemaVersion: number; 44 | * label: string; 45 | * message: "up" | "down"; 46 | * color: "brightgreen" | "red"; 47 | * isError: boolean 48 | * }} ShieldsResponse Shields.io response object. 49 | */ 50 | 51 | /** 52 | * Creates Json response that can be used for shields.io dynamic card generation. 53 | * 54 | * @param {boolean} up Whether the PATs are up or not. 55 | * @returns {ShieldsResponse} Dynamic shields.io JSON response object. 56 | * 57 | * @see https://shields.io/endpoint. 58 | */ 59 | const shieldsUptimeBadge = (up) => { 60 | const schemaVersion = 1; 61 | const isError = true; 62 | const label = "Public Instance"; 63 | const message = up ? "up" : "down"; 64 | const color = up ? "brightgreen" : "red"; 65 | return { 66 | schemaVersion, 67 | label, 68 | message, 69 | color, 70 | isError, 71 | }; 72 | }; 73 | 74 | /** 75 | * Cloud function that returns whether the PATs are still functional. 76 | * 77 | * @param {any} req The request. 78 | * @param {any} res The response. 79 | * @returns {Promise} Nothing. 80 | */ 81 | export default async (req, res) => { 82 | let { type } = req.query; 83 | type = type ? type.toLowerCase() : "boolean"; 84 | 85 | res.setHeader("Content-Type", "application/json"); 86 | 87 | try { 88 | let PATsValid = true; 89 | try { 90 | await retryer(uptimeFetcher, {}); 91 | } catch (err) { 92 | // Resolve eslint no-unused-vars 93 | err; 94 | 95 | PATsValid = false; 96 | } 97 | 98 | if (PATsValid) { 99 | res.setHeader( 100 | "Cache-Control", 101 | `max-age=0, s-maxage=${RATE_LIMIT_SECONDS}`, 102 | ); 103 | } else { 104 | res.setHeader("Cache-Control", "no-store"); 105 | } 106 | 107 | switch (type) { 108 | case "shields": 109 | res.send(shieldsUptimeBadge(PATsValid)); 110 | break; 111 | case "json": 112 | res.send({ up: PATsValid }); 113 | break; 114 | default: 115 | res.send(PATsValid); 116 | break; 117 | } 118 | } catch (err) { 119 | // Return fail boolean if something went wrong. 120 | logger.error(err); 121 | res.setHeader("Cache-Control", "no-store"); 122 | res.send("Something went wrong: " + err.message); 123 | } 124 | }; 125 | -------------------------------------------------------------------------------- /api/pin.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | import { renderRepoCard } from "../src/cards/repo.js"; 4 | import { guardAccess } from "../src/common/access.js"; 5 | import { 6 | CACHE_TTL, 7 | resolveCacheSeconds, 8 | setCacheHeaders, 9 | setErrorCacheHeaders, 10 | } from "../src/common/cache.js"; 11 | import { 12 | MissingParamError, 13 | retrieveSecondaryMessage, 14 | } from "../src/common/error.js"; 15 | import { parseBoolean } from "../src/common/ops.js"; 16 | import { renderError } from "../src/common/render.js"; 17 | import { fetchRepo } from "../src/fetchers/repo.js"; 18 | import { isLocaleAvailable } from "../src/translations.js"; 19 | 20 | // @ts-ignore 21 | export default async (req, res) => { 22 | const { 23 | username, 24 | repo, 25 | hide_border, 26 | title_color, 27 | icon_color, 28 | text_color, 29 | bg_color, 30 | theme, 31 | show_owner, 32 | cache_seconds, 33 | locale, 34 | border_radius, 35 | border_color, 36 | description_lines_count, 37 | } = req.query; 38 | 39 | res.setHeader("Content-Type", "image/svg+xml"); 40 | 41 | const access = guardAccess({ 42 | res, 43 | id: username, 44 | type: "username", 45 | colors: { 46 | title_color, 47 | text_color, 48 | bg_color, 49 | border_color, 50 | theme, 51 | }, 52 | }); 53 | if (!access.isPassed) { 54 | return access.result; 55 | } 56 | 57 | if (locale && !isLocaleAvailable(locale)) { 58 | return res.send( 59 | renderError({ 60 | message: "Something went wrong", 61 | secondaryMessage: "Language not found", 62 | renderOptions: { 63 | title_color, 64 | text_color, 65 | bg_color, 66 | border_color, 67 | theme, 68 | }, 69 | }), 70 | ); 71 | } 72 | 73 | try { 74 | const repoData = await fetchRepo(username, repo); 75 | const cacheSeconds = resolveCacheSeconds({ 76 | requested: parseInt(cache_seconds, 10), 77 | def: CACHE_TTL.PIN_CARD.DEFAULT, 78 | min: CACHE_TTL.PIN_CARD.MIN, 79 | max: CACHE_TTL.PIN_CARD.MAX, 80 | }); 81 | 82 | setCacheHeaders(res, cacheSeconds); 83 | 84 | return res.send( 85 | renderRepoCard(repoData, { 86 | hide_border: parseBoolean(hide_border), 87 | title_color, 88 | icon_color, 89 | text_color, 90 | bg_color, 91 | theme, 92 | border_radius, 93 | border_color, 94 | show_owner: parseBoolean(show_owner), 95 | locale: locale ? locale.toLowerCase() : null, 96 | description_lines_count, 97 | }), 98 | ); 99 | } catch (err) { 100 | setErrorCacheHeaders(res); 101 | if (err instanceof Error) { 102 | return res.send( 103 | renderError({ 104 | message: err.message, 105 | secondaryMessage: retrieveSecondaryMessage(err), 106 | renderOptions: { 107 | title_color, 108 | text_color, 109 | bg_color, 110 | border_color, 111 | theme, 112 | show_repo_link: !(err instanceof MissingParamError), 113 | }, 114 | }), 115 | ); 116 | } 117 | return res.send( 118 | renderError({ 119 | message: "An unknown error occurred", 120 | renderOptions: { 121 | title_color, 122 | text_color, 123 | bg_color, 124 | border_color, 125 | theme, 126 | }, 127 | }), 128 | ); 129 | } 130 | }; 131 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as 6 | contributors and maintainers pledge to making participation in our project and 7 | our community a harassment-free experience for everyone, regardless of age, body 8 | size, disability, ethnicity, sex characteristics, gender identity and expression, 9 | level of experience, education, socio-economic status, nationality, personal 10 | appearance, race, religion, or sexual identity and orientation. 11 | 12 | ## Our Standards 13 | 14 | Examples of behavior that contributes to creating a positive environment 15 | include: 16 | 17 | * Using welcoming and inclusive language 18 | * Being respectful of differing viewpoints and experiences 19 | * Gracefully accepting constructive criticism 20 | * Focusing on what is best for the community 21 | * Showing empathy towards other community members 22 | 23 | Examples of unacceptable behavior by participants include: 24 | 25 | * The use of sexualized language or imagery and unwelcome sexual attention or 26 | advances 27 | * Trolling, insulting/derogatory comments, and personal or political attacks 28 | * Public or private harassment 29 | * Publishing others' private information, such as a physical or electronic 30 | address, without explicit permission 31 | * Other conduct which could reasonably be considered inappropriate in a 32 | professional setting 33 | 34 | ## Our Responsibilities 35 | 36 | Project maintainers are responsible for clarifying the standards of acceptable 37 | behavior and are expected to take appropriate and fair corrective action in 38 | response to any instances of unacceptable behavior. 39 | 40 | Project maintainers have the right and responsibility to remove, edit, or 41 | reject comments, commits, code, wiki edits, issues, and other contributions 42 | that are not aligned to this Code of Conduct, or to ban temporarily or 43 | permanently any contributor for other behaviors that they deem inappropriate, 44 | threatening, offensive, or harmful. 45 | 46 | ## Scope 47 | 48 | This Code of Conduct applies both within project spaces and in public spaces 49 | when an individual is representing the project or its community. Examples of 50 | representing a project or community include using an official project e-mail 51 | address, posting via an official social media account, or acting as an appointed 52 | representative at an online or offline event. Representation of a project may be 53 | further defined and clarified by project maintainers. 54 | 55 | ## Enforcement 56 | 57 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 58 | reported by contacting the project team at hazru.anurag@gmail.com. All 59 | complaints will be reviewed and investigated and will result in a response that 60 | is deemed necessary and appropriate to the circumstances. The project team is 61 | obligated to maintain confidentiality with regard to the reporter of an incident. 62 | Further details of specific enforcement policies may be posted separately. 63 | 64 | Project maintainers who do not follow or enforce the Code of Conduct in good 65 | faith may face temporary or permanent repercussions as determined by other 66 | members of the project's leadership. 67 | 68 | ## Attribution 69 | 70 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, 71 | available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html 72 | 73 | [homepage]: https://www.contributor-covenant.org 74 | 75 | For answers to common questions about this code of conduct, see 76 | https://www.contributor-covenant.org/faq 77 | -------------------------------------------------------------------------------- /scripts/generate-theme-doc.js: -------------------------------------------------------------------------------- 1 | import fs from "fs"; 2 | import { themes } from "../themes/index.js"; 3 | 4 | const TARGET_FILE = "./themes/README.md"; 5 | const REPO_CARD_LINKS_FLAG = ""; 6 | const STAT_CARD_LINKS_FLAG = ""; 7 | 8 | const STAT_CARD_TABLE_FLAG = ""; 9 | const REPO_CARD_TABLE_FLAG = ""; 10 | 11 | const THEME_TEMPLATE = `## Available Themes 12 | 13 | 14 | 15 | With inbuilt themes, you can customize the look of the card without doing any manual customization. 16 | 17 | Use \`?theme=THEME_NAME\` parameter like so: 18 | 19 | \`\`\`md 20 | ![Anurag's GitHub stats](https://github-readme-stats.vercel.app/api?username=anuraghazra&theme=dark&show_icons=true) 21 | \`\`\` 22 | 23 | ## Stats 24 | 25 | > These themes work with all five of our cards: Stats Card, Repo Card, Gist Card, Top Languages Card, and WakaTime Card. 26 | 27 | | | | | 28 | | :--: | :--: | :--: | 29 | ${STAT_CARD_TABLE_FLAG} 30 | 31 | ## Repo Card 32 | 33 | > These themes work with all five of our cards: Stats Card, Repo Card, Gist Card, Top Languages Card, and WakaTime Card. 34 | 35 | | | | | 36 | | :--: | :--: | :--: | 37 | ${REPO_CARD_TABLE_FLAG} 38 | 39 | ${STAT_CARD_LINKS_FLAG} 40 | 41 | ${REPO_CARD_LINKS_FLAG} 42 | `; 43 | 44 | const createRepoMdLink = (theme) => { 45 | return `\n[${theme}_repo]: https://github-readme-stats.vercel.app/api/pin/?username=anuraghazra&repo=github-readme-stats&cache_seconds=86400&theme=${theme}`; 46 | }; 47 | const createStatMdLink = (theme) => { 48 | return `\n[${theme}]: https://github-readme-stats.vercel.app/api?username=anuraghazra&show_icons=true&hide=contribs,prs&cache_seconds=86400&theme=${theme}`; 49 | }; 50 | 51 | const generateLinks = (fn) => { 52 | return Object.keys(themes) 53 | .map((name) => fn(name)) 54 | .join(""); 55 | }; 56 | 57 | const createTableItem = ({ link, label, isRepoCard }) => { 58 | if (!link || !label) { 59 | return ""; 60 | } 61 | return `\`${label}\` ![${link}][${link}${isRepoCard ? "_repo" : ""}]`; 62 | }; 63 | 64 | const generateTable = ({ isRepoCard }) => { 65 | const rows = []; 66 | const themesFiltered = Object.keys(themes).filter( 67 | (name) => name !== (isRepoCard ? "default" : "default_repocard"), 68 | ); 69 | 70 | for (let i = 0; i < themesFiltered.length; i += 3) { 71 | const one = themesFiltered[i]; 72 | const two = themesFiltered[i + 1]; 73 | const three = themesFiltered[i + 2]; 74 | 75 | let tableItem1 = createTableItem({ link: one, label: one, isRepoCard }); 76 | let tableItem2 = createTableItem({ link: two, label: two, isRepoCard }); 77 | let tableItem3 = createTableItem({ link: three, label: three, isRepoCard }); 78 | 79 | rows.push(`| ${tableItem1} | ${tableItem2} | ${tableItem3} |`); 80 | } 81 | 82 | return rows.join("\n"); 83 | }; 84 | 85 | const buildReadme = () => { 86 | return THEME_TEMPLATE.split("\n") 87 | .map((line) => { 88 | if (line.includes(REPO_CARD_LINKS_FLAG)) { 89 | return generateLinks(createRepoMdLink); 90 | } 91 | if (line.includes(STAT_CARD_LINKS_FLAG)) { 92 | return generateLinks(createStatMdLink); 93 | } 94 | if (line.includes(REPO_CARD_TABLE_FLAG)) { 95 | return generateTable({ isRepoCard: true }); 96 | } 97 | if (line.includes(STAT_CARD_TABLE_FLAG)) { 98 | return generateTable({ isRepoCard: false }); 99 | } 100 | return line; 101 | }) 102 | .join("\n"); 103 | }; 104 | 105 | fs.writeFileSync(TARGET_FILE, buildReadme()); 106 | -------------------------------------------------------------------------------- /tests/fetchGist.test.js: -------------------------------------------------------------------------------- 1 | import { afterEach, describe, expect, it } from "@jest/globals"; 2 | import "@testing-library/jest-dom"; 3 | import axios from "axios"; 4 | import MockAdapter from "axios-mock-adapter"; 5 | import { fetchGist } from "../src/fetchers/gist.js"; 6 | 7 | const gist_data = { 8 | data: { 9 | viewer: { 10 | gist: { 11 | description: 12 | "List of countries and territories in English and Spanish: name, continent, capital, dial code, country codes, TLD, and area in sq km. Lista de países y territorios en Inglés y Español: nombre, continente, capital, código de teléfono, códigos de país, dominio y área en km cuadrados. Updated 2023", 13 | owner: { 14 | login: "Yizack", 15 | }, 16 | stargazerCount: 33, 17 | forks: { 18 | totalCount: 11, 19 | }, 20 | files: [ 21 | { 22 | name: "countries.json", 23 | language: { 24 | name: "JSON", 25 | }, 26 | size: 85858, 27 | }, 28 | { 29 | name: "territories.txt", 30 | language: { 31 | name: "Text", 32 | }, 33 | size: 87858, 34 | }, 35 | { 36 | name: "countries_spanish.json", 37 | language: { 38 | name: "JSON", 39 | }, 40 | size: 85858, 41 | }, 42 | { 43 | name: "territories_spanish.txt", 44 | language: { 45 | name: "Text", 46 | }, 47 | size: 87858, 48 | }, 49 | ], 50 | }, 51 | }, 52 | }, 53 | }; 54 | 55 | const gist_not_found_data = { 56 | data: { 57 | viewer: { 58 | gist: null, 59 | }, 60 | }, 61 | }; 62 | 63 | const gist_errors_data = { 64 | errors: [ 65 | { 66 | message: "Some test GraphQL error", 67 | }, 68 | ], 69 | }; 70 | 71 | const mock = new MockAdapter(axios); 72 | 73 | afterEach(() => { 74 | mock.reset(); 75 | }); 76 | 77 | describe("Test fetchGist", () => { 78 | it("should fetch gist correctly", async () => { 79 | mock.onPost("https://api.github.com/graphql").reply(200, gist_data); 80 | 81 | let gist = await fetchGist("bbfce31e0217a3689c8d961a356cb10d"); 82 | 83 | expect(gist).toStrictEqual({ 84 | name: "countries.json", 85 | nameWithOwner: "Yizack/countries.json", 86 | description: 87 | "List of countries and territories in English and Spanish: name, continent, capital, dial code, country codes, TLD, and area in sq km. Lista de países y territorios en Inglés y Español: nombre, continente, capital, código de teléfono, códigos de país, dominio y área en km cuadrados. Updated 2023", 88 | language: "Text", 89 | starsCount: 33, 90 | forksCount: 11, 91 | }); 92 | }); 93 | 94 | it("should throw correct error if gist not found", async () => { 95 | mock 96 | .onPost("https://api.github.com/graphql") 97 | .reply(200, gist_not_found_data); 98 | 99 | await expect(fetchGist("bbfce31e0217a3689c8d961a356cb10d")).rejects.toThrow( 100 | "Gist not found", 101 | ); 102 | }); 103 | 104 | it("should throw error if reaponse contains them", async () => { 105 | mock.onPost("https://api.github.com/graphql").reply(200, gist_errors_data); 106 | 107 | await expect(fetchGist("bbfce31e0217a3689c8d961a356cb10d")).rejects.toThrow( 108 | "Some test GraphQL error", 109 | ); 110 | }); 111 | 112 | it("should throw error if id is not provided", async () => { 113 | await expect(fetchGist()).rejects.toThrow( 114 | 'Missing params "id" make sure you pass the parameters in URL', 115 | ); 116 | }); 117 | }); 118 | -------------------------------------------------------------------------------- /api/wakatime.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | import { renderWakatimeCard } from "../src/cards/wakatime.js"; 4 | import { renderError } from "../src/common/render.js"; 5 | import { fetchWakatimeStats } from "../src/fetchers/wakatime.js"; 6 | import { isLocaleAvailable } from "../src/translations.js"; 7 | import { 8 | CACHE_TTL, 9 | resolveCacheSeconds, 10 | setCacheHeaders, 11 | setErrorCacheHeaders, 12 | } from "../src/common/cache.js"; 13 | import { guardAccess } from "../src/common/access.js"; 14 | import { 15 | MissingParamError, 16 | retrieveSecondaryMessage, 17 | } from "../src/common/error.js"; 18 | import { parseArray, parseBoolean } from "../src/common/ops.js"; 19 | 20 | // @ts-ignore 21 | export default async (req, res) => { 22 | const { 23 | username, 24 | title_color, 25 | icon_color, 26 | hide_border, 27 | card_width, 28 | line_height, 29 | text_color, 30 | bg_color, 31 | theme, 32 | cache_seconds, 33 | hide_title, 34 | hide_progress, 35 | custom_title, 36 | locale, 37 | layout, 38 | langs_count, 39 | hide, 40 | api_domain, 41 | border_radius, 42 | border_color, 43 | display_format, 44 | disable_animations, 45 | } = req.query; 46 | 47 | res.setHeader("Content-Type", "image/svg+xml"); 48 | 49 | const access = guardAccess({ 50 | res, 51 | id: username, 52 | type: "wakatime", 53 | colors: { 54 | title_color, 55 | text_color, 56 | bg_color, 57 | border_color, 58 | theme, 59 | }, 60 | }); 61 | if (!access.isPassed) { 62 | return access.result; 63 | } 64 | 65 | if (locale && !isLocaleAvailable(locale)) { 66 | return res.send( 67 | renderError({ 68 | message: "Something went wrong", 69 | secondaryMessage: "Language not found", 70 | renderOptions: { 71 | title_color, 72 | text_color, 73 | bg_color, 74 | border_color, 75 | theme, 76 | }, 77 | }), 78 | ); 79 | } 80 | 81 | try { 82 | const stats = await fetchWakatimeStats({ username, api_domain }); 83 | const cacheSeconds = resolveCacheSeconds({ 84 | requested: parseInt(cache_seconds, 10), 85 | def: CACHE_TTL.WAKATIME_CARD.DEFAULT, 86 | min: CACHE_TTL.WAKATIME_CARD.MIN, 87 | max: CACHE_TTL.WAKATIME_CARD.MAX, 88 | }); 89 | 90 | setCacheHeaders(res, cacheSeconds); 91 | 92 | return res.send( 93 | renderWakatimeCard(stats, { 94 | custom_title, 95 | hide_title: parseBoolean(hide_title), 96 | hide_border: parseBoolean(hide_border), 97 | card_width: parseInt(card_width, 10), 98 | hide: parseArray(hide), 99 | line_height, 100 | title_color, 101 | icon_color, 102 | text_color, 103 | bg_color, 104 | theme, 105 | hide_progress, 106 | border_radius, 107 | border_color, 108 | locale: locale ? locale.toLowerCase() : null, 109 | layout, 110 | langs_count, 111 | display_format, 112 | disable_animations: parseBoolean(disable_animations), 113 | }), 114 | ); 115 | } catch (err) { 116 | setErrorCacheHeaders(res); 117 | if (err instanceof Error) { 118 | return res.send( 119 | renderError({ 120 | message: err.message, 121 | secondaryMessage: retrieveSecondaryMessage(err), 122 | renderOptions: { 123 | title_color, 124 | text_color, 125 | bg_color, 126 | border_color, 127 | theme, 128 | show_repo_link: !(err instanceof MissingParamError), 129 | }, 130 | }), 131 | ); 132 | } 133 | return res.send( 134 | renderError({ 135 | message: "An unknown error occurred", 136 | renderOptions: { 137 | title_color, 138 | text_color, 139 | bg_color, 140 | border_color, 141 | theme, 142 | }, 143 | }), 144 | ); 145 | } 146 | }; 147 | -------------------------------------------------------------------------------- /tests/bench/utils.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | const DEFAULT_RUNS = 1000; 4 | const DEFAULT_WARMUPS = 50; 5 | 6 | /** 7 | * Formats a duration in nanoseconds to a compact human-readable string. 8 | * 9 | * @param {bigint} ns Duration in nanoseconds. 10 | * @returns {string} Formatted time string. 11 | */ 12 | const formatTime = (ns) => { 13 | if (ns < 1_000n) { 14 | return `${ns}ns`; 15 | } 16 | if (ns < 1_000_000n) { 17 | return `${Number(ns) / 1_000}µs`; 18 | } 19 | if (ns < 1_000_000_000n) { 20 | return `${(Number(ns) / 1_000_000).toFixed(3)}ms`; 21 | } 22 | return `${(Number(ns) / 1_000_000_000).toFixed(3)}s`; 23 | }; 24 | 25 | /** 26 | * Measures synchronous or async function execution time. 27 | * 28 | * @param {Function} fn Function to measure. 29 | * @returns {Promise} elapsed nanoseconds 30 | */ 31 | const measurePerformance = async (fn) => { 32 | const start = process.hrtime.bigint(); 33 | const ret = fn(); 34 | if (ret instanceof Promise) { 35 | await ret; 36 | } 37 | const end = process.hrtime.bigint(); 38 | return end - start; 39 | }; 40 | 41 | /** 42 | * Computes basic & extended statistics. 43 | * 44 | * @param {bigint[]} samples Array of samples in nanoseconds. 45 | * @returns {object} Stats 46 | */ 47 | const computeStats = (samples) => { 48 | const sorted = [...samples].sort((a, b) => (a < b ? -1 : 1)); 49 | const toNumber = (b) => Number(b); // safe for typical short benches 50 | const n = sorted.length; 51 | const sum = sorted.reduce((a, b) => a + b, 0n); 52 | const avg = Number(sum) / n; 53 | const median = 54 | n % 2 55 | ? toNumber(sorted[(n - 1) / 2]) 56 | : (toNumber(sorted[n / 2 - 1]) + toNumber(sorted[n / 2])) / 2; 57 | const p = (q) => { 58 | const idx = Math.min(n - 1, Math.floor((q / 100) * n)); 59 | return toNumber(sorted[idx]); 60 | }; 61 | const min = toNumber(sorted[0]); 62 | const max = toNumber(sorted[n - 1]); 63 | const variance = 64 | sorted.reduce((acc, v) => acc + (toNumber(v) - avg) ** 2, 0) / n; 65 | const stdev = Math.sqrt(variance); 66 | 67 | return { 68 | runs: n, 69 | min, 70 | max, 71 | average: avg, 72 | median, 73 | p75: p(75), 74 | p95: p(95), 75 | p99: p(99), 76 | stdev, 77 | totalTime: toNumber(sum), 78 | }; 79 | }; 80 | 81 | /** 82 | * Benchmark a function. 83 | * 84 | * @param {string} fnName Name of the function (for logging). 85 | * @param {Function} fn Function to benchmark. 86 | * @param {object} [opts] Options. 87 | * @param {number} [opts.runs] Number of measured runs. 88 | * @param {number} [opts.warmup] Warm-up iterations (not measured). 89 | * @param {boolean} [opts.trimOutliers] Drop top & bottom 1% before stats. 90 | * @returns {Promise} Stats (nanoseconds for core metrics). 91 | */ 92 | export const runAndLogStats = async ( 93 | fnName, 94 | fn, 95 | { runs = DEFAULT_RUNS, warmup = DEFAULT_WARMUPS, trimOutliers = false } = {}, 96 | ) => { 97 | if (runs <= 0) { 98 | throw new Error("Number of runs must be positive."); 99 | } 100 | 101 | // Warm-up 102 | for (let i = 0; i < warmup; i++) { 103 | const ret = fn(); 104 | if (ret instanceof Promise) { 105 | await ret; 106 | } 107 | } 108 | 109 | const samples = []; 110 | for (let i = 0; i < runs; i++) { 111 | samples.push(await measurePerformance(fn)); 112 | } 113 | 114 | let processed = samples; 115 | if (trimOutliers && samples.length > 10) { 116 | const sorted = [...samples].sort((a, b) => (a < b ? -1 : 1)); 117 | const cut = Math.max(1, Math.floor(sorted.length * 0.01)); 118 | processed = sorted.slice(cut, sorted.length - cut); 119 | } 120 | 121 | const stats = computeStats(processed); 122 | 123 | const fmt = (ns) => formatTime(BigInt(Math.round(ns))); 124 | console.log( 125 | `${fnName} | runs=${stats.runs} avg=${fmt(stats.average)} median=${fmt( 126 | stats.median, 127 | )} p95=${fmt(stats.p95)} min=${fmt(stats.min)} max=${fmt( 128 | stats.max, 129 | )} stdev=${fmt(stats.stdev)}`, 130 | ); 131 | 132 | return stats; 133 | }; 134 | -------------------------------------------------------------------------------- /tests/fetchWakatime.test.js: -------------------------------------------------------------------------------- 1 | import { afterEach, describe, expect, it } from "@jest/globals"; 2 | import "@testing-library/jest-dom"; 3 | import axios from "axios"; 4 | import MockAdapter from "axios-mock-adapter"; 5 | import { fetchWakatimeStats } from "../src/fetchers/wakatime.js"; 6 | 7 | const mock = new MockAdapter(axios); 8 | 9 | afterEach(() => { 10 | mock.reset(); 11 | }); 12 | 13 | const wakaTimeData = { 14 | data: { 15 | categories: [ 16 | { 17 | digital: "22:40", 18 | hours: 22, 19 | minutes: 40, 20 | name: "Coding", 21 | percent: 100, 22 | text: "22 hrs 40 mins", 23 | total_seconds: 81643.570077, 24 | }, 25 | ], 26 | daily_average: 16095, 27 | daily_average_including_other_language: 16329, 28 | days_including_holidays: 7, 29 | days_minus_holidays: 5, 30 | editors: [ 31 | { 32 | digital: "22:40", 33 | hours: 22, 34 | minutes: 40, 35 | name: "VS Code", 36 | percent: 100, 37 | text: "22 hrs 40 mins", 38 | total_seconds: 81643.570077, 39 | }, 40 | ], 41 | holidays: 2, 42 | human_readable_daily_average: "4 hrs 28 mins", 43 | human_readable_daily_average_including_other_language: "4 hrs 32 mins", 44 | human_readable_total: "22 hrs 21 mins", 45 | human_readable_total_including_other_language: "22 hrs 40 mins", 46 | id: "random hash", 47 | is_already_updating: false, 48 | is_coding_activity_visible: true, 49 | is_including_today: false, 50 | is_other_usage_visible: true, 51 | is_stuck: false, 52 | is_up_to_date: true, 53 | languages: [ 54 | { 55 | digital: "0:19", 56 | hours: 0, 57 | minutes: 19, 58 | name: "Other", 59 | percent: 1.43, 60 | text: "19 mins", 61 | total_seconds: 1170.434361, 62 | }, 63 | { 64 | digital: "0:01", 65 | hours: 0, 66 | minutes: 1, 67 | name: "TypeScript", 68 | percent: 0.1, 69 | text: "1 min", 70 | total_seconds: 83.293809, 71 | }, 72 | { 73 | digital: "0:00", 74 | hours: 0, 75 | minutes: 0, 76 | name: "YAML", 77 | percent: 0.07, 78 | text: "0 secs", 79 | total_seconds: 54.975151, 80 | }, 81 | ], 82 | operating_systems: [ 83 | { 84 | digital: "22:40", 85 | hours: 22, 86 | minutes: 40, 87 | name: "Mac", 88 | percent: 100, 89 | text: "22 hrs 40 mins", 90 | total_seconds: 81643.570077, 91 | }, 92 | ], 93 | percent_calculated: 100, 94 | range: "last_7_days", 95 | status: "ok", 96 | timeout: 15, 97 | total_seconds: 80473.135716, 98 | total_seconds_including_other_language: 81643.570077, 99 | user_id: "random hash", 100 | username: "anuraghazra", 101 | writes_only: false, 102 | }, 103 | }; 104 | 105 | describe("WakaTime fetcher", () => { 106 | it("should fetch correct WakaTime data", async () => { 107 | const username = "anuraghazra"; 108 | mock 109 | .onGet( 110 | `https://wakatime.com/api/v1/users/${username}/stats?is_including_today=true`, 111 | ) 112 | .reply(200, wakaTimeData); 113 | 114 | const repo = await fetchWakatimeStats({ username }); 115 | expect(repo).toStrictEqual(wakaTimeData.data); 116 | }); 117 | 118 | it("should throw error if username param missing", async () => { 119 | mock.onGet(/\/https:\/\/wakatime\.com\/api/).reply(404, wakaTimeData); 120 | 121 | await expect(fetchWakatimeStats("noone")).rejects.toThrow( 122 | 'Missing params "username" make sure you pass the parameters in URL', 123 | ); 124 | }); 125 | 126 | it("should throw error if username is not found", async () => { 127 | mock.onGet(/\/https:\/\/wakatime\.com\/api/).reply(404, wakaTimeData); 128 | 129 | await expect(fetchWakatimeStats({ username: "noone" })).rejects.toThrow( 130 | "Could not resolve to a User with the login of 'noone'", 131 | ); 132 | }); 133 | }); 134 | 135 | export { wakaTimeData }; 136 | -------------------------------------------------------------------------------- /tests/fmt.test.js: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from "@jest/globals"; 2 | import { 3 | formatBytes, 4 | kFormatter, 5 | wrapTextMultiline, 6 | } from "../src/common/fmt.js"; 7 | 8 | describe("Test fmt.js", () => { 9 | it("kFormatter: should format numbers correctly by default", () => { 10 | expect(kFormatter(1)).toBe(1); 11 | expect(kFormatter(-1)).toBe(-1); 12 | expect(kFormatter(500)).toBe(500); 13 | expect(kFormatter(1000)).toBe("1k"); 14 | expect(kFormatter(1200)).toBe("1.2k"); 15 | expect(kFormatter(10000)).toBe("10k"); 16 | expect(kFormatter(12345)).toBe("12.3k"); 17 | expect(kFormatter(99900)).toBe("99.9k"); 18 | expect(kFormatter(9900000)).toBe("9900k"); 19 | }); 20 | 21 | it("kFormatter: should format numbers correctly with 0 decimal precision", () => { 22 | expect(kFormatter(1, 0)).toBe("0k"); 23 | expect(kFormatter(-1, 0)).toBe("-0k"); 24 | expect(kFormatter(500, 0)).toBe("1k"); 25 | expect(kFormatter(1000, 0)).toBe("1k"); 26 | expect(kFormatter(1200, 0)).toBe("1k"); 27 | expect(kFormatter(10000, 0)).toBe("10k"); 28 | expect(kFormatter(12345, 0)).toBe("12k"); 29 | expect(kFormatter(99000, 0)).toBe("99k"); 30 | expect(kFormatter(99900, 0)).toBe("100k"); 31 | expect(kFormatter(9900000, 0)).toBe("9900k"); 32 | }); 33 | 34 | it("kFormatter: should format numbers correctly with 1 decimal precision", () => { 35 | expect(kFormatter(1, 1)).toBe("0.0k"); 36 | expect(kFormatter(-1, 1)).toBe("-0.0k"); 37 | expect(kFormatter(500, 1)).toBe("0.5k"); 38 | expect(kFormatter(1000, 1)).toBe("1.0k"); 39 | expect(kFormatter(1200, 1)).toBe("1.2k"); 40 | expect(kFormatter(10000, 1)).toBe("10.0k"); 41 | expect(kFormatter(12345, 1)).toBe("12.3k"); 42 | expect(kFormatter(99900, 1)).toBe("99.9k"); 43 | expect(kFormatter(9900000, 1)).toBe("9900.0k"); 44 | }); 45 | 46 | it("kFormatter: should format numbers correctly with 2 decimal precision", () => { 47 | expect(kFormatter(1, 2)).toBe("0.00k"); 48 | expect(kFormatter(-1, 2)).toBe("-0.00k"); 49 | expect(kFormatter(500, 2)).toBe("0.50k"); 50 | expect(kFormatter(1000, 2)).toBe("1.00k"); 51 | expect(kFormatter(1200, 2)).toBe("1.20k"); 52 | expect(kFormatter(10000, 2)).toBe("10.00k"); 53 | expect(kFormatter(12345, 2)).toBe("12.35k"); 54 | expect(kFormatter(99900, 2)).toBe("99.90k"); 55 | expect(kFormatter(9900000, 2)).toBe("9900.00k"); 56 | }); 57 | 58 | it("formatBytes: should return expected values", () => { 59 | expect(formatBytes(0)).toBe("0 B"); 60 | expect(formatBytes(100)).toBe("100.0 B"); 61 | expect(formatBytes(1024)).toBe("1.0 KB"); 62 | expect(formatBytes(1024 * 1024)).toBe("1.0 MB"); 63 | expect(formatBytes(1024 * 1024 * 1024)).toBe("1.0 GB"); 64 | expect(formatBytes(1024 * 1024 * 1024 * 1024)).toBe("1.0 TB"); 65 | expect(formatBytes(1024 * 1024 * 1024 * 1024 * 1024)).toBe("1.0 PB"); 66 | expect(formatBytes(1024 * 1024 * 1024 * 1024 * 1024 * 1024)).toBe("1.0 EB"); 67 | 68 | expect(formatBytes(1234 * 1024)).toBe("1.2 MB"); 69 | expect(formatBytes(123.4 * 1024)).toBe("123.4 KB"); 70 | }); 71 | 72 | it("wrapTextMultiline: should not wrap small texts", () => { 73 | { 74 | let multiLineText = wrapTextMultiline("Small text should not wrap"); 75 | expect(multiLineText).toEqual(["Small text should not wrap"]); 76 | } 77 | }); 78 | 79 | it("wrapTextMultiline: should wrap large texts", () => { 80 | let multiLineText = wrapTextMultiline( 81 | "Hello world long long long text", 82 | 20, 83 | 3, 84 | ); 85 | expect(multiLineText).toEqual(["Hello world long", "long long text"]); 86 | }); 87 | 88 | it("wrapTextMultiline: should wrap large texts and limit max lines", () => { 89 | let multiLineText = wrapTextMultiline( 90 | "Hello world long long long text", 91 | 10, 92 | 2, 93 | ); 94 | expect(multiLineText).toEqual(["Hello", "world long..."]); 95 | }); 96 | 97 | it("wrapTextMultiline: should wrap chinese by punctuation", () => { 98 | let multiLineText = wrapTextMultiline( 99 | "专门为刚开始刷题的同学准备的算法基地,没有最细只有更细,立志用动画将晦涩难懂的算法说的通俗易懂!", 100 | ); 101 | expect(multiLineText.length).toEqual(3); 102 | expect(multiLineText[0].length).toEqual(18 * 8); // &#xxxxx; x 8 103 | }); 104 | }); 105 | -------------------------------------------------------------------------------- /src/common/cache.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | import { clampValue } from "./ops.js"; 4 | 5 | const MIN = 60; 6 | const HOUR = 60 * MIN; 7 | const DAY = 24 * HOUR; 8 | 9 | /** 10 | * Common durations in seconds. 11 | */ 12 | const DURATIONS = { 13 | ONE_MINUTE: MIN, 14 | FIVE_MINUTES: 5 * MIN, 15 | TEN_MINUTES: 10 * MIN, 16 | FIFTEEN_MINUTES: 15 * MIN, 17 | THIRTY_MINUTES: 30 * MIN, 18 | 19 | TWO_HOURS: 2 * HOUR, 20 | FOUR_HOURS: 4 * HOUR, 21 | SIX_HOURS: 6 * HOUR, 22 | EIGHT_HOURS: 8 * HOUR, 23 | TWELVE_HOURS: 12 * HOUR, 24 | 25 | ONE_DAY: DAY, 26 | TWO_DAY: 2 * DAY, 27 | SIX_DAY: 6 * DAY, 28 | TEN_DAY: 10 * DAY, 29 | }; 30 | 31 | /** 32 | * Common cache TTL values in seconds. 33 | */ 34 | const CACHE_TTL = { 35 | STATS_CARD: { 36 | DEFAULT: DURATIONS.ONE_DAY, 37 | MIN: DURATIONS.TWELVE_HOURS, 38 | MAX: DURATIONS.TWO_DAY, 39 | }, 40 | TOP_LANGS_CARD: { 41 | DEFAULT: DURATIONS.SIX_DAY, 42 | MIN: DURATIONS.TWO_DAY, 43 | MAX: DURATIONS.TEN_DAY, 44 | }, 45 | PIN_CARD: { 46 | DEFAULT: DURATIONS.TEN_DAY, 47 | MIN: DURATIONS.ONE_DAY, 48 | MAX: DURATIONS.TEN_DAY, 49 | }, 50 | GIST_CARD: { 51 | DEFAULT: DURATIONS.TWO_DAY, 52 | MIN: DURATIONS.ONE_DAY, 53 | MAX: DURATIONS.TEN_DAY, 54 | }, 55 | WAKATIME_CARD: { 56 | DEFAULT: DURATIONS.ONE_DAY, 57 | MIN: DURATIONS.TWELVE_HOURS, 58 | MAX: DURATIONS.TWO_DAY, 59 | }, 60 | ERROR: DURATIONS.TEN_MINUTES, 61 | }; 62 | 63 | /** 64 | * Resolves the cache seconds based on the requested, default, min, and max values. 65 | * 66 | * @param {Object} args The parameters object. 67 | * @param {number} args.requested The requested cache seconds. 68 | * @param {number} args.def The default cache seconds. 69 | * @param {number} args.min The minimum cache seconds. 70 | * @param {number} args.max The maximum cache seconds. 71 | * @returns {number} The resolved cache seconds. 72 | */ 73 | const resolveCacheSeconds = ({ requested, def, min, max }) => { 74 | let cacheSeconds = clampValue(isNaN(requested) ? def : requested, min, max); 75 | 76 | if (process.env.CACHE_SECONDS) { 77 | const envCacheSeconds = parseInt(process.env.CACHE_SECONDS, 10); 78 | if (!isNaN(envCacheSeconds)) { 79 | cacheSeconds = envCacheSeconds; 80 | } 81 | } 82 | 83 | return cacheSeconds; 84 | }; 85 | 86 | /** 87 | * Disables caching by setting appropriate headers on the response object. 88 | * 89 | * @param {any} res The response object. 90 | */ 91 | const disableCaching = (res) => { 92 | // Disable caching for browsers, shared caches/CDNs, and GitHub Camo. 93 | res.setHeader( 94 | "Cache-Control", 95 | "no-cache, no-store, must-revalidate, max-age=0, s-maxage=0", 96 | ); 97 | res.setHeader("Pragma", "no-cache"); 98 | res.setHeader("Expires", "0"); 99 | }; 100 | 101 | /** 102 | * Sets the Cache-Control headers on the response object. 103 | * 104 | * @param {any} res The response object. 105 | * @param {number} cacheSeconds The cache seconds to set in the headers. 106 | */ 107 | const setCacheHeaders = (res, cacheSeconds) => { 108 | if (cacheSeconds < 1 || process.env.NODE_ENV === "development") { 109 | disableCaching(res); 110 | return; 111 | } 112 | 113 | res.setHeader( 114 | "Cache-Control", 115 | `max-age=${cacheSeconds}, ` + 116 | `s-maxage=${cacheSeconds}, ` + 117 | `stale-while-revalidate=${DURATIONS.ONE_DAY}`, 118 | ); 119 | }; 120 | 121 | /** 122 | * Sets the Cache-Control headers for error responses on the response object. 123 | * 124 | * @param {any} res The response object. 125 | */ 126 | const setErrorCacheHeaders = (res) => { 127 | const envCacheSeconds = process.env.CACHE_SECONDS 128 | ? parseInt(process.env.CACHE_SECONDS, 10) 129 | : NaN; 130 | if ( 131 | (!isNaN(envCacheSeconds) && envCacheSeconds < 1) || 132 | process.env.NODE_ENV === "development" 133 | ) { 134 | disableCaching(res); 135 | return; 136 | } 137 | 138 | // Use lower cache period for errors. 139 | res.setHeader( 140 | "Cache-Control", 141 | `max-age=${CACHE_TTL.ERROR}, ` + 142 | `s-maxage=${CACHE_TTL.ERROR}, ` + 143 | `stale-while-revalidate=${DURATIONS.ONE_DAY}`, 144 | ); 145 | }; 146 | 147 | export { 148 | resolveCacheSeconds, 149 | setCacheHeaders, 150 | setErrorCacheHeaders, 151 | DURATIONS, 152 | CACHE_TTL, 153 | }; 154 | -------------------------------------------------------------------------------- /tests/wakatime.test.js: -------------------------------------------------------------------------------- 1 | import { afterEach, describe, expect, it, jest } from "@jest/globals"; 2 | import "@testing-library/jest-dom"; 3 | import axios from "axios"; 4 | import MockAdapter from "axios-mock-adapter"; 5 | import wakatime from "../api/wakatime.js"; 6 | import { renderWakatimeCard } from "../src/cards/wakatime.js"; 7 | import { CACHE_TTL, DURATIONS } from "../src/common/cache.js"; 8 | 9 | const wakaTimeData = { 10 | data: { 11 | categories: [ 12 | { 13 | digital: "22:40", 14 | hours: 22, 15 | minutes: 40, 16 | name: "Coding", 17 | percent: 100, 18 | text: "22 hrs 40 mins", 19 | total_seconds: 81643.570077, 20 | }, 21 | ], 22 | daily_average: 16095, 23 | daily_average_including_other_language: 16329, 24 | days_including_holidays: 7, 25 | days_minus_holidays: 5, 26 | editors: [ 27 | { 28 | digital: "22:40", 29 | hours: 22, 30 | minutes: 40, 31 | name: "VS Code", 32 | percent: 100, 33 | text: "22 hrs 40 mins", 34 | total_seconds: 81643.570077, 35 | }, 36 | ], 37 | holidays: 2, 38 | human_readable_daily_average: "4 hrs 28 mins", 39 | human_readable_daily_average_including_other_language: "4 hrs 32 mins", 40 | human_readable_total: "22 hrs 21 mins", 41 | human_readable_total_including_other_language: "22 hrs 40 mins", 42 | id: "random hash", 43 | is_already_updating: false, 44 | is_coding_activity_visible: true, 45 | is_including_today: false, 46 | is_other_usage_visible: true, 47 | is_stuck: false, 48 | is_up_to_date: true, 49 | languages: [ 50 | { 51 | digital: "0:19", 52 | hours: 0, 53 | minutes: 19, 54 | name: "Other", 55 | percent: 1.43, 56 | text: "19 mins", 57 | total_seconds: 1170.434361, 58 | }, 59 | { 60 | digital: "0:01", 61 | hours: 0, 62 | minutes: 1, 63 | name: "TypeScript", 64 | percent: 0.1, 65 | text: "1 min", 66 | total_seconds: 83.293809, 67 | }, 68 | { 69 | digital: "0:00", 70 | hours: 0, 71 | minutes: 0, 72 | name: "YAML", 73 | percent: 0.07, 74 | text: "0 secs", 75 | total_seconds: 54.975151, 76 | }, 77 | ], 78 | operating_systems: [ 79 | { 80 | digital: "22:40", 81 | hours: 22, 82 | minutes: 40, 83 | name: "Mac", 84 | percent: 100, 85 | text: "22 hrs 40 mins", 86 | total_seconds: 81643.570077, 87 | }, 88 | ], 89 | percent_calculated: 100, 90 | range: "last_7_days", 91 | status: "ok", 92 | timeout: 15, 93 | total_seconds: 80473.135716, 94 | total_seconds_including_other_language: 81643.570077, 95 | user_id: "random hash", 96 | username: "anuraghazra", 97 | writes_only: false, 98 | }, 99 | }; 100 | 101 | const mock = new MockAdapter(axios); 102 | 103 | afterEach(() => { 104 | mock.reset(); 105 | }); 106 | 107 | describe("Test /api/wakatime", () => { 108 | it("should test the request", async () => { 109 | const username = "anuraghazra"; 110 | const req = { query: { username } }; 111 | const res = { setHeader: jest.fn(), send: jest.fn() }; 112 | mock 113 | .onGet( 114 | `https://wakatime.com/api/v1/users/${username}/stats?is_including_today=true`, 115 | ) 116 | .reply(200, wakaTimeData); 117 | 118 | await wakatime(req, res); 119 | 120 | expect(res.setHeader).toHaveBeenCalledWith("Content-Type", "image/svg+xml"); 121 | expect(res.send).toHaveBeenCalledWith( 122 | renderWakatimeCard(wakaTimeData.data, {}), 123 | ); 124 | }); 125 | 126 | it("should have proper cache", async () => { 127 | const username = "anuraghazra"; 128 | const req = { query: { username } }; 129 | const res = { setHeader: jest.fn(), send: jest.fn() }; 130 | mock 131 | .onGet( 132 | `https://wakatime.com/api/v1/users/${username}/stats?is_including_today=true`, 133 | ) 134 | .reply(200, wakaTimeData); 135 | 136 | await wakatime(req, res); 137 | 138 | expect(res.setHeader).toHaveBeenCalledWith("Content-Type", "image/svg+xml"); 139 | expect(res.setHeader).toHaveBeenCalledWith( 140 | "Cache-Control", 141 | `max-age=${CACHE_TTL.WAKATIME_CARD.DEFAULT}, ` + 142 | `s-maxage=${CACHE_TTL.WAKATIME_CARD.DEFAULT}, ` + 143 | `stale-while-revalidate=${DURATIONS.ONE_DAY}`, 144 | ); 145 | }); 146 | }); 147 | -------------------------------------------------------------------------------- /src/common/color.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | import { themes } from "../../themes/index.js"; 4 | 5 | /** 6 | * Checks if a string is a valid hex color. 7 | * 8 | * @param {string} hexColor String to check. 9 | * @returns {boolean} True if the given string is a valid hex color. 10 | */ 11 | const isValidHexColor = (hexColor) => { 12 | return new RegExp( 13 | /^([A-Fa-f0-9]{8}|[A-Fa-f0-9]{6}|[A-Fa-f0-9]{3}|[A-Fa-f0-9]{4})$/, 14 | ).test(hexColor); 15 | }; 16 | 17 | /** 18 | * Check if the given string is a valid gradient. 19 | * 20 | * @param {string[]} colors Array of colors. 21 | * @returns {boolean} True if the given string is a valid gradient. 22 | */ 23 | const isValidGradient = (colors) => { 24 | return ( 25 | colors.length > 2 && 26 | colors.slice(1).every((color) => isValidHexColor(color)) 27 | ); 28 | }; 29 | 30 | /** 31 | * Retrieves a gradient if color has more than one valid hex codes else a single color. 32 | * 33 | * @param {string} color The color to parse. 34 | * @param {string | string[]} fallbackColor The fallback color. 35 | * @returns {string | string[]} The gradient or color. 36 | */ 37 | const fallbackColor = (color, fallbackColor) => { 38 | let gradient = null; 39 | 40 | let colors = color ? color.split(",") : []; 41 | if (colors.length > 1 && isValidGradient(colors)) { 42 | gradient = colors; 43 | } 44 | 45 | return ( 46 | (gradient ? gradient : isValidHexColor(color) && `#${color}`) || 47 | fallbackColor 48 | ); 49 | }; 50 | 51 | /** 52 | * Object containing card colors. 53 | * @typedef {{ 54 | * titleColor: string; 55 | * iconColor: string; 56 | * textColor: string; 57 | * bgColor: string | string[]; 58 | * borderColor: string; 59 | * ringColor: string; 60 | * }} CardColors 61 | */ 62 | 63 | /** 64 | * Returns theme based colors with proper overrides and defaults. 65 | * 66 | * @param {Object} args Function arguments. 67 | * @param {string=} args.title_color Card title color. 68 | * @param {string=} args.text_color Card text color. 69 | * @param {string=} args.icon_color Card icon color. 70 | * @param {string=} args.bg_color Card background color. 71 | * @param {string=} args.border_color Card border color. 72 | * @param {string=} args.ring_color Card ring color. 73 | * @param {string=} args.theme Card theme. 74 | * @returns {CardColors} Card colors. 75 | */ 76 | const getCardColors = ({ 77 | title_color, 78 | text_color, 79 | icon_color, 80 | bg_color, 81 | border_color, 82 | ring_color, 83 | theme, 84 | }) => { 85 | const defaultTheme = themes["default"]; 86 | const isThemeProvided = theme !== null && theme !== undefined; 87 | 88 | // @ts-ignore 89 | const selectedTheme = isThemeProvided ? themes[theme] : defaultTheme; 90 | 91 | const defaultBorderColor = 92 | "border_color" in selectedTheme 93 | ? selectedTheme.border_color 94 | : // @ts-ignore 95 | defaultTheme.border_color; 96 | 97 | // get the color provided by the user else the theme color 98 | // finally if both colors are invalid fallback to default theme 99 | const titleColor = fallbackColor( 100 | title_color || selectedTheme.title_color, 101 | "#" + defaultTheme.title_color, 102 | ); 103 | 104 | // get the color provided by the user else the theme color 105 | // finally if both colors are invalid we use the titleColor 106 | const ringColor = fallbackColor( 107 | // @ts-ignore 108 | ring_color || selectedTheme.ring_color, 109 | titleColor, 110 | ); 111 | const iconColor = fallbackColor( 112 | icon_color || selectedTheme.icon_color, 113 | "#" + defaultTheme.icon_color, 114 | ); 115 | const textColor = fallbackColor( 116 | text_color || selectedTheme.text_color, 117 | "#" + defaultTheme.text_color, 118 | ); 119 | const bgColor = fallbackColor( 120 | bg_color || selectedTheme.bg_color, 121 | "#" + defaultTheme.bg_color, 122 | ); 123 | 124 | const borderColor = fallbackColor( 125 | border_color || defaultBorderColor, 126 | "#" + defaultBorderColor, 127 | ); 128 | 129 | if ( 130 | typeof titleColor !== "string" || 131 | typeof textColor !== "string" || 132 | typeof ringColor !== "string" || 133 | typeof iconColor !== "string" || 134 | typeof borderColor !== "string" 135 | ) { 136 | throw new Error( 137 | "Unexpected behavior, all colors except background should be string.", 138 | ); 139 | } 140 | 141 | return { titleColor, iconColor, textColor, bgColor, borderColor, ringColor }; 142 | }; 143 | 144 | export { isValidHexColor, isValidGradient, getCardColors }; 145 | -------------------------------------------------------------------------------- /api/index.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | import { renderStatsCard } from "../src/cards/stats.js"; 4 | import { guardAccess } from "../src/common/access.js"; 5 | import { 6 | CACHE_TTL, 7 | resolveCacheSeconds, 8 | setCacheHeaders, 9 | setErrorCacheHeaders, 10 | } from "../src/common/cache.js"; 11 | import { 12 | MissingParamError, 13 | retrieveSecondaryMessage, 14 | } from "../src/common/error.js"; 15 | import { parseArray, parseBoolean } from "../src/common/ops.js"; 16 | import { renderError } from "../src/common/render.js"; 17 | import { fetchStats } from "../src/fetchers/stats.js"; 18 | import { isLocaleAvailable } from "../src/translations.js"; 19 | 20 | // @ts-ignore 21 | export default async (req, res) => { 22 | const { 23 | username, 24 | hide, 25 | hide_title, 26 | hide_border, 27 | card_width, 28 | hide_rank, 29 | show_icons, 30 | include_all_commits, 31 | commits_year, 32 | line_height, 33 | title_color, 34 | ring_color, 35 | icon_color, 36 | text_color, 37 | text_bold, 38 | bg_color, 39 | theme, 40 | cache_seconds, 41 | exclude_repo, 42 | custom_title, 43 | locale, 44 | disable_animations, 45 | border_radius, 46 | number_format, 47 | number_precision, 48 | border_color, 49 | rank_icon, 50 | show, 51 | } = req.query; 52 | res.setHeader("Content-Type", "image/svg+xml"); 53 | 54 | const access = guardAccess({ 55 | res, 56 | id: username, 57 | type: "username", 58 | colors: { 59 | title_color, 60 | text_color, 61 | bg_color, 62 | border_color, 63 | theme, 64 | }, 65 | }); 66 | if (!access.isPassed) { 67 | return access.result; 68 | } 69 | 70 | if (locale && !isLocaleAvailable(locale)) { 71 | return res.send( 72 | renderError({ 73 | message: "Something went wrong", 74 | secondaryMessage: "Language not found", 75 | renderOptions: { 76 | title_color, 77 | text_color, 78 | bg_color, 79 | border_color, 80 | theme, 81 | }, 82 | }), 83 | ); 84 | } 85 | 86 | try { 87 | const showStats = parseArray(show); 88 | const stats = await fetchStats( 89 | username, 90 | parseBoolean(include_all_commits), 91 | parseArray(exclude_repo), 92 | showStats.includes("prs_merged") || 93 | showStats.includes("prs_merged_percentage"), 94 | showStats.includes("discussions_started"), 95 | showStats.includes("discussions_answered"), 96 | parseInt(commits_year, 10), 97 | ); 98 | const cacheSeconds = resolveCacheSeconds({ 99 | requested: parseInt(cache_seconds, 10), 100 | def: CACHE_TTL.STATS_CARD.DEFAULT, 101 | min: CACHE_TTL.STATS_CARD.MIN, 102 | max: CACHE_TTL.STATS_CARD.MAX, 103 | }); 104 | 105 | setCacheHeaders(res, cacheSeconds); 106 | 107 | return res.send( 108 | renderStatsCard(stats, { 109 | hide: parseArray(hide), 110 | show_icons: parseBoolean(show_icons), 111 | hide_title: parseBoolean(hide_title), 112 | hide_border: parseBoolean(hide_border), 113 | card_width: parseInt(card_width, 10), 114 | hide_rank: parseBoolean(hide_rank), 115 | include_all_commits: parseBoolean(include_all_commits), 116 | commits_year: parseInt(commits_year, 10), 117 | line_height, 118 | title_color, 119 | ring_color, 120 | icon_color, 121 | text_color, 122 | text_bold: parseBoolean(text_bold), 123 | bg_color, 124 | theme, 125 | custom_title, 126 | border_radius, 127 | border_color, 128 | number_format, 129 | number_precision: parseInt(number_precision, 10), 130 | locale: locale ? locale.toLowerCase() : null, 131 | disable_animations: parseBoolean(disable_animations), 132 | rank_icon, 133 | show: showStats, 134 | }), 135 | ); 136 | } catch (err) { 137 | setErrorCacheHeaders(res); 138 | if (err instanceof Error) { 139 | return res.send( 140 | renderError({ 141 | message: err.message, 142 | secondaryMessage: retrieveSecondaryMessage(err), 143 | renderOptions: { 144 | title_color, 145 | text_color, 146 | bg_color, 147 | border_color, 148 | theme, 149 | show_repo_link: !(err instanceof MissingParamError), 150 | }, 151 | }), 152 | ); 153 | } 154 | return res.send( 155 | renderError({ 156 | message: "An unknown error occurred", 157 | renderOptions: { 158 | title_color, 159 | text_color, 160 | bg_color, 161 | border_color, 162 | theme, 163 | }, 164 | }), 165 | ); 166 | } 167 | }; 168 | -------------------------------------------------------------------------------- /src/cards/gist.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | import { 4 | measureText, 5 | flexLayout, 6 | iconWithLabel, 7 | createLanguageNode, 8 | } from "../common/render.js"; 9 | import Card from "../common/Card.js"; 10 | import { getCardColors } from "../common/color.js"; 11 | import { kFormatter, wrapTextMultiline } from "../common/fmt.js"; 12 | import { encodeHTML } from "../common/html.js"; 13 | import { icons } from "../common/icons.js"; 14 | import { parseEmojis } from "../common/ops.js"; 15 | 16 | /** Import language colors. 17 | * 18 | * @description Here we use the workaround found in 19 | * https://stackoverflow.com/questions/66726365/how-should-i-import-json-in-node 20 | * since vercel is using v16.14.0 which does not yet support json imports without the 21 | * --experimental-json-modules flag. 22 | */ 23 | import { createRequire } from "module"; 24 | const require = createRequire(import.meta.url); 25 | const languageColors = require("../common/languageColors.json"); // now works 26 | 27 | const ICON_SIZE = 16; 28 | const CARD_DEFAULT_WIDTH = 400; 29 | const HEADER_MAX_LENGTH = 35; 30 | 31 | /** 32 | * @typedef {import('./types').GistCardOptions} GistCardOptions Gist card options. 33 | * @typedef {import('../fetchers/types').GistData} GistData Gist data. 34 | */ 35 | 36 | /** 37 | * Render gist card. 38 | * 39 | * @param {GistData} gistData Gist data. 40 | * @param {Partial} options Gist card options. 41 | * @returns {string} Gist card. 42 | */ 43 | const renderGistCard = (gistData, options = {}) => { 44 | const { name, nameWithOwner, description, language, starsCount, forksCount } = 45 | gistData; 46 | const { 47 | title_color, 48 | icon_color, 49 | text_color, 50 | bg_color, 51 | theme, 52 | border_radius, 53 | border_color, 54 | show_owner = false, 55 | hide_border = false, 56 | } = options; 57 | 58 | // returns theme based colors with proper overrides and defaults 59 | const { titleColor, textColor, iconColor, bgColor, borderColor } = 60 | getCardColors({ 61 | title_color, 62 | icon_color, 63 | text_color, 64 | bg_color, 65 | border_color, 66 | theme, 67 | }); 68 | 69 | const lineWidth = 59; 70 | const linesLimit = 10; 71 | const desc = parseEmojis(description || "No description provided"); 72 | const multiLineDescription = wrapTextMultiline(desc, lineWidth, linesLimit); 73 | const descriptionLines = multiLineDescription.length; 74 | const descriptionSvg = multiLineDescription 75 | .map((line) => `${encodeHTML(line)}`) 76 | .join(""); 77 | 78 | const lineHeight = descriptionLines > 3 ? 12 : 10; 79 | const height = 80 | (descriptionLines > 1 ? 120 : 110) + descriptionLines * lineHeight; 81 | 82 | const totalStars = kFormatter(starsCount); 83 | const totalForks = kFormatter(forksCount); 84 | const svgStars = iconWithLabel( 85 | icons.star, 86 | totalStars, 87 | "starsCount", 88 | ICON_SIZE, 89 | ); 90 | const svgForks = iconWithLabel( 91 | icons.fork, 92 | totalForks, 93 | "forksCount", 94 | ICON_SIZE, 95 | ); 96 | 97 | const languageName = language || "Unspecified"; 98 | // @ts-ignore 99 | const languageColor = languageColors[languageName] || "#858585"; 100 | 101 | const svgLanguage = createLanguageNode(languageName, languageColor); 102 | 103 | const starAndForkCount = flexLayout({ 104 | items: [svgLanguage, svgStars, svgForks], 105 | sizes: [ 106 | measureText(languageName, 12), 107 | ICON_SIZE + measureText(`${totalStars}`, 12), 108 | ICON_SIZE + measureText(`${totalForks}`, 12), 109 | ], 110 | gap: 25, 111 | }).join(""); 112 | 113 | const header = show_owner ? nameWithOwner : name; 114 | 115 | const card = new Card({ 116 | defaultTitle: 117 | header.length > HEADER_MAX_LENGTH 118 | ? `${header.slice(0, HEADER_MAX_LENGTH)}...` 119 | : header, 120 | titlePrefixIcon: icons.gist, 121 | width: CARD_DEFAULT_WIDTH, 122 | height, 123 | border_radius, 124 | colors: { 125 | titleColor, 126 | textColor, 127 | iconColor, 128 | bgColor, 129 | borderColor, 130 | }, 131 | }); 132 | 133 | card.setCSS(` 134 | .description { font: 400 13px 'Segoe UI', Ubuntu, Sans-Serif; fill: ${textColor} } 135 | .gray { font: 400 12px 'Segoe UI', Ubuntu, Sans-Serif; fill: ${textColor} } 136 | .icon { fill: ${iconColor} } 137 | `); 138 | card.setHideBorder(hide_border); 139 | 140 | return card.render(` 141 | 142 | ${descriptionSvg} 143 | 144 | 145 | 146 | ${starAndForkCount} 147 | 148 | `); 149 | }; 150 | 151 | export { renderGistCard, HEADER_MAX_LENGTH }; 152 | export default renderGistCard; 153 | -------------------------------------------------------------------------------- /powered-by-vercel.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /api/top-langs.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | import { renderTopLanguages } from "../src/cards/top-languages.js"; 4 | import { guardAccess } from "../src/common/access.js"; 5 | import { 6 | CACHE_TTL, 7 | resolveCacheSeconds, 8 | setCacheHeaders, 9 | setErrorCacheHeaders, 10 | } from "../src/common/cache.js"; 11 | import { 12 | MissingParamError, 13 | retrieveSecondaryMessage, 14 | } from "../src/common/error.js"; 15 | import { parseArray, parseBoolean } from "../src/common/ops.js"; 16 | import { renderError } from "../src/common/render.js"; 17 | import { fetchTopLanguages } from "../src/fetchers/top-languages.js"; 18 | import { isLocaleAvailable } from "../src/translations.js"; 19 | 20 | // @ts-ignore 21 | export default async (req, res) => { 22 | const { 23 | username, 24 | hide, 25 | hide_title, 26 | hide_border, 27 | card_width, 28 | title_color, 29 | text_color, 30 | bg_color, 31 | theme, 32 | cache_seconds, 33 | layout, 34 | langs_count, 35 | exclude_repo, 36 | size_weight, 37 | count_weight, 38 | custom_title, 39 | locale, 40 | border_radius, 41 | border_color, 42 | disable_animations, 43 | hide_progress, 44 | stats_format, 45 | } = req.query; 46 | res.setHeader("Content-Type", "image/svg+xml"); 47 | 48 | const access = guardAccess({ 49 | res, 50 | id: username, 51 | type: "username", 52 | colors: { 53 | title_color, 54 | text_color, 55 | bg_color, 56 | border_color, 57 | theme, 58 | }, 59 | }); 60 | if (!access.isPassed) { 61 | return access.result; 62 | } 63 | 64 | if (locale && !isLocaleAvailable(locale)) { 65 | return res.send( 66 | renderError({ 67 | message: "Something went wrong", 68 | secondaryMessage: "Locale not found", 69 | renderOptions: { 70 | title_color, 71 | text_color, 72 | bg_color, 73 | border_color, 74 | theme, 75 | }, 76 | }), 77 | ); 78 | } 79 | 80 | if ( 81 | layout !== undefined && 82 | (typeof layout !== "string" || 83 | !["compact", "normal", "donut", "donut-vertical", "pie"].includes(layout)) 84 | ) { 85 | return res.send( 86 | renderError({ 87 | message: "Something went wrong", 88 | secondaryMessage: "Incorrect layout input", 89 | renderOptions: { 90 | title_color, 91 | text_color, 92 | bg_color, 93 | border_color, 94 | theme, 95 | }, 96 | }), 97 | ); 98 | } 99 | 100 | if ( 101 | stats_format !== undefined && 102 | (typeof stats_format !== "string" || 103 | !["bytes", "percentages"].includes(stats_format)) 104 | ) { 105 | return res.send( 106 | renderError({ 107 | message: "Something went wrong", 108 | secondaryMessage: "Incorrect stats_format input", 109 | renderOptions: { 110 | title_color, 111 | text_color, 112 | bg_color, 113 | border_color, 114 | theme, 115 | }, 116 | }), 117 | ); 118 | } 119 | 120 | try { 121 | const topLangs = await fetchTopLanguages( 122 | username, 123 | parseArray(exclude_repo), 124 | size_weight, 125 | count_weight, 126 | ); 127 | const cacheSeconds = resolveCacheSeconds({ 128 | requested: parseInt(cache_seconds, 10), 129 | def: CACHE_TTL.TOP_LANGS_CARD.DEFAULT, 130 | min: CACHE_TTL.TOP_LANGS_CARD.MIN, 131 | max: CACHE_TTL.TOP_LANGS_CARD.MAX, 132 | }); 133 | 134 | setCacheHeaders(res, cacheSeconds); 135 | 136 | return res.send( 137 | renderTopLanguages(topLangs, { 138 | custom_title, 139 | hide_title: parseBoolean(hide_title), 140 | hide_border: parseBoolean(hide_border), 141 | card_width: parseInt(card_width, 10), 142 | hide: parseArray(hide), 143 | title_color, 144 | text_color, 145 | bg_color, 146 | theme, 147 | layout, 148 | langs_count, 149 | border_radius, 150 | border_color, 151 | locale: locale ? locale.toLowerCase() : null, 152 | disable_animations: parseBoolean(disable_animations), 153 | hide_progress: parseBoolean(hide_progress), 154 | stats_format, 155 | }), 156 | ); 157 | } catch (err) { 158 | setErrorCacheHeaders(res); 159 | if (err instanceof Error) { 160 | return res.send( 161 | renderError({ 162 | message: err.message, 163 | secondaryMessage: retrieveSecondaryMessage(err), 164 | renderOptions: { 165 | title_color, 166 | text_color, 167 | bg_color, 168 | border_color, 169 | theme, 170 | show_repo_link: !(err instanceof MissingParamError), 171 | }, 172 | }), 173 | ); 174 | } 175 | return res.send( 176 | renderError({ 177 | message: "An unknown error occurred", 178 | renderOptions: { 179 | title_color, 180 | text_color, 181 | bg_color, 182 | border_color, 183 | theme, 184 | }, 185 | }), 186 | ); 187 | } 188 | }; 189 | -------------------------------------------------------------------------------- /api/status/pat-info.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | /** 4 | * @file Contains a simple cloud function that can be used to check which PATs are no 5 | * longer working. It returns a list of valid PATs, expired PATs and PATs with errors. 6 | * 7 | * @description This function is currently rate limited to 1 request per 5 minutes. 8 | */ 9 | 10 | import { request } from "../../src/common/http.js"; 11 | import { logger } from "../../src/common/log.js"; 12 | import { dateDiff } from "../../src/common/ops.js"; 13 | 14 | export const RATE_LIMIT_SECONDS = 60 * 5; // 1 request per 5 minutes 15 | 16 | /** 17 | * Simple uptime check fetcher for the PATs. 18 | * 19 | * @param {any} variables Fetcher variables. 20 | * @param {string} token GitHub token. 21 | * @returns {Promise} The response. 22 | */ 23 | const uptimeFetcher = (variables, token) => { 24 | return request( 25 | { 26 | query: ` 27 | query { 28 | rateLimit { 29 | remaining 30 | resetAt 31 | }, 32 | }`, 33 | variables, 34 | }, 35 | { 36 | Authorization: `bearer ${token}`, 37 | }, 38 | ); 39 | }; 40 | 41 | const getAllPATs = () => { 42 | return Object.keys(process.env).filter((key) => /PAT_\d*$/.exec(key)); 43 | }; 44 | 45 | /** 46 | * @typedef {(variables: any, token: string) => Promise} Fetcher The fetcher function. 47 | * @typedef {{validPATs: string[], expiredPATs: string[], exhaustedPATs: string[], suspendedPATs: string[], errorPATs: string[], details: any}} PATInfo The PAT info. 48 | */ 49 | 50 | /** 51 | * Check whether any of the PATs is expired. 52 | * 53 | * @param {Fetcher} fetcher The fetcher function. 54 | * @param {any} variables Fetcher variables. 55 | * @returns {Promise} The response. 56 | */ 57 | const getPATInfo = async (fetcher, variables) => { 58 | /** @type {Record} */ 59 | const details = {}; 60 | const PATs = getAllPATs(); 61 | 62 | for (const pat of PATs) { 63 | try { 64 | const response = await fetcher(variables, process.env[pat]); 65 | const errors = response.data.errors; 66 | const hasErrors = Boolean(errors); 67 | const errorType = errors?.[0]?.type; 68 | const isRateLimited = 69 | (hasErrors && errorType === "RATE_LIMITED") || 70 | response.data.data?.rateLimit?.remaining === 0; 71 | 72 | // Store PATs with errors. 73 | if (hasErrors && errorType !== "RATE_LIMITED") { 74 | details[pat] = { 75 | status: "error", 76 | error: { 77 | type: errors[0].type, 78 | message: errors[0].message, 79 | }, 80 | }; 81 | continue; 82 | } else if (isRateLimited) { 83 | const date1 = new Date(); 84 | const date2 = new Date(response.data?.data?.rateLimit?.resetAt); 85 | details[pat] = { 86 | status: "exhausted", 87 | remaining: 0, 88 | resetIn: dateDiff(date2, date1) + " minutes", 89 | }; 90 | } else { 91 | details[pat] = { 92 | status: "valid", 93 | remaining: response.data.data.rateLimit.remaining, 94 | }; 95 | } 96 | } catch (err) { 97 | // Store the PAT if it is expired. 98 | const errorMessage = err.response?.data?.message?.toLowerCase(); 99 | if (errorMessage === "bad credentials") { 100 | details[pat] = { 101 | status: "expired", 102 | }; 103 | } else if (errorMessage === "sorry. your account was suspended.") { 104 | details[pat] = { 105 | status: "suspended", 106 | }; 107 | } else { 108 | throw err; 109 | } 110 | } 111 | } 112 | 113 | const filterPATsByStatus = (status) => { 114 | return Object.keys(details).filter((pat) => details[pat].status === status); 115 | }; 116 | 117 | const sortedDetails = Object.keys(details) 118 | .sort() 119 | .reduce((obj, key) => { 120 | obj[key] = details[key]; 121 | return obj; 122 | }, {}); 123 | 124 | return { 125 | validPATs: filterPATsByStatus("valid"), 126 | expiredPATs: filterPATsByStatus("expired"), 127 | exhaustedPATs: filterPATsByStatus("exhausted"), 128 | suspendedPATs: filterPATsByStatus("suspended"), 129 | errorPATs: filterPATsByStatus("error"), 130 | details: sortedDetails, 131 | }; 132 | }; 133 | 134 | /** 135 | * Cloud function that returns information about the used PATs. 136 | * 137 | * @param {any} _ The request. 138 | * @param {any} res The response. 139 | * @returns {Promise} The response. 140 | */ 141 | export default async (_, res) => { 142 | res.setHeader("Content-Type", "application/json"); 143 | try { 144 | // Add header to prevent abuse. 145 | const PATsInfo = await getPATInfo(uptimeFetcher, {}); 146 | if (PATsInfo) { 147 | res.setHeader( 148 | "Cache-Control", 149 | `max-age=0, s-maxage=${RATE_LIMIT_SECONDS}`, 150 | ); 151 | } 152 | res.send(JSON.stringify(PATsInfo, null, 2)); 153 | } catch (err) { 154 | // Throw error if something went wrong. 155 | logger.error(err); 156 | res.setHeader("Cache-Control", "no-store"); 157 | res.send("Something went wrong: " + err.message); 158 | } 159 | }; 160 | -------------------------------------------------------------------------------- /tests/fetchTopLanguages.test.js: -------------------------------------------------------------------------------- 1 | import { afterEach, describe, expect, it } from "@jest/globals"; 2 | import "@testing-library/jest-dom"; 3 | import axios from "axios"; 4 | import MockAdapter from "axios-mock-adapter"; 5 | import { fetchTopLanguages } from "../src/fetchers/top-languages.js"; 6 | 7 | const mock = new MockAdapter(axios); 8 | 9 | afterEach(() => { 10 | mock.reset(); 11 | }); 12 | 13 | const data_langs = { 14 | data: { 15 | user: { 16 | repositories: { 17 | nodes: [ 18 | { 19 | name: "test-repo-1", 20 | languages: { 21 | edges: [{ size: 100, node: { color: "#0f0", name: "HTML" } }], 22 | }, 23 | }, 24 | { 25 | name: "test-repo-2", 26 | languages: { 27 | edges: [{ size: 100, node: { color: "#0f0", name: "HTML" } }], 28 | }, 29 | }, 30 | { 31 | name: "test-repo-3", 32 | languages: { 33 | edges: [ 34 | { size: 100, node: { color: "#0ff", name: "javascript" } }, 35 | ], 36 | }, 37 | }, 38 | { 39 | name: "test-repo-4", 40 | languages: { 41 | edges: [ 42 | { size: 100, node: { color: "#0ff", name: "javascript" } }, 43 | ], 44 | }, 45 | }, 46 | ], 47 | }, 48 | }, 49 | }, 50 | }; 51 | 52 | const error = { 53 | errors: [ 54 | { 55 | type: "NOT_FOUND", 56 | path: ["user"], 57 | locations: [], 58 | message: "Could not resolve to a User with the login of 'noname'.", 59 | }, 60 | ], 61 | }; 62 | 63 | describe("FetchTopLanguages", () => { 64 | it("should fetch correct language data while using the new calculation", async () => { 65 | mock.onPost("https://api.github.com/graphql").reply(200, data_langs); 66 | 67 | let repo = await fetchTopLanguages("anuraghazra", [], 0.5, 0.5); 68 | expect(repo).toStrictEqual({ 69 | HTML: { 70 | color: "#0f0", 71 | count: 2, 72 | name: "HTML", 73 | size: 20.000000000000004, 74 | }, 75 | javascript: { 76 | color: "#0ff", 77 | count: 2, 78 | name: "javascript", 79 | size: 20.000000000000004, 80 | }, 81 | }); 82 | }); 83 | 84 | it("should fetch correct language data while excluding the 'test-repo-1' repository", async () => { 85 | mock.onPost("https://api.github.com/graphql").reply(200, data_langs); 86 | 87 | let repo = await fetchTopLanguages("anuraghazra", ["test-repo-1"]); 88 | expect(repo).toStrictEqual({ 89 | HTML: { 90 | color: "#0f0", 91 | count: 1, 92 | name: "HTML", 93 | size: 100, 94 | }, 95 | javascript: { 96 | color: "#0ff", 97 | count: 2, 98 | name: "javascript", 99 | size: 200, 100 | }, 101 | }); 102 | }); 103 | 104 | it("should fetch correct language data while using the old calculation", async () => { 105 | mock.onPost("https://api.github.com/graphql").reply(200, data_langs); 106 | 107 | let repo = await fetchTopLanguages("anuraghazra", [], 1, 0); 108 | expect(repo).toStrictEqual({ 109 | HTML: { 110 | color: "#0f0", 111 | count: 2, 112 | name: "HTML", 113 | size: 200, 114 | }, 115 | javascript: { 116 | color: "#0ff", 117 | count: 2, 118 | name: "javascript", 119 | size: 200, 120 | }, 121 | }); 122 | }); 123 | 124 | it("should rank languages by the number of repositories they appear in", async () => { 125 | mock.onPost("https://api.github.com/graphql").reply(200, data_langs); 126 | 127 | let repo = await fetchTopLanguages("anuraghazra", [], 0, 1); 128 | expect(repo).toStrictEqual({ 129 | HTML: { 130 | color: "#0f0", 131 | count: 2, 132 | name: "HTML", 133 | size: 2, 134 | }, 135 | javascript: { 136 | color: "#0ff", 137 | count: 2, 138 | name: "javascript", 139 | size: 2, 140 | }, 141 | }); 142 | }); 143 | 144 | it("should throw specific error when user not found", async () => { 145 | mock.onPost("https://api.github.com/graphql").reply(200, error); 146 | 147 | await expect(fetchTopLanguages("anuraghazra")).rejects.toThrow( 148 | "Could not resolve to a User with the login of 'noname'.", 149 | ); 150 | }); 151 | 152 | it("should throw other errors with their message", async () => { 153 | mock.onPost("https://api.github.com/graphql").reply(200, { 154 | errors: [{ message: "Some test GraphQL error" }], 155 | }); 156 | 157 | await expect(fetchTopLanguages("anuraghazra")).rejects.toThrow( 158 | "Some test GraphQL error", 159 | ); 160 | }); 161 | 162 | it("should throw error with specific message when error does not contain message property", async () => { 163 | mock.onPost("https://api.github.com/graphql").reply(200, { 164 | errors: [{ type: "TEST" }], 165 | }); 166 | 167 | await expect(fetchTopLanguages("anuraghazra")).rejects.toThrow( 168 | "Something went wrong while trying to retrieve the language data using the GraphQL API.", 169 | ); 170 | }); 171 | }); 172 | -------------------------------------------------------------------------------- /src/fetchers/top-languages.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | import { retryer } from "../common/retryer.js"; 4 | import { logger } from "../common/log.js"; 5 | import { excludeRepositories } from "../common/envs.js"; 6 | import { CustomError, MissingParamError } from "../common/error.js"; 7 | import { wrapTextMultiline } from "../common/fmt.js"; 8 | import { request } from "../common/http.js"; 9 | 10 | /** 11 | * Top languages fetcher object. 12 | * 13 | * @param {any} variables Fetcher variables. 14 | * @param {string} token GitHub token. 15 | * @returns {Promise} Languages fetcher response. 16 | */ 17 | const fetcher = (variables, token) => { 18 | return request( 19 | { 20 | query: ` 21 | query userInfo($login: String!) { 22 | user(login: $login) { 23 | # fetch only owner repos & not forks 24 | repositories(ownerAffiliations: OWNER, isFork: false, first: 100) { 25 | nodes { 26 | name 27 | languages(first: 10, orderBy: {field: SIZE, direction: DESC}) { 28 | edges { 29 | size 30 | node { 31 | color 32 | name 33 | } 34 | } 35 | } 36 | } 37 | } 38 | } 39 | } 40 | `, 41 | variables, 42 | }, 43 | { 44 | Authorization: `token ${token}`, 45 | }, 46 | ); 47 | }; 48 | 49 | /** 50 | * @typedef {import("./types").TopLangData} TopLangData Top languages data. 51 | */ 52 | 53 | /** 54 | * Fetch top languages for a given username. 55 | * 56 | * @param {string} username GitHub username. 57 | * @param {string[]} exclude_repo List of repositories to exclude. 58 | * @param {number} size_weight Weightage to be given to size. 59 | * @param {number} count_weight Weightage to be given to count. 60 | * @returns {Promise} Top languages data. 61 | */ 62 | const fetchTopLanguages = async ( 63 | username, 64 | exclude_repo = [], 65 | size_weight = 1, 66 | count_weight = 0, 67 | ) => { 68 | if (!username) { 69 | throw new MissingParamError(["username"]); 70 | } 71 | 72 | const res = await retryer(fetcher, { login: username }); 73 | 74 | if (res.data.errors) { 75 | logger.error(res.data.errors); 76 | if (res.data.errors[0].type === "NOT_FOUND") { 77 | throw new CustomError( 78 | res.data.errors[0].message || "Could not fetch user.", 79 | CustomError.USER_NOT_FOUND, 80 | ); 81 | } 82 | if (res.data.errors[0].message) { 83 | throw new CustomError( 84 | wrapTextMultiline(res.data.errors[0].message, 90, 1)[0], 85 | res.statusText, 86 | ); 87 | } 88 | throw new CustomError( 89 | "Something went wrong while trying to retrieve the language data using the GraphQL API.", 90 | CustomError.GRAPHQL_ERROR, 91 | ); 92 | } 93 | 94 | let repoNodes = res.data.data.user.repositories.nodes; 95 | /** @type {Record} */ 96 | let repoToHide = {}; 97 | const allExcludedRepos = [...exclude_repo, ...excludeRepositories]; 98 | 99 | // populate repoToHide map for quick lookup 100 | // while filtering out 101 | if (allExcludedRepos) { 102 | allExcludedRepos.forEach((repoName) => { 103 | repoToHide[repoName] = true; 104 | }); 105 | } 106 | 107 | // filter out repositories to be hidden 108 | repoNodes = repoNodes 109 | .sort((a, b) => b.size - a.size) 110 | .filter((name) => !repoToHide[name.name]); 111 | 112 | let repoCount = 0; 113 | 114 | repoNodes = repoNodes 115 | .filter((node) => node.languages.edges.length > 0) 116 | // flatten the list of language nodes 117 | .reduce((acc, curr) => curr.languages.edges.concat(acc), []) 118 | .reduce((acc, prev) => { 119 | // get the size of the language (bytes) 120 | let langSize = prev.size; 121 | 122 | // if we already have the language in the accumulator 123 | // & the current language name is same as previous name 124 | // add the size to the language size and increase repoCount. 125 | if (acc[prev.node.name] && prev.node.name === acc[prev.node.name].name) { 126 | langSize = prev.size + acc[prev.node.name].size; 127 | repoCount += 1; 128 | } else { 129 | // reset repoCount to 1 130 | // language must exist in at least one repo to be detected 131 | repoCount = 1; 132 | } 133 | return { 134 | ...acc, 135 | [prev.node.name]: { 136 | name: prev.node.name, 137 | color: prev.node.color, 138 | size: langSize, 139 | count: repoCount, 140 | }, 141 | }; 142 | }, {}); 143 | 144 | Object.keys(repoNodes).forEach((name) => { 145 | // comparison index calculation 146 | repoNodes[name].size = 147 | Math.pow(repoNodes[name].size, size_weight) * 148 | Math.pow(repoNodes[name].count, count_weight); 149 | }); 150 | 151 | const topLangs = Object.keys(repoNodes) 152 | .sort((a, b) => repoNodes[b].size - repoNodes[a].size) 153 | .reduce((result, key) => { 154 | result[key] = repoNodes[key]; 155 | return result; 156 | }, {}); 157 | 158 | return topLangs; 159 | }; 160 | 161 | export { fetchTopLanguages }; 162 | export default fetchTopLanguages; 163 | -------------------------------------------------------------------------------- /src/cards/repo.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | import { Card } from "../common/Card.js"; 4 | import { getCardColors } from "../common/color.js"; 5 | import { kFormatter, wrapTextMultiline } from "../common/fmt.js"; 6 | import { encodeHTML } from "../common/html.js"; 7 | import { I18n } from "../common/I18n.js"; 8 | import { icons } from "../common/icons.js"; 9 | import { clampValue, parseEmojis } from "../common/ops.js"; 10 | import { 11 | flexLayout, 12 | measureText, 13 | iconWithLabel, 14 | createLanguageNode, 15 | } from "../common/render.js"; 16 | import { repoCardLocales } from "../translations.js"; 17 | 18 | const ICON_SIZE = 16; 19 | const DESCRIPTION_LINE_WIDTH = 59; 20 | const DESCRIPTION_MAX_LINES = 3; 21 | 22 | /** 23 | * Retrieves the repository description and wraps it to fit the card width. 24 | * 25 | * @param {string} label The repository description. 26 | * @param {string} textColor The color of the text. 27 | * @returns {string} Wrapped repo description SVG object. 28 | */ 29 | const getBadgeSVG = (label, textColor) => ` 30 | 31 | 32 | 39 | ${label} 40 | 41 | 42 | `; 43 | 44 | /** 45 | * @typedef {import("../fetchers/types").RepositoryData} RepositoryData Repository data. 46 | * @typedef {import("./types").RepoCardOptions} RepoCardOptions Repo card options. 47 | */ 48 | 49 | /** 50 | * Renders repository card details. 51 | * 52 | * @param {RepositoryData} repo Repository data. 53 | * @param {Partial} options Card options. 54 | * @returns {string} Repository card SVG object. 55 | */ 56 | const renderRepoCard = (repo, options = {}) => { 57 | const { 58 | name, 59 | nameWithOwner, 60 | description, 61 | primaryLanguage, 62 | isArchived, 63 | isTemplate, 64 | starCount, 65 | forkCount, 66 | } = repo; 67 | const { 68 | hide_border = false, 69 | title_color, 70 | icon_color, 71 | text_color, 72 | bg_color, 73 | show_owner = false, 74 | theme = "default_repocard", 75 | border_radius, 76 | border_color, 77 | locale, 78 | description_lines_count, 79 | } = options; 80 | 81 | const lineHeight = 10; 82 | const header = show_owner ? nameWithOwner : name; 83 | const langName = (primaryLanguage && primaryLanguage.name) || "Unspecified"; 84 | const langColor = (primaryLanguage && primaryLanguage.color) || "#333"; 85 | const descriptionMaxLines = description_lines_count 86 | ? clampValue(description_lines_count, 1, DESCRIPTION_MAX_LINES) 87 | : DESCRIPTION_MAX_LINES; 88 | 89 | const desc = parseEmojis(description || "No description provided"); 90 | const multiLineDescription = wrapTextMultiline( 91 | desc, 92 | DESCRIPTION_LINE_WIDTH, 93 | descriptionMaxLines, 94 | ); 95 | const descriptionLinesCount = description_lines_count 96 | ? clampValue(description_lines_count, 1, DESCRIPTION_MAX_LINES) 97 | : multiLineDescription.length; 98 | 99 | const descriptionSvg = multiLineDescription 100 | .map((line) => `${encodeHTML(line)}`) 101 | .join(""); 102 | 103 | const height = 104 | (descriptionLinesCount > 1 ? 120 : 110) + 105 | descriptionLinesCount * lineHeight; 106 | 107 | const i18n = new I18n({ 108 | locale, 109 | translations: repoCardLocales, 110 | }); 111 | 112 | // returns theme based colors with proper overrides and defaults 113 | const colors = getCardColors({ 114 | title_color, 115 | icon_color, 116 | text_color, 117 | bg_color, 118 | border_color, 119 | theme, 120 | }); 121 | 122 | const svgLanguage = primaryLanguage 123 | ? createLanguageNode(langName, langColor) 124 | : ""; 125 | 126 | const totalStars = kFormatter(starCount); 127 | const totalForks = kFormatter(forkCount); 128 | const svgStars = iconWithLabel( 129 | icons.star, 130 | totalStars, 131 | "stargazers", 132 | ICON_SIZE, 133 | ); 134 | const svgForks = iconWithLabel( 135 | icons.fork, 136 | totalForks, 137 | "forkcount", 138 | ICON_SIZE, 139 | ); 140 | 141 | const starAndForkCount = flexLayout({ 142 | items: [svgLanguage, svgStars, svgForks], 143 | sizes: [ 144 | measureText(langName, 12), 145 | ICON_SIZE + measureText(`${totalStars}`, 12), 146 | ICON_SIZE + measureText(`${totalForks}`, 12), 147 | ], 148 | gap: 25, 149 | }).join(""); 150 | 151 | const card = new Card({ 152 | defaultTitle: header.length > 35 ? `${header.slice(0, 35)}...` : header, 153 | titlePrefixIcon: icons.contribs, 154 | width: 400, 155 | height, 156 | border_radius, 157 | colors, 158 | }); 159 | 160 | card.disableAnimations(); 161 | card.setHideBorder(hide_border); 162 | card.setHideTitle(false); 163 | card.setCSS(` 164 | .description { font: 400 13px 'Segoe UI', Ubuntu, Sans-Serif; fill: ${colors.textColor} } 165 | .gray { font: 400 12px 'Segoe UI', Ubuntu, Sans-Serif; fill: ${colors.textColor} } 166 | .icon { fill: ${colors.iconColor} } 167 | .badge { font: 600 11px 'Segoe UI', Ubuntu, Sans-Serif; } 168 | .badge rect { opacity: 0.2 } 169 | `); 170 | 171 | return card.render(` 172 | ${ 173 | isTemplate 174 | ? // @ts-ignore 175 | getBadgeSVG(i18n.t("repocard.template"), colors.textColor) 176 | : isArchived 177 | ? // @ts-ignore 178 | getBadgeSVG(i18n.t("repocard.archived"), colors.textColor) 179 | : "" 180 | } 181 | 182 | 183 | ${descriptionSvg} 184 | 185 | 186 | 187 | ${starAndForkCount} 188 | 189 | `); 190 | }; 191 | 192 | export { renderRepoCard }; 193 | export default renderRepoCard; 194 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to [github-readme-stats](https://github.com/anuraghazra/github-readme-stats) 2 | 3 | We love your input! We want to make contributing to this project as easy and transparent as possible, whether it's: 4 | 5 | - Reporting [an issue](https://github.com/anuraghazra/github-readme-stats/issues/new?assignees=&labels=bug&template=bug_report.yml). 6 | - [Discussing](https://github.com/anuraghazra/github-readme-stats/discussions) the current state of the code. 7 | - Submitting [a fix](https://github.com/anuraghazra/github-readme-stats/compare). 8 | - Proposing [new features](https://github.com/anuraghazra/github-readme-stats/issues/new?assignees=&labels=enhancement&template=feature_request.yml). 9 | - Becoming a maintainer. 10 | 11 | ## All Changes Happen Through Pull Requests 12 | 13 | Pull requests are the best way to propose changes. We actively welcome your pull requests: 14 | 15 | 1. Fork the repo and create your branch from `master`. 16 | 2. If you've added code that should be tested, add some tests' examples. 17 | 3. If you've changed APIs, update the documentation. 18 | 4. Issue that pull request! 19 | 20 | ## Under the hood of github-readme-stats 21 | 22 | Interested in diving deeper into understanding how github-readme-stats works? 23 | 24 | [Bohdan](https://github.com/Bogdan-Lyashenko) wrote a fantastic in-depth post about it, check it out: 25 | 26 | **[Under the hood of github-readme-stats project](https://codecrumbs.io/library/github-readme-stats)** 27 | 28 | ## Local Development 29 | 30 | To run & test github-readme-stats, you need to follow a few simple steps:- 31 | _(make sure you already have a [Vercel](https://vercel.com/) account)_ 32 | 33 | 1. Install [Vercel CLI](https://vercel.com/download). 34 | 2. Fork the repository and clone the code to your local machine. 35 | 3. Run `npm install` in the repository root. 36 | 4. Run the command `vercel` in the root and follow the steps there. 37 | 5. Run the command `vercel dev` to start a development server at . 38 | 6. Create a `.env` file in the root and add the following line `NODE_ENV=development`, this will disable caching for local development. 39 | 7. The cards will then be available from this local endpoint (i.e. `http://localhost:3000/api?username=anuraghazra`). 40 | 41 | > [!NOTE] 42 | > 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. 43 | 44 | ## Themes Contribution 45 | 46 | We're currently paused addition of new themes to decrease maintenance efforts. All pull requests related to new themes will be closed. 47 | 48 | > [!NOTE] 49 | > 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). 50 | 51 | ## Translations Contribution 52 | 53 | GitHub Readme Stats supports multiple languages, if we are missing your language, you can contribute it! You can check the currently supported languages [here](./readme.md#available-locales). 54 | 55 | To contribute your language you need to edit the [src/translations.js](./src/translations.js) file and add new property to each object where the key is the language code in [ISO 639-1 standard](https://www.andiamo.co.uk/resources/iso-language-codes/) and the value is the translated string. 56 | 57 | ## Any contributions you make will be under the MIT Software License 58 | 59 | In short, when you submit changes, your submissions are understood to be under the same [MIT License](https://choosealicense.com/licenses/mit/) that covers the project. Feel free to contact the maintainers if that's a concern. 60 | 61 | ## Report issues/bugs using GitHub's [issues](https://github.com/anuraghazra/github-readme-stats/issues) 62 | 63 | We use GitHub issues to track public bugs. Report a bug by [opening a new issue](https://github.com/anuraghazra/github-readme-stats/issues/new/choose); it's that easy! 64 | 65 | ## Frequently Asked Questions (FAQs) 66 | 67 | **Q:** How to hide Jupyter Notebook? 68 | 69 | > **Ans:** &hide=jupyter%20notebook 70 | 71 | **Q:** I could not figure out how to deploy on my own Vercel instance 72 | 73 | > **Ans:** 74 | > 75 | > - docs: 76 | > - YT tutorial by codeSTACKr: 77 | 78 | **Q:** Language Card is incorrect 79 | 80 | > **Ans:** Please read all the related issues/comments before opening any issues regarding language card stats: 81 | > 82 | > - 83 | > 84 | > - 85 | 86 | **Q:** How to count private stats? 87 | 88 | > **Ans:** We can only count public commits & we cannot access any other private info of any users, so it's not possible. The only way to count your personal private stats is to deploy on your own instance & use your own PAT (Personal Access Token) 89 | 90 | ### Bug Reports 91 | 92 | **Great Bug Reports** tend to have: 93 | 94 | - A quick summary and/or background 95 | - Steps to reproduce 96 | - Be specific! 97 | - Share the snapshot, if possible. 98 | - GitHub Readme Stats' live link 99 | - What actually happens 100 | - What you expected would happen 101 | - Notes (possibly including why you think this might be happening or stuff you tried that didn't work) 102 | 103 | People _love_ thorough bug reports. I'm not even kidding. 104 | 105 | ### Feature Request 106 | 107 | **Great Feature Requests** tend to have: 108 | 109 | - A quick idea summary 110 | - What & why do you want to add the specific feature 111 | - Additional context like images, links to resources to implement the feature, etc. 112 | -------------------------------------------------------------------------------- /scripts/close-stale-theme-prs.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @file Script that can be used to close stale theme PRs that have a `invalid` label. 3 | */ 4 | import * as dotenv from "dotenv"; 5 | dotenv.config(); 6 | 7 | import { debug, setFailed } from "@actions/core"; 8 | import github from "@actions/github"; 9 | import { RequestError } from "@octokit/request-error"; 10 | import { getGithubToken, getRepoInfo } from "./helpers.js"; 11 | 12 | const CLOSING_COMMENT = ` 13 | \rThis theme PR has been automatically closed due to inactivity. Please reopen it if you want to continue working on it.\ 14 | \rThank you for your contributions. 15 | `; 16 | const REVIEWER = "github-actions[bot]"; 17 | 18 | /** 19 | * Retrieve the review user. 20 | * @returns {string} review user. 21 | */ 22 | const getReviewer = () => { 23 | return process.env.REVIEWER ? process.env.REVIEWER : REVIEWER; 24 | }; 25 | 26 | /** 27 | * Fetch open PRs from a given repository. 28 | * 29 | * @param {module:@actions/github.Octokit} octokit The octokit client. 30 | * @param {string} user The user name of the repository owner. 31 | * @param {string} repo The name of the repository. 32 | * @param {string} reviewer The reviewer to filter by. 33 | * @returns {Promise} The open PRs. 34 | */ 35 | export const fetchOpenPRs = async (octokit, user, repo, reviewer) => { 36 | const openPRs = []; 37 | let hasNextPage = true; 38 | let endCursor; 39 | while (hasNextPage) { 40 | try { 41 | const { repository } = await octokit.graphql( 42 | ` 43 | { 44 | repository(owner: "${user}", name: "${repo}") { 45 | open_prs: pullRequests(${ 46 | endCursor ? `after: "${endCursor}", ` : "" 47 | } 48 | first: 100, states: OPEN, orderBy: {field: CREATED_AT, direction: DESC}) { 49 | nodes { 50 | number 51 | commits(last:1){ 52 | nodes{ 53 | commit{ 54 | pushedDate 55 | } 56 | } 57 | } 58 | labels(first: 100, orderBy:{field: CREATED_AT, direction: DESC}) { 59 | nodes { 60 | name 61 | } 62 | } 63 | reviews(first: 100, states: CHANGES_REQUESTED, author: "${reviewer}") { 64 | nodes { 65 | submittedAt 66 | } 67 | } 68 | } 69 | pageInfo { 70 | endCursor 71 | hasNextPage 72 | } 73 | } 74 | } 75 | } 76 | `, 77 | ); 78 | openPRs.push(...repository.open_prs.nodes); 79 | hasNextPage = repository.open_prs.pageInfo.hasNextPage; 80 | endCursor = repository.open_prs.pageInfo.endCursor; 81 | } catch (error) { 82 | if (error instanceof RequestError) { 83 | setFailed(`Could not retrieve top PRs using GraphQl: ${error.message}`); 84 | } 85 | throw error; 86 | } 87 | } 88 | return openPRs; 89 | }; 90 | 91 | /** 92 | * Retrieve pull requests that have a given label. 93 | * 94 | * @param {Object[]} pulls The pull requests to check. 95 | * @param {string} label The label to check for. 96 | * @returns {Object[]} The pull requests that have the given label. 97 | */ 98 | export const pullsWithLabel = (pulls, label) => { 99 | return pulls.filter((pr) => { 100 | return pr.labels.nodes.some((lab) => lab.name === label); 101 | }); 102 | }; 103 | 104 | /** 105 | * Check if PR is stale. Meaning that it hasn't been updated in a given time. 106 | * 107 | * @param {Object} pullRequest request object. 108 | * @param {number} staleDays number of days. 109 | * @returns {boolean} indicating if PR is stale. 110 | */ 111 | const isStale = (pullRequest, staleDays) => { 112 | const lastCommitDate = new Date( 113 | pullRequest.commits.nodes[0].commit.pushedDate, 114 | ); 115 | if (pullRequest.reviews.nodes[0]) { 116 | const lastReviewDate = new Date( 117 | pullRequest.reviews.nodes.sort((a, b) => (a < b ? 1 : -1))[0].submittedAt, 118 | ); 119 | const lastUpdateDate = 120 | lastCommitDate >= lastReviewDate ? lastCommitDate : lastReviewDate; 121 | const now = new Date(); 122 | return (now - lastUpdateDate) / (1000 * 60 * 60 * 24) >= staleDays; 123 | } else { 124 | return false; 125 | } 126 | }; 127 | 128 | /** 129 | * Main function. 130 | * 131 | * @returns {Promise} A promise. 132 | */ 133 | const run = async () => { 134 | try { 135 | // Create octokit client. 136 | const dryRun = process.env.DRY_RUN === "true" || false; 137 | const staleDays = process.env.STALE_DAYS || 20; 138 | debug("Creating octokit client..."); 139 | const octokit = github.getOctokit(getGithubToken()); 140 | const { owner, repo } = getRepoInfo(github.context); 141 | const reviewer = getReviewer(); 142 | 143 | // Retrieve all theme pull requests. 144 | debug("Retrieving all theme pull requests..."); 145 | const prs = await fetchOpenPRs(octokit, owner, repo, reviewer); 146 | const themePRs = pullsWithLabel(prs, "themes"); 147 | const invalidThemePRs = pullsWithLabel(themePRs, "invalid"); 148 | debug("Retrieving stale theme PRs..."); 149 | const staleThemePRs = invalidThemePRs.filter((pr) => 150 | isStale(pr, staleDays), 151 | ); 152 | const staleThemePRsNumbers = staleThemePRs.map((pr) => pr.number); 153 | debug(`Found ${staleThemePRs.length} stale theme PRs`); 154 | 155 | // Loop through all stale invalid theme pull requests and close them. 156 | for (const prNumber of staleThemePRsNumbers) { 157 | debug(`Closing #${prNumber} because it is stale...`); 158 | if (dryRun) { 159 | debug("Dry run enabled, skipping..."); 160 | } else { 161 | await octokit.rest.issues.createComment({ 162 | owner, 163 | repo, 164 | issue_number: prNumber, 165 | body: CLOSING_COMMENT, 166 | }); 167 | await octokit.rest.pulls.update({ 168 | owner, 169 | repo, 170 | pull_number: prNumber, 171 | state: "closed", 172 | }); 173 | } 174 | } 175 | } catch (error) { 176 | setFailed(error.message); 177 | } 178 | }; 179 | 180 | run(); 181 | -------------------------------------------------------------------------------- /tests/card.test.js: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from "@jest/globals"; 2 | import { queryByTestId } from "@testing-library/dom"; 3 | import "@testing-library/jest-dom"; 4 | import { cssToObject } from "@uppercod/css-to-object"; 5 | import { Card } from "../src/common/Card.js"; 6 | import { icons } from "../src/common/icons.js"; 7 | import { getCardColors } from "../src/common/color.js"; 8 | 9 | describe("Card", () => { 10 | it("should hide border", () => { 11 | const card = new Card({}); 12 | card.setHideBorder(true); 13 | 14 | document.body.innerHTML = card.render(``); 15 | expect(queryByTestId(document.body, "card-bg")).toHaveAttribute( 16 | "stroke-opacity", 17 | "0", 18 | ); 19 | }); 20 | 21 | it("should not hide border", () => { 22 | const card = new Card({}); 23 | card.setHideBorder(false); 24 | 25 | document.body.innerHTML = card.render(``); 26 | expect(queryByTestId(document.body, "card-bg")).toHaveAttribute( 27 | "stroke-opacity", 28 | "1", 29 | ); 30 | }); 31 | 32 | it("should have a custom title", () => { 33 | const card = new Card({ 34 | customTitle: "custom title", 35 | defaultTitle: "default title", 36 | }); 37 | 38 | document.body.innerHTML = card.render(``); 39 | expect(queryByTestId(document.body, "card-title")).toHaveTextContent( 40 | "custom title", 41 | ); 42 | }); 43 | 44 | it("should set custom title", () => { 45 | const card = new Card({}); 46 | card.setTitle("custom title"); 47 | 48 | document.body.innerHTML = card.render(``); 49 | expect(queryByTestId(document.body, "card-title")).toHaveTextContent( 50 | "custom title", 51 | ); 52 | }); 53 | 54 | it("should hide title", () => { 55 | const card = new Card({}); 56 | card.setHideTitle(true); 57 | 58 | document.body.innerHTML = card.render(``); 59 | expect(queryByTestId(document.body, "card-title")).toBeNull(); 60 | }); 61 | 62 | it("should not hide title", () => { 63 | const card = new Card({}); 64 | card.setHideTitle(false); 65 | 66 | document.body.innerHTML = card.render(``); 67 | expect(queryByTestId(document.body, "card-title")).toBeInTheDocument(); 68 | }); 69 | 70 | it("title should have prefix icon", () => { 71 | const card = new Card({ title: "ok", titlePrefixIcon: icons.contribs }); 72 | 73 | document.body.innerHTML = card.render(``); 74 | expect(document.getElementsByClassName("icon")[0]).toBeInTheDocument(); 75 | }); 76 | 77 | it("title should not have prefix icon", () => { 78 | const card = new Card({ title: "ok" }); 79 | 80 | document.body.innerHTML = card.render(``); 81 | expect(document.getElementsByClassName("icon")[0]).toBeUndefined(); 82 | }); 83 | 84 | it("should have proper height, width", () => { 85 | const card = new Card({ height: 200, width: 200, title: "ok" }); 86 | document.body.innerHTML = card.render(``); 87 | expect(document.getElementsByTagName("svg")[0]).toHaveAttribute( 88 | "height", 89 | "200", 90 | ); 91 | expect(document.getElementsByTagName("svg")[0]).toHaveAttribute( 92 | "width", 93 | "200", 94 | ); 95 | }); 96 | 97 | it("should have less height after title is hidden", () => { 98 | const card = new Card({ height: 200, title: "ok" }); 99 | card.setHideTitle(true); 100 | 101 | document.body.innerHTML = card.render(``); 102 | expect(document.getElementsByTagName("svg")[0]).toHaveAttribute( 103 | "height", 104 | "170", 105 | ); 106 | }); 107 | 108 | it("main-card-body should have proper when title is visible", () => { 109 | const card = new Card({ height: 200 }); 110 | document.body.innerHTML = card.render(``); 111 | expect(queryByTestId(document.body, "main-card-body")).toHaveAttribute( 112 | "transform", 113 | "translate(0, 55)", 114 | ); 115 | }); 116 | 117 | it("main-card-body should have proper position after title is hidden", () => { 118 | const card = new Card({ height: 200 }); 119 | card.setHideTitle(true); 120 | 121 | document.body.innerHTML = card.render(``); 122 | expect(queryByTestId(document.body, "main-card-body")).toHaveAttribute( 123 | "transform", 124 | "translate(0, 25)", 125 | ); 126 | }); 127 | 128 | it("should render with correct colors", () => { 129 | // returns theme based colors with proper overrides and defaults 130 | const { titleColor, textColor, iconColor, bgColor } = getCardColors({ 131 | title_color: "f00", 132 | icon_color: "0f0", 133 | text_color: "00f", 134 | bg_color: "fff", 135 | theme: "default", 136 | }); 137 | 138 | const card = new Card({ 139 | height: 200, 140 | colors: { 141 | titleColor, 142 | textColor, 143 | iconColor, 144 | bgColor, 145 | }, 146 | }); 147 | document.body.innerHTML = card.render(``); 148 | 149 | const styleTag = document.querySelector("style"); 150 | const stylesObject = cssToObject(styleTag.innerHTML); 151 | const headerClassStyles = stylesObject[":host"][".header "]; 152 | 153 | expect(headerClassStyles["fill"].trim()).toBe("#f00"); 154 | expect(queryByTestId(document.body, "card-bg")).toHaveAttribute( 155 | "fill", 156 | "#fff", 157 | ); 158 | }); 159 | it("should render gradient backgrounds", () => { 160 | const { titleColor, textColor, iconColor, bgColor } = getCardColors({ 161 | title_color: "f00", 162 | icon_color: "0f0", 163 | text_color: "00f", 164 | bg_color: "90,fff,000,f00", 165 | theme: "default", 166 | }); 167 | 168 | const card = new Card({ 169 | height: 200, 170 | colors: { 171 | titleColor, 172 | textColor, 173 | iconColor, 174 | bgColor, 175 | }, 176 | }); 177 | document.body.innerHTML = card.render(``); 178 | expect(queryByTestId(document.body, "card-bg")).toHaveAttribute( 179 | "fill", 180 | "url(#gradient)", 181 | ); 182 | expect(document.querySelector("defs #gradient")).toHaveAttribute( 183 | "gradientTransform", 184 | "rotate(90)", 185 | ); 186 | expect( 187 | document.querySelector("defs #gradient stop:nth-child(1)"), 188 | ).toHaveAttribute("stop-color", "#fff"); 189 | expect( 190 | document.querySelector("defs #gradient stop:nth-child(2)"), 191 | ).toHaveAttribute("stop-color", "#000"); 192 | expect( 193 | document.querySelector("defs #gradient stop:nth-child(3)"), 194 | ).toHaveAttribute("stop-color", "#f00"); 195 | }); 196 | }); 197 | --------------------------------------------------------------------------------