Repository: anuraghazra/github-readme-stats Branch: master Commit: 5df91f9bfa89 Files: 130 Total size: 577.0 KB Directory structure: gitextract_rbue6f_z/ ├── .devcontainer/ │ └── devcontainer.json ├── .eslintrc.json ├── .github/ │ ├── CODEOWNERS │ ├── FUNDING.yml │ ├── ISSUE_TEMPLATE/ │ │ ├── bug_report.yml │ │ ├── config.yml │ │ └── feature_request.yml │ ├── dependabot.yml │ ├── labeler.yml │ ├── stale.yml │ └── workflows/ │ ├── codeql-analysis.yml │ ├── deploy-prep.py │ ├── deploy-prep.yml │ ├── e2e-test.yml │ ├── empty-issues-closer.yml │ ├── generate-theme-doc.yml │ ├── label-pr.yml │ ├── ossf-analysis.yml │ ├── preview-theme.yml │ ├── prs-cache-clean.yml │ ├── stale-theme-pr-closer.yml │ ├── test.yml │ ├── theme-prs-closer.yml │ ├── top-issues-dashboard.yml │ └── update-langs.yml ├── .gitignore ├── .husky/ │ ├── .gitignore │ └── pre-commit ├── .nvmrc ├── .prettierignore ├── .prettierrc.json ├── .vercelignore ├── .vscode/ │ ├── extensions.json │ └── settings.json ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── LICENSE ├── SECURITY.md ├── api/ │ ├── gist.js │ ├── index.js │ ├── pin.js │ ├── status/ │ │ ├── pat-info.js │ │ └── up.js │ ├── top-langs.js │ └── wakatime.js ├── codecov.yml ├── eslint.config.mjs ├── express.js ├── jest.bench.config.js ├── jest.config.js ├── jest.e2e.config.js ├── package.json ├── readme.md ├── scripts/ │ ├── close-stale-theme-prs.js │ ├── generate-langs-json.js │ ├── generate-theme-doc.js │ ├── helpers.js │ ├── preview-theme.js │ └── push-theme-readme.sh ├── src/ │ ├── calculateRank.js │ ├── cards/ │ │ ├── gist.js │ │ ├── index.js │ │ ├── repo.js │ │ ├── stats.js │ │ ├── top-languages.js │ │ ├── types.d.ts │ │ └── wakatime.js │ ├── common/ │ │ ├── Card.js │ │ ├── I18n.js │ │ ├── access.js │ │ ├── blacklist.js │ │ ├── cache.js │ │ ├── color.js │ │ ├── envs.js │ │ ├── error.js │ │ ├── fmt.js │ │ ├── html.js │ │ ├── http.js │ │ ├── icons.js │ │ ├── index.js │ │ ├── languageColors.json │ │ ├── log.js │ │ ├── ops.js │ │ ├── render.js │ │ └── retryer.js │ ├── fetchers/ │ │ ├── gist.js │ │ ├── repo.js │ │ ├── stats.js │ │ ├── top-languages.js │ │ ├── types.d.ts │ │ └── wakatime.js │ ├── index.js │ └── translations.js ├── tests/ │ ├── __snapshots__/ │ │ └── renderWakatimeCard.test.js.snap │ ├── api.test.js │ ├── bench/ │ │ ├── api.bench.js │ │ ├── calculateRank.bench.js │ │ ├── gist.bench.js │ │ ├── pin.bench.js │ │ └── utils.js │ ├── calculateRank.test.js │ ├── card.test.js │ ├── color.test.js │ ├── e2e/ │ │ └── e2e.test.js │ ├── fetchGist.test.js │ ├── fetchRepo.test.js │ ├── fetchStats.test.js │ ├── fetchTopLanguages.test.js │ ├── fetchWakatime.test.js │ ├── flexLayout.test.js │ ├── fmt.test.js │ ├── gist.test.js │ ├── html.test.js │ ├── i18n.test.js │ ├── ops.test.js │ ├── pat-info.test.js │ ├── pin.test.js │ ├── render.test.js │ ├── renderGistCard.test.js │ ├── renderRepoCard.test.js │ ├── renderStatsCard.test.js │ ├── renderTopLanguagesCard.test.js │ ├── renderWakatimeCard.test.js │ ├── retryer.test.js │ ├── status.up.test.js │ ├── top-langs.test.js │ └── wakatime.test.js ├── themes/ │ ├── README.md │ └── index.js └── vercel.json ================================================ FILE CONTENTS ================================================ ================================================ FILE: .devcontainer/devcontainer.json ================================================ { "name": "GitHub Readme Stats Dev", "image": "mcr.microsoft.com/devcontainers/base:ubuntu", "features": { "ghcr.io/devcontainers/features/node:1": { "version": "22" } }, "forwardPorts": [3000], "portsAttributes": { "3000": { "label": "HTTP" } }, "appPort": [], // Use 'postCreateCommand' to run commands after the container is created. "postCreateCommand": "npm install -g vercel", // Use 'postStartCommand' to run commands after the container is started. "postStartCommand": "hostname dev && npm install", // Configure tool-specific properties. "customizations": { "vscode": { "extensions": [ "yzhang.markdown-all-in-one", "esbenp.prettier-vscode", "dbaeumer.vscode-eslint", "github.vscode-github-actions" ] } }, "remoteUser": "root", "privileged": true } ================================================ FILE: .eslintrc.json ================================================ // { // "env": { // "node": true, // "browser": true, // "es2021": true // }, // "extends": [ // // "eslint:recommended", // "prettier" // ], // "parserOptions": { // "sourceType": "module", // "ecmaVersion": 2022 // }, // "rules": { // // Possible Errors (overrides from recommended set) // // "no-extra-parens": "error", // "no-unexpected-multiline": "error", // // All JSDoc comments must be valid // "valid-jsdoc": [ "error", { // "requireReturn": true, // "requireReturnDescription": true, // "requireParamDescription": true, // "prefer": { // "return": "returns" // } // }], // // Best Practices // // Allowed a getter without setter, but all setters require getters // "accessor-pairs": [ "error", { // "getWithoutSet": false, // "setWithoutGet": true // }], // "block-scoped-var": "warn", // "consistent-return": "error", // "curly": "error", // // "default-case": "warn", // // the dot goes with the property when doing multiline // // "dot-location": [ "warn", "property" ], // // "dot-notation": "warn", // // "eqeqeq": [ "error", "smart" ], // // "guard-for-in": "warn", // "no-alert": "error", // "no-caller": "error", // // "no-case-declarations": "warn", // // "no-div-regex": "warn", // // "no-else-return": "warn", // // "no-empty-label": "warn", // // "no-empty-pattern": "warn", // // "no-eq-null": "warn", // // "no-eval": "error", // // "no-extend-native": "error", // // "no-extra-bind": "warn", // // "no-floating-decimal": "warn", // // "no-implicit-coercion": [ "warn", { // // "boolean": true, // // "number": true, // // "string": true // // }], // // "no-implied-eval": "error", // // "no-invalid-this": "error", // // "no-iterator": "error", // // "no-labels": "warn", // // "no-lone-blocks": "warn", // // "no-loop-func": "error", // // "no-magic-numbers": "warn", // // "no-multi-spaces": "error", // // "no-multi-str": "warn", // // "no-native-reassign": "error", // // "no-new-func": "error", // // "no-new-wrappers": "error", // // "no-new": "error", // // "no-octal-escape": "error", // // "no-param-reassign": "error", // // "no-process-env": "warn", // // "no-proto": "error", // // "no-redeclare": "error", // // "no-return-assign": "error", // // "no-script-url": "error", // // "no-self-compare": "error", // // "no-throw-literal": "error", // // "no-unused-expressions": "error", // // "no-useless-call": "error", // // "no-useless-concat": "error", // // "no-void": "warn", // // Produce warnings when something is commented as TODO or FIXME // "no-warning-comments": [ "warn", { // "terms": [ "TODO", "FIXME" ], // "location": "start" // }], // "no-with": "warn", // "radix": "warn", // // "vars-on-top": "error", // // Enforces the style of wrapped functions // // "wrap-iife": [ "error", "outside" ], // // "yoda": "error", // // Strict Mode - for ES6, never use strict. // // "strict": [ "error", "never" ], // // Variables // // "init-declarations": [ "error", "always" ], // // "no-catch-shadow": "warn", // "no-delete-var": "error", // // "no-label-var": "error", // // "no-shadow-restricted-names": "error", // // "no-shadow": "warn", // // We require all vars to be initialized (see init-declarations) // // If we NEED a var to be initialized to undefined, it needs to be explicit // "no-undef-init": "off", // "no-undef": "error", // "no-undefined": "off", // "no-unused-vars": "warn", // // Disallow hoisting - let & const don't allow hoisting anyhow // "no-use-before-define": "error", // // Node.js and CommonJS // // "callback-return": [ "warn", [ "callback", "next" ]], // // "global-require": "error", // // "handle-callback-err": "warn", // // "no-mixed-requires": "warn", // // "no-new-require": "error", // // Use path.concat instead // // "no-path-concat": "error", // // "no-process-exit": "error", // // "no-restricted-modules": "off", // // "no-sync": "warn", // // ECMAScript 6 support // // "arrow-body-style": [ "error", "always" ], // // "arrow-parens": [ "error", "always" ], // // "arrow-spacing": [ "error", { "before": true, "after": true }], // "constructor-super": "error", // // "generator-star-spacing": [ "error", "before" ], // // "no-arrow-condition": "error", // "no-class-assign": "error", // "no-const-assign": "error", // "no-dupe-class-members": "error", // "no-this-before-super": "error", // // "no-var": "warn", // "object-shorthand": [ "warn" ], // // "prefer-arrow-callback": "warn", // // "prefer-spread": "warn", // // "prefer-template": "warn", // // "require-yield": "error", // // Stylistic - everything here is a warning because of style. // // "array-bracket-spacing": [ "warn", "always" ], // // "block-spacing": [ "warn", "always" ], // // "brace-style": [ "warn", "1tbs", { "allowSingleLine": false } ], // // "camelcase": "warn", // // "comma-spacing": [ "warn", { "before": false, "after": true } ], // // "comma-style": [ "warn", "last" ], // // "computed-property-spacing": [ "warn", "never" ], // // "consistent-this": [ "warn", "self" ], // // "eol-last": "warn", // // "func-names": "warn", // // "func-style": [ "warn", "declaration" ], // // "id-length": [ "warn", { "min": 2, "max": 32 } ], // // "indent": [ "warn", 4 ], // // "jsx-quotes": [ "warn", "prefer-double" ], // // "linebreak-style": [ "warn", "unix" ], // // "lines-around-comment": [ "warn", { "beforeBlockComment": true } ], // // "max-depth": [ "warn", 8 ], // // "max-len": [ "warn", 132 ], // // "max-nested-callbacks": [ "warn", 8 ], // // "max-params": [ "warn", 8 ], // // "new-cap": "warn", // // "new-parens": "warn", // // "no-array-constructor": "warn", // // "no-bitwise": "off", // // "no-continue": "off", // // "no-inline-comments": "off", // // "no-lonely-if": "warn", // "no-mixed-spaces-and-tabs": "warn", // "no-multiple-empty-lines": "warn", // "no-negated-condition": "warn", // // "no-nested-ternary": "warn", // // "no-new-object": "warn", // // "no-plusplus": "off", // // "no-spaced-func": "warn", // // "no-ternary": "off", // // "no-trailing-spaces": "warn", // // "no-underscore-dangle": "warn", // "no-unneeded-ternary": "warn", // // "object-curly-spacing": [ "warn", "always" ], // // "one-var": "off", // // "operator-assignment": [ "warn", "never" ], // // "operator-linebreak": [ "warn", "after" ], // // "padded-blocks": [ "warn", "never" ], // // "quote-props": [ "warn", "consistent-as-needed" ], // // "quotes": [ "warn", "single" ], // "require-jsdoc": [ "warn", { // "require": { // "FunctionDeclaration": true, // "MethodDefinition": true, // "ClassDeclaration": false // } // }], // // "semi-spacing": [ "warn", { "before": false, "after": true }], // // "semi": [ "error", "always" ], // // "sort-vars": "off", // "keyword-spacing": ["error", { "before": true, "after": true }] // // "space-before-blocks": [ "warn", "always" ], // // "space-before-function-paren": [ "warn", "never" ], // // "space-in-parens": [ "warn", "never" ], // // "space-infix-ops": [ "warn", { "int32Hint": true } ], // // "space-return-throw-case": "error", // // "space-unary-ops": "error", // // "spaced-comment": [ "warn", "always" ], // // "wrap-regex": "warn" // } // } ================================================ FILE: .github/CODEOWNERS ================================================ # This file is used to define code owners for the repository. # Code owners are automatically requested for review when someone opens a pull request that modifies code they own. # Assign @qwerty541 as the owner for package.json and package-lock.json package.json @qwerty541 package-lock.json @qwerty541 ================================================ FILE: .github/FUNDING.yml ================================================ # These are supported funding model platforms github: [anuraghazra] # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2] patreon: # Replace with a single Patreon username open_collective: # Replace with a single Open Collective username ko_fi: # Replace with a single Ko-fi username tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry liberapay: # Replace with a single Liberapay username issuehunt: # Replace with a single IssueHunt username otechie: # Replace with a single Otechie username custom: [ "https://www.paypal.me/anuraghazra", "https://www.buymeacoffee.com/anuraghazra", ] # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2'] ================================================ FILE: .github/ISSUE_TEMPLATE/bug_report.yml ================================================ name: Bug report description: Create a report to help us improve. labels: - "bug" body: - type: markdown attributes: value: | :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)!!! - type: textarea attributes: label: Describe the bug description: A clear and concise description of what the bug is. validations: required: true - type: textarea attributes: label: Expected behavior description: A clear and concise description of what you expected to happen. - type: textarea attributes: label: Screenshots / Live demo link description: If applicable, add screenshots to help explain your problem. placeholder: Paste the github-readme-stats link as markdown image - type: textarea attributes: label: Additional context description: Add any other context about the problem here. - type: markdown attributes: value: | --- ### FAQ (Snippet) 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). #### Q: My card displays an error **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. #### Q: How to hide jupyter Notebook? **Ans:** `&hide=jupyter%20notebook`. #### Q: I could not figure out how to deploy on my own vercel instance **Ans:** Please check: - Docs: https://github.com/anuraghazra/github-readme-stats/#deploy-on-your-own-vercel-instance - YT tutorial by codeSTACKr: https://www.youtube.com/watch?v=n6d4KHSKqGk&feature=youtu.be&t=107 #### Q: Language Card is incorrect **Ans:** Please read these issues/comments before opening any issues regarding language card stats: - https://github.com/anuraghazra/github-readme-stats/issues/136#issuecomment-665164174 - https://github.com/anuraghazra/github-readme-stats/issues/136#issuecomment-665172181 #### Q: How to count private stats? **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). ================================================ FILE: .github/ISSUE_TEMPLATE/config.yml ================================================ blank_issues_enabled: true contact_links: - name: Question url: https://github.com/anuraghazra/github-readme-stats/discussions about: Please ask and answer questions here. - name: Error url: https://github.com/anuraghazra/github-readme-stats/issues/1772 about: Before opening a bug report, please check the 'Common Error Codes' issue. - name: FAQ url: https://github.com/anuraghazra/github-readme-stats/discussions/1770 about: Please first check the FAQ before asking a question. ================================================ FILE: .github/ISSUE_TEMPLATE/feature_request.yml ================================================ name: Feature request description: Suggest an idea for this project. labels: - "enhancement" body: - type: textarea attributes: label: Is your feature request related to a problem? Please describe. description: A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] validations: required: true - type: textarea attributes: label: Describe the solution you'd like description: A clear and concise description of what you want to happen. - type: textarea attributes: label: Describe alternatives you've considered description: A clear and concise description of any alternative solutions or features you've considered. - type: textarea attributes: label: Additional context description: Add any other context or screenshots about the feature request here. ================================================ FILE: .github/dependabot.yml ================================================ version: 2 updates: # Maintain dependencies for npm - package-ecosystem: npm directory: "/" schedule: interval: weekly open-pull-requests-limit: 10 commit-message: prefix: "build(deps)" prefix-development: "build(deps-dev)" # Maintain dependencies for GitHub Actions - package-ecosystem: github-actions directory: "/" schedule: interval: weekly open-pull-requests-limit: 10 commit-message: prefix: "ci(deps)" prefix-development: "ci(deps-dev)" # Maintain dependencies for Devcontainers - package-ecosystem: devcontainers directory: "/" schedule: interval: weekly open-pull-requests-limit: 10 commit-message: prefix: "build(deps)" prefix-development: "build(deps-dev)" ================================================ FILE: .github/labeler.yml ================================================ themes: - changed-files: - any-glob-to-any-file: - themes/index.js card-i18n: - changed-files: - any-glob-to-any-file: - src/translations.js - src/common/I18n.js documentation: - changed-files: - any-glob-to-any-file: - readme.md - CONTRIBUTING.md - CODE_OF_CONDUCT.md - SECURITY.md dependencies: - changed-files: - any-glob-to-any-file: - package.json - package-lock.json lang-card: - changed-files: - any-glob-to-any-file: - api/top-langs.js - src/cards/top-languages.js - src/fetchers/top-languages.js - tests/fetchTopLanguages.test.js - tests/renderTopLanguagesCard.test.js - tests/top-langs.test.js repo-card: - changed-files: - any-glob-to-any-file: - api/pin.js - src/cards/repo.js - src/fetchers/repo.js - tests/fetchRepo.test.js - tests/renderRepoCard.test.js - tests/pin.test.js stats-card: - changed-files: - any-glob-to-any-file: - api/index.js - src/cards/stats.js - src/fetchers/stats.js - tests/fetchStats.test.js - tests/renderStatsCard.test.js - tests/api.test.js wakatime-card: - changed-files: - any-glob-to-any-file: - api/wakatime.js - src/cards/wakatime.js - src/fetchers/wakatime.js - tests/fetchWakatime.test.js - tests/renderWakatimeCard.test.js - tests/wakatime.test.js gist-card: - changed-files: - any-glob-to-any-file: - api/gist.js - src/cards/gist.js - src/fetchers/gist.js - tests/fetchGist.test.js - tests/renderGistCard.test.js - tests/gist.test.js ranks: - changed-files: - any-glob-to-any-file: - src/calculateRank.js ci: - changed-files: - any-glob-to-any-file: - .github/workflows/* - scripts/* infrastructure: - changed-files: - any-glob-to-any-file: - .eslintrc.json ================================================ FILE: .github/stale.yml ================================================ # Number of days of inactivity before an issue becomes stale daysUntilStale: 30 # Number of days of inactivity before a stale issue is closed daysUntilClose: 7 # Issues with these labels will never be considered stale exemptLabels: - feature - enhancement - help wanted - bug # Label to use when marking an issue as stale staleLabel: stale # Comment to post when marking an issue as stale. Set to `false` to disable markComment: > This issue has been automatically marked as stale because it has not had recent activity. It will be closed if no further activity occurs. Thank you for your contributions. # Comment to post when closing a stale issue. Set to `false` to disable closeComment: false ================================================ FILE: .github/workflows/codeql-analysis.yml ================================================ name: "Static code analysis workflow (CodeQL)" on: push: branches: - master pull_request: branches: - master permissions: actions: read checks: read contents: read deployments: read issues: read discussions: read packages: read pages: read pull-requests: read repository-projects: read security-events: write statuses: read jobs: CodeQL-Build: if: github.repository == 'anuraghazra/github-readme-stats' # CodeQL runs on ubuntu-latest, windows-latest, and macos-latest runs-on: ubuntu-latest steps: - name: Checkout repository uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL uses: github/codeql-action/init@46a6823b81f2d7c67ddf123851eea88365bc8a67 # v2.13.5 with: languages: javascript - name: Perform CodeQL Analysis uses: github/codeql-action/analyze@46a6823b81f2d7c67ddf123851eea88365bc8a67 # v2.13.5 ================================================ FILE: .github/workflows/deploy-prep.py ================================================ import os file = open('./vercel.json', 'r') str = file.read() file = open('./vercel.json', 'w') str = str.replace('"maxDuration": 10', '"maxDuration": 15') file.write(str) file.close() ================================================ FILE: .github/workflows/deploy-prep.yml ================================================ name: Deployment Prep on: workflow_dispatch: push: branches: - master jobs: config: if: github.repository == 'anuraghazra/github-readme-stats' runs-on: ubuntu-latest steps: - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 - name: Deployment Prep run: python ./.github/workflows/deploy-prep.py - uses: stefanzweifel/git-auto-commit-action@28e16e81777b558cc906c8750092100bbb34c5e3 # v7.0.0 with: branch: vercel create_branch: true push_options: "--force" ================================================ FILE: .github/workflows/e2e-test.yml ================================================ name: Test Deployment on: # Temporarily disabled automatic triggers; manual-only for now. workflow_dispatch: # Original trigger (restore to re-enable): # deployment_status: permissions: read-all jobs: e2eTests: # Temporarily disabled; set to the original condition to re-enable. # if: # github.repository == 'anuraghazra/github-readme-stats' && # github.event_name == 'deployment_status' && # github.event.deployment_status.state == 'success' if: false name: Perform e2e tests runs-on: ubuntu-latest strategy: matrix: node-version: [22.x] steps: - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 - name: Setup Node uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0 with: node-version: ${{ matrix.node-version }} cache: npm - name: Install dependencies run: npm ci env: CI: true - name: Run end-to-end tests. run: npm run test:e2e # env: # VERCEL_PREVIEW_URL: ${{ github.event.deployment_status.target_url }} ================================================ FILE: .github/workflows/empty-issues-closer.yml ================================================ name: Close empty issues and templates on: issues: types: - reopened - opened - edited permissions: actions: read checks: read contents: read deployments: read issues: write discussions: read packages: read pages: read pull-requests: read repository-projects: read security-events: read statuses: read jobs: closeEmptyIssuesAndTemplates: if: github.repository == 'anuraghazra/github-readme-stats' name: Close empty issues runs-on: ubuntu-latest steps: # NOTE: Retrieve issue templates. - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 - name: Run empty issues closer action uses: rickstaa/empty-issues-closer-action@e96914613221511279ca25f50fd4acc85e331d99 # v1.1.74 env: github_token: ${{ secrets.GITHUB_TOKEN }} with: close_comment: Closing this issue because it appears to be empty. Please update the issue for it to be reopened. open_comment: Reopening this issue because the author provided more information. check_templates: true template_close_comment: Closing this issue since the issue template was not filled in. Please provide us with more information to have this issue reopened. template_open_comment: Reopening this issue because the author provided more information. ================================================ FILE: .github/workflows/generate-theme-doc.yml ================================================ name: Generate Theme Readme on: push: branches: - master paths: - "themes/index.js" workflow_dispatch: permissions: actions: read checks: read contents: write deployments: read issues: read discussions: read packages: read pages: read pull-requests: read repository-projects: read security-events: read statuses: read jobs: generateThemeDoc: runs-on: ubuntu-latest name: Generate theme doc strategy: matrix: node-version: [22.x] steps: - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 - name: Setup Node uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0 with: node-version: ${{ matrix.node-version }} cache: npm # Fix the unsafe repo error which was introduced by the CVE-2022-24765 git patches. - name: Fix unsafe repo error run: git config --global --add safe.directory ${{ github.workspace }} - name: npm install, generate readme run: | npm ci npm run theme-readme-gen env: CI: true - name: Run Script uses: skx/github-action-tester@e29768ff4ff67be9d1fdbccd8836ab83233bebb1 # v0.10.0 with: script: ./scripts/push-theme-readme.sh env: CI: true PERSONAL_TOKEN: ${{ secrets.PERSONAL_TOKEN }} GH_REPO: ${{ secrets.GH_REPO }} ================================================ FILE: .github/workflows/label-pr.yml ================================================ name: "Pull Request Labeler" on: - pull_request_target permissions: actions: read checks: read contents: read deployments: read issues: read discussions: read packages: read pages: read pull-requests: write repository-projects: read security-events: read statuses: read jobs: triage: if: github.repository == 'anuraghazra/github-readme-stats' runs-on: ubuntu-latest steps: - uses: actions/labeler@634933edcd8ababfe52f92936142cc22ac488b1b # v6.0.1 with: repo-token: "${{ secrets.GITHUB_TOKEN }}" sync-labels: true ================================================ FILE: .github/workflows/ossf-analysis.yml ================================================ name: OSSF Scorecard analysis workflow on: push: branches: - master pull_request: branches: - master permissions: read-all jobs: analysis: if: github.repository == 'anuraghazra/github-readme-stats' name: Scorecard analysis runs-on: ubuntu-latest permissions: # Needed if using Code scanning alerts security-events: write # Needed for GitHub OIDC token if publish_results is true id-token: write steps: - name: "Checkout code" uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 with: persist-credentials: false - name: "Run analysis" uses: ossf/scorecard-action@4eaacf0543bb3f2c246792bd56e8cdeffafb205a # v2.4.3 with: results_file: results.sarif results_format: sarif publish_results: true # Upload the results as artifacts (optional). Commenting out will disable uploads of run results in SARIF # format to the repository Actions tab. - name: "Upload artifact" uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 with: name: SARIF file path: results.sarif retention-days: 5 # required for Code scanning alerts - name: "Upload SARIF results to code scanning" uses: github/codeql-action/upload-sarif@fdcae64e1484d349b3366718cdfef3d404390e85 # v2.22.1 with: sarif_file: results.sarif ================================================ FILE: .github/workflows/preview-theme.yml ================================================ name: Theme preview on: # Temporary disabled due to paused themes addition. # See: https://github.com/anuraghazra/github-readme-stats/issues/3404 # pull_request_target: # types: [opened, edited, reopened, synchronize] # branches: # - master # paths: # - "themes/index.js" workflow_dispatch: permissions: actions: read checks: read contents: read deployments: read issues: read discussions: read packages: read pages: read pull-requests: write repository-projects: read security-events: read statuses: read jobs: previewTheme: name: Install & Preview runs-on: ubuntu-latest strategy: matrix: node-version: [22.x] steps: - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 - name: Setup Node uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0 with: node-version: ${{ matrix.node-version }} cache: npm - uses: bahmutov/npm-install@3e063b974f0d209807684aa23e534b3dde517fd9 # v1.11.2 with: useLockFile: false - run: npm run preview-theme env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} ================================================ FILE: .github/workflows/prs-cache-clean.yml ================================================ name: Cleanup closed pull requests cache on: pull_request: types: - closed permissions: actions: write checks: read contents: read deployments: read issues: read discussions: read packages: read pages: read pull-requests: read repository-projects: read security-events: read statuses: read jobs: cleanup: runs-on: ubuntu-latest steps: - name: Cleanup run: | gh extension install actions/gh-actions-cache REPO=${{ github.repository }} BRANCH="refs/pull/${{ github.event.pull_request.number }}/merge" echo "Fetching list of cache key" cacheKeysForPR=$(gh actions-cache list -R $REPO -B $BRANCH | cut -f 1 ) ## Setting this to not fail the workflow while deleting cache keys. set +e echo "Deleting caches..." for cacheKey in $cacheKeysForPR do gh actions-cache delete $cacheKey -R $REPO -B $BRANCH --confirm done echo "Done" env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} ================================================ FILE: .github/workflows/stale-theme-pr-closer.yml ================================================ name: Close stale theme pull requests that have the 'invalid' label. on: # Temporary disabled due to paused themes addition. # See: https://github.com/anuraghazra/github-readme-stats/issues/3404 # schedule: # # ┌───────────── minute (0 - 59) # # │ ┌───────────── hour (0 - 23) # # │ │ ┌───────────── day of the month (1 - 31) # # │ │ │ ┌───────────── month (1 - 12 or JAN-DEC) # # │ │ │ │ ┌───────────── day of the week (0 - 6 or SUN-SAT) # # │ │ │ │ │ # # │ │ │ │ │ # # │ │ │ │ │ # # * * * * * # - cron: "0 0 */7 * *" workflow_dispatch: permissions: actions: read checks: read contents: read deployments: read issues: read discussions: read packages: read pages: read pull-requests: write repository-projects: read security-events: read statuses: read jobs: closeOldThemePrs: if: github.repository == 'anuraghazra/github-readme-stats' name: Close stale 'invalid' theme PRs runs-on: ubuntu-latest strategy: matrix: node-version: [22.x] steps: - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 - name: Setup Node uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0 with: node-version: ${{ matrix.node-version }} cache: npm - uses: bahmutov/npm-install@3e063b974f0d209807684aa23e534b3dde517fd9 # v1.11.2 with: useLockFile: false - run: npm run close-stale-theme-prs env: STALE_DAYS: 20 GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} ================================================ FILE: .github/workflows/test.yml ================================================ name: Test on: push: branches: - master pull_request: branches: - master permissions: read-all jobs: build: name: Perform tests runs-on: ubuntu-latest strategy: matrix: node-version: [22.x] steps: - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 - name: Setup Node uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0 with: node-version: ${{ matrix.node-version }} cache: npm - name: Install & Test run: | npm ci npm run test - name: Run ESLint run: | npm run lint - name: Run bench tests run: | npm run bench - name: Run Prettier run: | npm run format:check - name: Code Coverage uses: codecov/codecov-action@4fe8c5f003fae66aa5ebb77cfd3e7bfbbda0b6b0 # v3.1.5 ================================================ FILE: .github/workflows/theme-prs-closer.yml ================================================ name: Theme Pull Requests Closer on: - pull_request_target permissions: actions: read checks: read contents: read deployments: read issues: read discussions: read packages: read pages: read pull-requests: write repository-projects: read security-events: read statuses: read jobs: close-prs: if: github.repository == 'anuraghazra/github-readme-stats' runs-on: ubuntu-latest steps: - name: Check out the code uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 - name: Set up Git run: | git config user.name "github-actions[bot]" git config user.email "github-actions[bot]@users.noreply.github.com" - name: Close Pull Requests run: | comment_message="We are currently pausing addition of new themes. If this theme is exclusively for your personal use, then instead of adding it to our theme collection, you can use card [customization options](https://github.com/anuraghazra/github-readme-stats?tab=readme-ov-file#customization)." for pr_number in $(gh pr list -l "themes" -q is:open --json number -q ".[].number"); do gh pr close $pr_number -c "$comment_message" done env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} ================================================ FILE: .github/workflows/top-issues-dashboard.yml ================================================ name: Update top issues dashboard on: schedule: # ┌───────────── minute (0 - 59) # │ ┌───────────── hour (0 - 23) # │ │ ┌───────────── day of the month (1 - 31) # │ │ │ ┌───────────── month (1 - 12 or JAN-DEC) # │ │ │ │ ┌───────────── day of the week (0 - 6 or SUN-SAT) # │ │ │ │ │ # │ │ │ │ │ # │ │ │ │ │ # * * * * * - cron: "0 0 */3 * *" workflow_dispatch: permissions: actions: read checks: read contents: read deployments: read issues: write discussions: read packages: read pages: read pull-requests: write repository-projects: read security-events: read statuses: read jobs: showAndLabelTopIssues: if: github.repository == 'anuraghazra/github-readme-stats' name: Update top issues Dashboard. runs-on: ubuntu-latest steps: - name: Run top issues action uses: rickstaa/top-issues-action@7e8dda5d5ae3087670f9094b9724a9a091fc3ba1 # v1.3.101 env: github_token: ${{ secrets.GITHUB_TOKEN }} with: top_list_size: 10 filter: "1772" label: true dashboard: true dashboard_show_total_reactions: true top_issues: true top_bugs: true top_features: true top_pull_requests: true ================================================ FILE: .github/workflows/update-langs.yml ================================================ name: Update supported languages on: schedule: # ┌───────────── minute (0 - 59) # │ ┌───────────── hour (0 - 23) # │ │ ┌───────────── day of the month (1 - 31) # │ │ │ ┌───────────── month (1 - 12 or JAN-DEC) # │ │ │ │ ┌───────────── day of the week (0 - 6 or SUN-SAT) # │ │ │ │ │ # │ │ │ │ │ # │ │ │ │ │ # * * * * * - cron: "0 0 */30 * *" permissions: actions: read checks: read contents: write deployments: read issues: read discussions: read packages: read pages: read pull-requests: write repository-projects: read security-events: read statuses: read jobs: updateLanguages: if: github.repository == 'anuraghazra/github-readme-stats' name: Update supported languages runs-on: ubuntu-latest strategy: matrix: node-version: [22.x] steps: - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 - name: Setup Node uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0 with: node-version: ${{ matrix.node-version }} cache: npm - name: Install dependencies run: npm ci env: CI: true - name: Run update-languages-json.js script run: npm run generate-langs-json - name: Create Pull Request if upstream language file is changed uses: peter-evans/create-pull-request@98357b18bf14b5342f975ff684046ec3b2a07725 # v8.0.0 with: commit-message: "refactor: update languages JSON" branch: "update_langs/patch" delete-branch: true title: Update languages JSON body: "The [update-langs](https://github.com/anuraghazra/github-readme-stats/actions/workflows/update-langs.yaml) action found new/updated languages in the [upstream languages JSON file](https://raw.githubusercontent.com/github/linguist/master/lib/linguist/languages.yml)." labels: "ci, lang-card" ================================================ FILE: .gitignore ================================================ .vercel .env node_modules *.lock .idea/ coverage benchmarks vercel_token # IDE .vscode/* !.vscode/extensions.json !.vscode/settings.json *.code-workspace .vercel ================================================ FILE: .husky/.gitignore ================================================ _ ================================================ FILE: .husky/pre-commit ================================================ npm test npm run lint npx lint-staged ================================================ FILE: .nvmrc ================================================ 22 ================================================ FILE: .prettierignore ================================================ node_modules *.json *.md coverage .vercel ================================================ FILE: .prettierrc.json ================================================ { "trailingComma": "all", "useTabs": false, "endOfLine": "auto", "proseWrap": "always" } ================================================ FILE: .vercelignore ================================================ .devcontainer .github .husky .vscode benchmarks coverage scripts tests .env **/*.md **/*.svg .eslintrc.json .prettierignore .pretterrc.json codecov.yml ================================================ FILE: .vscode/extensions.json ================================================ { "recommendations": [ "yzhang.markdown-all-in-one", "esbenp.prettier-vscode", "dbaeumer.vscode-eslint", "ms-azuretools.vscode-containers", "github.vscode-github-actions" ] } ================================================ FILE: .vscode/settings.json ================================================ { "markdown.extension.toc.levels": "1..3", "editor.formatOnSave": true, "editor.defaultFormatter": "esbenp.prettier-vscode", "[javascript]": { "editor.tabSize": 2 } } ================================================ FILE: CODE_OF_CONDUCT.md ================================================ # Contributor Covenant Code of Conduct ## Our Pledge In the interest of fostering an open and welcoming environment, we as contributors and maintainers pledge to making participation in our project and our community a harassment-free experience for everyone, regardless of age, body size, disability, ethnicity, sex characteristics, gender identity and expression, level of experience, education, socio-economic status, nationality, personal appearance, race, religion, or sexual identity and orientation. ## Our Standards Examples of behavior that contributes to creating a positive environment include: * Using welcoming and inclusive language * Being respectful of differing viewpoints and experiences * Gracefully accepting constructive criticism * Focusing on what is best for the community * Showing empathy towards other community members Examples of unacceptable behavior by participants include: * The use of sexualized language or imagery and unwelcome sexual attention or advances * Trolling, insulting/derogatory comments, and personal or political attacks * Public or private harassment * Publishing others' private information, such as a physical or electronic address, without explicit permission * Other conduct which could reasonably be considered inappropriate in a professional setting ## Our Responsibilities Project maintainers are responsible for clarifying the standards of acceptable behavior and are expected to take appropriate and fair corrective action in response to any instances of unacceptable behavior. Project maintainers have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, or to ban temporarily or permanently any contributor for other behaviors that they deem inappropriate, threatening, offensive, or harmful. ## Scope This Code of Conduct applies both within project spaces and in public spaces when an individual is representing the project or its community. Examples of representing a project or community include using an official project e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event. Representation of a project may be further defined and clarified by project maintainers. ## Enforcement Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by contacting the project team at hazru.anurag@gmail.com. All complaints will be reviewed and investigated and will result in a response that is deemed necessary and appropriate to the circumstances. The project team is obligated to maintain confidentiality with regard to the reporter of an incident. Further details of specific enforcement policies may be posted separately. Project maintainers who do not follow or enforce the Code of Conduct in good faith may face temporary or permanent repercussions as determined by other members of the project's leadership. ## Attribution This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html [homepage]: https://www.contributor-covenant.org For answers to common questions about this code of conduct, see https://www.contributor-covenant.org/faq ================================================ FILE: CONTRIBUTING.md ================================================ # Contributing to [github-readme-stats](https://github.com/anuraghazra/github-readme-stats) We love your input! We want to make contributing to this project as easy and transparent as possible, whether it's: - Reporting [an issue](https://github.com/anuraghazra/github-readme-stats/issues/new?assignees=&labels=bug&template=bug_report.yml). - [Discussing](https://github.com/anuraghazra/github-readme-stats/discussions) the current state of the code. - Submitting [a fix](https://github.com/anuraghazra/github-readme-stats/compare). - Proposing [new features](https://github.com/anuraghazra/github-readme-stats/issues/new?assignees=&labels=enhancement&template=feature_request.yml). - Becoming a maintainer. ## All Changes Happen Through Pull Requests Pull requests are the best way to propose changes. We actively welcome your pull requests: 1. Fork the repo and create your branch from `master`. 2. If you've added code that should be tested, add some tests' examples. 3. If you've changed APIs, update the documentation. 4. Issue that pull request! ## Under the hood of github-readme-stats Interested in diving deeper into understanding how github-readme-stats works? [Bohdan](https://github.com/Bogdan-Lyashenko) wrote a fantastic in-depth post about it, check it out: **[Under the hood of github-readme-stats project](https://codecrumbs.io/library/github-readme-stats)** ## Local Development To run & test github-readme-stats, you need to follow a few simple steps:- _(make sure you already have a [Vercel](https://vercel.com/) account)_ 1. Install [Vercel CLI](https://vercel.com/download). 2. Fork the repository and clone the code to your local machine. 3. Run `npm install` in the repository root. 4. Run the command `vercel` in the root and follow the steps there. 5. Run the command `vercel dev` to start a development server at . 6. Create a `.env` file in the root and add the following line `NODE_ENV=development`, this will disable caching for local development. 7. The cards will then be available from this local endpoint (i.e. `http://localhost:3000/api?username=anuraghazra`). > [!NOTE] > You can debug the package code in [Vscode](https://code.visualstudio.com/) by using the [Node.js: Attach to process](https://code.visualstudio.com/docs/nodejs/nodejs-debugging#_setting-up-an-attach-configuration) debug option. You can also debug any tests using the [VSCode Jest extension](https://marketplace.visualstudio.com/items?itemName=Orta.vscode-jest). For more information, see https://github.com/jest-community/vscode-jest/issues/912. ## Themes Contribution We're currently paused addition of new themes to decrease maintenance efforts. All pull requests related to new themes will be closed. > [!NOTE] > If you are considering contributing your theme just because you are using it personally, then instead of adding it to our theme collection, you can use card [customization options](./readme.md#customization). ## Translations Contribution 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). 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. ## Any contributions you make will be under the MIT Software License 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. ## Report issues/bugs using GitHub's [issues](https://github.com/anuraghazra/github-readme-stats/issues) 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! ## Frequently Asked Questions (FAQs) **Q:** How to hide Jupyter Notebook? > **Ans:** &hide=jupyter%20notebook **Q:** I could not figure out how to deploy on my own Vercel instance > **Ans:** > > - docs: > - YT tutorial by codeSTACKr: **Q:** Language Card is incorrect > **Ans:** Please read all the related issues/comments before opening any issues regarding language card stats: > > - > > - **Q:** How to count private stats? > **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) ### Bug Reports **Great Bug Reports** tend to have: - A quick summary and/or background - Steps to reproduce - Be specific! - Share the snapshot, if possible. - GitHub Readme Stats' live link - What actually happens - What you expected would happen - Notes (possibly including why you think this might be happening or stuff you tried that didn't work) People _love_ thorough bug reports. I'm not even kidding. ### Feature Request **Great Feature Requests** tend to have: - A quick idea summary - What & why do you want to add the specific feature - Additional context like images, links to resources to implement the feature, etc. ================================================ FILE: LICENSE ================================================ MIT License Copyright (c) 2020 Anurag Hazra Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: SECURITY.md ================================================ # GitHub Readme Stats Security Policies and Procedures This document outlines security procedures and general policies for the GitHub Readme Stats project. - [Reporting a Vulnerability](#reporting-a-vulnerability) - [Disclosure Policy](#disclosure-policy) ## Reporting a Vulnerability The GitHub Readme Stats team and community take all security vulnerabilities seriously. Thank you for improving the security of our open source software. We appreciate your efforts and responsible disclosure and will make every effort to acknowledge your contributions. Report security vulnerabilities by emailing the GitHub Readme Stats team at: ``` hazru.anurag@gmail.com ``` The lead maintainer will acknowledge your email within 24 hours, and will send a more detailed response within 48 hours indicating the next steps in handling your report. After the initial reply to your report, the security team will endeavor to keep you informed of the progress towards a fix and full announcement, and may ask for additional information or guidance. Report security vulnerabilities in third-party modules to the person or team maintaining the module. ## Disclosure Policy When the security team receives a security bug report, they will assign it to a primary handler. This person will coordinate the fix and release process, involving the following steps: * Confirm the problem. * Audit code to find any potential similar problems. * Prepare fixes and release them as fast as possible. ================================================ FILE: api/gist.js ================================================ // @ts-check import { renderError } from "../src/common/render.js"; import { isLocaleAvailable } from "../src/translations.js"; import { renderGistCard } from "../src/cards/gist.js"; import { fetchGist } from "../src/fetchers/gist.js"; import { CACHE_TTL, resolveCacheSeconds, setCacheHeaders, setErrorCacheHeaders, } from "../src/common/cache.js"; import { guardAccess } from "../src/common/access.js"; import { MissingParamError, retrieveSecondaryMessage, } from "../src/common/error.js"; import { parseBoolean } from "../src/common/ops.js"; // @ts-ignore export default async (req, res) => { const { id, title_color, icon_color, text_color, bg_color, theme, cache_seconds, locale, border_radius, border_color, show_owner, hide_border, } = req.query; res.setHeader("Content-Type", "image/svg+xml"); const access = guardAccess({ res, id, type: "gist", colors: { title_color, text_color, bg_color, border_color, theme, }, }); if (!access.isPassed) { return access.result; } if (locale && !isLocaleAvailable(locale)) { return res.send( renderError({ message: "Something went wrong", secondaryMessage: "Language not found", renderOptions: { title_color, text_color, bg_color, border_color, theme, }, }), ); } try { const gistData = await fetchGist(id); const cacheSeconds = resolveCacheSeconds({ requested: parseInt(cache_seconds, 10), def: CACHE_TTL.GIST_CARD.DEFAULT, min: CACHE_TTL.GIST_CARD.MIN, max: CACHE_TTL.GIST_CARD.MAX, }); setCacheHeaders(res, cacheSeconds); return res.send( renderGistCard(gistData, { title_color, icon_color, text_color, bg_color, theme, border_radius, border_color, locale: locale ? locale.toLowerCase() : null, show_owner: parseBoolean(show_owner), hide_border: parseBoolean(hide_border), }), ); } catch (err) { setErrorCacheHeaders(res); if (err instanceof Error) { return res.send( renderError({ message: err.message, secondaryMessage: retrieveSecondaryMessage(err), renderOptions: { title_color, text_color, bg_color, border_color, theme, show_repo_link: !(err instanceof MissingParamError), }, }), ); } return res.send( renderError({ message: "An unknown error occurred", renderOptions: { title_color, text_color, bg_color, border_color, theme, }, }), ); } }; ================================================ FILE: api/index.js ================================================ // @ts-check import { renderStatsCard } from "../src/cards/stats.js"; import { guardAccess } from "../src/common/access.js"; import { CACHE_TTL, resolveCacheSeconds, setCacheHeaders, setErrorCacheHeaders, } from "../src/common/cache.js"; import { MissingParamError, retrieveSecondaryMessage, } from "../src/common/error.js"; import { parseArray, parseBoolean } from "../src/common/ops.js"; import { renderError } from "../src/common/render.js"; import { fetchStats } from "../src/fetchers/stats.js"; import { isLocaleAvailable } from "../src/translations.js"; // @ts-ignore export default async (req, res) => { const { username, hide, hide_title, hide_border, card_width, hide_rank, show_icons, include_all_commits, commits_year, line_height, title_color, ring_color, icon_color, text_color, text_bold, bg_color, theme, cache_seconds, exclude_repo, custom_title, locale, disable_animations, border_radius, number_format, number_precision, border_color, rank_icon, show, } = req.query; res.setHeader("Content-Type", "image/svg+xml"); const access = guardAccess({ res, id: username, type: "username", colors: { title_color, text_color, bg_color, border_color, theme, }, }); if (!access.isPassed) { return access.result; } if (locale && !isLocaleAvailable(locale)) { return res.send( renderError({ message: "Something went wrong", secondaryMessage: "Language not found", renderOptions: { title_color, text_color, bg_color, border_color, theme, }, }), ); } try { const showStats = parseArray(show); const stats = await fetchStats( username, parseBoolean(include_all_commits), parseArray(exclude_repo), showStats.includes("prs_merged") || showStats.includes("prs_merged_percentage"), showStats.includes("discussions_started"), showStats.includes("discussions_answered"), parseInt(commits_year, 10), ); const cacheSeconds = resolveCacheSeconds({ requested: parseInt(cache_seconds, 10), def: CACHE_TTL.STATS_CARD.DEFAULT, min: CACHE_TTL.STATS_CARD.MIN, max: CACHE_TTL.STATS_CARD.MAX, }); setCacheHeaders(res, cacheSeconds); return res.send( renderStatsCard(stats, { hide: parseArray(hide), show_icons: parseBoolean(show_icons), hide_title: parseBoolean(hide_title), hide_border: parseBoolean(hide_border), card_width: parseInt(card_width, 10), hide_rank: parseBoolean(hide_rank), include_all_commits: parseBoolean(include_all_commits), commits_year: parseInt(commits_year, 10), line_height, title_color, ring_color, icon_color, text_color, text_bold: parseBoolean(text_bold), bg_color, theme, custom_title, border_radius, border_color, number_format, number_precision: parseInt(number_precision, 10), locale: locale ? locale.toLowerCase() : null, disable_animations: parseBoolean(disable_animations), rank_icon, show: showStats, }), ); } catch (err) { setErrorCacheHeaders(res); if (err instanceof Error) { return res.send( renderError({ message: err.message, secondaryMessage: retrieveSecondaryMessage(err), renderOptions: { title_color, text_color, bg_color, border_color, theme, show_repo_link: !(err instanceof MissingParamError), }, }), ); } return res.send( renderError({ message: "An unknown error occurred", renderOptions: { title_color, text_color, bg_color, border_color, theme, }, }), ); } }; ================================================ FILE: api/pin.js ================================================ // @ts-check import { renderRepoCard } from "../src/cards/repo.js"; import { guardAccess } from "../src/common/access.js"; import { CACHE_TTL, resolveCacheSeconds, setCacheHeaders, setErrorCacheHeaders, } from "../src/common/cache.js"; import { MissingParamError, retrieveSecondaryMessage, } from "../src/common/error.js"; import { parseBoolean } from "../src/common/ops.js"; import { renderError } from "../src/common/render.js"; import { fetchRepo } from "../src/fetchers/repo.js"; import { isLocaleAvailable } from "../src/translations.js"; // @ts-ignore export default async (req, res) => { const { username, repo, hide_border, title_color, icon_color, text_color, bg_color, theme, show_owner, cache_seconds, locale, border_radius, border_color, description_lines_count, } = req.query; res.setHeader("Content-Type", "image/svg+xml"); const access = guardAccess({ res, id: username, type: "username", colors: { title_color, text_color, bg_color, border_color, theme, }, }); if (!access.isPassed) { return access.result; } if (locale && !isLocaleAvailable(locale)) { return res.send( renderError({ message: "Something went wrong", secondaryMessage: "Language not found", renderOptions: { title_color, text_color, bg_color, border_color, theme, }, }), ); } try { const repoData = await fetchRepo(username, repo); const cacheSeconds = resolveCacheSeconds({ requested: parseInt(cache_seconds, 10), def: CACHE_TTL.PIN_CARD.DEFAULT, min: CACHE_TTL.PIN_CARD.MIN, max: CACHE_TTL.PIN_CARD.MAX, }); setCacheHeaders(res, cacheSeconds); return res.send( renderRepoCard(repoData, { hide_border: parseBoolean(hide_border), title_color, icon_color, text_color, bg_color, theme, border_radius, border_color, show_owner: parseBoolean(show_owner), locale: locale ? locale.toLowerCase() : null, description_lines_count, }), ); } catch (err) { setErrorCacheHeaders(res); if (err instanceof Error) { return res.send( renderError({ message: err.message, secondaryMessage: retrieveSecondaryMessage(err), renderOptions: { title_color, text_color, bg_color, border_color, theme, show_repo_link: !(err instanceof MissingParamError), }, }), ); } return res.send( renderError({ message: "An unknown error occurred", renderOptions: { title_color, text_color, bg_color, border_color, theme, }, }), ); } }; ================================================ FILE: api/status/pat-info.js ================================================ // @ts-check /** * @file Contains a simple cloud function that can be used to check which PATs are no * longer working. It returns a list of valid PATs, expired PATs and PATs with errors. * * @description This function is currently rate limited to 1 request per 5 minutes. */ import { request } from "../../src/common/http.js"; import { logger } from "../../src/common/log.js"; import { dateDiff } from "../../src/common/ops.js"; export const RATE_LIMIT_SECONDS = 60 * 5; // 1 request per 5 minutes /** * Simple uptime check fetcher for the PATs. * * @param {any} variables Fetcher variables. * @param {string} token GitHub token. * @returns {Promise} The response. */ const uptimeFetcher = (variables, token) => { return request( { query: ` query { rateLimit { remaining resetAt }, }`, variables, }, { Authorization: `bearer ${token}`, }, ); }; const getAllPATs = () => { return Object.keys(process.env).filter((key) => /PAT_\d*$/.exec(key)); }; /** * @typedef {(variables: any, token: string) => Promise} Fetcher The fetcher function. * @typedef {{validPATs: string[], expiredPATs: string[], exhaustedPATs: string[], suspendedPATs: string[], errorPATs: string[], details: any}} PATInfo The PAT info. */ /** * Check whether any of the PATs is expired. * * @param {Fetcher} fetcher The fetcher function. * @param {any} variables Fetcher variables. * @returns {Promise} The response. */ const getPATInfo = async (fetcher, variables) => { /** @type {Record} */ const details = {}; const PATs = getAllPATs(); for (const pat of PATs) { try { const response = await fetcher(variables, process.env[pat]); const errors = response.data.errors; const hasErrors = Boolean(errors); const errorType = errors?.[0]?.type; const isRateLimited = (hasErrors && errorType === "RATE_LIMITED") || response.data.data?.rateLimit?.remaining === 0; // Store PATs with errors. if (hasErrors && errorType !== "RATE_LIMITED") { details[pat] = { status: "error", error: { type: errors[0].type, message: errors[0].message, }, }; continue; } else if (isRateLimited) { const date1 = new Date(); const date2 = new Date(response.data?.data?.rateLimit?.resetAt); details[pat] = { status: "exhausted", remaining: 0, resetIn: dateDiff(date2, date1) + " minutes", }; } else { details[pat] = { status: "valid", remaining: response.data.data.rateLimit.remaining, }; } } catch (err) { // Store the PAT if it is expired. const errorMessage = err.response?.data?.message?.toLowerCase(); if (errorMessage === "bad credentials") { details[pat] = { status: "expired", }; } else if (errorMessage === "sorry. your account was suspended.") { details[pat] = { status: "suspended", }; } else { throw err; } } } const filterPATsByStatus = (status) => { return Object.keys(details).filter((pat) => details[pat].status === status); }; const sortedDetails = Object.keys(details) .sort() .reduce((obj, key) => { obj[key] = details[key]; return obj; }, {}); return { validPATs: filterPATsByStatus("valid"), expiredPATs: filterPATsByStatus("expired"), exhaustedPATs: filterPATsByStatus("exhausted"), suspendedPATs: filterPATsByStatus("suspended"), errorPATs: filterPATsByStatus("error"), details: sortedDetails, }; }; /** * Cloud function that returns information about the used PATs. * * @param {any} _ The request. * @param {any} res The response. * @returns {Promise} The response. */ export default async (_, res) => { res.setHeader("Content-Type", "application/json"); try { // Add header to prevent abuse. const PATsInfo = await getPATInfo(uptimeFetcher, {}); if (PATsInfo) { res.setHeader( "Cache-Control", `max-age=0, s-maxage=${RATE_LIMIT_SECONDS}`, ); } res.send(JSON.stringify(PATsInfo, null, 2)); } catch (err) { // Throw error if something went wrong. logger.error(err); res.setHeader("Cache-Control", "no-store"); res.send("Something went wrong: " + err.message); } }; ================================================ FILE: api/status/up.js ================================================ // @ts-check /** * @file Contains a simple cloud function that can be used to check if the PATs are still * functional. * * @description This function is currently rate limited to 1 request per 5 minutes. */ import { request } from "../../src/common/http.js"; import retryer from "../../src/common/retryer.js"; import { logger } from "../../src/common/log.js"; export const RATE_LIMIT_SECONDS = 60 * 5; // 1 request per 5 minutes /** * Simple uptime check fetcher for the PATs. * * @param {any} variables Fetcher variables. * @param {string} token GitHub token. * @returns {Promise} The response. */ const uptimeFetcher = (variables, token) => { return request( { query: ` query { rateLimit { remaining } } `, variables, }, { Authorization: `bearer ${token}`, }, ); }; /** * @typedef {{ * schemaVersion: number; * label: string; * message: "up" | "down"; * color: "brightgreen" | "red"; * isError: boolean * }} ShieldsResponse Shields.io response object. */ /** * Creates Json response that can be used for shields.io dynamic card generation. * * @param {boolean} up Whether the PATs are up or not. * @returns {ShieldsResponse} Dynamic shields.io JSON response object. * * @see https://shields.io/endpoint. */ const shieldsUptimeBadge = (up) => { const schemaVersion = 1; const isError = true; const label = "Public Instance"; const message = up ? "up" : "down"; const color = up ? "brightgreen" : "red"; return { schemaVersion, label, message, color, isError, }; }; /** * Cloud function that returns whether the PATs are still functional. * * @param {any} req The request. * @param {any} res The response. * @returns {Promise} Nothing. */ export default async (req, res) => { let { type } = req.query; type = type ? type.toLowerCase() : "boolean"; res.setHeader("Content-Type", "application/json"); try { let PATsValid = true; try { await retryer(uptimeFetcher, {}); } catch (err) { // Resolve eslint no-unused-vars err; PATsValid = false; } if (PATsValid) { res.setHeader( "Cache-Control", `max-age=0, s-maxage=${RATE_LIMIT_SECONDS}`, ); } else { res.setHeader("Cache-Control", "no-store"); } switch (type) { case "shields": res.send(shieldsUptimeBadge(PATsValid)); break; case "json": res.send({ up: PATsValid }); break; default: res.send(PATsValid); break; } } catch (err) { // Return fail boolean if something went wrong. logger.error(err); res.setHeader("Cache-Control", "no-store"); res.send("Something went wrong: " + err.message); } }; ================================================ FILE: api/top-langs.js ================================================ // @ts-check import { renderTopLanguages } from "../src/cards/top-languages.js"; import { guardAccess } from "../src/common/access.js"; import { CACHE_TTL, resolveCacheSeconds, setCacheHeaders, setErrorCacheHeaders, } from "../src/common/cache.js"; import { MissingParamError, retrieveSecondaryMessage, } from "../src/common/error.js"; import { parseArray, parseBoolean } from "../src/common/ops.js"; import { renderError } from "../src/common/render.js"; import { fetchTopLanguages } from "../src/fetchers/top-languages.js"; import { isLocaleAvailable } from "../src/translations.js"; // @ts-ignore export default async (req, res) => { const { username, hide, hide_title, hide_border, card_width, title_color, text_color, bg_color, theme, cache_seconds, layout, langs_count, exclude_repo, size_weight, count_weight, custom_title, locale, border_radius, border_color, disable_animations, hide_progress, stats_format, } = req.query; res.setHeader("Content-Type", "image/svg+xml"); const access = guardAccess({ res, id: username, type: "username", colors: { title_color, text_color, bg_color, border_color, theme, }, }); if (!access.isPassed) { return access.result; } if (locale && !isLocaleAvailable(locale)) { return res.send( renderError({ message: "Something went wrong", secondaryMessage: "Locale not found", renderOptions: { title_color, text_color, bg_color, border_color, theme, }, }), ); } if ( layout !== undefined && (typeof layout !== "string" || !["compact", "normal", "donut", "donut-vertical", "pie"].includes(layout)) ) { return res.send( renderError({ message: "Something went wrong", secondaryMessage: "Incorrect layout input", renderOptions: { title_color, text_color, bg_color, border_color, theme, }, }), ); } if ( stats_format !== undefined && (typeof stats_format !== "string" || !["bytes", "percentages"].includes(stats_format)) ) { return res.send( renderError({ message: "Something went wrong", secondaryMessage: "Incorrect stats_format input", renderOptions: { title_color, text_color, bg_color, border_color, theme, }, }), ); } try { const topLangs = await fetchTopLanguages( username, parseArray(exclude_repo), size_weight, count_weight, ); const cacheSeconds = resolveCacheSeconds({ requested: parseInt(cache_seconds, 10), def: CACHE_TTL.TOP_LANGS_CARD.DEFAULT, min: CACHE_TTL.TOP_LANGS_CARD.MIN, max: CACHE_TTL.TOP_LANGS_CARD.MAX, }); setCacheHeaders(res, cacheSeconds); return res.send( renderTopLanguages(topLangs, { custom_title, hide_title: parseBoolean(hide_title), hide_border: parseBoolean(hide_border), card_width: parseInt(card_width, 10), hide: parseArray(hide), title_color, text_color, bg_color, theme, layout, langs_count, border_radius, border_color, locale: locale ? locale.toLowerCase() : null, disable_animations: parseBoolean(disable_animations), hide_progress: parseBoolean(hide_progress), stats_format, }), ); } catch (err) { setErrorCacheHeaders(res); if (err instanceof Error) { return res.send( renderError({ message: err.message, secondaryMessage: retrieveSecondaryMessage(err), renderOptions: { title_color, text_color, bg_color, border_color, theme, show_repo_link: !(err instanceof MissingParamError), }, }), ); } return res.send( renderError({ message: "An unknown error occurred", renderOptions: { title_color, text_color, bg_color, border_color, theme, }, }), ); } }; ================================================ FILE: api/wakatime.js ================================================ // @ts-check import { renderWakatimeCard } from "../src/cards/wakatime.js"; import { renderError } from "../src/common/render.js"; import { fetchWakatimeStats } from "../src/fetchers/wakatime.js"; import { isLocaleAvailable } from "../src/translations.js"; import { CACHE_TTL, resolveCacheSeconds, setCacheHeaders, setErrorCacheHeaders, } from "../src/common/cache.js"; import { guardAccess } from "../src/common/access.js"; import { MissingParamError, retrieveSecondaryMessage, } from "../src/common/error.js"; import { parseArray, parseBoolean } from "../src/common/ops.js"; // @ts-ignore export default async (req, res) => { const { username, title_color, icon_color, hide_border, card_width, line_height, text_color, bg_color, theme, cache_seconds, hide_title, hide_progress, custom_title, locale, layout, langs_count, hide, api_domain, border_radius, border_color, display_format, disable_animations, } = req.query; res.setHeader("Content-Type", "image/svg+xml"); const access = guardAccess({ res, id: username, type: "wakatime", colors: { title_color, text_color, bg_color, border_color, theme, }, }); if (!access.isPassed) { return access.result; } if (locale && !isLocaleAvailable(locale)) { return res.send( renderError({ message: "Something went wrong", secondaryMessage: "Language not found", renderOptions: { title_color, text_color, bg_color, border_color, theme, }, }), ); } try { const stats = await fetchWakatimeStats({ username, api_domain }); const cacheSeconds = resolveCacheSeconds({ requested: parseInt(cache_seconds, 10), def: CACHE_TTL.WAKATIME_CARD.DEFAULT, min: CACHE_TTL.WAKATIME_CARD.MIN, max: CACHE_TTL.WAKATIME_CARD.MAX, }); setCacheHeaders(res, cacheSeconds); return res.send( renderWakatimeCard(stats, { custom_title, hide_title: parseBoolean(hide_title), hide_border: parseBoolean(hide_border), card_width: parseInt(card_width, 10), hide: parseArray(hide), line_height, title_color, icon_color, text_color, bg_color, theme, hide_progress, border_radius, border_color, locale: locale ? locale.toLowerCase() : null, layout, langs_count, display_format, disable_animations: parseBoolean(disable_animations), }), ); } catch (err) { setErrorCacheHeaders(res); if (err instanceof Error) { return res.send( renderError({ message: err.message, secondaryMessage: retrieveSecondaryMessage(err), renderOptions: { title_color, text_color, bg_color, border_color, theme, show_repo_link: !(err instanceof MissingParamError), }, }), ); } return res.send( renderError({ message: "An unknown error occurred", renderOptions: { title_color, text_color, bg_color, border_color, theme, }, }), ); } }; ================================================ FILE: codecov.yml ================================================ codecov: require_ci_to_pass: yes coverage: precision: 2 round: down range: "70...100" status: project: default: threshold: 5 patch: false ================================================ FILE: eslint.config.mjs ================================================ import globals from "globals"; import path from "node:path"; import { fileURLToPath } from "node:url"; import js from "@eslint/js"; import { FlatCompat } from "@eslint/eslintrc"; import jsdoc from "eslint-plugin-jsdoc"; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); const compat = new FlatCompat({ baseDirectory: __dirname, recommendedConfig: js.configs.recommended, allConfig: js.configs.all, }); export default [ ...compat.extends("prettier"), { languageOptions: { globals: { ...globals.node, ...globals.browser, }, ecmaVersion: 2022, sourceType: "module", }, plugins: { jsdoc, }, rules: { "no-unexpected-multiline": "error", "accessor-pairs": [ "error", { getWithoutSet: false, setWithoutGet: true, }, ], "block-scoped-var": "warn", "consistent-return": "error", curly: "error", "no-alert": "error", "no-caller": "error", "no-warning-comments": [ "warn", { terms: ["TODO", "FIXME"], location: "start", }, ], "no-with": "warn", radix: "warn", "no-delete-var": "error", "no-undef-init": "off", "no-undef": "error", "no-undefined": "off", "no-unused-vars": "warn", "no-use-before-define": "error", "constructor-super": "error", "no-class-assign": "error", "no-const-assign": "error", "no-dupe-class-members": "error", "no-this-before-super": "error", "object-shorthand": ["warn"], "no-mixed-spaces-and-tabs": "warn", "no-multiple-empty-lines": "warn", "no-negated-condition": "warn", "no-unneeded-ternary": "warn", "keyword-spacing": [ "error", { before: true, after: true, }, ], "jsdoc/require-returns": "warn", "jsdoc/require-returns-description": "warn", "jsdoc/require-param-description": "warn", "jsdoc/require-jsdoc": "warn", }, }, ]; ================================================ FILE: express.js ================================================ import "dotenv/config"; import statsCard from "./api/index.js"; import repoCard from "./api/pin.js"; import langCard from "./api/top-langs.js"; import wakatimeCard from "./api/wakatime.js"; import gistCard from "./api/gist.js"; import express from "express"; const app = express(); const router = express.Router(); router.get("/", statsCard); router.get("/pin", repoCard); router.get("/top-langs", langCard); router.get("/wakatime", wakatimeCard); router.get("/gist", gistCard); app.use("/api", router); const port = process.env.PORT || process.env.port || 9000; app.listen(port, "0.0.0.0", () => { console.log(`Server running on port ${port}`); }); ================================================ FILE: jest.bench.config.js ================================================ export default { clearMocks: true, transform: {}, testEnvironment: "jsdom", coverageProvider: "v8", testPathIgnorePatterns: ["/node_modules/", "/tests/e2e/"], modulePathIgnorePatterns: ["/node_modules/", "/tests/e2e/"], coveragePathIgnorePatterns: [ "/node_modules/", "/tests/e2e/", ], testRegex: "(\\.bench)\\.(ts|tsx|js)$", }; ================================================ FILE: jest.config.js ================================================ export default { clearMocks: true, transform: {}, testEnvironment: "jsdom", coverageProvider: "v8", testPathIgnorePatterns: ["/node_modules/", "/tests/e2e/"], modulePathIgnorePatterns: ["/node_modules/", "/tests/e2e/"], coveragePathIgnorePatterns: [ "/node_modules/", "/tests/E2E/", ], }; ================================================ FILE: jest.e2e.config.js ================================================ export default { clearMocks: true, transform: {}, testEnvironment: "node", coverageProvider: "v8", testMatch: ["/tests/e2e/**/*.test.js"], }; ================================================ FILE: package.json ================================================ { "name": "github-readme-stats", "version": "1.0.0", "description": "Dynamically generate stats for your GitHub readme", "keywords": [ "github-readme-stats", "readme-stats", "cards", "card-generator" ], "main": "src/index.js", "type": "module", "homepage": "https://github.com/anuraghazra/github-readme-stats", "bugs": { "url": "https://github.com/anuraghazra/github-readme-stats/issues" }, "repository": { "type": "git", "url": "https://github.com/anuraghazra/github-readme-stats.git" }, "scripts": { "test": "node --experimental-vm-modules node_modules/jest/bin/jest.js --coverage", "test:watch": "node --experimental-vm-modules node_modules/jest/bin/jest.js --watch", "test:update:snapshot": "node --experimental-vm-modules node_modules/jest/bin/jest.js -u", "test:e2e": "node --experimental-vm-modules node_modules/jest/bin/jest.js --config jest.e2e.config.js", "theme-readme-gen": "node scripts/generate-theme-doc", "preview-theme": "node scripts/preview-theme", "close-stale-theme-prs": "node scripts/close-stale-theme-prs", "generate-langs-json": "node scripts/generate-langs-json", "format": "prettier --write .", "format:check": "prettier --check .", "prepare": "husky", "lint": "npx eslint --max-warnings 0 \"./src/**/*.js\" \"./scripts/**/*.js\" \"./tests/**/*.js\" \"./api/**/*.js\" \"./themes/**/*.js\"", "bench": "node --experimental-vm-modules node_modules/jest/bin/jest.js --config jest.bench.config.js" }, "author": "Anurag Hazra", "license": "MIT", "devDependencies": { "@actions/core": "^2.0.1", "@actions/github": "^6.0.1", "@eslint/eslintrc": "^3.3.3", "@eslint/js": "^9.39.2", "@testing-library/dom": "^10.4.1", "@testing-library/jest-dom": "^6.9.1", "@uppercod/css-to-object": "^1.1.1", "axios-mock-adapter": "^2.1.0", "color-contrast-checker": "^2.1.0", "eslint": "^9.39.2", "eslint-config-prettier": "^10.1.8", "eslint-plugin-jsdoc": "^61.5.0", "express": "^5.2.1", "globals": "^16.5.0", "hjson": "^3.2.2", "husky": "^9.1.7", "jest": "^30.2.0", "jest-environment-jsdom": "^30.2.0", "js-yaml": "^4.1.1", "lint-staged": "^16.2.7", "lodash.snakecase": "^4.1.1", "parse-diff": "^0.11.1", "prettier": "^3.7.3" }, "dependencies": { "axios": "^1.13.1", "dotenv": "^17.2.3", "emoji-name-map": "^2.0.3", "github-username-regex": "^1.0.0", "word-wrap": "^1.2.5" }, "lint-staged": { "*.{js,css,md}": "prettier --write" }, "engines": { "node": ">=22" } } ================================================ FILE: readme.md ================================================
GitHub Readme Stats

GitHub Readme Stats

Get dynamically generated GitHub stats on your READMEs!

Tests Passing GitHub Contributors Tests Coverage Issues GitHub pull requests OpenSSF Scorecard

View Demo · Report Bug · Request Feature · FAQ · Ask Question

Love the project? Please consider donating to help it improve!

Table of contents (Click to show) - [GitHub Stats Card](#github-stats-card) - [Hiding individual stats](#hiding-individual-stats) - [Showing additional individual stats](#showing-additional-individual-stats) - [Showing icons](#showing-icons) - [Showing commits count for specified year](#showing-commits-count-for-specified-year) - [Themes](#themes) - [Customization](#customization) - [GitHub Extra Pins](#github-extra-pins) - [Usage](#usage) - [Options](#options) - [Demo](#demo) - [GitHub Gist Pins](#github-gist-pins) - [Usage](#usage-1) - [Options](#options-1) - [Demo](#demo-1) - [Top Languages Card](#top-languages-card) - [Usage](#usage-2) - [Options](#options-2) - [Language stats algorithm](#language-stats-algorithm) - [Exclude individual repositories](#exclude-individual-repositories) - [Hide individual languages](#hide-individual-languages) - [Show more languages](#show-more-languages) - [Compact Language Card Layout](#compact-language-card-layout) - [Donut Chart Language Card Layout](#donut-chart-language-card-layout) - [Donut Vertical Chart Language Card Layout](#donut-vertical-chart-language-card-layout) - [Pie Chart Language Card Layout](#pie-chart-language-card-layout) - [Hide Progress Bars](#hide-progress-bars) - [Change format of language's stats](#change-format-of-languages-stats) - [Demo](#demo-2) - [WakaTime Stats Card](#wakatime-stats-card) - [Options](#options-3) - [Demo](#demo-3) - [All Demos](#all-demos) - [Quick Tip (Align The Cards)](#quick-tip-align-the-cards) - [Stats and top languages cards](#stats-and-top-languages-cards) - [Pinning repositories](#pinning-repositories) - [Deploy on your own](#deploy-on-your-own) - [GitHub Actions (Recommended)](#github-actions-recommended) - [Self-hosted (Vercel/Other) (Recommended)](#self-hosted-vercelother-recommended) - [First step: get your Personal Access Token (PAT)](#first-step-get-your-personal-access-token-pat) - [On Vercel](#on-vercel) - [:film\_projector: Check Out Step By Step Video Tutorial By @codeSTACKr](#film_projector-check-out-step-by-step-video-tutorial-by-codestackr) - [On other platforms](#on-other-platforms) - [Available environment variables](#available-environment-variables) - [Keep your fork up to date](#keep-your-fork-up-to-date) - [:sparkling\_heart: Support the project](#sparkling_heart-support-the-project)
# Important Notices > [!IMPORTANT] > The public Vercel instance at `https://github-readme-stats.vercel.app/api` is best-effort and can be unreliable due to rate limits and traffic spikes (see [#1471](https://github.com/anuraghazra/github-readme-stats/issues/1471)). We use caching to improve stability (see [common options](#common-options)), but for reliable cards we recommend [self-hosting](#deploy-on-your-own) (Vercel or other) or using the [GitHub Actions workflow](#github-actions-recommended) to generate cards in your [profile repository](https://docs.github.com/en/account-and-profile/how-tos/profile-customization/managing-your-profile-readme). Uptime Badge > [!IMPORTANT] > We're a small team, and to prioritize, we rely on upvotes :+1:. We use the Top Issues dashboard for tracking community demand (see [#1935](https://github.com/anuraghazra/github-readme-stats/issues/1935)). Do not hesitate to upvote the issues and pull requests you are interested in. We will work on the most upvoted first. # GitHub Stats Card Copy and paste this into your markdown, and that's it. Simple! Change the `?username=` value to your GitHub username. ```md [![Anurag's GitHub stats](https://github-readme-stats.vercel.app/api?username=anuraghazra)](https://github.com/anuraghazra/github-readme-stats) ``` > [!WARNING] > By default, the stats card only shows statistics like stars, commits, and pull requests from public repositories. To show private statistics on the stats card, you should [deploy your own instance](#deploy-on-your-own) using your own GitHub API token. > [!NOTE] > Available ranks are S (top 1%), A+ (12.5%), A (25%), A- (37.5%), B+ (50%), B (62.5%), B- (75%), C+ (87.5%) and C (everyone). This ranking scheme is based on the [Japanese academic grading](https://wikipedia.org/wiki/Academic_grading_in_Japan) system. The global percentile is calculated as a weighted sum of percentiles for each statistic (number of commits, pull requests, reviews, issues, stars, and followers), based on the cumulative distribution function of the [exponential](https://wikipedia.org/wiki/exponential_distribution) and the [log-normal](https://wikipedia.org/wiki/Log-normal_distribution) distributions. The implementation can be investigated at [src/calculateRank.js](https://github.com/anuraghazra/github-readme-stats/blob/master/src/calculateRank.js). The circle around the rank shows 100 minus the global percentile. ### Hiding individual stats You can pass a query parameter `&hide=` to hide any specific stats with comma-separated values. > Options: `&hide=stars,commits,prs,issues,contribs` ```md ![Anurag's GitHub stats](https://github-readme-stats.vercel.app/api?username=anuraghazra&hide=contribs,prs) ``` ### Showing additional individual stats You can pass a query parameter `&show=` to show any specific additional stats with comma-separated values. > Options: `&show=reviews,discussions_started,discussions_answered,prs_merged,prs_merged_percentage` ```md ![Anurag's GitHub stats](https://github-readme-stats.vercel.app/api?username=anuraghazra&show=reviews,discussions_started,discussions_answered,prs_merged,prs_merged_percentage) ``` ### Showing icons To enable icons, you can pass `&show_icons=true` in the query param, like so: ```md ![Anurag's GitHub stats](https://github-readme-stats.vercel.app/api?username=anuraghazra&show_icons=true) ``` ### Showing commits count for specified year You can specify a year and fetch only the commits that were made in that year by passing `&commits_year=YYYY` to the parameter. ```md ![Anurag's GitHub stats](https://github-readme-stats.vercel.app/api?username=anuraghazra&commits_year=2020) ``` ### Themes With inbuilt themes, you can customize the look of the card without doing any [manual customization](#customization). Use `&theme=THEME_NAME` parameter like so : ```md ![Anurag's GitHub stats](https://github-readme-stats.vercel.app/api?username=anuraghazra&show_icons=true&theme=radical) ``` #### All inbuilt themes GitHub Readme Stats comes with several built-in themes (e.g. `dark`, `radical`, `merko`, `gruvbox`, `tokyonight`, `onedark`, `cobalt`, `synthwave`, `highcontrast`, `dracula`). GitHub Readme Stats Themes You can look at a preview for [all available themes](themes/README.md) or checkout the [theme config file](themes/index.js). Please note that we paused the addition of new themes to decrease maintenance efforts; all pull requests related to new themes will be closed. #### Responsive Card Theme [![Anurag's GitHub stats-Dark](https://github-readme-stats.vercel.app/api?username=anuraghazra\&show_icons=true\&theme=dark#gh-dark-mode-only)](https://github.com/anuraghazra/github-readme-stats#responsive-card-theme#gh-dark-mode-only) [![Anurag's GitHub stats-Light](https://github-readme-stats.vercel.app/api?username=anuraghazra\&show_icons=true\&theme=default#gh-light-mode-only)](https://github.com/anuraghazra/github-readme-stats#responsive-card-theme#gh-light-mode-only) Since GitHub will re-upload the cards and serve them from their [CDN](https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/about-anonymized-urls), we can not infer the browser/GitHub theme on the server side. There are, however, four methods you can use to create dynamics themes on the client side. ##### Use the transparent theme We have included a `transparent` theme that has a transparent background. This theme is optimized to look good on GitHub's dark and light default themes. You can enable this theme using the `&theme=transparent` parameter like so: ```md ![Anurag's GitHub stats](https://github-readme-stats.vercel.app/api?username=anuraghazra&show_icons=true&theme=transparent) ```
:eyes: Show example ![Anurag's GitHub stats](https://github-readme-stats.vercel.app/api?username=anuraghazra\&show_icons=true\&theme=transparent)
##### Add transparent alpha channel to a themes bg\_color You can use the `bg_color` parameter to make any of [the available themes](themes/README.md) transparent. This is done by setting the `bg_color` to a color with a transparent alpha channel (i.e. `bg_color=00000000`): ```md ![Anurag's GitHub stats](https://github-readme-stats.vercel.app/api?username=anuraghazra&show_icons=true&bg_color=00000000) ```
:eyes: Show example ![Anurag's GitHub stats](https://github-readme-stats.vercel.app/api?username=anuraghazra\&show_icons=true\&bg_color=00000000)
##### Use GitHub's theme context tag You can use [GitHub's theme context](https://github.blog/changelog/2021-11-24-specify-theme-context-for-images-in-markdown/) tags to switch the theme based on the user GitHub theme automatically. This is done by appending `#gh-dark-mode-only` or `#gh-light-mode-only` to the end of an image URL. This tag will define whether the image specified in the markdown is only shown to viewers using a light or a dark GitHub theme: ```md [![Anurag's GitHub stats-Dark](https://github-readme-stats.vercel.app/api?username=anuraghazra&show_icons=true&theme=dark#gh-dark-mode-only)](https://github.com/anuraghazra/github-readme-stats#gh-dark-mode-only) [![Anurag's GitHub stats-Light](https://github-readme-stats.vercel.app/api?username=anuraghazra&show_icons=true&theme=default#gh-light-mode-only)](https://github.com/anuraghazra/github-readme-stats#gh-light-mode-only) ```
:eyes: Show example [![Anurag's GitHub stats-Dark](https://github-readme-stats.vercel.app/api?username=anuraghazra\&show_icons=true\&theme=dark#gh-dark-mode-only)](https://github.com/anuraghazra/github-readme-stats#gh-dark-mode-only) [![Anurag's GitHub stats-Light](https://github-readme-stats.vercel.app/api?username=anuraghazra\&show_icons=true\&theme=default#gh-light-mode-only)](https://github.com/anuraghazra/github-readme-stats#gh-light-mode-only)
##### Use GitHub's new media feature You can use [GitHub's new media feature](https://github.blog/changelog/2022-05-19-specify-theme-context-for-images-in-markdown-beta/) in HTML to specify whether to display images for light or dark themes. This is done using the HTML `` element in combination with the `prefers-color-scheme` media feature. ```html ```
:eyes: Show example
### Customization You can customize the appearance of all your cards however you wish with URL parameters. #### Common Options | Name | Description | Type | Default value | | --- | --- | --- | --- | | `title_color` | Card's title color. | string (hex color) | `2f80ed` | | `text_color` | Body text color. | string (hex color) | `434d58` | | `icon_color` | Icons color if available. | string (hex color) | `4c71f2` | | `border_color` | Card's border color. Does not apply when `hide_border` is enabled. | string (hex color) | `e4e2e2` | | `bg_color` | Card's background color. | string (hex color or a gradient in the form of *angle,start,end*) | `fffefe` | | `hide_border` | Hides the card's border. | boolean | `false` | | `theme` | Name of the theme, choose from [all available themes](themes/README.md). | enum | `default` | | `cache_seconds` | Sets the cache header manually (min: 21600, max: 86400). | integer | `21600` | | `locale` | Sets the language in the card, you can check full list of available locales [here](#available-locales). | enum | `en` | | `border_radius` | Corner rounding on the card. | number | `4.5` | > [!WARNING] > We use caching to decrease the load on our servers (see ). Our cards have the following default cache hours: stats card - 24 hours, top languages card - 144 hours (6 days), pin card - 240 hours (10 days), gist card - 48 hours (2 days), and wakatime card - 24 hours. If you want the data on your cards to be updated more often you can [deploy your own instance](#deploy-on-your-own) and set [environment variable](#available-environment-variables) `CACHE_SECONDS` to a value of your choosing. ##### Gradient in bg\_color You can provide multiple comma-separated values in the bg\_color option to render a gradient with the following format: &bg_color=DEG,COLOR1,COLOR2,COLOR3...COLOR10 ##### Available locales Here is a list of all available locales:
| Code | Locale | | --- | --- | | `ar` | Arabic | | `az` | Azerbaijani | | `bn` | Bengali | | `bg` | Bulgarian | | `my` | Burmese | | `ca` | Catalan | | `cn` | Chinese | | `zh-tw` | Chinese (Taiwan) | | `cs` | Czech | | `nl` | Dutch | | `en` | English | | `fil` | Filipino | | `fi` | Finnish | | `fr` | French | | `de` | German | | `el` | Greek | | Code | Locale | | --- | --- | | `he` | Hebrew | | `hi` | Hindi | | `hu` | Hungarian | | `id` | Indonesian | | `it` | Italian | | `ja` | Japanese | | `kr` | Korean | | `ml` | Malayalam | | `np` | Nepali | | `no` | Norwegian | | `fa` | Persian (Farsi) | | `pl` | Polish | | `pt-br` | Portuguese (Brazil) | | `pt-pt` | Portuguese (Portugal) | | `ro` | Romanian | | Code | Locale | | --- | --- | | `ru` | Russian | | `sa` | Sanskrit | | `sr` | Serbian (Cyrillic) | | `sr-latn` | Serbian (Latin) | | `sk` | Slovak | | `es` | Spanish | | `sw` | Swahili | | `se` | Swedish | | `ta` | Tamil | | `th` | Thai | | `tr` | Turkish | | `uk-ua` | Ukrainian | | `ur` | Urdu | | `uz` | Uzbek | | `vi` | Vietnamese |
If we don't support your language, please consider contributing! You can find more information about how to do it in our [contributing guidelines](CONTRIBUTING.md#translations-contribution). #### Stats Card Exclusive Options | Name | Description | Type | Default value | | --- | --- | --- | --- | | `hide` | Hides the [specified items](#hiding-individual-stats) from stats. | string (comma-separated values) | `null` | | `hide_title` | Hides the title of your stats card. | boolean | `false` | | `card_width` | Sets the card's width manually. | number | `500px (approx.)` | | `hide_rank` | Hides the rank and automatically resizes the card width. | boolean | `false` | | `rank_icon` | Shows alternative rank icon (i.e. `github`, `percentile` or `default`). | enum | `default` | | `show_icons` | Shows icons near all stats. | boolean | `false` | | `include_all_commits` | Count total commits instead of just the current year commits. | boolean | `false` | | `line_height` | Sets the line height between text. | integer | `25` | | `exclude_repo` | Excludes specified repositories. | string (comma-separated values) | `null` | | `custom_title` | Sets a custom title for the card. | string | ` GitHub Stats` | | `text_bold` | Uses bold text. | boolean | `true` | | `disable_animations` | Disables all animations in the card. | boolean | `false` | | `ring_color` | Color of the rank circle. | string (hex color) | `2f80ed` | | `number_format` | Switches between two available formats for displaying the card values `short` (i.e. `6.6k`) and `long` (i.e. `6626`). | enum | `short` | | `number_precision` | Enforce the number of digits after the decimal point for `short` number format. Must be an integer between 0 and 2. Will be ignored for `long` number format. | integer (0, 1 or 2) | `null` | | `show` | Shows [additional items](#showing-additional-individual-stats) on stats card (i.e. `reviews`, `discussions_started`, `discussions_answered`, `prs_merged` or `prs_merged_percentage`). | string (comma-separated values) | `null` | | `commits_year` | Filters and counts only commits made in the specified year. | integer _(YYYY)_ | ` (one year to date)` | > [!WARNING] > Custom title should be URI-escaped, as specified in [Percent Encoding](https://en.wikipedia.org/wiki/Percent-encoding) (i.e: `Anurag's GitHub Stats` should become `Anurag%27s%20GitHub%20Stats`). You can use [urlencoder.org](https://www.urlencoder.org/) to help you do this automatically. > [!NOTE] > When hide\_rank=`true`, the minimum card width is 270 px + the title length and padding. *** # GitHub Extra Pins GitHub extra pins allow you to pin more than 6 repositories in your profile using a GitHub readme profile. Yay! You are no longer limited to 6 pinned repositories. ### Usage Copy-paste this code into your readme and change the links. Endpoint: `api/pin?username=anuraghazra&repo=github-readme-stats` ```md [![Readme Card](https://github-readme-stats.vercel.app/api/pin/?username=anuraghazra&repo=github-readme-stats)](https://github.com/anuraghazra/github-readme-stats) ``` ### Options You can customize the appearance and behavior of the pinned repository card using the [common options](#common-options) and exclusive options listed in the table below. | Name | Description | Type | Default value | | --- | --- | --- | --- | | `show_owner` | Shows the repo's owner name. | boolean | `false` | | `description_lines_count` | Manually set the number of lines for the description. Specified value will be clamped between 1 and 3. If this parameter is not specified, the number of lines will be automatically adjusted according to the actual length of the description. | number | `null` | ### Demo ![Readme Card](https://github-readme-stats.vercel.app/api/pin/?username=anuraghazra\&repo=github-readme-stats) Use `show_owner` query option to include the repo's owner username ![Readme Card](https://github-readme-stats.vercel.app/api/pin/?username=anuraghazra\&repo=github-readme-stats\&show_owner=true) # GitHub Gist Pins GitHub gist pins allow you to pin gists in your GitHub profile using a GitHub readme profile. ### Usage Copy-paste this code into your readme and change the links. Endpoint: `api/gist?id=bbfce31e0217a3689c8d961a356cb10d` ```md [![Gist Card](https://github-readme-stats.vercel.app/api/gist?id=bbfce31e0217a3689c8d961a356cb10d)](https://gist.github.com/Yizack/bbfce31e0217a3689c8d961a356cb10d/) ``` ### Options You can customize the appearance and behavior of the gist card using the [common options](#common-options) and exclusive options listed in the table below. | Name | Description | Type | Default value | | --- | --- | --- | --- | | `show_owner` | Shows the gist's owner name. | boolean | `false` | ### Demo ![Gist Card](https://github-readme-stats.vercel.app/api/gist?id=bbfce31e0217a3689c8d961a356cb10d) Use `show_owner` query option to include the gist's owner username ![Gist Card](https://github-readme-stats.vercel.app/api/gist?id=bbfce31e0217a3689c8d961a356cb10d\&show_owner=true) # Top Languages Card The top languages card shows a GitHub user's most frequently used languages. > [!WARNING] > By default, the language card shows language results only from public repositories. To include languages used in private repositories, you should [deploy your own instance](#deploy-on-your-own) using your own GitHub API token. > [!NOTE] > Top Languages does not indicate the user's skill level or anything like that; it's a GitHub metric to determine which languages have the most code on GitHub. It is a new feature of github-readme-stats. > [!WARNING] > This card shows language usage only inside your own non-forked repositories, not depending on who the author of the commits is. It does not include your contributions into another users/organizations repositories. Currently there are no way to get this data from GitHub API. If you want this behavior to be improved you can support [this feature request](https://github.com/orgs/community/discussions/18230) created by [@rickstaa](https://github.com/rickstaa) inside GitHub Community. > [!WARNING] > Currently this card shows data only about first 100 repositories. This is because GitHub API limitations which cause downtimes of public instances (see [#1471](https://github.com/anuraghazra/github-readme-stats/issues/1471)). In future this behavior will be improved by releasing GitHub action or providing environment variables for user's own instances. ### Usage Copy-paste this code into your readme and change the links. Endpoint: `api/top-langs?username=anuraghazra` ```md [![Top Langs](https://github-readme-stats.vercel.app/api/top-langs/?username=anuraghazra)](https://github.com/anuraghazra/github-readme-stats) ``` ### Options You can customize the appearance and behavior of the top languages card using the [common options](#common-options) and exclusive options listed in the table below. | Name | Description | Type | Default value | | --- | --- | --- | --- | | `hide` | Hides the [specified languages](#hide-individual-languages) from card. | string (comma-separated values) | `null` | | `hide_title` | Hides the title of your card. | boolean | `false` | | `layout` | Switches between five available layouts `normal` & `compact` & `donut` & `donut-vertical` & `pie`. | enum | `normal` | | `card_width` | Sets the card's width manually. | number | `300` | | `langs_count` | Shows more languages on the card, between 1-20. | integer | `5` for `normal` and `donut`, `6` for other layouts | | `exclude_repo` | Excludes specified repositories. | string (comma-separated values) | `null` | | `custom_title` | Sets a custom title for the card. | string | `Most Used Languages` | | `disable_animations` | Disables all animations in the card. | boolean | `false` | | `hide_progress` | Uses the compact layout option, hides percentages, and removes the bars. | boolean | `false` | | `size_weight` | Configures language stats algorithm (see [Language stats algorithm](#language-stats-algorithm)). | integer | `1` | | `count_weight` | Configures language stats algorithm (see [Language stats algorithm](#language-stats-algorithm)). | integer | `0` | | `stats_format` | Switches between two available formats for language's stats `percentages` and `bytes`. | enum | `percentages` | > [!WARNING] > Language names and custom title should be URI-escaped, as specified in [Percent Encoding](https://en.wikipedia.org/wiki/Percent-encoding) (i.e: `c++` should become `c%2B%2B`, `jupyter notebook` should become `jupyter%20notebook`, `Most Used Languages` should become `Most%20Used%20Languages`, etc.) You can use [urlencoder.org](https://www.urlencoder.org/) to help you do this automatically. ### Language stats algorithm We use the following algorithm to calculate the languages percentages on the language card: ```js ranking_index = (byte_count ^ size_weight) * (repo_count ^ count_weight) ``` By default, only the byte count is used for determining the languages percentages shown on the language card (i.e. `size_weight=1` and `count_weight=0`). You can, however, use the `&size_weight=` and `&count_weight=` options to weight the language usage calculation. The values must be positive real numbers. [More details about the algorithm can be found here](https://github.com/anuraghazra/github-readme-stats/issues/1600#issuecomment-1046056305). * `&size_weight=1&count_weight=0` - *(default)* Orders by byte count. * `&size_weight=0.5&count_weight=0.5` - *(recommended)* Uses both byte and repo count for ranking * `&size_weight=0&count_weight=1` - Orders by repo count ```md ![Top Langs](https://github-readme-stats.vercel.app/api/top-langs/?username=anuraghazra&size_weight=0.5&count_weight=0.5) ``` ### Exclude individual repositories You can use the `&exclude_repo=repo1,repo2` parameter to exclude individual repositories. ```md ![Top Langs](https://github-readme-stats.vercel.app/api/top-langs/?username=anuraghazra&exclude_repo=github-readme-stats,anuraghazra.github.io) ``` ### Hide individual languages You can use `&hide=language1,language2` parameter to hide individual languages. ```md ![Top Langs](https://github-readme-stats.vercel.app/api/top-langs/?username=anuraghazra&hide=javascript,html) ``` ### Show more languages You can use the `&langs_count=` option to increase or decrease the number of languages shown on the card. Valid values are integers between 1 and 20 (inclusive). By default it was set to `5` for `normal` & `donut` and `6` for other layouts. ```md ![Top Langs](https://github-readme-stats.vercel.app/api/top-langs/?username=anuraghazra&langs_count=8) ``` ### Compact Language Card Layout You can use the `&layout=compact` option to change the card design. ```md ![Top Langs](https://github-readme-stats.vercel.app/api/top-langs/?username=anuraghazra&layout=compact) ``` ### Donut Chart Language Card Layout You can use the `&layout=donut` option to change the card design. ```md [![Top Langs](https://github-readme-stats.vercel.app/api/top-langs/?username=anuraghazra&layout=donut)](https://github.com/anuraghazra/github-readme-stats) ``` ### Donut Vertical Chart Language Card Layout You can use the `&layout=donut-vertical` option to change the card design. ```md [![Top Langs](https://github-readme-stats.vercel.app/api/top-langs/?username=anuraghazra&layout=donut-vertical)](https://github.com/anuraghazra/github-readme-stats) ``` ### Pie Chart Language Card Layout You can use the `&layout=pie` option to change the card design. ```md [![Top Langs](https://github-readme-stats.vercel.app/api/top-langs/?username=anuraghazra&layout=pie)](https://github.com/anuraghazra/github-readme-stats) ``` ### Hide Progress Bars You can use the `&hide_progress=true` option to hide the percentages and the progress bars (layout will be automatically set to `compact`). ```md ![Top Langs](https://github-readme-stats.vercel.app/api/top-langs/?username=anuraghazra&hide_progress=true) ``` ### Change format of language's stats You can use the `&stats_format=bytes` option to display the stats in bytes instead of percentage. ```md ![Top Langs](https://github-readme-stats.vercel.app/api/top-langs/?username=anuraghazra&stats_format=bytes) ``` ### Demo ![Top Langs](https://github-readme-stats.vercel.app/api/top-langs/?username=anuraghazra) * Compact layout ![Top Langs](https://github-readme-stats.vercel.app/api/top-langs/?username=anuraghazra\&layout=compact) * Donut Chart layout [![Top Langs](https://github-readme-stats.vercel.app/api/top-langs/?username=anuraghazra\&layout=donut)](https://github.com/anuraghazra/github-readme-stats) * Donut Vertical Chart layout [![Top Langs](https://github-readme-stats.vercel.app/api/top-langs/?username=anuraghazra\&layout=donut-vertical)](https://github.com/anuraghazra/github-readme-stats) * Pie Chart layout [![Top Langs](https://github-readme-stats.vercel.app/api/top-langs/?username=anuraghazra\&layout=pie)](https://github.com/anuraghazra/github-readme-stats) * Hidden progress bars ![Top Langs](https://github-readme-stats.vercel.app/api/top-langs/?username=anuraghazra\&hide_progress=true) * Display bytes instead of percentage ![Top Langs](https://github-readme-stats.vercel.app/api/top-langs/?username=anuraghazra\&stats_format=bytes) # WakaTime Stats Card > [!WARNING] > Please be aware that we currently only show data from WakaTime profiles that are public. You therefore have to make sure that **BOTH** `Display code time publicly` and `Display languages, editors, os, categories publicly` are enabled. > [!WARNING] > In case you just created a new WakaTime account, then it might take up to 24 hours until your stats will become visible on the WakaTime stats card. Change the `?username=` value to your [WakaTime](https://wakatime.com) username. ```md [![Harlok's WakaTime stats](https://github-readme-stats.vercel.app/api/wakatime?username=ffflabs)](https://github.com/anuraghazra/github-readme-stats) ``` ### Options You can customize the appearance and behavior of the WakaTime stats card using the [common options](#common-options) and exclusive options listed in the table below. | Name | Description | Type | Default value | | --- | --- | --- | --- | | `hide` | Hides the languages specified from the card. | string (comma-separated values) | `null` | | `hide_title` | Hides the title of your card. | boolean | `false` | | `card_width` | Sets the card's width manually. | number | `495` | | `line_height` | Sets the line height between text. | integer | `25` | | `hide_progress` | Hides the progress bar and percentage. | boolean | `false` | | `custom_title` | Sets a custom title for the card. | string | `WakaTime Stats` | | `layout` | Switches between two available layouts `default` & `compact`. | enum | `default` | | `langs_count` | Limits the number of languages on the card, defaults to all reported languages. | integer | `null` | | `api_domain` | Sets a custom API domain for the card, e.g. to use services like [Hakatime](https://github.com/mujx/hakatime) or [Wakapi](https://github.com/muety/wakapi) | string | `wakatime.com` | | `display_format` | Sets the WakaTime stats display format. Choose `time` to display time-based stats or `percent` to show percentages. | enum | `time` | | `disable_animations` | Disables all animations in the card. | boolean | `false` | > [!WARNING] > Custom title should be URI-escaped, as specified in [Percent Encoding](https://en.wikipedia.org/wiki/Percent-encoding) (i.e: `WakaTime Stats` should become `WakaTime%20Stats`). You can use [urlencoder.org](https://www.urlencoder.org/) to help you do this automatically. ### Demo ![Harlok's WakaTime stats](https://github-readme-stats.vercel.app/api/wakatime?username=ffflabs) ![Harlok's WakaTime stats](https://github-readme-stats.vercel.app/api/wakatime?username=ffflabs\&hide_progress=true) * Compact layout ![Harlok's WakaTime stats](https://github-readme-stats.vercel.app/api/wakatime?username=ffflabs\&layout=compact) *** # All Demos * Default ![Anurag's GitHub stats](https://github-readme-stats.vercel.app/api?username=anuraghazra) * Hiding specific stats ![Anurag's GitHub stats](https://github-readme-stats.vercel.app/api?username=anuraghazra\&hide=contribs,issues) * Showing additional stats ![Anurag's GitHub stats](https://github-readme-stats.vercel.app/api?username=anuraghazra\&show_icons=true\&show=reviews,discussions_started,discussions_answered,prs_merged,prs_merged_percentage) * Showing icons ![Anurag's GitHub stats](https://github-readme-stats.vercel.app/api?username=anuraghazra\&hide=issues\&show_icons=true) * Shows GitHub logo instead rank level ![Anurag's GitHub stats](https://github-readme-stats.vercel.app/api?username=anuraghazra\&rank_icon=github) * Shows user rank percentile instead of rank level ![Anurag's GitHub stats](https://github-readme-stats.vercel.app/api?username=anuraghazra\&rank_icon=percentile) * Customize Border Color ![Anurag's GitHub stats](https://github-readme-stats.vercel.app/api?username=anuraghazra\&border_color=2e4058) * Include All Commits ![Anurag's GitHub stats](https://github-readme-stats.vercel.app/api?username=anuraghazra\&include_all_commits=true) * Themes Choose from any of the [default themes](#themes) ![Anurag's GitHub stats](https://github-readme-stats.vercel.app/api?username=anuraghazra\&show_icons=true\&theme=radical) * Gradient ![Anurag's GitHub stats](https://github-readme-stats.vercel.app/api?username=anuraghazra\&bg_color=30,e96443,904e95\&title_color=fff\&text_color=fff) * Customizing stats card ![Anurag's GitHub stats](https://github-readme-stats.vercel.app/api/?username=anuraghazra\&show_icons=true\&title_color=fff\&icon_color=79ff97\&text_color=9f9f9f\&bg_color=151515) * Setting card locale ![Anurag's GitHub stats](https://github-readme-stats.vercel.app/api/?username=anuraghazra\&locale=es) * Customizing repo card ![Customized Card](https://github-readme-stats.vercel.app/api/pin?username=anuraghazra\&repo=github-readme-stats\&title_color=fff\&icon_color=f9f9f9\&text_color=9f9f9f\&bg_color=151515) * Gist card ![Gist Card](https://github-readme-stats.vercel.app/api/gist?id=bbfce31e0217a3689c8d961a356cb10d) * Customizing gist card ![Gist Card](https://github-readme-stats.vercel.app/api/gist?id=bbfce31e0217a3689c8d961a356cb10d&theme=calm) * Top languages ![Top Langs](https://github-readme-stats.vercel.app/api/top-langs/?username=anuraghazra) * WakaTime card ![Harlok's WakaTime stats](https://github-readme-stats.vercel.app/api/wakatime?username=ffflabs) *** ## Quick Tip (Align The Cards) By default, GitHub does not lay out the cards side by side. To do that, you can use such approaches: ### Stats and top languages cards ```html ```
:eyes: Show example
### Pinning repositories ```html ```
:eyes: Show example
# Deploy on your own (recommended) Because the public endpoint is [not reliable](#Important-Notices), we recommend self-deployment via GitHub Actions or your own hosted instance. GitHub Actions is the simplest setup with static SVGs stored in your repo but less frequent updates, while self-hosting takes more work and can serve fresher stats (with caching). ## GitHub Actions GitHub Actions generates static SVGs and avoids per-request API calls. By default it uses `GITHUB_TOKEN` (public stats only), for private stats, set a [PAT](https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/managing-your-personal-access-tokens) as a secret and pass it to the action instead. Create `/.github/workflows/grs.yml` in your profile repo (`USERNAME/USERNAME`): ```yaml name: Update README cards on: schedule: - cron: "0 3 * * *" workflow_dispatch: jobs: build: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - name: Generate stats card uses: readme-tools/github-readme-stats-action@v1 with: card: stats options: username=${{ github.repository_owner }}&show_icons=true path: profile/stats.svg token: ${{ secrets.GITHUB_TOKEN }} - name: Commit cards run: | git config user.name "github-actions" git config user.email "github-actions@users.noreply.github.com" git add profile/*.svg git commit -m "Update README cards" || exit 0 git push ``` Then embed from your profile README: ```md ![Stats](./profile/stats.svg) ``` See more options and examples in the [GitHub Readme Stats Action README](https://github.com/readme-tools/github-readme-stats-action#readme). ## Self-hosted (Vercel/Other) Running your own instance avoids public rate limits and gives you full control over caching, tokens, and private stats. ### First step: get your Personal Access Token (PAT) For deploying your own instance of GitHub Readme Stats, you will need to create a GitHub Personal Access Token (PAT). Below are the steps to create one and the scopes you need to select for both classic and fine-grained tokens. Selecting the right scopes for your token is important in case you want to display private contributions on your cards. #### Classic token * Go to [Account -> Settings -> Developer Settings -> Personal access tokens -> Tokens (classic)](https://github.com/settings/tokens). * Click on `Generate new token -> Generate new token (classic)`. * Scopes to select: * repo * read:user * Click on `Generate token` and copy it. #### Fine-grained token > [!WARNING]\ > This limits the scope to issues in your repositories and includes only public commits. * Go to [Account -> Settings -> Developer Settings -> Personal access tokens -> Fine-grained tokens](https://github.com/settings/tokens). * Click on `Generate new token -> Generate new token`. * Select an expiration date * Select `All repositories` * Scopes to select in `Repository permission`: * Commit statuses: read-only * Contents: read-only * Issues: read-only * Metadata: read-only * Pull requests: read-only * Click on `Generate token` and copy it. ### On Vercel ### :film\_projector: [Check Out Step By Step Video Tutorial By @codeSTACKr](https://youtu.be/n6d4KHSKqGk?t=107) Since the GitHub API only allows 5k requests per hour, my `https://github-readme-stats.vercel.app/api` could possibly hit the rate limiter. If you host it on your own Vercel server, then you do not have to worry about anything. Click on the deploy button to get started! > [!NOTE] > Since [#58](https://github.com/anuraghazra/github-readme-stats/pull/58), we should be able to handle more than 5k requests and have fewer issues with downtime :grin:. > [!NOTE] > If you are on the [Pro (i.e. paid)](https://vercel.com/pricing) Vercel plan, the [maxDuration](https://vercel.com/docs/concepts/projects/project-configuration#value-definition) value found in the [vercel.json](https://github.com/anuraghazra/github-readme-stats/blob/master/vercel.json) can be increased when your Vercel instance frequently times out during the card request. You are advised to keep this value lower than `30` seconds to prevent high memory usage. [![Deploy to Vercel](https://vercel.com/button)](https://vercel.com/import/project?template=https://github.com/anuraghazra/github-readme-stats)
:hammer_and_wrench: Step-by-step guide on setting up your own Vercel instance 1. Go to [vercel.com](https://vercel.com/). 2. Click on `Log in`. ![](https://files.catbox.moe/pcxk33.png) 3. Sign in with GitHub by pressing `Continue with GitHub`. ![](https://files.catbox.moe/b9oxey.png) 4. Sign in to GitHub and allow access to all repositories if prompted. 5. Fork this repo. 6. Go back to your [Vercel dashboard](https://vercel.com/dashboard). 7. To import a project, click the `Add New...` button and select the `Project` option. ![](https://files.catbox.moe/3n76fh.png) 8. Click the `Continue with GitHub` button, search for the required Git Repository and import it by clicking the `Import` button. Alternatively, you can import a Third-Party Git Repository using the `Import Third-Party Git Repository ->` link at the bottom of the page. ![](https://files.catbox.moe/mg5p04.png) 9. Create a Personal Access Token (PAT) as described in the [previous section](#first-step-get-your-personal-access-token-pat). 10. Add the PAT as an environment variable named `PAT_1` (as shown). ![](https://files.catbox.moe/0yclio.png) 11. Click deploy, and you're good to go. See your domains to use the API!
### On other platforms > [!WARNING] > This way of using GRS is not officially supported and was added to cater to some particular use cases where Vercel could not be used (e.g. [#2341](https://github.com/anuraghazra/github-readme-stats/discussions/2341)). The support for this method, therefore, is limited.
:hammer_and_wrench: Step-by-step guide for deploying on other platforms 1. Fork or clone this repo as per your needs 2. Move `express` from the devDependencies to the dependencies section of `package.json` 3. Run `npm i` if needed (initial setup) 4. Run `node express.js` to start the server, or set the entry point to `express.js` in `package.json` if you're deploying on a managed service 5. You're done 🎉
### Available environment variables GitHub Readme Stats provides several environment variables that can be used to customize the behavior of your self-hosted instance. These include:
Name Description Supported values
CACHE_SECONDS Sets the cache duration in seconds for the generated cards. This variable takes precedence over the default cache timings for the public instance. If this variable is not set, the default cache duration is 24 hours (86,400 seconds). Any positive integer or 0 to disable caching
WHITELIST A comma-separated list of GitHub usernames that are allowed to access your instance. If this variable is not set, all usernames are allowed. Comma-separated GitHub usernames
GIST_WHITELIST A comma-separated list of GitHub Gist IDs that are allowed to be accessed on your instance. If this variable is not set, all Gist IDs are allowed. Comma-separated GitHub Gist IDs
EXCLUDE_REPO A comma-separated list of repositories that will be excluded from stats and top languages cards on your instance. This allows repository exclusion without exposing repository names in public URLs. This enhances privacy for self-hosted instances that include private repositories in stats cards. Comma-separated repository names
FETCH_MULTI_PAGE_STARS Enables fetching all starred repositories for accurate star counts, especially for users with more than 100 repositories. This may increase response times and API points usage, so it is disabled on the public instance. true or false
See [the Vercel documentation](https://vercel.com/docs/concepts/projects/environment-variables) on adding these environment variables to your Vercel instance. > [!WARNING] > Please remember to redeploy your instance after making any changes to the environment variables so that the updates take effect. The changes will not be applied to the previous deployments. ## Keep your fork up to date You can keep your fork, and thus your private Vercel instance up to date with the upstream using GitHub's [Sync Fork button](https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/working-with-forks/syncing-a-fork). You can also use the [pull](https://github.com/wei/pull) package created by [@wei](https://github.com/wei) to automate this process. # :sparkling\_heart: Support the project I open-source almost everything I can and try to reply to everyone needing help using these projects. Obviously, this takes time. You can use this service for free. However, if you are using this project and are happy with it or just want to encourage me to continue creating stuff, there are a few ways you can do it: * Giving proper credit when you use github-readme-stats on your readme, linking back to it. :D * Starring and sharing the project. :rocket: * [![paypal.me/anuraghazra](https://ionicabizau.github.io/badges/paypal.svg)](https://www.paypal.me/anuraghazra) - You can make a one-time donation via PayPal. I'll probably buy a ~~coffee~~ tea. :tea: Thanks! :heart: *** [![https://vercel.com?utm\_source=github\_readme\_stats\_team\&utm\_campaign=oss](powered-by-vercel.svg)](https://vercel.com?utm_source=github_readme_stats_team\&utm_campaign=oss) Contributions are welcome! <3 Made with :heart: and JavaScript. ================================================ FILE: scripts/close-stale-theme-prs.js ================================================ /** * @file Script that can be used to close stale theme PRs that have a `invalid` label. */ import * as dotenv from "dotenv"; dotenv.config(); import { debug, setFailed } from "@actions/core"; import github from "@actions/github"; import { RequestError } from "@octokit/request-error"; import { getGithubToken, getRepoInfo } from "./helpers.js"; const CLOSING_COMMENT = ` \rThis theme PR has been automatically closed due to inactivity. Please reopen it if you want to continue working on it.\ \rThank you for your contributions. `; const REVIEWER = "github-actions[bot]"; /** * Retrieve the review user. * @returns {string} review user. */ const getReviewer = () => { return process.env.REVIEWER ? process.env.REVIEWER : REVIEWER; }; /** * Fetch open PRs from a given repository. * * @param {module:@actions/github.Octokit} octokit The octokit client. * @param {string} user The user name of the repository owner. * @param {string} repo The name of the repository. * @param {string} reviewer The reviewer to filter by. * @returns {Promise} The open PRs. */ export const fetchOpenPRs = async (octokit, user, repo, reviewer) => { const openPRs = []; let hasNextPage = true; let endCursor; while (hasNextPage) { try { const { repository } = await octokit.graphql( ` { repository(owner: "${user}", name: "${repo}") { open_prs: pullRequests(${ endCursor ? `after: "${endCursor}", ` : "" } first: 100, states: OPEN, orderBy: {field: CREATED_AT, direction: DESC}) { nodes { number commits(last:1){ nodes{ commit{ pushedDate } } } labels(first: 100, orderBy:{field: CREATED_AT, direction: DESC}) { nodes { name } } reviews(first: 100, states: CHANGES_REQUESTED, author: "${reviewer}") { nodes { submittedAt } } } pageInfo { endCursor hasNextPage } } } } `, ); openPRs.push(...repository.open_prs.nodes); hasNextPage = repository.open_prs.pageInfo.hasNextPage; endCursor = repository.open_prs.pageInfo.endCursor; } catch (error) { if (error instanceof RequestError) { setFailed(`Could not retrieve top PRs using GraphQl: ${error.message}`); } throw error; } } return openPRs; }; /** * Retrieve pull requests that have a given label. * * @param {Object[]} pulls The pull requests to check. * @param {string} label The label to check for. * @returns {Object[]} The pull requests that have the given label. */ export const pullsWithLabel = (pulls, label) => { return pulls.filter((pr) => { return pr.labels.nodes.some((lab) => lab.name === label); }); }; /** * Check if PR is stale. Meaning that it hasn't been updated in a given time. * * @param {Object} pullRequest request object. * @param {number} staleDays number of days. * @returns {boolean} indicating if PR is stale. */ const isStale = (pullRequest, staleDays) => { const lastCommitDate = new Date( pullRequest.commits.nodes[0].commit.pushedDate, ); if (pullRequest.reviews.nodes[0]) { const lastReviewDate = new Date( pullRequest.reviews.nodes.sort((a, b) => (a < b ? 1 : -1))[0].submittedAt, ); const lastUpdateDate = lastCommitDate >= lastReviewDate ? lastCommitDate : lastReviewDate; const now = new Date(); return (now - lastUpdateDate) / (1000 * 60 * 60 * 24) >= staleDays; } else { return false; } }; /** * Main function. * * @returns {Promise} A promise. */ const run = async () => { try { // Create octokit client. const dryRun = process.env.DRY_RUN === "true" || false; const staleDays = process.env.STALE_DAYS || 20; debug("Creating octokit client..."); const octokit = github.getOctokit(getGithubToken()); const { owner, repo } = getRepoInfo(github.context); const reviewer = getReviewer(); // Retrieve all theme pull requests. debug("Retrieving all theme pull requests..."); const prs = await fetchOpenPRs(octokit, owner, repo, reviewer); const themePRs = pullsWithLabel(prs, "themes"); const invalidThemePRs = pullsWithLabel(themePRs, "invalid"); debug("Retrieving stale theme PRs..."); const staleThemePRs = invalidThemePRs.filter((pr) => isStale(pr, staleDays), ); const staleThemePRsNumbers = staleThemePRs.map((pr) => pr.number); debug(`Found ${staleThemePRs.length} stale theme PRs`); // Loop through all stale invalid theme pull requests and close them. for (const prNumber of staleThemePRsNumbers) { debug(`Closing #${prNumber} because it is stale...`); if (dryRun) { debug("Dry run enabled, skipping..."); } else { await octokit.rest.issues.createComment({ owner, repo, issue_number: prNumber, body: CLOSING_COMMENT, }); await octokit.rest.pulls.update({ owner, repo, pull_number: prNumber, state: "closed", }); } } } catch (error) { setFailed(error.message); } }; run(); ================================================ FILE: scripts/generate-langs-json.js ================================================ import axios from "axios"; import fs from "fs"; import jsYaml from "js-yaml"; const LANGS_FILEPATH = "./src/common/languageColors.json"; //Retrieve languages from github linguist repository yaml file //@ts-ignore axios .get( "https://raw.githubusercontent.com/github/linguist/master/lib/linguist/languages.yml", ) .then((response) => { //and convert them to a JS Object const languages = jsYaml.load(response.data); const languageColors = {}; //Filter only language colors from the whole file Object.keys(languages).forEach((lang) => { languageColors[lang] = languages[lang].color; }); //Debug Print //console.dir(languageColors); fs.writeFileSync( LANGS_FILEPATH, JSON.stringify(languageColors, null, " "), ); }); ================================================ FILE: scripts/generate-theme-doc.js ================================================ import fs from "fs"; import { themes } from "../themes/index.js"; const TARGET_FILE = "./themes/README.md"; const REPO_CARD_LINKS_FLAG = ""; const STAT_CARD_LINKS_FLAG = ""; const STAT_CARD_TABLE_FLAG = ""; const REPO_CARD_TABLE_FLAG = ""; const THEME_TEMPLATE = `## Available Themes With inbuilt themes, you can customize the look of the card without doing any manual customization. Use \`?theme=THEME_NAME\` parameter like so: \`\`\`md ![Anurag's GitHub stats](https://github-readme-stats.vercel.app/api?username=anuraghazra&theme=dark&show_icons=true) \`\`\` ## Stats > These themes work with all five of our cards: Stats Card, Repo Card, Gist Card, Top Languages Card, and WakaTime Card. | | | | | :--: | :--: | :--: | ${STAT_CARD_TABLE_FLAG} ## Repo Card > These themes work with all five of our cards: Stats Card, Repo Card, Gist Card, Top Languages Card, and WakaTime Card. | | | | | :--: | :--: | :--: | ${REPO_CARD_TABLE_FLAG} ${STAT_CARD_LINKS_FLAG} ${REPO_CARD_LINKS_FLAG} `; const createRepoMdLink = (theme) => { return `\n[${theme}_repo]: https://github-readme-stats.vercel.app/api/pin/?username=anuraghazra&repo=github-readme-stats&cache_seconds=86400&theme=${theme}`; }; const createStatMdLink = (theme) => { return `\n[${theme}]: https://github-readme-stats.vercel.app/api?username=anuraghazra&show_icons=true&hide=contribs,prs&cache_seconds=86400&theme=${theme}`; }; const generateLinks = (fn) => { return Object.keys(themes) .map((name) => fn(name)) .join(""); }; const createTableItem = ({ link, label, isRepoCard }) => { if (!link || !label) { return ""; } return `\`${label}\` ![${link}][${link}${isRepoCard ? "_repo" : ""}]`; }; const generateTable = ({ isRepoCard }) => { const rows = []; const themesFiltered = Object.keys(themes).filter( (name) => name !== (isRepoCard ? "default" : "default_repocard"), ); for (let i = 0; i < themesFiltered.length; i += 3) { const one = themesFiltered[i]; const two = themesFiltered[i + 1]; const three = themesFiltered[i + 2]; let tableItem1 = createTableItem({ link: one, label: one, isRepoCard }); let tableItem2 = createTableItem({ link: two, label: two, isRepoCard }); let tableItem3 = createTableItem({ link: three, label: three, isRepoCard }); rows.push(`| ${tableItem1} | ${tableItem2} | ${tableItem3} |`); } return rows.join("\n"); }; const buildReadme = () => { return THEME_TEMPLATE.split("\n") .map((line) => { if (line.includes(REPO_CARD_LINKS_FLAG)) { return generateLinks(createRepoMdLink); } if (line.includes(STAT_CARD_LINKS_FLAG)) { return generateLinks(createStatMdLink); } if (line.includes(REPO_CARD_TABLE_FLAG)) { return generateTable({ isRepoCard: true }); } if (line.includes(STAT_CARD_TABLE_FLAG)) { return generateTable({ isRepoCard: false }); } return line; }) .join("\n"); }; fs.writeFileSync(TARGET_FILE, buildReadme()); ================================================ FILE: scripts/helpers.js ================================================ /** * @file Contains helper functions used in the scripts. */ import { getInput } from "@actions/core"; const OWNER = "anuraghazra"; const REPO = "github-readme-stats"; /** * Retrieve information about the repository that ran the action. * * @param {Object} ctx Action context. * @returns {Object} Repository information. */ export const getRepoInfo = (ctx) => { try { return { owner: ctx.repo.owner, repo: ctx.repo.repo, }; } catch (error) { // Resolve eslint no-unused-vars error; return { owner: OWNER, repo: REPO, }; } }; /** * Retrieve github token and throw error if it is not found. * * @returns {string} GitHub token. */ export const getGithubToken = () => { const token = getInput("github_token") || process.env.GITHUB_TOKEN; if (!token) { throw Error("Could not find github token"); } return token; }; ================================================ FILE: scripts/preview-theme.js ================================================ /** * @file This script is used to preview the theme on theme PRs. */ import * as dotenv from "dotenv"; dotenv.config(); import { debug, setFailed } from "@actions/core"; import github from "@actions/github"; import ColorContrastChecker from "color-contrast-checker"; import { info } from "console"; import Hjson from "hjson"; import snakeCase from "lodash.snakecase"; import parse from "parse-diff"; import { inspect } from "util"; import { isValidHexColor, isValidGradient } from "../src/common/color.js"; import { themes } from "../themes/index.js"; import { getGithubToken, getRepoInfo } from "./helpers.js"; const COMMENTER = "github-actions[bot]"; const COMMENT_TITLE = "Automated Theme Preview"; const THEME_PR_FAIL_TEXT = ":x: Theme PR does not adhere to our guidelines."; const THEME_PR_SUCCESS_TEXT = ":heavy_check_mark: Theme PR does adhere to our guidelines."; const FAIL_TEXT = ` \rUnfortunately, your theme PR contains an error or does not adhere to our [theme guidelines](https://github.com/anuraghazra/github-readme-stats/blob/master/CONTRIBUTING.md#themes-contribution). Please fix the issues below, and we will review your\ \r PR again. This pull request will **automatically close in 20 days** if no changes are made. After this time, you must re-open the PR for it to be reviewed. `; const THEME_CONTRIB_GUIDELINES = ` \rHi, thanks for the theme contribution. Please read our theme [contribution guidelines](https://github.com/anuraghazra/github-readme-stats/blob/master/CONTRIBUTING.md#themes-contribution). \r> [!WARNING]\ \r> Keep in mind that we already have a vast collection of different themes. To keep their number manageable, we began to add only themes supported by the community. Your pull request with theme addition will be merged once we get enough positive feedback from the community in the form of thumbs up :+1: emojis (see [#1935](https://github.com/anuraghazra/github-readme-stats/issues/1935#top-themes-prs)). We expect to see at least 10-15 thumbs up before making a decision to merge your pull request into the master branch. Remember that you can also support themes of other contributors that you liked to speed up their merge. \r> [!WARNING]\ \r> Please do not submit a pull request with a batch of themes, since it will be hard to judge how the community will react to each of them. We will only merge one theme per pull request. If you have several themes, please submit a separate pull request for each of them. Situations when you have several versions of the same theme (e.g. light and dark) are an exception to this rule. \r> [!NOTE]\ \r> Also, note that 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#customization). `; const COLOR_PROPS = { title_color: 6, icon_color: 6, text_color: 6, bg_color: 23, border_color: 6, }; const ACCEPTED_COLOR_PROPS = Object.keys(COLOR_PROPS); const REQUIRED_COLOR_PROPS = ACCEPTED_COLOR_PROPS.slice(0, 4); const INVALID_REVIEW_COMMENT = (commentUrl) => `Some themes are invalid. See the [Automated Theme Preview](${commentUrl}) comment above for more information.`; var OCTOKIT; var OWNER; var REPO; var PULL_REQUEST_ID; /** * Incorrect JSON format error. * @extends Error * @param {string} message Error message. * @returns {Error} IncorrectJsonFormatError. */ class IncorrectJsonFormatError extends Error { /** * Constructor. * * @param {string} message Error message. */ constructor(message) { super(message); this.name = "IncorrectJsonFormatError"; } } /** * Retrieve PR number from the event payload. * * @returns {number} PR number. */ const getPrNumber = () => { if (process.env.MOCK_PR_NUMBER) { return process.env.MOCK_PR_NUMBER; // For testing purposes. } const pullRequest = github.context.payload.pull_request; if (!pullRequest) { throw Error("Could not get pull request number from context"); } return pullRequest.number; }; /** * Retrieve the commenting user. * @returns {string} Commenting user. */ const getCommenter = () => { return process.env.COMMENTER ? process.env.COMMENTER : COMMENTER; }; /** * Returns whether the comment is a preview comment. * * @param {Object} inputs Action inputs. * @param {Object} comment Comment object. * @returns {boolean} Whether the comment is a preview comment. */ const isPreviewComment = (inputs, comment) => { return ( (inputs.commentAuthor && comment.user ? comment.user.login === inputs.commentAuthor : true) && (inputs.bodyIncludes && comment.body ? comment.body.includes(inputs.bodyIncludes) : true) ); }; /** * Find the preview theme comment. * * @param {Object} octokit Octokit instance. * @param {number} issueNumber Issue number. * @param {string} owner Owner of the repository. * @param {string} repo Repository name. * @param {string} commenter Comment author. * @returns {Object | undefined} The GitHub comment object. */ const findComment = async (octokit, issueNumber, owner, repo, commenter) => { const parameters = { owner, repo, issue_number: issueNumber, }; const inputs = { commentAuthor: commenter, bodyIncludes: COMMENT_TITLE, }; // Search each page for the comment for await (const { data: comments } of octokit.paginate.iterator( octokit.rest.issues.listComments, parameters, )) { const comment = comments.find((comment) => isPreviewComment(inputs, comment), ); if (comment) { debug(`Found theme preview comment: ${inspect(comment)}`); return comment; } else { debug(`No theme preview comment found.`); } } return undefined; }; /** * Create or update the preview comment. * * @param {Object} octokit Octokit instance. * @param {number} issueNumber Issue number. * @param {Object} repo Repository name. * @param {Object} owner Owner of the repository. * @param {number} commentId Comment ID. * @param {string} body Comment body. * @returns {string} The comment URL. */ const upsertComment = async ( octokit, issueNumber, repo, owner, commentId, body, ) => { let resp; if (commentId === undefined) { resp = await octokit.rest.issues.createComment({ owner, repo, issue_number: issueNumber, body, }); } else { resp = await octokit.rest.issues.updateComment({ owner, repo, comment_id: commentId, body, }); } return resp.data.html_url; }; /** * Adds a review to the pull request. * * @param {Object} octokit Octokit instance. * @param {number} prNumber Pull request number. * @param {string} owner Owner of the repository. * @param {string} repo Repository name. * @param {string} reviewState The review state. Options are (APPROVE, REQUEST_CHANGES, COMMENT, PENDING). * @param {string} reason The reason for the review. * @returns {Promise} Promise. */ const addReview = async ( octokit, prNumber, owner, repo, reviewState, reason, ) => { await octokit.rest.pulls.createReview({ owner, repo, pull_number: prNumber, event: reviewState, body: reason, }); }; /** * Add label to pull request. * * @param {Object} octokit Octokit instance. * @param {number} prNumber Pull request number. * @param {string} owner Repository owner. * @param {string} repo Repository name. * @param {string[]} labels Labels to add. * @returns {Promise} Promise. */ const addLabel = async (octokit, prNumber, owner, repo, labels) => { await octokit.rest.issues.addLabels({ owner, repo, issue_number: prNumber, labels, }); }; /** * Remove label from the pull request. * * @param {Object} octokit Octokit instance. * @param {number} prNumber Pull request number. * @param {string} owner Repository owner. * @param {string} repo Repository name. * @param {string} label Label to add or remove. * @returns {Promise} Promise. */ const removeLabel = async (octokit, prNumber, owner, repo, label) => { await octokit.rest.issues.removeLabel({ owner, repo, issue_number: prNumber, name: label, }); }; /** * Adds or removes a label from the pull request. * * @param {Object} octokit Octokit instance. * @param {number} prNumber Pull request number. * @param {string} owner Repository owner. * @param {string} repo Repository name. * @param {string} label Label to add or remove. * @param {boolean} add Whether to add or remove the label. * @returns {Promise} Promise. */ const addRemoveLabel = async (octokit, prNumber, owner, repo, label, add) => { const res = await octokit.rest.pulls.get({ owner, repo, pull_number: prNumber, }); if (add) { if (!res.data.labels.find((l) => l.name === label)) { await addLabel(octokit, prNumber, owner, repo, [label]); } } else { if (res.data.labels.find((l) => l.name === label)) { await removeLabel(octokit, prNumber, owner, repo, label); } } }; /** * Retrieve webAim contrast color check link. * * @param {string} color1 First color. * @param {string} color2 Second color. * @returns {string} WebAim contrast color check link. */ const getWebAimLink = (color1, color2) => { return `https://webaim.org/resources/contrastchecker/?fcolor=${color1}&bcolor=${color2}`; }; /** * Retrieves the theme GRS url. * * @param {Object} colors The theme colors. * @returns {string} GRS theme url. */ const getGRSLink = (colors) => { const url = `https://github-readme-stats.vercel.app/api?username=anuraghazra`; const colorString = Object.keys(colors) .map((colorKey) => `${colorKey}=${colors[colorKey]}`) .join("&"); return `${url}&${colorString}&show_icons=true`; }; /** * Retrieve javascript object from json string. * * @description Wraps the Hjson parse function to fix several known json syntax errors. * * @param {string} json The json to parse. * @returns {Object} Object parsed from the json. */ const parseJSON = (json) => { try { const parsedJson = Hjson.parse(json); if (typeof parsedJson === "object") { return parsedJson; } else { throw new IncorrectJsonFormatError( "PR diff is not a valid theme JSON object.", ); } } catch (error) { // Resolve eslint no-unused-vars error; // Remove trailing commas (if any). let parsedJson = json.replace(/(,\s*})/g, "}"); // Remove JS comments (if any). parsedJson = parsedJson.replace(/\/\/[A-z\s]*\s/g, ""); // Fix incorrect open bracket (if any). const splitJson = parsedJson .split(/([\s\r\s]*}[\s\r\s]*,[\s\r\s]*)(?=[\w"-]+:)/) .filter((x) => typeof x !== "string" || !!x.trim()); // Split json into array of strings and objects. if (splitJson[0].replace(/\s+/g, "") === "},") { splitJson[0] = "},"; if (/\s*}\s*,?\s*$/.test(splitJson[1])) { splitJson.shift(); } else { splitJson.push(splitJson.shift()); } parsedJson = splitJson.join(""); } // Try to parse the fixed json. try { return Hjson.parse(parsedJson); } catch (error) { throw new IncorrectJsonFormatError( `Theme JSON file could not be parsed: ${error.message}`, ); } } }; /** * Check whether the theme name is still available. * @param {string} name Theme name. * @returns {boolean} Whether the theme name is available. */ const themeNameAlreadyExists = (name) => { return themes[name] !== undefined; }; const DRY_RUN = process.env.DRY_RUN === "true" || false; /** * Main function. * * @returns {Promise} Promise. */ export const run = async () => { try { debug("Retrieve action information from context..."); debug(`Context: ${inspect(github.context)}`); let commentBody = ` \r# ${COMMENT_TITLE} \r${THEME_CONTRIB_GUIDELINES} `; const ccc = new ColorContrastChecker(); OCTOKIT = github.getOctokit(getGithubToken()); PULL_REQUEST_ID = getPrNumber(); const { owner, repo } = getRepoInfo(github.context); OWNER = owner; REPO = repo; const commenter = getCommenter(); PULL_REQUEST_ID = getPrNumber(); debug(`Owner: ${OWNER}`); debug(`Repo: ${REPO}`); debug(`Commenter: ${commenter}`); // Retrieve the PR diff and preview-theme comment. debug("Retrieve PR diff..."); const res = await OCTOKIT.rest.pulls.get({ owner: OWNER, repo: REPO, pull_number: PULL_REQUEST_ID, mediaType: { format: "diff", }, }); debug("Retrieve preview-theme comment..."); const comment = await findComment( OCTOKIT, PULL_REQUEST_ID, OWNER, REPO, commenter, ); // Retrieve theme changes from the PR diff. debug("Retrieve themes..."); const diff = parse(res.data); // Retrieve all theme changes from the PR diff and convert to JSON. debug("Retrieve theme changes..."); const content = diff .find((file) => file.to === "themes/index.js") .chunks.map((chunk) => chunk.changes .filter((c) => c.type === "add") .map((c) => c.content.replace("+", "")) .join(""), ) .join(""); const themeObject = parseJSON(content); if ( Object.keys(themeObject).every( (key) => typeof themeObject[key] !== "object", ) ) { throw new Error("PR diff is not a valid theme JSON object."); } // Loop through themes and create theme preview body. debug("Create theme preview body..."); const themeValid = Object.fromEntries( Object.keys(themeObject).map((name) => [name, true]), ); let previewBody = ""; for (const theme in themeObject) { debug(`Create theme preview for ${theme}...`); const themeName = theme; const colors = themeObject[theme]; const warnings = []; const errors = []; // Check if the theme name is valid. debug("Theme preview body: Check if the theme name is valid..."); if (themeNameAlreadyExists(themeName)) { warnings.push("Theme name already taken"); themeValid[theme] = false; } if (themeName !== snakeCase(themeName)) { warnings.push("Theme name isn't in snake_case"); themeValid[theme] = false; } // Check if the theme colors are valid. debug("Theme preview body: Check if the theme colors are valid..."); let invalidColors = false; if (colors) { const missingKeys = REQUIRED_COLOR_PROPS.filter( (x) => !Object.keys(colors).includes(x), ); const extraKeys = Object.keys(colors).filter( (x) => !ACCEPTED_COLOR_PROPS.includes(x), ); if (missingKeys.length > 0 || extraKeys.length > 0) { for (const missingKey of missingKeys) { errors.push(`Theme color properties \`${missingKey}\` are missing`); } for (const extraKey of extraKeys) { warnings.push( `Theme color properties \`${extraKey}\` is not supported`, ); } invalidColors = true; } else { for (const [colorKey, colorValue] of Object.entries(colors)) { if (colorValue[0] === "#") { errors.push( `Theme color property \`${colorKey}\` should not start with '#'`, ); invalidColors = true; } else if (colorValue.length > COLOR_PROPS[colorKey]) { errors.push( `Theme color property \`${colorKey}\` can not be longer than \`${COLOR_PROPS[colorKey]}\` characters`, ); invalidColors = true; } else if ( !(colorKey === "bg_color" && colorValue.split(",").length > 1 ? isValidGradient(colorValue.split(",")) : isValidHexColor(colorValue)) ) { errors.push( `Theme color property \`${colorKey}\` is not a valid hex color: ${colorValue}`, ); invalidColors = true; } } } } else { warnings.push("Theme colors are missing"); invalidColors = true; } if (invalidColors) { themeValid[theme] = false; previewBody += ` \r### ${ themeName.charAt(0).toUpperCase() + themeName.slice(1) } theme preview \r${warnings.map((warning) => `- :warning: ${warning}.\n`).join("")} \r${errors.map((error) => `- :x: ${error}.\n`).join("")} \r>:x: Cannot create theme preview. `; continue; } // Check color contrast. debug("Theme preview body: Check color contrast..."); const titleColor = colors.title_color; const iconColor = colors.icon_color; const textColor = colors.text_color; const bgColor = colors.bg_color; const borderColor = colors.border_color; const url = getGRSLink(colors); const colorPairs = { title_color: [titleColor, bgColor], icon_color: [iconColor, bgColor], text_color: [textColor, bgColor], }; Object.keys(colorPairs).forEach((item) => { let color1 = colorPairs[item][0]; let color2 = colorPairs[item][1]; const isGradientColor = color2.split(",").length > 1; if (isGradientColor) { return; } color1 = color1.length === 4 ? color1.slice(0, 3) : color1.slice(0, 6); color2 = color2.length === 4 ? color2.slice(0, 3) : color2.slice(0, 6); if (!ccc.isLevelAA(`#${color1}`, `#${color2}`)) { const permalink = getWebAimLink(color1, color2); warnings.push( `\`${item}\` does not pass [AA contrast ratio](${permalink})`, ); themeValid[theme] = false; } }); // Create theme preview body. debug("Theme preview body: Create theme preview body..."); previewBody += ` \r### ${ themeName.charAt(0).toUpperCase() + themeName.slice(1) } theme preview \r${warnings.map((warning) => `- :warning: ${warning}.\n`).join("")} \ntitle_color: #${titleColor} | icon_color: #${iconColor} | text_color: #${textColor} | bg_color: #${bgColor}${ borderColor ? ` | border_color: #${borderColor}` : "" } \r[Preview Link](${url}) \r[![](${url})](${url}) `; } // Create comment body. debug("Create comment body..."); commentBody += ` \r${ Object.values(themeValid).every((value) => value) ? THEME_PR_SUCCESS_TEXT : THEME_PR_FAIL_TEXT } \r## Test results \r${Object.entries(themeValid) .map( ([key, value]) => `- ${value ? ":heavy_check_mark:" : ":x:"} ${key}`, ) .join("\r")} \r${ Object.values(themeValid).every((value) => value) ? "**Result:** :heavy_check_mark: All themes are valid." : "**Result:** :x: Some themes are invalid.\n\n" + FAIL_TEXT } \r## Details \r${previewBody} `; // Create or update theme-preview comment. debug("Create or update theme-preview comment..."); let comment_url; if (DRY_RUN) { info(`DRY_RUN: Comment body: ${commentBody}`); comment_url = ""; } else { comment_url = await upsertComment( OCTOKIT, PULL_REQUEST_ID, REPO, OWNER, comment?.id, commentBody, ); } // Change review state and add/remove `invalid` label based on theme PR validity. debug( "Change review state and add/remove `invalid` label based on whether all themes passed...", ); const themesValid = Object.values(themeValid).every((value) => value); const reviewState = themesValid ? "APPROVE" : "REQUEST_CHANGES"; const reviewReason = themesValid ? undefined : INVALID_REVIEW_COMMENT(comment_url); if (DRY_RUN) { info(`DRY_RUN: Review state: ${reviewState}`); info(`DRY_RUN: Review reason: ${reviewReason}`); } else { await addReview( OCTOKIT, PULL_REQUEST_ID, OWNER, REPO, reviewState, reviewReason, ); await addRemoveLabel( OCTOKIT, PULL_REQUEST_ID, OWNER, REPO, "invalid", !themesValid, ); } } catch (error) { debug("Set review state to `REQUEST_CHANGES` and add `invalid` label..."); if (DRY_RUN) { info(`DRY_RUN: Review state: REQUEST_CHANGES`); info(`DRY_RUN: Review reason: ${error.message}`); } else { await addReview( OCTOKIT, PULL_REQUEST_ID, OWNER, REPO, "REQUEST_CHANGES", "**Something went wrong in the theme preview action:** `" + error.message + "`", ); await addRemoveLabel( OCTOKIT, PULL_REQUEST_ID, OWNER, REPO, "invalid", true, ); } setFailed(error.message); } }; run(); ================================================ FILE: scripts/push-theme-readme.sh ================================================ #!/bin/bash set -x set -e export BRANCH_NAME=updated-theme-readme git --version git config --global user.email "no-reply@githubreadmestats.com" git config --global user.name "GitHub Readme Stats Bot" git config --global --add safe.directory ${GITHUB_WORKSPACE} git branch -d $BRANCH_NAME || true git checkout -b $BRANCH_NAME git add --all git commit --no-verify --message "docs(theme): auto update theme readme" git remote add origin-$BRANCH_NAME https://${PERSONAL_TOKEN}@github.com/${GH_REPO}.git git push --force --quiet --set-upstream origin-$BRANCH_NAME $BRANCH_NAME ================================================ FILE: src/calculateRank.js ================================================ /** * Calculates the exponential cdf. * * @param {number} x The value. * @returns {number} The exponential cdf. */ function exponential_cdf(x) { return 1 - 2 ** -x; } /** * Calculates the log normal cdf. * * @param {number} x The value. * @returns {number} The log normal cdf. */ function log_normal_cdf(x) { // approximation return x / (1 + x); } /** * Calculates the users rank. * * @param {object} params Parameters on which the user's rank depends. * @param {boolean} params.all_commits Whether `include_all_commits` was used. * @param {number} params.commits Number of commits. * @param {number} params.prs The number of pull requests. * @param {number} params.issues The number of issues. * @param {number} params.reviews The number of reviews. * @param {number} params.repos Total number of repos. * @param {number} params.stars The number of stars. * @param {number} params.followers The number of followers. * @returns {{ level: string, percentile: number }} The users rank. */ function calculateRank({ all_commits, commits, prs, issues, reviews, // eslint-disable-next-line no-unused-vars repos, // unused stars, followers, }) { const COMMITS_MEDIAN = all_commits ? 1000 : 250, COMMITS_WEIGHT = 2; const PRS_MEDIAN = 50, PRS_WEIGHT = 3; const ISSUES_MEDIAN = 25, ISSUES_WEIGHT = 1; const REVIEWS_MEDIAN = 2, REVIEWS_WEIGHT = 1; const STARS_MEDIAN = 50, STARS_WEIGHT = 4; const FOLLOWERS_MEDIAN = 10, FOLLOWERS_WEIGHT = 1; const TOTAL_WEIGHT = COMMITS_WEIGHT + PRS_WEIGHT + ISSUES_WEIGHT + REVIEWS_WEIGHT + STARS_WEIGHT + FOLLOWERS_WEIGHT; const THRESHOLDS = [1, 12.5, 25, 37.5, 50, 62.5, 75, 87.5, 100]; const LEVELS = ["S", "A+", "A", "A-", "B+", "B", "B-", "C+", "C"]; const rank = 1 - (COMMITS_WEIGHT * exponential_cdf(commits / COMMITS_MEDIAN) + PRS_WEIGHT * exponential_cdf(prs / PRS_MEDIAN) + ISSUES_WEIGHT * exponential_cdf(issues / ISSUES_MEDIAN) + REVIEWS_WEIGHT * exponential_cdf(reviews / REVIEWS_MEDIAN) + STARS_WEIGHT * log_normal_cdf(stars / STARS_MEDIAN) + FOLLOWERS_WEIGHT * log_normal_cdf(followers / FOLLOWERS_MEDIAN)) / TOTAL_WEIGHT; const level = LEVELS[THRESHOLDS.findIndex((t) => rank * 100 <= t)]; return { level, percentile: rank * 100 }; } export { calculateRank }; export default calculateRank; ================================================ FILE: src/cards/gist.js ================================================ // @ts-check import { measureText, flexLayout, iconWithLabel, createLanguageNode, } from "../common/render.js"; import Card from "../common/Card.js"; import { getCardColors } from "../common/color.js"; import { kFormatter, wrapTextMultiline } from "../common/fmt.js"; import { encodeHTML } from "../common/html.js"; import { icons } from "../common/icons.js"; import { parseEmojis } from "../common/ops.js"; /** Import language colors. * * @description Here we use the workaround found in * https://stackoverflow.com/questions/66726365/how-should-i-import-json-in-node * since vercel is using v16.14.0 which does not yet support json imports without the * --experimental-json-modules flag. */ import { createRequire } from "module"; const require = createRequire(import.meta.url); const languageColors = require("../common/languageColors.json"); // now works const ICON_SIZE = 16; const CARD_DEFAULT_WIDTH = 400; const HEADER_MAX_LENGTH = 35; /** * @typedef {import('./types').GistCardOptions} GistCardOptions Gist card options. * @typedef {import('../fetchers/types').GistData} GistData Gist data. */ /** * Render gist card. * * @param {GistData} gistData Gist data. * @param {Partial} options Gist card options. * @returns {string} Gist card. */ const renderGistCard = (gistData, options = {}) => { const { name, nameWithOwner, description, language, starsCount, forksCount } = gistData; const { title_color, icon_color, text_color, bg_color, theme, border_radius, border_color, show_owner = false, hide_border = false, } = options; // returns theme based colors with proper overrides and defaults const { titleColor, textColor, iconColor, bgColor, borderColor } = getCardColors({ title_color, icon_color, text_color, bg_color, border_color, theme, }); const lineWidth = 59; const linesLimit = 10; const desc = parseEmojis(description || "No description provided"); const multiLineDescription = wrapTextMultiline(desc, lineWidth, linesLimit); const descriptionLines = multiLineDescription.length; const descriptionSvg = multiLineDescription .map((line) => `${encodeHTML(line)}`) .join(""); const lineHeight = descriptionLines > 3 ? 12 : 10; const height = (descriptionLines > 1 ? 120 : 110) + descriptionLines * lineHeight; const totalStars = kFormatter(starsCount); const totalForks = kFormatter(forksCount); const svgStars = iconWithLabel( icons.star, totalStars, "starsCount", ICON_SIZE, ); const svgForks = iconWithLabel( icons.fork, totalForks, "forksCount", ICON_SIZE, ); const languageName = language || "Unspecified"; // @ts-ignore const languageColor = languageColors[languageName] || "#858585"; const svgLanguage = createLanguageNode(languageName, languageColor); const starAndForkCount = flexLayout({ items: [svgLanguage, svgStars, svgForks], sizes: [ measureText(languageName, 12), ICON_SIZE + measureText(`${totalStars}`, 12), ICON_SIZE + measureText(`${totalForks}`, 12), ], gap: 25, }).join(""); const header = show_owner ? nameWithOwner : name; const card = new Card({ defaultTitle: header.length > HEADER_MAX_LENGTH ? `${header.slice(0, HEADER_MAX_LENGTH)}...` : header, titlePrefixIcon: icons.gist, width: CARD_DEFAULT_WIDTH, height, border_radius, colors: { titleColor, textColor, iconColor, bgColor, borderColor, }, }); card.setCSS(` .description { font: 400 13px 'Segoe UI', Ubuntu, Sans-Serif; fill: ${textColor} } .gray { font: 400 12px 'Segoe UI', Ubuntu, Sans-Serif; fill: ${textColor} } .icon { fill: ${iconColor} } `); card.setHideBorder(hide_border); return card.render(` ${descriptionSvg} ${starAndForkCount} `); }; export { renderGistCard, HEADER_MAX_LENGTH }; export default renderGistCard; ================================================ FILE: src/cards/index.js ================================================ export { renderRepoCard } from "./repo.js"; export { renderStatsCard } from "./stats.js"; export { renderTopLanguages } from "./top-languages.js"; export { renderWakatimeCard } from "./wakatime.js"; ================================================ FILE: src/cards/repo.js ================================================ // @ts-check import { Card } from "../common/Card.js"; import { getCardColors } from "../common/color.js"; import { kFormatter, wrapTextMultiline } from "../common/fmt.js"; import { encodeHTML } from "../common/html.js"; import { I18n } from "../common/I18n.js"; import { icons } from "../common/icons.js"; import { clampValue, parseEmojis } from "../common/ops.js"; import { flexLayout, measureText, iconWithLabel, createLanguageNode, } from "../common/render.js"; import { repoCardLocales } from "../translations.js"; const ICON_SIZE = 16; const DESCRIPTION_LINE_WIDTH = 59; const DESCRIPTION_MAX_LINES = 3; /** * Retrieves the repository description and wraps it to fit the card width. * * @param {string} label The repository description. * @param {string} textColor The color of the text. * @returns {string} Wrapped repo description SVG object. */ const getBadgeSVG = (label, textColor) => ` ${label} `; /** * @typedef {import("../fetchers/types").RepositoryData} RepositoryData Repository data. * @typedef {import("./types").RepoCardOptions} RepoCardOptions Repo card options. */ /** * Renders repository card details. * * @param {RepositoryData} repo Repository data. * @param {Partial} options Card options. * @returns {string} Repository card SVG object. */ const renderRepoCard = (repo, options = {}) => { const { name, nameWithOwner, description, primaryLanguage, isArchived, isTemplate, starCount, forkCount, } = repo; const { hide_border = false, title_color, icon_color, text_color, bg_color, show_owner = false, theme = "default_repocard", border_radius, border_color, locale, description_lines_count, } = options; const lineHeight = 10; const header = show_owner ? nameWithOwner : name; const langName = (primaryLanguage && primaryLanguage.name) || "Unspecified"; const langColor = (primaryLanguage && primaryLanguage.color) || "#333"; const descriptionMaxLines = description_lines_count ? clampValue(description_lines_count, 1, DESCRIPTION_MAX_LINES) : DESCRIPTION_MAX_LINES; const desc = parseEmojis(description || "No description provided"); const multiLineDescription = wrapTextMultiline( desc, DESCRIPTION_LINE_WIDTH, descriptionMaxLines, ); const descriptionLinesCount = description_lines_count ? clampValue(description_lines_count, 1, DESCRIPTION_MAX_LINES) : multiLineDescription.length; const descriptionSvg = multiLineDescription .map((line) => `${encodeHTML(line)}`) .join(""); const height = (descriptionLinesCount > 1 ? 120 : 110) + descriptionLinesCount * lineHeight; const i18n = new I18n({ locale, translations: repoCardLocales, }); // returns theme based colors with proper overrides and defaults const colors = getCardColors({ title_color, icon_color, text_color, bg_color, border_color, theme, }); const svgLanguage = primaryLanguage ? createLanguageNode(langName, langColor) : ""; const totalStars = kFormatter(starCount); const totalForks = kFormatter(forkCount); const svgStars = iconWithLabel( icons.star, totalStars, "stargazers", ICON_SIZE, ); const svgForks = iconWithLabel( icons.fork, totalForks, "forkcount", ICON_SIZE, ); const starAndForkCount = flexLayout({ items: [svgLanguage, svgStars, svgForks], sizes: [ measureText(langName, 12), ICON_SIZE + measureText(`${totalStars}`, 12), ICON_SIZE + measureText(`${totalForks}`, 12), ], gap: 25, }).join(""); const card = new Card({ defaultTitle: header.length > 35 ? `${header.slice(0, 35)}...` : header, titlePrefixIcon: icons.contribs, width: 400, height, border_radius, colors, }); card.disableAnimations(); card.setHideBorder(hide_border); card.setHideTitle(false); card.setCSS(` .description { font: 400 13px 'Segoe UI', Ubuntu, Sans-Serif; fill: ${colors.textColor} } .gray { font: 400 12px 'Segoe UI', Ubuntu, Sans-Serif; fill: ${colors.textColor} } .icon { fill: ${colors.iconColor} } .badge { font: 600 11px 'Segoe UI', Ubuntu, Sans-Serif; } .badge rect { opacity: 0.2 } `); return card.render(` ${ isTemplate ? // @ts-ignore getBadgeSVG(i18n.t("repocard.template"), colors.textColor) : isArchived ? // @ts-ignore getBadgeSVG(i18n.t("repocard.archived"), colors.textColor) : "" } ${descriptionSvg} ${starAndForkCount} `); }; export { renderRepoCard }; export default renderRepoCard; ================================================ FILE: src/cards/stats.js ================================================ // @ts-check import { Card } from "../common/Card.js"; import { getCardColors } from "../common/color.js"; import { CustomError } from "../common/error.js"; import { kFormatter } from "../common/fmt.js"; import { I18n } from "../common/I18n.js"; import { icons, rankIcon } from "../common/icons.js"; import { clampValue } from "../common/ops.js"; import { flexLayout, measureText } from "../common/render.js"; import { statCardLocales, wakatimeCardLocales } from "../translations.js"; const CARD_MIN_WIDTH = 287; const CARD_DEFAULT_WIDTH = 287; const RANK_CARD_MIN_WIDTH = 420; const RANK_CARD_DEFAULT_WIDTH = 450; const RANK_ONLY_CARD_MIN_WIDTH = 290; const RANK_ONLY_CARD_DEFAULT_WIDTH = 290; /** * Long locales that need more space for text. Keep sorted alphabetically. * * @type {(keyof typeof wakatimeCardLocales["wakatimecard.title"])[]} */ const LONG_LOCALES = [ "az", "bg", "cs", "de", "el", "es", "fil", "fi", "fr", "hu", "id", "ja", "ml", "my", "nl", "pl", "pt-br", "pt-pt", "ru", "sr", "sr-latn", "sw", "ta", "uk-ua", "uz", "zh-tw", ]; /** * Create a stats card text item. * * @param {object} params Object that contains the createTextNode parameters. * @param {string} params.icon The icon to display. * @param {string} params.label The label to display. * @param {number} params.value The value to display. * @param {string} params.id The id of the stat. * @param {string=} params.unitSymbol The unit symbol of the stat. * @param {number} params.index The index of the stat. * @param {boolean} params.showIcons Whether to show icons. * @param {number} params.shiftValuePos Number of pixels the value has to be shifted to the right. * @param {boolean} params.bold Whether to bold the label. * @param {string} params.numberFormat The format of numbers on card. * @param {number=} params.numberPrecision The precision of numbers on card. * @returns {string} The stats card text item SVG object. */ const createTextNode = ({ icon, label, value, id, unitSymbol, index, showIcons, shiftValuePos, bold, numberFormat, numberPrecision, }) => { const precision = typeof numberPrecision === "number" && !isNaN(numberPrecision) ? clampValue(numberPrecision, 0, 2) : undefined; const kValue = numberFormat.toLowerCase() === "long" || id === "prs_merged_percentage" ? value : kFormatter(value, precision); const staggerDelay = (index + 3) * 150; const labelOffset = showIcons ? `x="25"` : ""; const iconSvg = showIcons ? ` ${icon} ` : ""; return ` ${iconSvg} ${label}: ${kValue}${unitSymbol ? ` ${unitSymbol}` : ""} `; }; /** * Calculates progress along the boundary of the circle, i.e. its circumference. * * @param {number} value The rank value to calculate progress for. * @returns {number} Progress value. */ const calculateCircleProgress = (value) => { const radius = 40; const c = Math.PI * (radius * 2); if (value < 0) { value = 0; } if (value > 100) { value = 100; } return ((100 - value) / 100) * c; }; /** * Retrieves the animation to display progress along the circumference of circle * from the beginning to the given value in a clockwise direction. * * @param {{progress: number}} progress The progress value to animate to. * @returns {string} Progress animation css. */ const getProgressAnimation = ({ progress }) => { return ` @keyframes rankAnimation { from { stroke-dashoffset: ${calculateCircleProgress(0)}; } to { stroke-dashoffset: ${calculateCircleProgress(progress)}; } } `; }; /** * Retrieves CSS styles for a card. * * @param {Object} colors The colors to use for the card. * @param {string} colors.titleColor The title color. * @param {string} colors.textColor The text color. * @param {string} colors.iconColor The icon color. * @param {string} colors.ringColor The ring color. * @param {boolean} colors.show_icons Whether to show icons. * @param {number} colors.progress The progress value to animate to. * @returns {string} Card CSS styles. */ const getStyles = ({ // eslint-disable-next-line no-unused-vars titleColor, textColor, iconColor, ringColor, show_icons, progress, }) => { return ` .stat { font: 600 14px 'Segoe UI', Ubuntu, "Helvetica Neue", Sans-Serif; fill: ${textColor}; } @supports(-moz-appearance: auto) { /* Selector detects Firefox */ .stat { font-size:12px; } } .stagger { opacity: 0; animation: fadeInAnimation 0.3s ease-in-out forwards; } .rank-text { font: 800 24px 'Segoe UI', Ubuntu, Sans-Serif; fill: ${textColor}; animation: scaleInAnimation 0.3s ease-in-out forwards; } .rank-percentile-header { font-size: 14px; } .rank-percentile-text { font-size: 16px; } .not_bold { font-weight: 400 } .bold { font-weight: 700 } .icon { fill: ${iconColor}; display: ${show_icons ? "block" : "none"}; } .rank-circle-rim { stroke: ${ringColor}; fill: none; stroke-width: 6; opacity: 0.2; } .rank-circle { stroke: ${ringColor}; stroke-dasharray: 250; fill: none; stroke-width: 6; stroke-linecap: round; opacity: 0.8; transform-origin: -10px 8px; transform: rotate(-90deg); animation: rankAnimation 1s forwards ease-in-out; } ${process.env.NODE_ENV === "test" ? "" : getProgressAnimation({ progress })} `; }; /** * Return the label for commits according to the selected options * * @param {boolean} include_all_commits Option to include all years * @param {number|undefined} commits_year Option to include only selected year * @param {I18n} i18n The I18n instance. * @returns {string} The label corresponding to the options. */ const getTotalCommitsYearLabel = (include_all_commits, commits_year, i18n) => include_all_commits ? "" : commits_year ? ` (${commits_year})` : ` (${i18n.t("wakatimecard.lastyear")})`; /** * @typedef {import('../fetchers/types').StatsData} StatsData * @typedef {import('./types').StatCardOptions} StatCardOptions */ /** * Renders the stats card. * * @param {StatsData} stats The stats data. * @param {Partial} options The card options. * @returns {string} The stats card SVG object. */ const renderStatsCard = (stats, options = {}) => { const { name, totalStars, totalCommits, totalIssues, totalPRs, totalPRsMerged, mergedPRsPercentage, totalReviews, totalDiscussionsStarted, totalDiscussionsAnswered, contributedTo, rank, } = stats; const { hide = [], show_icons = false, hide_title = false, hide_border = false, card_width, hide_rank = false, include_all_commits = false, commits_year, line_height = 25, title_color, ring_color, icon_color, text_color, text_bold = true, bg_color, theme = "default", custom_title, border_radius, border_color, number_format = "short", number_precision, locale, disable_animations = false, rank_icon = "default", show = [], } = options; const lheight = parseInt(String(line_height), 10); // returns theme based colors with proper overrides and defaults const { titleColor, iconColor, textColor, bgColor, borderColor, ringColor } = getCardColors({ title_color, text_color, icon_color, bg_color, border_color, ring_color, theme, }); const apostrophe = /s$/i.test(name.trim()) ? "" : "s"; const i18n = new I18n({ locale, translations: { ...statCardLocales({ name, apostrophe }), ...wakatimeCardLocales, }, }); // Meta data for creating text nodes with createTextNode function const STATS = {}; STATS.stars = { icon: icons.star, label: i18n.t("statcard.totalstars"), value: totalStars, id: "stars", }; STATS.commits = { icon: icons.commits, label: `${i18n.t("statcard.commits")}${getTotalCommitsYearLabel( include_all_commits, commits_year, i18n, )}`, value: totalCommits, id: "commits", }; STATS.prs = { icon: icons.prs, label: i18n.t("statcard.prs"), value: totalPRs, id: "prs", }; if (show.includes("prs_merged")) { STATS.prs_merged = { icon: icons.prs_merged, label: i18n.t("statcard.prs-merged"), value: totalPRsMerged, id: "prs_merged", }; } if (show.includes("prs_merged_percentage")) { STATS.prs_merged_percentage = { icon: icons.prs_merged_percentage, label: i18n.t("statcard.prs-merged-percentage"), value: mergedPRsPercentage.toFixed( typeof number_precision === "number" && !isNaN(number_precision) ? clampValue(number_precision, 0, 2) : 2, ), id: "prs_merged_percentage", unitSymbol: "%", }; } if (show.includes("reviews")) { STATS.reviews = { icon: icons.reviews, label: i18n.t("statcard.reviews"), value: totalReviews, id: "reviews", }; } STATS.issues = { icon: icons.issues, label: i18n.t("statcard.issues"), value: totalIssues, id: "issues", }; if (show.includes("discussions_started")) { STATS.discussions_started = { icon: icons.discussions_started, label: i18n.t("statcard.discussions-started"), value: totalDiscussionsStarted, id: "discussions_started", }; } if (show.includes("discussions_answered")) { STATS.discussions_answered = { icon: icons.discussions_answered, label: i18n.t("statcard.discussions-answered"), value: totalDiscussionsAnswered, id: "discussions_answered", }; } STATS.contribs = { icon: icons.contribs, label: i18n.t("statcard.contribs"), value: contributedTo, id: "contribs", }; // @ts-ignore const isLongLocale = locale ? LONG_LOCALES.includes(locale) : false; // filter out hidden stats defined by user & create the text nodes const statItems = Object.keys(STATS) .filter((key) => !hide.includes(key)) .map((key, index) => { // @ts-ignore const stats = STATS[key]; // create the text nodes, and pass index so that we can calculate the line spacing return createTextNode({ icon: stats.icon, label: stats.label, value: stats.value, id: stats.id, unitSymbol: stats.unitSymbol, index, showIcons: show_icons, shiftValuePos: 79.01 + (isLongLocale ? 50 : 0), bold: text_bold, numberFormat: number_format, numberPrecision: number_precision, }); }); if (statItems.length === 0 && hide_rank) { throw new CustomError( "Could not render stats card.", "Either stats or rank are required.", ); } // Calculate the card height depending on how many items there are // but if rank circle is visible clamp the minimum height to `150` let height = Math.max( 45 + (statItems.length + 1) * lheight, hide_rank ? 0 : statItems.length ? 150 : 180, ); // the lower the user's percentile the better const progress = 100 - rank.percentile; const cssStyles = getStyles({ titleColor, ringColor, textColor, iconColor, show_icons, progress, }); const calculateTextWidth = () => { return measureText( custom_title ? custom_title : statItems.length ? i18n.t("statcard.title") : i18n.t("statcard.ranktitle"), ); }; /* When hide_rank=true, the minimum card width is 270 px + the title length and padding. When hide_rank=false, the minimum card_width is 340 px + the icon width (if show_icons=true). Numbers are picked by looking at existing dimensions on production. */ const iconWidth = show_icons && statItems.length ? 16 + /* padding */ 1 : 0; const minCardWidth = (hide_rank ? clampValue( 50 /* padding */ + calculateTextWidth() * 2, CARD_MIN_WIDTH, Infinity, ) : statItems.length ? RANK_CARD_MIN_WIDTH : RANK_ONLY_CARD_MIN_WIDTH) + iconWidth; const defaultCardWidth = (hide_rank ? CARD_DEFAULT_WIDTH : statItems.length ? RANK_CARD_DEFAULT_WIDTH : RANK_ONLY_CARD_DEFAULT_WIDTH) + iconWidth; let width = card_width ? isNaN(card_width) ? defaultCardWidth : card_width : defaultCardWidth; if (width < minCardWidth) { width = minCardWidth; } const card = new Card({ customTitle: custom_title, defaultTitle: statItems.length ? i18n.t("statcard.title") : i18n.t("statcard.ranktitle"), width, height, border_radius, colors: { titleColor, textColor, iconColor, bgColor, borderColor, }, }); card.setHideBorder(hide_border); card.setHideTitle(hide_title); card.setCSS(cssStyles); if (disable_animations) { card.disableAnimations(); } /** * Calculates the right rank circle translation values such that the rank circle * keeps respecting the following padding: * * width > RANK_CARD_DEFAULT_WIDTH: The default right padding of 70 px will be used. * width < RANK_CARD_DEFAULT_WIDTH: The left and right padding will be enlarged * equally from a certain minimum at RANK_CARD_MIN_WIDTH. * * @returns {number} - Rank circle translation value. */ const calculateRankXTranslation = () => { if (statItems.length) { const minXTranslation = RANK_CARD_MIN_WIDTH + iconWidth - 70; if (width > RANK_CARD_DEFAULT_WIDTH) { const xMaxExpansion = minXTranslation + (450 - minCardWidth) / 2; return xMaxExpansion + width - RANK_CARD_DEFAULT_WIDTH; } else { return minXTranslation + (width - minCardWidth) / 2; } } else { return width / 2 + 20 - 10; } }; // Conditionally rendered elements const rankCircle = hide_rank ? "" : ` ${rankIcon(rank_icon, rank?.level, rank?.percentile)} `; // Accessibility Labels const labels = Object.keys(STATS) .filter((key) => !hide.includes(key)) .map((key) => { // @ts-ignore const stats = STATS[key]; if (key === "commits") { return `${i18n.t("statcard.commits")} ${getTotalCommitsYearLabel( include_all_commits, commits_year, i18n, )} : ${stats.value}`; } return `${stats.label}: ${stats.value}`; }) .join(", "); card.setAccessibilityLabel({ title: `${card.title}, Rank: ${rank.level}`, desc: labels, }); return card.render(` ${rankCircle} ${flexLayout({ items: statItems, gap: lheight, direction: "column", }).join("")} `); }; export { renderStatsCard }; export default renderStatsCard; ================================================ FILE: src/cards/top-languages.js ================================================ // @ts-check import { Card } from "../common/Card.js"; import { getCardColors } from "../common/color.js"; import { formatBytes } from "../common/fmt.js"; import { I18n } from "../common/I18n.js"; import { chunkArray, clampValue, lowercaseTrim } from "../common/ops.js"; import { createProgressNode, flexLayout, measureText, } from "../common/render.js"; import { langCardLocales } from "../translations.js"; const DEFAULT_CARD_WIDTH = 300; const MIN_CARD_WIDTH = 280; const DEFAULT_LANG_COLOR = "#858585"; const CARD_PADDING = 25; const COMPACT_LAYOUT_BASE_HEIGHT = 90; const MAXIMUM_LANGS_COUNT = 20; const NORMAL_LAYOUT_DEFAULT_LANGS_COUNT = 5; const COMPACT_LAYOUT_DEFAULT_LANGS_COUNT = 6; const DONUT_LAYOUT_DEFAULT_LANGS_COUNT = 5; const PIE_LAYOUT_DEFAULT_LANGS_COUNT = 6; const DONUT_VERTICAL_LAYOUT_DEFAULT_LANGS_COUNT = 6; /** * @typedef {import("../fetchers/types").Lang} Lang */ /** * Retrieves the programming language whose name is the longest. * * @param {Lang[]} arr Array of programming languages. * @returns {{ name: string, size: number, color: string }} Longest programming language object. */ const getLongestLang = (arr) => arr.reduce( (savedLang, lang) => lang.name.length > savedLang.name.length ? lang : savedLang, { name: "", size: 0, color: "" }, ); /** * Convert degrees to radians. * * @param {number} angleInDegrees Angle in degrees. * @returns {number} Angle in radians. */ const degreesToRadians = (angleInDegrees) => angleInDegrees * (Math.PI / 180.0); /** * Convert radians to degrees. * * @param {number} angleInRadians Angle in radians. * @returns {number} Angle in degrees. */ const radiansToDegrees = (angleInRadians) => angleInRadians / (Math.PI / 180.0); /** * Convert polar coordinates to cartesian coordinates. * * @param {number} centerX Center x coordinate. * @param {number} centerY Center y coordinate. * @param {number} radius Radius of the circle. * @param {number} angleInDegrees Angle in degrees. * @returns {{x: number, y: number}} Cartesian coordinates. */ const polarToCartesian = (centerX, centerY, radius, angleInDegrees) => { const rads = degreesToRadians(angleInDegrees); return { x: centerX + radius * Math.cos(rads), y: centerY + radius * Math.sin(rads), }; }; /** * Convert cartesian coordinates to polar coordinates. * * @param {number} centerX Center x coordinate. * @param {number} centerY Center y coordinate. * @param {number} x Point x coordinate. * @param {number} y Point y coordinate. * @returns {{radius: number, angleInDegrees: number}} Polar coordinates. */ const cartesianToPolar = (centerX, centerY, x, y) => { const radius = Math.sqrt(Math.pow(x - centerX, 2) + Math.pow(y - centerY, 2)); let angleInDegrees = radiansToDegrees(Math.atan2(y - centerY, x - centerX)); if (angleInDegrees < 0) { angleInDegrees += 360; } return { radius, angleInDegrees }; }; /** * Calculates length of circle. * * @param {number} radius Radius of the circle. * @returns {number} The length of the circle. */ const getCircleLength = (radius) => { return 2 * Math.PI * radius; }; /** * Calculates height for the compact layout. * * @param {number} totalLangs Total number of languages. * @returns {number} Card height. */ const calculateCompactLayoutHeight = (totalLangs) => { return COMPACT_LAYOUT_BASE_HEIGHT + Math.round(totalLangs / 2) * 25; }; /** * Calculates height for the normal layout. * * @param {number} totalLangs Total number of languages. * @returns {number} Card height. */ const calculateNormalLayoutHeight = (totalLangs) => { return 45 + (totalLangs + 1) * 40; }; /** * Calculates height for the donut layout. * * @param {number} totalLangs Total number of languages. * @returns {number} Card height. */ const calculateDonutLayoutHeight = (totalLangs) => { return 215 + Math.max(totalLangs - 5, 0) * 32; }; /** * Calculates height for the donut vertical layout. * * @param {number} totalLangs Total number of languages. * @returns {number} Card height. */ const calculateDonutVerticalLayoutHeight = (totalLangs) => { return 300 + Math.round(totalLangs / 2) * 25; }; /** * Calculates height for the pie layout. * * @param {number} totalLangs Total number of languages. * @returns {number} Card height. */ const calculatePieLayoutHeight = (totalLangs) => { return 300 + Math.round(totalLangs / 2) * 25; }; /** * Calculates the center translation needed to keep the donut chart centred. * @param {number} totalLangs Total number of languages. * @returns {number} Donut center translation. */ const donutCenterTranslation = (totalLangs) => { return -45 + Math.max(totalLangs - 5, 0) * 16; }; /** * Trim top languages to lang_count while also hiding certain languages. * * @param {Record} topLangs Top languages. * @param {number} langs_count Number of languages to show. * @param {string[]=} hide Languages to hide. * @returns {{ langs: Lang[], totalLanguageSize: number }} Trimmed top languages and total size. */ const trimTopLanguages = (topLangs, langs_count, hide) => { let langs = Object.values(topLangs); let langsToHide = {}; let langsCount = clampValue(langs_count, 1, MAXIMUM_LANGS_COUNT); // populate langsToHide map for quick lookup // while filtering out if (hide) { hide.forEach((langName) => { // @ts-ignore langsToHide[lowercaseTrim(langName)] = true; }); } // filter out languages to be hidden langs = langs .sort((a, b) => b.size - a.size) .filter((lang) => { // @ts-ignore return !langsToHide[lowercaseTrim(lang.name)]; }) .slice(0, langsCount); const totalLanguageSize = langs.reduce((acc, curr) => acc + curr.size, 0); return { langs, totalLanguageSize }; }; /** * Get display value corresponding to the format. * * @param {number} size Bytes size. * @param {number} percentages Percentage value. * @param {string} format Format of the stats. * @returns {string} Display value. */ const getDisplayValue = (size, percentages, format) => { return format === "bytes" ? formatBytes(size) : `${percentages.toFixed(2)}%`; }; /** * Create progress bar text item for a programming language. * * @param {object} props Function properties. * @param {number} props.width The card width * @param {string} props.color Color of the programming language. * @param {string} props.name Name of the programming language. * @param {number} props.size Size of the programming language. * @param {number} props.totalSize Total size of all languages. * @param {string} props.statsFormat Stats format. * @param {number} props.index Index of the programming language. * @returns {string} Programming language SVG node. */ const createProgressTextNode = ({ width, color, name, size, totalSize, statsFormat, index, }) => { const staggerDelay = (index + 3) * 150; const paddingRight = 95; const progressTextX = width - paddingRight + 10; const progressWidth = width - paddingRight; const progress = (size / totalSize) * 100; const displayValue = getDisplayValue(size, progress, statsFormat); return ` ${name} ${displayValue} ${createProgressNode({ x: 0, y: 25, color, width: progressWidth, progress, progressBarBackgroundColor: "#ddd", delay: staggerDelay + 300, })} `; }; /** * Creates compact text item for a programming language. * * @param {object} props Function properties. * @param {Lang} props.lang Programming language object. * @param {number} props.totalSize Total size of all languages. * @param {boolean=} props.hideProgress Whether to hide percentage. * @param {string=} props.statsFormat Stats format * @param {number} props.index Index of the programming language. * @returns {string} Compact layout programming language SVG node. */ const createCompactLangNode = ({ lang, totalSize, hideProgress, statsFormat = "percentages", index, }) => { const percentages = (lang.size / totalSize) * 100; const displayValue = getDisplayValue(lang.size, percentages, statsFormat); const staggerDelay = (index + 3) * 150; const color = lang.color || "#858585"; return ` ${lang.name} ${hideProgress ? "" : displayValue} `; }; /** * Create compact languages text items for all programming languages. * * @param {object} props Function properties. * @param {Lang[]} props.langs Array of programming languages. * @param {number} props.totalSize Total size of all languages. * @param {boolean=} props.hideProgress Whether to hide percentage. * @param {string=} props.statsFormat Stats format * @returns {string} Programming languages SVG node. */ const createLanguageTextNode = ({ langs, totalSize, hideProgress, statsFormat, }) => { const longestLang = getLongestLang(langs); const chunked = chunkArray(langs, langs.length / 2); const layouts = chunked.map((array) => { // @ts-ignore const items = array.map((lang, index) => createCompactLangNode({ lang, totalSize, hideProgress, statsFormat, index, }), ); return flexLayout({ items, gap: 25, direction: "column", }).join(""); }); const percent = ((longestLang.size / totalSize) * 100).toFixed(2); const minGap = 150; const maxGap = 20 + measureText(`${longestLang.name} ${percent}%`, 11); return flexLayout({ items: layouts, gap: maxGap < minGap ? minGap : maxGap, }).join(""); }; /** * Create donut languages text items for all programming languages. * * @param {object} props Function properties. * @param {Lang[]} props.langs Array of programming languages. * @param {number} props.totalSize Total size of all languages. * @param {string} props.statsFormat Stats format * @returns {string} Donut layout programming language SVG node. */ const createDonutLanguagesNode = ({ langs, totalSize, statsFormat }) => { return flexLayout({ items: langs.map((lang, index) => { return createCompactLangNode({ lang, totalSize, hideProgress: false, statsFormat, index, }); }), gap: 32, direction: "column", }).join(""); }; /** * Renders the default language card layout. * * @param {Lang[]} langs Array of programming languages. * @param {number} width Card width. * @param {number} totalLanguageSize Total size of all languages. * @param {string} statsFormat Stats format. * @returns {string} Normal layout card SVG object. */ const renderNormalLayout = (langs, width, totalLanguageSize, statsFormat) => { return flexLayout({ items: langs.map((lang, index) => { return createProgressTextNode({ width, name: lang.name, color: lang.color || DEFAULT_LANG_COLOR, size: lang.size, totalSize: totalLanguageSize, statsFormat, index, }); }), gap: 40, direction: "column", }).join(""); }; /** * Renders the compact language card layout. * * @param {Lang[]} langs Array of programming languages. * @param {number} width Card width. * @param {number} totalLanguageSize Total size of all languages. * @param {boolean=} hideProgress Whether to hide progress bar. * @param {string} statsFormat Stats format. * @returns {string} Compact layout card SVG object. */ const renderCompactLayout = ( langs, width, totalLanguageSize, hideProgress, statsFormat = "percentages", ) => { const paddingRight = 50; const offsetWidth = width - paddingRight; // progressOffset holds the previous language's width and used to offset the next language // so that we can stack them one after another, like this: [--][----][---] let progressOffset = 0; const compactProgressBar = langs .map((lang) => { const percentage = parseFloat( ((lang.size / totalLanguageSize) * offsetWidth).toFixed(2), ); const progress = percentage < 10 ? percentage + 10 : percentage; const output = ` `; progressOffset += percentage; return output; }) .join(""); return ` ${ hideProgress ? "" : ` ${compactProgressBar} ` } ${createLanguageTextNode({ langs, totalSize: totalLanguageSize, hideProgress, statsFormat, })} `; }; /** * Renders donut vertical layout to display user's most frequently used programming languages. * * @param {Lang[]} langs Array of programming languages. * @param {number} totalLanguageSize Total size of all languages. * @param {string} statsFormat Stats format. * @returns {string} Compact layout card SVG object. */ const renderDonutVerticalLayout = (langs, totalLanguageSize, statsFormat) => { // Donut vertical chart radius and total length const radius = 80; const totalCircleLength = getCircleLength(radius); // SVG circles let circles = []; // Start indent for donut vertical chart parts let indent = 0; // Start delay coefficient for donut vertical chart parts let startDelayCoefficient = 1; // Generate each donut vertical chart part for (const lang of langs) { const percentage = (lang.size / totalLanguageSize) * 100; const circleLength = totalCircleLength * (percentage / 100); const delay = startDelayCoefficient * 100; circles.push(` `); // Update the indent for the next part indent += circleLength; // Update the start delay coefficient for the next part startDelayCoefficient += 1; } return ` ${circles.join("")} ${createLanguageTextNode({ langs, totalSize: totalLanguageSize, hideProgress: false, statsFormat, })} `; }; /** * Renders pie layout to display user's most frequently used programming languages. * * @param {Lang[]} langs Array of programming languages. * @param {number} totalLanguageSize Total size of all languages. * @param {string} statsFormat Stats format. * @returns {string} Compact layout card SVG object. */ const renderPieLayout = (langs, totalLanguageSize, statsFormat) => { // Pie chart radius and center coordinates const radius = 90; const centerX = 150; const centerY = 100; // Start angle for the pie chart parts let startAngle = 0; // Start delay coefficient for the pie chart parts let startDelayCoefficient = 1; // SVG paths const paths = []; // Generate each pie chart part for (const lang of langs) { if (langs.length === 1) { paths.push(` `); break; } const langSizePart = lang.size / totalLanguageSize; const percentage = langSizePart * 100; // Calculate the angle for the current part const angle = langSizePart * 360; // Calculate the end angle const endAngle = startAngle + angle; // Calculate the coordinates of the start and end points of the arc const startPoint = polarToCartesian(centerX, centerY, radius, startAngle); const endPoint = polarToCartesian(centerX, centerY, radius, endAngle); // Determine the large arc flag based on the angle const largeArcFlag = angle > 180 ? 1 : 0; // Calculate delay const delay = startDelayCoefficient * 100; // SVG arc markup paths.push(` `); // Update the start angle for the next part startAngle = endAngle; // Update the start delay coefficient for the next part startDelayCoefficient += 1; } return ` ${paths.join("")} ${createLanguageTextNode({ langs, totalSize: totalLanguageSize, hideProgress: false, statsFormat, })} `; }; /** * Creates the SVG paths for the language donut chart. * * @param {number} cx Donut center x-position. * @param {number} cy Donut center y-position. * @param {number} radius Donut arc Radius. * @param {number[]} percentages Array with donut section percentages. * @returns {{d: string, percent: number}[]} Array of svg path elements */ const createDonutPaths = (cx, cy, radius, percentages) => { const paths = []; let startAngle = 0; let endAngle = 0; const totalPercent = percentages.reduce((acc, curr) => acc + curr, 0); for (let i = 0; i < percentages.length; i++) { const tmpPath = {}; let percent = parseFloat( ((percentages[i] / totalPercent) * 100).toFixed(2), ); endAngle = 3.6 * percent + startAngle; const startPoint = polarToCartesian(cx, cy, radius, endAngle - 90); // rotate donut 90 degrees counter-clockwise. const endPoint = polarToCartesian(cx, cy, radius, startAngle - 90); // rotate donut 90 degrees counter-clockwise. const largeArc = endAngle - startAngle <= 180 ? 0 : 1; tmpPath.percent = percent; tmpPath.d = `M ${startPoint.x} ${startPoint.y} A ${radius} ${radius} 0 ${largeArc} 0 ${endPoint.x} ${endPoint.y}`; paths.push(tmpPath); startAngle = endAngle; } return paths; }; /** * Renders the donut language card layout. * * @param {Lang[]} langs Array of programming languages. * @param {number} width Card width. * @param {number} totalLanguageSize Total size of all languages. * @param {string} statsFormat Stats format. * @returns {string} Donut layout card SVG object. */ const renderDonutLayout = (langs, width, totalLanguageSize, statsFormat) => { const centerX = width / 3; const centerY = width / 3; const radius = centerX - 60; const strokeWidth = 12; const colors = langs.map((lang) => lang.color); const langsPercents = langs.map((lang) => parseFloat(((lang.size / totalLanguageSize) * 100).toFixed(2)), ); const langPaths = createDonutPaths(centerX, centerY, radius, langsPercents); const donutPaths = langs.length === 1 ? `` : langPaths .map((section, index) => { const staggerDelay = (index + 3) * 100; const delay = staggerDelay + 300; const output = ` `; return output; }) .join(""); const donut = `${donutPaths}`; return ` ${createDonutLanguagesNode({ langs, totalSize: totalLanguageSize, statsFormat })} ${donut} `; }; /** * @typedef {import("./types").TopLangOptions} TopLangOptions * @typedef {TopLangOptions["layout"]} Layout */ /** * Creates the no languages data SVG node. * * @param {object} props Object with function properties. * @param {string} props.color No languages data text color. * @param {string} props.text No languages data translated text. * @param {Layout | undefined} props.layout Card layout. * @returns {string} No languages data SVG node string. */ const noLanguagesDataNode = ({ color, text, layout }) => { return ` ${text} `; }; /** * Get default languages count for provided card layout. * * @param {object} props Function properties. * @param {Layout=} props.layout Input layout string. * @param {boolean=} props.hide_progress Input hide_progress parameter value. * @returns {number} Default languages count for input layout. */ const getDefaultLanguagesCountByLayout = ({ layout, hide_progress }) => { if (layout === "compact" || hide_progress === true) { return COMPACT_LAYOUT_DEFAULT_LANGS_COUNT; } else if (layout === "donut") { return DONUT_LAYOUT_DEFAULT_LANGS_COUNT; } else if (layout === "donut-vertical") { return DONUT_VERTICAL_LAYOUT_DEFAULT_LANGS_COUNT; } else if (layout === "pie") { return PIE_LAYOUT_DEFAULT_LANGS_COUNT; } else { return NORMAL_LAYOUT_DEFAULT_LANGS_COUNT; } }; /** * @typedef {import('../fetchers/types').TopLangData} TopLangData */ /** * Renders card that display user's most frequently used programming languages. * * @param {TopLangData} topLangs User's most frequently used programming languages. * @param {Partial} options Card options. * @returns {string} Language card SVG object. */ const renderTopLanguages = (topLangs, options = {}) => { const { hide_title = false, hide_border = false, card_width, title_color, text_color, bg_color, hide, hide_progress, theme, layout, custom_title, locale, langs_count = getDefaultLanguagesCountByLayout({ layout, hide_progress }), border_radius, border_color, disable_animations, stats_format = "percentages", } = options; const i18n = new I18n({ locale, translations: langCardLocales, }); const { langs, totalLanguageSize } = trimTopLanguages( topLangs, langs_count, hide, ); let width = card_width ? isNaN(card_width) ? DEFAULT_CARD_WIDTH : card_width < MIN_CARD_WIDTH ? MIN_CARD_WIDTH : card_width : DEFAULT_CARD_WIDTH; let height = calculateNormalLayoutHeight(langs.length); // returns theme based colors with proper overrides and defaults const colors = getCardColors({ title_color, text_color, bg_color, border_color, theme, }); let finalLayout = ""; if (langs.length === 0) { height = COMPACT_LAYOUT_BASE_HEIGHT; finalLayout = noLanguagesDataNode({ color: colors.textColor, text: i18n.t("langcard.nodata"), layout, }); } else if (layout === "pie") { height = calculatePieLayoutHeight(langs.length); finalLayout = renderPieLayout(langs, totalLanguageSize, stats_format); } else if (layout === "donut-vertical") { height = calculateDonutVerticalLayoutHeight(langs.length); finalLayout = renderDonutVerticalLayout( langs, totalLanguageSize, stats_format, ); } else if (layout === "compact" || hide_progress == true) { height = calculateCompactLayoutHeight(langs.length) + (hide_progress ? -25 : 0); finalLayout = renderCompactLayout( langs, width, totalLanguageSize, hide_progress, stats_format, ); } else if (layout === "donut") { height = calculateDonutLayoutHeight(langs.length); width = width + 50; // padding finalLayout = renderDonutLayout( langs, width, totalLanguageSize, stats_format, ); } else { finalLayout = renderNormalLayout( langs, width, totalLanguageSize, stats_format, ); } const card = new Card({ customTitle: custom_title, defaultTitle: i18n.t("langcard.title"), width, height, border_radius, colors, }); if (disable_animations) { card.disableAnimations(); } card.setHideBorder(hide_border); card.setHideTitle(hide_title); card.setCSS( ` @keyframes slideInAnimation { from { width: 0; } to { width: calc(100%-100px); } } @keyframes growWidthAnimation { from { width: 0; } to { width: 100%; } } .stat { font: 600 14px 'Segoe UI', Ubuntu, "Helvetica Neue", Sans-Serif; fill: ${colors.textColor}; } @supports(-moz-appearance: auto) { /* Selector detects Firefox */ .stat { font-size:12px; } } .bold { font-weight: 700 } .lang-name { font: 400 11px "Segoe UI", Ubuntu, Sans-Serif; fill: ${colors.textColor}; } .stagger { opacity: 0; animation: fadeInAnimation 0.3s ease-in-out forwards; } #rect-mask rect{ animation: slideInAnimation 1s ease-in-out forwards; } .lang-progress{ animation: growWidthAnimation 0.6s ease-in-out forwards; } `, ); if (layout === "pie" || layout === "donut-vertical") { return card.render(finalLayout); } return card.render(` ${finalLayout} `); }; export { getLongestLang, degreesToRadians, radiansToDegrees, polarToCartesian, cartesianToPolar, getCircleLength, calculateCompactLayoutHeight, calculateNormalLayoutHeight, calculateDonutLayoutHeight, calculateDonutVerticalLayoutHeight, calculatePieLayoutHeight, donutCenterTranslation, trimTopLanguages, renderTopLanguages, MIN_CARD_WIDTH, getDefaultLanguagesCountByLayout, }; ================================================ FILE: src/cards/types.d.ts ================================================ type ThemeNames = keyof typeof import("../../themes/index.js"); type RankIcon = "default" | "github" | "percentile"; export type CommonOptions = { title_color: string; icon_color: string; text_color: string; bg_color: string; theme: ThemeNames; border_radius: number; border_color: string; locale: string; hide_border: boolean; }; export type StatCardOptions = CommonOptions & { hide: string[]; show_icons: boolean; hide_title: boolean; card_width: number; hide_rank: boolean; include_all_commits: boolean; commits_year: number; line_height: number | string; custom_title: string; disable_animations: boolean; number_format: string; number_precision: number; ring_color: string; text_bold: boolean; rank_icon: RankIcon; show: string[]; }; export type RepoCardOptions = CommonOptions & { show_owner: boolean; description_lines_count: number; }; export type TopLangOptions = CommonOptions & { hide_title: boolean; card_width: number; hide: string[]; layout: "compact" | "normal" | "donut" | "donut-vertical" | "pie"; custom_title: string; langs_count: number; disable_animations: boolean; hide_progress: boolean; stats_format: "percentages" | "bytes"; }; export type WakaTimeOptions = CommonOptions & { hide_title: boolean; hide: string[]; card_width: number; line_height: string; hide_progress: boolean; custom_title: string; layout: "compact" | "normal"; langs_count: number; display_format: "time" | "percent"; disable_animations: boolean; }; export type GistCardOptions = CommonOptions & { show_owner: boolean; }; ================================================ FILE: src/cards/wakatime.js ================================================ // @ts-check import { Card } from "../common/Card.js"; import { getCardColors } from "../common/color.js"; import { I18n } from "../common/I18n.js"; import { clampValue, lowercaseTrim } from "../common/ops.js"; import { createProgressNode, flexLayout } from "../common/render.js"; import { wakatimeCardLocales } from "../translations.js"; /** Import language colors. * * @description Here we use the workaround found in * https://stackoverflow.com/questions/66726365/how-should-i-import-json-in-node * since vercel is using v16.14.0 which does not yet support json imports without the * --experimental-json-modules flag. */ import { createRequire } from "module"; const require = createRequire(import.meta.url); const languageColors = require("../common/languageColors.json"); // now works const DEFAULT_CARD_WIDTH = 495; const MIN_CARD_WIDTH = 250; const COMPACT_LAYOUT_MIN_WIDTH = 400; const DEFAULT_LINE_HEIGHT = 25; const PROGRESSBAR_PADDING = 130; const HIDDEN_PROGRESSBAR_PADDING = 170; const COMPACT_LAYOUT_PROGRESSBAR_PADDING = 25; const TOTAL_TEXT_WIDTH = 275; /** * Creates the no coding activity SVG node. * * @param {object} props The function properties. * @param {string} props.color No coding activity text color. * @param {string} props.text No coding activity translated text. * @returns {string} No coding activity SVG node string. */ const noCodingActivityNode = ({ color, text }) => { return ` ${text} `; }; /** * @typedef {import('../fetchers/types').WakaTimeLang} WakaTimeLang */ /** * Format language value. * * @param {Object} args The function arguments. * @param {WakaTimeLang} args.lang The language object. * @param {"time" | "percent"} args.display_format The display format of the language node. * @returns {string} The formatted language value. */ const formatLanguageValue = ({ display_format, lang }) => { return display_format === "percent" ? `${lang.percent.toFixed(2).toString()} %` : lang.text; }; /** * Create compact WakaTime layout. * * @param {Object} args The function arguments. * @param {WakaTimeLang} args.lang The languages array. * @param {number} args.x The x position of the language node. * @param {number} args.y The y position of the language node. * @param {"time" | "percent"} args.display_format The display format of the language node. * @returns {string} The compact layout language SVG node. */ const createCompactLangNode = ({ lang, x, y, display_format }) => { // @ts-ignore const color = languageColors[lang.name] || "#858585"; const value = formatLanguageValue({ display_format, lang }); return ` ${lang.name} - ${value} `; }; /** * Create WakaTime language text node item. * * @param {Object} args The function arguments. * @param {WakaTimeLang[]} args.langs The language objects. * @param {number} args.y The y position of the language node. * @param {"time" | "percent"} args.display_format The display format of the language node. * @param {number} args.card_width Width in px of the card. * @returns {string[]} The language text node items. */ const createLanguageTextNode = ({ langs, y, display_format, card_width }) => { const LEFT_X = 25; const RIGHT_X_BASE = 230; const rightOffset = (card_width - DEFAULT_CARD_WIDTH) / 2; const RIGHT_X = RIGHT_X_BASE + rightOffset; return langs.map((lang, index) => { const isLeft = index % 2 === 0; return createCompactLangNode({ lang, x: isLeft ? LEFT_X : RIGHT_X, y: y + DEFAULT_LINE_HEIGHT * Math.floor(index / 2), display_format, }); }); }; /** * Create WakaTime text item. * * @param {Object} args The function arguments. * @param {string} args.id The id of the text node item. * @param {string} args.label The label of the text node item. * @param {string} args.value The value of the text node item. * @param {number} args.index The index of the text node item. * @param {number} args.percent Percentage of the text node item. * @param {boolean=} args.hideProgress Whether to hide the progress bar. * @param {string} args.progressBarColor The color of the progress bar. * @param {string} args.progressBarBackgroundColor The color of the progress bar background. * @param {number} args.progressBarWidth The width of the progress bar. * @returns {string} The text SVG node. */ const createTextNode = ({ id, label, value, index, percent, hideProgress, progressBarColor, progressBarBackgroundColor, progressBarWidth, }) => { const staggerDelay = (index + 3) * 150; const cardProgress = hideProgress ? null : createProgressNode({ x: 110, y: 4, progress: percent, color: progressBarColor, width: progressBarWidth, // @ts-ignore name: label, progressBarBackgroundColor, delay: staggerDelay + 300, }); return ` ${label}: ${value} ${cardProgress} `; }; /** * Recalculating percentages so that, compact layout's progress bar does not break when * hiding languages. * * @param {WakaTimeLang[]} languages The languages array. * @returns {void} The recalculated languages array. */ const recalculatePercentages = (languages) => { const totalSum = languages.reduce( (totalSum, language) => totalSum + language.percent, 0, ); const weight = +(100 / totalSum).toFixed(2); languages.forEach((language) => { language.percent = +(language.percent * weight).toFixed(2); }); }; /** * Retrieves CSS styles for a card. * * @param {Object} colors The colors to use for the card. * @param {string} colors.titleColor The title color. * @param {string} colors.textColor The text color. * @returns {string} Card CSS styles. */ const getStyles = ({ // eslint-disable-next-line no-unused-vars titleColor, textColor, }) => { return ` .stat { font: 600 14px 'Segoe UI', Ubuntu, "Helvetica Neue", Sans-Serif; fill: ${textColor}; } @supports(-moz-appearance: auto) { /* Selector detects Firefox */ .stat { font-size:12px; } } .stagger { opacity: 0; animation: fadeInAnimation 0.3s ease-in-out forwards; } .not_bold { font-weight: 400 } .bold { font-weight: 700 } `; }; /** * Normalize incoming width (string or number) and clamp to minimum. * * @param {Object} args The function arguments. * @param {WakaTimeOptions["layout"] | undefined} args.layout The incoming layout value. * @param {number|undefined} args.value The incoming width value. * @returns {number} The normalized width value. */ const normalizeCardWidth = ({ value, layout }) => { if (value === undefined || value === null || isNaN(value)) { return DEFAULT_CARD_WIDTH; } return Math.max( layout === "compact" ? COMPACT_LAYOUT_MIN_WIDTH : MIN_CARD_WIDTH, value, ); }; /** * @typedef {import('../fetchers/types').WakaTimeData} WakaTimeData * @typedef {import('./types').WakaTimeOptions} WakaTimeOptions */ /** * Renders WakaTime card. * * @param {Partial} stats WakaTime stats. * @param {Partial} options Card options. * @returns {string} WakaTime card SVG. */ const renderWakatimeCard = (stats = {}, options = { hide: [] }) => { let { languages = [] } = stats; const { hide_title = false, hide_border = false, card_width, hide, line_height = DEFAULT_LINE_HEIGHT, title_color, icon_color, text_color, bg_color, theme = "default", hide_progress, custom_title, locale, layout, langs_count = languages.length, border_radius, border_color, display_format = "time", disable_animations, } = options; const normalizedWidth = normalizeCardWidth({ value: card_width, layout }); const shouldHideLangs = Array.isArray(hide) && hide.length > 0; if (shouldHideLangs) { const languagesToHide = new Set(hide.map((lang) => lowercaseTrim(lang))); languages = languages.filter( (lang) => !languagesToHide.has(lowercaseTrim(lang.name)), ); } // Since the percentages are sorted in descending order, we can just // slice from the beginning without sorting. languages = languages.slice(0, langs_count); recalculatePercentages(languages); const i18n = new I18n({ locale, translations: wakatimeCardLocales, }); const lheight = parseInt(String(line_height), 10); const langsCount = clampValue(langs_count, 1, langs_count); // returns theme based colors with proper overrides and defaults const { titleColor, textColor, iconColor, bgColor, borderColor } = getCardColors({ title_color, icon_color, text_color, bg_color, border_color, theme, }); const filteredLanguages = languages .filter((language) => language.hours || language.minutes) .slice(0, langsCount); // Calculate the card height depending on how many items there are // but if rank circle is visible clamp the minimum height to `150` let height = Math.max(45 + (filteredLanguages.length + 1) * lheight, 150); const cssStyles = getStyles({ titleColor, textColor, }); let finalLayout = ""; // RENDER COMPACT LAYOUT if (layout === "compact") { const width = normalizedWidth - 5; height = 90 + Math.round(filteredLanguages.length / 2) * DEFAULT_LINE_HEIGHT; // progressOffset holds the previous language's width and used to offset the next language // so that we can stack them one after another, like this: [--][----][---] let progressOffset = 0; const compactProgressBar = filteredLanguages .map((language) => { const progress = ((width - COMPACT_LAYOUT_PROGRESSBAR_PADDING) * language.percent) / 100; // @ts-ignore const languageColor = languageColors[language.name] || "#858585"; const output = ` `; progressOffset += progress; return output; }) .join(""); finalLayout = ` ${compactProgressBar} ${ filteredLanguages.length ? createLanguageTextNode({ y: 25, langs: filteredLanguages, display_format, card_width: normalizedWidth, }).join("") : noCodingActivityNode({ // @ts-ignore color: textColor, text: stats.is_coding_activity_visible ? stats.is_other_usage_visible ? i18n.t("wakatimecard.nocodingactivity") : i18n.t("wakatimecard.nocodedetails") : i18n.t("wakatimecard.notpublic"), }) } `; } else { finalLayout = flexLayout({ items: filteredLanguages.length ? filteredLanguages.map((language, index) => { return createTextNode({ id: language.name, label: language.name, value: formatLanguageValue({ display_format, lang: language }), index, percent: language.percent, // @ts-ignore progressBarColor: titleColor, // @ts-ignore progressBarBackgroundColor: textColor, hideProgress: hide_progress, progressBarWidth: normalizedWidth - TOTAL_TEXT_WIDTH, }); }) : [ noCodingActivityNode({ // @ts-ignore color: textColor, text: stats.is_coding_activity_visible ? stats.is_other_usage_visible ? i18n.t("wakatimecard.nocodingactivity") : i18n.t("wakatimecard.nocodedetails") : i18n.t("wakatimecard.notpublic"), }), ], gap: lheight, direction: "column", }).join(""); } // Get title range text let titleText = i18n.t("wakatimecard.title"); switch (stats.range) { case "last_7_days": titleText += ` (${i18n.t("wakatimecard.last7days")})`; break; case "last_year": titleText += ` (${i18n.t("wakatimecard.lastyear")})`; break; } const card = new Card({ customTitle: custom_title, defaultTitle: titleText, width: normalizedWidth, height, border_radius, colors: { titleColor, textColor, iconColor, bgColor, borderColor, }, }); if (disable_animations) { card.disableAnimations(); } card.setHideBorder(hide_border); card.setHideTitle(hide_title); card.setCSS( ` ${cssStyles} @keyframes slideInAnimation { from { width: 0; } to { width: calc(100%-100px); } } @keyframes growWidthAnimation { from { width: 0; } to { width: 100%; } } .lang-name { font: 400 11px 'Segoe UI', Ubuntu, Sans-Serif; fill: ${textColor} } #rect-mask rect{ animation: slideInAnimation 1s ease-in-out forwards; } .lang-progress{ animation: growWidthAnimation 0.6s ease-in-out forwards; } `, ); return card.render(` ${finalLayout} `); }; export { renderWakatimeCard }; export default renderWakatimeCard; ================================================ FILE: src/common/Card.js ================================================ // @ts-check import { encodeHTML } from "./html.js"; import { flexLayout } from "./render.js"; class Card { /** * Creates a new card instance. * * @param {object} args Card arguments. * @param {number=} args.width Card width. * @param {number=} args.height Card height. * @param {number=} args.border_radius Card border radius. * @param {string=} args.customTitle Card custom title. * @param {string=} args.defaultTitle Card default title. * @param {string=} args.titlePrefixIcon Card title prefix icon. * @param {object} [args.colors={}] Card colors arguments. * @param {string=} args.colors.titleColor Card title color. * @param {string=} args.colors.textColor Card text color. * @param {string=} args.colors.iconColor Card icon color. * @param {string|string[]=} args.colors.bgColor Card background color. * @param {string=} args.colors.borderColor Card border color. */ constructor({ width = 100, height = 100, border_radius = 4.5, colors = {}, customTitle, defaultTitle = "", titlePrefixIcon, }) { this.width = width; this.height = height; this.hideBorder = false; this.hideTitle = false; this.border_radius = border_radius; // returns theme based colors with proper overrides and defaults this.colors = colors; this.title = customTitle === undefined ? encodeHTML(defaultTitle) : encodeHTML(customTitle); this.css = ""; this.paddingX = 25; this.paddingY = 35; this.titlePrefixIcon = titlePrefixIcon; this.animations = true; this.a11yTitle = ""; this.a11yDesc = ""; } /** * @returns {void} */ disableAnimations() { this.animations = false; } /** * @param {Object} props The props object. * @param {string} props.title Accessibility title. * @param {string} props.desc Accessibility description. * @returns {void} */ setAccessibilityLabel({ title, desc }) { this.a11yTitle = title; this.a11yDesc = desc; } /** * @param {string} value The CSS to add to the card. * @returns {void} */ setCSS(value) { this.css = value; } /** * @param {boolean} value Whether to hide the border or not. * @returns {void} */ setHideBorder(value) { this.hideBorder = value; } /** * @param {boolean} value Whether to hide the title or not. * @returns {void} */ setHideTitle(value) { this.hideTitle = value; if (value) { this.height -= 30; } } /** * @param {string} text The title to set. * @returns {void} */ setTitle(text) { this.title = text; } /** * @returns {string} The rendered card title. */ renderTitle() { const titleText = ` ${this.title} `; const prefixIcon = ` ${this.titlePrefixIcon} `; return ` ${flexLayout({ items: [this.titlePrefixIcon ? prefixIcon : "", titleText], gap: 25, }).join("")} `; } /** * @returns {string} The rendered card gradient. */ renderGradient() { if (typeof this.colors.bgColor !== "object") { return ""; } const gradients = this.colors.bgColor.slice(1); return typeof this.colors.bgColor === "object" ? ` ${gradients.map((grad, index) => { let offset = (index * 100) / (gradients.length - 1); return ``; })} ` : ""; } /** * Retrieves css animations for a card. * * @returns {string} Animation css. */ getAnimations = () => { return ` /* Animations */ @keyframes scaleInAnimation { from { transform: translate(-5px, 5px) scale(0); } to { transform: translate(-5px, 5px) scale(1); } } @keyframes fadeInAnimation { from { opacity: 0; } to { opacity: 1; } } `; }; /** * @param {string} body The inner body of the card. * @returns {string} The rendered card. */ render(body) { return ` ${this.a11yTitle} ${this.a11yDesc} ${this.renderGradient()} ${this.hideTitle ? "" : this.renderTitle()} ${body} `; } } export { Card }; export default Card; ================================================ FILE: src/common/I18n.js ================================================ // @ts-check const FALLBACK_LOCALE = "en"; /** * I18n translation class. */ class I18n { /** * Constructor. * * @param {Object} options Options. * @param {string=} options.locale Locale. * @param {any} options.translations Translations. */ constructor({ locale, translations }) { this.locale = locale || FALLBACK_LOCALE; this.translations = translations; } /** * Get translation. * * @param {string} str String to translate. * @returns {string} Translated string. */ t(str) { if (!this.translations[str]) { throw new Error(`${str} Translation string not found`); } if (!this.translations[str][this.locale]) { throw new Error( `'${str}' translation not found for locale '${this.locale}'`, ); } return this.translations[str][this.locale]; } } export { I18n }; export default I18n; ================================================ FILE: src/common/access.js ================================================ // @ts-check import { renderError } from "./render.js"; import { blacklist } from "./blacklist.js"; import { whitelist, gistWhitelist } from "./envs.js"; const NOT_WHITELISTED_USERNAME_MESSAGE = "This username is not whitelisted"; const NOT_WHITELISTED_GIST_MESSAGE = "This gist ID is not whitelisted"; const BLACKLISTED_MESSAGE = "This username is blacklisted"; /** * Guards access using whitelist/blacklist. * * @param {Object} args The parameters object. * @param {any} args.res The response object. * @param {string} args.id Resource identifier (username or gist id). * @param {"username"|"gist"|"wakatime"} args.type The type of identifier. * @param {{ title_color?: string, text_color?: string, bg_color?: string, border_color?: string, theme?: string }} args.colors Color options for the error card. * @returns {{ isPassed: boolean, result?: any }} The result object indicating success or failure. */ const guardAccess = ({ res, id, type, colors }) => { if (!["username", "gist", "wakatime"].includes(type)) { throw new Error( 'Invalid type. Expected "username", "gist", or "wakatime".', ); } const currentWhitelist = type === "gist" ? gistWhitelist : whitelist; const notWhitelistedMsg = type === "gist" ? NOT_WHITELISTED_GIST_MESSAGE : NOT_WHITELISTED_USERNAME_MESSAGE; if (Array.isArray(currentWhitelist) && !currentWhitelist.includes(id)) { const result = res.send( renderError({ message: notWhitelistedMsg, secondaryMessage: "Please deploy your own instance", renderOptions: { ...colors, show_repo_link: false, }, }), ); return { isPassed: false, result }; } if ( type === "username" && currentWhitelist === undefined && blacklist.includes(id) ) { const result = res.send( renderError({ message: BLACKLISTED_MESSAGE, secondaryMessage: "Please deploy your own instance", renderOptions: { ...colors, show_repo_link: false, }, }), ); return { isPassed: false, result }; } return { isPassed: true }; }; export { guardAccess }; ================================================ FILE: src/common/blacklist.js ================================================ const blacklist = [ "renovate-bot", "technote-space", "sw-yx", "YourUsername", "[YourUsername]", ]; export { blacklist }; export default blacklist; ================================================ FILE: src/common/cache.js ================================================ // @ts-check import { clampValue } from "./ops.js"; const MIN = 60; const HOUR = 60 * MIN; const DAY = 24 * HOUR; /** * Common durations in seconds. */ const DURATIONS = { ONE_MINUTE: MIN, FIVE_MINUTES: 5 * MIN, TEN_MINUTES: 10 * MIN, FIFTEEN_MINUTES: 15 * MIN, THIRTY_MINUTES: 30 * MIN, TWO_HOURS: 2 * HOUR, FOUR_HOURS: 4 * HOUR, SIX_HOURS: 6 * HOUR, EIGHT_HOURS: 8 * HOUR, TWELVE_HOURS: 12 * HOUR, ONE_DAY: DAY, TWO_DAY: 2 * DAY, SIX_DAY: 6 * DAY, TEN_DAY: 10 * DAY, }; /** * Common cache TTL values in seconds. */ const CACHE_TTL = { STATS_CARD: { DEFAULT: DURATIONS.ONE_DAY, MIN: DURATIONS.TWELVE_HOURS, MAX: DURATIONS.TWO_DAY, }, TOP_LANGS_CARD: { DEFAULT: DURATIONS.SIX_DAY, MIN: DURATIONS.TWO_DAY, MAX: DURATIONS.TEN_DAY, }, PIN_CARD: { DEFAULT: DURATIONS.TEN_DAY, MIN: DURATIONS.ONE_DAY, MAX: DURATIONS.TEN_DAY, }, GIST_CARD: { DEFAULT: DURATIONS.TWO_DAY, MIN: DURATIONS.ONE_DAY, MAX: DURATIONS.TEN_DAY, }, WAKATIME_CARD: { DEFAULT: DURATIONS.ONE_DAY, MIN: DURATIONS.TWELVE_HOURS, MAX: DURATIONS.TWO_DAY, }, ERROR: DURATIONS.TEN_MINUTES, }; /** * Resolves the cache seconds based on the requested, default, min, and max values. * * @param {Object} args The parameters object. * @param {number} args.requested The requested cache seconds. * @param {number} args.def The default cache seconds. * @param {number} args.min The minimum cache seconds. * @param {number} args.max The maximum cache seconds. * @returns {number} The resolved cache seconds. */ const resolveCacheSeconds = ({ requested, def, min, max }) => { let cacheSeconds = clampValue(isNaN(requested) ? def : requested, min, max); if (process.env.CACHE_SECONDS) { const envCacheSeconds = parseInt(process.env.CACHE_SECONDS, 10); if (!isNaN(envCacheSeconds)) { cacheSeconds = envCacheSeconds; } } return cacheSeconds; }; /** * Disables caching by setting appropriate headers on the response object. * * @param {any} res The response object. */ const disableCaching = (res) => { // Disable caching for browsers, shared caches/CDNs, and GitHub Camo. res.setHeader( "Cache-Control", "no-cache, no-store, must-revalidate, max-age=0, s-maxage=0", ); res.setHeader("Pragma", "no-cache"); res.setHeader("Expires", "0"); }; /** * Sets the Cache-Control headers on the response object. * * @param {any} res The response object. * @param {number} cacheSeconds The cache seconds to set in the headers. */ const setCacheHeaders = (res, cacheSeconds) => { if (cacheSeconds < 1 || process.env.NODE_ENV === "development") { disableCaching(res); return; } res.setHeader( "Cache-Control", `max-age=${cacheSeconds}, ` + `s-maxage=${cacheSeconds}, ` + `stale-while-revalidate=${DURATIONS.ONE_DAY}`, ); }; /** * Sets the Cache-Control headers for error responses on the response object. * * @param {any} res The response object. */ const setErrorCacheHeaders = (res) => { const envCacheSeconds = process.env.CACHE_SECONDS ? parseInt(process.env.CACHE_SECONDS, 10) : NaN; if ( (!isNaN(envCacheSeconds) && envCacheSeconds < 1) || process.env.NODE_ENV === "development" ) { disableCaching(res); return; } // Use lower cache period for errors. res.setHeader( "Cache-Control", `max-age=${CACHE_TTL.ERROR}, ` + `s-maxage=${CACHE_TTL.ERROR}, ` + `stale-while-revalidate=${DURATIONS.ONE_DAY}`, ); }; export { resolveCacheSeconds, setCacheHeaders, setErrorCacheHeaders, DURATIONS, CACHE_TTL, }; ================================================ FILE: src/common/color.js ================================================ // @ts-check import { themes } from "../../themes/index.js"; /** * Checks if a string is a valid hex color. * * @param {string} hexColor String to check. * @returns {boolean} True if the given string is a valid hex color. */ const isValidHexColor = (hexColor) => { return new RegExp( /^([A-Fa-f0-9]{8}|[A-Fa-f0-9]{6}|[A-Fa-f0-9]{3}|[A-Fa-f0-9]{4})$/, ).test(hexColor); }; /** * Check if the given string is a valid gradient. * * @param {string[]} colors Array of colors. * @returns {boolean} True if the given string is a valid gradient. */ const isValidGradient = (colors) => { return ( colors.length > 2 && colors.slice(1).every((color) => isValidHexColor(color)) ); }; /** * Retrieves a gradient if color has more than one valid hex codes else a single color. * * @param {string} color The color to parse. * @param {string | string[]} fallbackColor The fallback color. * @returns {string | string[]} The gradient or color. */ const fallbackColor = (color, fallbackColor) => { let gradient = null; let colors = color ? color.split(",") : []; if (colors.length > 1 && isValidGradient(colors)) { gradient = colors; } return ( (gradient ? gradient : isValidHexColor(color) && `#${color}`) || fallbackColor ); }; /** * Object containing card colors. * @typedef {{ * titleColor: string; * iconColor: string; * textColor: string; * bgColor: string | string[]; * borderColor: string; * ringColor: string; * }} CardColors */ /** * Returns theme based colors with proper overrides and defaults. * * @param {Object} args Function arguments. * @param {string=} args.title_color Card title color. * @param {string=} args.text_color Card text color. * @param {string=} args.icon_color Card icon color. * @param {string=} args.bg_color Card background color. * @param {string=} args.border_color Card border color. * @param {string=} args.ring_color Card ring color. * @param {string=} args.theme Card theme. * @returns {CardColors} Card colors. */ const getCardColors = ({ title_color, text_color, icon_color, bg_color, border_color, ring_color, theme, }) => { const defaultTheme = themes["default"]; const isThemeProvided = theme !== null && theme !== undefined; // @ts-ignore const selectedTheme = isThemeProvided ? themes[theme] : defaultTheme; const defaultBorderColor = "border_color" in selectedTheme ? selectedTheme.border_color : // @ts-ignore defaultTheme.border_color; // get the color provided by the user else the theme color // finally if both colors are invalid fallback to default theme const titleColor = fallbackColor( title_color || selectedTheme.title_color, "#" + defaultTheme.title_color, ); // get the color provided by the user else the theme color // finally if both colors are invalid we use the titleColor const ringColor = fallbackColor( // @ts-ignore ring_color || selectedTheme.ring_color, titleColor, ); const iconColor = fallbackColor( icon_color || selectedTheme.icon_color, "#" + defaultTheme.icon_color, ); const textColor = fallbackColor( text_color || selectedTheme.text_color, "#" + defaultTheme.text_color, ); const bgColor = fallbackColor( bg_color || selectedTheme.bg_color, "#" + defaultTheme.bg_color, ); const borderColor = fallbackColor( border_color || defaultBorderColor, "#" + defaultBorderColor, ); if ( typeof titleColor !== "string" || typeof textColor !== "string" || typeof ringColor !== "string" || typeof iconColor !== "string" || typeof borderColor !== "string" ) { throw new Error( "Unexpected behavior, all colors except background should be string.", ); } return { titleColor, iconColor, textColor, bgColor, borderColor, ringColor }; }; export { isValidHexColor, isValidGradient, getCardColors }; ================================================ FILE: src/common/envs.js ================================================ // @ts-check const whitelist = process.env.WHITELIST ? process.env.WHITELIST.split(",") : undefined; const gistWhitelist = process.env.GIST_WHITELIST ? process.env.GIST_WHITELIST.split(",") : undefined; const excludeRepositories = process.env.EXCLUDE_REPO ? process.env.EXCLUDE_REPO.split(",") : []; export { whitelist, gistWhitelist, excludeRepositories }; ================================================ FILE: src/common/error.js ================================================ // @ts-check /** * @type {string} A general message to ask user to try again later. */ const TRY_AGAIN_LATER = "Please try again later"; /** * @type {Object} A map of error types to secondary error messages. */ const SECONDARY_ERROR_MESSAGES = { MAX_RETRY: "You can deploy own instance or wait until public will be no longer limited", NO_TOKENS: "Please add an env variable called PAT_1 with your GitHub API token in vercel", USER_NOT_FOUND: "Make sure the provided username is not an organization", GRAPHQL_ERROR: TRY_AGAIN_LATER, GITHUB_REST_API_ERROR: TRY_AGAIN_LATER, WAKATIME_USER_NOT_FOUND: "Make sure you have a public WakaTime profile", }; /** * Custom error class to handle custom GRS errors. */ class CustomError extends Error { /** * Custom error constructor. * * @param {string} message Error message. * @param {string} type Error type. */ constructor(message, type) { super(message); this.type = type; this.secondaryMessage = SECONDARY_ERROR_MESSAGES[type] || type; } static MAX_RETRY = "MAX_RETRY"; static NO_TOKENS = "NO_TOKENS"; static USER_NOT_FOUND = "USER_NOT_FOUND"; static GRAPHQL_ERROR = "GRAPHQL_ERROR"; static GITHUB_REST_API_ERROR = "GITHUB_REST_API_ERROR"; static WAKATIME_ERROR = "WAKATIME_ERROR"; } /** * Missing query parameter class. */ class MissingParamError extends Error { /** * Missing query parameter error constructor. * * @param {string[]} missedParams An array of missing parameters names. * @param {string=} secondaryMessage Optional secondary message to display. */ constructor(missedParams, secondaryMessage) { const msg = `Missing params ${missedParams .map((p) => `"${p}"`) .join(", ")} make sure you pass the parameters in URL`; super(msg); this.missedParams = missedParams; this.secondaryMessage = secondaryMessage; } } /** * Retrieve secondary message from an error object. * * @param {Error} err The error object. * @returns {string|undefined} The secondary message if available, otherwise undefined. */ const retrieveSecondaryMessage = (err) => { return "secondaryMessage" in err && typeof err.secondaryMessage === "string" ? err.secondaryMessage : undefined; }; export { CustomError, MissingParamError, SECONDARY_ERROR_MESSAGES, TRY_AGAIN_LATER, retrieveSecondaryMessage, }; ================================================ FILE: src/common/fmt.js ================================================ // @ts-check import wrap from "word-wrap"; import { encodeHTML } from "./html.js"; /** * Retrieves num with suffix k(thousands) precise to given decimal places. * * @param {number} num The number to format. * @param {number=} precision The number of decimal places to include. * @returns {string|number} The formatted number. */ const kFormatter = (num, precision) => { const abs = Math.abs(num); const sign = Math.sign(num); if (typeof precision === "number" && !isNaN(precision)) { return (sign * (abs / 1000)).toFixed(precision) + "k"; } if (abs < 1000) { return sign * abs; } return sign * parseFloat((abs / 1000).toFixed(1)) + "k"; }; /** * Convert bytes to a human-readable string representation. * * @param {number} bytes The number of bytes to convert. * @returns {string} The human-readable representation of bytes. * @throws {Error} If bytes is negative or too large. */ const formatBytes = (bytes) => { if (bytes < 0) { throw new Error("Bytes must be a non-negative number"); } if (bytes === 0) { return "0 B"; } const sizes = ["B", "KB", "MB", "GB", "TB", "PB", "EB"]; const base = 1024; const i = Math.floor(Math.log(bytes) / Math.log(base)); if (i >= sizes.length) { throw new Error("Bytes is too large to convert to a human-readable string"); } return `${(bytes / Math.pow(base, i)).toFixed(1)} ${sizes[i]}`; }; /** * Split text over multiple lines based on the card width. * * @param {string} text Text to split. * @param {number} width Line width in number of characters. * @param {number} maxLines Maximum number of lines. * @returns {string[]} Array of lines. */ const wrapTextMultiline = (text, width = 59, maxLines = 3) => { const fullWidthComma = ","; const encoded = encodeHTML(text); const isChinese = encoded.includes(fullWidthComma); let wrapped = []; if (isChinese) { wrapped = encoded.split(fullWidthComma); // Chinese full punctuation } else { wrapped = wrap(encoded, { width, }).split("\n"); // Split wrapped lines to get an array of lines } const lines = wrapped.map((line) => line.trim()).slice(0, maxLines); // Only consider maxLines lines // Add "..." to the last line if the text exceeds maxLines if (wrapped.length > maxLines) { lines[maxLines - 1] += "..."; } // Remove empty lines if text fits in less than maxLines lines const multiLineText = lines.filter(Boolean); return multiLineText; }; export { kFormatter, formatBytes, wrapTextMultiline }; ================================================ FILE: src/common/html.js ================================================ // @ts-check /** * Encode string as HTML. * * @see https://stackoverflow.com/a/48073476/10629172 * * @param {string} str String to encode. * @returns {string} Encoded string. */ const encodeHTML = (str) => { return str .replace(/[\u00A0-\u9999<>&](?!#)/gim, (i) => { return "&#" + i.charCodeAt(0) + ";"; }) .replace(/\u0008/gim, ""); }; export { encodeHTML }; ================================================ FILE: src/common/http.js ================================================ // @ts-check import axios from "axios"; /** * Send GraphQL request to GitHub API. * * @param {import('axios').AxiosRequestConfig['data']} data Request data. * @param {import('axios').AxiosRequestConfig['headers']} headers Request headers. * @returns {Promise} Request response. */ const request = (data, headers) => { return axios({ url: "https://api.github.com/graphql", method: "post", headers, data, }); }; export { request }; ================================================ FILE: src/common/icons.js ================================================ // @ts-check const icons = { star: ``, commits: ``, prs: ``, prs_merged: ``, prs_merged_percentage: ``, issues: ``, icon: ``, contribs: ``, fork: ``, reviews: ``, discussions_started: ``, discussions_answered: ``, gist: ``, }; /** * Get rank icon * * @param {string} rankIcon - The rank icon type. * @param {string} rankLevel - The rank level. * @param {number} percentile - The rank percentile. * @returns {string} - The SVG code of the rank icon */ const rankIcon = (rankIcon, rankLevel, percentile) => { switch (rankIcon) { case "github": return ` `; case "percentile": return ` Top ${percentile.toFixed(1)}% `; case "default": default: return ` ${rankLevel} `; } }; export { icons, rankIcon }; export default icons; ================================================ FILE: src/common/index.js ================================================ // @ts-check export { blacklist } from "./blacklist.js"; export { Card } from "./Card.js"; export { I18n } from "./I18n.js"; export { icons } from "./icons.js"; export { retryer } from "./retryer.js"; export { ERROR_CARD_LENGTH, renderError, flexLayout, measureText, } from "./render.js"; ================================================ FILE: src/common/languageColors.json ================================================ { "1C Enterprise": "#814CCC", "2-Dimensional Array": "#38761D", "4D": "#004289", "ABAP": "#E8274B", "ABAP CDS": "#555e25", "AGS Script": "#B9D9FF", "AIDL": "#34EB6B", "AL": "#3AA2B5", "AMPL": "#E6EFBB", "ANTLR": "#9DC3FF", "API Blueprint": "#2ACCA8", "APL": "#5A8164", "ASP.NET": "#9400ff", "ATS": "#1ac620", "ActionScript": "#882B0F", "Ada": "#02f88c", "Adblock Filter List": "#800000", "Adobe Font Metrics": "#fa0f00", "Agda": "#315665", "Aiken": "#640ff8", "Alloy": "#64C800", "Alpine Abuild": "#0D597F", "Altium Designer": "#A89663", "AngelScript": "#C7D7DC", "Answer Set Programming": "#A9CC29", "Ant Build System": "#A9157E", "Antlers": "#ff269e", "ApacheConf": "#d12127", "Apex": "#1797c0", "Apollo Guidance Computer": "#0B3D91", "AppleScript": "#101F1F", "Arc": "#aa2afe", "ArkTS": "#0080ff", "AsciiDoc": "#73a0c5", "AspectJ": "#a957b0", "Assembly": "#6E4C13", "Astro": "#ff5a03", "Asymptote": "#ff0000", "Augeas": "#9CC134", "AutoHotkey": "#6594b9", "AutoIt": "#1C3552", "Avro IDL": "#0040FF", "Awk": "#c30e9b", "B4X": "#00e4ff", "BASIC": "#ff0000", "BQN": "#2b7067", "Ballerina": "#FF5000", "Batchfile": "#C1F12E", "Beef": "#a52f4e", "Berry": "#15A13C", "BibTeX": "#778899", "Bicep": "#519aba", "Bikeshed": "#5562ac", "Bison": "#6A463F", "BitBake": "#00bce4", "Blade": "#f7523f", "BlitzBasic": "#00FFAE", "BlitzMax": "#cd6400", "Bluespec": "#12223c", "Bluespec BH": "#12223c", "Boo": "#d4bec1", "Boogie": "#c80fa0", "Brainfuck": "#2F2530", "BrighterScript": "#66AABB", "Brightscript": "#662D91", "Browserslist": "#ffd539", "Bru": "#F4AA41", "BuildStream": "#006bff", "C": "#555555", "C#": "#178600", "C++": "#f34b7d", "C3": "#2563eb", "CAP CDS": "#0092d1", "CLIPS": "#00A300", "CMake": "#DA3434", "COLLADA": "#F1A42B", "CSON": "#244776", "CSS": "#663399", "CSV": "#237346", "CUE": "#5886E1", "CWeb": "#00007a", "Cabal Config": "#483465", "Caddyfile": "#22b638", "Cadence": "#00ef8b", "Cairo": "#ff4a48", "Cairo Zero": "#ff4a48", "CameLIGO": "#3be133", "Cangjie": "#00868B", "Cap'n Proto": "#c42727", "Carbon": "#222222", "Ceylon": "#dfa535", "Chapel": "#8dc63f", "ChucK": "#3f8000", "Circom": "#707575", "Cirru": "#ccccff", "Clarion": "#db901e", "Clarity": "#5546ff", "Classic ASP": "#6a40fd", "Clean": "#3F85AF", "Click": "#E4E6F3", "Clojure": "#db5855", "Closure Templates": "#0d948f", "Cloud Firestore Security Rules": "#FFA000", "Clue": "#0009b5", "CodeQL": "#140f46", "CoffeeScript": "#244776", "ColdFusion": "#ed2cd6", "ColdFusion CFC": "#ed2cd6", "Common Lisp": "#3fb68b", "Common Workflow Language": "#B5314C", "Component Pascal": "#B0CE4E", "Cooklang": "#E15A29", "Crystal": "#000100", "Csound": "#1a1a1a", "Csound Document": "#1a1a1a", "Csound Score": "#1a1a1a", "Cuda": "#3A4E3A", "Curry": "#531242", "Cylc": "#00b3fd", "Cypher": "#34c0eb", "Cython": "#fedf5b", "D": "#ba595e", "D2": "#526ee8", "DM": "#447265", "Dafny": "#FFEC25", "Darcs Patch": "#8eff23", "Dart": "#00B4AB", "Daslang": "#d3d3d3", "DataWeave": "#003a52", "Debian Package Control File": "#D70751", "DenizenScript": "#FBEE96", "Dhall": "#dfafff", "DirectX 3D File": "#aace60", "Dockerfile": "#384d54", "Dogescript": "#cca760", "Dotenv": "#e5d559", "Dune": "#89421e", "Dylan": "#6c616e", "E": "#ccce35", "ECL": "#8a1267", "ECLiPSe": "#001d9d", "EJS": "#a91e50", "EQ": "#a78649", "Earthly": "#2af0ff", "Easybuild": "#069406", "Ecere Projects": "#913960", "Ecmarkup": "#eb8131", "Edge": "#0dffe0", "EdgeQL": "#31A7FF", "EditorConfig": "#fff1f2", "Eiffel": "#4d6977", "Elixir": "#6e4a7e", "Elm": "#60B5CC", "Elvish": "#55BB55", "Elvish Transcript": "#55BB55", "Emacs Lisp": "#c065db", "EmberScript": "#FFF4F3", "Erlang": "#B83998", "Euphoria": "#FF790B", "F#": "#b845fc", "F*": "#572e30", "FIGlet Font": "#FFDDBB", "FIRRTL": "#2f632f", "FLUX": "#88ccff", "Factor": "#636746", "Fancy": "#7b9db4", "Fantom": "#14253c", "Faust": "#c37240", "Fennel": "#fff3d7", "Filebench WML": "#F6B900", "Flix": "#d44a45", "Fluent": "#ffcc33", "Forth": "#341708", "Fortran": "#4d41b1", "Fortran Free Form": "#4d41b1", "FreeBASIC": "#141AC9", "FreeMarker": "#0050b2", "Frege": "#00cafe", "Futhark": "#5f021f", "G-code": "#D08CF2", "GAML": "#FFC766", "GAMS": "#f49a22", "GAP": "#0000cc", "GCC Machine Description": "#FFCFAB", "GDScript": "#355570", "GDShader": "#478CBF", "GEDCOM": "#003058", "GLSL": "#5686a5", "GSC": "#FF6800", "Game Maker Language": "#71b417", "Gemfile.lock": "#701516", "Gemini": "#ff6900", "Genero 4gl": "#63408e", "Genero per": "#d8df39", "Genie": "#fb855d", "Genshi": "#951531", "Gentoo Ebuild": "#9400ff", "Gentoo Eclass": "#9400ff", "Gerber Image": "#d20b00", "Gherkin": "#5B2063", "Git Attributes": "#F44D27", "Git Commit": "#F44D27", "Git Config": "#F44D27", "Git Revision List": "#F44D27", "Gleam": "#ffaff3", "Glimmer JS": "#F5835F", "Glimmer TS": "#3178c6", "Glyph": "#c1ac7f", "Gnuplot": "#f0a9f0", "Go": "#00ADD8", "Go Checksums": "#00ADD8", "Go Module": "#00ADD8", "Go Workspace": "#00ADD8", "Godot Resource": "#355570", "Golo": "#88562A", "Gosu": "#82937f", "Grace": "#615f8b", "Gradle": "#02303a", "Gradle Kotlin DSL": "#02303a", "Grammatical Framework": "#ff0000", "GraphQL": "#e10098", "Graphviz (DOT)": "#2596be", "Groovy": "#4298b8", "Groovy Server Pages": "#4298b8", "HAProxy": "#106da9", "HCL": "#844FBA", "HIP": "#4F3A4F", "HLSL": "#aace60", "HOCON": "#9ff8ee", "HTML": "#e34c26", "HTML+ECR": "#2e1052", "HTML+EEX": "#6e4a7e", "HTML+ERB": "#701516", "HTML+PHP": "#4f5d95", "HTML+Razor": "#512be4", "HTTP": "#005C9C", "HXML": "#f68712", "Hack": "#878787", "Haml": "#ece2a9", "Handlebars": "#f7931e", "Harbour": "#0e60e3", "Hare": "#9d7424", "Haskell": "#5e5086", "Haxe": "#df7900", "HiveQL": "#dce200", "HolyC": "#ffefaf", "Hosts File": "#308888", "Hurl": "#FF0288", "Hy": "#7790B2", "IDL": "#a3522f", "IGOR Pro": "#0000cc", "INI": "#d1dbe0", "ISPC": "#2D68B1", "Idris": "#b30000", "Ignore List": "#000000", "ImageJ Macro": "#99AAFF", "Imba": "#16cec6", "Inno Setup": "#264b99", "Io": "#a9188d", "Ioke": "#078193", "Isabelle": "#FEFE00", "Isabelle ROOT": "#FEFE00", "J": "#9EEDFF", "JAR Manifest": "#b07219", "JCL": "#d90e09", "JFlex": "#DBCA00", "JSON": "#292929", "JSON with Comments": "#292929", "JSON5": "#267CB9", "JSONLD": "#0c479c", "JSONiq": "#40d47e", "Jai": "#ab8b4b", "Janet": "#0886a5", "Jasmin": "#d03600", "Java": "#b07219", "Java Properties": "#2A6277", "Java Server Pages": "#2A6277", "Java Template Engine": "#2A6277", "JavaScript": "#f1e05a", "JavaScript+ERB": "#f1e05a", "Jest Snapshot": "#15c213", "JetBrains MPS": "#21D789", "Jinja": "#a52a22", "Jison": "#56b3cb", "Jison Lex": "#56b3cb", "Jolie": "#843179", "Jsonnet": "#0064bd", "Julia": "#a270ba", "Julia REPL": "#a270ba", "Jupyter Notebook": "#DA5B0B", "Just": "#384d54", "KDL": "#ffb3b3", "KRL": "#28430A", "Kaitai Struct": "#773b37", "KakouneScript": "#6f8042", "KerboScript": "#41adf0", "KiCad Layout": "#2f4aab", "KiCad Legacy Layout": "#2f4aab", "KiCad Schematic": "#2f4aab", "KoLmafia ASH": "#B9D9B9", "Koka": "#215166", "Kotlin": "#A97BFF", "LFE": "#4C3023", "LLVM": "#185619", "LOLCODE": "#cc9900", "LSL": "#3d9970", "LabVIEW": "#fede06", "Lark": "#2980B9", "Lasso": "#999999", "Latte": "#f2a542", "Leo": "#C4FFC2", "Less": "#1d365d", "Lex": "#DBCA00", "LigoLANG": "#0e74ff", "LilyPond": "#9ccc7c", "Liquid": "#67b8de", "Literate Agda": "#315665", "Literate CoffeeScript": "#244776", "Literate Haskell": "#5e5086", "LiveCode Script": "#0c5ba5", "LiveScript": "#499886", "Logtalk": "#295b9a", "LookML": "#652B81", "Lua": "#000080", "Luau": "#00A2FF", "M3U": "#179C7D", "MATLAB": "#e16737", "MAXScript": "#00a6a6", "MDX": "#fcb32c", "MLIR": "#5EC8DB", "MQL4": "#62A8D6", "MQL5": "#4A76B8", "MTML": "#b7e1f4", "Macaulay2": "#d8ffff", "Makefile": "#427819", "Mako": "#7e858d", "Markdown": "#083fa1", "Marko": "#42bff2", "Mask": "#f97732", "Max": "#c4a79c", "Mercury": "#ff2b2b", "Mermaid": "#ff3670", "Meson": "#007800", "Metal": "#8f14e9", "MiniYAML": "#ff1111", "MiniZinc": "#06a9e6", "Mint": "#02b046", "Mirah": "#c7a938", "Modelica": "#de1d31", "Modula-2": "#10253f", "Modula-3": "#223388", "Mojo": "#ff4c1f", "Monkey C": "#8D6747", "MoonBit": "#b92381", "MoonScript": "#ff4585", "Motoko": "#fbb03b", "Motorola 68K Assembly": "#005daa", "Move": "#4a137a", "Mustache": "#724b3b", "NCL": "#28431f", "NMODL": "#00356B", "NPM Config": "#cb3837", "NWScript": "#111522", "Nasal": "#1d2c4e", "Nearley": "#990000", "Nemerle": "#3d3c6e", "NetLinx": "#0aa0ff", "NetLinx+ERB": "#747faa", "NetLogo": "#ff6375", "NewLisp": "#87AED7", "Nextflow": "#3ac486", "Nginx": "#009639", "Nickel": "#E0C3FC", "Nim": "#ffc200", "Nit": "#009917", "Nix": "#7e7eff", "Noir": "#2f1f49", "Nu": "#c9df40", "NumPy": "#9C8AF9", "Nunjucks": "#3d8137", "Nushell": "#4E9906", "OASv2-json": "#85ea2d", "OASv2-yaml": "#85ea2d", "OASv3-json": "#85ea2d", "OASv3-yaml": "#85ea2d", "OCaml": "#ef7a08", "OMNeT++ MSG": "#a0e0a0", "OMNeT++ NED": "#08607c", "ObjectScript": "#424893", "Objective-C": "#438eff", "Objective-C++": "#6866fb", "Objective-J": "#ff0c5a", "Odin": "#60AFFE", "Omgrofl": "#cabbff", "Opal": "#f7ede0", "Open Policy Agent": "#7d9199", "OpenAPI Specification v2": "#85ea2d", "OpenAPI Specification v3": "#85ea2d", "OpenCL": "#ed2e2d", "OpenEdge ABL": "#5ce600", "OpenQASM": "#AA70FF", "OpenSCAD": "#e5cd45", "Option List": "#476732", "Org": "#77aa99", "OverpassQL": "#cce2aa", "Oxygene": "#cdd0e3", "Oz": "#fab738", "P4": "#7055b5", "PDDL": "#0d00ff", "PEG.js": "#234d6b", "PHP": "#4F5D95", "PLSQL": "#dad8d8", "PLpgSQL": "#336790", "POV-Ray SDL": "#6bac65", "Pact": "#F7A8B8", "Pan": "#cc0000", "Papyrus": "#6600cc", "Parrot": "#f3ca0a", "Pascal": "#E3F171", "Pawn": "#dbb284", "Pep8": "#C76F5B", "Perl": "#0298c3", "PicoLisp": "#6067af", "PigLatin": "#fcd7de", "Pike": "#005390", "Pip Requirements": "#FFD343", "Pkl": "#6b9543", "PlantUML": "#fbbd16", "PogoScript": "#d80074", "Polar": "#ae81ff", "Portugol": "#f8bd00", "PostCSS": "#dc3a0c", "PostScript": "#da291c", "PowerBuilder": "#8f0f8d", "PowerShell": "#012456", "Praat": "#c8506d", "Prisma": "#0c344b", "Processing": "#0096D8", "Procfile": "#3B2F63", "Prolog": "#74283c", "Promela": "#de0000", "Propeller Spin": "#7fa2a7", "Pug": "#a86454", "Puppet": "#302B6D", "PureBasic": "#5a6986", "PureScript": "#1D222D", "Pyret": "#ee1e10", "Python": "#3572A5", "Python console": "#3572A5", "Python traceback": "#3572A5", "Q#": "#fed659", "QML": "#44a51c", "Qt Script": "#00b841", "Quake": "#882233", "QuakeC": "#975777", "QuickBASIC": "#008080", "R": "#198CE7", "RAML": "#77d9fb", "RBS": "#701516", "RDoc": "#701516", "REXX": "#d90e09", "RMarkdown": "#198ce7", "RON": "#a62c00", "ROS Interface": "#22314e", "RPGLE": "#2BDE21", "RUNOFF": "#665a4e", "Racket": "#3c5caa", "Ragel": "#9d5200", "Raku": "#0000fb", "Rascal": "#fffaa0", "ReScript": "#ed5051", "Reason": "#ff5847", "ReasonLIGO": "#ff5847", "Rebol": "#358a5b", "Record Jar": "#0673ba", "Red": "#f50000", "Regular Expression": "#009a00", "Ren'Py": "#ff7f7f", "Rez": "#FFDAB3", "Ring": "#2D54CB", "Riot": "#A71E49", "RobotFramework": "#00c0b5", "Roc": "#7c38f5", "Rocq Prover": "#d0b68c", "Roff": "#ecdebe", "Roff Manpage": "#ecdebe", "Rouge": "#cc0088", "RouterOS Script": "#DE3941", "Ruby": "#701516", "Rust": "#dea584", "SAS": "#B34936", "SCSS": "#c6538c", "SPARQL": "#0C4597", "SQF": "#3F3F3F", "SQL": "#e38c00", "SQLPL": "#e38c00", "SRecode Template": "#348a34", "STL": "#373b5e", "SVG": "#ff9900", "Sail": "#259dd5", "SaltStack": "#646464", "Sass": "#a53b70", "Scala": "#c22d40", "Scaml": "#bd181a", "Scenic": "#fdc700", "Scheme": "#1e4aec", "Scilab": "#ca0f21", "Self": "#0579aa", "ShaderLab": "#222c37", "Shell": "#89e051", "ShellCheck Config": "#cecfcb", "Shen": "#120F14", "Simple File Verification": "#C9BFED", "Singularity": "#64E6AD", "Slang": "#1fbec9", "Slash": "#007eff", "Slice": "#003fa2", "Slim": "#2b2b2b", "Slint": "#2379F4", "SmPL": "#c94949", "Smalltalk": "#596706", "Smarty": "#f0c040", "Smithy": "#c44536", "Snakemake": "#419179", "Solidity": "#AA6746", "SourcePawn": "#f69e1d", "Squirrel": "#800000", "Stan": "#b2011d", "Standard ML": "#dc566d", "Starlark": "#76d275", "Stata": "#1a5f91", "StringTemplate": "#3fb34f", "Stylus": "#ff6347", "SubRip Text": "#9e0101", "SugarSS": "#2fcc9f", "SuperCollider": "#46390b", "Survex data": "#ffcc99", "Svelte": "#ff3e00", "Sway": "#00F58C", "Sweave": "#198ce7", "Swift": "#F05138", "SystemVerilog": "#DAE1C2", "TI Program": "#A0AA87", "TL-Verilog": "#C40023", "TLA": "#4b0079", "TOML": "#9c4221", "TSQL": "#e38c00", "TSV": "#237346", "TSX": "#3178c6", "TXL": "#0178b8", "Tact": "#48b5ff", "Talon": "#333333", "Tcl": "#e4cc98", "TeX": "#3D6117", "Teal": "#00B1BC", "Terra": "#00004c", "Terraform Template": "#7b42bb", "TextGrid": "#c8506d", "TextMate Properties": "#df66e4", "Textile": "#ffe7ac", "Thrift": "#D12127", "Toit": "#c2c9fb", "Tor Config": "#59316b", "Tree-sitter Query": "#8ea64c", "Turing": "#cf142b", "Twig": "#c1d026", "TypeScript": "#3178c6", "TypeSpec": "#4A3665", "Typst": "#239dad", "Unified Parallel C": "#4e3617", "Unity3D Asset": "#222c37", "Uno": "#9933cc", "UnrealScript": "#a54c4d", "Untyped Plutus Core": "#36adbd", "UrWeb": "#ccccee", "V": "#4f87c4", "VBA": "#867db1", "VBScript": "#15dcdc", "VCL": "#148AA8", "VHDL": "#adb2cb", "Vala": "#a56de2", "Valve Data Format": "#f26025", "Velocity Template Language": "#507cff", "Vento": "#ff0080", "Verilog": "#b2b7f8", "Vim Help File": "#199f4b", "Vim Script": "#199f4b", "Vim Snippet": "#199f4b", "Visual Basic .NET": "#945db7", "Visual Basic 6.0": "#2c6353", "Volt": "#1F1F1F", "Vue": "#41b883", "Vyper": "#9F4CF2", "WDL": "#42f1f4", "WGSL": "#1a5e9a", "Web Ontology Language": "#5b70bd", "WebAssembly": "#04133b", "WebAssembly Interface Type": "#6250e7", "Whiley": "#d5c397", "Wikitext": "#fc5757", "Windows Registry Entries": "#52d5ff", "Witcher Script": "#ff0000", "Wolfram Language": "#dd1100", "Wollok": "#a23738", "World of Warcraft Addon Data": "#f7e43f", "Wren": "#383838", "X10": "#4B6BEF", "XC": "#99DA07", "XML": "#0060ac", "XML Property List": "#0060ac", "XQuery": "#5232e7", "XSLT": "#EB8CEB", "Xmake": "#22a079", "Xojo": "#81bd41", "Xonsh": "#285EEF", "Xtend": "#24255d", "YAML": "#cb171e", "YARA": "#220000", "YASnippet": "#32AB90", "Yacc": "#4B6C4B", "Yul": "#794932", "ZAP": "#0d665e", "ZIL": "#dc75e5", "ZenScript": "#00BCD1", "Zephir": "#118f9e", "Zig": "#ec915c", "Zimpl": "#d67711", "Zmodel": "#ff7100", "crontab": "#ead7ac", "eC": "#913960", "fish": "#4aae47", "hoon": "#00b171", "iCalendar": "#ec564c", "jq": "#c7254e", "kvlang": "#1da6e0", "mIRC Script": "#3d57c3", "mcfunction": "#E22837", "mdsvex": "#5f9ea0", "mupad": "#244963", "nanorc": "#2d004d", "nesC": "#94B0C7", "ooc": "#b0b77e", "q": "#0040cd", "reStructuredText": "#141414", "sed": "#64b970", "templ": "#66D0DD", "vCard": "#ee2647", "wisp": "#7582D1", "xBase": "#403a40" } ================================================ FILE: src/common/log.js ================================================ // @ts-check const noop = () => {}; /** * Return console instance based on the environment. * * @type {Console | {log: () => void, error: () => void}} */ const logger = process.env.NODE_ENV === "test" ? { log: noop, error: noop } : console; export { logger }; export default logger; ================================================ FILE: src/common/ops.js ================================================ // @ts-check import toEmoji from "emoji-name-map"; /** * Returns boolean if value is either "true" or "false" else the value as it is. * * @param {string | boolean} value The value to parse. * @returns {boolean | undefined } The parsed value. */ const parseBoolean = (value) => { if (typeof value === "boolean") { return value; } if (typeof value === "string") { if (value.toLowerCase() === "true") { return true; } else if (value.toLowerCase() === "false") { return false; } } return undefined; }; /** * Parse string to array of strings. * * @param {string} str The string to parse. * @returns {string[]} The array of strings. */ const parseArray = (str) => { if (!str) { return []; } return str.split(","); }; /** * Clamp the given number between the given range. * * @param {number} number The number to clamp. * @param {number} min The minimum value. * @param {number} max The maximum value. * @returns {number} The clamped number. */ const clampValue = (number, min, max) => { // @ts-ignore if (Number.isNaN(parseInt(number, 10))) { return min; } return Math.max(min, Math.min(number, max)); }; /** * Lowercase and trim string. * * @param {string} name String to lowercase and trim. * @returns {string} Lowercased and trimmed string. */ const lowercaseTrim = (name) => name.toLowerCase().trim(); /** * Split array of languages in two columns. * * @template T Language object. * @param {Array} arr Array of languages. * @param {number} perChunk Number of languages per column. * @returns {Array} Array of languages split in two columns. */ const chunkArray = (arr, perChunk) => { return arr.reduce((resultArray, item, index) => { const chunkIndex = Math.floor(index / perChunk); if (!resultArray[chunkIndex]) { // @ts-ignore resultArray[chunkIndex] = []; // start a new chunk } // @ts-ignore resultArray[chunkIndex].push(item); return resultArray; }, []); }; /** * Parse emoji from string. * * @param {string} str String to parse emoji from. * @returns {string} String with emoji parsed. */ const parseEmojis = (str) => { if (!str) { throw new Error("[parseEmoji]: str argument not provided"); } return str.replace(/:\w+:/gm, (emoji) => { return toEmoji.get(emoji) || ""; }); }; /** * Get diff in minutes between two dates. * * @param {Date} d1 First date. * @param {Date} d2 Second date. * @returns {number} Number of minutes between the two dates. */ const dateDiff = (d1, d2) => { const date1 = new Date(d1); const date2 = new Date(d2); const diff = date1.getTime() - date2.getTime(); return Math.round(diff / (1000 * 60)); }; export { parseBoolean, parseArray, clampValue, lowercaseTrim, chunkArray, parseEmojis, dateDiff, }; ================================================ FILE: src/common/render.js ================================================ // @ts-check import { SECONDARY_ERROR_MESSAGES, TRY_AGAIN_LATER } from "./error.js"; import { getCardColors } from "./color.js"; import { encodeHTML } from "./html.js"; import { clampValue } from "./ops.js"; /** * Auto layout utility, allows us to layout things vertically or horizontally with * proper gaping. * * @param {object} props Function properties. * @param {string[]} props.items Array of items to layout. * @param {number} props.gap Gap between items. * @param {"column" | "row"=} props.direction Direction to layout items. * @param {number[]=} props.sizes Array of sizes for each item. * @returns {string[]} Array of items with proper layout. */ const flexLayout = ({ items, gap, direction, sizes = [] }) => { let lastSize = 0; // filter() for filtering out empty strings return items.filter(Boolean).map((item, i) => { const size = sizes[i] || 0; let transform = `translate(${lastSize}, 0)`; if (direction === "column") { transform = `translate(0, ${lastSize})`; } lastSize += size + gap; return `${item}`; }); }; /** * Creates a node to display the primary programming language of the repository/gist. * * @param {string} langName Language name. * @param {string} langColor Language color. * @returns {string} Language display SVG object. */ const createLanguageNode = (langName, langColor) => { return ` ${langName} `; }; /** * Create a node to indicate progress in percentage along a horizontal line. * * @param {Object} params Object that contains the createProgressNode parameters. * @param {number} params.x X-axis position. * @param {number} params.y Y-axis position. * @param {number} params.width Width of progress bar. * @param {string} params.color Progress color. * @param {number} params.progress Progress value. * @param {string} params.progressBarBackgroundColor Progress bar bg color. * @param {number} params.delay Delay before animation starts. * @returns {string} Progress node. */ const createProgressNode = ({ x, y, width, color, progress, progressBarBackgroundColor, delay, }) => { const progressPercentage = clampValue(progress, 2, 100); return ` `; }; /** * Creates an icon with label to display repository/gist stats like forks, stars, etc. * * @param {string} icon The icon to display. * @param {number|string} label The label to display. * @param {string} testid The testid to assign to the label. * @param {number} iconSize The size of the icon. * @returns {string} Icon with label SVG object. */ const iconWithLabel = (icon, label, testid, iconSize) => { if (typeof label === "number" && label <= 0) { return ""; } const iconSvg = ` ${icon} `; const text = `${label}`; return flexLayout({ items: [iconSvg, text], gap: 20 }).join(""); }; // Script parameters. const ERROR_CARD_LENGTH = 576.5; const UPSTREAM_API_ERRORS = [ TRY_AGAIN_LATER, SECONDARY_ERROR_MESSAGES.MAX_RETRY, ]; /** * Renders error message on the card. * * @param {object} args Function arguments. * @param {string} args.message Main error message. * @param {string} [args.secondaryMessage=""] The secondary error message. * @param {object} [args.renderOptions={}] Render options. * @param {string=} args.renderOptions.title_color Card title color. * @param {string=} args.renderOptions.text_color Card text color. * @param {string=} args.renderOptions.bg_color Card background color. * @param {string=} args.renderOptions.border_color Card border color. * @param {Parameters[0]["theme"]=} args.renderOptions.theme Card theme. * @param {boolean=} args.renderOptions.show_repo_link Whether to show repo link or not. * @returns {string} The SVG markup. */ const renderError = ({ message, secondaryMessage = "", renderOptions = {}, }) => { const { title_color, text_color, bg_color, border_color, theme = "default", show_repo_link = true, } = renderOptions; // returns theme based colors with proper overrides and defaults const { titleColor, textColor, bgColor, borderColor } = getCardColors({ title_color, text_color, icon_color: "", bg_color, border_color, ring_color: "", theme, }); return ` Something went wrong!${ UPSTREAM_API_ERRORS.includes(secondaryMessage) || !show_repo_link ? "" : " file an issue at https://tiny.one/readme-stats" } ${encodeHTML(message)} ${secondaryMessage} `; }; /** * Retrieve text length. * * @see https://stackoverflow.com/a/48172630/10629172 * @param {string} str String to measure. * @param {number} fontSize Font size. * @returns {number} Text length. */ const measureText = (str, fontSize = 10) => { // prettier-ignore const widths = [ 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0.2796875, 0.2765625, 0.3546875, 0.5546875, 0.5546875, 0.8890625, 0.665625, 0.190625, 0.3328125, 0.3328125, 0.3890625, 0.5828125, 0.2765625, 0.3328125, 0.2765625, 0.3015625, 0.5546875, 0.5546875, 0.5546875, 0.5546875, 0.5546875, 0.5546875, 0.5546875, 0.5546875, 0.5546875, 0.5546875, 0.2765625, 0.2765625, 0.584375, 0.5828125, 0.584375, 0.5546875, 1.0140625, 0.665625, 0.665625, 0.721875, 0.721875, 0.665625, 0.609375, 0.7765625, 0.721875, 0.2765625, 0.5, 0.665625, 0.5546875, 0.8328125, 0.721875, 0.7765625, 0.665625, 0.7765625, 0.721875, 0.665625, 0.609375, 0.721875, 0.665625, 0.94375, 0.665625, 0.665625, 0.609375, 0.2765625, 0.3546875, 0.2765625, 0.4765625, 0.5546875, 0.3328125, 0.5546875, 0.5546875, 0.5, 0.5546875, 0.5546875, 0.2765625, 0.5546875, 0.5546875, 0.221875, 0.240625, 0.5, 0.221875, 0.8328125, 0.5546875, 0.5546875, 0.5546875, 0.5546875, 0.3328125, 0.5, 0.2765625, 0.5546875, 0.5, 0.721875, 0.5, 0.5, 0.5, 0.3546875, 0.259375, 0.353125, 0.5890625, ]; const avg = 0.5279276315789471; return ( str .split("") .map((c) => c.charCodeAt(0) < widths.length ? widths[c.charCodeAt(0)] : avg, ) .reduce((cur, acc) => acc + cur) * fontSize ); }; export { ERROR_CARD_LENGTH, renderError, createLanguageNode, createProgressNode, iconWithLabel, flexLayout, measureText, }; ================================================ FILE: src/common/retryer.js ================================================ // @ts-check import { CustomError } from "./error.js"; import { logger } from "./log.js"; // Script variables. // Count the number of GitHub API tokens available. const PATs = Object.keys(process.env).filter((key) => /PAT_\d*$/.exec(key), ).length; const RETRIES = process.env.NODE_ENV === "test" ? 7 : PATs; /** * @typedef {import("axios").AxiosResponse} AxiosResponse Axios response. * @typedef {(variables: any, token: string, retriesForTests?: number) => Promise} FetcherFunction Fetcher function. */ /** * Try to execute the fetcher function until it succeeds or the max number of retries is reached. * * @param {FetcherFunction} fetcher The fetcher function. * @param {any} variables Object with arguments to pass to the fetcher function. * @param {number} retries How many times to retry. * @returns {Promise} The response from the fetcher function. */ const retryer = async (fetcher, variables, retries = 0) => { if (!RETRIES) { throw new CustomError("No GitHub API tokens found", CustomError.NO_TOKENS); } if (retries > RETRIES) { throw new CustomError( "Downtime due to GitHub API rate limiting", CustomError.MAX_RETRY, ); } try { // try to fetch with the first token since RETRIES is 0 index i'm adding +1 let response = await fetcher( variables, // @ts-ignore process.env[`PAT_${retries + 1}`], // used in tests for faking rate limit retries, ); // react on both type and message-based rate-limit signals. // https://github.com/anuraghazra/github-readme-stats/issues/4425 const errors = response?.data?.errors; const errorType = errors?.[0]?.type; const errorMsg = errors?.[0]?.message || ""; const isRateLimited = (errors && errorType === "RATE_LIMITED") || /rate limit/i.test(errorMsg); // if rate limit is hit increase the RETRIES and recursively call the retryer // with username, and current RETRIES if (isRateLimited) { logger.log(`PAT_${retries + 1} Failed`); retries++; // directly return from the function return retryer(fetcher, variables, retries); } // finally return the response return response; } catch (err) { /** @type {any} */ const e = err; // network/unexpected error → let caller treat as failure if (!e?.response) { throw e; } // prettier-ignore // also checking for bad credentials if any tokens gets invalidated const isBadCredential = e?.response?.data?.message === "Bad credentials"; const isAccountSuspended = e?.response?.data?.message === "Sorry. Your account was suspended."; if (isBadCredential || isAccountSuspended) { logger.log(`PAT_${retries + 1} Failed`); retries++; // directly return from the function return retryer(fetcher, variables, retries); } // HTTP error with a response → return it for caller-side handling return e.response; } }; export { retryer, RETRIES }; export default retryer; ================================================ FILE: src/fetchers/gist.js ================================================ // @ts-check import { retryer } from "../common/retryer.js"; import { MissingParamError } from "../common/error.js"; import { request } from "../common/http.js"; const QUERY = ` query gistInfo($gistName: String!) { viewer { gist(name: $gistName) { description owner { login } stargazerCount forks { totalCount } files { name language { name } size } } } } `; /** * Gist data fetcher. * * @param {object} variables Fetcher variables. * @param {string} token GitHub token. * @returns {Promise} The response. */ const fetcher = async (variables, token) => { return await request( { query: QUERY, variables }, { Authorization: `token ${token}` }, ); }; /** * @typedef {{ name: string; language: { name: string; }, size: number }} GistFile Gist file. */ /** * This function calculates the primary language of a gist by files size. * * @param {GistFile[]} files Files. * @returns {string} Primary language. */ const calculatePrimaryLanguage = (files) => { /** @type {Record} */ const languages = {}; for (const file of files) { if (file.language) { if (languages[file.language.name]) { languages[file.language.name] += file.size; } else { languages[file.language.name] = file.size; } } } let primaryLanguage = Object.keys(languages)[0]; for (const language in languages) { if (languages[language] > languages[primaryLanguage]) { primaryLanguage = language; } } return primaryLanguage; }; /** * @typedef {import('./types').GistData} GistData Gist data. */ /** * Fetch GitHub gist information by given username and ID. * * @param {string} id GitHub gist ID. * @returns {Promise} Gist data. */ const fetchGist = async (id) => { if (!id) { throw new MissingParamError(["id"], "/api/gist?id=GIST_ID"); } const res = await retryer(fetcher, { gistName: id }); if (res.data.errors) { throw new Error(res.data.errors[0].message); } if (!res.data.data.viewer.gist) { throw new Error("Gist not found"); } const data = res.data.data.viewer.gist; return { name: data.files[Object.keys(data.files)[0]].name, nameWithOwner: `${data.owner.login}/${ data.files[Object.keys(data.files)[0]].name }`, description: data.description, language: calculatePrimaryLanguage(data.files), starsCount: data.stargazerCount, forksCount: data.forks.totalCount, }; }; export { fetchGist }; export default fetchGist; ================================================ FILE: src/fetchers/repo.js ================================================ // @ts-check import { MissingParamError } from "../common/error.js"; import { request } from "../common/http.js"; import { retryer } from "../common/retryer.js"; /** * Repo data fetcher. * * @param {object} variables Fetcher variables. * @param {string} token GitHub token. * @returns {Promise} The response. */ const fetcher = (variables, token) => { return request( { query: ` fragment RepoInfo on Repository { name nameWithOwner isPrivate isArchived isTemplate stargazers { totalCount } description primaryLanguage { color id name } forkCount } query getRepo($login: String!, $repo: String!) { user(login: $login) { repository(name: $repo) { ...RepoInfo } } organization(login: $login) { repository(name: $repo) { ...RepoInfo } } } `, variables, }, { Authorization: `token ${token}`, }, ); }; const urlExample = "/api/pin?username=USERNAME&repo=REPO_NAME"; /** * @typedef {import("./types").RepositoryData} RepositoryData Repository data. */ /** * Fetch repository data. * * @param {string} username GitHub username. * @param {string} reponame GitHub repository name. * @returns {Promise} Repository data. */ const fetchRepo = async (username, reponame) => { if (!username && !reponame) { throw new MissingParamError(["username", "repo"], urlExample); } if (!username) { throw new MissingParamError(["username"], urlExample); } if (!reponame) { throw new MissingParamError(["repo"], urlExample); } let res = await retryer(fetcher, { login: username, repo: reponame }); const data = res.data.data; if (!data.user && !data.organization) { throw new Error("Not found"); } const isUser = data.organization === null && data.user; const isOrg = data.user === null && data.organization; if (isUser) { if (!data.user.repository || data.user.repository.isPrivate) { throw new Error("User Repository Not found"); } return { ...data.user.repository, starCount: data.user.repository.stargazers.totalCount, }; } if (isOrg) { if ( !data.organization.repository || data.organization.repository.isPrivate ) { throw new Error("Organization Repository Not found"); } return { ...data.organization.repository, starCount: data.organization.repository.stargazers.totalCount, }; } throw new Error("Unexpected behavior"); }; export { fetchRepo }; export default fetchRepo; ================================================ FILE: src/fetchers/stats.js ================================================ // @ts-check import axios from "axios"; import * as dotenv from "dotenv"; import githubUsernameRegex from "github-username-regex"; import { calculateRank } from "../calculateRank.js"; import { retryer } from "../common/retryer.js"; import { logger } from "../common/log.js"; import { excludeRepositories } from "../common/envs.js"; import { CustomError, MissingParamError } from "../common/error.js"; import { wrapTextMultiline } from "../common/fmt.js"; import { request } from "../common/http.js"; dotenv.config(); // GraphQL queries. const GRAPHQL_REPOS_FIELD = ` repositories(first: 100, ownerAffiliations: OWNER, orderBy: {direction: DESC, field: STARGAZERS}, after: $after) { totalCount nodes { name stargazers { totalCount } } pageInfo { hasNextPage endCursor } } `; const GRAPHQL_REPOS_QUERY = ` query userInfo($login: String!, $after: String) { user(login: $login) { ${GRAPHQL_REPOS_FIELD} } } `; const GRAPHQL_STATS_QUERY = ` query userInfo($login: String!, $after: String, $includeMergedPullRequests: Boolean!, $includeDiscussions: Boolean!, $includeDiscussionsAnswers: Boolean!, $startTime: DateTime = null) { user(login: $login) { name login commits: contributionsCollection (from: $startTime) { totalCommitContributions, } reviews: contributionsCollection { totalPullRequestReviewContributions } repositoriesContributedTo(first: 1, contributionTypes: [COMMIT, ISSUE, PULL_REQUEST, REPOSITORY]) { totalCount } pullRequests(first: 1) { totalCount } mergedPullRequests: pullRequests(states: MERGED) @include(if: $includeMergedPullRequests) { totalCount } openIssues: issues(states: OPEN) { totalCount } closedIssues: issues(states: CLOSED) { totalCount } followers { totalCount } repositoryDiscussions @include(if: $includeDiscussions) { totalCount } repositoryDiscussionComments(onlyAnswers: true) @include(if: $includeDiscussionsAnswers) { totalCount } ${GRAPHQL_REPOS_FIELD} } } `; /** * Stats fetcher object. * * @param {object & { after: string | null }} variables Fetcher variables. * @param {string} token GitHub token. * @returns {Promise} Axios response. */ const fetcher = (variables, token) => { const query = variables.after ? GRAPHQL_REPOS_QUERY : GRAPHQL_STATS_QUERY; return request( { query, variables, }, { Authorization: `bearer ${token}`, }, ); }; /** * Fetch stats information for a given username. * * @param {object} variables Fetcher variables. * @param {string} variables.username GitHub username. * @param {boolean} variables.includeMergedPullRequests Include merged pull requests. * @param {boolean} variables.includeDiscussions Include discussions. * @param {boolean} variables.includeDiscussionsAnswers Include discussions answers. * @param {string|undefined} variables.startTime Time to start the count of total commits. * @returns {Promise} Axios response. * * @description This function supports multi-page fetching if the 'FETCH_MULTI_PAGE_STARS' environment variable is set to true. */ const statsFetcher = async ({ username, includeMergedPullRequests, includeDiscussions, includeDiscussionsAnswers, startTime, }) => { let stats; let hasNextPage = true; let endCursor = null; while (hasNextPage) { const variables = { login: username, first: 100, after: endCursor, includeMergedPullRequests, includeDiscussions, includeDiscussionsAnswers, startTime, }; let res = await retryer(fetcher, variables); if (res.data.errors) { return res; } // Store stats data. const repoNodes = res.data.data.user.repositories.nodes; if (stats) { stats.data.data.user.repositories.nodes.push(...repoNodes); } else { stats = res; } // Disable multi page fetching on public Vercel instance due to rate limits. const repoNodesWithStars = repoNodes.filter( (node) => node.stargazers.totalCount !== 0, ); hasNextPage = process.env.FETCH_MULTI_PAGE_STARS === "true" && repoNodes.length === repoNodesWithStars.length && res.data.data.user.repositories.pageInfo.hasNextPage; endCursor = res.data.data.user.repositories.pageInfo.endCursor; } return stats; }; /** * Fetch total commits using the REST API. * * @param {object} variables Fetcher variables. * @param {string} token GitHub token. * @returns {Promise} Axios response. * * @see https://developer.github.com/v3/search/#search-commits */ const fetchTotalCommits = (variables, token) => { return axios({ method: "get", url: `https://api.github.com/search/commits?q=author:${variables.login}`, headers: { "Content-Type": "application/json", Accept: "application/vnd.github.cloak-preview", Authorization: `token ${token}`, }, }); }; /** * Fetch all the commits for all the repositories of a given username. * * @param {string} username GitHub username. * @returns {Promise} Total commits. * * @description Done like this because the GitHub API does not provide a way to fetch all the commits. See * #92#issuecomment-661026467 and #211 for more information. */ const totalCommitsFetcher = async (username) => { if (!githubUsernameRegex.test(username)) { logger.log("Invalid username provided."); throw new Error("Invalid username provided."); } let res; try { res = await retryer(fetchTotalCommits, { login: username }); } catch (err) { logger.log(err); throw new Error(err); } const totalCount = res.data.total_count; if (!totalCount || isNaN(totalCount)) { throw new CustomError( "Could not fetch total commits.", CustomError.GITHUB_REST_API_ERROR, ); } return totalCount; }; /** * Fetch stats for a given username. * * @param {string} username GitHub username. * @param {boolean} include_all_commits Include all commits. * @param {string[]} exclude_repo Repositories to exclude. * @param {boolean} include_merged_pull_requests Include merged pull requests. * @param {boolean} include_discussions Include discussions. * @param {boolean} include_discussions_answers Include discussions answers. * @param {number|undefined} commits_year Year to count total commits * @returns {Promise} Stats data. */ const fetchStats = async ( username, include_all_commits = false, exclude_repo = [], include_merged_pull_requests = false, include_discussions = false, include_discussions_answers = false, commits_year, ) => { if (!username) { throw new MissingParamError(["username"]); } const stats = { name: "", totalPRs: 0, totalPRsMerged: 0, mergedPRsPercentage: 0, totalReviews: 0, totalCommits: 0, totalIssues: 0, totalStars: 0, totalDiscussionsStarted: 0, totalDiscussionsAnswered: 0, contributedTo: 0, rank: { level: "C", percentile: 100 }, }; let res = await statsFetcher({ username, includeMergedPullRequests: include_merged_pull_requests, includeDiscussions: include_discussions, includeDiscussionsAnswers: include_discussions_answers, startTime: commits_year ? `${commits_year}-01-01T00:00:00Z` : undefined, }); // Catch GraphQL errors. if (res.data.errors) { logger.error(res.data.errors); if (res.data.errors[0].type === "NOT_FOUND") { throw new CustomError( res.data.errors[0].message || "Could not fetch user.", CustomError.USER_NOT_FOUND, ); } if (res.data.errors[0].message) { throw new CustomError( wrapTextMultiline(res.data.errors[0].message, 90, 1)[0], res.statusText, ); } throw new CustomError( "Something went wrong while trying to retrieve the stats data using the GraphQL API.", CustomError.GRAPHQL_ERROR, ); } const user = res.data.data.user; stats.name = user.name || user.login; // if include_all_commits, fetch all commits using the REST API. if (include_all_commits) { stats.totalCommits = await totalCommitsFetcher(username); } else { stats.totalCommits = user.commits.totalCommitContributions; } stats.totalPRs = user.pullRequests.totalCount; if (include_merged_pull_requests) { stats.totalPRsMerged = user.mergedPullRequests.totalCount; stats.mergedPRsPercentage = (user.mergedPullRequests.totalCount / user.pullRequests.totalCount) * 100 || 0; } stats.totalReviews = user.reviews.totalPullRequestReviewContributions; stats.totalIssues = user.openIssues.totalCount + user.closedIssues.totalCount; if (include_discussions) { stats.totalDiscussionsStarted = user.repositoryDiscussions.totalCount; } if (include_discussions_answers) { stats.totalDiscussionsAnswered = user.repositoryDiscussionComments.totalCount; } stats.contributedTo = user.repositoriesContributedTo.totalCount; // Retrieve stars while filtering out repositories to be hidden. const allExcludedRepos = [...exclude_repo, ...excludeRepositories]; let repoToHide = new Set(allExcludedRepos); stats.totalStars = user.repositories.nodes .filter((data) => { return !repoToHide.has(data.name); }) .reduce((prev, curr) => { return prev + curr.stargazers.totalCount; }, 0); stats.rank = calculateRank({ all_commits: include_all_commits, commits: stats.totalCommits, prs: stats.totalPRs, reviews: stats.totalReviews, issues: stats.totalIssues, repos: user.repositories.totalCount, stars: stats.totalStars, followers: user.followers.totalCount, }); return stats; }; export { fetchStats }; export default fetchStats; ================================================ FILE: src/fetchers/top-languages.js ================================================ // @ts-check import { retryer } from "../common/retryer.js"; import { logger } from "../common/log.js"; import { excludeRepositories } from "../common/envs.js"; import { CustomError, MissingParamError } from "../common/error.js"; import { wrapTextMultiline } from "../common/fmt.js"; import { request } from "../common/http.js"; /** * Top languages fetcher object. * * @param {any} variables Fetcher variables. * @param {string} token GitHub token. * @returns {Promise} Languages fetcher response. */ const fetcher = (variables, token) => { return request( { query: ` query userInfo($login: String!) { user(login: $login) { # fetch only owner repos & not forks repositories(ownerAffiliations: OWNER, isFork: false, first: 100) { nodes { name languages(first: 10, orderBy: {field: SIZE, direction: DESC}) { edges { size node { color name } } } } } } } `, variables, }, { Authorization: `token ${token}`, }, ); }; /** * @typedef {import("./types").TopLangData} TopLangData Top languages data. */ /** * Fetch top languages for a given username. * * @param {string} username GitHub username. * @param {string[]} exclude_repo List of repositories to exclude. * @param {number} size_weight Weightage to be given to size. * @param {number} count_weight Weightage to be given to count. * @returns {Promise} Top languages data. */ const fetchTopLanguages = async ( username, exclude_repo = [], size_weight = 1, count_weight = 0, ) => { if (!username) { throw new MissingParamError(["username"]); } const res = await retryer(fetcher, { login: username }); if (res.data.errors) { logger.error(res.data.errors); if (res.data.errors[0].type === "NOT_FOUND") { throw new CustomError( res.data.errors[0].message || "Could not fetch user.", CustomError.USER_NOT_FOUND, ); } if (res.data.errors[0].message) { throw new CustomError( wrapTextMultiline(res.data.errors[0].message, 90, 1)[0], res.statusText, ); } throw new CustomError( "Something went wrong while trying to retrieve the language data using the GraphQL API.", CustomError.GRAPHQL_ERROR, ); } let repoNodes = res.data.data.user.repositories.nodes; /** @type {Record} */ let repoToHide = {}; const allExcludedRepos = [...exclude_repo, ...excludeRepositories]; // populate repoToHide map for quick lookup // while filtering out if (allExcludedRepos) { allExcludedRepos.forEach((repoName) => { repoToHide[repoName] = true; }); } // filter out repositories to be hidden repoNodes = repoNodes .sort((a, b) => b.size - a.size) .filter((name) => !repoToHide[name.name]); let repoCount = 0; repoNodes = repoNodes .filter((node) => node.languages.edges.length > 0) // flatten the list of language nodes .reduce((acc, curr) => curr.languages.edges.concat(acc), []) .reduce((acc, prev) => { // get the size of the language (bytes) let langSize = prev.size; // if we already have the language in the accumulator // & the current language name is same as previous name // add the size to the language size and increase repoCount. if (acc[prev.node.name] && prev.node.name === acc[prev.node.name].name) { langSize = prev.size + acc[prev.node.name].size; repoCount += 1; } else { // reset repoCount to 1 // language must exist in at least one repo to be detected repoCount = 1; } return { ...acc, [prev.node.name]: { name: prev.node.name, color: prev.node.color, size: langSize, count: repoCount, }, }; }, {}); Object.keys(repoNodes).forEach((name) => { // comparison index calculation repoNodes[name].size = Math.pow(repoNodes[name].size, size_weight) * Math.pow(repoNodes[name].count, count_weight); }); const topLangs = Object.keys(repoNodes) .sort((a, b) => repoNodes[b].size - repoNodes[a].size) .reduce((result, key) => { result[key] = repoNodes[key]; return result; }, {}); return topLangs; }; export { fetchTopLanguages }; export default fetchTopLanguages; ================================================ FILE: src/fetchers/types.d.ts ================================================ export type GistData = { name: string; nameWithOwner: string; description: string | null; language: string | null; starsCount: number; forksCount: number; }; export type RepositoryData = { name: string; nameWithOwner: string; isPrivate: boolean; isArchived: boolean; isTemplate: boolean; stargazers: { totalCount: number }; description: string; primaryLanguage: { color: string; id: string; name: string; }; forkCount: number; starCount: number; }; export type StatsData = { name: string; totalPRs: number; totalPRsMerged: number; mergedPRsPercentage: number; totalReviews: number; totalCommits: number; totalIssues: number; totalStars: number; totalDiscussionsStarted: number; totalDiscussionsAnswered: number; contributedTo: number; rank: { level: string; percentile: number }; }; export type Lang = { name: string; color: string; size: number; }; export type TopLangData = Record; export type WakaTimeData = { categories: { digital: string; hours: number; minutes: number; name: string; percent: number; text: string; total_seconds: number; }[]; daily_average: number; daily_average_including_other_language: number; days_including_holidays: number; days_minus_holidays: number; editors: { digital: string; hours: number; minutes: number; name: string; percent: number; text: string; total_seconds: number; }[]; holidays: number; human_readable_daily_average: string; human_readable_daily_average_including_other_language: string; human_readable_total: string; human_readable_total_including_other_language: string; id: string; is_already_updating: boolean; is_coding_activity_visible: boolean; is_including_today: boolean; is_other_usage_visible: boolean; is_stuck: boolean; is_up_to_date: boolean; languages: { digital: string; hours: number; minutes: number; name: string; percent: number; text: string; total_seconds: number; }[]; operating_systems: { digital: string; hours: number; minutes: number; name: string; percent: number; text: string; total_seconds: number; }[]; percent_calculated: number; range: string; status: string; timeout: number; total_seconds: number; total_seconds_including_other_language: number; user_id: string; username: string; writes_only: boolean; }; export type WakaTimeLang = { name: string; text: string; percent: number; }; ================================================ FILE: src/fetchers/wakatime.js ================================================ // @ts-check import axios from "axios"; import { CustomError, MissingParamError } from "../common/error.js"; /** * WakaTime data fetcher. * * @param {{username: string, api_domain: string }} props Fetcher props. * @returns {Promise} WakaTime data response. */ const fetchWakatimeStats = async ({ username, api_domain }) => { if (!username) { throw new MissingParamError(["username"]); } try { const { data } = await axios.get( `https://${ api_domain ? api_domain.replace(/\/$/gi, "") : "wakatime.com" }/api/v1/users/${username}/stats?is_including_today=true`, ); return data.data; } catch (err) { if (err.response.status < 200 || err.response.status > 299) { throw new CustomError( `Could not resolve to a User with the login of '${username}'`, "WAKATIME_USER_NOT_FOUND", ); } throw err; } }; export { fetchWakatimeStats }; export default fetchWakatimeStats; ================================================ FILE: src/index.js ================================================ export * from "./common/index.js"; export * from "./cards/index.js"; ================================================ FILE: src/translations.js ================================================ // @ts-check import { encodeHTML } from "./common/html.js"; /** * Retrieves stat card labels in the available locales. * * @param {object} props Function arguments. * @param {string} props.name The name of the locale. * @param {string} props.apostrophe Whether to use apostrophe or not. * @returns {object} The locales object. * * @see https://www.andiamo.co.uk/resources/iso-language-codes/ for language codes. */ const statCardLocales = ({ name, apostrophe }) => { const encodedName = encodeHTML(name); return { "statcard.title": { en: `${encodedName}'${apostrophe} GitHub Stats`, ar: `${encodedName} إحصائيات جيت هاب`, az: `${encodedName}'${apostrophe} Hesabının GitHub Statistikası`, ca: `Estadístiques de GitHub de ${encodedName}`, cn: `${encodedName} 的 GitHub 统计数据`, "zh-tw": `${encodedName} 的 GitHub 統計資料`, cs: `GitHub statistiky uživatele ${encodedName}`, de: `${encodedName + apostrophe} GitHub-Statistiken`, sw: `GitHub Stats za ${encodedName}`, ur: `${encodedName} کے گٹ ہب کے اعداد و شمار`, bg: `GitHub статистика на потребител ${encodedName}`, bn: `${encodedName} এর GitHub পরিসংখ্যান`, es: `Estadísticas de GitHub de ${encodedName}`, fa: `آمار گیت‌هاب ${encodedName}`, fi: `${encodedName}:n GitHub-tilastot`, fr: `Statistiques GitHub de ${encodedName}`, hi: `${encodedName} के GitHub आँकड़े`, sa: `${encodedName} इत्यस्य GitHub सांख्यिकी`, hu: `${encodedName} GitHub statisztika`, it: `Statistiche GitHub di ${encodedName}`, ja: `${encodedName}の GitHub 統計`, kr: `${encodedName}의 GitHub 통계`, nl: `${encodedName}'${apostrophe} GitHub-statistieken`, "pt-pt": `Estatísticas do GitHub de ${encodedName}`, "pt-br": `Estatísticas do GitHub de ${encodedName}`, np: `${encodedName}'${apostrophe} गिटहब तथ्याङ्क`, el: `Στατιστικά GitHub του ${encodedName}`, ro: `Statisticile GitHub ale lui ${encodedName}`, ru: `Статистика GitHub пользователя ${encodedName}`, "uk-ua": `Статистика GitHub користувача ${encodedName}`, id: `Statistik GitHub ${encodedName}`, ml: `${encodedName}'${apostrophe} ഗിറ്റ്ഹബ് സ്ഥിതിവിവരക്കണക്കുകൾ`, my: `${encodedName} ရဲ့ GitHub အခြေအနေများ`, ta: `${encodedName} கிட்ஹப் புள்ளிவிவரங்கள்`, sk: `GitHub štatistiky používateľa ${encodedName}`, tr: `${encodedName} Hesabının GitHub İstatistikleri`, pl: `Statystyki GitHub użytkownika ${encodedName}`, uz: `${encodedName}ning GitHub'dagi statistikasi`, vi: `Thống Kê GitHub ${encodedName}`, se: `GitHubstatistik för ${encodedName}`, he: `סטטיסטיקות הגיטהאב של ${encodedName}`, fil: `Mga Stats ng GitHub ni ${encodedName}`, th: `สถิติ GitHub ของ ${encodedName}`, sr: `GitHub статистика корисника ${encodedName}`, "sr-latn": `GitHub statistika korisnika ${encodedName}`, no: `GitHub-statistikk for ${encodedName}`, }, "statcard.ranktitle": { en: `${encodedName}'${apostrophe} GitHub Rank`, ar: `${encodedName} إحصائيات جيت هاب`, az: `${encodedName}'${apostrophe} Hesabının GitHub Statistikası`, ca: `Estadístiques de GitHub de ${encodedName}`, cn: `${encodedName} 的 GitHub 统计数据`, "zh-tw": `${encodedName} 的 GitHub 統計資料`, cs: `GitHub statistiky uživatele ${encodedName}`, de: `${encodedName + apostrophe} GitHub-Statistiken`, sw: `GitHub Rank ya ${encodedName}`, ur: `${encodedName} کی گٹ ہب رینک`, bg: `GitHub ранг на ${encodedName}`, bn: `${encodedName} এর GitHub পরিসংখ্যান`, es: `Estadísticas de GitHub de ${encodedName}`, fa: `رتبه گیت‌هاب ${encodedName}`, fi: `${encodedName}:n GitHub-sijoitus`, fr: `Statistiques GitHub de ${encodedName}`, hi: `${encodedName} का GitHub स्थान`, sa: `${encodedName} इत्यस्य GitHub स्थानम्`, hu: `${encodedName} GitHub statisztika`, it: `Statistiche GitHub di ${encodedName}`, ja: `${encodedName} の GitHub ランク`, kr: `${encodedName}의 GitHub 통계`, nl: `${encodedName}'${apostrophe} GitHub-statistieken`, "pt-pt": `Estatísticas do GitHub de ${encodedName}`, "pt-br": `Estatísticas do GitHub de ${encodedName}`, np: `${encodedName}'${apostrophe} गिटहब तथ्याङ्क`, el: `Στατιστικά GitHub του ${encodedName}`, ro: `Rankul GitHub al lui ${encodedName}`, ru: `Рейтинг GitHub пользователя ${encodedName}`, "uk-ua": `Рейтинг GitHub користувача ${encodedName}`, id: `Statistik GitHub ${encodedName}`, ml: `${encodedName}'${apostrophe} ഗിറ്റ്ഹബ് സ്ഥിതിവിവരക്കണക്കുകൾ`, my: `${encodedName} ရဲ့ GitHub အဆင့်`, ta: `${encodedName} கிட்ஹப் தரவரிசை`, sk: `GitHub štatistiky používateľa ${encodedName}`, tr: `${encodedName} Hesabının GitHub Yıldızları`, pl: `Statystyki GitHub użytkownika ${encodedName}`, uz: `${encodedName}ning GitHub'dagi statistikasi`, vi: `Thống Kê GitHub ${encodedName}`, se: `GitHubstatistik för ${encodedName}`, he: `דרגת הגיטהאב של ${encodedName}`, fil: `Ranggo ng GitHub ni ${encodedName}`, th: `อันดับ GitHub ของ ${encodedName}`, sr: `Ранк корисника ${encodedName}`, "sr-latn": `Rank korisnika ${encodedName}`, no: `GitHub-statistikk for ${encodedName}`, }, "statcard.totalstars": { en: "Total Stars Earned", ar: "مجموع النجوم", az: "Ümumi Ulduz", ca: "Total d'estrelles", cn: "获标星数", "zh-tw": "得標星星數量(Star)", cs: "Celkem hvězd", de: "Insgesamt erhaltene Sterne", sw: "Medali(stars) ulizojishindia", ur: "کل ستارے حاصل کیے", bg: "Получени звезди", bn: "সর্বমোট Star", es: "Estrellas totales", fa: "مجموع ستاره‌های دریافت‌شده", fi: "Ansaitut tähdet yhteensä", fr: "Total d'étoiles", hi: "कुल अर्जित सितारे", sa: "अर्जिताः कुल-तारकाः", hu: "Csillagok", it: "Stelle totali", ja: "スターされた数", kr: "받은 스타 수", nl: "Totaal Sterren Ontvangen", "pt-pt": "Total de estrelas", "pt-br": "Total de estrelas", np: "कुल ताराहरू", el: "Σύνολο Αστεριών", ro: "Total de stele câștigate", ru: "Всего звёзд", "uk-ua": "Всього зірок", id: "Total Bintang", ml: "ആകെ നക്ഷത്രങ്ങൾ", my: "စုစုပေါင်းကြယ်များ", ta: "சம்பாதித்த மொத்த நட்சத்திரங்கள்", sk: "Hviezdy", tr: "Toplam Yıldız", pl: "Liczba otrzymanych gwiazdek", uz: "Yulduzchalar", vi: "Tổng Số Sao", se: "Antal intjänade stjärnor", he: "סך כל הכוכבים שהושגו", fil: "Kabuuang Nakuhang Bituin", th: "ดาวทั้งหมดที่ได้รับ", sr: "Број освојених звездица", "sr-latn": "Broj osvojenih zvezdica", no: "Totalt antall stjerner", }, "statcard.commits": { en: "Total Commits", ar: "مجموع المساهمات", az: "Ümumi Commit", ca: "Commits totals", cn: "累计提交总数", "zh-tw": "累計提交數量(Commit)", cs: "Celkem commitů", de: "Anzahl Commits", sw: "Matendo yako yote", ur: "کل کمٹ", bg: "Общо ангажименти", bn: "সর্বমোট Commit", es: "Commits totales", fa: "مجموع کامیت‌ها", fi: "Yhteensä committeja", fr: "Total des Commits", hi: "कुल commits", sa: "कुल-समिन्चयः", hu: "Összes commit", it: "Commit totali", ja: "合計コミット数", kr: "전체 커밋 수", nl: "Aantal commits", "pt-pt": "Total de Commits", "pt-br": "Total de Commits", np: "कुल Commits", el: "Σύνολο Commits", ro: "Total Commit-uri", ru: "Всего коммитов", "uk-ua": "Всього комітів", id: "Total Komitmen", ml: "ആകെ കമ്മിറ്റുകൾ", my: "စုစုပေါင်း Commit များ", ta: `மொத்த கமிட்கள்`, sk: "Všetky commity", tr: "Toplam Commit", pl: "Wszystkie commity", uz: "'Commit'lar", vi: "Tổng Số Cam Kết", se: "Totalt antal commits", he: "סך כל ה־commits", fil: "Kabuuang Commits", th: "Commit ทั้งหมด", sr: "Укупно commit-ова", "sr-latn": "Ukupno commit-ova", no: "Totalt antall commits", }, "statcard.prs": { en: "Total PRs", ar: "مجموع طلبات السحب", az: "Ümumi PR", ca: "PRs totals", cn: "发起的 PR 总数", "zh-tw": "拉取請求數量(PR)", cs: "Celkem PRs", de: "PRs Insgesamt", sw: "PRs Zote", ur: "کل پی آرز", bg: "Заявки за изтегляния", bn: "সর্বমোট PR", es: "PRs totales", fa: "مجموع Pull Request", fi: "Yhteensä PR:t", fr: "Total des PRs", hi: "कुल PR", sa: "कुल-पीआर", hu: "Összes PR", it: "PR totali", ja: "合計 PR", kr: "PR 횟수", nl: "Aantal PR's", "pt-pt": "Total de PRs", "pt-br": "Total de PRs", np: "कुल PRs", el: "Σύνολο PRs", ro: "Total PR-uri", ru: "Всего запросов изменений", "uk-ua": "Всього запитів на злиття", id: "Total Permintaan Tarik", ml: "ആകെ പുൾ അഭ്യർത്ഥനകൾ", my: "စုစုပေါင်း PR များ", ta: `மொத்த இழுக்கும் கோரிக்கைகள்`, sk: "Všetky PR", tr: "Toplam PR", pl: "Wszystkie PR-y", uz: "'Pull Request'lar", vi: "Tổng Số PR", se: "Totalt antal PR", he: "סך כל ה־PRs", fil: "Kabuuang PRs", th: "PR ทั้งหมด", sr: "Укупно PR-ова", "sr-latn": "Ukupno PR-ova", no: "Totalt antall PR", }, "statcard.issues": { en: "Total Issues", ar: "مجموع التحسينات", az: "Ümumi Problem", ca: "Issues totals", cn: "提出的 issue 总数", "zh-tw": "提出問題數量(Issue)", cs: "Celkem problémů", de: "Anzahl Issues", sw: "Masuala Ibuka", ur: "کل مسائل", bg: "Брой въпроси", bn: "সর্বমোট Issue", es: "Issues totales", fa: "مجموع مسائل", fi: "Yhteensä ongelmat", fr: "Nombre total d'incidents", hi: "कुल मुद्दे(Issues)", sa: "कुल-समस्याः", hu: "Összes hibajegy", it: "Segnalazioni totali", ja: "合計 issue", kr: "이슈 개수", nl: "Aantal kwesties", "pt-pt": "Total de Issues", "pt-br": "Total de Issues", np: "कुल मुद्दाहरू", el: "Σύνολο Ζητημάτων", ro: "Total Issue-uri", ru: "Всего вопросов", "uk-ua": "Всього питань", id: "Total Masalah Dilaporkan", ml: "ആകെ പ്രശ്നങ്ങൾ", my: "စုစုပေါင်းပြဿနာများ", ta: `மொத்த சிக்கல்கள்`, sk: "Všetky problémy", tr: "Toplam Hata", pl: "Wszystkie problemy", uz: "'Issue'lar", vi: "Tổng Số Vấn Đề", se: "Total antal issues", he: "סך כל ה־issues", fil: "Kabuuang mga Isyu", th: "Issue ทั้งหมด", sr: "Укупно пријављених проблема", "sr-latn": "Ukupno prijavljenih problema", no: "Totalt antall issues", }, "statcard.contribs": { en: "Contributed to (last year)", ar: "ساهم في (العام الماضي)", az: "Töhfə verdi (ötən il)", ca: "Contribucions (l'any passat)", cn: "贡献的项目数(去年)", "zh-tw": "參與項目數量(去年)", cs: "Přispěl k (minulý rok)", de: "Beigetragen zu (letztes Jahr)", sw: "Idadi ya michango (mwaka mzima)", ur: "پچھلے سال میں تعاون کیا", bg: "Приноси (за изминалата година)", bn: "অবদান (গত বছর)", es: "Contribuciones en (el año pasado)", fa: "مشارکت در (سال گذشته)", fi: "Osallistunut (viime vuonna)", fr: "Contribué à (l'année dernière)", hi: "(पिछले वर्ष) में योगदान दिया", sa: "(गते वर्षे) योगदानम् कृतम्", hu: "Hozzájárulások (tavaly)", it: "Ha contribuito a (l'anno scorso)", ja: "貢献したリポジトリ (昨年)", kr: "(작년) 기여", nl: "Bijgedragen aan (vorig jaar)", "pt-pt": "Contribuiu em (ano passado)", "pt-br": "Contribuiu para (ano passado)", np: "कुल योगदानहरू (गत वर्ष)", el: "Συνεισφέρθηκε σε (πέρυσι)", ro: "Total Contribuiri", ru: "Внесено вклада (за прошлый год)", "uk-ua": "Зроблено внесок (за минулий рік)", id: "Berkontribusi ke (tahun lalu)", ml: "(കഴിഞ്ഞ വർഷത്തെ)ആകെ സംഭാവനകൾ ", my: "အကူအညီပေးခဲ့သည် (ပြီးခဲ့သည့်နှစ်)", ta: "(கடந்த ஆண்டு) பங்களித்தது", sk: "Účasti (minulý rok)", tr: "Katkı Verildi (geçen yıl)", pl: "Kontrybucje (w zeszłym roku)", uz: "Hissa qoʻshgan (o'tgan yili)", vi: "Đã Đóng Góp (năm ngoái)", se: "Bidragit till (förra året)", he: "תרם ל... (שנה שעברה)", fil: "Nag-ambag sa (nakaraang taon)", th: "มีส่วนร่วมใน (ปีที่แล้ว)", sr: "Доприноси (прошла година)", "sr-latn": "Doprinosi (prošla godina)", no: "Bidro til (i fjor)", }, "statcard.reviews": { en: "Total PRs Reviewed", ar: "طلبات السحب التي تم مراجعتها", az: "Nəzərdən Keçirilən Ümumi PR", ca: "Total de PRs revisats", cn: "审查的 PR 总数", "zh-tw": "審核的 PR 總計", cs: "Celkový počet PR", de: "Insgesamt überprüfte PRs", sw: "Idadi ya PRs zilizopitiliwa upya", ur: "کل پی آرز کا جائزہ لیا", bg: "Разгледани заявки за изтегляне", bn: "সর্বমোট পুনরালোচনা করা PR", es: "PR totales revisados", fa: "مجموع درخواست‌های ادغام بررسی‌شده", fi: "Yhteensä tarkastettuja PR:itä", fr: "Nombre total de PR examinés", hi: "कुल PRs की समीक्षा की गई", sa: "समीक्षिताः कुल-पीआर", hu: "Összes ellenőrzött PR", it: "PR totali esaminati", ja: "レビューされた PR の総数", kr: "검토된 총 PR", nl: "Totaal beoordeelde PR's", "pt-pt": "Total de PRs revistos", "pt-br": "Total de PRs revisados", np: "कुल पीआर समीक्षित", el: "Σύνολο Αναθεωρημένων PR", ro: "Total PR-uri Revizuite", ru: "Всего запросов проверено", "uk-ua": "Всього запитів перевірено", id: "Total PR yang Direview", ml: "ആകെ പുൾ അവലോകനങ്ങൾ", my: "စုစုပေါင်း PR များကို ပြန်လည်သုံးသပ်ခဲ့မှု", ta: "மதிப்பாய்வு செய்யப்பட்ட மொத்த இழுத்தல் கோரிக்கைகள்", sk: "Celkový počet PR", tr: "İncelenen toplam PR", pl: "Łącznie sprawdzonych PR", uz: "Koʻrib chiqilgan PR-lar soni", vi: "Tổng Số PR Đã Xem Xét", se: "Totalt antal granskade PR", he: "סך כל ה־PRs שנסרקו", fil: "Kabuuang PR na Na-review", th: "รีวิว PR แล้วทั้งหมด", sr: "Укупно прегледаних PR-ова", "sr-latn": "Ukupno pregledanih PR-ova", no: "Totalt antall vurderte PR", }, "statcard.discussions-started": { en: "Total Discussions Started", ar: "مجموع المناقشات التي بدأها", az: "Başladılan Ümumi Müzakirə", ca: "Discussions totals iniciades", cn: "发起的讨论总数", "zh-tw": "發起的討論總數", cs: "Celkem zahájených diskusí", de: "Gesamt gestartete Diskussionen", sw: "Idadi ya majadiliano yaliyoanzishwa", ur: "کل مباحثے شروع کیے", bg: "Започнати дискусии", bn: "সর্বমোট আলোচনা শুরু", es: "Discusiones totales iniciadas", fa: "مجموع بحث‌های آغازشده", fi: "Aloitetut keskustelut yhteensä", fr: "Nombre total de discussions lancées", hi: "कुल चर्चाएँ शुरू हुईं", sa: "प्रारब्धाः कुल-चर्चाः", hu: "Összes megkezdett megbeszélés", it: "Discussioni totali avviate", ja: "開始されたディスカッションの総数", kr: "시작된 토론 총 수", nl: "Totaal gestarte discussies", "pt-pt": "Total de Discussões Iniciadas", "pt-br": "Total de Discussões Iniciadas", np: "कुल चर्चा सुरु", el: "Σύνολο Συζητήσεων που Ξεκίνησαν", ro: "Total Discuții Începute", ru: "Всего начатых обсуждений", "uk-ua": "Всього розпочатих дискусій", id: "Total Diskusi Dimulai", ml: "ആരംഭിച്ച ആലോചനകൾ", my: "စုစုပေါင်း စတင်ခဲ့သော ဆွေးနွေးမှုများ", ta: "மொத்த விவாதங்கள் தொடங்கின", sk: "Celkový počet začatých diskusií", tr: "Başlatılan Toplam Tartışma", pl: "Łącznie rozpoczętych dyskusji", uz: "Boshlangan muzokaralar soni", vi: "Tổng Số Thảo Luận Bắt Đầu", se: "Totalt antal diskussioner startade", he: "סך כל הדיונים שהותחלו", fil: "Kabuuang mga Diskusyon na Sinimulan", th: "เริ่มหัวข้อสนทนาทั้งหมด", sr: "Укупно покренутих дискусија", "sr-latn": "Ukupno pokrenutih diskusija", no: "Totalt antall startede diskusjoner", }, "statcard.discussions-answered": { en: "Total Discussions Answered", ar: "مجموع المناقشات المُجابة", az: "Cavablandırılan Ümumi Müzakirə", ca: "Discussions totals respostes", cn: "回复的讨论总数", "zh-tw": "回覆討論總計", cs: "Celkem zodpovězených diskusí", de: "Gesamt beantwortete Diskussionen", sw: "Idadi ya majadiliano yaliyojibiwa", ur: "کل مباحثے جواب دیے", bg: "Отговорени дискусии", bn: "সর্বমোট আলোচনা উত্তর", es: "Discusiones totales respondidas", fa: "مجموع بحث‌های پاسخ‌داده‌شده", fi: "Vastatut keskustelut yhteensä", fr: "Nombre total de discussions répondues", hi: "कुल चर्चाओं के उत्तर", sa: "उत्तरिताः कुल-चर्चाः", hu: "Összes megválaszolt megbeszélés", it: "Discussioni totali risposte", ja: "回答されたディスカッションの総数", kr: "답변된 토론 총 수", nl: "Totaal beantwoorde discussies", "pt-pt": "Total de Discussões Respondidas", "pt-br": "Total de Discussões Respondidas", np: "कुल चर्चा उत्तर", el: "Σύνολο Συζητήσεων που Απαντήθηκαν", ro: "Total Răspunsuri La Discuții", ru: "Всего отвеченных обсуждений", "uk-ua": "Всього відповідей на дискусії", id: "Total Diskusi Dibalas", ml: "ഉത്തരം നൽകിയ ആലോചനകൾ", my: "စုစုပေါင်း ပြန်လည်ဖြေကြားခဲ့သော ဆွေးနွေးမှုများ", ta: "பதிலளிக்கப்பட்ட மொத்த விவாதங்கள்", sk: "Celkový počet zodpovedaných diskusií", tr: "Toplam Cevaplanan Tartışma", pl: "Łącznie odpowiedzianych dyskusji", uz: "Javob berilgan muzokaralar soni", vi: "Tổng Số Thảo Luận Đã Trả Lời", se: "Totalt antal diskussioner besvarade", he: "סך כל הדיונים שנענו", fil: "Kabuuang mga Diskusyon na Sinagot", th: "ตอบกลับหัวข้อสนทนาทั้งหมด", sr: "Укупно одговорених дискусија", "sr-latn": "Ukupno odgovorenih diskusija", no: "Totalt antall besvarte diskusjoner", }, "statcard.prs-merged": { en: "Total PRs Merged", ar: "مجموع طلبات السحب المُدمجة", az: "Birləşdirilmiş Ümumi PR", ca: "PRs totals fusionats", cn: "合并的 PR 总数", "zh-tw": "合併的 PR 總計", cs: "Celkem sloučených PR", de: "Insgesamt zusammengeführte PRs", sw: "Idadi ya PRs zilizounganishwa", ur: "کل پی آرز ضم کیے", bg: "Сляти заявки за изтегляния", bn: "সর্বমোট PR একত্রীকৃত", es: "PR totales fusionados", fa: "مجموع درخواست‌های ادغام شده", fi: "Yhteensä yhdistetyt PR:t", fr: "Nombre total de PR fusionnés", hi: "कुल PR का विलय", sa: "विलीनाः कुल-पीआर", hu: "Összes egyesített PR", it: "PR totali uniti", ja: "マージされた PR の総数", kr: "병합된 총 PR", nl: "Totaal samengevoegde PR's", "pt-pt": "Total de PRs Fundidos", "pt-br": "Total de PRs Integrados", np: "कुल विलयित PRs", el: "Σύνολο Συγχωνευμένων PR", ro: "Total PR-uri Fuzionate", ru: "Всего объединённых запросов", "uk-ua": "Всього об'єднаних запитів", id: "Total PR Digabungkan", my: "စုစုပေါင်း ပေါင်းစည်းခဲ့သော PR များ", ta: "இணைக்கப்பட்ட மொத்த PRகள்", sk: "Celkový počet zlúčených PR", tr: "Toplam Birleştirilmiş PR", pl: "Łącznie połączonych PR", uz: "Birlangan PR-lar soni", vi: "Tổng Số PR Đã Hợp Nhất", se: "Totalt antal sammanfogade PR", he: "סך כל ה־PRs ששולבו", fil: "Kabuuang mga PR na Pinagsama", th: "PR ที่ถูก Merged แล้วทั้งหมด", sr: "Укупно спојених PR-ова", "sr-latn": "Ukupno spojenih PR-ova", no: "Totalt antall sammenslåtte PR", }, "statcard.prs-merged-percentage": { en: "Merged PRs Percentage", ar: "نسبة طلبات السحب المُدمجة", az: "Birləşdirilmiş PR-ların Faizi", ca: "Percentatge de PRs fusionats", cn: "被合并的 PR 占比", "zh-tw": "合併的 PR 百分比", cs: "Sloučené PRs v procentech", de: "Zusammengeführte PRs in Prozent", sw: "Asilimia ya PRs zilizounganishwa", ur: "ضم کیے گئے پی آرز کی شرح", bg: "Процент сляти заявки за изтегляния", bn: "PR একত্রীকরণের শতাংশ", es: "Porcentaje de PR fusionados", fa: "درصد درخواست‌های ادغام‌شده", fi: "Yhdistettyjen PR:ien prosentti", fr: "Pourcentage de PR fusionnés", hi: "मर्ज किए गए PRs प्रतिशत", sa: "विलीन-पीआर प्रतिशतम्", hu: "Egyesített PR-k százaléka", it: "Percentuale di PR uniti", ja: "マージされた PR の割合", kr: "병합된 PR의 비율", nl: "Percentage samengevoegde PR's", "pt-pt": "Percentagem de PRs Fundidos", "pt-br": "Porcentagem de PRs Integrados", np: "PR मर्ज गरिएको प्रतिशत", el: "Ποσοστό Συγχωνευμένων PR", ro: "Procentaj PR-uri Fuzionate", ru: "Процент объединённых запросов", "uk-ua": "Відсоток об'єднаних запитів", id: "Persentase PR Digabungkan", my: "PR များကို ပေါင်းစည်းခဲ့သော ရာခိုင်နှုန်း", ta: "இணைக்கப்பட்ட PRகள் சதவீதம்", sk: "Percento zlúčených PR", tr: "Birleştirilmiş PR Yüzdesi", pl: "Procent połączonych PR", uz: "Birlangan PR-lar foizi", vi: "Tỷ Lệ PR Đã Hợp Nhất", se: "Procent av sammanfogade PR", he: "אחוז ה־PRs ששולבו", fil: "Porsyento ng mga PR na Pinagsama", th: "เปอร์เซ็นต์ PR ที่ถูก Merged แล้วทั้งหมด", sr: "Проценат спојених PR-ова", "sr-latn": "Procenat spojenih PR-ova", no: "Prosentandel sammenslåtte PR", }, }; }; const repoCardLocales = { "repocard.template": { en: "Template", ar: "قالب", az: "Şablon", bg: "Шаблон", bn: "টেমপ্লেট", ca: "Plantilla", cn: "模板", "zh-tw": "模板", cs: "Šablona", de: "Vorlage", sw: "Kigezo", ur: "سانچہ", es: "Plantilla", fa: "الگو", fi: "Malli", fr: "Modèle", hi: "खाका", sa: "प्रारूपम्", hu: "Sablon", it: "Template", ja: "テンプレート", kr: "템플릿", nl: "Sjabloon", "pt-pt": "Modelo", "pt-br": "Modelo", np: "टेम्पलेट", el: "Πρότυπο", ro: "Șablon", ru: "Шаблон", "uk-ua": "Шаблон", id: "Pola", ml: "ടെംപ്ലേറ്റ്", my: "ပုံစံ", ta: `டெம்ப்ளேட்`, sk: "Šablóna", tr: "Şablon", pl: "Szablony", uz: "Shablon", vi: "Mẫu", se: "Mall", he: "תבנית", fil: "Suleras", th: "เทมเพลต", sr: "Шаблон", "sr-latn": "Šablon", no: "Mal", }, "repocard.archived": { en: "Archived", ar: "مُؤرشف", az: "Arxiv", bg: "Архивирани", bn: "আর্কাইভড", ca: "Arxivats", cn: "已归档", "zh-tw": "已封存", cs: "Archivováno", de: "Archiviert", sw: "Hifadhiwa kwenye kumbukumbu", ur: "محفوظ شدہ", es: "Archivados", fa: "بایگانی‌شده", fi: "Arkistoitu", fr: "Archivé", hi: "संग्रहीत", sa: "संगृहीतम्", hu: "Archivált", it: "Archiviata", ja: "アーカイブ済み", kr: "보관됨", nl: "Gearchiveerd", "pt-pt": "Arquivados", "pt-br": "Arquivados", np: "अभिलेख राखियो", el: "Αρχειοθετημένα", ro: "Arhivat", ru: "Архивирован", "uk-ua": "Архивований", id: "Arsip", ml: "ശേഖരിച്ചത്", my: "သိုလှောင်ပြီး", ta: `காப்பகப்படுத்தப்பட்டது`, sk: "Archivované", tr: "Arşiv", pl: "Zarchiwizowano", uz: "Arxivlangan", vi: "Đã Lưu Trữ", se: "Arkiverade", he: "גנוז", fil: "Naka-arkibo", th: "เก็บถาวร", sr: "Архивирано", "sr-latn": "Arhivirano", no: "Arkivert", }, }; const langCardLocales = { "langcard.title": { en: "Most Used Languages", ar: "أكثر اللغات استخدامًا", az: "Ən Çox İstifadə Olunan Dillər", ca: "Llenguatges més utilitzats", cn: "最常用的语言", "zh-tw": "最常用的語言", cs: "Nejpoužívanější jazyky", de: "Meist verwendete Sprachen", bg: "Най-използвани езици", bn: "সর্বাধিক ব্যবহৃত ভাষা সমূহ", sw: "Lugha zilizotumika zaidi", ur: "سب سے زیادہ استعمال شدہ زبانیں", es: "Lenguajes más usados", fa: "زبان‌های پرکاربرد", fi: "Käytetyimmät kielet", fr: "Langages les plus utilisés", hi: "सर्वाधिक प्रयुक्त भाषा", sa: "सर्वाधिक-प्रयुक्ताः भाषाः", hu: "Leggyakrabban használt nyelvek", it: "Linguaggi più utilizzati", ja: "最もよく使っている言語", kr: "가장 많이 사용된 언어", nl: "Meest gebruikte talen", "pt-pt": "Linguagens mais usadas", "pt-br": "Linguagens mais usadas", np: "अधिक प्रयोग गरिएको भाषाहरू", el: "Οι περισσότερο χρησιμοποιούμενες γλώσσες", ro: "Cele Mai Folosite Limbaje", ru: "Наиболее используемые языки", "uk-ua": "Найбільш використовувані мови", id: "Bahasa Yang Paling Banyak Digunakan", ml: "കൂടുതൽ ഉപയോഗിച്ച ഭാഷകൾ", my: "အများဆုံးအသုံးပြုသောဘာသာစကားများ", ta: `அதிகம் பயன்படுத்தப்படும் மொழிகள்`, sk: "Najviac používané jazyky", tr: "En Çok Kullanılan Diller", pl: "Najczęściej używane języki", uz: "Eng koʻp ishlatiladigan tillar", vi: "Ngôn Ngữ Thường Sử Dụng", se: "Mest använda språken", he: "השפות הכי משומשות", fil: "Mga Pinakamadalas na Ginagamit na Wika", th: "ภาษาที่ใช้บ่อยที่สุด", sr: "Најкоришћенији језици", "sr-latn": "Najkorišćeniji jezici", no: "Mest brukte språk", }, "langcard.nodata": { en: "No languages data.", ar: "لا توجد بيانات للغات.", az: "Dil məlumatı yoxdur.", ca: "Sense dades d'idiomes", cn: "没有语言数据。", "zh-tw": "沒有語言資料。", cs: "Žádné jazykové údaje.", de: "Keine Sprachdaten.", bg: "Няма данни за езици", bn: "কোন ভাষার ডেটা নেই।", sw: "Hakuna kumbukumbu ya lugha zozote", ur: "کوئی زبان کا ڈیٹا نہیں۔", es: "Sin datos de idiomas.", fa: "داده‌ای برای زبان‌ها وجود ندارد.", fi: "Ei kielitietoja.", fr: "Aucune donnée sur les langues.", hi: "कोई भाषा डेटा नहीं", sa: "भाषा-विवरणं नास्ति।", hu: "Nincsenek nyelvi adatok.", it: "Nessun dato sulle lingue.", ja: "言語データがありません。", kr: "언어 데이터가 없습니다.", nl: "Ingen sprogdata.", "pt-pt": "Sem dados de linguagens.", "pt-br": "Sem dados de linguagens.", np: "कुनै भाषा डाटा छैन।", el: "Δεν υπάρχουν δεδομένα γλωσσών.", ro: "Lipsesc date despre limbă.", ru: "Нет данных о языках.", "uk-ua": "Немає даних про мови.", id: "Tidak ada data bahasa.", ml: "ഭാഷാ ഡാറ്റയില്ല.", my: "ဒေတာ မရှိပါ။", ta: `மொழி தரவு இல்லை.`, sk: "Žiadne údaje o jazykoch.", tr: "Dil verisi yok.", pl: "Brak danych dotyczących języków.", uz: "Til haqida ma'lumot yo'q.", vi: "Không có dữ liệu ngôn ngữ.", se: "Inga språkdata.", he: "אין נתוני שפות", fil: "Walang datos ng lenggwahe.", th: "ไม่มีข้อมูลภาษา", sr: "Нема података о језицима.", "sr-latn": "Nema podataka o jezicima.", no: "Ingen språkdata.", }, }; const wakatimeCardLocales = { "wakatimecard.title": { en: "WakaTime Stats", ar: "إحصائيات واكا تايم", az: "WakaTime Statistikası", ca: "Estadístiques de WakaTime", cn: "WakaTime 周统计", "zh-tw": "WakaTime 周統計", cs: "Statistiky WakaTime", de: "WakaTime Status", sw: "Takwimu ya WakaTime", ur: "وکاٹائم کے اعداد و شمار", bg: "WakaTime статистика", bn: "WakaTime স্ট্যাটাস", es: "Estadísticas de WakaTime", fa: "آمار WakaTime", fi: "WakaTime-tilastot", fr: "Statistiques de WakaTime", hi: "वाकाटाइम आँकड़े", sa: "WakaTime सांख्यिकी", hu: "WakaTime statisztika", it: "Statistiche WakaTime", ja: "WakaTime ワカタイム統計", kr: "WakaTime 주간 통계", nl: "WakaTime-statistieken", "pt-pt": "Estatísticas WakaTime", "pt-br": "Estatísticas WakaTime", np: "WakaTime तथ्या .्क", el: "Στατιστικά WakaTime", ro: "Statistici WakaTime", ru: "Статистика WakaTime", "uk-ua": "Статистика WakaTime", id: "Status WakaTime", ml: "വാകടൈം സ്ഥിതിവിവരക്കണക്കുകൾ", my: "WakaTime အချက်အလက်များ", ta: `WakaTime புள்ளிவிவரங்கள்`, sk: "WakaTime štatistika", tr: "WakaTime İstatistikler", pl: "Statystyki WakaTime", uz: "WakaTime statistikasi", vi: "Thống Kê WakaTime", se: "WakaTime statistik", he: "סטטיסטיקות WakaTime", fil: "Mga Estadistika ng WakaTime", th: "สถิติ WakaTime", sr: "WakaTime статистика", "sr-latn": "WakaTime statistika", no: "WakaTime-statistikk", }, "wakatimecard.lastyear": { en: "last year", ar: "العام الماضي", az: "Ötən il", ca: "L'any passat", cn: "去年", "zh-tw": "去年", cs: "Minulý rok", de: "Letztes Jahr", sw: "Mwaka uliopita", ur: "پچھلا سال", bg: "миналата год.", bn: "গত বছর", es: "El año pasado", fa: "سال گذشته", fi: "Viime vuosi", fr: "L'année dernière", hi: "पिछले साल", sa: "गतवर्षे", hu: "Tavaly", it: "L'anno scorso", ja: "昨年", kr: "작년", nl: "Vorig jaar", "pt-pt": "Ano passado", "pt-br": "Ano passado", np: "गत वर्ष", el: "Πέρυσι", ro: "Anul trecut", ru: "За прошлый год", "uk-ua": "За минулий рік", id: "Tahun lalu", ml: "കഴിഞ്ഞ വർഷം", my: "မနှစ်က", ta: `கடந்த ஆண்டு`, sk: "Minulý rok", tr: "Geçen yıl", pl: "W zeszłym roku", uz: "O'tgan yil", vi: "Năm ngoái", se: "Förra året", he: "שנה שעברה", fil: "Nakaraang Taon", th: "ปีที่แล้ว", sr: "Прошла год.", "sr-latn": "Prošla god.", no: "I fjor", }, "wakatimecard.last7days": { en: "last 7 days", ar: "آخر 7 أيام", az: "Son 7 gün", ca: "Ultims 7 dies", cn: "最近 7 天", "zh-tw": "最近 7 天", cs: "Posledních 7 dní", de: "Letzte 7 Tage", sw: "Siku 7 zilizopita", ur: "پچھلے 7 دن", bg: "последните 7 дни", bn: "গত ৭ দিন", es: "Últimos 7 días", fa: "هفت روز گذشته", fi: "Viimeiset 7 päivää", fr: "7 derniers jours", hi: "पिछले 7 दिन", sa: "विगतसप्तदिनेषु", hu: "Elmúlt 7 nap", it: "Ultimi 7 giorni", ja: "過去 7 日間", kr: "지난 7 일", nl: "Afgelopen 7 dagen", "pt-pt": "Últimos 7 dias", "pt-br": "Últimos 7 dias", np: "गत ७ दिन", el: "Τελευταίες 7 ημέρες", ro: "Ultimele 7 zile", ru: "Последние 7 дней", "uk-ua": "Останні 7 днів", id: "7 hari terakhir", ml: "കഴിഞ്ഞ 7 ദിവസം", my: "7 ရက်အတွင်း", ta: `கடந்த 7 நாட்கள்`, sk: "Posledných 7 dní", tr: "Son 7 gün", pl: "Ostatnie 7 dni", uz: "O'tgan 7 kun", vi: "7 ngày qua", se: "Senaste 7 dagarna", he: "ב־7 הימים האחרונים", fil: "Huling 7 Araw", th: "7 วันที่ผ่านมา", sr: "Претходних 7 дана", "sr-latn": "Prethodnih 7 dana", no: "Siste 7 dager", }, "wakatimecard.notpublic": { en: "WakaTime user profile not public", ar: "ملف مستخدم واكا تايم شخصي", az: "WakaTime istifadəçi profili ictimai deyil", ca: "Perfil d'usuari de WakaTime no públic", cn: "WakaTime 用户个人资料未公开", "zh-tw": "WakaTime 使用者個人資料未公開", cs: "Profil uživatele WakaTime není veřejný", de: "WakaTime-Benutzerprofil nicht öffentlich", sw: "Maelezo ya mtumizi wa WakaTime si ya watu wote(umma)", ur: "وکاٹائم صارف کا پروفائل عوامی نہیں", bg: "Потребителски профил в WakaTime не е общодостъпен", bn: "WakaTime ব্যবহারকারীর প্রোফাইল প্রকাশ্য নয়", es: "Perfil de usuario de WakaTime no público", fa: "پروفایل کاربری WakaTime عمومی نیست", fi: "WakaTime-käyttäjäprofiili ei ole julkinen", fr: "Profil utilisateur WakaTime non public", hi: "WakaTime उपयोगकर्ता प्रोफ़ाइल सार्वजनिक नहीं है", sa: "WakaTime उपयोगकर्ता-प्रोफ़ाइल सार्वजनिकं नास्ति", hu: "A WakaTime felhasználói profilja nem nyilvános", it: "Profilo utente WakaTime non pubblico", ja: "WakaTime ユーザープロファイルは公開されていません", kr: "WakaTime 사용자 프로필이 공개되지 않았습니다", nl: "WakaTime gebruikersprofiel niet openbaar", "pt-pt": "Perfil de utilizador WakaTime não público", "pt-br": "Perfil de usuário WakaTime não público", np: "WakaTime प्रयोगकर्ता प्रोफाइल सार्वजनिक छैन", el: "Το προφίλ χρήστη WakaTime δεν είναι δημόσιο", ro: "Profilul utilizatorului de Wakatime nu este public", ru: "Профиль пользователя WakaTime не общедоступный", "uk-ua": "Профіль користувача WakaTime не публічний", id: "Profil pengguna WakaTime tidak publik", ml: "WakaTime ഉപയോക്തൃ പ്രൊഫൈൽ പൊതുവായി പ്രസിദ്ധീകരിക്കപ്പെടാത്തതാണ്", my: "Public Profile မဟုတ်ပါ။", ta: `WakaTime பயனர் சுயவிவரம் பொதுவில் இல்லை.`, sk: "Profil používateľa WakaTime nie je verejný", tr: "WakaTime kullanıcı profili herkese açık değil", pl: "Profil użytkownika WakaTime nie jest publiczny", uz: "WakaTime foydalanuvchi profili ochiq emas", vi: "Hồ sơ người dùng WakaTime không công khai", se: "WakaTime användarprofil inte offentlig", he: "פרופיל משתמש WakaTime לא פומבי", fil: "Hindi pampubliko ang profile ng gumagamit ng WakaTime", th: "โปรไฟล์ผู้ใช้ WakaTime ไม่ได้เป็นสาธารณะ", sr: "WakaTime профил корисника није јаван", "sr-latn": "WakaTime profil korisnika nije javan", no: "WakaTime brukerprofil ikke offentlig", }, "wakatimecard.nocodedetails": { en: "User doesn't publicly share detailed code statistics", ar: "المستخدم لا يشارك المعلومات التفصيلية", az: "İstifadəçi kod statistikalarını ictimai şəkildə paylaşmır", ca: "L'usuari no comparteix dades públiques del seu codi", cn: "用户不公开分享详细的代码统计信息", "zh-tw": "使用者不公開分享詳細的程式碼統計資訊", cs: "Uživatel nesdílí podrobné statistiky kódu", de: "Benutzer teilt keine detaillierten Code-Statistiken", sw: "Mtumizi hagawi kila kitu au takwimu na umma", ur: "صارف عوامی طور پر تفصیلی کوڈ کے اعداد و شمار شیئر نہیں کرتا", bg: "Потребителят не споделя подробна статистика за код", bn: "ব্যবহারকারী বিস্তারিত কোড পরিসংখ্যান প্রকাশ করেন না", es: "El usuario no comparte públicamente estadísticas detalladas de código", fa: "کاربر آمار کد تفصیلی را به‌صورت عمومی به اشتراک نمی‌گذارد", fi: "Käyttäjä ei jaa julkisesti tarkkoja kooditilastoja", fr: "L'utilisateur ne partage pas publiquement de statistiques de code détaillées", hi: "उपयोगकर्ता विस्तृत कोड आँकड़े सार्वजनिक रूप से साझा नहीं करता है", sa: "उपयोगकर्ता विस्तृत-कोड-सांख्यिकीं सार्वजनिकरूपेण न दर्शयति", hu: "A felhasználó nem osztja meg nyilvánosan a részletes kódstatisztikákat", it: "L'utente non condivide pubblicamente statistiche dettagliate sul codice", ja: "ユーザーは詳細なコード統計を公開しません", kr: "사용자는 자세한 코드 통계를 공개하지 않습니다", nl: "Gebruiker deelt geen gedetailleerde code-statistieken", "pt-pt": "O utilizador não partilha publicamente estatísticas detalhadas de código", "pt-br": "O usuário não compartilha publicamente estatísticas detalhadas de código", np: "प्रयोगकर्ता सार्वजनिक रूपमा विस्तृत कोड तथ्याङ्क साझा गर्दैन", el: "Ο χρήστης δεν δημοσιεύει δημόσια λεπτομερείς στατιστικές κώδικα", ro: "Utilizatorul nu își publică statisticile detaliate ale codului", ru: "Пользователь не делится подробной статистикой кода", "uk-ua": "Користувач не публікує детальну статистику коду", id: "Pengguna tidak membagikan statistik kode terperinci secara publik", ml: "ഉപയോക്താവ് പൊതുവായി വിശദീകരിച്ച കോഡ് സ്റ്റാറ്റിസ്റ്റിക്സ് പങ്കിടുന്നില്ല", my: "အသုံးပြုသူသည် အသေးစိတ် ကုဒ် စာရင်းအင်းများကို အများသို့ မမျှဝေပါ။", ta: `பயனர் விரிவான குறியீட்டு புள்ளிவிவரங்களைப் பொதுவில் பகிர்வதில்லை.`, sk: "Používateľ neposkytuje verejne podrobné štatistiky kódu", tr: "Kullanıcı ayrıntılı kod istatistiklerini herkese açık olarak paylaşmıyor", pl: "Użytkownik nie udostępnia publicznie szczegółowych statystyk kodu", uz: "Foydalanuvchi umumiy ko`d statistikasini ochiq ravishda almashmaydi", vi: "Người dùng không chia sẻ thống kê mã chi tiết công khai", se: "Användaren delar inte offentligt detaljerad kodstatistik", he: "משתמש לא מפרסם פומבית סטטיסטיקות קוד מפורטות", fil: "Hindi ibinabahagi ng gumagamit ang detalyadong estadistika ng code nang pampubliko", th: "ผู้ใช้ไม่ได้แชร์สถิติโค้ดแบบสาธารณะ", sr: "Корисник не дели јавно детаљну статистику кода", "sr-latn": "Korisnik ne deli javno detaljnu statistiku koda", no: "Brukeren deler ikke detaljert kodestatistikk offentlig", }, "wakatimecard.nocodingactivity": { en: "No coding activity this week", ar: "لا يوجد نشاط برمجي لهذا الأسبوع", az: "Bu həftə heç bir kodlaşdırma fəaliyyəti olmayıb", ca: "No hi ha activitat de codificació aquesta setmana", cn: "本周没有编程活动", "zh-tw": "本周沒有編程活動", cs: "Tento týden žádná aktivita v kódování", de: "Keine Aktivitäten in dieser Woche", sw: "Hakuna matukio yoyote ya kusimba wiki hii", ur: "اس ہفتے کوئی کوڈنگ سرگرمی نہیں", bg: "Няма активност при кодирането тази седмица", bn: "এই সপ্তাহে কোন কোডিং অ্যাক্টিভিটি নেই", es: "No hay actividad de codificación esta semana", fa: "فعالیت کدنویسی در این هفته وجود ندارد", fi: "Ei koodaustoimintaa tällä viikolla", fr: "Aucune activité de codage cette semaine", hi: "इस सप्ताह कोई कोडिंग गतिविधि नहीं ", sa: "अस्मिन् सप्ताहे कोडिङ्-कार्यं नास्ति", hu: "Nem volt aktivitás ezen a héten", it: "Nessuna attività in questa settimana", ja: "今週のコーディング活動はありません", kr: "이번 주 작업내역 없음", nl: "Geen programmeeractiviteit deze week", "pt-pt": "Sem atividade esta semana", "pt-br": "Nenhuma atividade de codificação esta semana", np: "यस हप्ता कुनै कोडिंग गतिविधि छैन", el: "Δεν υπάρχει δραστηριότητα κώδικα γι' αυτή την εβδομάδα", ro: "Nicio activitate de programare săptămâna aceasta", ru: "На этой неделе не было активности", "uk-ua": "Цього тижня не було активності", id: "Tidak ada aktivitas perkodingan minggu ini", ml: "ഈ ആഴ്ച കോഡിംഗ് പ്രവർത്തനങ്ങളൊന്നുമില്ല", my: "ဒီအပတ်မှာ ကုဒ်ရေးခြင်း မရှိပါ။", ta: `இந்த வாரம் குறியீட்டு செயல்பாடு இல்லை.`, sk: "Žiadna kódovacia aktivita tento týždeň", tr: "Bu hafta herhangi bir kod yazma aktivitesi olmadı", pl: "Brak aktywności w tym tygodniu", uz: "Bu hafta faol bo'lmadi", vi: "Không Có Hoạt Động Trong Tuần Này", se: "Ingen aktivitet denna vecka", he: "אין פעילות תכנותית השבוע", fil: "Walang aktibidad sa pag-code ngayong linggo", th: "ไม่มีกิจกรรมการเขียนโค้ดในสัปดาห์นี้", sr: "Током ове недеље није било никаквих активности", "sr-latn": "Tokom ove nedelje nije bilo nikakvih aktivnosti", no: "Ingen kodeaktivitet denne uken", }, }; const availableLocales = Object.keys(repoCardLocales["repocard.archived"]); /** * Checks whether the locale is available or not. * * @param {string} locale The locale to check. * @returns {boolean} Boolean specifying whether the locale is available or not. */ const isLocaleAvailable = (locale) => { return availableLocales.includes(locale.toLowerCase()); }; export { availableLocales, isLocaleAvailable, langCardLocales, repoCardLocales, statCardLocales, wakatimeCardLocales, }; ================================================ FILE: tests/__snapshots__/renderWakatimeCard.test.js.snap ================================================ // Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing exports[`Test Render WakaTime Card should render correctly 1`] = ` " WakaTime Stats (last 7 days) Other: 19 mins TypeScript: 1 min " `; exports[`Test Render WakaTime Card should render correctly with compact layout 1`] = ` " WakaTime Stats (last 7 days) Other - 19 mins TypeScript - 1 min " `; exports[`Test Render WakaTime Card should render correctly with compact layout when langs_count is set 1`] = ` " WakaTime Stats (last 7 days) Other - 19 mins TypeScript - 1 min " `; exports[`Test Render WakaTime Card should render correctly with percent display format 1`] = ` " WakaTime Stats (last 7 days) Other: 47.39 % TypeScript: 50.48 % " `; ================================================ FILE: tests/api.test.js ================================================ // @ts-check import { afterEach, beforeEach, describe, expect, it, jest, } from "@jest/globals"; import axios from "axios"; import MockAdapter from "axios-mock-adapter"; import api from "../api/index.js"; import { calculateRank } from "../src/calculateRank.js"; import { renderStatsCard } from "../src/cards/stats.js"; import { renderError } from "../src/common/render.js"; import { CACHE_TTL, DURATIONS } from "../src/common/cache.js"; /** * @type {import("../src/fetchers/stats").StatsData} */ const stats = { name: "Anurag Hazra", totalStars: 100, totalCommits: 200, totalIssues: 300, totalPRs: 400, totalPRsMerged: 320, mergedPRsPercentage: 80, totalReviews: 50, totalDiscussionsStarted: 10, totalDiscussionsAnswered: 40, contributedTo: 50, rank: { level: "DEV", percentile: 0 }, }; stats.rank = calculateRank({ all_commits: false, commits: stats.totalCommits, prs: stats.totalPRs, reviews: stats.totalReviews, issues: stats.totalIssues, repos: 1, stars: stats.totalStars, followers: 0, }); const data_stats = { data: { user: { name: stats.name, repositoriesContributedTo: { totalCount: stats.contributedTo }, commits: { totalCommitContributions: stats.totalCommits, }, reviews: { totalPullRequestReviewContributions: stats.totalReviews, }, pullRequests: { totalCount: stats.totalPRs }, mergedPullRequests: { totalCount: stats.totalPRsMerged }, openIssues: { totalCount: stats.totalIssues }, closedIssues: { totalCount: 0 }, followers: { totalCount: 0 }, repositoryDiscussions: { totalCount: stats.totalDiscussionsStarted }, repositoryDiscussionComments: { totalCount: stats.totalDiscussionsAnswered, }, repositories: { totalCount: 1, nodes: [{ stargazers: { totalCount: 100 } }], pageInfo: { hasNextPage: false, endCursor: "cursor", }, }, }, }, }; const error = { errors: [ { type: "NOT_FOUND", path: ["user"], locations: [], message: "Could not fetch user", }, ], }; const mock = new MockAdapter(axios); // @ts-ignore const faker = (query, data) => { const req = { query: { username: "anuraghazra", ...query, }, }; const res = { setHeader: jest.fn(), send: jest.fn(), }; mock.onPost("https://api.github.com/graphql").replyOnce(200, data); return { req, res }; }; beforeEach(() => { process.env.CACHE_SECONDS = undefined; }); afterEach(() => { mock.reset(); }); describe("Test /api/", () => { it("should test the request", async () => { const { req, res } = faker({}, data_stats); await api(req, res); expect(res.setHeader).toHaveBeenCalledWith("Content-Type", "image/svg+xml"); expect(res.send).toHaveBeenCalledWith( renderStatsCard(stats, { ...req.query }), ); }); it("should render error card on error", async () => { const { req, res } = faker({}, error); await api(req, res); expect(res.setHeader).toHaveBeenCalledWith("Content-Type", "image/svg+xml"); expect(res.send).toHaveBeenCalledWith( renderError({ message: error.errors[0].message, secondaryMessage: "Make sure the provided username is not an organization", }), ); }); it("should render error card in same theme as requested card", async () => { const { req, res } = faker({ theme: "merko" }, error); await api(req, res); expect(res.setHeader).toHaveBeenCalledWith("Content-Type", "image/svg+xml"); expect(res.send).toHaveBeenCalledWith( renderError({ message: error.errors[0].message, secondaryMessage: "Make sure the provided username is not an organization", renderOptions: { theme: "merko" }, }), ); }); it("should get the query options", async () => { const { req, res } = faker( { username: "anuraghazra", hide: "issues,prs,contribs", show_icons: true, hide_border: true, line_height: 100, title_color: "fff", icon_color: "fff", text_color: "fff", bg_color: "fff", }, data_stats, ); await api(req, res); expect(res.setHeader).toHaveBeenCalledWith("Content-Type", "image/svg+xml"); expect(res.send).toHaveBeenCalledWith( renderStatsCard(stats, { hide: ["issues", "prs", "contribs"], show_icons: true, hide_border: true, line_height: 100, title_color: "fff", icon_color: "fff", text_color: "fff", bg_color: "fff", }), ); }); it("should have proper cache", async () => { const { req, res } = faker({}, data_stats); await api(req, res); expect(res.setHeader.mock.calls).toEqual([ ["Content-Type", "image/svg+xml"], [ "Cache-Control", `max-age=${CACHE_TTL.STATS_CARD.DEFAULT}, ` + `s-maxage=${CACHE_TTL.STATS_CARD.DEFAULT}, ` + `stale-while-revalidate=${DURATIONS.ONE_DAY}`, ], ]); }); it("should set proper cache", async () => { const cache_seconds = DURATIONS.TWELVE_HOURS; const { req, res } = faker({ cache_seconds }, data_stats); await api(req, res); expect(res.setHeader.mock.calls).toEqual([ ["Content-Type", "image/svg+xml"], [ "Cache-Control", `max-age=${cache_seconds}, ` + `s-maxage=${cache_seconds}, ` + `stale-while-revalidate=${DURATIONS.ONE_DAY}`, ], ]); }); it("should set shorter cache when error", async () => { const { req, res } = faker({}, error); await api(req, res); expect(res.setHeader.mock.calls).toEqual([ ["Content-Type", "image/svg+xml"], [ "Cache-Control", `max-age=${CACHE_TTL.ERROR}, ` + `s-maxage=${CACHE_TTL.ERROR}, ` + `stale-while-revalidate=${DURATIONS.ONE_DAY}`, ], ]); }); it("should properly set cache using CACHE_SECONDS env variable", async () => { const cacheSeconds = "10000"; process.env.CACHE_SECONDS = cacheSeconds; const { req, res } = faker({}, data_stats); await api(req, res); expect(res.setHeader.mock.calls).toEqual([ ["Content-Type", "image/svg+xml"], [ "Cache-Control", `max-age=${cacheSeconds}, ` + `s-maxage=${cacheSeconds}, ` + `stale-while-revalidate=${DURATIONS.ONE_DAY}`, ], ]); }); it("should disable cache when CACHE_SECONDS is set to 0", async () => { process.env.CACHE_SECONDS = "0"; const { req, res } = faker({}, data_stats); await api(req, res); expect(res.setHeader.mock.calls).toEqual([ ["Content-Type", "image/svg+xml"], [ "Cache-Control", "no-cache, no-store, must-revalidate, max-age=0, s-maxage=0", ], ["Pragma", "no-cache"], ["Expires", "0"], ]); }); it("should set proper cache with clamped values", async () => { { let { req, res } = faker({ cache_seconds: 200_000 }, data_stats); await api(req, res); expect(res.setHeader.mock.calls).toEqual([ ["Content-Type", "image/svg+xml"], [ "Cache-Control", `max-age=${CACHE_TTL.STATS_CARD.MAX}, ` + `s-maxage=${CACHE_TTL.STATS_CARD.MAX}, ` + `stale-while-revalidate=${DURATIONS.ONE_DAY}`, ], ]); } // note i'm using block scoped vars { let { req, res } = faker({ cache_seconds: 0 }, data_stats); await api(req, res); expect(res.setHeader.mock.calls).toEqual([ ["Content-Type", "image/svg+xml"], [ "Cache-Control", `max-age=${CACHE_TTL.STATS_CARD.MIN}, ` + `s-maxage=${CACHE_TTL.STATS_CARD.MIN}, ` + `stale-while-revalidate=${DURATIONS.ONE_DAY}`, ], ]); } { let { req, res } = faker({ cache_seconds: -10_000 }, data_stats); await api(req, res); expect(res.setHeader.mock.calls).toEqual([ ["Content-Type", "image/svg+xml"], [ "Cache-Control", `max-age=${CACHE_TTL.STATS_CARD.MIN}, ` + `s-maxage=${CACHE_TTL.STATS_CARD.MIN}, ` + `stale-while-revalidate=${DURATIONS.ONE_DAY}`, ], ]); } }); it("should allow changing ring_color", async () => { const { req, res } = faker( { username: "anuraghazra", hide: "issues,prs,contribs", show_icons: true, hide_border: true, line_height: 100, title_color: "fff", ring_color: "0000ff", icon_color: "fff", text_color: "fff", bg_color: "fff", }, data_stats, ); await api(req, res); expect(res.setHeader).toHaveBeenCalledWith("Content-Type", "image/svg+xml"); expect(res.send).toHaveBeenCalledWith( renderStatsCard(stats, { hide: ["issues", "prs", "contribs"], show_icons: true, hide_border: true, line_height: 100, title_color: "fff", ring_color: "0000ff", icon_color: "fff", text_color: "fff", bg_color: "fff", }), ); }); it("should render error card if username in blacklist", async () => { const { req, res } = faker({ username: "renovate-bot" }, data_stats); await api(req, res); expect(res.setHeader).toHaveBeenCalledWith("Content-Type", "image/svg+xml"); expect(res.send).toHaveBeenCalledWith( renderError({ message: "This username is blacklisted", secondaryMessage: "Please deploy your own instance", renderOptions: { show_repo_link: false }, }), ); }); it("should render error card when wrong locale is provided", async () => { const { req, res } = faker({ locale: "asdf" }, data_stats); await api(req, res); expect(res.setHeader).toHaveBeenCalledWith("Content-Type", "image/svg+xml"); expect(res.send).toHaveBeenCalledWith( renderError({ message: "Something went wrong", secondaryMessage: "Language not found", }), ); }); it("should render error card when include_all_commits true and upstream API fails", async () => { mock .onGet("https://api.github.com/search/commits?q=author:anuraghazra") .reply(200, { error: "Some test error message" }); const { req, res } = faker( { username: "anuraghazra", include_all_commits: true }, data_stats, ); await api(req, res); expect(res.setHeader).toHaveBeenCalledWith("Content-Type", "image/svg+xml"); expect(res.send).toHaveBeenCalledWith( renderError({ message: "Could not fetch total commits.", secondaryMessage: "Please try again later", }), ); // Received SVG output should not contain string "https://tiny.one/readme-stats" expect(res.send.mock.calls[0][0]).not.toContain( "https://tiny.one/readme-stats", ); }); }); ================================================ FILE: tests/bench/api.bench.js ================================================ import api from "../../api/index.js"; import axios from "axios"; import MockAdapter from "axios-mock-adapter"; import { it, jest } from "@jest/globals"; import { runAndLogStats } from "./utils.js"; const stats = { name: "Anurag Hazra", totalStars: 100, totalCommits: 200, totalIssues: 300, totalPRs: 400, totalPRsMerged: 320, mergedPRsPercentage: 80, totalReviews: 50, totalDiscussionsStarted: 10, totalDiscussionsAnswered: 40, contributedTo: 50, rank: null, }; const data_stats = { data: { user: { name: stats.name, repositoriesContributedTo: { totalCount: stats.contributedTo }, commits: { totalCommitContributions: stats.totalCommits, }, reviews: { totalPullRequestReviewContributions: stats.totalReviews, }, pullRequests: { totalCount: stats.totalPRs }, mergedPullRequests: { totalCount: stats.totalPRsMerged }, openIssues: { totalCount: stats.totalIssues }, closedIssues: { totalCount: 0 }, followers: { totalCount: 0 }, repositoryDiscussions: { totalCount: stats.totalDiscussionsStarted }, repositoryDiscussionComments: { totalCount: stats.totalDiscussionsAnswered, }, repositories: { totalCount: 1, nodes: [{ stargazers: { totalCount: 100 } }], pageInfo: { hasNextPage: false, endCursor: "cursor", }, }, }, }, }; const mock = new MockAdapter(axios); const faker = (query, data) => { const req = { query: { username: "anuraghazra", ...query, }, }; const res = { setHeader: jest.fn(), send: jest.fn(), }; mock.onPost("https://api.github.com/graphql").replyOnce(200, data); return { req, res }; }; it("test /api", async () => { await runAndLogStats("test /api", async () => { const { req, res } = faker({}, data_stats); await api(req, res); }); }); ================================================ FILE: tests/bench/calculateRank.bench.js ================================================ import { calculateRank } from "../../src/calculateRank.js"; import { it } from "@jest/globals"; import { runAndLogStats } from "./utils.js"; it("calculateRank", async () => { await runAndLogStats("calculateRank", () => { calculateRank({ all_commits: false, commits: 1300, prs: 1500, issues: 4500, reviews: 1000, repos: 0, stars: 600000, followers: 50000, }); }); }); ================================================ FILE: tests/bench/gist.bench.js ================================================ import gist from "../../api/gist.js"; import axios from "axios"; import MockAdapter from "axios-mock-adapter"; import { it, jest } from "@jest/globals"; import { runAndLogStats } from "./utils.js"; const gist_data = { data: { viewer: { gist: { description: "List of countries and territories in English and Spanish: name, continent, capital, dial code, country codes, TLD, and area in sq km. Lista de países y territorios en Inglés y Español: nombre, continente, capital, código de teléfono, códigos de país, dominio y área en km cuadrados. Updated 2023", owner: { login: "Yizack", }, stargazerCount: 33, forks: { totalCount: 11, }, files: [ { name: "countries.json", language: { name: "JSON", }, size: 85858, }, ], }, }, }, }; const mock = new MockAdapter(axios); mock.onPost("https://api.github.com/graphql").reply(200, gist_data); it("test /api/gist", async () => { await runAndLogStats("test /api/gist", async () => { const req = { query: { id: "bbfce31e0217a3689c8d961a356cb10d", }, }; const res = { setHeader: jest.fn(), send: jest.fn(), }; await gist(req, res); }); }); ================================================ FILE: tests/bench/pin.bench.js ================================================ import pin from "../../api/pin.js"; import axios from "axios"; import MockAdapter from "axios-mock-adapter"; import { it, jest } from "@jest/globals"; import { runAndLogStats } from "./utils.js"; const data_repo = { repository: { username: "anuraghazra", name: "convoychat", stargazers: { totalCount: 38000, }, description: "Help us take over the world! React + TS + GraphQL Chat App", primaryLanguage: { color: "#2b7489", id: "MDg6TGFuZ3VhZ2UyODc=", name: "TypeScript", }, forkCount: 100, isTemplate: false, }, }; const data_user = { data: { user: { repository: data_repo.repository }, organization: null, }, }; const mock = new MockAdapter(axios); mock.onPost("https://api.github.com/graphql").reply(200, data_user); it("test /api/pin", async () => { await runAndLogStats("test /api/pin", async () => { const req = { query: { username: "anuraghazra", repo: "convoychat", }, }; const res = { setHeader: jest.fn(), send: jest.fn(), }; await pin(req, res); }); }); ================================================ FILE: tests/bench/utils.js ================================================ // @ts-check const DEFAULT_RUNS = 1000; const DEFAULT_WARMUPS = 50; /** * Formats a duration in nanoseconds to a compact human-readable string. * * @param {bigint} ns Duration in nanoseconds. * @returns {string} Formatted time string. */ const formatTime = (ns) => { if (ns < 1_000n) { return `${ns}ns`; } if (ns < 1_000_000n) { return `${Number(ns) / 1_000}µs`; } if (ns < 1_000_000_000n) { return `${(Number(ns) / 1_000_000).toFixed(3)}ms`; } return `${(Number(ns) / 1_000_000_000).toFixed(3)}s`; }; /** * Measures synchronous or async function execution time. * * @param {Function} fn Function to measure. * @returns {Promise} elapsed nanoseconds */ const measurePerformance = async (fn) => { const start = process.hrtime.bigint(); const ret = fn(); if (ret instanceof Promise) { await ret; } const end = process.hrtime.bigint(); return end - start; }; /** * Computes basic & extended statistics. * * @param {bigint[]} samples Array of samples in nanoseconds. * @returns {object} Stats */ const computeStats = (samples) => { const sorted = [...samples].sort((a, b) => (a < b ? -1 : 1)); const toNumber = (b) => Number(b); // safe for typical short benches const n = sorted.length; const sum = sorted.reduce((a, b) => a + b, 0n); const avg = Number(sum) / n; const median = n % 2 ? toNumber(sorted[(n - 1) / 2]) : (toNumber(sorted[n / 2 - 1]) + toNumber(sorted[n / 2])) / 2; const p = (q) => { const idx = Math.min(n - 1, Math.floor((q / 100) * n)); return toNumber(sorted[idx]); }; const min = toNumber(sorted[0]); const max = toNumber(sorted[n - 1]); const variance = sorted.reduce((acc, v) => acc + (toNumber(v) - avg) ** 2, 0) / n; const stdev = Math.sqrt(variance); return { runs: n, min, max, average: avg, median, p75: p(75), p95: p(95), p99: p(99), stdev, totalTime: toNumber(sum), }; }; /** * Benchmark a function. * * @param {string} fnName Name of the function (for logging). * @param {Function} fn Function to benchmark. * @param {object} [opts] Options. * @param {number} [opts.runs] Number of measured runs. * @param {number} [opts.warmup] Warm-up iterations (not measured). * @param {boolean} [opts.trimOutliers] Drop top & bottom 1% before stats. * @returns {Promise} Stats (nanoseconds for core metrics). */ export const runAndLogStats = async ( fnName, fn, { runs = DEFAULT_RUNS, warmup = DEFAULT_WARMUPS, trimOutliers = false } = {}, ) => { if (runs <= 0) { throw new Error("Number of runs must be positive."); } // Warm-up for (let i = 0; i < warmup; i++) { const ret = fn(); if (ret instanceof Promise) { await ret; } } const samples = []; for (let i = 0; i < runs; i++) { samples.push(await measurePerformance(fn)); } let processed = samples; if (trimOutliers && samples.length > 10) { const sorted = [...samples].sort((a, b) => (a < b ? -1 : 1)); const cut = Math.max(1, Math.floor(sorted.length * 0.01)); processed = sorted.slice(cut, sorted.length - cut); } const stats = computeStats(processed); const fmt = (ns) => formatTime(BigInt(Math.round(ns))); console.log( `${fnName} | runs=${stats.runs} avg=${fmt(stats.average)} median=${fmt( stats.median, )} p95=${fmt(stats.p95)} min=${fmt(stats.min)} max=${fmt( stats.max, )} stdev=${fmt(stats.stdev)}`, ); return stats; }; ================================================ FILE: tests/calculateRank.test.js ================================================ import { describe, expect, it } from "@jest/globals"; import "@testing-library/jest-dom"; import { calculateRank } from "../src/calculateRank.js"; describe("Test calculateRank", () => { it("new user gets C rank", () => { expect( calculateRank({ all_commits: false, commits: 0, prs: 0, issues: 0, reviews: 0, repos: 0, stars: 0, followers: 0, }), ).toStrictEqual({ level: "C", percentile: 100 }); }); it("beginner user gets B- rank", () => { expect( calculateRank({ all_commits: false, commits: 125, prs: 25, issues: 10, reviews: 5, repos: 0, stars: 25, followers: 5, }), ).toStrictEqual({ level: "B-", percentile: 65.02918514848255 }); }); it("median user gets B+ rank", () => { expect( calculateRank({ all_commits: false, commits: 250, prs: 50, issues: 25, reviews: 10, repos: 0, stars: 50, followers: 10, }), ).toStrictEqual({ level: "B+", percentile: 46.09375 }); }); it("average user gets B+ rank (include_all_commits)", () => { expect( calculateRank({ all_commits: true, commits: 1000, prs: 50, issues: 25, reviews: 10, repos: 0, stars: 50, followers: 10, }), ).toStrictEqual({ level: "B+", percentile: 46.09375 }); }); it("advanced user gets A rank", () => { expect( calculateRank({ all_commits: false, commits: 500, prs: 100, issues: 50, reviews: 20, repos: 0, stars: 200, followers: 40, }), ).toStrictEqual({ level: "A", percentile: 20.841471354166664 }); }); it("expert user gets A+ rank", () => { expect( calculateRank({ all_commits: false, commits: 1000, prs: 200, issues: 100, reviews: 40, repos: 0, stars: 800, followers: 160, }), ).toStrictEqual({ level: "A+", percentile: 5.575988339442828 }); }); it("sindresorhus gets S rank", () => { expect( calculateRank({ all_commits: false, commits: 1300, prs: 1500, issues: 4500, reviews: 1000, repos: 0, stars: 600000, followers: 50000, }), ).toStrictEqual({ level: "S", percentile: 0.4578556547153667 }); }); }); ================================================ FILE: tests/card.test.js ================================================ import { describe, expect, it } from "@jest/globals"; import { queryByTestId } from "@testing-library/dom"; import "@testing-library/jest-dom"; import { cssToObject } from "@uppercod/css-to-object"; import { Card } from "../src/common/Card.js"; import { icons } from "../src/common/icons.js"; import { getCardColors } from "../src/common/color.js"; describe("Card", () => { it("should hide border", () => { const card = new Card({}); card.setHideBorder(true); document.body.innerHTML = card.render(``); expect(queryByTestId(document.body, "card-bg")).toHaveAttribute( "stroke-opacity", "0", ); }); it("should not hide border", () => { const card = new Card({}); card.setHideBorder(false); document.body.innerHTML = card.render(``); expect(queryByTestId(document.body, "card-bg")).toHaveAttribute( "stroke-opacity", "1", ); }); it("should have a custom title", () => { const card = new Card({ customTitle: "custom title", defaultTitle: "default title", }); document.body.innerHTML = card.render(``); expect(queryByTestId(document.body, "card-title")).toHaveTextContent( "custom title", ); }); it("should set custom title", () => { const card = new Card({}); card.setTitle("custom title"); document.body.innerHTML = card.render(``); expect(queryByTestId(document.body, "card-title")).toHaveTextContent( "custom title", ); }); it("should hide title", () => { const card = new Card({}); card.setHideTitle(true); document.body.innerHTML = card.render(``); expect(queryByTestId(document.body, "card-title")).toBeNull(); }); it("should not hide title", () => { const card = new Card({}); card.setHideTitle(false); document.body.innerHTML = card.render(``); expect(queryByTestId(document.body, "card-title")).toBeInTheDocument(); }); it("title should have prefix icon", () => { const card = new Card({ title: "ok", titlePrefixIcon: icons.contribs }); document.body.innerHTML = card.render(``); expect(document.getElementsByClassName("icon")[0]).toBeInTheDocument(); }); it("title should not have prefix icon", () => { const card = new Card({ title: "ok" }); document.body.innerHTML = card.render(``); expect(document.getElementsByClassName("icon")[0]).toBeUndefined(); }); it("should have proper height, width", () => { const card = new Card({ height: 200, width: 200, title: "ok" }); document.body.innerHTML = card.render(``); expect(document.getElementsByTagName("svg")[0]).toHaveAttribute( "height", "200", ); expect(document.getElementsByTagName("svg")[0]).toHaveAttribute( "width", "200", ); }); it("should have less height after title is hidden", () => { const card = new Card({ height: 200, title: "ok" }); card.setHideTitle(true); document.body.innerHTML = card.render(``); expect(document.getElementsByTagName("svg")[0]).toHaveAttribute( "height", "170", ); }); it("main-card-body should have proper when title is visible", () => { const card = new Card({ height: 200 }); document.body.innerHTML = card.render(``); expect(queryByTestId(document.body, "main-card-body")).toHaveAttribute( "transform", "translate(0, 55)", ); }); it("main-card-body should have proper position after title is hidden", () => { const card = new Card({ height: 200 }); card.setHideTitle(true); document.body.innerHTML = card.render(``); expect(queryByTestId(document.body, "main-card-body")).toHaveAttribute( "transform", "translate(0, 25)", ); }); it("should render with correct colors", () => { // returns theme based colors with proper overrides and defaults const { titleColor, textColor, iconColor, bgColor } = getCardColors({ title_color: "f00", icon_color: "0f0", text_color: "00f", bg_color: "fff", theme: "default", }); const card = new Card({ height: 200, colors: { titleColor, textColor, iconColor, bgColor, }, }); document.body.innerHTML = card.render(``); const styleTag = document.querySelector("style"); const stylesObject = cssToObject(styleTag.innerHTML); const headerClassStyles = stylesObject[":host"][".header "]; expect(headerClassStyles["fill"].trim()).toBe("#f00"); expect(queryByTestId(document.body, "card-bg")).toHaveAttribute( "fill", "#fff", ); }); it("should render gradient backgrounds", () => { const { titleColor, textColor, iconColor, bgColor } = getCardColors({ title_color: "f00", icon_color: "0f0", text_color: "00f", bg_color: "90,fff,000,f00", theme: "default", }); const card = new Card({ height: 200, colors: { titleColor, textColor, iconColor, bgColor, }, }); document.body.innerHTML = card.render(``); expect(queryByTestId(document.body, "card-bg")).toHaveAttribute( "fill", "url(#gradient)", ); expect(document.querySelector("defs #gradient")).toHaveAttribute( "gradientTransform", "rotate(90)", ); expect( document.querySelector("defs #gradient stop:nth-child(1)"), ).toHaveAttribute("stop-color", "#fff"); expect( document.querySelector("defs #gradient stop:nth-child(2)"), ).toHaveAttribute("stop-color", "#000"); expect( document.querySelector("defs #gradient stop:nth-child(3)"), ).toHaveAttribute("stop-color", "#f00"); }); }); ================================================ FILE: tests/color.test.js ================================================ import { getCardColors } from "../src/common/color"; import { describe, expect, it } from "@jest/globals"; describe("Test color.js", () => { it("getCardColors: should return expected values", () => { let colors = getCardColors({ title_color: "f00", text_color: "0f0", ring_color: "0000ff", icon_color: "00f", bg_color: "fff", border_color: "fff", theme: "dark", }); expect(colors).toStrictEqual({ titleColor: "#f00", textColor: "#0f0", iconColor: "#00f", ringColor: "#0000ff", bgColor: "#fff", borderColor: "#fff", }); }); it("getCardColors: should fallback to default colors if color is invalid", () => { let colors = getCardColors({ title_color: "invalidcolor", text_color: "0f0", icon_color: "00f", bg_color: "fff", border_color: "invalidColor", theme: "dark", }); expect(colors).toStrictEqual({ titleColor: "#2f80ed", textColor: "#0f0", iconColor: "#00f", ringColor: "#2f80ed", bgColor: "#fff", borderColor: "#e4e2e2", }); }); it("getCardColors: should fallback to specified theme colors if is not defined", () => { let colors = getCardColors({ theme: "dark", }); expect(colors).toStrictEqual({ titleColor: "#fff", textColor: "#9f9f9f", ringColor: "#fff", iconColor: "#79ff97", bgColor: "#151515", borderColor: "#e4e2e2", }); }); it("getCardColors: should return ring color equal to title color if not ring color is defined", () => { let colors = getCardColors({ title_color: "f00", text_color: "0f0", icon_color: "00f", bg_color: "fff", border_color: "fff", theme: "dark", }); expect(colors).toStrictEqual({ titleColor: "#f00", textColor: "#0f0", iconColor: "#00f", ringColor: "#f00", bgColor: "#fff", borderColor: "#fff", }); }); }); ================================================ FILE: tests/e2e/e2e.test.js ================================================ /** * @file Contains end-to-end tests for the Vercel preview instance. */ import dotenv from "dotenv"; dotenv.config(); import { beforeAll, describe, expect, test } from "@jest/globals"; import axios from "axios"; import { renderGistCard } from "../../src/cards/gist.js"; import { renderRepoCard } from "../../src/cards/repo.js"; import { renderStatsCard } from "../../src/cards/stats.js"; import { renderTopLanguages } from "../../src/cards/top-languages.js"; import { renderWakatimeCard } from "../../src/cards/wakatime.js"; const REPO = "curly-fiesta"; const USER = "catelinemnemosyne"; const STATS_CARD_USER = "e2eninja"; const GIST_ID = "372cef55fd897b31909fdeb3a7262758"; const STATS_DATA = { name: "CodeNinja", totalPRs: 1, totalReviews: 0, totalCommits: 3, totalIssues: 1, totalStars: 1, contributedTo: 0, rank: { level: "C", percentile: 98.73972605284538, }, }; const LANGS_DATA = { HTML: { color: "#e34c26", name: "HTML", size: 1721, }, CSS: { color: "#663399", name: "CSS", size: 930, }, JavaScript: { color: "#f1e05a", name: "JavaScript", size: 1912, }, }; const WAKATIME_DATA = { human_readable_range: "last week", is_already_updating: false, is_coding_activity_visible: true, is_including_today: false, is_other_usage_visible: false, is_stuck: false, is_up_to_date: false, is_up_to_date_pending_future: false, percent_calculated: 0, range: "all_time", status: "pending_update", timeout: 15, username: USER, writes_only: false, }; const REPOSITORY_DATA = { name: REPO, nameWithOwner: `${USER}/cra-test`, isPrivate: false, isArchived: false, isTemplate: false, stargazers: { totalCount: 1, }, description: "Simple cra test repo.", primaryLanguage: { color: "#f1e05a", id: "MDg6TGFuZ3VhZ2UxNDA=", name: "JavaScript", }, forkCount: 0, starCount: 1, }; /** * @typedef {import("../../src/fetchers/types").GistData} GistData Gist data type. */ /** * @type {GistData} */ const GIST_DATA = { name: "link.txt", nameWithOwner: "qwerty541/link.txt", description: "Trying to access this path on Windows 10 ver. 1803+ will breaks NTFS", language: "Text", starsCount: 1, forksCount: 0, }; const CACHE_BURST_STRING = `v=${new Date().getTime()}`; describe("Fetch Cards", () => { let VERCEL_PREVIEW_URL; beforeAll(() => { process.env.NODE_ENV = "development"; VERCEL_PREVIEW_URL = process.env.VERCEL_PREVIEW_URL; }); test("retrieve stats card", async () => { expect(VERCEL_PREVIEW_URL).toBeDefined(); // Check if the Vercel preview instance stats card function is up and running. await expect( axios.get(`${VERCEL_PREVIEW_URL}/api?username=${STATS_CARD_USER}`), ).resolves.not.toThrow(); // Get local stats card. const localStatsCardSVG = renderStatsCard(STATS_DATA, { include_all_commits: true, }); // Get the Vercel preview stats card response. const serverStatsSvg = await axios.get( `${VERCEL_PREVIEW_URL}/api?username=${STATS_CARD_USER}&include_all_commits=true&${CACHE_BURST_STRING}`, ); // Check if stats card from deployment matches the stats card from local. expect(serverStatsSvg.data).toEqual(localStatsCardSVG); }, 15000); test("retrieve language card", async () => { expect(VERCEL_PREVIEW_URL).toBeDefined(); // Check if the Vercel preview instance language card function is up and running. console.log( `${VERCEL_PREVIEW_URL}/api/top-langs/?username=${USER}&${CACHE_BURST_STRING}`, ); await expect( axios.get( `${VERCEL_PREVIEW_URL}/api/top-langs/?username=${USER}&${CACHE_BURST_STRING}`, ), ).resolves.not.toThrow(); // Get local language card. const localLanguageCardSVG = renderTopLanguages(LANGS_DATA); // Get the Vercel preview language card response. const severLanguageSVG = await axios.get( `${VERCEL_PREVIEW_URL}/api/top-langs/?username=${USER}&${CACHE_BURST_STRING}`, ); // Check if language card from deployment matches the local language card. expect(severLanguageSVG.data).toEqual(localLanguageCardSVG); }, 15000); test("retrieve WakaTime card", async () => { expect(VERCEL_PREVIEW_URL).toBeDefined(); // Check if the Vercel preview instance WakaTime function is up and running. await expect( axios.get(`${VERCEL_PREVIEW_URL}/api/wakatime?username=${USER}`), ).resolves.not.toThrow(); // Get local WakaTime card. const localWakaCardSVG = renderWakatimeCard(WAKATIME_DATA); // Get the Vercel preview WakaTime card response. const serverWakaTimeSvg = await axios.get( `${VERCEL_PREVIEW_URL}/api/wakatime?username=${USER}&${CACHE_BURST_STRING}`, ); // Check if WakaTime card from deployment matches the local WakaTime card. expect(serverWakaTimeSvg.data).toEqual(localWakaCardSVG); }, 15000); test("retrieve repo card", async () => { expect(VERCEL_PREVIEW_URL).toBeDefined(); // Check if the Vercel preview instance Repo function is up and running. await expect( axios.get( `${VERCEL_PREVIEW_URL}/api/pin/?username=${USER}&repo=${REPO}&${CACHE_BURST_STRING}`, ), ).resolves.not.toThrow(); // Get local repo card. const localRepoCardSVG = renderRepoCard(REPOSITORY_DATA); // Get the Vercel preview repo card response. const serverRepoSvg = await axios.get( `${VERCEL_PREVIEW_URL}/api/pin/?username=${USER}&repo=${REPO}&${CACHE_BURST_STRING}`, ); // Check if Repo card from deployment matches the local Repo card. expect(serverRepoSvg.data).toEqual(localRepoCardSVG); }, 15000); test("retrieve gist card", async () => { expect(VERCEL_PREVIEW_URL).toBeDefined(); // Check if the Vercel preview instance Gist function is up and running. await expect( axios.get( `${VERCEL_PREVIEW_URL}/api/gist?id=${GIST_ID}&${CACHE_BURST_STRING}`, ), ).resolves.not.toThrow(); // Get local gist card. const localGistCardSVG = renderGistCard(GIST_DATA); // Get the Vercel preview gist card response. const serverGistSvg = await axios.get( `${VERCEL_PREVIEW_URL}/api/gist?id=${GIST_ID}&${CACHE_BURST_STRING}`, ); // Check if Gist card from deployment matches the local Gist card. expect(serverGistSvg.data).toEqual(localGistCardSVG); }, 15000); }); ================================================ FILE: tests/fetchGist.test.js ================================================ import { afterEach, describe, expect, it } from "@jest/globals"; import "@testing-library/jest-dom"; import axios from "axios"; import MockAdapter from "axios-mock-adapter"; import { fetchGist } from "../src/fetchers/gist.js"; const gist_data = { data: { viewer: { gist: { description: "List of countries and territories in English and Spanish: name, continent, capital, dial code, country codes, TLD, and area in sq km. Lista de países y territorios en Inglés y Español: nombre, continente, capital, código de teléfono, códigos de país, dominio y área en km cuadrados. Updated 2023", owner: { login: "Yizack", }, stargazerCount: 33, forks: { totalCount: 11, }, files: [ { name: "countries.json", language: { name: "JSON", }, size: 85858, }, { name: "territories.txt", language: { name: "Text", }, size: 87858, }, { name: "countries_spanish.json", language: { name: "JSON", }, size: 85858, }, { name: "territories_spanish.txt", language: { name: "Text", }, size: 87858, }, ], }, }, }, }; const gist_not_found_data = { data: { viewer: { gist: null, }, }, }; const gist_errors_data = { errors: [ { message: "Some test GraphQL error", }, ], }; const mock = new MockAdapter(axios); afterEach(() => { mock.reset(); }); describe("Test fetchGist", () => { it("should fetch gist correctly", async () => { mock.onPost("https://api.github.com/graphql").reply(200, gist_data); let gist = await fetchGist("bbfce31e0217a3689c8d961a356cb10d"); expect(gist).toStrictEqual({ name: "countries.json", nameWithOwner: "Yizack/countries.json", description: "List of countries and territories in English and Spanish: name, continent, capital, dial code, country codes, TLD, and area in sq km. Lista de países y territorios en Inglés y Español: nombre, continente, capital, código de teléfono, códigos de país, dominio y área en km cuadrados. Updated 2023", language: "Text", starsCount: 33, forksCount: 11, }); }); it("should throw correct error if gist not found", async () => { mock .onPost("https://api.github.com/graphql") .reply(200, gist_not_found_data); await expect(fetchGist("bbfce31e0217a3689c8d961a356cb10d")).rejects.toThrow( "Gist not found", ); }); it("should throw error if reaponse contains them", async () => { mock.onPost("https://api.github.com/graphql").reply(200, gist_errors_data); await expect(fetchGist("bbfce31e0217a3689c8d961a356cb10d")).rejects.toThrow( "Some test GraphQL error", ); }); it("should throw error if id is not provided", async () => { await expect(fetchGist()).rejects.toThrow( 'Missing params "id" make sure you pass the parameters in URL', ); }); }); ================================================ FILE: tests/fetchRepo.test.js ================================================ import { afterEach, describe, expect, it } from "@jest/globals"; import "@testing-library/jest-dom"; import axios from "axios"; import MockAdapter from "axios-mock-adapter"; import { fetchRepo } from "../src/fetchers/repo.js"; const data_repo = { repository: { name: "convoychat", stargazers: { totalCount: 38000 }, description: "Help us take over the world! React + TS + GraphQL Chat App", primaryLanguage: { color: "#2b7489", id: "MDg6TGFuZ3VhZ2UyODc=", name: "TypeScript", }, forkCount: 100, }, }; const data_user = { data: { user: { repository: data_repo.repository }, organization: null, }, }; const data_org = { data: { user: null, organization: { repository: data_repo.repository }, }, }; const mock = new MockAdapter(axios); afterEach(() => { mock.reset(); }); describe("Test fetchRepo", () => { it("should fetch correct user repo", async () => { mock.onPost("https://api.github.com/graphql").reply(200, data_user); let repo = await fetchRepo("anuraghazra", "convoychat"); expect(repo).toStrictEqual({ ...data_repo.repository, starCount: data_repo.repository.stargazers.totalCount, }); }); it("should fetch correct org repo", async () => { mock.onPost("https://api.github.com/graphql").reply(200, data_org); let repo = await fetchRepo("anuraghazra", "convoychat"); expect(repo).toStrictEqual({ ...data_repo.repository, starCount: data_repo.repository.stargazers.totalCount, }); }); it("should throw error if user is found but repo is null", async () => { mock .onPost("https://api.github.com/graphql") .reply(200, { data: { user: { repository: null }, organization: null } }); await expect(fetchRepo("anuraghazra", "convoychat")).rejects.toThrow( "User Repository Not found", ); }); it("should throw error if org is found but repo is null", async () => { mock .onPost("https://api.github.com/graphql") .reply(200, { data: { user: null, organization: { repository: null } } }); await expect(fetchRepo("anuraghazra", "convoychat")).rejects.toThrow( "Organization Repository Not found", ); }); it("should throw error if both user & org data not found", async () => { mock .onPost("https://api.github.com/graphql") .reply(200, { data: { user: null, organization: null } }); await expect(fetchRepo("anuraghazra", "convoychat")).rejects.toThrow( "Not found", ); }); it("should throw error if repository is private", async () => { mock.onPost("https://api.github.com/graphql").reply(200, { data: { user: { repository: { ...data_repo, isPrivate: true } }, organization: null, }, }); await expect(fetchRepo("anuraghazra", "convoychat")).rejects.toThrow( "User Repository Not found", ); }); }); ================================================ FILE: tests/fetchStats.test.js ================================================ import { afterEach, beforeEach, describe, expect, it } from "@jest/globals"; import "@testing-library/jest-dom"; import axios from "axios"; import MockAdapter from "axios-mock-adapter"; import { calculateRank } from "../src/calculateRank.js"; import { fetchStats } from "../src/fetchers/stats.js"; // Test parameters. const data_stats = { data: { user: { name: "Anurag Hazra", repositoriesContributedTo: { totalCount: 61 }, commits: { totalCommitContributions: 100, }, reviews: { totalPullRequestReviewContributions: 50, }, pullRequests: { totalCount: 300 }, mergedPullRequests: { totalCount: 240 }, openIssues: { totalCount: 100 }, closedIssues: { totalCount: 100 }, followers: { totalCount: 100 }, repositoryDiscussions: { totalCount: 10 }, repositoryDiscussionComments: { totalCount: 40 }, repositories: { totalCount: 5, nodes: [ { name: "test-repo-1", stargazers: { totalCount: 100 } }, { name: "test-repo-2", stargazers: { totalCount: 100 } }, { name: "test-repo-3", stargazers: { totalCount: 100 } }, ], pageInfo: { hasNextPage: true, endCursor: "cursor", }, }, }, }, }; const data_year2003 = JSON.parse(JSON.stringify(data_stats)); data_year2003.data.user.commits.totalCommitContributions = 428; const data_without_pull_requests = { data: { user: { ...data_stats.data.user, pullRequests: { totalCount: 0 }, mergedPullRequests: { totalCount: 0 }, }, }, }; const data_repo = { data: { user: { repositories: { nodes: [ { name: "test-repo-4", stargazers: { totalCount: 50 } }, { name: "test-repo-5", stargazers: { totalCount: 50 } }, ], pageInfo: { hasNextPage: false, endCursor: "cursor", }, }, }, }, }; const data_repo_zero_stars = { data: { user: { repositories: { nodes: [ { name: "test-repo-1", stargazers: { totalCount: 100 } }, { name: "test-repo-2", stargazers: { totalCount: 100 } }, { name: "test-repo-3", stargazers: { totalCount: 100 } }, { name: "test-repo-4", stargazers: { totalCount: 0 } }, { name: "test-repo-5", stargazers: { totalCount: 0 } }, ], pageInfo: { hasNextPage: true, endCursor: "cursor", }, }, }, }, }; const error = { errors: [ { type: "NOT_FOUND", path: ["user"], locations: [], message: "Could not resolve to a User with the login of 'noname'.", }, ], }; const mock = new MockAdapter(axios); beforeEach(() => { process.env.FETCH_MULTI_PAGE_STARS = "false"; // Set to `false` to fetch only one page of stars. mock.onPost("https://api.github.com/graphql").reply((cfg) => { let req = JSON.parse(cfg.data); if ( req.variables && req.variables.startTime && req.variables.startTime.startsWith("2003") ) { return [200, data_year2003]; } return [ 200, req.query.includes("totalCommitContributions") ? data_stats : data_repo, ]; }); }); afterEach(() => { mock.reset(); }); describe("Test fetchStats", () => { it("should fetch correct stats", async () => { let stats = await fetchStats("anuraghazra"); const rank = calculateRank({ all_commits: false, commits: 100, prs: 300, reviews: 50, issues: 200, repos: 5, stars: 300, followers: 100, }); expect(stats).toStrictEqual({ contributedTo: 61, name: "Anurag Hazra", totalCommits: 100, totalIssues: 200, totalPRs: 300, totalPRsMerged: 0, mergedPRsPercentage: 0, totalReviews: 50, totalStars: 300, totalDiscussionsStarted: 0, totalDiscussionsAnswered: 0, rank, }); }); it("should stop fetching when there are repos with zero stars", async () => { mock.reset(); mock .onPost("https://api.github.com/graphql") .replyOnce(200, data_stats) .onPost("https://api.github.com/graphql") .replyOnce(200, data_repo_zero_stars); let stats = await fetchStats("anuraghazra"); const rank = calculateRank({ all_commits: false, commits: 100, prs: 300, reviews: 50, issues: 200, repos: 5, stars: 300, followers: 100, }); expect(stats).toStrictEqual({ contributedTo: 61, name: "Anurag Hazra", totalCommits: 100, totalIssues: 200, totalPRs: 300, totalPRsMerged: 0, mergedPRsPercentage: 0, totalReviews: 50, totalStars: 300, totalDiscussionsStarted: 0, totalDiscussionsAnswered: 0, rank, }); }); it("should throw error", async () => { mock.reset(); mock.onPost("https://api.github.com/graphql").reply(200, error); await expect(fetchStats("anuraghazra")).rejects.toThrow( "Could not resolve to a User with the login of 'noname'.", ); }); it("should fetch total commits", async () => { mock .onGet("https://api.github.com/search/commits?q=author:anuraghazra") .reply(200, { total_count: 1000 }); let stats = await fetchStats("anuraghazra", true); const rank = calculateRank({ all_commits: true, commits: 1000, prs: 300, reviews: 50, issues: 200, repos: 5, stars: 300, followers: 100, }); expect(stats).toStrictEqual({ contributedTo: 61, name: "Anurag Hazra", totalCommits: 1000, totalIssues: 200, totalPRs: 300, totalPRsMerged: 0, mergedPRsPercentage: 0, totalReviews: 50, totalStars: 300, totalDiscussionsStarted: 0, totalDiscussionsAnswered: 0, rank, }); }); it("should throw specific error when include_all_commits true and invalid username", async () => { expect(fetchStats("asdf///---", true)).rejects.toThrow( new Error("Invalid username provided."), ); }); it("should throw specific error when include_all_commits true and API returns error", async () => { mock .onGet("https://api.github.com/search/commits?q=author:anuraghazra") .reply(200, { error: "Some test error message" }); expect(fetchStats("anuraghazra", true)).rejects.toThrow( new Error("Could not fetch total commits."), ); }); it("should exclude stars of the `test-repo-1` repository", async () => { mock .onGet("https://api.github.com/search/commits?q=author:anuraghazra") .reply(200, { total_count: 1000 }); let stats = await fetchStats("anuraghazra", true, ["test-repo-1"]); const rank = calculateRank({ all_commits: true, commits: 1000, prs: 300, reviews: 50, issues: 200, repos: 5, stars: 200, followers: 100, }); expect(stats).toStrictEqual({ contributedTo: 61, name: "Anurag Hazra", totalCommits: 1000, totalIssues: 200, totalPRs: 300, totalPRsMerged: 0, mergedPRsPercentage: 0, totalReviews: 50, totalStars: 200, totalDiscussionsStarted: 0, totalDiscussionsAnswered: 0, rank, }); }); it("should fetch two pages of stars if 'FETCH_MULTI_PAGE_STARS' env variable is set to `true`", async () => { process.env.FETCH_MULTI_PAGE_STARS = true; let stats = await fetchStats("anuraghazra"); const rank = calculateRank({ all_commits: false, commits: 100, prs: 300, reviews: 50, issues: 200, repos: 5, stars: 400, followers: 100, }); expect(stats).toStrictEqual({ contributedTo: 61, name: "Anurag Hazra", totalCommits: 100, totalIssues: 200, totalPRs: 300, totalPRsMerged: 0, mergedPRsPercentage: 0, totalReviews: 50, totalStars: 400, totalDiscussionsStarted: 0, totalDiscussionsAnswered: 0, rank, }); }); it("should fetch one page of stars if 'FETCH_MULTI_PAGE_STARS' env variable is set to `false`", async () => { process.env.FETCH_MULTI_PAGE_STARS = "false"; let stats = await fetchStats("anuraghazra"); const rank = calculateRank({ all_commits: false, commits: 100, prs: 300, reviews: 50, issues: 200, repos: 5, stars: 300, followers: 100, }); expect(stats).toStrictEqual({ contributedTo: 61, name: "Anurag Hazra", totalCommits: 100, totalIssues: 200, totalPRs: 300, totalPRsMerged: 0, mergedPRsPercentage: 0, totalReviews: 50, totalStars: 300, totalDiscussionsStarted: 0, totalDiscussionsAnswered: 0, rank, }); }); it("should fetch one page of stars if 'FETCH_MULTI_PAGE_STARS' env variable is not set", async () => { process.env.FETCH_MULTI_PAGE_STARS = undefined; let stats = await fetchStats("anuraghazra"); const rank = calculateRank({ all_commits: false, commits: 100, prs: 300, reviews: 50, issues: 200, repos: 5, stars: 300, followers: 100, }); expect(stats).toStrictEqual({ contributedTo: 61, name: "Anurag Hazra", totalCommits: 100, totalIssues: 200, totalPRs: 300, totalPRsMerged: 0, mergedPRsPercentage: 0, totalReviews: 50, totalStars: 300, totalDiscussionsStarted: 0, totalDiscussionsAnswered: 0, rank, }); }); it("should not fetch additional stats data when it not requested", async () => { let stats = await fetchStats("anuraghazra"); const rank = calculateRank({ all_commits: false, commits: 100, prs: 300, reviews: 50, issues: 200, repos: 5, stars: 300, followers: 100, }); expect(stats).toStrictEqual({ contributedTo: 61, name: "Anurag Hazra", totalCommits: 100, totalIssues: 200, totalPRs: 300, totalPRsMerged: 0, mergedPRsPercentage: 0, totalReviews: 50, totalStars: 300, totalDiscussionsStarted: 0, totalDiscussionsAnswered: 0, rank, }); }); it("should fetch additional stats when it requested", async () => { let stats = await fetchStats("anuraghazra", false, [], true, true, true); const rank = calculateRank({ all_commits: false, commits: 100, prs: 300, reviews: 50, issues: 200, repos: 5, stars: 300, followers: 100, }); expect(stats).toStrictEqual({ contributedTo: 61, name: "Anurag Hazra", totalCommits: 100, totalIssues: 200, totalPRs: 300, totalPRsMerged: 240, mergedPRsPercentage: 80, totalReviews: 50, totalStars: 300, totalDiscussionsStarted: 10, totalDiscussionsAnswered: 40, rank, }); }); it("should get commits of provided year", async () => { let stats = await fetchStats( "anuraghazra", false, [], false, false, false, 2003, ); const rank = calculateRank({ all_commits: false, commits: 428, prs: 300, reviews: 50, issues: 200, repos: 5, stars: 300, followers: 100, }); expect(stats).toStrictEqual({ contributedTo: 61, name: "Anurag Hazra", totalCommits: 428, totalIssues: 200, totalPRs: 300, totalPRsMerged: 0, mergedPRsPercentage: 0, totalReviews: 50, totalStars: 300, totalDiscussionsStarted: 0, totalDiscussionsAnswered: 0, rank, }); }); it("should return correct data when user don't have any pull requests", async () => { mock.reset(); mock .onPost("https://api.github.com/graphql") .reply(200, data_without_pull_requests); const stats = await fetchStats("anuraghazra", false, [], true); const rank = calculateRank({ all_commits: false, commits: 100, prs: 0, reviews: 50, issues: 200, repos: 5, stars: 300, followers: 100, }); expect(stats).toStrictEqual({ contributedTo: 61, name: "Anurag Hazra", totalCommits: 100, totalIssues: 200, totalPRs: 0, totalPRsMerged: 0, mergedPRsPercentage: 0, totalReviews: 50, totalStars: 300, totalDiscussionsStarted: 0, totalDiscussionsAnswered: 0, rank, }); }); }); ================================================ FILE: tests/fetchTopLanguages.test.js ================================================ import { afterEach, describe, expect, it } from "@jest/globals"; import "@testing-library/jest-dom"; import axios from "axios"; import MockAdapter from "axios-mock-adapter"; import { fetchTopLanguages } from "../src/fetchers/top-languages.js"; const mock = new MockAdapter(axios); afterEach(() => { mock.reset(); }); const data_langs = { data: { user: { repositories: { nodes: [ { name: "test-repo-1", languages: { edges: [{ size: 100, node: { color: "#0f0", name: "HTML" } }], }, }, { name: "test-repo-2", languages: { edges: [{ size: 100, node: { color: "#0f0", name: "HTML" } }], }, }, { name: "test-repo-3", languages: { edges: [ { size: 100, node: { color: "#0ff", name: "javascript" } }, ], }, }, { name: "test-repo-4", languages: { edges: [ { size: 100, node: { color: "#0ff", name: "javascript" } }, ], }, }, ], }, }, }, }; const error = { errors: [ { type: "NOT_FOUND", path: ["user"], locations: [], message: "Could not resolve to a User with the login of 'noname'.", }, ], }; describe("FetchTopLanguages", () => { it("should fetch correct language data while using the new calculation", async () => { mock.onPost("https://api.github.com/graphql").reply(200, data_langs); let repo = await fetchTopLanguages("anuraghazra", [], 0.5, 0.5); expect(repo).toStrictEqual({ HTML: { color: "#0f0", count: 2, name: "HTML", size: 20.000000000000004, }, javascript: { color: "#0ff", count: 2, name: "javascript", size: 20.000000000000004, }, }); }); it("should fetch correct language data while excluding the 'test-repo-1' repository", async () => { mock.onPost("https://api.github.com/graphql").reply(200, data_langs); let repo = await fetchTopLanguages("anuraghazra", ["test-repo-1"]); expect(repo).toStrictEqual({ HTML: { color: "#0f0", count: 1, name: "HTML", size: 100, }, javascript: { color: "#0ff", count: 2, name: "javascript", size: 200, }, }); }); it("should fetch correct language data while using the old calculation", async () => { mock.onPost("https://api.github.com/graphql").reply(200, data_langs); let repo = await fetchTopLanguages("anuraghazra", [], 1, 0); expect(repo).toStrictEqual({ HTML: { color: "#0f0", count: 2, name: "HTML", size: 200, }, javascript: { color: "#0ff", count: 2, name: "javascript", size: 200, }, }); }); it("should rank languages by the number of repositories they appear in", async () => { mock.onPost("https://api.github.com/graphql").reply(200, data_langs); let repo = await fetchTopLanguages("anuraghazra", [], 0, 1); expect(repo).toStrictEqual({ HTML: { color: "#0f0", count: 2, name: "HTML", size: 2, }, javascript: { color: "#0ff", count: 2, name: "javascript", size: 2, }, }); }); it("should throw specific error when user not found", async () => { mock.onPost("https://api.github.com/graphql").reply(200, error); await expect(fetchTopLanguages("anuraghazra")).rejects.toThrow( "Could not resolve to a User with the login of 'noname'.", ); }); it("should throw other errors with their message", async () => { mock.onPost("https://api.github.com/graphql").reply(200, { errors: [{ message: "Some test GraphQL error" }], }); await expect(fetchTopLanguages("anuraghazra")).rejects.toThrow( "Some test GraphQL error", ); }); it("should throw error with specific message when error does not contain message property", async () => { mock.onPost("https://api.github.com/graphql").reply(200, { errors: [{ type: "TEST" }], }); await expect(fetchTopLanguages("anuraghazra")).rejects.toThrow( "Something went wrong while trying to retrieve the language data using the GraphQL API.", ); }); }); ================================================ FILE: tests/fetchWakatime.test.js ================================================ import { afterEach, describe, expect, it } from "@jest/globals"; import "@testing-library/jest-dom"; import axios from "axios"; import MockAdapter from "axios-mock-adapter"; import { fetchWakatimeStats } from "../src/fetchers/wakatime.js"; const mock = new MockAdapter(axios); afterEach(() => { mock.reset(); }); const wakaTimeData = { data: { categories: [ { digital: "22:40", hours: 22, minutes: 40, name: "Coding", percent: 100, text: "22 hrs 40 mins", total_seconds: 81643.570077, }, ], daily_average: 16095, daily_average_including_other_language: 16329, days_including_holidays: 7, days_minus_holidays: 5, editors: [ { digital: "22:40", hours: 22, minutes: 40, name: "VS Code", percent: 100, text: "22 hrs 40 mins", total_seconds: 81643.570077, }, ], holidays: 2, human_readable_daily_average: "4 hrs 28 mins", human_readable_daily_average_including_other_language: "4 hrs 32 mins", human_readable_total: "22 hrs 21 mins", human_readable_total_including_other_language: "22 hrs 40 mins", id: "random hash", is_already_updating: false, is_coding_activity_visible: true, is_including_today: false, is_other_usage_visible: true, is_stuck: false, is_up_to_date: true, languages: [ { digital: "0:19", hours: 0, minutes: 19, name: "Other", percent: 1.43, text: "19 mins", total_seconds: 1170.434361, }, { digital: "0:01", hours: 0, minutes: 1, name: "TypeScript", percent: 0.1, text: "1 min", total_seconds: 83.293809, }, { digital: "0:00", hours: 0, minutes: 0, name: "YAML", percent: 0.07, text: "0 secs", total_seconds: 54.975151, }, ], operating_systems: [ { digital: "22:40", hours: 22, minutes: 40, name: "Mac", percent: 100, text: "22 hrs 40 mins", total_seconds: 81643.570077, }, ], percent_calculated: 100, range: "last_7_days", status: "ok", timeout: 15, total_seconds: 80473.135716, total_seconds_including_other_language: 81643.570077, user_id: "random hash", username: "anuraghazra", writes_only: false, }, }; describe("WakaTime fetcher", () => { it("should fetch correct WakaTime data", async () => { const username = "anuraghazra"; mock .onGet( `https://wakatime.com/api/v1/users/${username}/stats?is_including_today=true`, ) .reply(200, wakaTimeData); const repo = await fetchWakatimeStats({ username }); expect(repo).toStrictEqual(wakaTimeData.data); }); it("should throw error if username param missing", async () => { mock.onGet(/\/https:\/\/wakatime\.com\/api/).reply(404, wakaTimeData); await expect(fetchWakatimeStats("noone")).rejects.toThrow( 'Missing params "username" make sure you pass the parameters in URL', ); }); it("should throw error if username is not found", async () => { mock.onGet(/\/https:\/\/wakatime\.com\/api/).reply(404, wakaTimeData); await expect(fetchWakatimeStats({ username: "noone" })).rejects.toThrow( "Could not resolve to a User with the login of 'noone'", ); }); }); export { wakaTimeData }; ================================================ FILE: tests/flexLayout.test.js ================================================ import { describe, expect, it } from "@jest/globals"; import { flexLayout } from "../src/common/render.js"; describe("flexLayout", () => { it("should work with row & col layouts", () => { const layout = flexLayout({ items: ["1", "2"], gap: 60, }); expect(layout).toStrictEqual([ `1`, `2`, ]); const columns = flexLayout({ items: ["1", "2"], gap: 60, direction: "column", }); expect(columns).toStrictEqual([ `1`, `2`, ]); }); it("should work with sizes", () => { const layout = flexLayout({ items: [ "1", "2", "3", "4", ], gap: 20, sizes: [200, 100, 55, 25], }); expect(layout).toStrictEqual([ `1`, `2`, `3`, `4`, ]); }); }); ================================================ FILE: tests/fmt.test.js ================================================ import { describe, expect, it } from "@jest/globals"; import { formatBytes, kFormatter, wrapTextMultiline, } from "../src/common/fmt.js"; describe("Test fmt.js", () => { it("kFormatter: should format numbers correctly by default", () => { expect(kFormatter(1)).toBe(1); expect(kFormatter(-1)).toBe(-1); expect(kFormatter(500)).toBe(500); expect(kFormatter(1000)).toBe("1k"); expect(kFormatter(1200)).toBe("1.2k"); expect(kFormatter(10000)).toBe("10k"); expect(kFormatter(12345)).toBe("12.3k"); expect(kFormatter(99900)).toBe("99.9k"); expect(kFormatter(9900000)).toBe("9900k"); }); it("kFormatter: should format numbers correctly with 0 decimal precision", () => { expect(kFormatter(1, 0)).toBe("0k"); expect(kFormatter(-1, 0)).toBe("-0k"); expect(kFormatter(500, 0)).toBe("1k"); expect(kFormatter(1000, 0)).toBe("1k"); expect(kFormatter(1200, 0)).toBe("1k"); expect(kFormatter(10000, 0)).toBe("10k"); expect(kFormatter(12345, 0)).toBe("12k"); expect(kFormatter(99000, 0)).toBe("99k"); expect(kFormatter(99900, 0)).toBe("100k"); expect(kFormatter(9900000, 0)).toBe("9900k"); }); it("kFormatter: should format numbers correctly with 1 decimal precision", () => { expect(kFormatter(1, 1)).toBe("0.0k"); expect(kFormatter(-1, 1)).toBe("-0.0k"); expect(kFormatter(500, 1)).toBe("0.5k"); expect(kFormatter(1000, 1)).toBe("1.0k"); expect(kFormatter(1200, 1)).toBe("1.2k"); expect(kFormatter(10000, 1)).toBe("10.0k"); expect(kFormatter(12345, 1)).toBe("12.3k"); expect(kFormatter(99900, 1)).toBe("99.9k"); expect(kFormatter(9900000, 1)).toBe("9900.0k"); }); it("kFormatter: should format numbers correctly with 2 decimal precision", () => { expect(kFormatter(1, 2)).toBe("0.00k"); expect(kFormatter(-1, 2)).toBe("-0.00k"); expect(kFormatter(500, 2)).toBe("0.50k"); expect(kFormatter(1000, 2)).toBe("1.00k"); expect(kFormatter(1200, 2)).toBe("1.20k"); expect(kFormatter(10000, 2)).toBe("10.00k"); expect(kFormatter(12345, 2)).toBe("12.35k"); expect(kFormatter(99900, 2)).toBe("99.90k"); expect(kFormatter(9900000, 2)).toBe("9900.00k"); }); it("formatBytes: should return expected values", () => { expect(formatBytes(0)).toBe("0 B"); expect(formatBytes(100)).toBe("100.0 B"); expect(formatBytes(1024)).toBe("1.0 KB"); expect(formatBytes(1024 * 1024)).toBe("1.0 MB"); expect(formatBytes(1024 * 1024 * 1024)).toBe("1.0 GB"); expect(formatBytes(1024 * 1024 * 1024 * 1024)).toBe("1.0 TB"); expect(formatBytes(1024 * 1024 * 1024 * 1024 * 1024)).toBe("1.0 PB"); expect(formatBytes(1024 * 1024 * 1024 * 1024 * 1024 * 1024)).toBe("1.0 EB"); expect(formatBytes(1234 * 1024)).toBe("1.2 MB"); expect(formatBytes(123.4 * 1024)).toBe("123.4 KB"); }); it("wrapTextMultiline: should not wrap small texts", () => { { let multiLineText = wrapTextMultiline("Small text should not wrap"); expect(multiLineText).toEqual(["Small text should not wrap"]); } }); it("wrapTextMultiline: should wrap large texts", () => { let multiLineText = wrapTextMultiline( "Hello world long long long text", 20, 3, ); expect(multiLineText).toEqual(["Hello world long", "long long text"]); }); it("wrapTextMultiline: should wrap large texts and limit max lines", () => { let multiLineText = wrapTextMultiline( "Hello world long long long text", 10, 2, ); expect(multiLineText).toEqual(["Hello", "world long..."]); }); it("wrapTextMultiline: should wrap chinese by punctuation", () => { let multiLineText = wrapTextMultiline( "专门为刚开始刷题的同学准备的算法基地,没有最细只有更细,立志用动画将晦涩难懂的算法说的通俗易懂!", ); expect(multiLineText.length).toEqual(3); expect(multiLineText[0].length).toEqual(18 * 8); // &#xxxxx; x 8 }); }); ================================================ FILE: tests/gist.test.js ================================================ // @ts-check import { afterEach, describe, expect, it, jest } from "@jest/globals"; import "@testing-library/jest-dom"; import axios from "axios"; import MockAdapter from "axios-mock-adapter"; import gist from "../api/gist.js"; import { renderGistCard } from "../src/cards/gist.js"; import { renderError } from "../src/common/render.js"; import { CACHE_TTL, DURATIONS } from "../src/common/cache.js"; const gist_data = { data: { viewer: { gist: { description: "List of countries and territories in English and Spanish: name, continent, capital, dial code, country codes, TLD, and area in sq km. Lista de países y territorios en Inglés y Español: nombre, continente, capital, código de teléfono, códigos de país, dominio y área en km cuadrados. Updated 2023", owner: { login: "Yizack", }, stargazerCount: 33, forks: { totalCount: 11, }, files: [ { name: "countries.json", language: { name: "JSON", }, size: 85858, }, ], }, }, }, }; const gist_not_found_data = { data: { viewer: { gist: null, }, }, }; const mock = new MockAdapter(axios); afterEach(() => { mock.reset(); }); describe("Test /api/gist", () => { it("should test the request", async () => { const req = { query: { id: "bbfce31e0217a3689c8d961a356cb10d", }, }; const res = { setHeader: jest.fn(), send: jest.fn(), }; mock.onPost("https://api.github.com/graphql").reply(200, gist_data); await gist(req, res); expect(res.setHeader).toHaveBeenCalledWith("Content-Type", "image/svg+xml"); expect(res.send).toHaveBeenCalledWith( renderGistCard({ name: gist_data.data.viewer.gist.files[0].name, nameWithOwner: `${gist_data.data.viewer.gist.owner.login}/${gist_data.data.viewer.gist.files[0].name}`, description: gist_data.data.viewer.gist.description, language: gist_data.data.viewer.gist.files[0].language.name, starsCount: gist_data.data.viewer.gist.stargazerCount, forksCount: gist_data.data.viewer.gist.forks.totalCount, }), ); }); it("should get the query options", async () => { const req = { query: { id: "bbfce31e0217a3689c8d961a356cb10d", title_color: "fff", icon_color: "fff", text_color: "fff", bg_color: "fff", show_owner: true, }, }; const res = { setHeader: jest.fn(), send: jest.fn(), }; mock.onPost("https://api.github.com/graphql").reply(200, gist_data); await gist(req, res); expect(res.setHeader).toHaveBeenCalledWith("Content-Type", "image/svg+xml"); expect(res.send).toHaveBeenCalledWith( renderGistCard( { name: gist_data.data.viewer.gist.files[0].name, nameWithOwner: `${gist_data.data.viewer.gist.owner.login}/${gist_data.data.viewer.gist.files[0].name}`, description: gist_data.data.viewer.gist.description, language: gist_data.data.viewer.gist.files[0].language.name, starsCount: gist_data.data.viewer.gist.stargazerCount, forksCount: gist_data.data.viewer.gist.forks.totalCount, }, { ...req.query }, ), ); }); it("should render error if id is not provided", async () => { const req = { query: {}, }; const res = { setHeader: jest.fn(), send: jest.fn(), }; await gist(req, res); expect(res.setHeader).toHaveBeenCalledWith("Content-Type", "image/svg+xml"); expect(res.send).toHaveBeenCalledWith( renderError({ message: 'Missing params "id" make sure you pass the parameters in URL', secondaryMessage: "/api/gist?id=GIST_ID", renderOptions: { show_repo_link: false }, }), ); }); it("should render error if gist is not found", async () => { const req = { query: { id: "bbfce31e0217a3689c8d961a356cb10d", }, }; const res = { setHeader: jest.fn(), send: jest.fn(), }; mock .onPost("https://api.github.com/graphql") .reply(200, gist_not_found_data); await gist(req, res); expect(res.setHeader).toHaveBeenCalledWith("Content-Type", "image/svg+xml"); expect(res.send).toHaveBeenCalledWith( renderError({ message: "Gist not found" }), ); }); it("should render error if wrong locale is provided", async () => { const req = { query: { id: "bbfce31e0217a3689c8d961a356cb10d", locale: "asdf", }, }; const res = { setHeader: jest.fn(), send: jest.fn(), }; await gist(req, res); expect(res.setHeader).toHaveBeenCalledWith("Content-Type", "image/svg+xml"); expect(res.send).toHaveBeenCalledWith( renderError({ message: "Something went wrong", secondaryMessage: "Language not found", }), ); }); it("should have proper cache", async () => { const req = { query: { id: "bbfce31e0217a3689c8d961a356cb10d", }, }; const res = { setHeader: jest.fn(), send: jest.fn(), }; mock.onPost("https://api.github.com/graphql").reply(200, gist_data); await gist(req, res); expect(res.setHeader).toHaveBeenCalledWith("Content-Type", "image/svg+xml"); expect(res.setHeader).toHaveBeenCalledWith( "Cache-Control", `max-age=${CACHE_TTL.GIST_CARD.DEFAULT}, ` + `s-maxage=${CACHE_TTL.GIST_CARD.DEFAULT}, ` + `stale-while-revalidate=${DURATIONS.ONE_DAY}`, ); }); }); ================================================ FILE: tests/html.test.js ================================================ import { describe, expect, it } from "@jest/globals"; import { encodeHTML } from "../src/common/html.js"; describe("Test html.js", () => { it("should test encodeHTML", () => { expect(encodeHTML(`hello world<,.#4^&^@%!))`)).toBe( "<html>hello world<,.#4^&^@%!))", ); }); }); ================================================ FILE: tests/i18n.test.js ================================================ import { describe, expect, it } from "@jest/globals"; import { I18n } from "../src/common/I18n.js"; import { statCardLocales } from "../src/translations.js"; describe("I18n", () => { it("should return translated string", () => { const i18n = new I18n({ locale: "en", translations: statCardLocales({ name: "Anurag Hazra", apostrophe: "s" }), }); expect(i18n.t("statcard.title")).toBe("Anurag Hazra's GitHub Stats"); }); it("should throw error if translation string not found", () => { const i18n = new I18n({ locale: "en", translations: statCardLocales({ name: "Anurag Hazra", apostrophe: "s" }), }); expect(() => i18n.t("statcard.title1")).toThrow( "statcard.title1 Translation string not found", ); }); it("should throw error if translation not found for locale", () => { const i18n = new I18n({ locale: "asdf", translations: statCardLocales({ name: "Anurag Hazra", apostrophe: "s" }), }); expect(() => i18n.t("statcard.title")).toThrow( "'statcard.title' translation not found for locale 'asdf'", ); }); }); ================================================ FILE: tests/ops.test.js ================================================ import { describe, expect, it } from "@jest/globals"; import { parseBoolean, parseArray, clampValue, lowercaseTrim, chunkArray, parseEmojis, dateDiff, } from "../src/common/ops.js"; describe("Test ops.js", () => { it("should test parseBoolean", () => { expect(parseBoolean(true)).toBe(true); expect(parseBoolean(false)).toBe(false); expect(parseBoolean("true")).toBe(true); expect(parseBoolean("false")).toBe(false); expect(parseBoolean("True")).toBe(true); expect(parseBoolean("False")).toBe(false); expect(parseBoolean("TRUE")).toBe(true); expect(parseBoolean("FALSE")).toBe(false); expect(parseBoolean("1")).toBe(undefined); expect(parseBoolean("0")).toBe(undefined); expect(parseBoolean("")).toBe(undefined); // @ts-ignore expect(parseBoolean(undefined)).toBe(undefined); }); it("should test parseArray", () => { expect(parseArray("a,b,c")).toEqual(["a", "b", "c"]); expect(parseArray("a, b, c")).toEqual(["a", " b", " c"]); // preserves spaces expect(parseArray("")).toEqual([]); // @ts-ignore expect(parseArray(undefined)).toEqual([]); }); it("should test clampValue", () => { expect(clampValue(5, 1, 10)).toBe(5); expect(clampValue(0, 1, 10)).toBe(1); expect(clampValue(15, 1, 10)).toBe(10); // string inputs are coerced numerically by Math.min/Math.max // @ts-ignore expect(clampValue("7", 1, 10)).toBe(7); // non-numeric and NaN fall back to min // @ts-ignore expect(clampValue("abc", 1, 10)).toBe(1); expect(clampValue(NaN, 2, 5)).toBe(2); }); it("should test lowercaseTrim", () => { expect(lowercaseTrim(" Hello World ")).toBe("hello world"); expect(lowercaseTrim("already lower")).toBe("already lower"); }); it("should test chunkArray", () => { expect(chunkArray([1, 2, 3, 4, 5], 2)).toEqual([[1, 2], [3, 4], [5]]); expect(chunkArray([1, 2, 3, 4, 5], 1)).toEqual([[1], [2], [3], [4], [5]]); expect(chunkArray([1, 2, 3, 4, 5], 10)).toEqual([[1, 2, 3, 4, 5]]); }); it("should test parseEmojis", () => { // unknown emoji name is stripped expect(parseEmojis("Hello :nonexistent:")).toBe("Hello "); // common emoji names should be replaced (at least token removed) const out = parseEmojis("I :heart: OSS"); expect(out).not.toContain(":heart:"); expect(out.startsWith("I ")).toBe(true); expect(out.endsWith(" OSS")).toBe(true); expect(() => parseEmojis("")).toThrow(/parseEmoji/); // @ts-ignore expect(() => parseEmojis()).toThrow(/parseEmoji/); }); it("should test dateDiff", () => { const a = new Date("2020-01-01T00:10:00Z"); const b = new Date("2020-01-01T00:00:00Z"); expect(dateDiff(a, b)).toBe(10); const c = new Date("2020-01-01T00:00:00Z"); const d = new Date("2020-01-01T00:10:30Z"); // rounds to nearest minute expect(dateDiff(c, d)).toBe(-10); }); }); ================================================ FILE: tests/pat-info.test.js ================================================ /** * @file Tests for the status/pat-info cloud function. */ import dotenv from "dotenv"; dotenv.config(); import { afterEach, beforeAll, describe, expect, it, jest, } from "@jest/globals"; import axios from "axios"; import MockAdapter from "axios-mock-adapter"; import patInfo, { RATE_LIMIT_SECONDS } from "../api/status/pat-info.js"; const mock = new MockAdapter(axios); const successData = { data: { rateLimit: { remaining: 4986, }, }, }; const faker = (query) => { const req = { query: { ...query }, }; const res = { setHeader: jest.fn(), send: jest.fn(), }; return { req, res }; }; const rate_limit_error = { errors: [ { type: "RATE_LIMITED", message: "API rate limit exceeded for user ID.", }, ], data: { rateLimit: { resetAt: Date.now(), }, }, }; const other_error = { errors: [ { type: "SOME_ERROR", message: "This is a error", }, ], }; const bad_credentials_error = { message: "Bad credentials", }; afterEach(() => { mock.reset(); }); describe("Test /api/status/pat-info", () => { beforeAll(() => { // reset patenv first so that dotenv doesn't populate them with local envs process.env = {}; process.env.PAT_1 = "testPAT1"; process.env.PAT_2 = "testPAT2"; process.env.PAT_3 = "testPAT3"; process.env.PAT_4 = "testPAT4"; }); it("should return only 'validPATs' if all PATs are valid", async () => { mock .onPost("https://api.github.com/graphql") .replyOnce(200, rate_limit_error) .onPost("https://api.github.com/graphql") .reply(200, successData); const { req, res } = faker({}, {}); await patInfo(req, res); expect(res.setHeader).toHaveBeenCalledWith( "Content-Type", "application/json", ); expect(res.send).toHaveBeenCalledWith( JSON.stringify( { validPATs: ["PAT_2", "PAT_3", "PAT_4"], expiredPATs: [], exhaustedPATs: ["PAT_1"], suspendedPATs: [], errorPATs: [], details: { PAT_1: { status: "exhausted", remaining: 0, resetIn: "0 minutes", }, PAT_2: { status: "valid", remaining: 4986, }, PAT_3: { status: "valid", remaining: 4986, }, PAT_4: { status: "valid", remaining: 4986, }, }, }, null, 2, ), ); }); it("should return `errorPATs` if a PAT causes an error to be thrown", async () => { mock .onPost("https://api.github.com/graphql") .replyOnce(200, other_error) .onPost("https://api.github.com/graphql") .reply(200, successData); const { req, res } = faker({}, {}); await patInfo(req, res); expect(res.setHeader).toHaveBeenCalledWith( "Content-Type", "application/json", ); expect(res.send).toHaveBeenCalledWith( JSON.stringify( { validPATs: ["PAT_2", "PAT_3", "PAT_4"], expiredPATs: [], exhaustedPATs: [], suspendedPATs: [], errorPATs: ["PAT_1"], details: { PAT_1: { status: "error", error: { type: "SOME_ERROR", message: "This is a error", }, }, PAT_2: { status: "valid", remaining: 4986, }, PAT_3: { status: "valid", remaining: 4986, }, PAT_4: { status: "valid", remaining: 4986, }, }, }, null, 2, ), ); }); it("should return `expiredPaths` if a PAT returns a 'Bad credentials' error", async () => { mock .onPost("https://api.github.com/graphql") .replyOnce(404, bad_credentials_error) .onPost("https://api.github.com/graphql") .reply(200, successData); const { req, res } = faker({}, {}); await patInfo(req, res); expect(res.setHeader).toHaveBeenCalledWith( "Content-Type", "application/json", ); expect(res.send).toHaveBeenCalledWith( JSON.stringify( { validPATs: ["PAT_2", "PAT_3", "PAT_4"], expiredPATs: ["PAT_1"], exhaustedPATs: [], suspendedPATs: [], errorPATs: [], details: { PAT_1: { status: "expired", }, PAT_2: { status: "valid", remaining: 4986, }, PAT_3: { status: "valid", remaining: 4986, }, PAT_4: { status: "valid", remaining: 4986, }, }, }, null, 2, ), ); }); it("should throw an error if something goes wrong", async () => { mock.onPost("https://api.github.com/graphql").networkError(); const { req, res } = faker({}, {}); await patInfo(req, res); expect(res.setHeader).toHaveBeenCalledWith( "Content-Type", "application/json", ); expect(res.send).toHaveBeenCalledWith( "Something went wrong: Network Error", ); }); it("should have proper cache when no error is thrown", async () => { mock.onPost("https://api.github.com/graphql").reply(200, successData); const { req, res } = faker({}, {}); await patInfo(req, res); expect(res.setHeader.mock.calls).toEqual([ ["Content-Type", "application/json"], ["Cache-Control", `max-age=0, s-maxage=${RATE_LIMIT_SECONDS}`], ]); }); it("should have proper cache when error is thrown", async () => { mock.reset(); mock.onPost("https://api.github.com/graphql").networkError(); const { req, res } = faker({}, {}); await patInfo(req, res); expect(res.setHeader.mock.calls).toEqual([ ["Content-Type", "application/json"], ["Cache-Control", "no-store"], ]); }); }); ================================================ FILE: tests/pin.test.js ================================================ // @ts-check import { afterEach, describe, expect, it, jest } from "@jest/globals"; import "@testing-library/jest-dom"; import axios from "axios"; import MockAdapter from "axios-mock-adapter"; import pin from "../api/pin.js"; import { renderRepoCard } from "../src/cards/repo.js"; import { renderError } from "../src/common/render.js"; import { CACHE_TTL, DURATIONS } from "../src/common/cache.js"; const data_repo = { repository: { username: "anuraghazra", name: "convoychat", stargazers: { totalCount: 38000, }, description: "Help us take over the world! React + TS + GraphQL Chat App", primaryLanguage: { color: "#2b7489", id: "MDg6TGFuZ3VhZ2UyODc=", name: "TypeScript", }, forkCount: 100, isTemplate: false, }, }; const data_user = { data: { user: { repository: data_repo.repository }, organization: null, }, }; const mock = new MockAdapter(axios); afterEach(() => { mock.reset(); }); describe("Test /api/pin", () => { it("should test the request", async () => { const req = { query: { username: "anuraghazra", repo: "convoychat", }, }; const res = { setHeader: jest.fn(), send: jest.fn(), }; mock.onPost("https://api.github.com/graphql").reply(200, data_user); await pin(req, res); expect(res.setHeader).toHaveBeenCalledWith("Content-Type", "image/svg+xml"); expect(res.send).toHaveBeenCalledWith( // @ts-ignore renderRepoCard({ ...data_repo.repository, starCount: data_repo.repository.stargazers.totalCount, }), ); }); it("should get the query options", async () => { const req = { query: { username: "anuraghazra", repo: "convoychat", title_color: "fff", icon_color: "fff", text_color: "fff", bg_color: "fff", full_name: "1", }, }; const res = { setHeader: jest.fn(), send: jest.fn(), }; mock.onPost("https://api.github.com/graphql").reply(200, data_user); await pin(req, res); expect(res.setHeader).toHaveBeenCalledWith("Content-Type", "image/svg+xml"); expect(res.send).toHaveBeenCalledWith( renderRepoCard( // @ts-ignore { ...data_repo.repository, starCount: data_repo.repository.stargazers.totalCount, }, { ...req.query }, ), ); }); it("should render error card if user repo not found", async () => { const req = { query: { username: "anuraghazra", repo: "convoychat", }, }; const res = { setHeader: jest.fn(), send: jest.fn(), }; mock .onPost("https://api.github.com/graphql") .reply(200, { data: { user: { repository: null }, organization: null } }); await pin(req, res); expect(res.setHeader).toHaveBeenCalledWith("Content-Type", "image/svg+xml"); expect(res.send).toHaveBeenCalledWith( renderError({ message: "User Repository Not found" }), ); }); it("should render error card if org repo not found", async () => { const req = { query: { username: "anuraghazra", repo: "convoychat", }, }; const res = { setHeader: jest.fn(), send: jest.fn(), }; mock .onPost("https://api.github.com/graphql") .reply(200, { data: { user: null, organization: { repository: null } } }); await pin(req, res); expect(res.setHeader).toHaveBeenCalledWith("Content-Type", "image/svg+xml"); expect(res.send).toHaveBeenCalledWith( renderError({ message: "Organization Repository Not found" }), ); }); it("should render error card if username in blacklist", async () => { const req = { query: { username: "renovate-bot", repo: "convoychat", }, }; const res = { setHeader: jest.fn(), send: jest.fn(), }; mock.onPost("https://api.github.com/graphql").reply(200, data_user); await pin(req, res); expect(res.setHeader).toHaveBeenCalledWith("Content-Type", "image/svg+xml"); expect(res.send).toHaveBeenCalledWith( renderError({ message: "This username is blacklisted", secondaryMessage: "Please deploy your own instance", renderOptions: { show_repo_link: false }, }), ); }); it("should render error card if wrong locale provided", async () => { const req = { query: { username: "anuraghazra", repo: "convoychat", locale: "asdf", }, }; const res = { setHeader: jest.fn(), send: jest.fn(), }; mock.onPost("https://api.github.com/graphql").reply(200, data_user); await pin(req, res); expect(res.setHeader).toHaveBeenCalledWith("Content-Type", "image/svg+xml"); expect(res.send).toHaveBeenCalledWith( renderError({ message: "Something went wrong", secondaryMessage: "Language not found", }), ); }); it("should render error card if missing required parameters", async () => { const req = { query: {}, }; const res = { setHeader: jest.fn(), send: jest.fn(), }; await pin(req, res); expect(res.setHeader).toHaveBeenCalledWith("Content-Type", "image/svg+xml"); expect(res.send).toHaveBeenCalledWith( renderError({ message: 'Missing params "username", "repo" make sure you pass the parameters in URL', secondaryMessage: "/api/pin?username=USERNAME&repo=REPO_NAME", renderOptions: { show_repo_link: false }, }), ); }); it("should have proper cache", async () => { const req = { query: { username: "anuraghazra", repo: "convoychat", }, }; const res = { setHeader: jest.fn(), send: jest.fn(), }; mock.onPost("https://api.github.com/graphql").reply(200, data_user); await pin(req, res); expect(res.setHeader).toHaveBeenCalledWith("Content-Type", "image/svg+xml"); expect(res.setHeader).toHaveBeenCalledWith( "Cache-Control", `max-age=${CACHE_TTL.PIN_CARD.DEFAULT}, ` + `s-maxage=${CACHE_TTL.PIN_CARD.DEFAULT}, ` + `stale-while-revalidate=${DURATIONS.ONE_DAY}`, ); }); }); ================================================ FILE: tests/render.test.js ================================================ // @ts-check import { describe, expect, it } from "@jest/globals"; import { queryByTestId } from "@testing-library/dom"; import "@testing-library/jest-dom/jest-globals"; import { renderError } from "../src/common/render.js"; describe("Test render.js", () => { it("should test renderError", () => { document.body.innerHTML = renderError({ message: "Something went wrong" }); expect( queryByTestId(document.body, "message")?.children[0], ).toHaveTextContent(/Something went wrong/gim); expect( queryByTestId(document.body, "message")?.children[1], ).toBeEmptyDOMElement(); // Secondary message document.body.innerHTML = renderError({ message: "Something went wrong", secondaryMessage: "Secondary Message", }); expect( queryByTestId(document.body, "message")?.children[1], ).toHaveTextContent(/Secondary Message/gim); }); }); ================================================ FILE: tests/renderGistCard.test.js ================================================ import { describe, expect, it } from "@jest/globals"; import { queryByTestId } from "@testing-library/dom"; import "@testing-library/jest-dom"; import { cssToObject } from "@uppercod/css-to-object"; import { renderGistCard } from "../src/cards/gist.js"; import { themes } from "../themes/index.js"; /** * @type {import("../src/fetchers/gist").GistData} */ const data = { name: "test", nameWithOwner: "anuraghazra/test", description: "Small test repository with different Python programs.", language: "Python", starsCount: 163, forksCount: 19, }; describe("test renderGistCard", () => { it("should render correctly", () => { document.body.innerHTML = renderGistCard(data); const [header] = document.getElementsByClassName("header"); expect(header).toHaveTextContent("test"); expect(header).not.toHaveTextContent("anuraghazra"); expect(document.getElementsByClassName("description")[0]).toHaveTextContent( "Small test repository with different Python programs.", ); expect(queryByTestId(document.body, "starsCount")).toHaveTextContent("163"); expect(queryByTestId(document.body, "forksCount")).toHaveTextContent("19"); expect(queryByTestId(document.body, "lang-name")).toHaveTextContent( "Python", ); expect(queryByTestId(document.body, "lang-color")).toHaveAttribute( "fill", "#3572A5", ); }); it("should display username in title if show_owner is true", () => { document.body.innerHTML = renderGistCard(data, { show_owner: true }); const [header] = document.getElementsByClassName("header"); expect(header).toHaveTextContent("anuraghazra/test"); }); it("should trim header if name is too long", () => { document.body.innerHTML = renderGistCard({ ...data, name: "some-really-long-repo-name-for-test-purposes", }); const [header] = document.getElementsByClassName("header"); expect(header).toHaveTextContent("some-really-long-repo-name-for-test..."); }); it("should trim description if description os too long", () => { document.body.innerHTML = renderGistCard({ ...data, description: "The quick brown fox jumps over the lazy dog is an English-language pangram—a sentence that contains all of the letters of the English alphabet", }); expect( document.getElementsByClassName("description")[0].children[0].textContent, ).toBe("The quick brown fox jumps over the lazy dog is an"); expect( document.getElementsByClassName("description")[0].children[1].textContent, ).toBe("English-language pangram—a sentence that contains all"); }); it("should not trim description if it is short", () => { document.body.innerHTML = renderGistCard({ ...data, description: "Small text should not trim", }); expect(document.getElementsByClassName("description")[0]).toHaveTextContent( "Small text should not trim", ); }); it("should render emojis in description", () => { document.body.innerHTML = renderGistCard({ ...data, description: "This is a test gist description with :heart: emoji.", }); expect(document.getElementsByClassName("description")[0]).toHaveTextContent( "This is a test gist description with ❤️ emoji.", ); }); it("should render custom colors properly", () => { const customColors = { title_color: "5a0", icon_color: "1b998b", text_color: "9991", bg_color: "252525", }; document.body.innerHTML = renderGistCard(data, { ...customColors, }); const styleTag = document.querySelector("style"); const stylesObject = cssToObject(styleTag.innerHTML); const headerClassStyles = stylesObject[":host"][".header "]; const descClassStyles = stylesObject[":host"][".description "]; const iconClassStyles = stylesObject[":host"][".icon "]; expect(headerClassStyles.fill.trim()).toBe(`#${customColors.title_color}`); expect(descClassStyles.fill.trim()).toBe(`#${customColors.text_color}`); expect(iconClassStyles.fill.trim()).toBe(`#${customColors.icon_color}`); expect(queryByTestId(document.body, "card-bg")).toHaveAttribute( "fill", "#252525", ); }); it("should render with all the themes", () => { Object.keys(themes).forEach((name) => { document.body.innerHTML = renderGistCard(data, { theme: name, }); const styleTag = document.querySelector("style"); const stylesObject = cssToObject(styleTag.innerHTML); const headerClassStyles = stylesObject[":host"][".header "]; const descClassStyles = stylesObject[":host"][".description "]; const iconClassStyles = stylesObject[":host"][".icon "]; expect(headerClassStyles.fill.trim()).toBe( `#${themes[name].title_color}`, ); expect(descClassStyles.fill.trim()).toBe(`#${themes[name].text_color}`); expect(iconClassStyles.fill.trim()).toBe(`#${themes[name].icon_color}`); const backgroundElement = queryByTestId(document.body, "card-bg"); const backgroundElementFill = backgroundElement.getAttribute("fill"); expect([`#${themes[name].bg_color}`, "url(#gradient)"]).toContain( backgroundElementFill, ); }); }); it("should render custom colors with themes", () => { document.body.innerHTML = renderGistCard(data, { title_color: "5a0", theme: "radical", }); const styleTag = document.querySelector("style"); const stylesObject = cssToObject(styleTag.innerHTML); const headerClassStyles = stylesObject[":host"][".header "]; const descClassStyles = stylesObject[":host"][".description "]; const iconClassStyles = stylesObject[":host"][".icon "]; expect(headerClassStyles.fill.trim()).toBe("#5a0"); expect(descClassStyles.fill.trim()).toBe(`#${themes.radical.text_color}`); expect(iconClassStyles.fill.trim()).toBe(`#${themes.radical.icon_color}`); expect(queryByTestId(document.body, "card-bg")).toHaveAttribute( "fill", `#${themes.radical.bg_color}`, ); }); it("should render custom colors with themes and fallback to default colors if invalid", () => { document.body.innerHTML = renderGistCard(data, { title_color: "invalid color", text_color: "invalid color", theme: "radical", }); const styleTag = document.querySelector("style"); const stylesObject = cssToObject(styleTag.innerHTML); const headerClassStyles = stylesObject[":host"][".header "]; const descClassStyles = stylesObject[":host"][".description "]; const iconClassStyles = stylesObject[":host"][".icon "]; expect(headerClassStyles.fill.trim()).toBe( `#${themes.default.title_color}`, ); expect(descClassStyles.fill.trim()).toBe(`#${themes.default.text_color}`); expect(iconClassStyles.fill.trim()).toBe(`#${themes.radical.icon_color}`); expect(queryByTestId(document.body, "card-bg")).toHaveAttribute( "fill", `#${themes.radical.bg_color}`, ); }); it("should not render star count or fork count if either of the are zero", () => { document.body.innerHTML = renderGistCard({ ...data, starsCount: 0, }); expect(queryByTestId(document.body, "starsCount")).toBeNull(); expect(queryByTestId(document.body, "forksCount")).toBeInTheDocument(); document.body.innerHTML = renderGistCard({ ...data, starsCount: 1, forksCount: 0, }); expect(queryByTestId(document.body, "starsCount")).toBeInTheDocument(); expect(queryByTestId(document.body, "forksCount")).toBeNull(); document.body.innerHTML = renderGistCard({ ...data, starsCount: 0, forksCount: 0, }); expect(queryByTestId(document.body, "starsCount")).toBeNull(); expect(queryByTestId(document.body, "forksCount")).toBeNull(); }); it("should render without rounding", () => { document.body.innerHTML = renderGistCard(data, { border_radius: "0", }); expect(document.querySelector("rect")).toHaveAttribute("rx", "0"); document.body.innerHTML = renderGistCard(data, {}); expect(document.querySelector("rect")).toHaveAttribute("rx", "4.5"); }); it("should fallback to default description", () => { document.body.innerHTML = renderGistCard({ ...data, description: undefined, }); expect(document.getElementsByClassName("description")[0]).toHaveTextContent( "No description provided", ); }); }); ================================================ FILE: tests/renderRepoCard.test.js ================================================ import { describe, expect, it } from "@jest/globals"; import { queryByTestId } from "@testing-library/dom"; import "@testing-library/jest-dom"; import { cssToObject } from "@uppercod/css-to-object"; import { renderRepoCard } from "../src/cards/repo.js"; import { themes } from "../themes/index.js"; const data_repo = { repository: { nameWithOwner: "anuraghazra/convoychat", name: "convoychat", description: "Help us take over the world! React + TS + GraphQL Chat App", primaryLanguage: { color: "#2b7489", id: "MDg6TGFuZ3VhZ2UyODc=", name: "TypeScript", }, starCount: 38000, forkCount: 100, }, }; describe("Test renderRepoCard", () => { it("should render correctly", () => { document.body.innerHTML = renderRepoCard(data_repo.repository); const [header] = document.getElementsByClassName("header"); expect(header).toHaveTextContent("convoychat"); expect(header).not.toHaveTextContent("anuraghazra"); expect(document.getElementsByClassName("description")[0]).toHaveTextContent( "Help us take over the world! React + TS + GraphQL Chat App", ); expect(queryByTestId(document.body, "stargazers")).toHaveTextContent("38k"); expect(queryByTestId(document.body, "forkcount")).toHaveTextContent("100"); expect(queryByTestId(document.body, "lang-name")).toHaveTextContent( "TypeScript", ); expect(queryByTestId(document.body, "lang-color")).toHaveAttribute( "fill", "#2b7489", ); }); it("should display username in title (full repo name)", () => { document.body.innerHTML = renderRepoCard(data_repo.repository, { show_owner: true, }); expect(document.getElementsByClassName("header")[0]).toHaveTextContent( "anuraghazra/convoychat", ); }); it("should trim header", () => { document.body.innerHTML = renderRepoCard({ ...data_repo.repository, name: "some-really-long-repo-name-for-test-purposes", }); expect(document.getElementsByClassName("header")[0].textContent).toBe( "some-really-long-repo-name-for-test...", ); }); it("should trim description", () => { document.body.innerHTML = renderRepoCard({ ...data_repo.repository, description: "The quick brown fox jumps over the lazy dog is an English-language pangram—a sentence that contains all of the letters of the English alphabet", }); expect( document.getElementsByClassName("description")[0].children[0].textContent, ).toBe("The quick brown fox jumps over the lazy dog is an"); expect( document.getElementsByClassName("description")[0].children[1].textContent, ).toBe("English-language pangram—a sentence that contains all"); // Should not trim document.body.innerHTML = renderRepoCard({ ...data_repo.repository, description: "Small text should not trim", }); expect(document.getElementsByClassName("description")[0]).toHaveTextContent( "Small text should not trim", ); }); it("should render emojis", () => { document.body.innerHTML = renderRepoCard({ ...data_repo.repository, description: "This is a text with a :poop: poo emoji", }); // poop emoji may not show in all editors but it's there between "a" and "poo" expect(document.getElementsByClassName("description")[0]).toHaveTextContent( "This is a text with a 💩 poo emoji", ); }); it("should hide language if primaryLanguage is null & fallback to correct values", () => { document.body.innerHTML = renderRepoCard({ ...data_repo.repository, primaryLanguage: null, }); expect(queryByTestId(document.body, "primary-lang")).toBeNull(); document.body.innerHTML = renderRepoCard({ ...data_repo.repository, primaryLanguage: { color: null, name: null }, }); expect(queryByTestId(document.body, "primary-lang")).toBeInTheDocument(); expect(queryByTestId(document.body, "lang-color")).toHaveAttribute( "fill", "#333", ); expect(queryByTestId(document.body, "lang-name")).toHaveTextContent( "Unspecified", ); }); it("should render default colors properly", () => { document.body.innerHTML = renderRepoCard(data_repo.repository); const styleTag = document.querySelector("style"); const stylesObject = cssToObject(styleTag.innerHTML); const headerClassStyles = stylesObject[":host"][".header "]; const descClassStyles = stylesObject[":host"][".description "]; const iconClassStyles = stylesObject[":host"][".icon "]; expect(headerClassStyles.fill.trim()).toBe("#2f80ed"); expect(descClassStyles.fill.trim()).toBe("#434d58"); expect(iconClassStyles.fill.trim()).toBe("#586069"); expect(queryByTestId(document.body, "card-bg")).toHaveAttribute( "fill", "#fffefe", ); }); it("should render custom colors properly", () => { const customColors = { title_color: "5a0", icon_color: "1b998b", text_color: "9991", bg_color: "252525", }; document.body.innerHTML = renderRepoCard(data_repo.repository, { ...customColors, }); const styleTag = document.querySelector("style"); const stylesObject = cssToObject(styleTag.innerHTML); const headerClassStyles = stylesObject[":host"][".header "]; const descClassStyles = stylesObject[":host"][".description "]; const iconClassStyles = stylesObject[":host"][".icon "]; expect(headerClassStyles.fill.trim()).toBe(`#${customColors.title_color}`); expect(descClassStyles.fill.trim()).toBe(`#${customColors.text_color}`); expect(iconClassStyles.fill.trim()).toBe(`#${customColors.icon_color}`); expect(queryByTestId(document.body, "card-bg")).toHaveAttribute( "fill", "#252525", ); }); it("should render with all the themes", () => { Object.keys(themes).forEach((name) => { document.body.innerHTML = renderRepoCard(data_repo.repository, { theme: name, }); const styleTag = document.querySelector("style"); const stylesObject = cssToObject(styleTag.innerHTML); const headerClassStyles = stylesObject[":host"][".header "]; const descClassStyles = stylesObject[":host"][".description "]; const iconClassStyles = stylesObject[":host"][".icon "]; expect(headerClassStyles.fill.trim()).toBe( `#${themes[name].title_color}`, ); expect(descClassStyles.fill.trim()).toBe(`#${themes[name].text_color}`); expect(iconClassStyles.fill.trim()).toBe(`#${themes[name].icon_color}`); const backgroundElement = queryByTestId(document.body, "card-bg"); const backgroundElementFill = backgroundElement.getAttribute("fill"); expect([`#${themes[name].bg_color}`, "url(#gradient)"]).toContain( backgroundElementFill, ); }); }); it("should render custom colors with themes", () => { document.body.innerHTML = renderRepoCard(data_repo.repository, { title_color: "5a0", theme: "radical", }); const styleTag = document.querySelector("style"); const stylesObject = cssToObject(styleTag.innerHTML); const headerClassStyles = stylesObject[":host"][".header "]; const descClassStyles = stylesObject[":host"][".description "]; const iconClassStyles = stylesObject[":host"][".icon "]; expect(headerClassStyles.fill.trim()).toBe("#5a0"); expect(descClassStyles.fill.trim()).toBe(`#${themes.radical.text_color}`); expect(iconClassStyles.fill.trim()).toBe(`#${themes.radical.icon_color}`); expect(queryByTestId(document.body, "card-bg")).toHaveAttribute( "fill", `#${themes.radical.bg_color}`, ); }); it("should render custom colors with themes and fallback to default colors if invalid", () => { document.body.innerHTML = renderRepoCard(data_repo.repository, { title_color: "invalid color", text_color: "invalid color", theme: "radical", }); const styleTag = document.querySelector("style"); const stylesObject = cssToObject(styleTag.innerHTML); const headerClassStyles = stylesObject[":host"][".header "]; const descClassStyles = stylesObject[":host"][".description "]; const iconClassStyles = stylesObject[":host"][".icon "]; expect(headerClassStyles.fill.trim()).toBe( `#${themes.default.title_color}`, ); expect(descClassStyles.fill.trim()).toBe(`#${themes.default.text_color}`); expect(iconClassStyles.fill.trim()).toBe(`#${themes.radical.icon_color}`); expect(queryByTestId(document.body, "card-bg")).toHaveAttribute( "fill", `#${themes.radical.bg_color}`, ); }); it("should not render star count or fork count if either of the are zero", () => { document.body.innerHTML = renderRepoCard({ ...data_repo.repository, starCount: 0, }); expect(queryByTestId(document.body, "stargazers")).toBeNull(); expect(queryByTestId(document.body, "forkcount")).toBeInTheDocument(); document.body.innerHTML = renderRepoCard({ ...data_repo.repository, starCount: 1, forkCount: 0, }); expect(queryByTestId(document.body, "stargazers")).toBeInTheDocument(); expect(queryByTestId(document.body, "forkcount")).toBeNull(); document.body.innerHTML = renderRepoCard({ ...data_repo.repository, starCount: 0, forkCount: 0, }); expect(queryByTestId(document.body, "stargazers")).toBeNull(); expect(queryByTestId(document.body, "forkcount")).toBeNull(); }); it("should render badges", () => { document.body.innerHTML = renderRepoCard({ ...data_repo.repository, isArchived: true, }); expect(queryByTestId(document.body, "badge")).toHaveTextContent("Archived"); document.body.innerHTML = renderRepoCard({ ...data_repo.repository, isTemplate: true, }); expect(queryByTestId(document.body, "badge")).toHaveTextContent("Template"); }); it("should not render template", () => { document.body.innerHTML = renderRepoCard({ ...data_repo.repository, }); expect(queryByTestId(document.body, "badge")).toBeNull(); }); it("should render translated badges", () => { document.body.innerHTML = renderRepoCard( { ...data_repo.repository, isArchived: true, }, { locale: "cn", }, ); expect(queryByTestId(document.body, "badge")).toHaveTextContent("已归档"); document.body.innerHTML = renderRepoCard( { ...data_repo.repository, isTemplate: true, }, { locale: "cn", }, ); expect(queryByTestId(document.body, "badge")).toHaveTextContent("模板"); }); it("should render without rounding", () => { document.body.innerHTML = renderRepoCard(data_repo.repository, { border_radius: "0", }); expect(document.querySelector("rect")).toHaveAttribute("rx", "0"); document.body.innerHTML = renderRepoCard(data_repo.repository, {}); expect(document.querySelector("rect")).toHaveAttribute("rx", "4.5"); }); it("should fallback to default description", () => { document.body.innerHTML = renderRepoCard({ ...data_repo.repository, description: undefined, isArchived: true, }); expect(document.getElementsByClassName("description")[0]).toHaveTextContent( "No description provided", ); }); it("should have correct height with specified `description_lines_count` parameter", () => { // Testing short description document.body.innerHTML = renderRepoCard(data_repo.repository, { description_lines_count: 1, }); expect(document.querySelector("svg")).toHaveAttribute("height", "120"); document.body.innerHTML = renderRepoCard(data_repo.repository, { description_lines_count: 3, }); expect(document.querySelector("svg")).toHaveAttribute("height", "150"); // Testing long description const longDescription = "A tool that will make a lot of iPhone/iPad developers' life easier. It shares your app over-the-air in a WiFi network. Bonjour is used and no configuration is needed."; document.body.innerHTML = renderRepoCard( { ...data_repo.repository, description: longDescription }, { description_lines_count: 3, }, ); expect(document.querySelector("svg")).toHaveAttribute("height", "150"); document.body.innerHTML = renderRepoCard( { ...data_repo.repository, description: longDescription }, { description_lines_count: 1, }, ); expect(document.querySelector("svg")).toHaveAttribute("height", "120"); }); }); ================================================ FILE: tests/renderStatsCard.test.js ================================================ import { describe, expect, it } from "@jest/globals"; import { getByTestId, queryAllByTestId, queryByTestId, } from "@testing-library/dom"; import "@testing-library/jest-dom"; import { cssToObject } from "@uppercod/css-to-object"; import { renderStatsCard } from "../src/cards/stats.js"; import { CustomError } from "../src/common/error.js"; import { themes } from "../themes/index.js"; const stats = { name: "Anurag Hazra", totalStars: 100, totalCommits: 200, totalIssues: 300, totalPRs: 400, totalPRsMerged: 320, mergedPRsPercentage: 80, totalReviews: 50, totalDiscussionsStarted: 10, totalDiscussionsAnswered: 50, contributedTo: 500, rank: { level: "A+", percentile: 40 }, }; describe("Test renderStatsCard", () => { it("should render correctly", () => { document.body.innerHTML = renderStatsCard(stats); expect(document.getElementsByClassName("header")[0].textContent).toBe( "Anurag Hazra's GitHub Stats", ); expect( document.body.getElementsByTagName("svg")[0].getAttribute("height"), ).toBe("195"); expect(getByTestId(document.body, "stars").textContent).toBe("100"); expect(getByTestId(document.body, "commits").textContent).toBe("200"); expect(getByTestId(document.body, "issues").textContent).toBe("300"); expect(getByTestId(document.body, "prs").textContent).toBe("400"); expect(getByTestId(document.body, "contribs").textContent).toBe("500"); expect(queryByTestId(document.body, "card-bg")).toBeInTheDocument(); expect(queryByTestId(document.body, "rank-circle")).toBeInTheDocument(); // Default hidden stats expect(queryByTestId(document.body, "reviews")).not.toBeInTheDocument(); expect( queryByTestId(document.body, "discussions_started"), ).not.toBeInTheDocument(); expect( queryByTestId(document.body, "discussions_answered"), ).not.toBeInTheDocument(); expect(queryByTestId(document.body, "prs_merged")).not.toBeInTheDocument(); expect( queryByTestId(document.body, "prs_merged_percentage"), ).not.toBeInTheDocument(); }); it("should have proper name apostrophe", () => { document.body.innerHTML = renderStatsCard({ ...stats, name: "Anil Das" }); expect(document.getElementsByClassName("header")[0].textContent).toBe( "Anil Das' GitHub Stats", ); document.body.innerHTML = renderStatsCard({ ...stats, name: "Felix" }); expect(document.getElementsByClassName("header")[0].textContent).toBe( "Felix's GitHub Stats", ); }); it("should hide individual stats", () => { document.body.innerHTML = renderStatsCard(stats, { hide: ["issues", "prs", "contribs"], }); expect( document.body.getElementsByTagName("svg")[0].getAttribute("height"), ).toBe("150"); // height should be 150 because we clamped it. expect(queryByTestId(document.body, "stars")).toBeDefined(); expect(queryByTestId(document.body, "commits")).toBeDefined(); expect(queryByTestId(document.body, "issues")).toBeNull(); expect(queryByTestId(document.body, "prs")).toBeNull(); expect(queryByTestId(document.body, "contribs")).toBeNull(); expect(queryByTestId(document.body, "reviews")).toBeNull(); expect(queryByTestId(document.body, "discussions_started")).toBeNull(); expect(queryByTestId(document.body, "discussions_answered")).toBeNull(); expect(queryByTestId(document.body, "prs_merged")).toBeNull(); expect(queryByTestId(document.body, "prs_merged_percentage")).toBeNull(); }); it("should show additional stats", () => { document.body.innerHTML = renderStatsCard(stats, { show: [ "reviews", "discussions_started", "discussions_answered", "prs_merged", "prs_merged_percentage", ], }); expect( document.body.getElementsByTagName("svg")[0].getAttribute("height"), ).toBe("320"); expect(queryByTestId(document.body, "stars")).toBeDefined(); expect(queryByTestId(document.body, "commits")).toBeDefined(); expect(queryByTestId(document.body, "issues")).toBeDefined(); expect(queryByTestId(document.body, "prs")).toBeDefined(); expect(queryByTestId(document.body, "contribs")).toBeDefined(); expect(queryByTestId(document.body, "reviews")).toBeDefined(); expect(queryByTestId(document.body, "discussions_started")).toBeDefined(); expect(queryByTestId(document.body, "discussions_answered")).toBeDefined(); expect(queryByTestId(document.body, "prs_merged")).toBeDefined(); expect(queryByTestId(document.body, "prs_merged_percentage")).toBeDefined(); }); it("should hide_rank", () => { document.body.innerHTML = renderStatsCard(stats, { hide_rank: true }); expect(queryByTestId(document.body, "rank-circle")).not.toBeInTheDocument(); }); it("should render with custom width set", () => { document.body.innerHTML = renderStatsCard(stats); expect(document.querySelector("svg")).toHaveAttribute("width", "450"); document.body.innerHTML = renderStatsCard(stats, { card_width: 500 }); expect(document.querySelector("svg")).toHaveAttribute("width", "500"); }); it("should render with custom width set and limit minimum width", () => { document.body.innerHTML = renderStatsCard(stats, { card_width: 1 }); expect(document.querySelector("svg")).toHaveAttribute("width", "420"); // Test default minimum card width without rank circle. document.body.innerHTML = renderStatsCard(stats, { card_width: 1, hide_rank: true, }); expect(document.querySelector("svg")).toHaveAttribute( "width", "305.81250000000006", ); // Test minimum card width with rank and icons. document.body.innerHTML = renderStatsCard(stats, { card_width: 1, hide_rank: true, show_icons: true, }); expect(document.querySelector("svg")).toHaveAttribute( "width", "322.81250000000006", ); // Test minimum card width with icons but without rank. document.body.innerHTML = renderStatsCard(stats, { card_width: 1, hide_rank: false, show_icons: true, }); expect(document.querySelector("svg")).toHaveAttribute("width", "437"); // Test minimum card width without icons or rank. document.body.innerHTML = renderStatsCard(stats, { card_width: 1, hide_rank: false, show_icons: false, }); expect(document.querySelector("svg")).toHaveAttribute("width", "420"); }); it("should render default colors properly", () => { document.body.innerHTML = renderStatsCard(stats); const styleTag = document.querySelector("style"); const stylesObject = cssToObject(styleTag.textContent); const headerClassStyles = stylesObject[":host"][".header "]; const statClassStyles = stylesObject[":host"][".stat "]; const iconClassStyles = stylesObject[":host"][".icon "]; expect(headerClassStyles.fill.trim()).toBe("#2f80ed"); expect(statClassStyles.fill.trim()).toBe("#434d58"); expect(iconClassStyles.fill.trim()).toBe("#4c71f2"); expect(queryByTestId(document.body, "card-bg")).toHaveAttribute( "fill", "#fffefe", ); }); it("should render custom colors properly", () => { const customColors = { title_color: "5a0", icon_color: "1b998b", text_color: "9991", bg_color: "252525", }; document.body.innerHTML = renderStatsCard(stats, { ...customColors }); const styleTag = document.querySelector("style"); const stylesObject = cssToObject(styleTag.innerHTML); const headerClassStyles = stylesObject[":host"][".header "]; const statClassStyles = stylesObject[":host"][".stat "]; const iconClassStyles = stylesObject[":host"][".icon "]; expect(headerClassStyles.fill.trim()).toBe(`#${customColors.title_color}`); expect(statClassStyles.fill.trim()).toBe(`#${customColors.text_color}`); expect(iconClassStyles.fill.trim()).toBe(`#${customColors.icon_color}`); expect(queryByTestId(document.body, "card-bg")).toHaveAttribute( "fill", "#252525", ); }); it("should render custom colors with themes", () => { document.body.innerHTML = renderStatsCard(stats, { title_color: "5a0", theme: "radical", }); const styleTag = document.querySelector("style"); const stylesObject = cssToObject(styleTag.innerHTML); const headerClassStyles = stylesObject[":host"][".header "]; const statClassStyles = stylesObject[":host"][".stat "]; const iconClassStyles = stylesObject[":host"][".icon "]; expect(headerClassStyles.fill.trim()).toBe("#5a0"); expect(statClassStyles.fill.trim()).toBe(`#${themes.radical.text_color}`); expect(iconClassStyles.fill.trim()).toBe(`#${themes.radical.icon_color}`); expect(queryByTestId(document.body, "card-bg")).toHaveAttribute( "fill", `#${themes.radical.bg_color}`, ); }); it("should render with all the themes", () => { Object.keys(themes).forEach((name) => { document.body.innerHTML = renderStatsCard(stats, { theme: name, }); const styleTag = document.querySelector("style"); const stylesObject = cssToObject(styleTag.innerHTML); const headerClassStyles = stylesObject[":host"][".header "]; const statClassStyles = stylesObject[":host"][".stat "]; const iconClassStyles = stylesObject[":host"][".icon "]; expect(headerClassStyles.fill.trim()).toBe( `#${themes[name].title_color}`, ); expect(statClassStyles.fill.trim()).toBe(`#${themes[name].text_color}`); expect(iconClassStyles.fill.trim()).toBe(`#${themes[name].icon_color}`); const backgroundElement = queryByTestId(document.body, "card-bg"); const backgroundElementFill = backgroundElement.getAttribute("fill"); expect([`#${themes[name].bg_color}`, "url(#gradient)"]).toContain( backgroundElementFill, ); }); }); it("should render custom colors with themes and fallback to default colors if invalid", () => { document.body.innerHTML = renderStatsCard(stats, { title_color: "invalid color", text_color: "invalid color", theme: "radical", }); const styleTag = document.querySelector("style"); const stylesObject = cssToObject(styleTag.innerHTML); const headerClassStyles = stylesObject[":host"][".header "]; const statClassStyles = stylesObject[":host"][".stat "]; const iconClassStyles = stylesObject[":host"][".icon "]; expect(headerClassStyles.fill.trim()).toBe( `#${themes.default.title_color}`, ); expect(statClassStyles.fill.trim()).toBe(`#${themes.default.text_color}`); expect(iconClassStyles.fill.trim()).toBe(`#${themes.radical.icon_color}`); expect(queryByTestId(document.body, "card-bg")).toHaveAttribute( "fill", `#${themes.radical.bg_color}`, ); }); it("should render custom ring_color properly", () => { const customColors = { title_color: "5a0", ring_color: "0000ff", icon_color: "1b998b", text_color: "9991", bg_color: "252525", }; document.body.innerHTML = renderStatsCard(stats, { ...customColors }); const styleTag = document.querySelector("style"); const stylesObject = cssToObject(styleTag.innerHTML); const headerClassStyles = stylesObject[":host"][".header "]; const statClassStyles = stylesObject[":host"][".stat "]; const iconClassStyles = stylesObject[":host"][".icon "]; const rankCircleStyles = stylesObject[":host"][".rank-circle "]; const rankCircleRimStyles = stylesObject[":host"][".rank-circle-rim "]; expect(headerClassStyles.fill.trim()).toBe(`#${customColors.title_color}`); expect(statClassStyles.fill.trim()).toBe(`#${customColors.text_color}`); expect(iconClassStyles.fill.trim()).toBe(`#${customColors.icon_color}`); expect(rankCircleStyles.stroke.trim()).toBe(`#${customColors.ring_color}`); expect(rankCircleRimStyles.stroke.trim()).toBe( `#${customColors.ring_color}`, ); expect(queryByTestId(document.body, "card-bg")).toHaveAttribute( "fill", "#252525", ); }); it("should render icons correctly", () => { document.body.innerHTML = renderStatsCard(stats, { show_icons: true, }); expect(queryAllByTestId(document.body, "icon")[0]).toBeDefined(); expect(queryByTestId(document.body, "stars")).toBeDefined(); expect( queryByTestId(document.body, "stars").previousElementSibling, // the label ).toHaveAttribute("x", "25"); }); it("should not have icons if show_icons is false", () => { document.body.innerHTML = renderStatsCard(stats, { show_icons: false }); expect(queryAllByTestId(document.body, "icon")[0]).not.toBeDefined(); expect(queryByTestId(document.body, "stars")).toBeDefined(); expect( queryByTestId(document.body, "stars").previousElementSibling, // the label ).not.toHaveAttribute("x"); }); it("should auto resize if hide_rank is true", () => { document.body.innerHTML = renderStatsCard(stats, { hide_rank: true, }); expect( document.body.getElementsByTagName("svg")[0].getAttribute("width"), ).toBe("305.81250000000006"); }); it("should auto resize if hide_rank is true & custom_title is set", () => { document.body.innerHTML = renderStatsCard(stats, { hide_rank: true, custom_title: "Hello world", }); expect( document.body.getElementsByTagName("svg")[0].getAttribute("width"), ).toBe("287"); }); it("should render translations", () => { document.body.innerHTML = renderStatsCard(stats, { locale: "cn" }); expect(document.getElementsByClassName("header")[0].textContent).toBe( "Anurag Hazra 的 GitHub 统计数据", ); expect( document.querySelector( 'g[transform="translate(0, 0)"]>.stagger>.stat.bold', ).textContent, ).toMatchInlineSnapshot(`"获标星数:"`); expect( document.querySelector( 'g[transform="translate(0, 25)"]>.stagger>.stat.bold', ).textContent, ).toMatchInlineSnapshot(`"累计提交总数 (去年):"`); expect( document.querySelector( 'g[transform="translate(0, 50)"]>.stagger>.stat.bold', ).textContent, ).toMatchInlineSnapshot(`"发起的 PR 总数:"`); expect( document.querySelector( 'g[transform="translate(0, 75)"]>.stagger>.stat.bold', ).textContent, ).toMatchInlineSnapshot(`"提出的 issue 总数:"`); expect( document.querySelector( 'g[transform="translate(0, 100)"]>.stagger>.stat.bold', ).textContent, ).toMatchInlineSnapshot(`"贡献的项目数(去年):"`); }); it("should render without rounding", () => { document.body.innerHTML = renderStatsCard(stats, { border_radius: "0" }); expect(document.querySelector("rect")).toHaveAttribute("rx", "0"); document.body.innerHTML = renderStatsCard(stats, {}); expect(document.querySelector("rect")).toHaveAttribute("rx", "4.5"); }); it("should shorten values", () => { stats["totalCommits"] = 1999; document.body.innerHTML = renderStatsCard(stats); expect(getByTestId(document.body, "commits").textContent).toBe("2k"); document.body.innerHTML = renderStatsCard(stats, { number_format: "long" }); expect(getByTestId(document.body, "commits").textContent).toBe("1999"); document.body.innerHTML = renderStatsCard(stats, { number_precision: 2 }); expect(getByTestId(document.body, "commits").textContent).toBe("2.00k"); document.body.innerHTML = renderStatsCard(stats, { number_format: "long", number_precision: 2, }); expect(getByTestId(document.body, "commits").textContent).toBe("1999"); }); it("should render default rank icon with level A+", () => { document.body.innerHTML = renderStatsCard(stats, { rank_icon: "default", }); expect(queryByTestId(document.body, "level-rank-icon")).toBeDefined(); expect( queryByTestId(document.body, "level-rank-icon").textContent.trim(), ).toBe("A+"); }); it("should render github rank icon", () => { document.body.innerHTML = renderStatsCard(stats, { rank_icon: "github", }); expect(queryByTestId(document.body, "github-rank-icon")).toBeDefined(); }); it("should show the rank percentile", () => { document.body.innerHTML = renderStatsCard(stats, { rank_icon: "percentile", }); expect(queryByTestId(document.body, "percentile-top-header")).toBeDefined(); expect( queryByTestId(document.body, "percentile-top-header").textContent.trim(), ).toBe("Top"); expect(queryByTestId(document.body, "rank-percentile-text")).toBeDefined(); expect( queryByTestId(document.body, "percentile-rank-value").textContent.trim(), ).toBe(stats.rank.percentile.toFixed(1) + "%"); }); it("should throw error if all stats and rank icon are hidden", () => { expect(() => renderStatsCard(stats, { hide: ["stars", "commits", "prs", "issues", "contribs"], hide_rank: true, }), ).toThrow( new CustomError( "Could not render stats card.", "Either stats or rank are required.", ), ); }); }); ================================================ FILE: tests/renderTopLanguagesCard.test.js ================================================ import { describe, expect, it } from "@jest/globals"; import { queryAllByTestId, queryByTestId } from "@testing-library/dom"; import "@testing-library/jest-dom"; import { cssToObject } from "@uppercod/css-to-object"; import { MIN_CARD_WIDTH, calculateCompactLayoutHeight, calculateDonutLayoutHeight, calculateDonutVerticalLayoutHeight, calculateNormalLayoutHeight, calculatePieLayoutHeight, cartesianToPolar, degreesToRadians, donutCenterTranslation, getCircleLength, getDefaultLanguagesCountByLayout, getLongestLang, polarToCartesian, radiansToDegrees, renderTopLanguages, trimTopLanguages, } from "../src/cards/top-languages.js"; import { themes } from "../themes/index.js"; const langs = { HTML: { color: "#0f0", name: "HTML", size: 200, }, javascript: { color: "#0ff", name: "javascript", size: 200, }, css: { color: "#ff0", name: "css", size: 100, }, }; /** * Retrieve number array from SVG path definition string. * * @param {string} d SVG path definition string. * @returns {number[]} Resulting numbers array. */ const getNumbersFromSvgPathDefinitionAttribute = (d) => { return d .split(" ") .filter((x) => !isNaN(x)) .map((x) => parseFloat(x)); }; /** * Retrieve the language percentage from the donut chart SVG. * * @param {string} d The SVG path element. * @param {number} centerX The center X coordinate of the donut chart. * @param {number} centerY The center Y coordinate of the donut chart. * @returns {number} The percentage of the language. */ const langPercentFromDonutLayoutSvg = (d, centerX, centerY) => { const dTmp = getNumbersFromSvgPathDefinitionAttribute(d); const endAngle = cartesianToPolar(centerX, centerY, dTmp[0], dTmp[1]).angleInDegrees + 90; let startAngle = cartesianToPolar(centerX, centerY, dTmp[7], dTmp[8]).angleInDegrees + 90; if (startAngle > endAngle) { startAngle -= 360; } return (endAngle - startAngle) / 3.6; }; /** * Calculate language percentage for donut vertical chart SVG. * * @param {number} partLength Length of current chart part.. * @param {number} totalCircleLength Total length of circle. * @returns {number} Chart part percentage. */ const langPercentFromDonutVerticalLayoutSvg = ( partLength, totalCircleLength, ) => { return (partLength / totalCircleLength) * 100; }; /** * Retrieve the language percentage from the pie chart SVG. * * @param {string} d The SVG path element. * @param {number} centerX The center X coordinate of the pie chart. * @param {number} centerY The center Y coordinate of the pie chart. * @returns {number} The percentage of the language. */ const langPercentFromPieLayoutSvg = (d, centerX, centerY) => { const dTmp = getNumbersFromSvgPathDefinitionAttribute(d); const startAngle = cartesianToPolar( centerX, centerY, dTmp[2], dTmp[3], ).angleInDegrees; let endAngle = cartesianToPolar( centerX, centerY, dTmp[9], dTmp[10], ).angleInDegrees; return ((endAngle - startAngle) / 360) * 100; }; describe("Test renderTopLanguages helper functions", () => { it("getLongestLang", () => { const langArray = Object.values(langs); expect(getLongestLang(langArray)).toBe(langs.javascript); }); it("degreesToRadians", () => { expect(degreesToRadians(0)).toBe(0); expect(degreesToRadians(90)).toBe(Math.PI / 2); expect(degreesToRadians(180)).toBe(Math.PI); expect(degreesToRadians(270)).toBe((3 * Math.PI) / 2); expect(degreesToRadians(360)).toBe(2 * Math.PI); }); it("radiansToDegrees", () => { expect(radiansToDegrees(0)).toBe(0); expect(radiansToDegrees(Math.PI / 2)).toBe(90); expect(radiansToDegrees(Math.PI)).toBe(180); expect(radiansToDegrees((3 * Math.PI) / 2)).toBe(270); expect(radiansToDegrees(2 * Math.PI)).toBe(360); }); it("polarToCartesian", () => { expect(polarToCartesian(100, 100, 60, 0)).toStrictEqual({ x: 160, y: 100 }); expect(polarToCartesian(100, 100, 60, 45)).toStrictEqual({ x: 142.42640687119285, y: 142.42640687119285, }); expect(polarToCartesian(100, 100, 60, 90)).toStrictEqual({ x: 100, y: 160, }); expect(polarToCartesian(100, 100, 60, 135)).toStrictEqual({ x: 57.573593128807154, y: 142.42640687119285, }); expect(polarToCartesian(100, 100, 60, 180)).toStrictEqual({ x: 40, y: 100.00000000000001, }); expect(polarToCartesian(100, 100, 60, 225)).toStrictEqual({ x: 57.57359312880714, y: 57.573593128807154, }); expect(polarToCartesian(100, 100, 60, 270)).toStrictEqual({ x: 99.99999999999999, y: 40, }); expect(polarToCartesian(100, 100, 60, 315)).toStrictEqual({ x: 142.42640687119285, y: 57.57359312880714, }); expect(polarToCartesian(100, 100, 60, 360)).toStrictEqual({ x: 160, y: 99.99999999999999, }); }); it("cartesianToPolar", () => { expect(cartesianToPolar(100, 100, 160, 100)).toStrictEqual({ radius: 60, angleInDegrees: 0, }); expect( cartesianToPolar(100, 100, 142.42640687119285, 142.42640687119285), ).toStrictEqual({ radius: 60.00000000000001, angleInDegrees: 45 }); expect(cartesianToPolar(100, 100, 100, 160)).toStrictEqual({ radius: 60, angleInDegrees: 90, }); expect( cartesianToPolar(100, 100, 57.573593128807154, 142.42640687119285), ).toStrictEqual({ radius: 60, angleInDegrees: 135 }); expect(cartesianToPolar(100, 100, 40, 100.00000000000001)).toStrictEqual({ radius: 60, angleInDegrees: 180, }); expect( cartesianToPolar(100, 100, 57.57359312880714, 57.573593128807154), ).toStrictEqual({ radius: 60, angleInDegrees: 225 }); expect(cartesianToPolar(100, 100, 99.99999999999999, 40)).toStrictEqual({ radius: 60, angleInDegrees: 270, }); expect( cartesianToPolar(100, 100, 142.42640687119285, 57.57359312880714), ).toStrictEqual({ radius: 60.00000000000001, angleInDegrees: 315 }); expect(cartesianToPolar(100, 100, 160, 99.99999999999999)).toStrictEqual({ radius: 60, angleInDegrees: 360, }); }); it("calculateCompactLayoutHeight", () => { expect(calculateCompactLayoutHeight(0)).toBe(90); expect(calculateCompactLayoutHeight(1)).toBe(115); expect(calculateCompactLayoutHeight(2)).toBe(115); expect(calculateCompactLayoutHeight(3)).toBe(140); expect(calculateCompactLayoutHeight(4)).toBe(140); expect(calculateCompactLayoutHeight(5)).toBe(165); expect(calculateCompactLayoutHeight(6)).toBe(165); expect(calculateCompactLayoutHeight(7)).toBe(190); expect(calculateCompactLayoutHeight(8)).toBe(190); expect(calculateCompactLayoutHeight(9)).toBe(215); expect(calculateCompactLayoutHeight(10)).toBe(215); }); it("calculateNormalLayoutHeight", () => { expect(calculateNormalLayoutHeight(0)).toBe(85); expect(calculateNormalLayoutHeight(1)).toBe(125); expect(calculateNormalLayoutHeight(2)).toBe(165); expect(calculateNormalLayoutHeight(3)).toBe(205); expect(calculateNormalLayoutHeight(4)).toBe(245); expect(calculateNormalLayoutHeight(5)).toBe(285); expect(calculateNormalLayoutHeight(6)).toBe(325); expect(calculateNormalLayoutHeight(7)).toBe(365); expect(calculateNormalLayoutHeight(8)).toBe(405); expect(calculateNormalLayoutHeight(9)).toBe(445); expect(calculateNormalLayoutHeight(10)).toBe(485); }); it("calculateDonutLayoutHeight", () => { expect(calculateDonutLayoutHeight(0)).toBe(215); expect(calculateDonutLayoutHeight(1)).toBe(215); expect(calculateDonutLayoutHeight(2)).toBe(215); expect(calculateDonutLayoutHeight(3)).toBe(215); expect(calculateDonutLayoutHeight(4)).toBe(215); expect(calculateDonutLayoutHeight(5)).toBe(215); expect(calculateDonutLayoutHeight(6)).toBe(247); expect(calculateDonutLayoutHeight(7)).toBe(279); expect(calculateDonutLayoutHeight(8)).toBe(311); expect(calculateDonutLayoutHeight(9)).toBe(343); expect(calculateDonutLayoutHeight(10)).toBe(375); }); it("calculateDonutVerticalLayoutHeight", () => { expect(calculateDonutVerticalLayoutHeight(0)).toBe(300); expect(calculateDonutVerticalLayoutHeight(1)).toBe(325); expect(calculateDonutVerticalLayoutHeight(2)).toBe(325); expect(calculateDonutVerticalLayoutHeight(3)).toBe(350); expect(calculateDonutVerticalLayoutHeight(4)).toBe(350); expect(calculateDonutVerticalLayoutHeight(5)).toBe(375); expect(calculateDonutVerticalLayoutHeight(6)).toBe(375); expect(calculateDonutVerticalLayoutHeight(7)).toBe(400); expect(calculateDonutVerticalLayoutHeight(8)).toBe(400); expect(calculateDonutVerticalLayoutHeight(9)).toBe(425); expect(calculateDonutVerticalLayoutHeight(10)).toBe(425); }); it("calculatePieLayoutHeight", () => { expect(calculatePieLayoutHeight(0)).toBe(300); expect(calculatePieLayoutHeight(1)).toBe(325); expect(calculatePieLayoutHeight(2)).toBe(325); expect(calculatePieLayoutHeight(3)).toBe(350); expect(calculatePieLayoutHeight(4)).toBe(350); expect(calculatePieLayoutHeight(5)).toBe(375); expect(calculatePieLayoutHeight(6)).toBe(375); expect(calculatePieLayoutHeight(7)).toBe(400); expect(calculatePieLayoutHeight(8)).toBe(400); expect(calculatePieLayoutHeight(9)).toBe(425); expect(calculatePieLayoutHeight(10)).toBe(425); }); it("donutCenterTranslation", () => { expect(donutCenterTranslation(0)).toBe(-45); expect(donutCenterTranslation(1)).toBe(-45); expect(donutCenterTranslation(2)).toBe(-45); expect(donutCenterTranslation(3)).toBe(-45); expect(donutCenterTranslation(4)).toBe(-45); expect(donutCenterTranslation(5)).toBe(-45); expect(donutCenterTranslation(6)).toBe(-29); expect(donutCenterTranslation(7)).toBe(-13); expect(donutCenterTranslation(8)).toBe(3); expect(donutCenterTranslation(9)).toBe(19); expect(donutCenterTranslation(10)).toBe(35); }); it("getCircleLength", () => { expect(getCircleLength(20)).toBeCloseTo(125.663); expect(getCircleLength(30)).toBeCloseTo(188.495); expect(getCircleLength(40)).toBeCloseTo(251.327); expect(getCircleLength(50)).toBeCloseTo(314.159); expect(getCircleLength(60)).toBeCloseTo(376.991); expect(getCircleLength(70)).toBeCloseTo(439.822); expect(getCircleLength(80)).toBeCloseTo(502.654); expect(getCircleLength(90)).toBeCloseTo(565.486); expect(getCircleLength(100)).toBeCloseTo(628.318); }); it("trimTopLanguages", () => { expect(trimTopLanguages([])).toStrictEqual({ langs: [], totalLanguageSize: 0, }); expect(trimTopLanguages([langs.javascript])).toStrictEqual({ langs: [langs.javascript], totalLanguageSize: 200, }); expect(trimTopLanguages([langs.javascript, langs.HTML], 5)).toStrictEqual({ langs: [langs.javascript, langs.HTML], totalLanguageSize: 400, }); expect(trimTopLanguages(langs, 5)).toStrictEqual({ langs: Object.values(langs), totalLanguageSize: 500, }); expect(trimTopLanguages(langs, 2)).toStrictEqual({ langs: Object.values(langs).slice(0, 2), totalLanguageSize: 400, }); expect(trimTopLanguages(langs, 5, ["javascript"])).toStrictEqual({ langs: [langs.HTML, langs.css], totalLanguageSize: 300, }); }); it("getDefaultLanguagesCountByLayout", () => { expect( getDefaultLanguagesCountByLayout({ layout: "normal" }), ).toStrictEqual(5); expect(getDefaultLanguagesCountByLayout({})).toStrictEqual(5); expect( getDefaultLanguagesCountByLayout({ layout: "compact" }), ).toStrictEqual(6); expect( getDefaultLanguagesCountByLayout({ hide_progress: true }), ).toStrictEqual(6); expect(getDefaultLanguagesCountByLayout({ layout: "donut" })).toStrictEqual( 5, ); expect( getDefaultLanguagesCountByLayout({ layout: "donut-vertical" }), ).toStrictEqual(6); expect(getDefaultLanguagesCountByLayout({ layout: "pie" })).toStrictEqual( 6, ); }); }); describe("Test renderTopLanguages", () => { it("should render correctly", () => { document.body.innerHTML = renderTopLanguages(langs); expect(queryByTestId(document.body, "header")).toHaveTextContent( "Most Used Languages", ); expect(queryAllByTestId(document.body, "lang-name")[0]).toHaveTextContent( "HTML", ); expect(queryAllByTestId(document.body, "lang-name")[1]).toHaveTextContent( "javascript", ); expect(queryAllByTestId(document.body, "lang-name")[2]).toHaveTextContent( "css", ); expect(queryAllByTestId(document.body, "lang-progress")[0]).toHaveAttribute( "width", "40%", ); expect(queryAllByTestId(document.body, "lang-progress")[1]).toHaveAttribute( "width", "40%", ); expect(queryAllByTestId(document.body, "lang-progress")[2]).toHaveAttribute( "width", "20%", ); }); it("should hide languages when hide is passed", () => { document.body.innerHTML = renderTopLanguages(langs, { hide: ["HTML"], }); expect(queryAllByTestId(document.body, "lang-name")[0]).toBeInTheDocument( "javascript", ); expect(queryAllByTestId(document.body, "lang-name")[1]).toBeInTheDocument( "css", ); expect(queryAllByTestId(document.body, "lang-name")[2]).not.toBeDefined(); // multiple languages passed document.body.innerHTML = renderTopLanguages(langs, { hide: ["HTML", "css"], }); expect(queryAllByTestId(document.body, "lang-name")[0]).toBeInTheDocument( "javascript", ); expect(queryAllByTestId(document.body, "lang-name")[1]).not.toBeDefined(); }); it("should resize the height correctly depending on langs", () => { document.body.innerHTML = renderTopLanguages(langs, {}); expect(document.querySelector("svg")).toHaveAttribute("height", "205"); document.body.innerHTML = renderTopLanguages( { ...langs, python: { color: "#ff0", name: "python", size: 100, }, }, {}, ); expect(document.querySelector("svg")).toHaveAttribute("height", "245"); }); it("should render with custom width set", () => { document.body.innerHTML = renderTopLanguages(langs, {}); expect(document.querySelector("svg")).toHaveAttribute("width", "300"); document.body.innerHTML = renderTopLanguages(langs, { card_width: 400 }); expect(document.querySelector("svg")).toHaveAttribute("width", "400"); }); it("should render with min width", () => { document.body.innerHTML = renderTopLanguages(langs, { card_width: 190 }); expect(document.querySelector("svg")).toHaveAttribute( "width", MIN_CARD_WIDTH.toString(), ); document.body.innerHTML = renderTopLanguages(langs, { card_width: 100 }); expect(document.querySelector("svg")).toHaveAttribute( "width", MIN_CARD_WIDTH.toString(), ); }); it("should render default colors properly", () => { document.body.innerHTML = renderTopLanguages(langs); const styleTag = document.querySelector("style"); const stylesObject = cssToObject(styleTag.textContent); const headerStyles = stylesObject[":host"][".header "]; const langNameStyles = stylesObject[":host"][".lang-name "]; expect(headerStyles.fill.trim()).toBe("#2f80ed"); expect(langNameStyles.fill.trim()).toBe("#434d58"); expect(queryByTestId(document.body, "card-bg")).toHaveAttribute( "fill", "#fffefe", ); }); it("should render custom colors properly", () => { const customColors = { title_color: "5a0", icon_color: "1b998b", text_color: "9991", bg_color: "252525", }; document.body.innerHTML = renderTopLanguages(langs, { ...customColors }); const styleTag = document.querySelector("style"); const stylesObject = cssToObject(styleTag.innerHTML); const headerStyles = stylesObject[":host"][".header "]; const langNameStyles = stylesObject[":host"][".lang-name "]; expect(headerStyles.fill.trim()).toBe(`#${customColors.title_color}`); expect(langNameStyles.fill.trim()).toBe(`#${customColors.text_color}`); expect(queryByTestId(document.body, "card-bg")).toHaveAttribute( "fill", "#252525", ); }); it("should render custom colors with themes", () => { document.body.innerHTML = renderTopLanguages(langs, { title_color: "5a0", theme: "radical", }); const styleTag = document.querySelector("style"); const stylesObject = cssToObject(styleTag.innerHTML); const headerStyles = stylesObject[":host"][".header "]; const langNameStyles = stylesObject[":host"][".lang-name "]; expect(headerStyles.fill.trim()).toBe("#5a0"); expect(langNameStyles.fill.trim()).toBe(`#${themes.radical.text_color}`); expect(queryByTestId(document.body, "card-bg")).toHaveAttribute( "fill", `#${themes.radical.bg_color}`, ); }); it("should render with all the themes", () => { Object.keys(themes).forEach((name) => { document.body.innerHTML = renderTopLanguages(langs, { theme: name, }); const styleTag = document.querySelector("style"); const stylesObject = cssToObject(styleTag.innerHTML); const headerStyles = stylesObject[":host"][".header "]; const langNameStyles = stylesObject[":host"][".lang-name "]; expect(headerStyles.fill.trim()).toBe(`#${themes[name].title_color}`); expect(langNameStyles.fill.trim()).toBe(`#${themes[name].text_color}`); const backgroundElement = queryByTestId(document.body, "card-bg"); const backgroundElementFill = backgroundElement.getAttribute("fill"); expect([`#${themes[name].bg_color}`, "url(#gradient)"]).toContain( backgroundElementFill, ); }); }); it("should render with layout compact", () => { document.body.innerHTML = renderTopLanguages(langs, { layout: "compact" }); expect(queryByTestId(document.body, "header")).toHaveTextContent( "Most Used Languages", ); expect(queryAllByTestId(document.body, "lang-name")[0]).toHaveTextContent( "HTML 40.00%", ); expect(queryAllByTestId(document.body, "lang-progress")[0]).toHaveAttribute( "width", "100", ); expect(queryAllByTestId(document.body, "lang-name")[1]).toHaveTextContent( "javascript 40.00%", ); expect(queryAllByTestId(document.body, "lang-progress")[1]).toHaveAttribute( "width", "100", ); expect(queryAllByTestId(document.body, "lang-name")[2]).toHaveTextContent( "css 20.00%", ); expect(queryAllByTestId(document.body, "lang-progress")[2]).toHaveAttribute( "width", "50", ); }); it("should render with layout donut", () => { document.body.innerHTML = renderTopLanguages(langs, { layout: "donut" }); expect(queryByTestId(document.body, "header")).toHaveTextContent( "Most Used Languages", ); expect(queryAllByTestId(document.body, "lang-name")[0]).toHaveTextContent( "HTML 40.00%", ); expect(queryAllByTestId(document.body, "lang-donut")[0]).toHaveAttribute( "size", "40", ); const d = getNumbersFromSvgPathDefinitionAttribute( queryAllByTestId(document.body, "lang-donut")[0].getAttribute("d"), ); const center = { x: d[7], y: d[7] }; const HTMLLangPercent = langPercentFromDonutLayoutSvg( queryAllByTestId(document.body, "lang-donut")[0].getAttribute("d"), center.x, center.y, ); expect(HTMLLangPercent).toBeCloseTo(40); expect(queryAllByTestId(document.body, "lang-name")[1]).toHaveTextContent( "javascript 40.00%", ); expect(queryAllByTestId(document.body, "lang-donut")[1]).toHaveAttribute( "size", "40", ); const javascriptLangPercent = langPercentFromDonutLayoutSvg( queryAllByTestId(document.body, "lang-donut")[1].getAttribute("d"), center.x, center.y, ); expect(javascriptLangPercent).toBeCloseTo(40); expect(queryAllByTestId(document.body, "lang-name")[2]).toHaveTextContent( "css 20.00%", ); expect(queryAllByTestId(document.body, "lang-donut")[2]).toHaveAttribute( "size", "20", ); const cssLangPercent = langPercentFromDonutLayoutSvg( queryAllByTestId(document.body, "lang-donut")[2].getAttribute("d"), center.x, center.y, ); expect(cssLangPercent).toBeCloseTo(20); expect(HTMLLangPercent + javascriptLangPercent + cssLangPercent).toBe(100); // Should render full donut (circle) if one language is 100%. document.body.innerHTML = renderTopLanguages( { HTML: langs.HTML }, { layout: "donut" }, ); expect(queryAllByTestId(document.body, "lang-name")[0]).toHaveTextContent( "HTML 100.00%", ); expect(queryAllByTestId(document.body, "lang-donut")[0]).toHaveAttribute( "size", "100", ); expect(queryAllByTestId(document.body, "lang-donut")).toHaveLength(1); expect(queryAllByTestId(document.body, "lang-donut")[0].tagName).toBe( "circle", ); }); it("should render with layout donut vertical", () => { document.body.innerHTML = renderTopLanguages(langs, { layout: "donut-vertical", }); expect(queryByTestId(document.body, "header")).toHaveTextContent( "Most Used Languages", ); expect(queryAllByTestId(document.body, "lang-name")[0]).toHaveTextContent( "HTML 40.00%", ); expect(queryAllByTestId(document.body, "lang-donut")[0]).toHaveAttribute( "size", "40", ); const totalCircleLength = queryAllByTestId( document.body, "lang-donut", )[0].getAttribute("stroke-dasharray"); const HTMLLangPercent = langPercentFromDonutVerticalLayoutSvg( queryAllByTestId(document.body, "lang-donut")[1].getAttribute( "stroke-dashoffset", ) - queryAllByTestId(document.body, "lang-donut")[0].getAttribute( "stroke-dashoffset", ), totalCircleLength, ); expect(HTMLLangPercent).toBeCloseTo(40); expect(queryAllByTestId(document.body, "lang-name")[1]).toHaveTextContent( "javascript 40.00%", ); expect(queryAllByTestId(document.body, "lang-donut")[1]).toHaveAttribute( "size", "40", ); const javascriptLangPercent = langPercentFromDonutVerticalLayoutSvg( queryAllByTestId(document.body, "lang-donut")[2].getAttribute( "stroke-dashoffset", ) - queryAllByTestId(document.body, "lang-donut")[1].getAttribute( "stroke-dashoffset", ), totalCircleLength, ); expect(javascriptLangPercent).toBeCloseTo(40); expect(queryAllByTestId(document.body, "lang-name")[2]).toHaveTextContent( "css 20.00%", ); expect(queryAllByTestId(document.body, "lang-donut")[2]).toHaveAttribute( "size", "20", ); const cssLangPercent = langPercentFromDonutVerticalLayoutSvg( totalCircleLength - queryAllByTestId(document.body, "lang-donut")[2].getAttribute( "stroke-dashoffset", ), totalCircleLength, ); expect(cssLangPercent).toBeCloseTo(20); expect(HTMLLangPercent + javascriptLangPercent + cssLangPercent).toBe(100); }); it("should render with layout donut vertical full donut circle of one language is 100%", () => { document.body.innerHTML = renderTopLanguages( { HTML: langs.HTML }, { layout: "donut-vertical" }, ); expect(queryAllByTestId(document.body, "lang-name")[0]).toHaveTextContent( "HTML 100.00%", ); expect(queryAllByTestId(document.body, "lang-donut")[0]).toHaveAttribute( "size", "100", ); const totalCircleLength = queryAllByTestId( document.body, "lang-donut", )[0].getAttribute("stroke-dasharray"); const HTMLLangPercent = langPercentFromDonutVerticalLayoutSvg( totalCircleLength - queryAllByTestId(document.body, "lang-donut")[0].getAttribute( "stroke-dashoffset", ), totalCircleLength, ); expect(HTMLLangPercent).toBeCloseTo(100); }); it("should render with layout pie", () => { document.body.innerHTML = renderTopLanguages(langs, { layout: "pie" }); expect(queryByTestId(document.body, "header")).toHaveTextContent( "Most Used Languages", ); expect(queryAllByTestId(document.body, "lang-name")[0]).toHaveTextContent( "HTML 40.00%", ); expect(queryAllByTestId(document.body, "lang-pie")[0]).toHaveAttribute( "size", "40", ); const d = getNumbersFromSvgPathDefinitionAttribute( queryAllByTestId(document.body, "lang-pie")[0].getAttribute("d"), ); const center = { x: d[0], y: d[1] }; const HTMLLangPercent = langPercentFromPieLayoutSvg( queryAllByTestId(document.body, "lang-pie")[0].getAttribute("d"), center.x, center.y, ); expect(HTMLLangPercent).toBeCloseTo(40); expect(queryAllByTestId(document.body, "lang-name")[1]).toHaveTextContent( "javascript 40.00%", ); expect(queryAllByTestId(document.body, "lang-pie")[1]).toHaveAttribute( "size", "40", ); const javascriptLangPercent = langPercentFromPieLayoutSvg( queryAllByTestId(document.body, "lang-pie")[1].getAttribute("d"), center.x, center.y, ); expect(javascriptLangPercent).toBeCloseTo(40); expect(queryAllByTestId(document.body, "lang-name")[2]).toHaveTextContent( "css 20.00%", ); expect(queryAllByTestId(document.body, "lang-pie")[2]).toHaveAttribute( "size", "20", ); const cssLangPercent = langPercentFromPieLayoutSvg( queryAllByTestId(document.body, "lang-pie")[2].getAttribute("d"), center.x, center.y, ); expect(cssLangPercent).toBeCloseTo(20); expect(HTMLLangPercent + javascriptLangPercent + cssLangPercent).toBe(100); // Should render full pie (circle) if one language is 100%. document.body.innerHTML = renderTopLanguages( { HTML: langs.HTML }, { layout: "pie" }, ); expect(queryAllByTestId(document.body, "lang-name")[0]).toHaveTextContent( "HTML 100.00%", ); expect(queryAllByTestId(document.body, "lang-pie")[0]).toHaveAttribute( "size", "100", ); expect(queryAllByTestId(document.body, "lang-pie")).toHaveLength(1); expect(queryAllByTestId(document.body, "lang-pie")[0].tagName).toBe( "circle", ); }); it("should render a translated title", () => { document.body.innerHTML = renderTopLanguages(langs, { locale: "cn" }); expect(document.getElementsByClassName("header")[0].textContent).toBe( "最常用的语言", ); }); it("should render without rounding", () => { document.body.innerHTML = renderTopLanguages(langs, { border_radius: "0" }); expect(document.querySelector("rect")).toHaveAttribute("rx", "0"); document.body.innerHTML = renderTopLanguages(langs, {}); expect(document.querySelector("rect")).toHaveAttribute("rx", "4.5"); }); it("should render langs with specified langs_count", () => { const options = { langs_count: 1, }; document.body.innerHTML = renderTopLanguages(langs, { ...options }); expect(queryAllByTestId(document.body, "lang-name").length).toBe( options.langs_count, ); }); it("should render langs with specified langs_count even when hide is set", () => { const options = { hide: ["HTML"], langs_count: 2, }; document.body.innerHTML = renderTopLanguages(langs, { ...options }); expect(queryAllByTestId(document.body, "lang-name").length).toBe( options.langs_count, ); }); it('should show "No languages data." message instead of empty card when nothing to show', () => { document.body.innerHTML = renderTopLanguages({}); expect(document.querySelector(".stat").textContent).toBe( "No languages data.", ); }); it("should show proper stats format", () => { document.body.innerHTML = renderTopLanguages(langs, { layout: "compact", stats_format: "percentages", }); expect(queryAllByTestId(document.body, "lang-name")[0]).toHaveTextContent( "HTML 40.00%", ); expect(queryAllByTestId(document.body, "lang-name")[1]).toHaveTextContent( "javascript 40.00%", ); expect(queryAllByTestId(document.body, "lang-name")[2]).toHaveTextContent( "css 20.00%", ); document.body.innerHTML = renderTopLanguages(langs, { layout: "compact", stats_format: "bytes", }); expect(queryAllByTestId(document.body, "lang-name")[0]).toHaveTextContent( "HTML 200.0 B", ); expect(queryAllByTestId(document.body, "lang-name")[1]).toHaveTextContent( "javascript 200.0 B", ); expect(queryAllByTestId(document.body, "lang-name")[2]).toHaveTextContent( "css 100.0 B", ); }); }); ================================================ FILE: tests/renderWakatimeCard.test.js ================================================ import { describe, expect, it } from "@jest/globals"; import { queryByTestId } from "@testing-library/dom"; import "@testing-library/jest-dom"; import { renderWakatimeCard } from "../src/cards/wakatime.js"; import { wakaTimeData } from "./fetchWakatime.test.js"; describe("Test Render WakaTime Card", () => { it("should render correctly", () => { const card = renderWakatimeCard(wakaTimeData.data); expect(card).toMatchSnapshot(); }); it("should render correctly with compact layout", () => { const card = renderWakatimeCard(wakaTimeData.data, { layout: "compact" }); expect(card).toMatchSnapshot(); }); it("should render correctly with compact layout when langs_count is set", () => { const card = renderWakatimeCard(wakaTimeData.data, { layout: "compact", langs_count: 2, }); expect(card).toMatchSnapshot(); }); it("should hide languages when hide is passed", () => { document.body.innerHTML = renderWakatimeCard(wakaTimeData.data, { hide: ["YAML", "Other"], }); expect(queryByTestId(document.body, /YAML/i)).toBeNull(); expect(queryByTestId(document.body, /Other/i)).toBeNull(); expect(queryByTestId(document.body, /TypeScript/i)).not.toBeNull(); }); it("should render translations", () => { document.body.innerHTML = renderWakatimeCard({}, { locale: "cn" }); expect(document.getElementsByClassName("header")[0].textContent).toBe( "WakaTime 周统计", ); expect( document.querySelector('g[transform="translate(0, 0)"]>text.stat.bold') .textContent, ).toBe("WakaTime 用户个人资料未公开"); }); it("should render without rounding", () => { document.body.innerHTML = renderWakatimeCard(wakaTimeData.data, { border_radius: "0", }); expect(document.querySelector("rect")).toHaveAttribute("rx", "0"); document.body.innerHTML = renderWakatimeCard(wakaTimeData.data, {}); expect(document.querySelector("rect")).toHaveAttribute("rx", "4.5"); }); it('should show "no coding activity this week" message when there has not been activity', () => { document.body.innerHTML = renderWakatimeCard( { ...wakaTimeData.data, languages: undefined, }, {}, ); expect(document.querySelector(".stat").textContent).toBe( "No coding activity this week", ); }); it('should show "no coding activity this week" message when using compact layout and there has not been activity', () => { document.body.innerHTML = renderWakatimeCard( { ...wakaTimeData.data, languages: undefined, }, { layout: "compact", }, ); expect(document.querySelector(".stat").textContent).toBe( "No coding activity this week", ); }); it("should render correctly with percent display format", () => { const card = renderWakatimeCard(wakaTimeData.data, { display_format: "percent", }); expect(card).toMatchSnapshot(); }); }); ================================================ FILE: tests/retryer.test.js ================================================ // @ts-check import { describe, expect, it, jest } from "@jest/globals"; import "@testing-library/jest-dom"; import { RETRIES, retryer } from "../src/common/retryer.js"; import { logger } from "../src/common/log.js"; const fetcher = jest.fn((variables, token) => { logger.log(variables, token); return new Promise((res) => res({ data: "ok" })); }); const fetcherFail = jest.fn(() => { return new Promise((res) => res({ data: { errors: [{ type: "RATE_LIMITED" }] } }), ); }); const fetcherFailOnSecondTry = jest.fn((_vars, _token, retries) => { return new Promise((res) => { // faking rate limit // @ts-ignore if (retries < 1) { return res({ data: { errors: [{ type: "RATE_LIMITED" }] } }); } return res({ data: "ok" }); }); }); const fetcherFailWithMessageBasedRateLimitErr = jest.fn( (_vars, _token, retries) => { return new Promise((res) => { // faking rate limit // @ts-ignore if (retries < 1) { return res({ data: { errors: [ { type: "ASDF", message: "API rate limit already exceeded for user ID 11111111", }, ], }, }); } return res({ data: "ok" }); }); }, ); describe("Test Retryer", () => { it("retryer should return value and have zero retries on first try", async () => { let res = await retryer(fetcher, {}); expect(fetcher).toHaveBeenCalledTimes(1); expect(res).toStrictEqual({ data: "ok" }); }); it("retryer should return value and have 2 retries", async () => { let res = await retryer(fetcherFailOnSecondTry, {}); expect(fetcherFailOnSecondTry).toHaveBeenCalledTimes(2); expect(res).toStrictEqual({ data: "ok" }); }); it("retryer should return value and have 2 retries with message based rate limit error", async () => { let res = await retryer(fetcherFailWithMessageBasedRateLimitErr, {}); expect(fetcherFailWithMessageBasedRateLimitErr).toHaveBeenCalledTimes(2); expect(res).toStrictEqual({ data: "ok" }); }); it("retryer should throw specific error if maximum retries reached", async () => { try { await retryer(fetcherFail, {}); } catch (err) { expect(fetcherFail).toHaveBeenCalledTimes(RETRIES + 1); // @ts-ignore expect(err.message).toBe("Downtime due to GitHub API rate limiting"); } }); }); ================================================ FILE: tests/status.up.test.js ================================================ /** * @file Tests for the status/up cloud function. */ import { afterEach, describe, expect, it, jest } from "@jest/globals"; import axios from "axios"; import MockAdapter from "axios-mock-adapter"; import up, { RATE_LIMIT_SECONDS } from "../api/status/up.js"; const mock = new MockAdapter(axios); const successData = { rateLimit: { remaining: 4986, }, }; const faker = (query) => { const req = { query: { ...query }, }; const res = { setHeader: jest.fn(), send: jest.fn(), }; return { req, res }; }; const rate_limit_error = { errors: [ { type: "RATE_LIMITED", }, ], }; const bad_credentials_error = { message: "Bad credentials", }; const shields_up = { schemaVersion: 1, label: "Public Instance", isError: true, message: "up", color: "brightgreen", }; const shields_down = { schemaVersion: 1, label: "Public Instance", isError: true, message: "down", color: "red", }; afterEach(() => { mock.reset(); }); describe("Test /api/status/up", () => { it("should return `true` if request was successful", async () => { mock.onPost("https://api.github.com/graphql").replyOnce(200, successData); const { req, res } = faker({}, {}); await up(req, res); expect(res.setHeader).toHaveBeenCalledWith( "Content-Type", "application/json", ); expect(res.send).toHaveBeenCalledWith(true); }); it("should return `false` if all PATs are rate limited", async () => { mock.onPost("https://api.github.com/graphql").reply(200, rate_limit_error); const { req, res } = faker({}, {}); await up(req, res); expect(res.setHeader).toHaveBeenCalledWith( "Content-Type", "application/json", ); expect(res.send).toHaveBeenCalledWith(false); }); it("should return JSON `true` if request was successful and type='json'", async () => { mock.onPost("https://api.github.com/graphql").replyOnce(200, successData); const { req, res } = faker({ type: "json" }, {}); await up(req, res); expect(res.setHeader).toHaveBeenCalledWith( "Content-Type", "application/json", ); expect(res.send).toHaveBeenCalledWith({ up: true }); }); it("should return JSON `false` if all PATs are rate limited and type='json'", async () => { mock.onPost("https://api.github.com/graphql").reply(200, rate_limit_error); const { req, res } = faker({ type: "json" }, {}); await up(req, res); expect(res.setHeader).toHaveBeenCalledWith( "Content-Type", "application/json", ); expect(res.send).toHaveBeenCalledWith({ up: false }); }); it("should return UP shields.io config if request was successful and type='shields'", async () => { mock.onPost("https://api.github.com/graphql").replyOnce(200, successData); const { req, res } = faker({ type: "shields" }, {}); await up(req, res); expect(res.setHeader).toHaveBeenCalledWith( "Content-Type", "application/json", ); expect(res.send).toHaveBeenCalledWith(shields_up); }); it("should return DOWN shields.io config if all PATs are rate limited and type='shields'", async () => { mock.onPost("https://api.github.com/graphql").reply(200, rate_limit_error); const { req, res } = faker({ type: "shields" }, {}); await up(req, res); expect(res.setHeader).toHaveBeenCalledWith( "Content-Type", "application/json", ); expect(res.send).toHaveBeenCalledWith(shields_down); }); it("should return `true` if the first PAT is rate limited but the second PATs works", async () => { mock .onPost("https://api.github.com/graphql") .replyOnce(200, rate_limit_error) .onPost("https://api.github.com/graphql") .replyOnce(200, successData); const { req, res } = faker({}, {}); await up(req, res); expect(res.setHeader).toHaveBeenCalledWith( "Content-Type", "application/json", ); expect(res.send).toHaveBeenCalledWith(true); }); it("should return `true` if the first PAT has 'Bad credentials' but the second PAT works", async () => { mock .onPost("https://api.github.com/graphql") .replyOnce(404, bad_credentials_error) .onPost("https://api.github.com/graphql") .replyOnce(200, successData); const { req, res } = faker({}, {}); await up(req, res); expect(res.setHeader).toHaveBeenCalledWith( "Content-Type", "application/json", ); expect(res.send).toHaveBeenCalledWith(true); }); it("should return `false` if all pats have 'Bad credentials'", async () => { mock .onPost("https://api.github.com/graphql") .reply(404, bad_credentials_error); const { req, res } = faker({}, {}); await up(req, res); expect(res.setHeader).toHaveBeenCalledWith( "Content-Type", "application/json", ); expect(res.send).toHaveBeenCalledWith(false); }); it("should throw an error if the request fails", async () => { mock.onPost("https://api.github.com/graphql").networkError(); const { req, res } = faker({}, {}); await up(req, res); expect(res.setHeader).toHaveBeenCalledWith( "Content-Type", "application/json", ); expect(res.send).toHaveBeenCalledWith(false); }); it("should have proper cache when no error is thrown", async () => { mock.onPost("https://api.github.com/graphql").replyOnce(200, successData); const { req, res } = faker({}, {}); await up(req, res); expect(res.setHeader.mock.calls).toEqual([ ["Content-Type", "application/json"], ["Cache-Control", `max-age=0, s-maxage=${RATE_LIMIT_SECONDS}`], ]); }); it("should have proper cache when error is thrown", async () => { mock.onPost("https://api.github.com/graphql").networkError(); const { req, res } = faker({}, {}); await up(req, res); expect(res.setHeader.mock.calls).toEqual([ ["Content-Type", "application/json"], ["Cache-Control", "no-store"], ]); }); }); ================================================ FILE: tests/top-langs.test.js ================================================ // @ts-check import { afterEach, describe, expect, it, jest } from "@jest/globals"; import "@testing-library/jest-dom"; import axios from "axios"; import MockAdapter from "axios-mock-adapter"; import topLangs from "../api/top-langs.js"; import { renderTopLanguages } from "../src/cards/top-languages.js"; import { renderError } from "../src/common/render.js"; import { CACHE_TTL, DURATIONS } from "../src/common/cache.js"; const data_langs = { data: { user: { repositories: { nodes: [ { languages: { edges: [{ size: 150, node: { color: "#0f0", name: "HTML" } }], }, }, { languages: { edges: [{ size: 100, node: { color: "#0f0", name: "HTML" } }], }, }, { languages: { edges: [ { size: 100, node: { color: "#0ff", name: "javascript" } }, ], }, }, { languages: { edges: [ { size: 100, node: { color: "#0ff", name: "javascript" } }, ], }, }, ], }, }, }, }; const error = { errors: [ { type: "NOT_FOUND", path: ["user"], locations: [], message: "Could not fetch user", }, ], }; const langs = { HTML: { color: "#0f0", name: "HTML", size: 250, }, javascript: { color: "#0ff", name: "javascript", size: 200, }, }; const mock = new MockAdapter(axios); afterEach(() => { mock.reset(); }); describe("Test /api/top-langs", () => { it("should test the request", async () => { const req = { query: { username: "anuraghazra", }, }; const res = { setHeader: jest.fn(), send: jest.fn(), }; mock.onPost("https://api.github.com/graphql").reply(200, data_langs); await topLangs(req, res); expect(res.setHeader).toHaveBeenCalledWith("Content-Type", "image/svg+xml"); expect(res.send).toHaveBeenCalledWith(renderTopLanguages(langs)); }); it("should work with the query options", async () => { const req = { query: { username: "anuraghazra", hide_title: true, card_width: 100, title_color: "fff", icon_color: "fff", text_color: "fff", bg_color: "fff", }, }; const res = { setHeader: jest.fn(), send: jest.fn(), }; mock.onPost("https://api.github.com/graphql").reply(200, data_langs); await topLangs(req, res); expect(res.setHeader).toHaveBeenCalledWith("Content-Type", "image/svg+xml"); expect(res.send).toHaveBeenCalledWith( renderTopLanguages(langs, { hide_title: true, card_width: 100, title_color: "fff", icon_color: "fff", text_color: "fff", bg_color: "fff", }), ); }); it("should render error card on user data fetch error", async () => { const req = { query: { username: "anuraghazra", }, }; const res = { setHeader: jest.fn(), send: jest.fn(), }; mock.onPost("https://api.github.com/graphql").reply(200, error); await topLangs(req, res); expect(res.setHeader).toHaveBeenCalledWith("Content-Type", "image/svg+xml"); expect(res.send).toHaveBeenCalledWith( renderError({ message: error.errors[0].message, secondaryMessage: "Make sure the provided username is not an organization", }), ); }); it("should render error card on incorrect layout input", async () => { const req = { query: { username: "anuraghazra", layout: ["pie"], }, }; const res = { setHeader: jest.fn(), send: jest.fn(), }; mock.onPost("https://api.github.com/graphql").reply(200, data_langs); await topLangs(req, res); expect(res.setHeader).toHaveBeenCalledWith("Content-Type", "image/svg+xml"); expect(res.send).toHaveBeenCalledWith( renderError({ message: "Something went wrong", secondaryMessage: "Incorrect layout input", }), ); }); it("should render error card if username in blacklist", async () => { const req = { query: { username: "renovate-bot", }, }; const res = { setHeader: jest.fn(), send: jest.fn(), }; mock.onPost("https://api.github.com/graphql").reply(200, data_langs); await topLangs(req, res); expect(res.setHeader).toHaveBeenCalledWith("Content-Type", "image/svg+xml"); expect(res.send).toHaveBeenCalledWith( renderError({ message: "This username is blacklisted", secondaryMessage: "Please deploy your own instance", renderOptions: { show_repo_link: false }, }), ); }); it("should render error card if wrong locale provided", async () => { const req = { query: { username: "anuraghazra", locale: "asdf", }, }; const res = { setHeader: jest.fn(), send: jest.fn(), }; mock.onPost("https://api.github.com/graphql").reply(200, data_langs); await topLangs(req, res); expect(res.setHeader).toHaveBeenCalledWith("Content-Type", "image/svg+xml"); expect(res.send).toHaveBeenCalledWith( renderError({ message: "Something went wrong", secondaryMessage: "Locale not found", }), ); }); it("should have proper cache", async () => { const req = { query: { username: "anuraghazra", }, }; const res = { setHeader: jest.fn(), send: jest.fn(), }; mock.onPost("https://api.github.com/graphql").reply(200, data_langs); await topLangs(req, res); expect(res.setHeader).toHaveBeenCalledWith("Content-Type", "image/svg+xml"); expect(res.setHeader).toHaveBeenCalledWith( "Cache-Control", `max-age=${CACHE_TTL.TOP_LANGS_CARD.DEFAULT}, ` + `s-maxage=${CACHE_TTL.TOP_LANGS_CARD.DEFAULT}, ` + `stale-while-revalidate=${DURATIONS.ONE_DAY}`, ); }); }); ================================================ FILE: tests/wakatime.test.js ================================================ import { afterEach, describe, expect, it, jest } from "@jest/globals"; import "@testing-library/jest-dom"; import axios from "axios"; import MockAdapter from "axios-mock-adapter"; import wakatime from "../api/wakatime.js"; import { renderWakatimeCard } from "../src/cards/wakatime.js"; import { CACHE_TTL, DURATIONS } from "../src/common/cache.js"; const wakaTimeData = { data: { categories: [ { digital: "22:40", hours: 22, minutes: 40, name: "Coding", percent: 100, text: "22 hrs 40 mins", total_seconds: 81643.570077, }, ], daily_average: 16095, daily_average_including_other_language: 16329, days_including_holidays: 7, days_minus_holidays: 5, editors: [ { digital: "22:40", hours: 22, minutes: 40, name: "VS Code", percent: 100, text: "22 hrs 40 mins", total_seconds: 81643.570077, }, ], holidays: 2, human_readable_daily_average: "4 hrs 28 mins", human_readable_daily_average_including_other_language: "4 hrs 32 mins", human_readable_total: "22 hrs 21 mins", human_readable_total_including_other_language: "22 hrs 40 mins", id: "random hash", is_already_updating: false, is_coding_activity_visible: true, is_including_today: false, is_other_usage_visible: true, is_stuck: false, is_up_to_date: true, languages: [ { digital: "0:19", hours: 0, minutes: 19, name: "Other", percent: 1.43, text: "19 mins", total_seconds: 1170.434361, }, { digital: "0:01", hours: 0, minutes: 1, name: "TypeScript", percent: 0.1, text: "1 min", total_seconds: 83.293809, }, { digital: "0:00", hours: 0, minutes: 0, name: "YAML", percent: 0.07, text: "0 secs", total_seconds: 54.975151, }, ], operating_systems: [ { digital: "22:40", hours: 22, minutes: 40, name: "Mac", percent: 100, text: "22 hrs 40 mins", total_seconds: 81643.570077, }, ], percent_calculated: 100, range: "last_7_days", status: "ok", timeout: 15, total_seconds: 80473.135716, total_seconds_including_other_language: 81643.570077, user_id: "random hash", username: "anuraghazra", writes_only: false, }, }; const mock = new MockAdapter(axios); afterEach(() => { mock.reset(); }); describe("Test /api/wakatime", () => { it("should test the request", async () => { const username = "anuraghazra"; const req = { query: { username } }; const res = { setHeader: jest.fn(), send: jest.fn() }; mock .onGet( `https://wakatime.com/api/v1/users/${username}/stats?is_including_today=true`, ) .reply(200, wakaTimeData); await wakatime(req, res); expect(res.setHeader).toHaveBeenCalledWith("Content-Type", "image/svg+xml"); expect(res.send).toHaveBeenCalledWith( renderWakatimeCard(wakaTimeData.data, {}), ); }); it("should have proper cache", async () => { const username = "anuraghazra"; const req = { query: { username } }; const res = { setHeader: jest.fn(), send: jest.fn() }; mock .onGet( `https://wakatime.com/api/v1/users/${username}/stats?is_including_today=true`, ) .reply(200, wakaTimeData); await wakatime(req, res); expect(res.setHeader).toHaveBeenCalledWith("Content-Type", "image/svg+xml"); expect(res.setHeader).toHaveBeenCalledWith( "Cache-Control", `max-age=${CACHE_TTL.WAKATIME_CARD.DEFAULT}, ` + `s-maxage=${CACHE_TTL.WAKATIME_CARD.DEFAULT}, ` + `stale-while-revalidate=${DURATIONS.ONE_DAY}`, ); }); }); ================================================ FILE: themes/README.md ================================================ ## Available Themes With inbuilt themes, you can customize the look of the card without doing any manual customization. Use `?theme=THEME_NAME` parameter like so: ```md ![Anurag's GitHub stats](https://github-readme-stats.vercel.app/api?username=anuraghazra&theme=dark&show_icons=true) ``` ## Stats > These themes work with all five of our cards: Stats Card, Repo Card, Gist Card, Top Languages Card, and WakaTime Card. | | | | | :--: | :--: | :--: | | `default` ![default][default] | `transparent` ![transparent][transparent] | `shadow_red` ![shadow_red][shadow_red] | | `shadow_green` ![shadow_green][shadow_green] | `shadow_blue` ![shadow_blue][shadow_blue] | `dark` ![dark][dark] | | `radical` ![radical][radical] | `merko` ![merko][merko] | `gruvbox` ![gruvbox][gruvbox] | | `gruvbox_light` ![gruvbox_light][gruvbox_light] | `tokyonight` ![tokyonight][tokyonight] | `onedark` ![onedark][onedark] | | `cobalt` ![cobalt][cobalt] | `synthwave` ![synthwave][synthwave] | `highcontrast` ![highcontrast][highcontrast] | | `dracula` ![dracula][dracula] | `prussian` ![prussian][prussian] | `monokai` ![monokai][monokai] | | `vue` ![vue][vue] | `vue-dark` ![vue-dark][vue-dark] | `shades-of-purple` ![shades-of-purple][shades-of-purple] | | `nightowl` ![nightowl][nightowl] | `buefy` ![buefy][buefy] | `blue-green` ![blue-green][blue-green] | | `algolia` ![algolia][algolia] | `great-gatsby` ![great-gatsby][great-gatsby] | `darcula` ![darcula][darcula] | | `bear` ![bear][bear] | `solarized-dark` ![solarized-dark][solarized-dark] | `solarized-light` ![solarized-light][solarized-light] | | `chartreuse-dark` ![chartreuse-dark][chartreuse-dark] | `nord` ![nord][nord] | `gotham` ![gotham][gotham] | | `material-palenight` ![material-palenight][material-palenight] | `graywhite` ![graywhite][graywhite] | `vision-friendly-dark` ![vision-friendly-dark][vision-friendly-dark] | | `ayu-mirage` ![ayu-mirage][ayu-mirage] | `midnight-purple` ![midnight-purple][midnight-purple] | `calm` ![calm][calm] | | `flag-india` ![flag-india][flag-india] | `omni` ![omni][omni] | `react` ![react][react] | | `jolly` ![jolly][jolly] | `maroongold` ![maroongold][maroongold] | `yeblu` ![yeblu][yeblu] | | `blueberry` ![blueberry][blueberry] | `slateorange` ![slateorange][slateorange] | `kacho_ga` ![kacho_ga][kacho_ga] | | `outrun` ![outrun][outrun] | `ocean_dark` ![ocean_dark][ocean_dark] | `city_lights` ![city_lights][city_lights] | | `github_dark` ![github_dark][github_dark] | `github_dark_dimmed` ![github_dark_dimmed][github_dark_dimmed] | `discord_old_blurple` ![discord_old_blurple][discord_old_blurple] | | `aura_dark` ![aura_dark][aura_dark] | `panda` ![panda][panda] | `noctis_minimus` ![noctis_minimus][noctis_minimus] | | `cobalt2` ![cobalt2][cobalt2] | `swift` ![swift][swift] | `aura` ![aura][aura] | | `apprentice` ![apprentice][apprentice] | `moltack` ![moltack][moltack] | `codeSTACKr` ![codeSTACKr][codeSTACKr] | | `rose_pine` ![rose_pine][rose_pine] | `catppuccin_latte` ![catppuccin_latte][catppuccin_latte] | `catppuccin_mocha` ![catppuccin_mocha][catppuccin_mocha] | | `date_night` ![date_night][date_night] | `one_dark_pro` ![one_dark_pro][one_dark_pro] | `rose` ![rose][rose] | | `holi` ![holi][holi] | `neon` ![neon][neon] | `blue_navy` ![blue_navy][blue_navy] | | `calm_pink` ![calm_pink][calm_pink] | `ambient_gradient` ![ambient_gradient][ambient_gradient] | | ## Repo Card > These themes work with all five of our cards: Stats Card, Repo Card, Gist Card, Top Languages Card, and WakaTime Card. | | | | | :--: | :--: | :--: | | `default_repocard` ![default_repocard][default_repocard_repo] | `transparent` ![transparent][transparent_repo] | `shadow_red` ![shadow_red][shadow_red_repo] | | `shadow_green` ![shadow_green][shadow_green_repo] | `shadow_blue` ![shadow_blue][shadow_blue_repo] | `dark` ![dark][dark_repo] | | `radical` ![radical][radical_repo] | `merko` ![merko][merko_repo] | `gruvbox` ![gruvbox][gruvbox_repo] | | `gruvbox_light` ![gruvbox_light][gruvbox_light_repo] | `tokyonight` ![tokyonight][tokyonight_repo] | `onedark` ![onedark][onedark_repo] | | `cobalt` ![cobalt][cobalt_repo] | `synthwave` ![synthwave][synthwave_repo] | `highcontrast` ![highcontrast][highcontrast_repo] | | `dracula` ![dracula][dracula_repo] | `prussian` ![prussian][prussian_repo] | `monokai` ![monokai][monokai_repo] | | `vue` ![vue][vue_repo] | `vue-dark` ![vue-dark][vue-dark_repo] | `shades-of-purple` ![shades-of-purple][shades-of-purple_repo] | | `nightowl` ![nightowl][nightowl_repo] | `buefy` ![buefy][buefy_repo] | `blue-green` ![blue-green][blue-green_repo] | | `algolia` ![algolia][algolia_repo] | `great-gatsby` ![great-gatsby][great-gatsby_repo] | `darcula` ![darcula][darcula_repo] | | `bear` ![bear][bear_repo] | `solarized-dark` ![solarized-dark][solarized-dark_repo] | `solarized-light` ![solarized-light][solarized-light_repo] | | `chartreuse-dark` ![chartreuse-dark][chartreuse-dark_repo] | `nord` ![nord][nord_repo] | `gotham` ![gotham][gotham_repo] | | `material-palenight` ![material-palenight][material-palenight_repo] | `graywhite` ![graywhite][graywhite_repo] | `vision-friendly-dark` ![vision-friendly-dark][vision-friendly-dark_repo] | | `ayu-mirage` ![ayu-mirage][ayu-mirage_repo] | `midnight-purple` ![midnight-purple][midnight-purple_repo] | `calm` ![calm][calm_repo] | | `flag-india` ![flag-india][flag-india_repo] | `omni` ![omni][omni_repo] | `react` ![react][react_repo] | | `jolly` ![jolly][jolly_repo] | `maroongold` ![maroongold][maroongold_repo] | `yeblu` ![yeblu][yeblu_repo] | | `blueberry` ![blueberry][blueberry_repo] | `slateorange` ![slateorange][slateorange_repo] | `kacho_ga` ![kacho_ga][kacho_ga_repo] | | `outrun` ![outrun][outrun_repo] | `ocean_dark` ![ocean_dark][ocean_dark_repo] | `city_lights` ![city_lights][city_lights_repo] | | `github_dark` ![github_dark][github_dark_repo] | `github_dark_dimmed` ![github_dark_dimmed][github_dark_dimmed_repo] | `discord_old_blurple` ![discord_old_blurple][discord_old_blurple_repo] | | `aura_dark` ![aura_dark][aura_dark_repo] | `panda` ![panda][panda_repo] | `noctis_minimus` ![noctis_minimus][noctis_minimus_repo] | | `cobalt2` ![cobalt2][cobalt2_repo] | `swift` ![swift][swift_repo] | `aura` ![aura][aura_repo] | | `apprentice` ![apprentice][apprentice_repo] | `moltack` ![moltack][moltack_repo] | `codeSTACKr` ![codeSTACKr][codeSTACKr_repo] | | `rose_pine` ![rose_pine][rose_pine_repo] | `catppuccin_latte` ![catppuccin_latte][catppuccin_latte_repo] | `catppuccin_mocha` ![catppuccin_mocha][catppuccin_mocha_repo] | | `date_night` ![date_night][date_night_repo] | `one_dark_pro` ![one_dark_pro][one_dark_pro_repo] | `rose` ![rose][rose_repo] | | `holi` ![holi][holi_repo] | `neon` ![neon][neon_repo] | `blue_navy` ![blue_navy][blue_navy_repo] | | `calm_pink` ![calm_pink][calm_pink_repo] | `ambient_gradient` ![ambient_gradient][ambient_gradient_repo] | | [default]: https://github-readme-stats.vercel.app/api?username=anuraghazra&show_icons=true&hide=contribs,prs&cache_seconds=86400&theme=default [default_repocard]: https://github-readme-stats.vercel.app/api?username=anuraghazra&show_icons=true&hide=contribs,prs&cache_seconds=86400&theme=default_repocard [transparent]: https://github-readme-stats.vercel.app/api?username=anuraghazra&show_icons=true&hide=contribs,prs&cache_seconds=86400&theme=transparent [shadow_red]: https://github-readme-stats.vercel.app/api?username=anuraghazra&show_icons=true&hide=contribs,prs&cache_seconds=86400&theme=shadow_red [shadow_green]: https://github-readme-stats.vercel.app/api?username=anuraghazra&show_icons=true&hide=contribs,prs&cache_seconds=86400&theme=shadow_green [shadow_blue]: https://github-readme-stats.vercel.app/api?username=anuraghazra&show_icons=true&hide=contribs,prs&cache_seconds=86400&theme=shadow_blue [dark]: https://github-readme-stats.vercel.app/api?username=anuraghazra&show_icons=true&hide=contribs,prs&cache_seconds=86400&theme=dark [radical]: https://github-readme-stats.vercel.app/api?username=anuraghazra&show_icons=true&hide=contribs,prs&cache_seconds=86400&theme=radical [merko]: https://github-readme-stats.vercel.app/api?username=anuraghazra&show_icons=true&hide=contribs,prs&cache_seconds=86400&theme=merko [gruvbox]: https://github-readme-stats.vercel.app/api?username=anuraghazra&show_icons=true&hide=contribs,prs&cache_seconds=86400&theme=gruvbox [gruvbox_light]: https://github-readme-stats.vercel.app/api?username=anuraghazra&show_icons=true&hide=contribs,prs&cache_seconds=86400&theme=gruvbox_light [tokyonight]: https://github-readme-stats.vercel.app/api?username=anuraghazra&show_icons=true&hide=contribs,prs&cache_seconds=86400&theme=tokyonight [onedark]: https://github-readme-stats.vercel.app/api?username=anuraghazra&show_icons=true&hide=contribs,prs&cache_seconds=86400&theme=onedark [cobalt]: https://github-readme-stats.vercel.app/api?username=anuraghazra&show_icons=true&hide=contribs,prs&cache_seconds=86400&theme=cobalt [synthwave]: https://github-readme-stats.vercel.app/api?username=anuraghazra&show_icons=true&hide=contribs,prs&cache_seconds=86400&theme=synthwave [highcontrast]: https://github-readme-stats.vercel.app/api?username=anuraghazra&show_icons=true&hide=contribs,prs&cache_seconds=86400&theme=highcontrast [dracula]: https://github-readme-stats.vercel.app/api?username=anuraghazra&show_icons=true&hide=contribs,prs&cache_seconds=86400&theme=dracula [prussian]: https://github-readme-stats.vercel.app/api?username=anuraghazra&show_icons=true&hide=contribs,prs&cache_seconds=86400&theme=prussian [monokai]: https://github-readme-stats.vercel.app/api?username=anuraghazra&show_icons=true&hide=contribs,prs&cache_seconds=86400&theme=monokai [vue]: https://github-readme-stats.vercel.app/api?username=anuraghazra&show_icons=true&hide=contribs,prs&cache_seconds=86400&theme=vue [vue-dark]: https://github-readme-stats.vercel.app/api?username=anuraghazra&show_icons=true&hide=contribs,prs&cache_seconds=86400&theme=vue-dark [shades-of-purple]: https://github-readme-stats.vercel.app/api?username=anuraghazra&show_icons=true&hide=contribs,prs&cache_seconds=86400&theme=shades-of-purple [nightowl]: https://github-readme-stats.vercel.app/api?username=anuraghazra&show_icons=true&hide=contribs,prs&cache_seconds=86400&theme=nightowl [buefy]: https://github-readme-stats.vercel.app/api?username=anuraghazra&show_icons=true&hide=contribs,prs&cache_seconds=86400&theme=buefy [blue-green]: https://github-readme-stats.vercel.app/api?username=anuraghazra&show_icons=true&hide=contribs,prs&cache_seconds=86400&theme=blue-green [algolia]: https://github-readme-stats.vercel.app/api?username=anuraghazra&show_icons=true&hide=contribs,prs&cache_seconds=86400&theme=algolia [great-gatsby]: https://github-readme-stats.vercel.app/api?username=anuraghazra&show_icons=true&hide=contribs,prs&cache_seconds=86400&theme=great-gatsby [darcula]: https://github-readme-stats.vercel.app/api?username=anuraghazra&show_icons=true&hide=contribs,prs&cache_seconds=86400&theme=darcula [bear]: https://github-readme-stats.vercel.app/api?username=anuraghazra&show_icons=true&hide=contribs,prs&cache_seconds=86400&theme=bear [solarized-dark]: https://github-readme-stats.vercel.app/api?username=anuraghazra&show_icons=true&hide=contribs,prs&cache_seconds=86400&theme=solarized-dark [solarized-light]: https://github-readme-stats.vercel.app/api?username=anuraghazra&show_icons=true&hide=contribs,prs&cache_seconds=86400&theme=solarized-light [chartreuse-dark]: https://github-readme-stats.vercel.app/api?username=anuraghazra&show_icons=true&hide=contribs,prs&cache_seconds=86400&theme=chartreuse-dark [nord]: https://github-readme-stats.vercel.app/api?username=anuraghazra&show_icons=true&hide=contribs,prs&cache_seconds=86400&theme=nord [gotham]: https://github-readme-stats.vercel.app/api?username=anuraghazra&show_icons=true&hide=contribs,prs&cache_seconds=86400&theme=gotham [material-palenight]: https://github-readme-stats.vercel.app/api?username=anuraghazra&show_icons=true&hide=contribs,prs&cache_seconds=86400&theme=material-palenight [graywhite]: https://github-readme-stats.vercel.app/api?username=anuraghazra&show_icons=true&hide=contribs,prs&cache_seconds=86400&theme=graywhite [vision-friendly-dark]: https://github-readme-stats.vercel.app/api?username=anuraghazra&show_icons=true&hide=contribs,prs&cache_seconds=86400&theme=vision-friendly-dark [ayu-mirage]: https://github-readme-stats.vercel.app/api?username=anuraghazra&show_icons=true&hide=contribs,prs&cache_seconds=86400&theme=ayu-mirage [midnight-purple]: https://github-readme-stats.vercel.app/api?username=anuraghazra&show_icons=true&hide=contribs,prs&cache_seconds=86400&theme=midnight-purple [calm]: https://github-readme-stats.vercel.app/api?username=anuraghazra&show_icons=true&hide=contribs,prs&cache_seconds=86400&theme=calm [flag-india]: https://github-readme-stats.vercel.app/api?username=anuraghazra&show_icons=true&hide=contribs,prs&cache_seconds=86400&theme=flag-india [omni]: https://github-readme-stats.vercel.app/api?username=anuraghazra&show_icons=true&hide=contribs,prs&cache_seconds=86400&theme=omni [react]: https://github-readme-stats.vercel.app/api?username=anuraghazra&show_icons=true&hide=contribs,prs&cache_seconds=86400&theme=react [jolly]: https://github-readme-stats.vercel.app/api?username=anuraghazra&show_icons=true&hide=contribs,prs&cache_seconds=86400&theme=jolly [maroongold]: https://github-readme-stats.vercel.app/api?username=anuraghazra&show_icons=true&hide=contribs,prs&cache_seconds=86400&theme=maroongold [yeblu]: https://github-readme-stats.vercel.app/api?username=anuraghazra&show_icons=true&hide=contribs,prs&cache_seconds=86400&theme=yeblu [blueberry]: https://github-readme-stats.vercel.app/api?username=anuraghazra&show_icons=true&hide=contribs,prs&cache_seconds=86400&theme=blueberry [slateorange]: https://github-readme-stats.vercel.app/api?username=anuraghazra&show_icons=true&hide=contribs,prs&cache_seconds=86400&theme=slateorange [kacho_ga]: https://github-readme-stats.vercel.app/api?username=anuraghazra&show_icons=true&hide=contribs,prs&cache_seconds=86400&theme=kacho_ga [outrun]: https://github-readme-stats.vercel.app/api?username=anuraghazra&show_icons=true&hide=contribs,prs&cache_seconds=86400&theme=outrun [ocean_dark]: https://github-readme-stats.vercel.app/api?username=anuraghazra&show_icons=true&hide=contribs,prs&cache_seconds=86400&theme=ocean_dark [city_lights]: https://github-readme-stats.vercel.app/api?username=anuraghazra&show_icons=true&hide=contribs,prs&cache_seconds=86400&theme=city_lights [github_dark]: https://github-readme-stats.vercel.app/api?username=anuraghazra&show_icons=true&hide=contribs,prs&cache_seconds=86400&theme=github_dark [github_dark_dimmed]: https://github-readme-stats.vercel.app/api?username=anuraghazra&show_icons=true&hide=contribs,prs&cache_seconds=86400&theme=github_dark_dimmed [discord_old_blurple]: https://github-readme-stats.vercel.app/api?username=anuraghazra&show_icons=true&hide=contribs,prs&cache_seconds=86400&theme=discord_old_blurple [aura_dark]: https://github-readme-stats.vercel.app/api?username=anuraghazra&show_icons=true&hide=contribs,prs&cache_seconds=86400&theme=aura_dark [panda]: https://github-readme-stats.vercel.app/api?username=anuraghazra&show_icons=true&hide=contribs,prs&cache_seconds=86400&theme=panda [noctis_minimus]: https://github-readme-stats.vercel.app/api?username=anuraghazra&show_icons=true&hide=contribs,prs&cache_seconds=86400&theme=noctis_minimus [cobalt2]: https://github-readme-stats.vercel.app/api?username=anuraghazra&show_icons=true&hide=contribs,prs&cache_seconds=86400&theme=cobalt2 [swift]: https://github-readme-stats.vercel.app/api?username=anuraghazra&show_icons=true&hide=contribs,prs&cache_seconds=86400&theme=swift [aura]: https://github-readme-stats.vercel.app/api?username=anuraghazra&show_icons=true&hide=contribs,prs&cache_seconds=86400&theme=aura [apprentice]: https://github-readme-stats.vercel.app/api?username=anuraghazra&show_icons=true&hide=contribs,prs&cache_seconds=86400&theme=apprentice [moltack]: https://github-readme-stats.vercel.app/api?username=anuraghazra&show_icons=true&hide=contribs,prs&cache_seconds=86400&theme=moltack [codeSTACKr]: https://github-readme-stats.vercel.app/api?username=anuraghazra&show_icons=true&hide=contribs,prs&cache_seconds=86400&theme=codeSTACKr [rose_pine]: https://github-readme-stats.vercel.app/api?username=anuraghazra&show_icons=true&hide=contribs,prs&cache_seconds=86400&theme=rose_pine [catppuccin_latte]: https://github-readme-stats.vercel.app/api?username=anuraghazra&show_icons=true&hide=contribs,prs&cache_seconds=86400&theme=catppuccin_latte [catppuccin_mocha]: https://github-readme-stats.vercel.app/api?username=anuraghazra&show_icons=true&hide=contribs,prs&cache_seconds=86400&theme=catppuccin_mocha [date_night]: https://github-readme-stats.vercel.app/api?username=anuraghazra&show_icons=true&hide=contribs,prs&cache_seconds=86400&theme=date_night [one_dark_pro]: https://github-readme-stats.vercel.app/api?username=anuraghazra&show_icons=true&hide=contribs,prs&cache_seconds=86400&theme=one_dark_pro [rose]: https://github-readme-stats.vercel.app/api?username=anuraghazra&show_icons=true&hide=contribs,prs&cache_seconds=86400&theme=rose [holi]: https://github-readme-stats.vercel.app/api?username=anuraghazra&show_icons=true&hide=contribs,prs&cache_seconds=86400&theme=holi [neon]: https://github-readme-stats.vercel.app/api?username=anuraghazra&show_icons=true&hide=contribs,prs&cache_seconds=86400&theme=neon [blue_navy]: https://github-readme-stats.vercel.app/api?username=anuraghazra&show_icons=true&hide=contribs,prs&cache_seconds=86400&theme=blue_navy [calm_pink]: https://github-readme-stats.vercel.app/api?username=anuraghazra&show_icons=true&hide=contribs,prs&cache_seconds=86400&theme=calm_pink [ambient_gradient]: https://github-readme-stats.vercel.app/api?username=anuraghazra&show_icons=true&hide=contribs,prs&cache_seconds=86400&theme=ambient_gradient [default_repo]: https://github-readme-stats.vercel.app/api/pin/?username=anuraghazra&repo=github-readme-stats&cache_seconds=86400&theme=default [default_repocard_repo]: https://github-readme-stats.vercel.app/api/pin/?username=anuraghazra&repo=github-readme-stats&cache_seconds=86400&theme=default_repocard [transparent_repo]: https://github-readme-stats.vercel.app/api/pin/?username=anuraghazra&repo=github-readme-stats&cache_seconds=86400&theme=transparent [shadow_red_repo]: https://github-readme-stats.vercel.app/api/pin/?username=anuraghazra&repo=github-readme-stats&cache_seconds=86400&theme=shadow_red [shadow_green_repo]: https://github-readme-stats.vercel.app/api/pin/?username=anuraghazra&repo=github-readme-stats&cache_seconds=86400&theme=shadow_green [shadow_blue_repo]: https://github-readme-stats.vercel.app/api/pin/?username=anuraghazra&repo=github-readme-stats&cache_seconds=86400&theme=shadow_blue [dark_repo]: https://github-readme-stats.vercel.app/api/pin/?username=anuraghazra&repo=github-readme-stats&cache_seconds=86400&theme=dark [radical_repo]: https://github-readme-stats.vercel.app/api/pin/?username=anuraghazra&repo=github-readme-stats&cache_seconds=86400&theme=radical [merko_repo]: https://github-readme-stats.vercel.app/api/pin/?username=anuraghazra&repo=github-readme-stats&cache_seconds=86400&theme=merko [gruvbox_repo]: https://github-readme-stats.vercel.app/api/pin/?username=anuraghazra&repo=github-readme-stats&cache_seconds=86400&theme=gruvbox [gruvbox_light_repo]: https://github-readme-stats.vercel.app/api/pin/?username=anuraghazra&repo=github-readme-stats&cache_seconds=86400&theme=gruvbox_light [tokyonight_repo]: https://github-readme-stats.vercel.app/api/pin/?username=anuraghazra&repo=github-readme-stats&cache_seconds=86400&theme=tokyonight [onedark_repo]: https://github-readme-stats.vercel.app/api/pin/?username=anuraghazra&repo=github-readme-stats&cache_seconds=86400&theme=onedark [cobalt_repo]: https://github-readme-stats.vercel.app/api/pin/?username=anuraghazra&repo=github-readme-stats&cache_seconds=86400&theme=cobalt [synthwave_repo]: https://github-readme-stats.vercel.app/api/pin/?username=anuraghazra&repo=github-readme-stats&cache_seconds=86400&theme=synthwave [highcontrast_repo]: https://github-readme-stats.vercel.app/api/pin/?username=anuraghazra&repo=github-readme-stats&cache_seconds=86400&theme=highcontrast [dracula_repo]: https://github-readme-stats.vercel.app/api/pin/?username=anuraghazra&repo=github-readme-stats&cache_seconds=86400&theme=dracula [prussian_repo]: https://github-readme-stats.vercel.app/api/pin/?username=anuraghazra&repo=github-readme-stats&cache_seconds=86400&theme=prussian [monokai_repo]: https://github-readme-stats.vercel.app/api/pin/?username=anuraghazra&repo=github-readme-stats&cache_seconds=86400&theme=monokai [vue_repo]: https://github-readme-stats.vercel.app/api/pin/?username=anuraghazra&repo=github-readme-stats&cache_seconds=86400&theme=vue [vue-dark_repo]: https://github-readme-stats.vercel.app/api/pin/?username=anuraghazra&repo=github-readme-stats&cache_seconds=86400&theme=vue-dark [shades-of-purple_repo]: https://github-readme-stats.vercel.app/api/pin/?username=anuraghazra&repo=github-readme-stats&cache_seconds=86400&theme=shades-of-purple [nightowl_repo]: https://github-readme-stats.vercel.app/api/pin/?username=anuraghazra&repo=github-readme-stats&cache_seconds=86400&theme=nightowl [buefy_repo]: https://github-readme-stats.vercel.app/api/pin/?username=anuraghazra&repo=github-readme-stats&cache_seconds=86400&theme=buefy [blue-green_repo]: https://github-readme-stats.vercel.app/api/pin/?username=anuraghazra&repo=github-readme-stats&cache_seconds=86400&theme=blue-green [algolia_repo]: https://github-readme-stats.vercel.app/api/pin/?username=anuraghazra&repo=github-readme-stats&cache_seconds=86400&theme=algolia [great-gatsby_repo]: https://github-readme-stats.vercel.app/api/pin/?username=anuraghazra&repo=github-readme-stats&cache_seconds=86400&theme=great-gatsby [darcula_repo]: https://github-readme-stats.vercel.app/api/pin/?username=anuraghazra&repo=github-readme-stats&cache_seconds=86400&theme=darcula [bear_repo]: https://github-readme-stats.vercel.app/api/pin/?username=anuraghazra&repo=github-readme-stats&cache_seconds=86400&theme=bear [solarized-dark_repo]: https://github-readme-stats.vercel.app/api/pin/?username=anuraghazra&repo=github-readme-stats&cache_seconds=86400&theme=solarized-dark [solarized-light_repo]: https://github-readme-stats.vercel.app/api/pin/?username=anuraghazra&repo=github-readme-stats&cache_seconds=86400&theme=solarized-light [chartreuse-dark_repo]: https://github-readme-stats.vercel.app/api/pin/?username=anuraghazra&repo=github-readme-stats&cache_seconds=86400&theme=chartreuse-dark [nord_repo]: https://github-readme-stats.vercel.app/api/pin/?username=anuraghazra&repo=github-readme-stats&cache_seconds=86400&theme=nord [gotham_repo]: https://github-readme-stats.vercel.app/api/pin/?username=anuraghazra&repo=github-readme-stats&cache_seconds=86400&theme=gotham [material-palenight_repo]: https://github-readme-stats.vercel.app/api/pin/?username=anuraghazra&repo=github-readme-stats&cache_seconds=86400&theme=material-palenight [graywhite_repo]: https://github-readme-stats.vercel.app/api/pin/?username=anuraghazra&repo=github-readme-stats&cache_seconds=86400&theme=graywhite [vision-friendly-dark_repo]: https://github-readme-stats.vercel.app/api/pin/?username=anuraghazra&repo=github-readme-stats&cache_seconds=86400&theme=vision-friendly-dark [ayu-mirage_repo]: https://github-readme-stats.vercel.app/api/pin/?username=anuraghazra&repo=github-readme-stats&cache_seconds=86400&theme=ayu-mirage [midnight-purple_repo]: https://github-readme-stats.vercel.app/api/pin/?username=anuraghazra&repo=github-readme-stats&cache_seconds=86400&theme=midnight-purple [calm_repo]: https://github-readme-stats.vercel.app/api/pin/?username=anuraghazra&repo=github-readme-stats&cache_seconds=86400&theme=calm [flag-india_repo]: https://github-readme-stats.vercel.app/api/pin/?username=anuraghazra&repo=github-readme-stats&cache_seconds=86400&theme=flag-india [omni_repo]: https://github-readme-stats.vercel.app/api/pin/?username=anuraghazra&repo=github-readme-stats&cache_seconds=86400&theme=omni [react_repo]: https://github-readme-stats.vercel.app/api/pin/?username=anuraghazra&repo=github-readme-stats&cache_seconds=86400&theme=react [jolly_repo]: https://github-readme-stats.vercel.app/api/pin/?username=anuraghazra&repo=github-readme-stats&cache_seconds=86400&theme=jolly [maroongold_repo]: https://github-readme-stats.vercel.app/api/pin/?username=anuraghazra&repo=github-readme-stats&cache_seconds=86400&theme=maroongold [yeblu_repo]: https://github-readme-stats.vercel.app/api/pin/?username=anuraghazra&repo=github-readme-stats&cache_seconds=86400&theme=yeblu [blueberry_repo]: https://github-readme-stats.vercel.app/api/pin/?username=anuraghazra&repo=github-readme-stats&cache_seconds=86400&theme=blueberry [slateorange_repo]: https://github-readme-stats.vercel.app/api/pin/?username=anuraghazra&repo=github-readme-stats&cache_seconds=86400&theme=slateorange [kacho_ga_repo]: https://github-readme-stats.vercel.app/api/pin/?username=anuraghazra&repo=github-readme-stats&cache_seconds=86400&theme=kacho_ga [outrun_repo]: https://github-readme-stats.vercel.app/api/pin/?username=anuraghazra&repo=github-readme-stats&cache_seconds=86400&theme=outrun [ocean_dark_repo]: https://github-readme-stats.vercel.app/api/pin/?username=anuraghazra&repo=github-readme-stats&cache_seconds=86400&theme=ocean_dark [city_lights_repo]: https://github-readme-stats.vercel.app/api/pin/?username=anuraghazra&repo=github-readme-stats&cache_seconds=86400&theme=city_lights [github_dark_repo]: https://github-readme-stats.vercel.app/api/pin/?username=anuraghazra&repo=github-readme-stats&cache_seconds=86400&theme=github_dark [github_dark_dimmed_repo]: https://github-readme-stats.vercel.app/api/pin/?username=anuraghazra&repo=github-readme-stats&cache_seconds=86400&theme=github_dark_dimmed [discord_old_blurple_repo]: https://github-readme-stats.vercel.app/api/pin/?username=anuraghazra&repo=github-readme-stats&cache_seconds=86400&theme=discord_old_blurple [aura_dark_repo]: https://github-readme-stats.vercel.app/api/pin/?username=anuraghazra&repo=github-readme-stats&cache_seconds=86400&theme=aura_dark [panda_repo]: https://github-readme-stats.vercel.app/api/pin/?username=anuraghazra&repo=github-readme-stats&cache_seconds=86400&theme=panda [noctis_minimus_repo]: https://github-readme-stats.vercel.app/api/pin/?username=anuraghazra&repo=github-readme-stats&cache_seconds=86400&theme=noctis_minimus [cobalt2_repo]: https://github-readme-stats.vercel.app/api/pin/?username=anuraghazra&repo=github-readme-stats&cache_seconds=86400&theme=cobalt2 [swift_repo]: https://github-readme-stats.vercel.app/api/pin/?username=anuraghazra&repo=github-readme-stats&cache_seconds=86400&theme=swift [aura_repo]: https://github-readme-stats.vercel.app/api/pin/?username=anuraghazra&repo=github-readme-stats&cache_seconds=86400&theme=aura [apprentice_repo]: https://github-readme-stats.vercel.app/api/pin/?username=anuraghazra&repo=github-readme-stats&cache_seconds=86400&theme=apprentice [moltack_repo]: https://github-readme-stats.vercel.app/api/pin/?username=anuraghazra&repo=github-readme-stats&cache_seconds=86400&theme=moltack [codeSTACKr_repo]: https://github-readme-stats.vercel.app/api/pin/?username=anuraghazra&repo=github-readme-stats&cache_seconds=86400&theme=codeSTACKr [rose_pine_repo]: https://github-readme-stats.vercel.app/api/pin/?username=anuraghazra&repo=github-readme-stats&cache_seconds=86400&theme=rose_pine [catppuccin_latte_repo]: https://github-readme-stats.vercel.app/api/pin/?username=anuraghazra&repo=github-readme-stats&cache_seconds=86400&theme=catppuccin_latte [catppuccin_mocha_repo]: https://github-readme-stats.vercel.app/api/pin/?username=anuraghazra&repo=github-readme-stats&cache_seconds=86400&theme=catppuccin_mocha [date_night_repo]: https://github-readme-stats.vercel.app/api/pin/?username=anuraghazra&repo=github-readme-stats&cache_seconds=86400&theme=date_night [one_dark_pro_repo]: https://github-readme-stats.vercel.app/api/pin/?username=anuraghazra&repo=github-readme-stats&cache_seconds=86400&theme=one_dark_pro [rose_repo]: https://github-readme-stats.vercel.app/api/pin/?username=anuraghazra&repo=github-readme-stats&cache_seconds=86400&theme=rose [holi_repo]: https://github-readme-stats.vercel.app/api/pin/?username=anuraghazra&repo=github-readme-stats&cache_seconds=86400&theme=holi [neon_repo]: https://github-readme-stats.vercel.app/api/pin/?username=anuraghazra&repo=github-readme-stats&cache_seconds=86400&theme=neon [blue_navy_repo]: https://github-readme-stats.vercel.app/api/pin/?username=anuraghazra&repo=github-readme-stats&cache_seconds=86400&theme=blue_navy [calm_pink_repo]: https://github-readme-stats.vercel.app/api/pin/?username=anuraghazra&repo=github-readme-stats&cache_seconds=86400&theme=calm_pink [ambient_gradient_repo]: https://github-readme-stats.vercel.app/api/pin/?username=anuraghazra&repo=github-readme-stats&cache_seconds=86400&theme=ambient_gradient ================================================ FILE: themes/index.js ================================================ export const themes = { default: { title_color: "2f80ed", icon_color: "4c71f2", text_color: "434d58", bg_color: "fffefe", border_color: "e4e2e2", }, default_repocard: { title_color: "2f80ed", icon_color: "586069", // icon color is different text_color: "434d58", bg_color: "fffefe", }, transparent: { title_color: "006AFF", icon_color: "0579C3", text_color: "417E87", bg_color: "ffffff00", }, shadow_red: { title_color: "9A0000", text_color: "444", icon_color: "4F0000", border_color: "4F0000", bg_color: "ffffff00", }, shadow_green: { title_color: "007A00", text_color: "444", icon_color: "003D00", border_color: "003D00", bg_color: "ffffff00", }, shadow_blue: { title_color: "00779A", text_color: "444", icon_color: "004450", border_color: "004490", bg_color: "ffffff00", }, dark: { title_color: "fff", icon_color: "79ff97", text_color: "9f9f9f", bg_color: "151515", }, radical: { title_color: "fe428e", icon_color: "f8d847", text_color: "a9fef7", bg_color: "141321", }, merko: { title_color: "abd200", icon_color: "b7d364", text_color: "68b587", bg_color: "0a0f0b", }, gruvbox: { title_color: "fabd2f", icon_color: "fe8019", text_color: "8ec07c", bg_color: "282828", }, gruvbox_light: { title_color: "b57614", icon_color: "af3a03", text_color: "427b58", bg_color: "fbf1c7", }, tokyonight: { title_color: "70a5fd", icon_color: "bf91f3", text_color: "38bdae", bg_color: "1a1b27", }, onedark: { title_color: "e4bf7a", icon_color: "8eb573", text_color: "df6d74", bg_color: "282c34", }, cobalt: { title_color: "e683d9", icon_color: "0480ef", text_color: "75eeb2", bg_color: "193549", }, synthwave: { title_color: "e2e9ec", icon_color: "ef8539", text_color: "e5289e", bg_color: "2b213a", }, highcontrast: { title_color: "e7f216", icon_color: "00ffff", text_color: "fff", bg_color: "000", }, dracula: { title_color: "ff6e96", icon_color: "79dafa", text_color: "f8f8f2", bg_color: "282a36", }, prussian: { title_color: "bddfff", icon_color: "38a0ff", text_color: "6e93b5", bg_color: "172f45", }, monokai: { title_color: "eb1f6a", icon_color: "e28905", text_color: "f1f1eb", bg_color: "272822", }, vue: { title_color: "41b883", icon_color: "41b883", text_color: "273849", bg_color: "fffefe", }, "vue-dark": { title_color: "41b883", icon_color: "41b883", text_color: "fffefe", bg_color: "273849", }, "shades-of-purple": { title_color: "fad000", icon_color: "b362ff", text_color: "a599e9", bg_color: "2d2b55", }, nightowl: { title_color: "c792ea", icon_color: "ffeb95", text_color: "7fdbca", bg_color: "011627", }, buefy: { title_color: "7957d5", icon_color: "ff3860", text_color: "363636", bg_color: "ffffff", }, "blue-green": { title_color: "2f97c1", icon_color: "f5b700", text_color: "0cf574", bg_color: "040f0f", }, algolia: { title_color: "00AEFF", icon_color: "2DDE98", text_color: "FFFFFF", bg_color: "050F2C", }, "great-gatsby": { title_color: "ffa726", icon_color: "ffb74d", text_color: "ffd95b", bg_color: "000000", }, darcula: { title_color: "BA5F17", icon_color: "84628F", text_color: "BEBEBE", bg_color: "242424", }, bear: { title_color: "e03c8a", icon_color: "00AEFF", text_color: "bcb28d", bg_color: "1f2023", }, "solarized-dark": { title_color: "268bd2", icon_color: "b58900", text_color: "859900", bg_color: "002b36", }, "solarized-light": { title_color: "268bd2", icon_color: "b58900", text_color: "859900", bg_color: "fdf6e3", }, "chartreuse-dark": { title_color: "7fff00", icon_color: "00AEFF", text_color: "fff", bg_color: "000", }, nord: { title_color: "81a1c1", text_color: "d8dee9", icon_color: "88c0d0", bg_color: "2e3440", }, gotham: { title_color: "2aa889", icon_color: "599cab", text_color: "99d1ce", bg_color: "0c1014", }, "material-palenight": { title_color: "c792ea", icon_color: "89ddff", text_color: "a6accd", bg_color: "292d3e", }, graywhite: { title_color: "24292e", icon_color: "24292e", text_color: "24292e", bg_color: "ffffff", }, "vision-friendly-dark": { title_color: "ffb000", icon_color: "785ef0", text_color: "ffffff", bg_color: "000000", }, "ayu-mirage": { title_color: "f4cd7c", icon_color: "73d0ff", text_color: "c7c8c2", bg_color: "1f2430", }, "midnight-purple": { title_color: "9745f5", icon_color: "9f4bff", text_color: "ffffff", bg_color: "000000", }, calm: { title_color: "e07a5f", icon_color: "edae49", text_color: "ebcfb2", bg_color: "373f51", }, "flag-india": { title_color: "ff8f1c", icon_color: "250E62", text_color: "509E2F", bg_color: "ffffff", }, omni: { title_color: "FF79C6", icon_color: "e7de79", text_color: "E1E1E6", bg_color: "191622", }, react: { title_color: "61dafb", icon_color: "61dafb", text_color: "ffffff", bg_color: "20232a", }, jolly: { title_color: "ff64da", icon_color: "a960ff", text_color: "ffffff", bg_color: "291B3E", }, maroongold: { title_color: "F7EF8A", icon_color: "F7EF8A", text_color: "E0AA3E", bg_color: "260000", }, yeblu: { title_color: "ffff00", icon_color: "ffff00", text_color: "ffffff", bg_color: "002046", }, blueberry: { title_color: "82aaff", icon_color: "89ddff", text_color: "27e8a7", bg_color: "242938", }, slateorange: { title_color: "faa627", icon_color: "faa627", text_color: "ffffff", bg_color: "36393f", }, kacho_ga: { title_color: "bf4a3f", icon_color: "a64833", text_color: "d9c8a9", bg_color: "402b23", }, outrun: { title_color: "ffcc00", icon_color: "ff1aff", text_color: "8080ff", bg_color: "141439", }, ocean_dark: { title_color: "8957B2", icon_color: "FFFFFF", text_color: "92D534", bg_color: "151A28", }, city_lights: { title_color: "5D8CB3", icon_color: "4798FF", text_color: "718CA1", bg_color: "1D252C", }, github_dark: { title_color: "58A6FF", icon_color: "1F6FEB", text_color: "C3D1D9", bg_color: "0D1117", }, github_dark_dimmed: { title_color: "539bf5", icon_color: "539bf5", text_color: "ADBAC7", bg_color: "24292F", border_color: "373E47", }, discord_old_blurple: { title_color: "7289DA", icon_color: "7289DA", text_color: "FFFFFF", bg_color: "2C2F33", }, aura_dark: { title_color: "ff7372", icon_color: "6cffd0", text_color: "dbdbdb", bg_color: "252334", }, panda: { title_color: "19f9d899", icon_color: "19f9d899", text_color: "FF75B5", bg_color: "31353a", }, noctis_minimus: { title_color: "d3b692", icon_color: "72b7c0", text_color: "c5cdd3", bg_color: "1b2932", }, cobalt2: { title_color: "ffc600", icon_color: "ffffff", text_color: "0088ff", bg_color: "193549", }, swift: { title_color: "000000", icon_color: "f05237", text_color: "000000", bg_color: "f7f7f7", }, aura: { title_color: "a277ff", icon_color: "ffca85", text_color: "61ffca", bg_color: "15141b", }, apprentice: { title_color: "ffffff", icon_color: "ffffaf", text_color: "bcbcbc", bg_color: "262626", }, moltack: { title_color: "86092C", icon_color: "86092C", text_color: "574038", bg_color: "F5E1C0", }, codeSTACKr: { title_color: "ff652f", icon_color: "FFE400", text_color: "ffffff", bg_color: "09131B", border_color: "0c1a25", }, rose_pine: { title_color: "9ccfd8", icon_color: "ebbcba", text_color: "e0def4", bg_color: "191724", }, catppuccin_latte: { title_color: "137980", icon_color: "8839ef", text_color: "4c4f69", bg_color: "eff1f5", }, catppuccin_mocha: { title_color: "94e2d5", icon_color: "cba6f7", text_color: "cdd6f4", bg_color: "1e1e2e", }, date_night: { title_color: "DA7885", text_color: "E1B2A2", icon_color: "BB8470", border_color: "170F0C", bg_color: "170F0C", }, one_dark_pro: { title_color: "61AFEF", text_color: "E5C06E", icon_color: "C678DD", border_color: "3B4048", bg_color: "23272E", }, rose: { title_color: "8d192b", text_color: "862931", icon_color: "B71F36", border_color: "e9d8d4", bg_color: "e9d8d4", }, holi: { title_color: "5FABEE", text_color: "D6E7FF", icon_color: "5FABEE", border_color: "85A4C0", bg_color: "030314", }, neon: { title_color: "00EAD3", text_color: "FF449F", icon_color: "00EAD3", border_color: "ffffff", bg_color: "000000", }, blue_navy: { title_color: "82AAFF", text_color: "82AAFF", icon_color: "82AAFF", border_color: "ffffff", bg_color: "000000", }, calm_pink: { title_color: "e07a5f", text_color: "edae49", icon_color: "ebcfb2", border_color: "e1bc29", bg_color: "2b2d40", }, ambient_gradient: { title_color: "ffffff", text_color: "ffffff", icon_color: "ffffff", bg_color: "35,4158d0,c850c0,ffcc70", }, }; export default themes; ================================================ FILE: vercel.json ================================================ { "functions": { "api/*.js": { "memory": 128, "maxDuration": 10 } }, "redirects": [ { "source": "/", "destination": "https://github.com/anuraghazra/github-readme-stats" } ] }