├── .eslintrc.json ├── .gitattributes ├── .github ├── 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 │ ├── 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 ├── docs ├── readme_cn.md ├── readme_de.md ├── readme_es.md ├── readme_fr.md ├── readme_it.md ├── readme_ja.md ├── readme_kr.md ├── readme_nl.md ├── readme_np.md ├── readme_pt-BR.md └── readme_tr.md ├── express.js ├── jest.bench.config.js ├── jest.config.js ├── jest.e2e.config.js ├── package-lock.json ├── package.json ├── powered-by-vercel.svg ├── 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-card.js │ ├── index.js │ ├── repo-card.js │ ├── stats-card.js │ ├── top-languages-card.js │ ├── types.d.ts │ └── wakatime-card.js ├── common │ ├── Card.js │ ├── I18n.js │ ├── blacklist.js │ ├── createProgressNode.js │ ├── icons.js │ ├── index.js │ ├── languageColors.json │ ├── retryer.js │ └── utils.js ├── fetchers │ ├── gist-fetcher.js │ ├── repo-fetcher.js │ ├── stats-fetcher.js │ ├── top-languages-fetcher.js │ ├── types.d.ts │ └── wakatime-fetcher.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 ├── calculateRank.test.js ├── card.test.js ├── e2e │ └── e2e.test.js ├── fetchGist.test.js ├── fetchRepo.test.js ├── fetchStats.test.js ├── fetchTopLanguages.test.js ├── fetchWakatime.test.js ├── flexLayout.test.js ├── gist.test.js ├── i18n.test.js ├── pat-info.test.js ├── pin.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 ├── utils.test.js └── wakatime.test.js ├── themes ├── README.md └── index.js └── vercel.json /.gitattributes: -------------------------------------------------------------------------------- 1 | dist/* linguist-vendored=false 2 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: [anuraghazra] # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2] 4 | patreon: # Replace with a single Patreon username 5 | open_collective: # Replace with a single Open Collective username 6 | ko_fi: # Replace with a single Ko-fi username 7 | tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel 8 | community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry 9 | liberapay: # Replace with a single Liberapay username 10 | issuehunt: # Replace with a single IssueHunt username 11 | otechie: # Replace with a single Otechie username 12 | custom: [ 13 | "https://www.paypal.me/anuraghazra", 14 | "https://www.buymeacoffee.com/anuraghazra", 15 | ] # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2'] 16 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.yml: -------------------------------------------------------------------------------- 1 | name: Bug report 2 | description: Create a report to help us improve. 3 | labels: 4 | - "bug" 5 | body: 6 | - type: markdown 7 | attributes: 8 | value: | 9 | :warning: PLEASE FIRST READ THE FAQ [(#1770)](https://github.com/anuraghazra/github-readme-stats/discussions/1770) AND COMMON ERROR CODES [(#1772)](https://github.com/anuraghazra/github-readme-stats/issues/1772)!!! 10 | - type: textarea 11 | attributes: 12 | label: Describe the bug 13 | description: A clear and concise description of what the bug is. 14 | validations: 15 | required: true 16 | - type: textarea 17 | attributes: 18 | label: Expected behavior 19 | description: 20 | A clear and concise description of what you expected to happen. 21 | - type: textarea 22 | attributes: 23 | label: Screenshots / Live demo link 24 | description: If applicable, add screenshots to help explain your problem. 25 | placeholder: Paste the github-readme-stats link as markdown image 26 | - type: textarea 27 | attributes: 28 | label: Additional context 29 | description: Add any other context about the problem here. 30 | - type: markdown 31 | attributes: 32 | value: | 33 | --- 34 | ### FAQ (Snippet) 35 | 36 | Below are some questions that are found in the FAQ. The full FAQ can be found in [#1770](https://github.com/anuraghazra/github-readme-stats/discussions/1770). 37 | 38 | #### Q: My card displays an error 39 | 40 | **Ans:** First, check the common error codes (i.e. https://github.com/anuraghazra/github-readme-stats/issues/1772) and existing issues before creating a new one. 41 | 42 | #### Q: How to hide jupyter Notebook? 43 | 44 | **Ans:** `&hide=jupyter%20notebook`. 45 | 46 | #### Q: I could not figure out how to deploy on my own vercel instance 47 | 48 | **Ans:** Please check: 49 | - Docs: https://github.com/anuraghazra/github-readme-stats/#deploy-on-your-own-vercel-instance 50 | - YT tutorial by codeSTACKr: https://www.youtube.com/watch?v=n6d4KHSKqGk&feature=youtu.be&t=107 51 | 52 | #### Q: Language Card is incorrect 53 | 54 | **Ans:** Please read these issues/comments before opening any issues regarding language card stats: 55 | - https://github.com/anuraghazra/github-readme-stats/issues/136#issuecomment-665164174 56 | - https://github.com/anuraghazra/github-readme-stats/issues/136#issuecomment-665172181 57 | 58 | #### Q: How to count private stats? 59 | 60 | **Ans:** We can only count private commits & we cannot access any other private info of any users, so it's impossible. The only way is to deploy on your own instance & use your own PAT (Personal Access Token). 61 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | blank_issues_enabled: true 2 | contact_links: 3 | - name: Question 4 | url: https://github.com/anuraghazra/github-readme-stats/discussions 5 | about: Please ask and answer questions here. 6 | - name: Error 7 | url: https://github.com/anuraghazra/github-readme-stats/issues/1772 8 | about: 9 | Before opening a bug report, please check the 'Common Error Codes' issue. 10 | - name: FAQ 11 | url: https://github.com/anuraghazra/github-readme-stats/discussions/1770 12 | about: Please first check the FAQ before asking a question. 13 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.yml: -------------------------------------------------------------------------------- 1 | name: Feature request 2 | description: Suggest an idea for this project. 3 | labels: 4 | - "enhancement" 5 | body: 6 | - type: textarea 7 | attributes: 8 | label: Is your feature request related to a problem? Please describe. 9 | description: 10 | A clear and concise description of what the problem is. Ex. I'm always 11 | frustrated when [...] 12 | validations: 13 | required: true 14 | - type: textarea 15 | attributes: 16 | label: Describe the solution you'd like 17 | description: A clear and concise description of what you want to happen. 18 | - type: textarea 19 | attributes: 20 | label: Describe alternatives you've considered 21 | description: 22 | A clear and concise description of any alternative solutions or features 23 | you've considered. 24 | - type: textarea 25 | attributes: 26 | label: Additional context 27 | description: 28 | Add any other context or screenshots about the feature request here. 29 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | # Maintain dependencies for NPM 4 | - package-ecosystem: npm 5 | directory: "/" 6 | schedule: 7 | interval: weekly 8 | open-pull-requests-limit: 10 9 | commit-message: 10 | prefix: "build(deps)" 11 | prefix-development: "build(deps-dev)" 12 | reviewers: 13 | - "qwerty541" 14 | 15 | # Maintain dependencies for GitHub Actions 16 | - package-ecosystem: github-actions 17 | directory: "/" 18 | schedule: 19 | interval: weekly 20 | open-pull-requests-limit: 10 21 | commit-message: 22 | prefix: "ci(deps)" 23 | prefix-development: "ci(deps-dev)" 24 | reviewers: 25 | - "qwerty541" 26 | -------------------------------------------------------------------------------- /.github/labeler.yml: -------------------------------------------------------------------------------- 1 | themes: 2 | - changed-files: 3 | - any-glob-to-any-file: 4 | - themes/index.js 5 | 6 | doc-translation: 7 | - changed-files: 8 | - any-glob-to-any-file: 9 | - docs/* 10 | 11 | card-i18n: 12 | - changed-files: 13 | - any-glob-to-any-file: 14 | - src/translations.js 15 | - src/common/I18n.js 16 | 17 | documentation: 18 | - changed-files: 19 | - any-glob-to-any-file: 20 | - readme.md 21 | - CONTRIBUTING.md 22 | - CODE_OF_CONDUCT.md 23 | - SECURITY.md 24 | 25 | dependencies: 26 | - changed-files: 27 | - any-glob-to-any-file: 28 | - package.json 29 | - package-lock.json 30 | 31 | lang-card: 32 | - changed-files: 33 | - any-glob-to-any-file: 34 | - api/top-langs.js 35 | - src/cards/top-languages-card.js 36 | - src/fetchers/top-languages-fetcher.js 37 | - tests/fetchTopLanguages.test.js 38 | - tests/renderTopLanguagesCard.test.js 39 | - tests/top-langs.test.js 40 | 41 | repo-card: 42 | - changed-files: 43 | - any-glob-to-any-file: 44 | - api/pin.js 45 | - src/cards/repo-card.js 46 | - src/fetchers/repo-fetcher.js 47 | - tests/fetchRepo.test.js 48 | - tests/renderRepoCard.test.js 49 | - tests/pin.test.js 50 | 51 | stats-card: 52 | - changed-files: 53 | - any-glob-to-any-file: 54 | - api/index.js 55 | - src/cards/stats-card.js 56 | - src/fetchers/stats-fetcher.js 57 | - tests/fetchStats.test.js 58 | - tests/renderStatsCard.test.js 59 | - tests/api.test.js 60 | 61 | wakatime-card: 62 | - changed-files: 63 | - any-glob-to-any-file: 64 | - api/wakatime.js 65 | - src/cards/wakatime-card.js 66 | - src/fetchers/wakatime-fetcher.js 67 | - tests/fetchWakatime.test.js 68 | - tests/renderWakatimeCard.test.js 69 | - tests/wakatime.test.js 70 | 71 | gist-card: 72 | - changed-files: 73 | - any-glob-to-any-file: 74 | - api/gist.js 75 | - src/cards/gist-card.js 76 | - src/fetchers/gist-fetcher.js 77 | - tests/fetchGist.test.js 78 | - tests/renderGistCard.test.js 79 | - tests/gist.test.js 80 | 81 | ranks: 82 | - changed-files: 83 | - any-glob-to-any-file: 84 | - src/calculateRank.js 85 | 86 | ci: 87 | - changed-files: 88 | - any-glob-to-any-file: 89 | - .github/workflows/* 90 | - scripts/* 91 | 92 | infrastructure: 93 | - changed-files: 94 | - any-glob-to-any-file: 95 | - .eslintrc.json 96 | -------------------------------------------------------------------------------- /.github/stale.yml: -------------------------------------------------------------------------------- 1 | # Number of days of inactivity before an issue becomes stale 2 | daysUntilStale: 30 3 | # Number of days of inactivity before a stale issue is closed 4 | daysUntilClose: 7 5 | # Issues with these labels will never be considered stale 6 | exemptLabels: 7 | - feature 8 | - enhancement 9 | - help wanted 10 | - bug 11 | 12 | # Label to use when marking an issue as stale 13 | staleLabel: stale 14 | # Comment to post when marking an issue as stale. Set to `false` to disable 15 | markComment: > 16 | This issue has been automatically marked as stale because it has not had 17 | recent activity. It will be closed if no further activity occurs. Thank you 18 | for your contributions. 19 | # Comment to post when closing a stale issue. Set to `false` to disable 20 | closeComment: false 21 | -------------------------------------------------------------------------------- /.github/workflows/codeql-analysis.yml: -------------------------------------------------------------------------------- 1 | name: "Static code analysis workflow (CodeQL)" 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | pull_request: 8 | branches: 9 | - master 10 | 11 | permissions: 12 | actions: read 13 | checks: read 14 | contents: read 15 | deployments: read 16 | issues: read 17 | discussions: read 18 | packages: read 19 | pages: read 20 | pull-requests: read 21 | repository-projects: read 22 | security-events: write 23 | statuses: read 24 | 25 | jobs: 26 | CodeQL-Build: 27 | # CodeQL runs on ubuntu-latest, windows-latest, and macos-latest 28 | runs-on: ubuntu-latest 29 | 30 | steps: 31 | - name: Checkout repository 32 | uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1 33 | 34 | # Initializes the CodeQL tools for scanning. 35 | - name: Initialize CodeQL 36 | uses: github/codeql-action/init@46a6823b81f2d7c67ddf123851eea88365bc8a67 # v2.13.5 37 | with: 38 | languages: javascript 39 | 40 | - name: Perform CodeQL Analysis 41 | uses: github/codeql-action/analyze@46a6823b81f2d7c67ddf123851eea88365bc8a67 # v2.13.5 42 | -------------------------------------------------------------------------------- /.github/workflows/deploy-prep.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | file = open('./vercel.json', 'r') 4 | str = file.read() 5 | file = open('./vercel.json', 'w') 6 | 7 | str = str.replace('"maxDuration": 10', '"maxDuration": 30') 8 | 9 | file.write(str) 10 | file.close() 11 | -------------------------------------------------------------------------------- /.github/workflows/deploy-prep.yml: -------------------------------------------------------------------------------- 1 | name: Deployment Prep 2 | on: 3 | workflow_dispatch: 4 | push: 5 | branches: 6 | - master 7 | 8 | jobs: 9 | config: 10 | if: github.repository == 'anuraghazra/github-readme-stats' 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1 14 | - name: Deployment Prep 15 | run: python ./.github/workflows/deploy-prep.py 16 | - uses: stefanzweifel/git-auto-commit-action@8756aa072ef5b4a080af5dc8fef36c5d586e521d # v5.0.0 17 | with: 18 | branch: vercel 19 | create_branch: true 20 | push_options: "--force" 21 | -------------------------------------------------------------------------------- /.github/workflows/e2e-test.yml: -------------------------------------------------------------------------------- 1 | name: Test Deployment 2 | on: 3 | deployment_status: 4 | 5 | permissions: read-all 6 | 7 | jobs: 8 | e2eTests: 9 | if: 10 | github.repository == 'anuraghazra/github-readme-stats' && 11 | github.event_name == 'deployment_status' && 12 | github.event.deployment_status.state == 'success' 13 | name: Perform 2e2 tests 14 | runs-on: ubuntu-latest 15 | strategy: 16 | matrix: 17 | node-version: [18.x] 18 | 19 | steps: 20 | - uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1 21 | 22 | - name: Setup Node 23 | uses: actions/setup-node@60edb5dd545a775178f52524783378180af0d1f8 # v4.0.2 24 | with: 25 | node-version: ${{ matrix.node-version }} 26 | cache: npm 27 | 28 | - name: Install dependencies 29 | run: npm ci 30 | env: 31 | CI: true 32 | 33 | - name: Run end-to-end tests. 34 | run: npm run test:e2e 35 | env: 36 | VERCEL_PREVIEW_URL: ${{ github.event.deployment_status.target_url }} 37 | -------------------------------------------------------------------------------- /.github/workflows/empty-issues-closer.yml: -------------------------------------------------------------------------------- 1 | name: Close empty issues and templates 2 | on: 3 | issues: 4 | types: 5 | - reopened 6 | - opened 7 | - edited 8 | 9 | permissions: 10 | actions: read 11 | checks: read 12 | contents: read 13 | deployments: read 14 | issues: write 15 | discussions: read 16 | packages: read 17 | pages: read 18 | pull-requests: read 19 | repository-projects: read 20 | security-events: read 21 | statuses: read 22 | 23 | jobs: 24 | closeEmptyIssuesAndTemplates: 25 | if: github.repository == 'anuraghazra/github-readme-stats' 26 | name: Close empty issues 27 | runs-on: ubuntu-latest 28 | steps: 29 | # NOTE: Retrieve issue templates. 30 | - uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1 31 | 32 | - name: Run empty issues closer action 33 | uses: rickstaa/empty-issues-closer-action@e96914613221511279ca25f50fd4acc85e331d99 # v1.1.74 34 | env: 35 | github_token: ${{ secrets.GITHUB_TOKEN }} 36 | with: 37 | close_comment: 38 | Closing this issue because it appears to be empty. Please update the 39 | issue for it to be reopened. 40 | open_comment: 41 | Reopening this issue because the author provided more information. 42 | check_templates: true 43 | template_close_comment: 44 | Closing this issue since the issue template was not filled in. 45 | Please provide us with more information to have this issue reopened. 46 | template_open_comment: 47 | Reopening this issue because the author provided more information. 48 | -------------------------------------------------------------------------------- /.github/workflows/generate-theme-doc.yml: -------------------------------------------------------------------------------- 1 | name: Generate Theme Readme 2 | on: 3 | push: 4 | branches: 5 | - master 6 | paths: 7 | - "themes/index.js" 8 | 9 | permissions: 10 | actions: read 11 | checks: read 12 | contents: write 13 | deployments: read 14 | issues: read 15 | discussions: read 16 | packages: read 17 | pages: read 18 | pull-requests: read 19 | repository-projects: read 20 | security-events: read 21 | statuses: read 22 | 23 | jobs: 24 | generateThemeDoc: 25 | runs-on: ubuntu-latest 26 | name: Generate theme doc 27 | strategy: 28 | matrix: 29 | node-version: [18.x] 30 | 31 | steps: 32 | - uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1 33 | 34 | - name: Setup Node 35 | uses: actions/setup-node@60edb5dd545a775178f52524783378180af0d1f8 # v4.0.2 36 | with: 37 | node-version: ${{ matrix.node-version }} 38 | cache: npm 39 | 40 | # Fix the unsafe repo error which was introduced by the CVE-2022-24765 git patches. 41 | - name: Fix unsafe repo error 42 | run: git config --global --add safe.directory ${{ github.workspace }} 43 | 44 | - name: npm install, generate readme 45 | run: | 46 | npm ci 47 | npm run theme-readme-gen 48 | env: 49 | CI: true 50 | 51 | - name: Run Script 52 | uses: skx/github-action-tester@e29768ff4ff67be9d1fdbccd8836ab83233bebb1 # v0.10.0 53 | with: 54 | script: ./scripts/push-theme-readme.sh 55 | env: 56 | CI: true 57 | PERSONAL_TOKEN: ${{ secrets.PERSONAL_TOKEN }} 58 | GH_REPO: ${{ secrets.GH_REPO }} 59 | -------------------------------------------------------------------------------- /.github/workflows/label-pr.yml: -------------------------------------------------------------------------------- 1 | name: "Pull Request Labeler" 2 | on: 3 | - pull_request_target 4 | 5 | permissions: 6 | actions: read 7 | checks: read 8 | contents: read 9 | deployments: read 10 | issues: read 11 | discussions: read 12 | packages: read 13 | pages: read 14 | pull-requests: write 15 | repository-projects: read 16 | security-events: read 17 | statuses: read 18 | 19 | jobs: 20 | triage: 21 | if: github.repository == 'anuraghazra/github-readme-stats' 22 | runs-on: ubuntu-latest 23 | steps: 24 | - uses: actions/labeler@8558fd74291d67161a8a78ce36a881fa63b766a9 # v5.0.0 25 | with: 26 | repo-token: "${{ secrets.GITHUB_TOKEN }}" 27 | sync-labels: true 28 | -------------------------------------------------------------------------------- /.github/workflows/ossf-analysis.yml: -------------------------------------------------------------------------------- 1 | name: OSSF Scorecard analysis workflow 2 | on: 3 | push: 4 | branches: 5 | - master 6 | pull_request: 7 | branches: 8 | - master 9 | 10 | permissions: read-all 11 | 12 | jobs: 13 | analysis: 14 | if: github.repository == 'anuraghazra/github-readme-stats' 15 | name: Scorecard analysis 16 | runs-on: ubuntu-latest 17 | permissions: 18 | # Needed if using Code scanning alerts 19 | security-events: write 20 | # Needed for GitHub OIDC token if publish_results is true 21 | id-token: write 22 | 23 | steps: 24 | - name: "Checkout code" 25 | uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1 26 | with: 27 | persist-credentials: false 28 | 29 | - name: "Run analysis" 30 | uses: ossf/scorecard-action@0864cf19026789058feabb7e87baa5f140aac736 # v2.3.1 31 | with: 32 | results_file: results.sarif 33 | results_format: sarif 34 | publish_results: true 35 | 36 | # Upload the results as artifacts (optional). Commenting out will disable uploads of run results in SARIF 37 | # format to the repository Actions tab. 38 | - name: "Upload artifact" 39 | uses: actions/upload-artifact@5d5d22a31266ced268874388b861e4b58bb5c2f3 # v4.3.1 40 | with: 41 | name: SARIF file 42 | path: results.sarif 43 | retention-days: 5 44 | 45 | # required for Code scanning alerts 46 | - name: "Upload SARIF results to code scanning" 47 | uses: github/codeql-action/upload-sarif@fdcae64e1484d349b3366718cdfef3d404390e85 # v2.22.1 48 | with: 49 | sarif_file: results.sarif 50 | -------------------------------------------------------------------------------- /.github/workflows/preview-theme.yml: -------------------------------------------------------------------------------- 1 | name: Theme preview 2 | on: 3 | pull_request_target: 4 | types: [opened, edited, reopened, synchronize] 5 | branches: 6 | - master 7 | paths: 8 | - "themes/index.js" 9 | 10 | permissions: 11 | actions: read 12 | checks: read 13 | contents: read 14 | deployments: read 15 | issues: read 16 | discussions: read 17 | packages: read 18 | pages: read 19 | pull-requests: write 20 | repository-projects: read 21 | security-events: read 22 | statuses: read 23 | 24 | jobs: 25 | previewTheme: 26 | name: Install & Preview 27 | runs-on: ubuntu-latest 28 | strategy: 29 | matrix: 30 | node-version: [18.x] 31 | 32 | steps: 33 | - uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1 34 | 35 | - name: Setup Node 36 | uses: actions/setup-node@60edb5dd545a775178f52524783378180af0d1f8 # v4.0.2 37 | with: 38 | node-version: ${{ matrix.node-version }} 39 | cache: npm 40 | 41 | - uses: bahmutov/npm-install@237ded403e6012a48281f4572eab0c8eafe55b3f # v1.10.1 42 | with: 43 | useLockFile: false 44 | 45 | - run: npm run preview-theme 46 | env: 47 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 48 | -------------------------------------------------------------------------------- /.github/workflows/prs-cache-clean.yml: -------------------------------------------------------------------------------- 1 | name: prs cache clean 2 | on: 3 | pull_request: 4 | types: 5 | - closed 6 | 7 | permissions: 8 | actions: write 9 | checks: read 10 | contents: read 11 | deployments: read 12 | issues: read 13 | discussions: read 14 | packages: read 15 | pages: read 16 | pull-requests: read 17 | repository-projects: read 18 | security-events: read 19 | statuses: read 20 | 21 | jobs: 22 | cleanup: 23 | runs-on: ubuntu-latest 24 | steps: 25 | - name: Checkout 26 | uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1 27 | 28 | - name: Cleanup 29 | run: | 30 | gh extension install actions/gh-actions-cache 31 | 32 | REPO=${{ github.repository }} 33 | BRANCH="refs/pull/${{ github.event.pull_request.number }}/merge" 34 | 35 | echo "Fetching list of cache key" 36 | cacheKeysForPR=$(gh actions-cache list -R $REPO -B $BRANCH | cut -f 1 ) 37 | 38 | ## Setting this to not fail the workflow while deleting cache keys. 39 | set +e 40 | echo "Deleting caches..." 41 | for cacheKey in $cacheKeysForPR 42 | do 43 | gh actions-cache delete $cacheKey -R $REPO -B $BRANCH --confirm 44 | done 45 | echo "Done" 46 | env: 47 | GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} 48 | -------------------------------------------------------------------------------- /.github/workflows/stale-theme-pr-closer.yml: -------------------------------------------------------------------------------- 1 | name: Close stale theme pull requests that have the 'invalid' label. 2 | on: 3 | schedule: 4 | # ┌───────────── minute (0 - 59) 5 | # │ ┌───────────── hour (0 - 23) 6 | # │ │ ┌───────────── day of the month (1 - 31) 7 | # │ │ │ ┌───────────── month (1 - 12 or JAN-DEC) 8 | # │ │ │ │ ┌───────────── day of the week (0 - 6 or SUN-SAT) 9 | # │ │ │ │ │ 10 | # │ │ │ │ │ 11 | # │ │ │ │ │ 12 | # * * * * * 13 | - cron: "0 0 */7 * *" 14 | 15 | permissions: 16 | actions: read 17 | checks: read 18 | contents: read 19 | deployments: read 20 | issues: read 21 | discussions: read 22 | packages: read 23 | pages: read 24 | pull-requests: write 25 | repository-projects: read 26 | security-events: read 27 | statuses: read 28 | 29 | jobs: 30 | closeOldThemePrs: 31 | if: github.repository == 'anuraghazra/github-readme-stats' 32 | name: Close stale 'invalid' theme PRs 33 | runs-on: ubuntu-latest 34 | strategy: 35 | matrix: 36 | node-version: [18.x] 37 | 38 | steps: 39 | - uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1 40 | 41 | - name: Setup Node 42 | uses: actions/setup-node@60edb5dd545a775178f52524783378180af0d1f8 # v4.0.2 43 | with: 44 | node-version: ${{ matrix.node-version }} 45 | cache: npm 46 | 47 | - uses: bahmutov/npm-install@237ded403e6012a48281f4572eab0c8eafe55b3f # v1.10.1 48 | with: 49 | useLockFile: false 50 | 51 | - run: npm run close-stale-theme-prs 52 | env: 53 | STALE_DAYS: 20 54 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 55 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | on: 3 | push: 4 | branches: 5 | - master 6 | pull_request: 7 | branches: 8 | - master 9 | 10 | permissions: read-all 11 | 12 | jobs: 13 | build: 14 | name: Perform tests 15 | runs-on: ubuntu-latest 16 | strategy: 17 | matrix: 18 | node-version: [18.x] 19 | 20 | steps: 21 | - uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1 22 | 23 | - name: Setup Node 24 | uses: actions/setup-node@60edb5dd545a775178f52524783378180af0d1f8 # v4.0.2 25 | with: 26 | node-version: ${{ matrix.node-version }} 27 | cache: npm 28 | 29 | - name: Install & Test 30 | run: | 31 | npm ci 32 | npm run test 33 | 34 | - name: Run ESLint 35 | run: | 36 | npm run lint 37 | 38 | - name: Run bench tests 39 | run: | 40 | npm run bench 41 | 42 | - name: Run Prettier 43 | run: | 44 | npm run format:check 45 | 46 | - name: Code Coverage 47 | uses: codecov/codecov-action@4fe8c5f003fae66aa5ebb77cfd3e7bfbbda0b6b0 # v3.1.5 48 | -------------------------------------------------------------------------------- /.github/workflows/top-issues-dashboard.yml: -------------------------------------------------------------------------------- 1 | name: Update top issues dashboard 2 | on: 3 | schedule: 4 | # ┌───────────── minute (0 - 59) 5 | # │ ┌───────────── hour (0 - 23) 6 | # │ │ ┌───────────── day of the month (1 - 31) 7 | # │ │ │ ┌───────────── month (1 - 12 or JAN-DEC) 8 | # │ │ │ │ ┌───────────── day of the week (0 - 6 or SUN-SAT) 9 | # │ │ │ │ │ 10 | # │ │ │ │ │ 11 | # │ │ │ │ │ 12 | # * * * * * 13 | - cron: "0 0 */3 * *" 14 | workflow_dispatch: 15 | 16 | permissions: 17 | actions: read 18 | checks: read 19 | contents: read 20 | deployments: read 21 | issues: write 22 | discussions: read 23 | packages: read 24 | pages: read 25 | pull-requests: write 26 | repository-projects: read 27 | security-events: read 28 | statuses: read 29 | 30 | jobs: 31 | showAndLabelTopIssues: 32 | if: github.repository == 'anuraghazra/github-readme-stats' 33 | name: Update top issues Dashboard. 34 | runs-on: ubuntu-latest 35 | steps: 36 | - name: Run top issues action 37 | uses: rickstaa/top-issues-action@5389f9c080fc351632b51536ce39a081a98d652c # v1.3.99 38 | env: 39 | github_token: ${{ secrets.GITHUB_TOKEN }} 40 | with: 41 | top_list_size: 10 42 | filter: "1772" 43 | label: true 44 | dashboard: true 45 | dashboard_show_total_reactions: true 46 | top_issues: true 47 | top_bugs: true 48 | top_features: true 49 | top_pull_requests: true 50 | -------------------------------------------------------------------------------- /.github/workflows/update-langs.yml: -------------------------------------------------------------------------------- 1 | name: Update supported languages 2 | on: 3 | schedule: 4 | # ┌───────────── minute (0 - 59) 5 | # │ ┌───────────── hour (0 - 23) 6 | # │ │ ┌───────────── day of the month (1 - 31) 7 | # │ │ │ ┌───────────── month (1 - 12 or JAN-DEC) 8 | # │ │ │ │ ┌───────────── day of the week (0 - 6 or SUN-SAT) 9 | # │ │ │ │ │ 10 | # │ │ │ │ │ 11 | # │ │ │ │ │ 12 | # * * * * * 13 | - cron: "0 0 */30 * *" 14 | 15 | permissions: 16 | actions: read 17 | checks: read 18 | contents: write 19 | deployments: read 20 | issues: read 21 | discussions: read 22 | packages: read 23 | pages: read 24 | pull-requests: write 25 | repository-projects: read 26 | security-events: read 27 | statuses: read 28 | 29 | jobs: 30 | updateLanguages: 31 | if: github.repository == 'anuraghazra/github-readme-stats' 32 | name: Update supported languages 33 | runs-on: ubuntu-latest 34 | strategy: 35 | matrix: 36 | node-version: [18.x] 37 | 38 | steps: 39 | - uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1 40 | 41 | - name: Setup Node 42 | uses: actions/setup-node@60edb5dd545a775178f52524783378180af0d1f8 # v4.0.2 43 | with: 44 | node-version: ${{ matrix.node-version }} 45 | cache: npm 46 | 47 | - name: Install dependencies 48 | run: npm ci 49 | env: 50 | CI: true 51 | 52 | - name: Run update-languages-json.js script 53 | run: npm run generate-langs-json 54 | 55 | - name: Create Pull Request if upstream language file is changed 56 | uses: peter-evans/create-pull-request@a4f52f8033a6168103c2538976c07b467e8163bc # v6.0.1 57 | with: 58 | commit-message: "refactor: update languages JSON" 59 | branch: "update_langs/patch" 60 | delete-branch: true 61 | title: Update languages JSON 62 | body: 63 | "The 64 | [update-langs](https://github.com/anuraghazra/github-readme-stats/actions/workflows/update-langs.yaml) 65 | action found new/updated languages in the [upstream languages JSON 66 | file](https://raw.githubusercontent.com/github/linguist/master/lib/linguist/languages.yml)." 67 | labels: "ci, lang-card" 68 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .vercel 2 | .env 3 | node_modules 4 | *.lock 5 | .idea/ 6 | coverage 7 | benchmarks 8 | vercel_token 9 | 10 | # IDE 11 | .vscode/* 12 | !.vscode/extensions.json 13 | !.vscode/settings.json 14 | *.code-workspace 15 | 16 | .vercel 17 | -------------------------------------------------------------------------------- /.husky/.gitignore: -------------------------------------------------------------------------------- 1 | _ 2 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | npm test 2 | npm run lint 3 | npx lint-staged 4 | -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | 18 -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | *.json 3 | *.md 4 | coverage 5 | .vercel 6 | -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "trailingComma": "all", 3 | "useTabs": false, 4 | "endOfLine": "auto", 5 | "proseWrap": "always" 6 | } -------------------------------------------------------------------------------- /.vercelignore: -------------------------------------------------------------------------------- 1 | .env 2 | package-lock.json 3 | coverage -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": [ 3 | "yzhang.markdown-all-in-one", 4 | "esbenp.prettier-vscode", 5 | "dbaeumer.vscode-eslint" 6 | ] 7 | } 8 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "markdown.extension.toc.levels": "1..3", 3 | "editor.formatOnSave": true, 4 | "editor.defaultFormatter": "esbenp.prettier-vscode", 5 | } 6 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as 6 | contributors and maintainers pledge to making participation in our project and 7 | our community a harassment-free experience for everyone, regardless of age, body 8 | size, disability, ethnicity, sex characteristics, gender identity and expression, 9 | level of experience, education, socio-economic status, nationality, personal 10 | appearance, race, religion, or sexual identity and orientation. 11 | 12 | ## Our Standards 13 | 14 | Examples of behavior that contributes to creating a positive environment 15 | include: 16 | 17 | * Using welcoming and inclusive language 18 | * Being respectful of differing viewpoints and experiences 19 | * Gracefully accepting constructive criticism 20 | * Focusing on what is best for the community 21 | * Showing empathy towards other community members 22 | 23 | Examples of unacceptable behavior by participants include: 24 | 25 | * The use of sexualized language or imagery and unwelcome sexual attention or 26 | advances 27 | * Trolling, insulting/derogatory comments, and personal or political attacks 28 | * Public or private harassment 29 | * Publishing others' private information, such as a physical or electronic 30 | address, without explicit permission 31 | * Other conduct which could reasonably be considered inappropriate in a 32 | professional setting 33 | 34 | ## Our Responsibilities 35 | 36 | Project maintainers are responsible for clarifying the standards of acceptable 37 | behavior and are expected to take appropriate and fair corrective action in 38 | response to any instances of unacceptable behavior. 39 | 40 | Project maintainers have the right and responsibility to remove, edit, or 41 | reject comments, commits, code, wiki edits, issues, and other contributions 42 | that are not aligned to this Code of Conduct, or to ban temporarily or 43 | permanently any contributor for other behaviors that they deem inappropriate, 44 | threatening, offensive, or harmful. 45 | 46 | ## Scope 47 | 48 | This Code of Conduct applies both within project spaces and in public spaces 49 | when an individual is representing the project or its community. Examples of 50 | representing a project or community include using an official project e-mail 51 | address, posting via an official social media account, or acting as an appointed 52 | representative at an online or offline event. Representation of a project may be 53 | further defined and clarified by project maintainers. 54 | 55 | ## Enforcement 56 | 57 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 58 | reported by contacting the project team at hazru.anurag@gmail.com. All 59 | complaints will be reviewed and investigated and will result in a response that 60 | is deemed necessary and appropriate to the circumstances. The project team is 61 | obligated to maintain confidentiality with regard to the reporter of an incident. 62 | Further details of specific enforcement policies may be posted separately. 63 | 64 | Project maintainers who do not follow or enforce the Code of Conduct in good 65 | faith may face temporary or permanent repercussions as determined by other 66 | members of the project's leadership. 67 | 68 | ## Attribution 69 | 70 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, 71 | available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html 72 | 73 | [homepage]: https://www.contributor-covenant.org 74 | 75 | For answers to common questions about this code of conduct, see 76 | https://www.contributor-covenant.org/faq 77 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to [github-readme-stats](https://github.com/anuraghazra/github-readme-stats) 2 | 3 | We love your input! We want to make contributing to this project as easy and transparent as possible, whether it's: 4 | 5 | - Reporting [an issue](https://github.com/anuraghazra/github-readme-stats/issues/new?assignees=&labels=bug&template=bug_report.yml). 6 | - [Discussing](https://github.com/anuraghazra/github-readme-stats/discussions) the current state of the code. 7 | - Submitting [a fix](https://github.com/anuraghazra/github-readme-stats/compare). 8 | - Proposing [new features](https://github.com/anuraghazra/github-readme-stats/issues/new?assignees=&labels=enhancement&template=feature_request.yml). 9 | - Becoming a maintainer. 10 | 11 | ## All Changes Happen Through Pull Requests 12 | 13 | Pull requests are the best way to propose changes. We actively welcome your pull requests: 14 | 15 | 1. Fork the repo and create your branch from `master`. 16 | 2. If you've added code that should be tested, add some tests' examples. 17 | 3. If you've changed APIs, update the documentation. 18 | 4. Issue that pull request! 19 | 20 | ## Under the hood of github-readme-stats 21 | 22 | Interested in diving deeper into understanding how github-readme-stats works? 23 | 24 | [Bohdan](https://github.com/Bogdan-Lyashenko) wrote a fantastic in-depth post about it, check it out: 25 | 26 | **[Under the hood of github-readme-stats project](https://codecrumbs.io/library/github-readme-stats)** 27 | 28 | ## Local Development 29 | 30 | To run & test github-readme-stats, you need to follow a few simple steps:- 31 | _(make sure you already have a [Vercel](https://vercel.com/) account)_ 32 | 33 | 1. Install [Vercel CLI](https://vercel.com/download). 34 | 2. Fork the repository and clone the code to your local machine. 35 | 3. Run `npm install` in the repository root. 36 | 4. Run the command `vercel` in the root and follow the steps there. 37 | 5. Run the command `vercel dev` to start a development server at . 38 | 6. The cards will then be available from this local endpoint (i.e. `http://localhost:3000/api?username=anuraghazra`). 39 | 40 | > [!NOTE]\ 41 | > 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. 42 | 43 | ## Themes Contribution 44 | 45 | We're currently paused addition of new themes to decrease maintenance efforts. All pull requests related to new themes will be closed. 46 | 47 | > [!NOTE]\ 48 | > 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). 49 | 50 | ## Translations Contribution 51 | 52 | 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). 53 | 54 | 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. 55 | 56 | ## Any contributions you make will be under the MIT Software License 57 | 58 | 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. 59 | 60 | ## Report issues/bugs using GitHub's [issues](https://github.com/anuraghazra/github-readme-stats/issues) 61 | 62 | 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! 63 | 64 | ## Frequently Asked Questions (FAQs) 65 | 66 | **Q:** How to hide Jupyter Notebook? 67 | 68 | > **Ans:** &hide=jupyter%20notebook 69 | 70 | **Q:** I could not figure out how to deploy on my own Vercel instance 71 | 72 | > **Ans:** 73 | > 74 | > - docs: 75 | > - YT tutorial by codeSTACKr: 76 | 77 | **Q:** Language Card is incorrect 78 | 79 | > **Ans:** Please read all the related issues/comments before opening any issues regarding language card stats: 80 | > 81 | > - 82 | > 83 | > - 84 | 85 | **Q:** How to count private stats? 86 | 87 | > **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) 88 | 89 | ### Bug Reports 90 | 91 | **Great Bug Reports** tend to have: 92 | 93 | - A quick summary and/or background 94 | - Steps to reproduce 95 | - Be specific! 96 | - Share the snapshot, if possible. 97 | - GitHub Readme Stats' live link 98 | - What actually happens 99 | - What you expected would happen 100 | - Notes (possibly including why you think this might be happening or stuff you tried that didn't work) 101 | 102 | People _love_ thorough bug reports. I'm not even kidding. 103 | 104 | ### Feature Request 105 | 106 | **Great Feature Requests** tend to have: 107 | 108 | - A quick idea summary 109 | - What & why do you want to add the specific feature 110 | - Additional context like images, links to resources to implement the feature, etc. 111 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Anurag Hazra 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | # GitHub Readme Stats Security Policies and Procedures 2 | 3 | This document outlines security procedures and general policies for the 4 | GitHub Readme Stats project. 5 | 6 | - [Reporting a Vulnerability](#reporting-a-vulnerability) 7 | - [Disclosure Policy](#disclosure-policy) 8 | 9 | ## Reporting a Vulnerability 10 | 11 | The GitHub Readme Stats team and community take all security vulnerabilities 12 | seriously. Thank you for improving the security of our open source 13 | software. We appreciate your efforts and responsible disclosure and will 14 | make every effort to acknowledge your contributions. 15 | 16 | Report security vulnerabilities by emailing the GitHub Readme Stats team at: 17 | 18 | ``` 19 | hazru.anurag@gmail.com 20 | ``` 21 | 22 | The lead maintainer will acknowledge your email within 24 hours, and will 23 | send a more detailed response within 48 hours indicating the next steps in 24 | handling your report. After the initial reply to your report, the security 25 | team will endeavor to keep you informed of the progress towards a fix and 26 | full announcement, and may ask for additional information or guidance. 27 | 28 | Report security vulnerabilities in third-party modules to the person or 29 | team maintaining the module. 30 | 31 | ## Disclosure Policy 32 | 33 | When the security team receives a security bug report, they will assign it 34 | to a primary handler. This person will coordinate the fix and release 35 | process, involving the following steps: 36 | 37 | * Confirm the problem. 38 | * Audit code to find any potential similar problems. 39 | * Prepare fixes and release them as fast as possible. 40 | -------------------------------------------------------------------------------- /api/gist.js: -------------------------------------------------------------------------------- 1 | import { 2 | clampValue, 3 | CONSTANTS, 4 | renderError, 5 | parseBoolean, 6 | } from "../src/common/utils.js"; 7 | import { isLocaleAvailable } from "../src/translations.js"; 8 | import { renderGistCard } from "../src/cards/gist-card.js"; 9 | import { fetchGist } from "../src/fetchers/gist-fetcher.js"; 10 | 11 | export default async (req, res) => { 12 | const { 13 | id, 14 | title_color, 15 | icon_color, 16 | text_color, 17 | bg_color, 18 | theme, 19 | cache_seconds, 20 | locale, 21 | border_radius, 22 | border_color, 23 | show_owner, 24 | hide_border, 25 | } = req.query; 26 | 27 | res.setHeader("Content-Type", "image/svg+xml"); 28 | 29 | if (locale && !isLocaleAvailable(locale)) { 30 | return res.send( 31 | renderError("Something went wrong", "Language not found", { 32 | title_color, 33 | text_color, 34 | bg_color, 35 | border_color, 36 | theme, 37 | }), 38 | ); 39 | } 40 | 41 | try { 42 | const gistData = await fetchGist(id); 43 | 44 | let cacheSeconds = clampValue( 45 | parseInt(cache_seconds || CONSTANTS.SIX_HOURS, 10), 46 | CONSTANTS.SIX_HOURS, 47 | CONSTANTS.ONE_DAY, 48 | ); 49 | cacheSeconds = process.env.CACHE_SECONDS 50 | ? parseInt(process.env.CACHE_SECONDS, 10) || cacheSeconds 51 | : cacheSeconds; 52 | 53 | /* 54 | if star count & fork count is over 1k then we are kFormating the text 55 | and if both are zero we are not showing the stats 56 | so we can just make the cache longer, since there is no need to frequent updates 57 | */ 58 | const stars = gistData.starsCount; 59 | const forks = gistData.forksCount; 60 | const isBothOver1K = stars > 1000 && forks > 1000; 61 | const isBothUnder1 = stars < 1 && forks < 1; 62 | if (!cache_seconds && (isBothOver1K || isBothUnder1)) { 63 | cacheSeconds = CONSTANTS.SIX_HOURS; 64 | } 65 | 66 | res.setHeader( 67 | "Cache-Control", 68 | `max-age=${ 69 | cacheSeconds / 2 70 | }, s-maxage=${cacheSeconds}, stale-while-revalidate=${CONSTANTS.ONE_DAY}`, 71 | ); 72 | 73 | return res.send( 74 | renderGistCard(gistData, { 75 | title_color, 76 | icon_color, 77 | text_color, 78 | bg_color, 79 | theme, 80 | border_radius, 81 | border_color, 82 | locale: locale ? locale.toLowerCase() : null, 83 | show_owner: parseBoolean(show_owner), 84 | hide_border: parseBoolean(hide_border), 85 | }), 86 | ); 87 | } catch (err) { 88 | res.setHeader( 89 | "Cache-Control", 90 | `max-age=${CONSTANTS.ERROR_CACHE_SECONDS / 2}, s-maxage=${ 91 | CONSTANTS.ERROR_CACHE_SECONDS 92 | }, stale-while-revalidate=${CONSTANTS.ONE_DAY}`, 93 | ); // Use lower cache period for errors. 94 | return res.send( 95 | renderError(err.message, err.secondaryMessage, { 96 | title_color, 97 | text_color, 98 | bg_color, 99 | border_color, 100 | theme, 101 | }), 102 | ); 103 | } 104 | }; 105 | -------------------------------------------------------------------------------- /api/index.js: -------------------------------------------------------------------------------- 1 | import { renderStatsCard } from "../src/cards/stats-card.js"; 2 | import { blacklist } from "../src/common/blacklist.js"; 3 | import { 4 | clampValue, 5 | CONSTANTS, 6 | parseArray, 7 | parseBoolean, 8 | renderError, 9 | } from "../src/common/utils.js"; 10 | import { fetchStats } from "../src/fetchers/stats-fetcher.js"; 11 | import { isLocaleAvailable } from "../src/translations.js"; 12 | 13 | export default async (req, res) => { 14 | const { 15 | username, 16 | hide, 17 | hide_title, 18 | hide_border, 19 | card_width, 20 | hide_rank, 21 | show_icons, 22 | include_all_commits, 23 | line_height, 24 | title_color, 25 | ring_color, 26 | icon_color, 27 | text_color, 28 | text_bold, 29 | bg_color, 30 | theme, 31 | cache_seconds, 32 | exclude_repo, 33 | custom_title, 34 | locale, 35 | disable_animations, 36 | border_radius, 37 | number_format, 38 | border_color, 39 | rank_icon, 40 | show, 41 | } = req.query; 42 | res.setHeader("Content-Type", "image/svg+xml"); 43 | 44 | if (blacklist.includes(username)) { 45 | return res.send( 46 | renderError("Something went wrong", "This username is blacklisted", { 47 | title_color, 48 | text_color, 49 | bg_color, 50 | border_color, 51 | theme, 52 | }), 53 | ); 54 | } 55 | 56 | if (locale && !isLocaleAvailable(locale)) { 57 | return res.send( 58 | renderError("Something went wrong", "Language not found", { 59 | title_color, 60 | text_color, 61 | bg_color, 62 | border_color, 63 | theme, 64 | }), 65 | ); 66 | } 67 | 68 | try { 69 | const showStats = parseArray(show); 70 | const stats = await fetchStats( 71 | username, 72 | parseBoolean(include_all_commits), 73 | parseArray(exclude_repo), 74 | showStats.includes("prs_merged") || 75 | showStats.includes("prs_merged_percentage"), 76 | showStats.includes("discussions_started"), 77 | showStats.includes("discussions_answered"), 78 | ); 79 | 80 | let cacheSeconds = clampValue( 81 | parseInt(cache_seconds || CONSTANTS.CARD_CACHE_SECONDS, 10), 82 | CONSTANTS.SIX_HOURS, 83 | CONSTANTS.ONE_DAY, 84 | ); 85 | cacheSeconds = process.env.CACHE_SECONDS 86 | ? parseInt(process.env.CACHE_SECONDS, 10) || cacheSeconds 87 | : cacheSeconds; 88 | 89 | res.setHeader( 90 | "Cache-Control", 91 | `max-age=${ 92 | cacheSeconds / 2 93 | }, s-maxage=${cacheSeconds}, stale-while-revalidate=${CONSTANTS.ONE_DAY}`, 94 | ); 95 | 96 | return res.send( 97 | renderStatsCard(stats, { 98 | hide: parseArray(hide), 99 | show_icons: parseBoolean(show_icons), 100 | hide_title: parseBoolean(hide_title), 101 | hide_border: parseBoolean(hide_border), 102 | card_width: parseInt(card_width, 10), 103 | hide_rank: parseBoolean(hide_rank), 104 | include_all_commits: parseBoolean(include_all_commits), 105 | line_height, 106 | title_color, 107 | ring_color, 108 | icon_color, 109 | text_color, 110 | text_bold: parseBoolean(text_bold), 111 | bg_color, 112 | theme, 113 | custom_title, 114 | border_radius, 115 | border_color, 116 | number_format, 117 | locale: locale ? locale.toLowerCase() : null, 118 | disable_animations: parseBoolean(disable_animations), 119 | rank_icon, 120 | show: showStats, 121 | }), 122 | ); 123 | } catch (err) { 124 | res.setHeader( 125 | "Cache-Control", 126 | `max-age=${CONSTANTS.ERROR_CACHE_SECONDS / 2}, s-maxage=${ 127 | CONSTANTS.ERROR_CACHE_SECONDS 128 | }, stale-while-revalidate=${CONSTANTS.ONE_DAY}`, 129 | ); // Use lower cache period for errors. 130 | return res.send( 131 | renderError(err.message, err.secondaryMessage, { 132 | title_color, 133 | text_color, 134 | bg_color, 135 | border_color, 136 | theme, 137 | }), 138 | ); 139 | } 140 | }; 141 | -------------------------------------------------------------------------------- /api/pin.js: -------------------------------------------------------------------------------- 1 | import { renderRepoCard } from "../src/cards/repo-card.js"; 2 | import { blacklist } from "../src/common/blacklist.js"; 3 | import { 4 | clampValue, 5 | CONSTANTS, 6 | parseBoolean, 7 | renderError, 8 | } from "../src/common/utils.js"; 9 | import { fetchRepo } from "../src/fetchers/repo-fetcher.js"; 10 | import { isLocaleAvailable } from "../src/translations.js"; 11 | 12 | export default async (req, res) => { 13 | const { 14 | username, 15 | repo, 16 | hide_border, 17 | title_color, 18 | icon_color, 19 | text_color, 20 | bg_color, 21 | theme, 22 | show_owner, 23 | cache_seconds, 24 | locale, 25 | border_radius, 26 | border_color, 27 | description_lines_count, 28 | } = req.query; 29 | 30 | res.setHeader("Content-Type", "image/svg+xml"); 31 | 32 | if (blacklist.includes(username)) { 33 | return res.send( 34 | renderError("Something went wrong", "This username is blacklisted", { 35 | title_color, 36 | text_color, 37 | bg_color, 38 | border_color, 39 | theme, 40 | }), 41 | ); 42 | } 43 | 44 | if (locale && !isLocaleAvailable(locale)) { 45 | return res.send( 46 | renderError("Something went wrong", "Language not found", { 47 | title_color, 48 | text_color, 49 | bg_color, 50 | border_color, 51 | theme, 52 | }), 53 | ); 54 | } 55 | 56 | try { 57 | const repoData = await fetchRepo(username, repo); 58 | 59 | let cacheSeconds = clampValue( 60 | parseInt(cache_seconds || CONSTANTS.CARD_CACHE_SECONDS, 10), 61 | CONSTANTS.SIX_HOURS, 62 | CONSTANTS.ONE_DAY, 63 | ); 64 | cacheSeconds = process.env.CACHE_SECONDS 65 | ? parseInt(process.env.CACHE_SECONDS, 10) || cacheSeconds 66 | : cacheSeconds; 67 | 68 | /* 69 | if star count & fork count is over 1k then we are kFormating the text 70 | and if both are zero we are not showing the stats 71 | so we can just make the cache longer, since there is no need to frequent updates 72 | */ 73 | const stars = repoData.starCount; 74 | const forks = repoData.forkCount; 75 | const isBothOver1K = stars > 1000 && forks > 1000; 76 | const isBothUnder1 = stars < 1 && forks < 1; 77 | if (!cache_seconds && (isBothOver1K || isBothUnder1)) { 78 | cacheSeconds = CONSTANTS.SIX_HOURS; 79 | } 80 | 81 | res.setHeader( 82 | "Cache-Control", 83 | `max-age=${ 84 | cacheSeconds / 2 85 | }, s-maxage=${cacheSeconds}, stale-while-revalidate=${CONSTANTS.ONE_DAY}`, 86 | ); 87 | 88 | return res.send( 89 | renderRepoCard(repoData, { 90 | hide_border: parseBoolean(hide_border), 91 | title_color, 92 | icon_color, 93 | text_color, 94 | bg_color, 95 | theme, 96 | border_radius, 97 | border_color, 98 | show_owner: parseBoolean(show_owner), 99 | locale: locale ? locale.toLowerCase() : null, 100 | description_lines_count, 101 | }), 102 | ); 103 | } catch (err) { 104 | res.setHeader( 105 | "Cache-Control", 106 | `max-age=${CONSTANTS.ERROR_CACHE_SECONDS / 2}, s-maxage=${ 107 | CONSTANTS.ERROR_CACHE_SECONDS 108 | }, stale-while-revalidate=${CONSTANTS.ONE_DAY}`, 109 | ); // Use lower cache period for errors. 110 | return res.send( 111 | renderError(err.message, err.secondaryMessage, { 112 | title_color, 113 | text_color, 114 | bg_color, 115 | border_color, 116 | theme, 117 | }), 118 | ); 119 | } 120 | }; 121 | -------------------------------------------------------------------------------- /api/status/pat-info.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @file Contains a simple cloud function that can be used to check which PATs are no 3 | * longer working. It returns a list of valid PATs, expired PATs and PATs with errors. 4 | * 5 | * @description This function is currently rate limited to 1 request per 5 minutes. 6 | */ 7 | 8 | import { logger, request, dateDiff } from "../../src/common/utils.js"; 9 | export const RATE_LIMIT_SECONDS = 60 * 5; // 1 request per 5 minutes 10 | 11 | /** 12 | * @typedef {import('axios').AxiosRequestHeaders} AxiosRequestHeaders Axios request headers. 13 | * @typedef {import('axios').AxiosResponse} AxiosResponse Axios response. 14 | */ 15 | 16 | /** 17 | * Simple uptime check fetcher for the PATs. 18 | * 19 | * @param {AxiosRequestHeaders} variables Fetcher variables. 20 | * @param {string} token GitHub token. 21 | * @returns {Promise} The response. 22 | */ 23 | const uptimeFetcher = (variables, token) => { 24 | return request( 25 | { 26 | query: ` 27 | query { 28 | rateLimit { 29 | remaining 30 | resetAt 31 | }, 32 | }`, 33 | variables, 34 | }, 35 | { 36 | Authorization: `bearer ${token}`, 37 | }, 38 | ); 39 | }; 40 | 41 | const getAllPATs = () => { 42 | return Object.keys(process.env).filter((key) => /PAT_\d*$/.exec(key)); 43 | }; 44 | 45 | /** 46 | * @typedef {(variables: AxiosRequestHeaders, token: string) => Promise} Fetcher The fetcher function. 47 | * @typedef {{validPATs: string[], expiredPATs: string[], exhaustedPATs: string[], suspendedPATs: string[], errorPATs: string[], details: any}} PATInfo The PAT info. 48 | */ 49 | 50 | /** 51 | * Check whether any of the PATs is expired. 52 | * 53 | * @param {Fetcher} fetcher The fetcher function. 54 | * @param {AxiosRequestHeaders} variables Fetcher variables. 55 | * @returns {Promise} The response. 56 | */ 57 | const getPATInfo = async (fetcher, variables) => { 58 | const details = {}; 59 | const PATs = getAllPATs(); 60 | 61 | for (const pat of PATs) { 62 | try { 63 | const response = await fetcher(variables, process.env[pat]); 64 | const errors = response.data.errors; 65 | const hasErrors = Boolean(errors); 66 | const errorType = errors?.[0]?.type; 67 | const isRateLimited = 68 | (hasErrors && errorType === "RATE_LIMITED") || 69 | response.data.data?.rateLimit?.remaining === 0; 70 | 71 | // Store PATs with errors. 72 | if (hasErrors && errorType !== "RATE_LIMITED") { 73 | details[pat] = { 74 | status: "error", 75 | error: { 76 | type: errors[0].type, 77 | message: errors[0].message, 78 | }, 79 | }; 80 | continue; 81 | } else if (isRateLimited) { 82 | const date1 = new Date(); 83 | const date2 = new Date(response.data?.data?.rateLimit?.resetAt); 84 | details[pat] = { 85 | status: "exhausted", 86 | remaining: 0, 87 | resetIn: dateDiff(date2, date1) + " minutes", 88 | }; 89 | } else { 90 | details[pat] = { 91 | status: "valid", 92 | remaining: response.data.data.rateLimit.remaining, 93 | }; 94 | } 95 | } catch (err) { 96 | // Store the PAT if it is expired. 97 | const errorMessage = err.response?.data?.message?.toLowerCase(); 98 | if (errorMessage === "bad credentials") { 99 | details[pat] = { 100 | status: "expired", 101 | }; 102 | } else if (errorMessage === "sorry. your account was suspended.") { 103 | details[pat] = { 104 | status: "suspended", 105 | }; 106 | } else { 107 | throw err; 108 | } 109 | } 110 | } 111 | 112 | const filterPATsByStatus = (status) => { 113 | return Object.keys(details).filter((pat) => details[pat].status === status); 114 | }; 115 | 116 | const sortedDetails = Object.keys(details) 117 | .sort() 118 | .reduce((obj, key) => { 119 | obj[key] = details[key]; 120 | return obj; 121 | }, {}); 122 | 123 | return { 124 | validPATs: filterPATsByStatus("valid"), 125 | expiredPATs: filterPATsByStatus("expired"), 126 | exhaustedPATs: filterPATsByStatus("exhausted"), 127 | suspendedPATs: filterPATsByStatus("suspended"), 128 | errorPATs: filterPATsByStatus("error"), 129 | details: sortedDetails, 130 | }; 131 | }; 132 | 133 | /** 134 | * Cloud function that returns information about the used PATs. 135 | * 136 | * @param {any} _ The request. 137 | * @param {any} res The response. 138 | * @returns {Promise} The response. 139 | */ 140 | export default async (_, res) => { 141 | res.setHeader("Content-Type", "application/json"); 142 | try { 143 | // Add header to prevent abuse. 144 | const PATsInfo = await getPATInfo(uptimeFetcher, {}); 145 | if (PATsInfo) { 146 | res.setHeader( 147 | "Cache-Control", 148 | `max-age=0, s-maxage=${RATE_LIMIT_SECONDS}`, 149 | ); 150 | } 151 | res.send(JSON.stringify(PATsInfo, null, 2)); 152 | } catch (err) { 153 | // Throw error if something went wrong. 154 | logger.error(err); 155 | res.setHeader("Cache-Control", "no-store"); 156 | res.send("Something went wrong: " + err.message); 157 | } 158 | }; 159 | -------------------------------------------------------------------------------- /api/status/up.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @file Contains a simple cloud function that can be used to check if the PATs are still 3 | * functional. 4 | * 5 | * @description This function is currently rate limited to 1 request per 5 minutes. 6 | */ 7 | 8 | import retryer from "../../src/common/retryer.js"; 9 | import { logger, request } from "../../src/common/utils.js"; 10 | 11 | export const RATE_LIMIT_SECONDS = 60 * 5; // 1 request per 5 minutes 12 | 13 | /** 14 | * @typedef {import('axios').AxiosRequestHeaders} AxiosRequestHeaders Axios request headers. 15 | * @typedef {import('axios').AxiosResponse} AxiosResponse Axios response. 16 | */ 17 | 18 | /** 19 | * Simple uptime check fetcher for the PATs. 20 | * 21 | * @param {AxiosRequestHeaders} variables Fetcher variables. 22 | * @param {string} token GitHub token. 23 | * @returns {Promise} The response. 24 | */ 25 | const uptimeFetcher = (variables, token) => { 26 | return request( 27 | { 28 | query: ` 29 | query { 30 | rateLimit { 31 | remaining 32 | } 33 | } 34 | `, 35 | variables, 36 | }, 37 | { 38 | Authorization: `bearer ${token}`, 39 | }, 40 | ); 41 | }; 42 | 43 | /** 44 | * @typedef {{ 45 | * schemaVersion: number; 46 | * label: string; 47 | * message: "up" | "down"; 48 | * color: "brightgreen" | "red"; 49 | * isError: boolean 50 | * }} ShieldsResponse Shields.io response object. 51 | */ 52 | 53 | /** 54 | * Creates Json response that can be used for shields.io dynamic card generation. 55 | * 56 | * @param {boolean} up Whether the PATs are up or not. 57 | * @returns {ShieldsResponse} Dynamic shields.io JSON response object. 58 | * 59 | * @see https://shields.io/endpoint. 60 | */ 61 | const shieldsUptimeBadge = (up) => { 62 | const schemaVersion = 1; 63 | const isError = true; 64 | const label = "Public Instance"; 65 | const message = up ? "up" : "down"; 66 | const color = up ? "brightgreen" : "red"; 67 | return { 68 | schemaVersion, 69 | label, 70 | message, 71 | color, 72 | isError, 73 | }; 74 | }; 75 | 76 | /** 77 | * Cloud function that returns whether the PATs are still functional. 78 | * 79 | * @param {any} req The request. 80 | * @param {any} res The response. 81 | * @returns {Promise} Nothing. 82 | */ 83 | export default async (req, res) => { 84 | let { type } = req.query; 85 | type = type ? type.toLowerCase() : "boolean"; 86 | 87 | res.setHeader("Content-Type", "application/json"); 88 | 89 | try { 90 | let PATsValid = true; 91 | try { 92 | await retryer(uptimeFetcher, {}); 93 | } catch (err) { 94 | PATsValid = false; 95 | } 96 | 97 | if (PATsValid) { 98 | res.setHeader( 99 | "Cache-Control", 100 | `max-age=0, s-maxage=${RATE_LIMIT_SECONDS}`, 101 | ); 102 | } else { 103 | res.setHeader("Cache-Control", "no-store"); 104 | } 105 | 106 | switch (type) { 107 | case "shields": 108 | res.send(shieldsUptimeBadge(PATsValid)); 109 | break; 110 | case "json": 111 | res.send({ up: PATsValid }); 112 | break; 113 | default: 114 | res.send(PATsValid); 115 | break; 116 | } 117 | } catch (err) { 118 | // Return fail boolean if something went wrong. 119 | logger.error(err); 120 | res.setHeader("Cache-Control", "no-store"); 121 | res.send("Something went wrong: " + err.message); 122 | } 123 | }; 124 | -------------------------------------------------------------------------------- /api/top-langs.js: -------------------------------------------------------------------------------- 1 | import { renderTopLanguages } from "../src/cards/top-languages-card.js"; 2 | import { blacklist } from "../src/common/blacklist.js"; 3 | import { 4 | clampValue, 5 | CONSTANTS, 6 | parseArray, 7 | parseBoolean, 8 | renderError, 9 | } from "../src/common/utils.js"; 10 | import { fetchTopLanguages } from "../src/fetchers/top-languages-fetcher.js"; 11 | import { isLocaleAvailable } from "../src/translations.js"; 12 | 13 | export default async (req, res) => { 14 | const { 15 | username, 16 | hide, 17 | hide_title, 18 | hide_border, 19 | card_width, 20 | title_color, 21 | text_color, 22 | bg_color, 23 | theme, 24 | cache_seconds, 25 | layout, 26 | langs_count, 27 | exclude_repo, 28 | size_weight, 29 | count_weight, 30 | custom_title, 31 | locale, 32 | border_radius, 33 | border_color, 34 | disable_animations, 35 | hide_progress, 36 | } = req.query; 37 | res.setHeader("Content-Type", "image/svg+xml"); 38 | 39 | if (blacklist.includes(username)) { 40 | return res.send( 41 | renderError("Something went wrong", "This username is blacklisted", { 42 | title_color, 43 | text_color, 44 | bg_color, 45 | border_color, 46 | theme, 47 | }), 48 | ); 49 | } 50 | 51 | if (locale && !isLocaleAvailable(locale)) { 52 | return res.send(renderError("Something went wrong", "Locale not found")); 53 | } 54 | 55 | if ( 56 | layout !== undefined && 57 | (typeof layout !== "string" || 58 | !["compact", "normal", "donut", "donut-vertical", "pie"].includes(layout)) 59 | ) { 60 | return res.send( 61 | renderError("Something went wrong", "Incorrect layout input"), 62 | ); 63 | } 64 | 65 | try { 66 | const topLangs = await fetchTopLanguages( 67 | username, 68 | parseArray(exclude_repo), 69 | size_weight, 70 | count_weight, 71 | ); 72 | 73 | let cacheSeconds = clampValue( 74 | parseInt(cache_seconds || CONSTANTS.CARD_CACHE_SECONDS, 10), 75 | CONSTANTS.SIX_HOURS, 76 | CONSTANTS.ONE_DAY, 77 | ); 78 | cacheSeconds = process.env.CACHE_SECONDS 79 | ? parseInt(process.env.CACHE_SECONDS, 10) || cacheSeconds 80 | : cacheSeconds; 81 | 82 | res.setHeader( 83 | "Cache-Control", 84 | `max-age=${ 85 | cacheSeconds / 2 86 | }, s-maxage=${cacheSeconds}, stale-while-revalidate=${CONSTANTS.ONE_DAY}`, 87 | ); 88 | 89 | return res.send( 90 | renderTopLanguages(topLangs, { 91 | custom_title, 92 | hide_title: parseBoolean(hide_title), 93 | hide_border: parseBoolean(hide_border), 94 | card_width: parseInt(card_width, 10), 95 | hide: parseArray(hide), 96 | title_color, 97 | text_color, 98 | bg_color, 99 | theme, 100 | layout, 101 | langs_count, 102 | border_radius, 103 | border_color, 104 | locale: locale ? locale.toLowerCase() : null, 105 | disable_animations: parseBoolean(disable_animations), 106 | hide_progress: parseBoolean(hide_progress), 107 | }), 108 | ); 109 | } catch (err) { 110 | res.setHeader( 111 | "Cache-Control", 112 | `max-age=${CONSTANTS.ERROR_CACHE_SECONDS / 2}, s-maxage=${ 113 | CONSTANTS.ERROR_CACHE_SECONDS 114 | }, stale-while-revalidate=${CONSTANTS.ONE_DAY}`, 115 | ); // Use lower cache period for errors. 116 | return res.send( 117 | renderError(err.message, err.secondaryMessage, { 118 | title_color, 119 | text_color, 120 | bg_color, 121 | border_color, 122 | theme, 123 | }), 124 | ); 125 | } 126 | }; 127 | -------------------------------------------------------------------------------- /api/wakatime.js: -------------------------------------------------------------------------------- 1 | import { renderWakatimeCard } from "../src/cards/wakatime-card.js"; 2 | import { 3 | clampValue, 4 | CONSTANTS, 5 | parseArray, 6 | parseBoolean, 7 | renderError, 8 | } from "../src/common/utils.js"; 9 | import { fetchWakatimeStats } from "../src/fetchers/wakatime-fetcher.js"; 10 | import { isLocaleAvailable } from "../src/translations.js"; 11 | 12 | export default async (req, res) => { 13 | const { 14 | username, 15 | title_color, 16 | icon_color, 17 | hide_border, 18 | line_height, 19 | text_color, 20 | bg_color, 21 | theme, 22 | cache_seconds, 23 | hide_title, 24 | hide_progress, 25 | custom_title, 26 | locale, 27 | layout, 28 | langs_count, 29 | hide, 30 | api_domain, 31 | border_radius, 32 | border_color, 33 | display_format, 34 | disable_animations, 35 | } = req.query; 36 | 37 | res.setHeader("Content-Type", "image/svg+xml"); 38 | 39 | if (locale && !isLocaleAvailable(locale)) { 40 | return res.send( 41 | renderError("Something went wrong", "Language not found", { 42 | title_color, 43 | text_color, 44 | bg_color, 45 | border_color, 46 | theme, 47 | }), 48 | ); 49 | } 50 | 51 | try { 52 | const stats = await fetchWakatimeStats({ username, api_domain }); 53 | 54 | let cacheSeconds = clampValue( 55 | parseInt(cache_seconds || CONSTANTS.CARD_CACHE_SECONDS, 10), 56 | CONSTANTS.SIX_HOURS, 57 | CONSTANTS.ONE_DAY, 58 | ); 59 | cacheSeconds = process.env.CACHE_SECONDS 60 | ? parseInt(process.env.CACHE_SECONDS, 10) || cacheSeconds 61 | : cacheSeconds; 62 | 63 | res.setHeader( 64 | "Cache-Control", 65 | `max-age=${ 66 | cacheSeconds / 2 67 | }, s-maxage=${cacheSeconds}, stale-while-revalidate=${CONSTANTS.ONE_DAY}`, 68 | ); 69 | 70 | return res.send( 71 | renderWakatimeCard(stats, { 72 | custom_title, 73 | hide_title: parseBoolean(hide_title), 74 | hide_border: parseBoolean(hide_border), 75 | hide: parseArray(hide), 76 | line_height, 77 | title_color, 78 | icon_color, 79 | text_color, 80 | bg_color, 81 | theme, 82 | hide_progress, 83 | border_radius, 84 | border_color, 85 | locale: locale ? locale.toLowerCase() : null, 86 | layout, 87 | langs_count, 88 | display_format, 89 | disable_animations: parseBoolean(disable_animations), 90 | }), 91 | ); 92 | } catch (err) { 93 | res.setHeader( 94 | "Cache-Control", 95 | `max-age=${CONSTANTS.ERROR_CACHE_SECONDS / 2}, s-maxage=${ 96 | CONSTANTS.ERROR_CACHE_SECONDS 97 | }, stale-while-revalidate=${CONSTANTS.ONE_DAY}`, 98 | ); // Use lower cache period for errors. 99 | return res.send( 100 | renderError(err.message, err.secondaryMessage, { 101 | title_color, 102 | text_color, 103 | bg_color, 104 | border_color, 105 | theme, 106 | }), 107 | ); 108 | } 109 | }; 110 | -------------------------------------------------------------------------------- /codecov.yml: -------------------------------------------------------------------------------- 1 | codecov: 2 | require_ci_to_pass: yes 3 | 4 | coverage: 5 | precision: 2 6 | round: down 7 | range: "70...100" 8 | 9 | status: 10 | project: 11 | default: 12 | threshold: 5 13 | patch: false 14 | -------------------------------------------------------------------------------- /express.js: -------------------------------------------------------------------------------- 1 | import "dotenv/config"; 2 | import statsCard from "./api/index.js"; 3 | import repoCard from "./api/pin.js"; 4 | import langCard from "./api/top-langs.js"; 5 | import wakatimeCard from "./api/wakatime.js"; 6 | import gistCard from "./api/gist.js"; 7 | import express from "express"; 8 | 9 | const app = express(); 10 | app.listen(process.env.port || 9000); 11 | 12 | app.get("/", statsCard); 13 | app.get("/pin", repoCard); 14 | app.get("/top-langs", langCard); 15 | app.get("/wakatime", wakatimeCard); 16 | app.get("/gist", gistCard); 17 | -------------------------------------------------------------------------------- /jest.bench.config.js: -------------------------------------------------------------------------------- 1 | export default { 2 | // Jest-bench need its own test environment to function 3 | testEnvironment: "jest-bench/environment", 4 | testEnvironmentOptions: { 5 | // still Jest-bench environment will run your environment if you specify it here 6 | testEnvironment: "jest-environment-node", 7 | testEnvironmentOptions: { 8 | // specify any option for your environment 9 | }, 10 | }, 11 | // always include "default" reporter along with Jest-bench reporter 12 | // for error reporting 13 | reporters: ["default", "jest-bench/reporter"], 14 | // will pick up "*.bench.js" file. 15 | testRegex: "(\\.bench)\\.(ts|tsx|js)$", 16 | }; 17 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | export default { 2 | clearMocks: true, 3 | transform: {}, 4 | testEnvironment: "jsdom", 5 | coverageProvider: "v8", 6 | testPathIgnorePatterns: ["/node_modules/", "/tests/e2e/"], 7 | modulePathIgnorePatterns: ["/node_modules/", "/tests/e2e/"], 8 | coveragePathIgnorePatterns: [ 9 | "/node_modules/", 10 | "/tests/E2E/", 11 | ], 12 | }; 13 | -------------------------------------------------------------------------------- /jest.e2e.config.js: -------------------------------------------------------------------------------- 1 | export default { 2 | clearMocks: true, 3 | transform: {}, 4 | testEnvironment: "node", 5 | coverageProvider: "v8", 6 | testMatch: ["/tests/e2e/**/*.test.js"], 7 | }; 8 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "github-readme-stats", 3 | "version": "1.0.0", 4 | "description": "Dynamically generate stats for your GitHub readme", 5 | "keywords": [ 6 | "github-readme-stats", 7 | "readme-stats", 8 | "cards", 9 | "card-generator" 10 | ], 11 | "main": "src/index.js", 12 | "type": "module", 13 | "homepage": "https://github.com/anuraghazra/github-readme-stats", 14 | "bugs": { 15 | "url": "https://github.com/anuraghazra/github-readme-stats/issues" 16 | }, 17 | "repository": { 18 | "type": "git", 19 | "url": "https://github.com/anuraghazra/github-readme-stats.git" 20 | }, 21 | "scripts": { 22 | "test": "node --experimental-vm-modules node_modules/jest/bin/jest.js --coverage", 23 | "test:watch": "node --experimental-vm-modules node_modules/jest/bin/jest.js --watch", 24 | "test:update:snapshot": "node --experimental-vm-modules node_modules/jest/bin/jest.js -u", 25 | "test:e2e": "node --experimental-vm-modules node_modules/jest/bin/jest.js --config jest.e2e.config.js", 26 | "theme-readme-gen": "node scripts/generate-theme-doc", 27 | "preview-theme": "node scripts/preview-theme", 28 | "close-stale-theme-prs": "node scripts/close-stale-theme-prs", 29 | "generate-langs-json": "node scripts/generate-langs-json", 30 | "format": "prettier --write .", 31 | "format:check": "prettier --check .", 32 | "prepare": "husky", 33 | "lint": "npx eslint --max-warnings 0 \"./src/**/*.js\" \"./scripts/**/*.js\" \"./tests/**/*.js\" \"./api/**/*.js\" \"./themes/**/*.js\"", 34 | "bench": "node --experimental-vm-modules node_modules/jest/bin/jest.js --config jest.bench.config.js" 35 | }, 36 | "author": "Anurag Hazra", 37 | "license": "MIT", 38 | "devDependencies": { 39 | "@actions/core": "^1.10.1", 40 | "@actions/github": "^6.0.0", 41 | "@testing-library/dom": "^9.3.4", 42 | "@testing-library/jest-dom": "^6.4.2", 43 | "@uppercod/css-to-object": "^1.1.1", 44 | "axios-mock-adapter": "^1.22.0", 45 | "color-contrast-checker": "^2.1.0", 46 | "eslint": "^8.57.0", 47 | "eslint-config-prettier": "^9.1.0", 48 | "hjson": "^3.2.2", 49 | "husky": "^9.0.11", 50 | "jest": "^29.7.0", 51 | "jest-bench": "^29.7.1", 52 | "jest-environment-jsdom": "^29.7.0", 53 | "js-yaml": "^4.1.0", 54 | "lint-staged": "^15.2.2", 55 | "lodash.snakecase": "^4.1.1", 56 | "parse-diff": "^0.11.1", 57 | "prettier": "^3.2.5" 58 | }, 59 | "dependencies": { 60 | "axios": "^1.6.7", 61 | "dotenv": "^16.4.5", 62 | "emoji-name-map": "^1.2.8", 63 | "github-username-regex": "^1.0.0", 64 | "upgrade": "^1.1.0", 65 | "word-wrap": "^1.2.5" 66 | }, 67 | "lint-staged": { 68 | "*.{js,css,md}": "prettier --write" 69 | }, 70 | "engines": { 71 | "node": ">=18.0.0" 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /powered-by-vercel.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /scripts/close-stale-theme-prs.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @file Script that can be used to close stale theme PRs that have a `invalid` label. 3 | */ 4 | import * as dotenv from "dotenv"; 5 | dotenv.config(); 6 | 7 | import { debug, setFailed } from "@actions/core"; 8 | import github from "@actions/github"; 9 | import { RequestError } from "@octokit/request-error"; 10 | import { getGithubToken, getRepoInfo } from "./helpers.js"; 11 | 12 | const CLOSING_COMMENT = ` 13 | \rThis theme PR has been automatically closed due to inactivity. Please reopen it if you want to continue working on it.\ 14 | \rThank you for your contributions. 15 | `; 16 | const REVIEWER = "github-actions[bot]"; 17 | 18 | /** 19 | * Retrieve the review user. 20 | * @returns {string} review user. 21 | */ 22 | const getReviewer = () => { 23 | return process.env.REVIEWER ? process.env.REVIEWER : REVIEWER; 24 | }; 25 | 26 | /** 27 | * Fetch open PRs from a given repository. 28 | * 29 | * @param {module:@actions/github.Octokit} octokit The octokit client. 30 | * @param {string} user The user name of the repository owner. 31 | * @param {string} repo The name of the repository. 32 | * @param {string} reviewer The reviewer to filter by. 33 | * @returns {Promise} The open PRs. 34 | */ 35 | export const fetchOpenPRs = async (octokit, user, repo, reviewer) => { 36 | const openPRs = []; 37 | let hasNextPage = true; 38 | let endCursor; 39 | while (hasNextPage) { 40 | try { 41 | const { repository } = await octokit.graphql( 42 | ` 43 | { 44 | repository(owner: "${user}", name: "${repo}") { 45 | open_prs: pullRequests(${ 46 | endCursor ? `after: "${endCursor}", ` : "" 47 | } 48 | first: 100, states: OPEN, orderBy: {field: CREATED_AT, direction: DESC}) { 49 | nodes { 50 | number 51 | commits(last:1){ 52 | nodes{ 53 | commit{ 54 | pushedDate 55 | } 56 | } 57 | } 58 | labels(first: 100, orderBy:{field: CREATED_AT, direction: DESC}) { 59 | nodes { 60 | name 61 | } 62 | } 63 | reviews(first: 100, states: CHANGES_REQUESTED, author: "${reviewer}") { 64 | nodes { 65 | submittedAt 66 | } 67 | } 68 | } 69 | pageInfo { 70 | endCursor 71 | hasNextPage 72 | } 73 | } 74 | } 75 | } 76 | `, 77 | ); 78 | openPRs.push(...repository.open_prs.nodes); 79 | hasNextPage = repository.open_prs.pageInfo.hasNextPage; 80 | endCursor = repository.open_prs.pageInfo.endCursor; 81 | } catch (error) { 82 | if (error instanceof RequestError) { 83 | setFailed(`Could not retrieve top PRs using GraphQl: ${error.message}`); 84 | } 85 | throw error; 86 | } 87 | } 88 | return openPRs; 89 | }; 90 | 91 | /** 92 | * Retrieve pull requests that have a given label. 93 | * 94 | * @param {Object[]} pulls The pull requests to check. 95 | * @param {string} label The label to check for. 96 | * @returns {Object[]} The pull requests that have the given label. 97 | */ 98 | export const pullsWithLabel = (pulls, label) => { 99 | return pulls.filter((pr) => { 100 | return pr.labels.nodes.some((lab) => lab.name === label); 101 | }); 102 | }; 103 | 104 | /** 105 | * Check if PR is stale. Meaning that it hasn't been updated in a given time. 106 | * 107 | * @param {Object} pullRequest request object. 108 | * @param {number} staleDays number of days. 109 | * @returns {boolean} indicating if PR is stale. 110 | */ 111 | const isStale = (pullRequest, staleDays) => { 112 | const lastCommitDate = new Date( 113 | pullRequest.commits.nodes[0].commit.pushedDate, 114 | ); 115 | if (pullRequest.reviews.nodes[0]) { 116 | const lastReviewDate = new Date( 117 | pullRequest.reviews.nodes.sort((a, b) => (a < b ? 1 : -1))[0].submittedAt, 118 | ); 119 | const lastUpdateDate = 120 | lastCommitDate >= lastReviewDate ? lastCommitDate : lastReviewDate; 121 | const now = new Date(); 122 | return (now - lastUpdateDate) / (1000 * 60 * 60 * 24) >= staleDays; 123 | } else { 124 | return false; 125 | } 126 | }; 127 | 128 | /** 129 | * Main function. 130 | * 131 | * @returns {Promise} A promise. 132 | */ 133 | const run = async () => { 134 | try { 135 | // Create octokit client. 136 | const dryRun = process.env.DRY_RUN === "true" || false; 137 | const staleDays = process.env.STALE_DAYS || 20; 138 | debug("Creating octokit client..."); 139 | const octokit = github.getOctokit(getGithubToken()); 140 | const { owner, repo } = getRepoInfo(github.context); 141 | const reviewer = getReviewer(); 142 | 143 | // Retrieve all theme pull requests. 144 | debug("Retrieving all theme pull requests..."); 145 | const prs = await fetchOpenPRs(octokit, owner, repo, reviewer); 146 | const themePRs = pullsWithLabel(prs, "themes"); 147 | const invalidThemePRs = pullsWithLabel(themePRs, "invalid"); 148 | debug("Retrieving stale theme PRs..."); 149 | const staleThemePRs = invalidThemePRs.filter((pr) => 150 | isStale(pr, staleDays), 151 | ); 152 | const staleThemePRsNumbers = staleThemePRs.map((pr) => pr.number); 153 | debug(`Found ${staleThemePRs.length} stale theme PRs`); 154 | 155 | // Loop through all stale invalid theme pull requests and close them. 156 | for (const prNumber of staleThemePRsNumbers) { 157 | debug(`Closing #${prNumber} because it is stale...`); 158 | if (dryRun) { 159 | debug("Dry run enabled, skipping..."); 160 | } else { 161 | await octokit.rest.issues.createComment({ 162 | owner, 163 | repo, 164 | issue_number: prNumber, 165 | body: CLOSING_COMMENT, 166 | }); 167 | await octokit.rest.pulls.update({ 168 | owner, 169 | repo, 170 | pull_number: prNumber, 171 | state: "closed", 172 | }); 173 | } 174 | } 175 | } catch (error) { 176 | setFailed(error.message); 177 | } 178 | }; 179 | 180 | run(); 181 | -------------------------------------------------------------------------------- /scripts/generate-langs-json.js: -------------------------------------------------------------------------------- 1 | import axios from "axios"; 2 | import fs from "fs"; 3 | import jsYaml from "js-yaml"; 4 | 5 | const LANGS_FILEPATH = "./src/common/languageColors.json"; 6 | 7 | //Retrieve languages from github linguist repository yaml file 8 | //@ts-ignore 9 | axios 10 | .get( 11 | "https://raw.githubusercontent.com/github/linguist/master/lib/linguist/languages.yml", 12 | ) 13 | .then((response) => { 14 | //and convert them to a JS Object 15 | const languages = jsYaml.load(response.data); 16 | 17 | const languageColors = {}; 18 | 19 | //Filter only language colors from the whole file 20 | Object.keys(languages).forEach((lang) => { 21 | languageColors[lang] = languages[lang].color; 22 | }); 23 | 24 | //Debug Print 25 | //console.dir(languageColors); 26 | fs.writeFileSync( 27 | LANGS_FILEPATH, 28 | JSON.stringify(languageColors, null, " "), 29 | ); 30 | }); 31 | -------------------------------------------------------------------------------- /scripts/generate-theme-doc.js: -------------------------------------------------------------------------------- 1 | import fs from "fs"; 2 | import { themes } from "../themes/index.js"; 3 | 4 | const TARGET_FILE = "./themes/README.md"; 5 | const REPO_CARD_LINKS_FLAG = ""; 6 | const STAT_CARD_LINKS_FLAG = ""; 7 | 8 | const STAT_CARD_TABLE_FLAG = ""; 9 | const REPO_CARD_TABLE_FLAG = ""; 10 | 11 | const THEME_TEMPLATE = `## Available Themes 12 | 13 | 14 | 15 | With inbuilt themes, you can customize the look of the card without doing any manual customization. 16 | 17 | Use \`?theme=THEME_NAME\` parameter like so: 18 | 19 | \`\`\`md 20 | ![Anurag's GitHub stats](https://github-readme-stats.vercel.app/api?username=anuraghazra&theme=dark&show_icons=true) 21 | \`\`\` 22 | 23 | ## Stats 24 | 25 | > These themes works with all five our cards: Stats Card, Repo Card, Gist Card, Top languages Card and WakaTime Card. 26 | 27 | | | | | 28 | | :--: | :--: | :--: | 29 | ${STAT_CARD_TABLE_FLAG} 30 | 31 | ## Repo Card 32 | 33 | > These themes works with all five our cards: Stats Card, Repo Card, Gist Card, Top languages Card and WakaTime Card. 34 | 35 | | | | | 36 | | :--: | :--: | :--: | 37 | ${REPO_CARD_TABLE_FLAG} 38 | 39 | ${STAT_CARD_LINKS_FLAG} 40 | 41 | ${REPO_CARD_LINKS_FLAG} 42 | 43 | 44 | [add-theme]: https://github.com/anuraghazra/github-readme-stats/edit/master/themes/index.js 45 | 46 | Want to add a new theme? Consider reading the [contribution guidelines](../CONTRIBUTING.md#themes-contribution) :D 47 | `; 48 | 49 | const createRepoMdLink = (theme) => { 50 | return `\n[${theme}_repo]: https://github-readme-stats.vercel.app/api/pin/?username=anuraghazra&repo=github-readme-stats&cache_seconds=86400&theme=${theme}`; 51 | }; 52 | const createStatMdLink = (theme) => { 53 | return `\n[${theme}]: https://github-readme-stats.vercel.app/api?username=anuraghazra&show_icons=true&hide=contribs,prs&cache_seconds=86400&theme=${theme}`; 54 | }; 55 | 56 | const generateLinks = (fn) => { 57 | return Object.keys(themes) 58 | .map((name) => fn(name)) 59 | .join(""); 60 | }; 61 | 62 | const createTableItem = ({ link, label, isRepoCard }) => { 63 | if (!link || !label) { 64 | return ""; 65 | } 66 | return `\`${label}\` ![${link}][${link}${isRepoCard ? "_repo" : ""}]`; 67 | }; 68 | 69 | const generateTable = ({ isRepoCard }) => { 70 | const rows = []; 71 | const themesFiltered = Object.keys(themes).filter( 72 | (name) => name !== (isRepoCard ? "default" : "default_repocard"), 73 | ); 74 | 75 | for (let i = 0; i < themesFiltered.length; i += 3) { 76 | const one = themesFiltered[i]; 77 | const two = themesFiltered[i + 1]; 78 | const three = themesFiltered[i + 2]; 79 | 80 | let tableItem1 = createTableItem({ link: one, label: one, isRepoCard }); 81 | let tableItem2 = createTableItem({ link: two, label: two, isRepoCard }); 82 | let tableItem3 = createTableItem({ link: three, label: three, isRepoCard }); 83 | 84 | if (three === undefined) { 85 | tableItem3 = `[Add your theme][add-theme]`; 86 | } 87 | rows.push(`| ${tableItem1} | ${tableItem2} | ${tableItem3} |`); 88 | 89 | // if it's the last row & the row has no empty space push a new row 90 | if (three && i + 3 === themesFiltered.length) { 91 | rows.push(`| [Add your theme][add-theme] | | |`); 92 | } 93 | } 94 | 95 | return rows.join("\n"); 96 | }; 97 | 98 | const buildReadme = () => { 99 | return THEME_TEMPLATE.split("\n") 100 | .map((line) => { 101 | if (line.includes(REPO_CARD_LINKS_FLAG)) { 102 | return generateLinks(createRepoMdLink); 103 | } 104 | if (line.includes(STAT_CARD_LINKS_FLAG)) { 105 | return generateLinks(createStatMdLink); 106 | } 107 | if (line.includes(REPO_CARD_TABLE_FLAG)) { 108 | return generateTable({ isRepoCard: true }); 109 | } 110 | if (line.includes(STAT_CARD_TABLE_FLAG)) { 111 | return generateTable({ isRepoCard: false }); 112 | } 113 | return line; 114 | }) 115 | .join("\n"); 116 | }; 117 | 118 | fs.writeFileSync(TARGET_FILE, buildReadme()); 119 | -------------------------------------------------------------------------------- /scripts/helpers.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @file Contains helper functions used in the scripts. 3 | */ 4 | 5 | import { getInput } from "@actions/core"; 6 | 7 | const OWNER = "anuraghazra"; 8 | const REPO = "github-readme-stats"; 9 | 10 | /** 11 | * Retrieve information about the repository that ran the action. 12 | * 13 | * @param {Object} ctx Action context. 14 | * @returns {Object} Repository information. 15 | */ 16 | export const getRepoInfo = (ctx) => { 17 | try { 18 | return { 19 | owner: ctx.repo.owner, 20 | repo: ctx.repo.repo, 21 | }; 22 | } catch (error) { 23 | return { 24 | owner: OWNER, 25 | repo: REPO, 26 | }; 27 | } 28 | }; 29 | 30 | /** 31 | * Retrieve github token and throw error if it is not found. 32 | * 33 | * @returns {string} GitHub token. 34 | */ 35 | export const getGithubToken = () => { 36 | const token = getInput("github_token") || process.env.GITHUB_TOKEN; 37 | if (!token) { 38 | throw Error("Could not find github token"); 39 | } 40 | return token; 41 | }; 42 | -------------------------------------------------------------------------------- /scripts/push-theme-readme.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -x 3 | set -e 4 | 5 | export BRANCH_NAME=updated-theme-readme 6 | git --version 7 | git config --global user.email "no-reply@githubreadmestats.com" 8 | git config --global user.name "GitHub Readme Stats Bot" 9 | git config --global --add safe.directory ${GITHUB_WORKSPACE} 10 | git branch -d $BRANCH_NAME || true 11 | git checkout -b $BRANCH_NAME 12 | git add --all 13 | git commit --no-verify --message "docs(theme): auto update theme readme" 14 | git remote add origin-$BRANCH_NAME https://${PERSONAL_TOKEN}@github.com/${GH_REPO}.git 15 | git push --force --quiet --set-upstream origin-$BRANCH_NAME $BRANCH_NAME 16 | -------------------------------------------------------------------------------- /src/calculateRank.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Calculates the exponential cdf. 3 | * 4 | * @param {number} x The value. 5 | * @returns {number} The exponential cdf. 6 | */ 7 | function exponential_cdf(x) { 8 | return 1 - 2 ** -x; 9 | } 10 | 11 | /** 12 | * Calculates the log normal cdf. 13 | * 14 | * @param {number} x The value. 15 | * @returns {number} The log normal cdf. 16 | */ 17 | function log_normal_cdf(x) { 18 | // approximation 19 | return x / (1 + x); 20 | } 21 | 22 | /** 23 | * Calculates the users rank. 24 | * 25 | * @param {object} params Parameters on which the user's rank depends. 26 | * @param {boolean} params.all_commits Whether `include_all_commits` was used. 27 | * @param {number} params.commits Number of commits. 28 | * @param {number} params.prs The number of pull requests. 29 | * @param {number} params.issues The number of issues. 30 | * @param {number} params.reviews The number of reviews. 31 | * @param {number} params.repos Total number of repos. 32 | * @param {number} params.stars The number of stars. 33 | * @param {number} params.followers The number of followers. 34 | * @returns {{level: string, percentile: number}}} The users rank. 35 | */ 36 | function calculateRank({ 37 | all_commits, 38 | commits, 39 | prs, 40 | issues, 41 | reviews, 42 | // eslint-disable-next-line no-unused-vars 43 | repos, // unused 44 | stars, 45 | followers, 46 | }) { 47 | const COMMITS_MEDIAN = all_commits ? 1000 : 250, 48 | COMMITS_WEIGHT = 2; 49 | const PRS_MEDIAN = 50, 50 | PRS_WEIGHT = 3; 51 | const ISSUES_MEDIAN = 25, 52 | ISSUES_WEIGHT = 1; 53 | const REVIEWS_MEDIAN = 2, 54 | REVIEWS_WEIGHT = 1; 55 | const STARS_MEDIAN = 50, 56 | STARS_WEIGHT = 4; 57 | const FOLLOWERS_MEDIAN = 10, 58 | FOLLOWERS_WEIGHT = 1; 59 | 60 | const TOTAL_WEIGHT = 61 | COMMITS_WEIGHT + 62 | PRS_WEIGHT + 63 | ISSUES_WEIGHT + 64 | REVIEWS_WEIGHT + 65 | STARS_WEIGHT + 66 | FOLLOWERS_WEIGHT; 67 | 68 | const THRESHOLDS = [1, 12.5, 25, 37.5, 50, 62.5, 75, 87.5, 100]; 69 | const LEVELS = ["S", "A+", "A", "A-", "B+", "B", "B-", "C+", "C"]; 70 | 71 | const rank = 72 | 1 - 73 | (COMMITS_WEIGHT * exponential_cdf(commits / COMMITS_MEDIAN) + 74 | PRS_WEIGHT * exponential_cdf(prs / PRS_MEDIAN) + 75 | ISSUES_WEIGHT * exponential_cdf(issues / ISSUES_MEDIAN) + 76 | REVIEWS_WEIGHT * exponential_cdf(reviews / REVIEWS_MEDIAN) + 77 | STARS_WEIGHT * log_normal_cdf(stars / STARS_MEDIAN) + 78 | FOLLOWERS_WEIGHT * log_normal_cdf(followers / FOLLOWERS_MEDIAN)) / 79 | TOTAL_WEIGHT; 80 | 81 | const level = LEVELS[THRESHOLDS.findIndex((t) => rank * 100 <= t)]; 82 | 83 | return { level, percentile: rank * 100 }; 84 | } 85 | 86 | export { calculateRank }; 87 | export default calculateRank; 88 | -------------------------------------------------------------------------------- /src/cards/gist-card.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | import { 4 | getCardColors, 5 | parseEmojis, 6 | wrapTextMultiline, 7 | encodeHTML, 8 | kFormatter, 9 | measureText, 10 | flexLayout, 11 | iconWithLabel, 12 | createLanguageNode, 13 | } from "../common/utils.js"; 14 | import Card from "../common/Card.js"; 15 | import { icons } from "../common/icons.js"; 16 | 17 | /** Import language colors. 18 | * 19 | * @description Here we use the workaround found in 20 | * https://stackoverflow.com/questions/66726365/how-should-i-import-json-in-node 21 | * since vercel is using v16.14.0 which does not yet support json imports without the 22 | * --experimental-json-modules flag. 23 | */ 24 | import { createRequire } from "module"; 25 | const require = createRequire(import.meta.url); 26 | const languageColors = require("../common/languageColors.json"); // now works 27 | 28 | const ICON_SIZE = 16; 29 | const CARD_DEFAULT_WIDTH = 400; 30 | const HEADER_MAX_LENGTH = 35; 31 | 32 | /** 33 | * @typedef {import('./types').GistCardOptions} GistCardOptions Gist card options. 34 | * @typedef {import('../fetchers/types').GistData} GistData Gist data. 35 | */ 36 | 37 | /** 38 | * Render gist card. 39 | * 40 | * @param {GistData} gistData Gist data. 41 | * @param {Partial} options Gist card options. 42 | * @returns {string} Gist card. 43 | */ 44 | const renderGistCard = (gistData, options = {}) => { 45 | const { name, nameWithOwner, description, language, starsCount, forksCount } = 46 | gistData; 47 | const { 48 | title_color, 49 | icon_color, 50 | text_color, 51 | bg_color, 52 | theme, 53 | border_radius, 54 | border_color, 55 | show_owner = false, 56 | hide_border = false, 57 | } = options; 58 | 59 | // returns theme based colors with proper overrides and defaults 60 | const { titleColor, textColor, iconColor, bgColor, borderColor } = 61 | getCardColors({ 62 | title_color, 63 | icon_color, 64 | text_color, 65 | bg_color, 66 | border_color, 67 | theme, 68 | }); 69 | 70 | const lineWidth = 59; 71 | const linesLimit = 10; 72 | const desc = parseEmojis(description || "No description provided"); 73 | const multiLineDescription = wrapTextMultiline(desc, lineWidth, linesLimit); 74 | const descriptionLines = multiLineDescription.length; 75 | const descriptionSvg = multiLineDescription 76 | .map((line) => `${encodeHTML(line)}`) 77 | .join(""); 78 | 79 | const lineHeight = descriptionLines > 3 ? 12 : 10; 80 | const height = 81 | (descriptionLines > 1 ? 120 : 110) + descriptionLines * lineHeight; 82 | 83 | const totalStars = kFormatter(starsCount); 84 | const totalForks = kFormatter(forksCount); 85 | const svgStars = iconWithLabel( 86 | icons.star, 87 | totalStars, 88 | "starsCount", 89 | ICON_SIZE, 90 | ); 91 | const svgForks = iconWithLabel( 92 | icons.fork, 93 | totalForks, 94 | "forksCount", 95 | ICON_SIZE, 96 | ); 97 | 98 | const languageName = language || "Unspecified"; 99 | const languageColor = languageColors[languageName] || "#858585"; 100 | 101 | const svgLanguage = createLanguageNode(languageName, languageColor); 102 | 103 | const starAndForkCount = flexLayout({ 104 | items: [svgLanguage, svgStars, svgForks], 105 | sizes: [ 106 | measureText(languageName, 12), 107 | ICON_SIZE + measureText(`${totalStars}`, 12), 108 | ICON_SIZE + measureText(`${totalForks}`, 12), 109 | ], 110 | gap: 25, 111 | }).join(""); 112 | 113 | const header = show_owner ? nameWithOwner : name; 114 | 115 | const card = new Card({ 116 | defaultTitle: 117 | header.length > HEADER_MAX_LENGTH 118 | ? `${header.slice(0, HEADER_MAX_LENGTH)}...` 119 | : header, 120 | titlePrefixIcon: icons.gist, 121 | width: CARD_DEFAULT_WIDTH, 122 | height, 123 | border_radius, 124 | colors: { 125 | titleColor, 126 | textColor, 127 | iconColor, 128 | bgColor, 129 | borderColor, 130 | }, 131 | }); 132 | 133 | card.setCSS(` 134 | .description { font: 400 13px 'Segoe UI', Ubuntu, Sans-Serif; fill: ${textColor} } 135 | .gray { font: 400 12px 'Segoe UI', Ubuntu, Sans-Serif; fill: ${textColor} } 136 | .icon { fill: ${iconColor} } 137 | `); 138 | card.setHideBorder(hide_border); 139 | 140 | return card.render(` 141 | 142 | ${descriptionSvg} 143 | 144 | 145 | 146 | ${starAndForkCount} 147 | 148 | `); 149 | }; 150 | 151 | export { renderGistCard, HEADER_MAX_LENGTH }; 152 | export default renderGistCard; 153 | -------------------------------------------------------------------------------- /src/cards/index.js: -------------------------------------------------------------------------------- 1 | export { renderRepoCard } from "./repo-card.js"; 2 | export { renderStatsCard } from "./stats-card.js"; 3 | export { renderTopLanguages } from "./top-languages-card.js"; 4 | export { renderWakatimeCard } from "./wakatime-card.js"; 5 | -------------------------------------------------------------------------------- /src/cards/repo-card.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | import { Card } from "../common/Card.js"; 3 | import { I18n } from "../common/I18n.js"; 4 | import { icons } from "../common/icons.js"; 5 | import { 6 | encodeHTML, 7 | flexLayout, 8 | getCardColors, 9 | kFormatter, 10 | measureText, 11 | parseEmojis, 12 | wrapTextMultiline, 13 | iconWithLabel, 14 | createLanguageNode, 15 | clampValue, 16 | } from "../common/utils.js"; 17 | import { repoCardLocales } from "../translations.js"; 18 | 19 | const ICON_SIZE = 16; 20 | const DESCRIPTION_LINE_WIDTH = 59; 21 | const DESCRIPTION_MAX_LINES = 3; 22 | 23 | /** 24 | * Retrieves the repository description and wraps it to fit the card width. 25 | * 26 | * @param {string} label The repository description. 27 | * @param {string} textColor The color of the text. 28 | * @returns {string} Wrapped repo description SVG object. 29 | */ 30 | const getBadgeSVG = (label, textColor) => ` 31 | 32 | 33 | 40 | ${label} 41 | 42 | 43 | `; 44 | 45 | /** 46 | * @typedef {import("../fetchers/types").RepositoryData} RepositoryData Repository data. 47 | * @typedef {import("./types").RepoCardOptions} RepoCardOptions Repo card options. 48 | */ 49 | 50 | /** 51 | * Renders repository card details. 52 | * 53 | * @param {RepositoryData} repo Repository data. 54 | * @param {Partial} options Card options. 55 | * @returns {string} Repository card SVG object. 56 | */ 57 | const renderRepoCard = (repo, options = {}) => { 58 | const { 59 | name, 60 | nameWithOwner, 61 | description, 62 | primaryLanguage, 63 | isArchived, 64 | isTemplate, 65 | starCount, 66 | forkCount, 67 | } = repo; 68 | const { 69 | hide_border = false, 70 | title_color, 71 | icon_color, 72 | text_color, 73 | bg_color, 74 | show_owner = false, 75 | theme = "default_repocard", 76 | border_radius, 77 | border_color, 78 | locale, 79 | description_lines_count, 80 | } = options; 81 | 82 | const lineHeight = 10; 83 | const header = show_owner ? nameWithOwner : name; 84 | const langName = (primaryLanguage && primaryLanguage.name) || "Unspecified"; 85 | const langColor = (primaryLanguage && primaryLanguage.color) || "#333"; 86 | const descriptionMaxLines = description_lines_count 87 | ? clampValue(description_lines_count, 1, DESCRIPTION_MAX_LINES) 88 | : DESCRIPTION_MAX_LINES; 89 | 90 | const desc = parseEmojis(description || "No description provided"); 91 | const multiLineDescription = wrapTextMultiline( 92 | desc, 93 | DESCRIPTION_LINE_WIDTH, 94 | descriptionMaxLines, 95 | ); 96 | const descriptionLinesCount = description_lines_count 97 | ? clampValue(description_lines_count, 1, DESCRIPTION_MAX_LINES) 98 | : multiLineDescription.length; 99 | 100 | const descriptionSvg = multiLineDescription 101 | .map((line) => `${encodeHTML(line)}`) 102 | .join(""); 103 | 104 | const height = 105 | (descriptionLinesCount > 1 ? 120 : 110) + 106 | descriptionLinesCount * lineHeight; 107 | 108 | const i18n = new I18n({ 109 | locale, 110 | translations: repoCardLocales, 111 | }); 112 | 113 | // returns theme based colors with proper overrides and defaults 114 | const colors = getCardColors({ 115 | title_color, 116 | icon_color, 117 | text_color, 118 | bg_color, 119 | border_color, 120 | theme, 121 | }); 122 | 123 | const svgLanguage = primaryLanguage 124 | ? createLanguageNode(langName, langColor) 125 | : ""; 126 | 127 | const totalStars = kFormatter(starCount); 128 | const totalForks = kFormatter(forkCount); 129 | const svgStars = iconWithLabel( 130 | icons.star, 131 | totalStars, 132 | "stargazers", 133 | ICON_SIZE, 134 | ); 135 | const svgForks = iconWithLabel( 136 | icons.fork, 137 | totalForks, 138 | "forkcount", 139 | ICON_SIZE, 140 | ); 141 | 142 | const starAndForkCount = flexLayout({ 143 | items: [svgLanguage, svgStars, svgForks], 144 | sizes: [ 145 | measureText(langName, 12), 146 | ICON_SIZE + measureText(`${totalStars}`, 12), 147 | ICON_SIZE + measureText(`${totalForks}`, 12), 148 | ], 149 | gap: 25, 150 | }).join(""); 151 | 152 | const card = new Card({ 153 | defaultTitle: header.length > 35 ? `${header.slice(0, 35)}...` : header, 154 | titlePrefixIcon: icons.contribs, 155 | width: 400, 156 | height, 157 | border_radius, 158 | colors, 159 | }); 160 | 161 | card.disableAnimations(); 162 | card.setHideBorder(hide_border); 163 | card.setHideTitle(false); 164 | card.setCSS(` 165 | .description { font: 400 13px 'Segoe UI', Ubuntu, Sans-Serif; fill: ${colors.textColor} } 166 | .gray { font: 400 12px 'Segoe UI', Ubuntu, Sans-Serif; fill: ${colors.textColor} } 167 | .icon { fill: ${colors.iconColor} } 168 | .badge { font: 600 11px 'Segoe UI', Ubuntu, Sans-Serif; } 169 | .badge rect { opacity: 0.2 } 170 | `); 171 | 172 | return card.render(` 173 | ${ 174 | isTemplate 175 | ? // @ts-ignore 176 | getBadgeSVG(i18n.t("repocard.template"), colors.textColor) 177 | : isArchived 178 | ? // @ts-ignore 179 | getBadgeSVG(i18n.t("repocard.archived"), colors.textColor) 180 | : "" 181 | } 182 | 183 | 184 | ${descriptionSvg} 185 | 186 | 187 | 188 | ${starAndForkCount} 189 | 190 | `); 191 | }; 192 | 193 | export { renderRepoCard }; 194 | export default renderRepoCard; 195 | -------------------------------------------------------------------------------- /src/cards/types.d.ts: -------------------------------------------------------------------------------- 1 | type ThemeNames = keyof typeof import("../../themes/index.js"); 2 | type RankIcon = "default" | "github" | "percentile"; 3 | 4 | export type CommonOptions = { 5 | title_color: string; 6 | icon_color: string; 7 | text_color: string; 8 | bg_color: string; 9 | theme: ThemeNames; 10 | border_radius: number; 11 | border_color: string; 12 | locale: string; 13 | hide_border: boolean; 14 | }; 15 | 16 | export type StatCardOptions = CommonOptions & { 17 | hide: string[]; 18 | show_icons: boolean; 19 | hide_title: boolean; 20 | card_width: number; 21 | hide_rank: boolean; 22 | include_all_commits: boolean; 23 | line_height: number | string; 24 | custom_title: string; 25 | disable_animations: boolean; 26 | number_format: string; 27 | ring_color: string; 28 | text_bold: boolean; 29 | rank_icon: RankIcon; 30 | show: string[]; 31 | }; 32 | 33 | export type RepoCardOptions = CommonOptions & { 34 | show_owner: boolean; 35 | description_lines_count: number; 36 | }; 37 | 38 | export type TopLangOptions = CommonOptions & { 39 | hide_title: boolean; 40 | card_width: number; 41 | hide: string[]; 42 | layout: "compact" | "normal" | "donut" | "donut-vertical" | "pie"; 43 | custom_title: string; 44 | langs_count: number; 45 | disable_animations: boolean; 46 | hide_progress: boolean; 47 | }; 48 | 49 | export type WakaTimeOptions = CommonOptions & { 50 | hide_title: boolean; 51 | hide: string[]; 52 | line_height: string; 53 | hide_progress: boolean; 54 | custom_title: string; 55 | layout: "compact" | "normal"; 56 | langs_count: number; 57 | display_format: "time" | "percent"; 58 | disable_animations: boolean; 59 | }; 60 | 61 | export type GistCardOptions = CommonOptions & { 62 | show_owner: boolean; 63 | }; 64 | -------------------------------------------------------------------------------- /src/common/I18n.js: -------------------------------------------------------------------------------- 1 | const FALLBACK_LOCALE = "en"; 2 | 3 | /** 4 | * I18n translation class. 5 | */ 6 | class I18n { 7 | /** 8 | * Constructor. 9 | * 10 | * @param {Object} options Options. 11 | * @param {string=} options.locale Locale. 12 | * @param {Object} options.translations Translations. 13 | */ 14 | constructor({ locale, translations }) { 15 | this.locale = locale || FALLBACK_LOCALE; 16 | this.translations = translations; 17 | } 18 | 19 | /** 20 | * Get translation. 21 | * 22 | * @param {string} str String to translate. 23 | * @returns {string} Translated string. 24 | */ 25 | t(str) { 26 | if (!this.translations[str]) { 27 | throw new Error(`${str} Translation string not found`); 28 | } 29 | 30 | if (!this.translations[str][this.locale]) { 31 | throw new Error( 32 | `'${str}' translation not found for locale '${this.locale}'`, 33 | ); 34 | } 35 | 36 | return this.translations[str][this.locale]; 37 | } 38 | } 39 | 40 | export { I18n }; 41 | export default I18n; 42 | -------------------------------------------------------------------------------- /src/common/blacklist.js: -------------------------------------------------------------------------------- 1 | const blacklist = ["renovate-bot", "technote-space", "sw-yx"]; 2 | 3 | export { blacklist }; 4 | export default blacklist; 5 | -------------------------------------------------------------------------------- /src/common/createProgressNode.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | import { clampValue } from "./utils.js"; 4 | 5 | /** 6 | * Create a node to indicate progress in percentage along a horizontal line. 7 | * 8 | * @param {Object} createProgressNodeParams Object that contains the createProgressNode parameters. 9 | * @param {number} createProgressNodeParams.x X-axis position. 10 | * @param {number} createProgressNodeParams.y Y-axis position. 11 | * @param {number} createProgressNodeParams.width Width of progress bar. 12 | * @param {string} createProgressNodeParams.color Progress color. 13 | * @param {number} createProgressNodeParams.progress Progress value. 14 | * @param {string} createProgressNodeParams.progressBarBackgroundColor Progress bar bg color. 15 | * @param {number} createProgressNodeParams.delay Delay before animation starts. 16 | * @returns {string} Progress node. 17 | */ 18 | const createProgressNode = ({ 19 | x, 20 | y, 21 | width, 22 | color, 23 | progress, 24 | progressBarBackgroundColor, 25 | delay, 26 | }) => { 27 | const progressPercentage = clampValue(progress, 2, 100); 28 | 29 | return ` 30 | 31 | 32 | 33 | 40 | 41 | 42 | `; 43 | }; 44 | 45 | export { createProgressNode }; 46 | export default createProgressNode; 47 | -------------------------------------------------------------------------------- /src/common/icons.js: -------------------------------------------------------------------------------- 1 | const icons = { 2 | star: ``, 3 | commits: ``, 4 | prs: ``, 5 | prs_merged: ``, 6 | prs_merged_percentage: ``, 7 | issues: ``, 8 | icon: ``, 9 | contribs: ``, 10 | fork: ``, 11 | reviews: ``, 12 | discussions_started: ``, 13 | discussions_answered: ``, 14 | gist: ``, 15 | }; 16 | 17 | /** 18 | * Get rank icon 19 | * 20 | * @param {string} rankIcon - The rank icon type. 21 | * @param {string} rankLevel - The rank level. 22 | * @param {number} percentile - The rank percentile. 23 | * @returns {string} - The SVG code of the rank icon 24 | */ 25 | const rankIcon = (rankIcon, rankLevel, percentile) => { 26 | switch (rankIcon) { 27 | case "github": 28 | return ` 29 | 32 | `; 33 | case "percentile": 34 | return ` 35 | 36 | Top 37 | 38 | 39 | ${percentile.toFixed(1)}% 40 | 41 | `; 42 | case "default": 43 | default: 44 | return ` 45 | 46 | ${rankLevel} 47 | 48 | `; 49 | } 50 | }; 51 | 52 | export { icons, rankIcon }; 53 | export default icons; 54 | -------------------------------------------------------------------------------- /src/common/index.js: -------------------------------------------------------------------------------- 1 | export { blacklist } from "./blacklist.js"; 2 | export { Card } from "./Card.js"; 3 | export { createProgressNode } from "./createProgressNode.js"; 4 | export { I18n } from "./I18n.js"; 5 | export { icons } from "./icons.js"; 6 | export { retryer } from "./retryer.js"; 7 | export { 8 | ERROR_CARD_LENGTH, 9 | renderError, 10 | encodeHTML, 11 | kFormatter, 12 | isValidHexColor, 13 | parseBoolean, 14 | parseArray, 15 | clampValue, 16 | isValidGradient, 17 | fallbackColor, 18 | request, 19 | flexLayout, 20 | getCardColors, 21 | wrapTextMultiline, 22 | logger, 23 | CONSTANTS, 24 | CustomError, 25 | MissingParamError, 26 | measureText, 27 | lowercaseTrim, 28 | chunkArray, 29 | parseEmojis, 30 | } from "./utils.js"; 31 | -------------------------------------------------------------------------------- /src/common/retryer.js: -------------------------------------------------------------------------------- 1 | import { CustomError, logger } from "./utils.js"; 2 | 3 | // Script variables. 4 | 5 | // Count the number of GitHub API tokens available. 6 | const PATs = Object.keys(process.env).filter((key) => 7 | /PAT_\d*$/.exec(key), 8 | ).length; 9 | const RETRIES = process.env.NODE_ENV === "test" ? 7 : PATs; 10 | 11 | /** 12 | * @typedef {import("axios").AxiosResponse} AxiosResponse Axios response. 13 | * @typedef {(variables: object, token: string) => Promise} FetcherFunction Fetcher function. 14 | */ 15 | 16 | /** 17 | * Try to execute the fetcher function until it succeeds or the max number of retries is reached. 18 | * 19 | * @param {FetcherFunction} fetcher The fetcher function. 20 | * @param {object} variables Object with arguments to pass to the fetcher function. 21 | * @param {number} retries How many times to retry. 22 | * @returns {Promise} The response from the fetcher function. 23 | */ 24 | const retryer = async (fetcher, variables, retries = 0) => { 25 | if (!RETRIES) { 26 | throw new CustomError("No GitHub API tokens found", CustomError.NO_TOKENS); 27 | } 28 | if (retries > RETRIES) { 29 | throw new CustomError( 30 | "Downtime due to GitHub API rate limiting", 31 | CustomError.MAX_RETRY, 32 | ); 33 | } 34 | try { 35 | // try to fetch with the first token since RETRIES is 0 index i'm adding +1 36 | let response = await fetcher( 37 | variables, 38 | process.env[`PAT_${retries + 1}`], 39 | retries, 40 | ); 41 | 42 | // prettier-ignore 43 | const isRateExceeded = response.data.errors && response.data.errors[0].type === "RATE_LIMITED"; 44 | 45 | // if rate limit is hit increase the RETRIES and recursively call the retryer 46 | // with username, and current RETRIES 47 | if (isRateExceeded) { 48 | logger.log(`PAT_${retries + 1} Failed`); 49 | retries++; 50 | // directly return from the function 51 | return retryer(fetcher, variables, retries); 52 | } 53 | 54 | // finally return the response 55 | return response; 56 | } catch (err) { 57 | // prettier-ignore 58 | // also checking for bad credentials if any tokens gets invalidated 59 | const isBadCredential = err.response.data && err.response.data.message === "Bad credentials"; 60 | const isAccountSuspended = 61 | err.response.data && 62 | err.response.data.message === "Sorry. Your account was suspended."; 63 | 64 | if (isBadCredential || isAccountSuspended) { 65 | logger.log(`PAT_${retries + 1} Failed`); 66 | retries++; 67 | // directly return from the function 68 | return retryer(fetcher, variables, retries); 69 | } else { 70 | return err.response; 71 | } 72 | } 73 | }; 74 | 75 | export { retryer, RETRIES }; 76 | export default retryer; 77 | -------------------------------------------------------------------------------- /src/fetchers/gist-fetcher.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | import { request, MissingParamError } from "../common/utils.js"; 4 | import { retryer } from "../common/retryer.js"; 5 | 6 | /** 7 | * @typedef {import('axios').AxiosRequestHeaders} AxiosRequestHeaders Axios request headers. 8 | * @typedef {import('axios').AxiosResponse} AxiosResponse Axios response. 9 | */ 10 | 11 | const QUERY = ` 12 | query gistInfo($gistName: String!) { 13 | viewer { 14 | gist(name: $gistName) { 15 | description 16 | owner { 17 | login 18 | } 19 | stargazerCount 20 | forks { 21 | totalCount 22 | } 23 | files { 24 | name 25 | language { 26 | name 27 | } 28 | size 29 | } 30 | } 31 | } 32 | } 33 | `; 34 | 35 | /** 36 | * Gist data fetcher. 37 | * 38 | * @param {AxiosRequestHeaders} variables Fetcher variables. 39 | * @param {string} token GitHub token. 40 | * @returns {Promise} The response. 41 | */ 42 | const fetcher = async (variables, token) => { 43 | return await request( 44 | { query: QUERY, variables }, 45 | { Authorization: `token ${token}` }, 46 | ); 47 | }; 48 | 49 | /** 50 | * @typedef {{ name: string; language: { name: string; }, size: number }} GistFile Gist file. 51 | */ 52 | 53 | /** 54 | * This function calculates the primary language of a gist by files size. 55 | * 56 | * @param {GistFile[]} files Files. 57 | * @returns {string} Primary language. 58 | */ 59 | const calculatePrimaryLanguage = (files) => { 60 | const languages = {}; 61 | for (const file of files) { 62 | if (file.language) { 63 | if (languages[file.language.name]) { 64 | languages[file.language.name] += file.size; 65 | } else { 66 | languages[file.language.name] = file.size; 67 | } 68 | } 69 | } 70 | let primaryLanguage = Object.keys(languages)[0]; 71 | for (const language in languages) { 72 | if (languages[language] > languages[primaryLanguage]) { 73 | primaryLanguage = language; 74 | } 75 | } 76 | return primaryLanguage; 77 | }; 78 | 79 | /** 80 | * @typedef {import('./types').GistData} GistData Gist data. 81 | */ 82 | 83 | /** 84 | * Fetch GitHub gist information by given username and ID. 85 | * 86 | * @param {string} id Github gist ID. 87 | * @returns {Promise} Gist data. 88 | */ 89 | const fetchGist = async (id) => { 90 | if (!id) { 91 | throw new MissingParamError(["id"], "/api/gist?id=GIST_ID"); 92 | } 93 | const res = await retryer(fetcher, { gistName: id }); 94 | if (res.data.errors) { 95 | throw new Error(res.data.errors[0].message); 96 | } 97 | if (!res.data.data.viewer.gist) { 98 | throw new Error("Gist not found"); 99 | } 100 | const data = res.data.data.viewer.gist; 101 | return { 102 | name: data.files[Object.keys(data.files)[0]].name, 103 | nameWithOwner: `${data.owner.login}/${ 104 | data.files[Object.keys(data.files)[0]].name 105 | }`, 106 | description: data.description, 107 | language: calculatePrimaryLanguage(data.files), 108 | starsCount: data.stargazerCount, 109 | forksCount: data.forks.totalCount, 110 | }; 111 | }; 112 | 113 | export { fetchGist }; 114 | export default fetchGist; 115 | -------------------------------------------------------------------------------- /src/fetchers/repo-fetcher.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | import { retryer } from "../common/retryer.js"; 3 | import { MissingParamError, request } from "../common/utils.js"; 4 | 5 | /** 6 | * @typedef {import('axios').AxiosRequestHeaders} AxiosRequestHeaders Axios request headers. 7 | * @typedef {import('axios').AxiosResponse} AxiosResponse Axios response. 8 | */ 9 | 10 | /** 11 | * Repo data fetcher. 12 | * 13 | * @param {AxiosRequestHeaders} variables Fetcher variables. 14 | * @param {string} token GitHub token. 15 | * @returns {Promise} The response. 16 | */ 17 | const fetcher = (variables, token) => { 18 | return request( 19 | { 20 | query: ` 21 | fragment RepoInfo on Repository { 22 | name 23 | nameWithOwner 24 | isPrivate 25 | isArchived 26 | isTemplate 27 | stargazers { 28 | totalCount 29 | } 30 | description 31 | primaryLanguage { 32 | color 33 | id 34 | name 35 | } 36 | forkCount 37 | } 38 | query getRepo($login: String!, $repo: String!) { 39 | user(login: $login) { 40 | repository(name: $repo) { 41 | ...RepoInfo 42 | } 43 | } 44 | organization(login: $login) { 45 | repository(name: $repo) { 46 | ...RepoInfo 47 | } 48 | } 49 | } 50 | `, 51 | variables, 52 | }, 53 | { 54 | Authorization: `token ${token}`, 55 | }, 56 | ); 57 | }; 58 | 59 | const urlExample = "/api/pin?username=USERNAME&repo=REPO_NAME"; 60 | 61 | /** 62 | * @typedef {import("./types").RepositoryData} RepositoryData Repository data. 63 | */ 64 | 65 | /** 66 | * Fetch repository data. 67 | * 68 | * @param {string} username GitHub username. 69 | * @param {string} reponame GitHub repository name. 70 | * @returns {Promise} Repository data. 71 | */ 72 | const fetchRepo = async (username, reponame) => { 73 | if (!username && !reponame) { 74 | throw new MissingParamError(["username", "repo"], urlExample); 75 | } 76 | if (!username) { 77 | throw new MissingParamError(["username"], urlExample); 78 | } 79 | if (!reponame) { 80 | throw new MissingParamError(["repo"], urlExample); 81 | } 82 | 83 | let res = await retryer(fetcher, { login: username, repo: reponame }); 84 | 85 | const data = res.data.data; 86 | 87 | if (!data.user && !data.organization) { 88 | throw new Error("Not found"); 89 | } 90 | 91 | const isUser = data.organization === null && data.user; 92 | const isOrg = data.user === null && data.organization; 93 | 94 | if (isUser) { 95 | if (!data.user.repository || data.user.repository.isPrivate) { 96 | throw new Error("User Repository Not found"); 97 | } 98 | return { 99 | ...data.user.repository, 100 | starCount: data.user.repository.stargazers.totalCount, 101 | }; 102 | } 103 | 104 | if (isOrg) { 105 | if ( 106 | !data.organization.repository || 107 | data.organization.repository.isPrivate 108 | ) { 109 | throw new Error("Organization Repository Not found"); 110 | } 111 | return { 112 | ...data.organization.repository, 113 | starCount: data.organization.repository.stargazers.totalCount, 114 | }; 115 | } 116 | 117 | throw new Error("Unexpected behavior"); 118 | }; 119 | 120 | export { fetchRepo }; 121 | export default fetchRepo; 122 | -------------------------------------------------------------------------------- /src/fetchers/top-languages-fetcher.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | import { retryer } from "../common/retryer.js"; 3 | import { 4 | CustomError, 5 | logger, 6 | MissingParamError, 7 | request, 8 | wrapTextMultiline, 9 | } from "../common/utils.js"; 10 | 11 | /** 12 | * @typedef {import("axios").AxiosRequestHeaders} AxiosRequestHeaders Axios request headers. 13 | * @typedef {import("axios").AxiosResponse} AxiosResponse Axios response. 14 | */ 15 | 16 | /** 17 | * Top languages fetcher object. 18 | * 19 | * @param {AxiosRequestHeaders} variables Fetcher variables. 20 | * @param {string} token GitHub token. 21 | * @returns {Promise} Languages fetcher response. 22 | */ 23 | const fetcher = (variables, token) => { 24 | return request( 25 | { 26 | query: ` 27 | query userInfo($login: String!) { 28 | user(login: $login) { 29 | # fetch only owner repos & not forks 30 | repositories(ownerAffiliations: OWNER, isFork: false, first: 100) { 31 | nodes { 32 | name 33 | languages(first: 10, orderBy: {field: SIZE, direction: DESC}) { 34 | edges { 35 | size 36 | node { 37 | color 38 | name 39 | } 40 | } 41 | } 42 | } 43 | } 44 | } 45 | } 46 | `, 47 | variables, 48 | }, 49 | { 50 | Authorization: `token ${token}`, 51 | }, 52 | ); 53 | }; 54 | 55 | /** 56 | * @typedef {import("./types").TopLangData} TopLangData Top languages data. 57 | */ 58 | 59 | /** 60 | * Fetch top languages for a given username. 61 | * 62 | * @param {string} username GitHub username. 63 | * @param {string[]} exclude_repo List of repositories to exclude. 64 | * @param {number} size_weight Weightage to be given to size. 65 | * @param {number} count_weight Weightage to be given to count. 66 | * @returns {Promise} Top languages data. 67 | */ 68 | const fetchTopLanguages = async ( 69 | username, 70 | exclude_repo = [], 71 | size_weight = 1, 72 | count_weight = 0, 73 | ) => { 74 | if (!username) { 75 | throw new MissingParamError(["username"]); 76 | } 77 | 78 | const res = await retryer(fetcher, { login: username }); 79 | 80 | if (res.data.errors) { 81 | logger.error(res.data.errors); 82 | if (res.data.errors[0].type === "NOT_FOUND") { 83 | throw new CustomError( 84 | res.data.errors[0].message || "Could not fetch user.", 85 | CustomError.USER_NOT_FOUND, 86 | ); 87 | } 88 | if (res.data.errors[0].message) { 89 | throw new CustomError( 90 | wrapTextMultiline(res.data.errors[0].message, 90, 1)[0], 91 | res.statusText, 92 | ); 93 | } 94 | throw new CustomError( 95 | "Something went wrong while trying to retrieve the language data using the GraphQL API.", 96 | CustomError.GRAPHQL_ERROR, 97 | ); 98 | } 99 | 100 | let repoNodes = res.data.data.user.repositories.nodes; 101 | let repoToHide = {}; 102 | 103 | // populate repoToHide map for quick lookup 104 | // while filtering out 105 | if (exclude_repo) { 106 | exclude_repo.forEach((repoName) => { 107 | repoToHide[repoName] = true; 108 | }); 109 | } 110 | 111 | // filter out repositories to be hidden 112 | repoNodes = repoNodes 113 | .sort((a, b) => b.size - a.size) 114 | .filter((name) => !repoToHide[name.name]); 115 | 116 | let repoCount = 0; 117 | 118 | repoNodes = repoNodes 119 | .filter((node) => node.languages.edges.length > 0) 120 | // flatten the list of language nodes 121 | .reduce((acc, curr) => curr.languages.edges.concat(acc), []) 122 | .reduce((acc, prev) => { 123 | // get the size of the language (bytes) 124 | let langSize = prev.size; 125 | 126 | // if we already have the language in the accumulator 127 | // & the current language name is same as previous name 128 | // add the size to the language size and increase repoCount. 129 | if (acc[prev.node.name] && prev.node.name === acc[prev.node.name].name) { 130 | langSize = prev.size + acc[prev.node.name].size; 131 | repoCount += 1; 132 | } else { 133 | // reset repoCount to 1 134 | // language must exist in at least one repo to be detected 135 | repoCount = 1; 136 | } 137 | return { 138 | ...acc, 139 | [prev.node.name]: { 140 | name: prev.node.name, 141 | color: prev.node.color, 142 | size: langSize, 143 | count: repoCount, 144 | }, 145 | }; 146 | }, {}); 147 | 148 | Object.keys(repoNodes).forEach((name) => { 149 | // comparison index calculation 150 | repoNodes[name].size = 151 | Math.pow(repoNodes[name].size, size_weight) * 152 | Math.pow(repoNodes[name].count, count_weight); 153 | }); 154 | 155 | const topLangs = Object.keys(repoNodes) 156 | .sort((a, b) => repoNodes[b].size - repoNodes[a].size) 157 | .reduce((result, key) => { 158 | result[key] = repoNodes[key]; 159 | return result; 160 | }, {}); 161 | 162 | return topLangs; 163 | }; 164 | 165 | export { fetchTopLanguages }; 166 | export default fetchTopLanguages; 167 | -------------------------------------------------------------------------------- /src/fetchers/types.d.ts: -------------------------------------------------------------------------------- 1 | export type GistData = { 2 | name: string; 3 | nameWithOwner: string; 4 | description: string | null; 5 | language: string | null; 6 | starsCount: number; 7 | forksCount: number; 8 | }; 9 | 10 | export type RepositoryData = { 11 | name: string; 12 | nameWithOwner: string; 13 | isPrivate: boolean; 14 | isArchived: boolean; 15 | isTemplate: boolean; 16 | stargazers: { totalCount: number }; 17 | description: string; 18 | primaryLanguage: { 19 | color: string; 20 | id: string; 21 | name: string; 22 | }; 23 | forkCount: number; 24 | starCount: number; 25 | }; 26 | 27 | export type StatsData = { 28 | name: string; 29 | totalPRs: number; 30 | totalPRsMerged: number; 31 | mergedPRsPercentage: number; 32 | totalReviews: number; 33 | totalCommits: number; 34 | totalIssues: number; 35 | totalStars: number; 36 | totalDiscussionsStarted: number; 37 | totalDiscussionsAnswered: number; 38 | contributedTo: number; 39 | rank: { level: string; percentile: number }; 40 | }; 41 | 42 | export type Lang = { 43 | name: string; 44 | color: string; 45 | size: number; 46 | }; 47 | 48 | export type TopLangData = Record; 49 | 50 | export type WakaTimeData = { 51 | categories: { 52 | digital: string; 53 | hours: number; 54 | minutes: number; 55 | name: string; 56 | percent: number; 57 | text: string; 58 | total_seconds: number; 59 | }[]; 60 | daily_average: number; 61 | daily_average_including_other_language: number; 62 | days_including_holidays: number; 63 | days_minus_holidays: number; 64 | editors: { 65 | digital: string; 66 | hours: number; 67 | minutes: number; 68 | name: string; 69 | percent: number; 70 | text: string; 71 | total_seconds: number; 72 | }[]; 73 | holidays: number; 74 | human_readable_daily_average: string; 75 | human_readable_daily_average_including_other_language: string; 76 | human_readable_total: string; 77 | human_readable_total_including_other_language: string; 78 | id: string; 79 | is_already_updating: boolean; 80 | is_coding_activity_visible: boolean; 81 | is_including_today: boolean; 82 | is_other_usage_visible: boolean; 83 | is_stuck: boolean; 84 | is_up_to_date: boolean; 85 | languages: { 86 | digital: string; 87 | hours: number; 88 | minutes: number; 89 | name: string; 90 | percent: number; 91 | text: string; 92 | total_seconds: number; 93 | }[]; 94 | operating_systems: { 95 | digital: string; 96 | hours: number; 97 | minutes: number; 98 | name: string; 99 | percent: number; 100 | text: string; 101 | total_seconds: number; 102 | }[]; 103 | percent_calculated: number; 104 | range: string; 105 | status: string; 106 | timeout: number; 107 | total_seconds: number; 108 | total_seconds_including_other_language: number; 109 | user_id: string; 110 | username: string; 111 | writes_only: boolean; 112 | }; 113 | 114 | export type WakaTimeLang = { 115 | name: string; 116 | text: string; 117 | percent: number; 118 | }; 119 | -------------------------------------------------------------------------------- /src/fetchers/wakatime-fetcher.js: -------------------------------------------------------------------------------- 1 | import axios from "axios"; 2 | import { CustomError, MissingParamError } from "../common/utils.js"; 3 | 4 | /** 5 | * WakaTime data fetcher. 6 | * 7 | * @param {{username: string, api_domain: string }} props Fetcher props. 8 | * @returns {Promise} WakaTime data response. 9 | */ 10 | const fetchWakatimeStats = async ({ username, api_domain }) => { 11 | if (!username) { 12 | throw new MissingParamError(["username"]); 13 | } 14 | 15 | try { 16 | const { data } = await axios.get( 17 | `https://${ 18 | api_domain ? api_domain.replace(/\/$/gi, "") : "wakatime.com" 19 | }/api/v1/users/${username}/stats?is_including_today=true`, 20 | ); 21 | 22 | return data.data; 23 | } catch (err) { 24 | if (err.response.status < 200 || err.response.status > 299) { 25 | throw new CustomError( 26 | `Could not resolve to a User with the login of '${username}'`, 27 | "WAKATIME_USER_NOT_FOUND", 28 | ); 29 | } 30 | throw err; 31 | } 32 | }; 33 | 34 | export { fetchWakatimeStats }; 35 | export default fetchWakatimeStats; 36 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | export * from "./common/index.js"; 2 | export * from "./cards/index.js"; 3 | -------------------------------------------------------------------------------- /tests/bench/api.bench.js: -------------------------------------------------------------------------------- 1 | import { benchmarkSuite } from "jest-bench"; 2 | import api from "../../api/index.js"; 3 | import axios from "axios"; 4 | import MockAdapter from "axios-mock-adapter"; 5 | import { jest } from "@jest/globals"; 6 | 7 | const stats = { 8 | name: "Anurag Hazra", 9 | totalStars: 100, 10 | totalCommits: 200, 11 | totalIssues: 300, 12 | totalPRs: 400, 13 | totalPRsMerged: 320, 14 | mergedPRsPercentage: 80, 15 | totalReviews: 50, 16 | totalDiscussionsStarted: 10, 17 | totalDiscussionsAnswered: 40, 18 | contributedTo: 50, 19 | rank: null, 20 | }; 21 | 22 | const data_stats = { 23 | data: { 24 | user: { 25 | name: stats.name, 26 | repositoriesContributedTo: { totalCount: stats.contributedTo }, 27 | contributionsCollection: { 28 | totalCommitContributions: stats.totalCommits, 29 | totalPullRequestReviewContributions: stats.totalReviews, 30 | }, 31 | pullRequests: { totalCount: stats.totalPRs }, 32 | mergedPullRequests: { totalCount: stats.totalPRsMerged }, 33 | openIssues: { totalCount: stats.totalIssues }, 34 | closedIssues: { totalCount: 0 }, 35 | followers: { totalCount: 0 }, 36 | repositoryDiscussions: { totalCount: stats.totalDiscussionsStarted }, 37 | repositoryDiscussionComments: { 38 | totalCount: stats.totalDiscussionsAnswered, 39 | }, 40 | repositories: { 41 | totalCount: 1, 42 | nodes: [{ stargazers: { totalCount: 100 } }], 43 | pageInfo: { 44 | hasNextPage: false, 45 | endCursor: "cursor", 46 | }, 47 | }, 48 | }, 49 | }, 50 | }; 51 | 52 | const mock = new MockAdapter(axios); 53 | 54 | const faker = (query, data) => { 55 | const req = { 56 | query: { 57 | username: "anuraghazra", 58 | ...query, 59 | }, 60 | }; 61 | const res = { 62 | setHeader: jest.fn(), 63 | send: jest.fn(), 64 | }; 65 | mock.onPost("https://api.github.com/graphql").replyOnce(200, data); 66 | 67 | return { req, res }; 68 | }; 69 | 70 | benchmarkSuite("test /api", { 71 | ["simple request"]: async () => { 72 | const { req, res } = faker({}, data_stats); 73 | 74 | await api(req, res); 75 | }, 76 | }); 77 | -------------------------------------------------------------------------------- /tests/bench/calculateRank.bench.js: -------------------------------------------------------------------------------- 1 | import { benchmarkSuite } from "jest-bench"; 2 | import { calculateRank } from "../../src/calculateRank.js"; 3 | 4 | benchmarkSuite("calculateRank", { 5 | ["calculateRank"]: () => { 6 | calculateRank({ 7 | all_commits: false, 8 | commits: 1300, 9 | prs: 1500, 10 | issues: 4500, 11 | reviews: 1000, 12 | repos: 0, 13 | stars: 600000, 14 | followers: 50000, 15 | }); 16 | }, 17 | }); 18 | -------------------------------------------------------------------------------- /tests/bench/gist.bench.js: -------------------------------------------------------------------------------- 1 | import { benchmarkSuite } from "jest-bench"; 2 | import gist from "../../api/gist.js"; 3 | import axios from "axios"; 4 | import MockAdapter from "axios-mock-adapter"; 5 | import { jest } from "@jest/globals"; 6 | 7 | const gist_data = { 8 | data: { 9 | viewer: { 10 | gist: { 11 | description: 12 | "List of countries and territories in English and Spanish: name, continent, capital, dial code, country codes, TLD, and area in sq km. Lista de países y territorios en Inglés y Español: nombre, continente, capital, código de teléfono, códigos de país, dominio y área en km cuadrados. Updated 2023", 13 | owner: { 14 | login: "Yizack", 15 | }, 16 | stargazerCount: 33, 17 | forks: { 18 | totalCount: 11, 19 | }, 20 | files: [ 21 | { 22 | name: "countries.json", 23 | language: { 24 | name: "JSON", 25 | }, 26 | size: 85858, 27 | }, 28 | ], 29 | }, 30 | }, 31 | }, 32 | }; 33 | 34 | const mock = new MockAdapter(axios); 35 | mock.onPost("https://api.github.com/graphql").reply(200, gist_data); 36 | 37 | benchmarkSuite("test /api/gist", { 38 | ["simple request"]: async () => { 39 | const req = { 40 | query: { 41 | id: "bbfce31e0217a3689c8d961a356cb10d", 42 | }, 43 | }; 44 | const res = { 45 | setHeader: jest.fn(), 46 | send: jest.fn(), 47 | }; 48 | 49 | await gist(req, res); 50 | }, 51 | }); 52 | -------------------------------------------------------------------------------- /tests/bench/pin.bench.js: -------------------------------------------------------------------------------- 1 | import { benchmarkSuite } from "jest-bench"; 2 | import pin from "../../api/pin.js"; 3 | import axios from "axios"; 4 | import MockAdapter from "axios-mock-adapter"; 5 | import { jest } from "@jest/globals"; 6 | 7 | const data_repo = { 8 | repository: { 9 | username: "anuraghazra", 10 | name: "convoychat", 11 | stargazers: { 12 | totalCount: 38000, 13 | }, 14 | description: "Help us take over the world! React + TS + GraphQL Chat App", 15 | primaryLanguage: { 16 | color: "#2b7489", 17 | id: "MDg6TGFuZ3VhZ2UyODc=", 18 | name: "TypeScript", 19 | }, 20 | forkCount: 100, 21 | isTemplate: false, 22 | }, 23 | }; 24 | 25 | const data_user = { 26 | data: { 27 | user: { repository: data_repo.repository }, 28 | organization: null, 29 | }, 30 | }; 31 | 32 | const mock = new MockAdapter(axios); 33 | mock.onPost("https://api.github.com/graphql").reply(200, data_user); 34 | 35 | benchmarkSuite("test /api/pin", { 36 | ["simple request"]: async () => { 37 | const req = { 38 | query: { 39 | username: "anuraghazra", 40 | repo: "convoychat", 41 | }, 42 | }; 43 | const res = { 44 | setHeader: jest.fn(), 45 | send: jest.fn(), 46 | }; 47 | 48 | await pin(req, res); 49 | }, 50 | }); 51 | -------------------------------------------------------------------------------- /tests/calculateRank.test.js: -------------------------------------------------------------------------------- 1 | import "@testing-library/jest-dom"; 2 | import { calculateRank } from "../src/calculateRank.js"; 3 | import { expect, it, describe } from "@jest/globals"; 4 | 5 | describe("Test calculateRank", () => { 6 | it("new user gets C rank", () => { 7 | expect( 8 | calculateRank({ 9 | all_commits: false, 10 | commits: 0, 11 | prs: 0, 12 | issues: 0, 13 | reviews: 0, 14 | repos: 0, 15 | stars: 0, 16 | followers: 0, 17 | }), 18 | ).toStrictEqual({ level: "C", percentile: 100 }); 19 | }); 20 | 21 | it("beginner user gets B- rank", () => { 22 | expect( 23 | calculateRank({ 24 | all_commits: false, 25 | commits: 125, 26 | prs: 25, 27 | issues: 10, 28 | reviews: 5, 29 | repos: 0, 30 | stars: 25, 31 | followers: 5, 32 | }), 33 | ).toStrictEqual({ level: "B-", percentile: 65.02918514848255 }); 34 | }); 35 | 36 | it("median user gets B+ rank", () => { 37 | expect( 38 | calculateRank({ 39 | all_commits: false, 40 | commits: 250, 41 | prs: 50, 42 | issues: 25, 43 | reviews: 10, 44 | repos: 0, 45 | stars: 50, 46 | followers: 10, 47 | }), 48 | ).toStrictEqual({ level: "B+", percentile: 46.09375 }); 49 | }); 50 | 51 | it("average user gets B+ rank (include_all_commits)", () => { 52 | expect( 53 | calculateRank({ 54 | all_commits: true, 55 | commits: 1000, 56 | prs: 50, 57 | issues: 25, 58 | reviews: 10, 59 | repos: 0, 60 | stars: 50, 61 | followers: 10, 62 | }), 63 | ).toStrictEqual({ level: "B+", percentile: 46.09375 }); 64 | }); 65 | 66 | it("advanced user gets A rank", () => { 67 | expect( 68 | calculateRank({ 69 | all_commits: false, 70 | commits: 500, 71 | prs: 100, 72 | issues: 50, 73 | reviews: 20, 74 | repos: 0, 75 | stars: 200, 76 | followers: 40, 77 | }), 78 | ).toStrictEqual({ level: "A", percentile: 20.841471354166664 }); 79 | }); 80 | 81 | it("expert user gets A+ rank", () => { 82 | expect( 83 | calculateRank({ 84 | all_commits: false, 85 | commits: 1000, 86 | prs: 200, 87 | issues: 100, 88 | reviews: 40, 89 | repos: 0, 90 | stars: 800, 91 | followers: 160, 92 | }), 93 | ).toStrictEqual({ level: "A+", percentile: 5.575988339442828 }); 94 | }); 95 | 96 | it("sindresorhus gets S rank", () => { 97 | expect( 98 | calculateRank({ 99 | all_commits: false, 100 | commits: 1300, 101 | prs: 1500, 102 | issues: 4500, 103 | reviews: 1000, 104 | repos: 0, 105 | stars: 600000, 106 | followers: 50000, 107 | }), 108 | ).toStrictEqual({ level: "S", percentile: 0.4578556547153667 }); 109 | }); 110 | }); 111 | -------------------------------------------------------------------------------- /tests/card.test.js: -------------------------------------------------------------------------------- 1 | import { queryByTestId } from "@testing-library/dom"; 2 | import "@testing-library/jest-dom"; 3 | import { cssToObject } from "@uppercod/css-to-object"; 4 | import { Card } from "../src/common/Card.js"; 5 | import { icons } from "../src/common/icons.js"; 6 | import { getCardColors } from "../src/common/utils.js"; 7 | import { expect, it, describe } from "@jest/globals"; 8 | 9 | describe("Card", () => { 10 | it("should hide border", () => { 11 | const card = new Card({}); 12 | card.setHideBorder(true); 13 | 14 | document.body.innerHTML = card.render(``); 15 | expect(queryByTestId(document.body, "card-bg")).toHaveAttribute( 16 | "stroke-opacity", 17 | "0", 18 | ); 19 | }); 20 | 21 | it("should not hide border", () => { 22 | const card = new Card({}); 23 | card.setHideBorder(false); 24 | 25 | document.body.innerHTML = card.render(``); 26 | expect(queryByTestId(document.body, "card-bg")).toHaveAttribute( 27 | "stroke-opacity", 28 | "1", 29 | ); 30 | }); 31 | 32 | it("should have a custom title", () => { 33 | const card = new Card({ 34 | customTitle: "custom title", 35 | defaultTitle: "default title", 36 | }); 37 | 38 | document.body.innerHTML = card.render(``); 39 | expect(queryByTestId(document.body, "card-title")).toHaveTextContent( 40 | "custom title", 41 | ); 42 | }); 43 | 44 | it("should set custom title", () => { 45 | const card = new Card({}); 46 | card.setTitle("custom title"); 47 | 48 | document.body.innerHTML = card.render(``); 49 | expect(queryByTestId(document.body, "card-title")).toHaveTextContent( 50 | "custom title", 51 | ); 52 | }); 53 | 54 | it("should hide title", () => { 55 | const card = new Card({}); 56 | card.setHideTitle(true); 57 | 58 | document.body.innerHTML = card.render(``); 59 | expect(queryByTestId(document.body, "card-title")).toBeNull(); 60 | }); 61 | 62 | it("should not hide title", () => { 63 | const card = new Card({}); 64 | card.setHideTitle(false); 65 | 66 | document.body.innerHTML = card.render(``); 67 | expect(queryByTestId(document.body, "card-title")).toBeInTheDocument(); 68 | }); 69 | 70 | it("title should have prefix icon", () => { 71 | const card = new Card({ title: "ok", titlePrefixIcon: icons.contribs }); 72 | 73 | document.body.innerHTML = card.render(``); 74 | expect(document.getElementsByClassName("icon")[0]).toBeInTheDocument(); 75 | }); 76 | 77 | it("title should not have prefix icon", () => { 78 | const card = new Card({ title: "ok" }); 79 | 80 | document.body.innerHTML = card.render(``); 81 | expect(document.getElementsByClassName("icon")[0]).toBeUndefined(); 82 | }); 83 | 84 | it("should have proper height, width", () => { 85 | const card = new Card({ height: 200, width: 200, title: "ok" }); 86 | document.body.innerHTML = card.render(``); 87 | expect(document.getElementsByTagName("svg")[0]).toHaveAttribute( 88 | "height", 89 | "200", 90 | ); 91 | expect(document.getElementsByTagName("svg")[0]).toHaveAttribute( 92 | "width", 93 | "200", 94 | ); 95 | }); 96 | 97 | it("should have less height after title is hidden", () => { 98 | const card = new Card({ height: 200, title: "ok" }); 99 | card.setHideTitle(true); 100 | 101 | document.body.innerHTML = card.render(``); 102 | expect(document.getElementsByTagName("svg")[0]).toHaveAttribute( 103 | "height", 104 | "170", 105 | ); 106 | }); 107 | 108 | it("main-card-body should have proper when title is visible", () => { 109 | const card = new Card({ height: 200 }); 110 | document.body.innerHTML = card.render(``); 111 | expect(queryByTestId(document.body, "main-card-body")).toHaveAttribute( 112 | "transform", 113 | "translate(0, 55)", 114 | ); 115 | }); 116 | 117 | it("main-card-body should have proper position after title is hidden", () => { 118 | const card = new Card({ height: 200 }); 119 | card.setHideTitle(true); 120 | 121 | document.body.innerHTML = card.render(``); 122 | expect(queryByTestId(document.body, "main-card-body")).toHaveAttribute( 123 | "transform", 124 | "translate(0, 25)", 125 | ); 126 | }); 127 | 128 | it("should render with correct colors", () => { 129 | // returns theme based colors with proper overrides and defaults 130 | const { titleColor, textColor, iconColor, bgColor } = getCardColors({ 131 | title_color: "f00", 132 | icon_color: "0f0", 133 | text_color: "00f", 134 | bg_color: "fff", 135 | theme: "default", 136 | }); 137 | 138 | const card = new Card({ 139 | height: 200, 140 | colors: { 141 | titleColor, 142 | textColor, 143 | iconColor, 144 | bgColor, 145 | }, 146 | }); 147 | document.body.innerHTML = card.render(``); 148 | 149 | const styleTag = document.querySelector("style"); 150 | const stylesObject = cssToObject(styleTag.innerHTML); 151 | const headerClassStyles = stylesObject[":host"][".header "]; 152 | 153 | expect(headerClassStyles["fill"].trim()).toBe("#f00"); 154 | expect(queryByTestId(document.body, "card-bg")).toHaveAttribute( 155 | "fill", 156 | "#fff", 157 | ); 158 | }); 159 | it("should render gradient backgrounds", () => { 160 | const { titleColor, textColor, iconColor, bgColor } = getCardColors({ 161 | title_color: "f00", 162 | icon_color: "0f0", 163 | text_color: "00f", 164 | bg_color: "90,fff,000,f00", 165 | theme: "default", 166 | }); 167 | 168 | const card = new Card({ 169 | height: 200, 170 | colors: { 171 | titleColor, 172 | textColor, 173 | iconColor, 174 | bgColor, 175 | }, 176 | }); 177 | document.body.innerHTML = card.render(``); 178 | expect(queryByTestId(document.body, "card-bg")).toHaveAttribute( 179 | "fill", 180 | "url(#gradient)", 181 | ); 182 | expect(document.querySelector("defs #gradient")).toHaveAttribute( 183 | "gradientTransform", 184 | "rotate(90)", 185 | ); 186 | expect( 187 | document.querySelector("defs #gradient stop:nth-child(1)"), 188 | ).toHaveAttribute("stop-color", "#fff"); 189 | expect( 190 | document.querySelector("defs #gradient stop:nth-child(2)"), 191 | ).toHaveAttribute("stop-color", "#000"); 192 | expect( 193 | document.querySelector("defs #gradient stop:nth-child(3)"), 194 | ).toHaveAttribute("stop-color", "#f00"); 195 | }); 196 | }); 197 | -------------------------------------------------------------------------------- /tests/fetchGist.test.js: -------------------------------------------------------------------------------- 1 | import "@testing-library/jest-dom"; 2 | import axios from "axios"; 3 | import MockAdapter from "axios-mock-adapter"; 4 | import { expect, it, describe, afterEach } from "@jest/globals"; 5 | import { fetchGist } from "../src/fetchers/gist-fetcher.js"; 6 | 7 | const gist_data = { 8 | data: { 9 | viewer: { 10 | gist: { 11 | description: 12 | "List of countries and territories in English and Spanish: name, continent, capital, dial code, country codes, TLD, and area in sq km. Lista de países y territorios en Inglés y Español: nombre, continente, capital, código de teléfono, códigos de país, dominio y área en km cuadrados. Updated 2023", 13 | owner: { 14 | login: "Yizack", 15 | }, 16 | stargazerCount: 33, 17 | forks: { 18 | totalCount: 11, 19 | }, 20 | files: [ 21 | { 22 | name: "countries.json", 23 | language: { 24 | name: "JSON", 25 | }, 26 | size: 85858, 27 | }, 28 | { 29 | name: "territories.txt", 30 | language: { 31 | name: "Text", 32 | }, 33 | size: 87858, 34 | }, 35 | { 36 | name: "countries_spanish.json", 37 | language: { 38 | name: "JSON", 39 | }, 40 | size: 85858, 41 | }, 42 | { 43 | name: "territories_spanish.txt", 44 | language: { 45 | name: "Text", 46 | }, 47 | size: 87858, 48 | }, 49 | ], 50 | }, 51 | }, 52 | }, 53 | }; 54 | 55 | const gist_not_found_data = { 56 | data: { 57 | viewer: { 58 | gist: null, 59 | }, 60 | }, 61 | }; 62 | 63 | const gist_errors_data = { 64 | errors: [ 65 | { 66 | message: "Some test GraphQL error", 67 | }, 68 | ], 69 | }; 70 | 71 | const mock = new MockAdapter(axios); 72 | 73 | afterEach(() => { 74 | mock.reset(); 75 | }); 76 | 77 | describe("Test fetchGist", () => { 78 | it("should fetch gist correctly", async () => { 79 | mock.onPost("https://api.github.com/graphql").reply(200, gist_data); 80 | 81 | let gist = await fetchGist("bbfce31e0217a3689c8d961a356cb10d"); 82 | 83 | expect(gist).toStrictEqual({ 84 | name: "countries.json", 85 | nameWithOwner: "Yizack/countries.json", 86 | description: 87 | "List of countries and territories in English and Spanish: name, continent, capital, dial code, country codes, TLD, and area in sq km. Lista de países y territorios en Inglés y Español: nombre, continente, capital, código de teléfono, códigos de país, dominio y área en km cuadrados. Updated 2023", 88 | language: "Text", 89 | starsCount: 33, 90 | forksCount: 11, 91 | }); 92 | }); 93 | 94 | it("should throw correct error if gist not found", async () => { 95 | mock 96 | .onPost("https://api.github.com/graphql") 97 | .reply(200, gist_not_found_data); 98 | 99 | await expect(fetchGist("bbfce31e0217a3689c8d961a356cb10d")).rejects.toThrow( 100 | "Gist not found", 101 | ); 102 | }); 103 | 104 | it("should throw error if reaponse contains them", async () => { 105 | mock.onPost("https://api.github.com/graphql").reply(200, gist_errors_data); 106 | 107 | await expect(fetchGist("bbfce31e0217a3689c8d961a356cb10d")).rejects.toThrow( 108 | "Some test GraphQL error", 109 | ); 110 | }); 111 | 112 | it("should throw error if id is not provided", async () => { 113 | await expect(fetchGist()).rejects.toThrow( 114 | 'Missing params "id" make sure you pass the parameters in URL', 115 | ); 116 | }); 117 | }); 118 | -------------------------------------------------------------------------------- /tests/fetchRepo.test.js: -------------------------------------------------------------------------------- 1 | import "@testing-library/jest-dom"; 2 | import axios from "axios"; 3 | import MockAdapter from "axios-mock-adapter"; 4 | import { fetchRepo } from "../src/fetchers/repo-fetcher.js"; 5 | import { expect, it, describe, afterEach } from "@jest/globals"; 6 | 7 | const data_repo = { 8 | repository: { 9 | name: "convoychat", 10 | stargazers: { totalCount: 38000 }, 11 | description: "Help us take over the world! React + TS + GraphQL Chat App", 12 | primaryLanguage: { 13 | color: "#2b7489", 14 | id: "MDg6TGFuZ3VhZ2UyODc=", 15 | name: "TypeScript", 16 | }, 17 | forkCount: 100, 18 | }, 19 | }; 20 | 21 | const data_user = { 22 | data: { 23 | user: { repository: data_repo.repository }, 24 | organization: null, 25 | }, 26 | }; 27 | 28 | const data_org = { 29 | data: { 30 | user: null, 31 | organization: { repository: data_repo.repository }, 32 | }, 33 | }; 34 | 35 | const mock = new MockAdapter(axios); 36 | 37 | afterEach(() => { 38 | mock.reset(); 39 | }); 40 | 41 | describe("Test fetchRepo", () => { 42 | it("should fetch correct user repo", async () => { 43 | mock.onPost("https://api.github.com/graphql").reply(200, data_user); 44 | 45 | let repo = await fetchRepo("anuraghazra", "convoychat"); 46 | 47 | expect(repo).toStrictEqual({ 48 | ...data_repo.repository, 49 | starCount: data_repo.repository.stargazers.totalCount, 50 | }); 51 | }); 52 | 53 | it("should fetch correct org repo", async () => { 54 | mock.onPost("https://api.github.com/graphql").reply(200, data_org); 55 | 56 | let repo = await fetchRepo("anuraghazra", "convoychat"); 57 | expect(repo).toStrictEqual({ 58 | ...data_repo.repository, 59 | starCount: data_repo.repository.stargazers.totalCount, 60 | }); 61 | }); 62 | 63 | it("should throw error if user is found but repo is null", async () => { 64 | mock 65 | .onPost("https://api.github.com/graphql") 66 | .reply(200, { data: { user: { repository: null }, organization: null } }); 67 | 68 | await expect(fetchRepo("anuraghazra", "convoychat")).rejects.toThrow( 69 | "User Repository Not found", 70 | ); 71 | }); 72 | 73 | it("should throw error if org is found but repo is null", async () => { 74 | mock 75 | .onPost("https://api.github.com/graphql") 76 | .reply(200, { data: { user: null, organization: { repository: null } } }); 77 | 78 | await expect(fetchRepo("anuraghazra", "convoychat")).rejects.toThrow( 79 | "Organization Repository Not found", 80 | ); 81 | }); 82 | 83 | it("should throw error if both user & org data not found", async () => { 84 | mock 85 | .onPost("https://api.github.com/graphql") 86 | .reply(200, { data: { user: null, organization: null } }); 87 | 88 | await expect(fetchRepo("anuraghazra", "convoychat")).rejects.toThrow( 89 | "Not found", 90 | ); 91 | }); 92 | 93 | it("should throw error if repository is private", async () => { 94 | mock.onPost("https://api.github.com/graphql").reply(200, { 95 | data: { 96 | user: { repository: { ...data_repo, isPrivate: true } }, 97 | organization: null, 98 | }, 99 | }); 100 | 101 | await expect(fetchRepo("anuraghazra", "convoychat")).rejects.toThrow( 102 | "User Repository Not found", 103 | ); 104 | }); 105 | }); 106 | -------------------------------------------------------------------------------- /tests/fetchTopLanguages.test.js: -------------------------------------------------------------------------------- 1 | import "@testing-library/jest-dom"; 2 | import axios from "axios"; 3 | import MockAdapter from "axios-mock-adapter"; 4 | import { fetchTopLanguages } from "../src/fetchers/top-languages-fetcher.js"; 5 | import { expect, it, describe, afterEach } from "@jest/globals"; 6 | 7 | const mock = new MockAdapter(axios); 8 | 9 | afterEach(() => { 10 | mock.reset(); 11 | }); 12 | 13 | const data_langs = { 14 | data: { 15 | user: { 16 | repositories: { 17 | nodes: [ 18 | { 19 | name: "test-repo-1", 20 | languages: { 21 | edges: [{ size: 100, node: { color: "#0f0", name: "HTML" } }], 22 | }, 23 | }, 24 | { 25 | name: "test-repo-2", 26 | languages: { 27 | edges: [{ size: 100, node: { color: "#0f0", name: "HTML" } }], 28 | }, 29 | }, 30 | { 31 | name: "test-repo-3", 32 | languages: { 33 | edges: [ 34 | { size: 100, node: { color: "#0ff", name: "javascript" } }, 35 | ], 36 | }, 37 | }, 38 | { 39 | name: "test-repo-4", 40 | languages: { 41 | edges: [ 42 | { size: 100, node: { color: "#0ff", name: "javascript" } }, 43 | ], 44 | }, 45 | }, 46 | ], 47 | }, 48 | }, 49 | }, 50 | }; 51 | 52 | const error = { 53 | errors: [ 54 | { 55 | type: "NOT_FOUND", 56 | path: ["user"], 57 | locations: [], 58 | message: "Could not resolve to a User with the login of 'noname'.", 59 | }, 60 | ], 61 | }; 62 | 63 | describe("FetchTopLanguages", () => { 64 | it("should fetch correct language data while using the new calculation", async () => { 65 | mock.onPost("https://api.github.com/graphql").reply(200, data_langs); 66 | 67 | let repo = await fetchTopLanguages("anuraghazra", [], 0.5, 0.5); 68 | expect(repo).toStrictEqual({ 69 | HTML: { 70 | color: "#0f0", 71 | count: 2, 72 | name: "HTML", 73 | size: 20.000000000000004, 74 | }, 75 | javascript: { 76 | color: "#0ff", 77 | count: 2, 78 | name: "javascript", 79 | size: 20.000000000000004, 80 | }, 81 | }); 82 | }); 83 | 84 | it("should fetch correct language data while excluding the 'test-repo-1' repository", async () => { 85 | mock.onPost("https://api.github.com/graphql").reply(200, data_langs); 86 | 87 | let repo = await fetchTopLanguages("anuraghazra", ["test-repo-1"]); 88 | expect(repo).toStrictEqual({ 89 | HTML: { 90 | color: "#0f0", 91 | count: 1, 92 | name: "HTML", 93 | size: 100, 94 | }, 95 | javascript: { 96 | color: "#0ff", 97 | count: 2, 98 | name: "javascript", 99 | size: 200, 100 | }, 101 | }); 102 | }); 103 | 104 | it("should fetch correct language data while using the old calculation", async () => { 105 | mock.onPost("https://api.github.com/graphql").reply(200, data_langs); 106 | 107 | let repo = await fetchTopLanguages("anuraghazra", [], 1, 0); 108 | expect(repo).toStrictEqual({ 109 | HTML: { 110 | color: "#0f0", 111 | count: 2, 112 | name: "HTML", 113 | size: 200, 114 | }, 115 | javascript: { 116 | color: "#0ff", 117 | count: 2, 118 | name: "javascript", 119 | size: 200, 120 | }, 121 | }); 122 | }); 123 | 124 | it("should rank languages by the number of repositories they appear in", async () => { 125 | mock.onPost("https://api.github.com/graphql").reply(200, data_langs); 126 | 127 | let repo = await fetchTopLanguages("anuraghazra", [], 0, 1); 128 | expect(repo).toStrictEqual({ 129 | HTML: { 130 | color: "#0f0", 131 | count: 2, 132 | name: "HTML", 133 | size: 2, 134 | }, 135 | javascript: { 136 | color: "#0ff", 137 | count: 2, 138 | name: "javascript", 139 | size: 2, 140 | }, 141 | }); 142 | }); 143 | 144 | it("should throw specific error when user not found", async () => { 145 | mock.onPost("https://api.github.com/graphql").reply(200, error); 146 | 147 | await expect(fetchTopLanguages("anuraghazra")).rejects.toThrow( 148 | "Could not resolve to a User with the login of 'noname'.", 149 | ); 150 | }); 151 | 152 | it("should throw other errors with their message", async () => { 153 | mock.onPost("https://api.github.com/graphql").reply(200, { 154 | errors: [{ message: "Some test GraphQL error" }], 155 | }); 156 | 157 | await expect(fetchTopLanguages("anuraghazra")).rejects.toThrow( 158 | "Some test GraphQL error", 159 | ); 160 | }); 161 | 162 | it("should throw error with specific message when error does not contain message property", async () => { 163 | mock.onPost("https://api.github.com/graphql").reply(200, { 164 | errors: [{ type: "TEST" }], 165 | }); 166 | 167 | await expect(fetchTopLanguages("anuraghazra")).rejects.toThrow( 168 | "Something went wrong while trying to retrieve the language data using the GraphQL API.", 169 | ); 170 | }); 171 | }); 172 | -------------------------------------------------------------------------------- /tests/fetchWakatime.test.js: -------------------------------------------------------------------------------- 1 | import "@testing-library/jest-dom"; 2 | import axios from "axios"; 3 | import MockAdapter from "axios-mock-adapter"; 4 | import { fetchWakatimeStats } from "../src/fetchers/wakatime-fetcher.js"; 5 | import { expect, it, describe, afterEach } from "@jest/globals"; 6 | 7 | const mock = new MockAdapter(axios); 8 | 9 | afterEach(() => { 10 | mock.reset(); 11 | }); 12 | 13 | const wakaTimeData = { 14 | data: { 15 | categories: [ 16 | { 17 | digital: "22:40", 18 | hours: 22, 19 | minutes: 40, 20 | name: "Coding", 21 | percent: 100, 22 | text: "22 hrs 40 mins", 23 | total_seconds: 81643.570077, 24 | }, 25 | ], 26 | daily_average: 16095, 27 | daily_average_including_other_language: 16329, 28 | days_including_holidays: 7, 29 | days_minus_holidays: 5, 30 | editors: [ 31 | { 32 | digital: "22:40", 33 | hours: 22, 34 | minutes: 40, 35 | name: "VS Code", 36 | percent: 100, 37 | text: "22 hrs 40 mins", 38 | total_seconds: 81643.570077, 39 | }, 40 | ], 41 | holidays: 2, 42 | human_readable_daily_average: "4 hrs 28 mins", 43 | human_readable_daily_average_including_other_language: "4 hrs 32 mins", 44 | human_readable_total: "22 hrs 21 mins", 45 | human_readable_total_including_other_language: "22 hrs 40 mins", 46 | id: "random hash", 47 | is_already_updating: false, 48 | is_coding_activity_visible: true, 49 | is_including_today: false, 50 | is_other_usage_visible: true, 51 | is_stuck: false, 52 | is_up_to_date: true, 53 | languages: [ 54 | { 55 | digital: "0:19", 56 | hours: 0, 57 | minutes: 19, 58 | name: "Other", 59 | percent: 1.43, 60 | text: "19 mins", 61 | total_seconds: 1170.434361, 62 | }, 63 | { 64 | digital: "0:01", 65 | hours: 0, 66 | minutes: 1, 67 | name: "TypeScript", 68 | percent: 0.1, 69 | text: "1 min", 70 | total_seconds: 83.293809, 71 | }, 72 | { 73 | digital: "0:00", 74 | hours: 0, 75 | minutes: 0, 76 | name: "YAML", 77 | percent: 0.07, 78 | text: "0 secs", 79 | total_seconds: 54.975151, 80 | }, 81 | ], 82 | operating_systems: [ 83 | { 84 | digital: "22:40", 85 | hours: 22, 86 | minutes: 40, 87 | name: "Mac", 88 | percent: 100, 89 | text: "22 hrs 40 mins", 90 | total_seconds: 81643.570077, 91 | }, 92 | ], 93 | percent_calculated: 100, 94 | range: "last_7_days", 95 | status: "ok", 96 | timeout: 15, 97 | total_seconds: 80473.135716, 98 | total_seconds_including_other_language: 81643.570077, 99 | user_id: "random hash", 100 | username: "anuraghazra", 101 | writes_only: false, 102 | }, 103 | }; 104 | 105 | describe("WakaTime fetcher", () => { 106 | it("should fetch correct WakaTime data", async () => { 107 | const username = "anuraghazra"; 108 | mock 109 | .onGet( 110 | `https://wakatime.com/api/v1/users/${username}/stats?is_including_today=true`, 111 | ) 112 | .reply(200, wakaTimeData); 113 | 114 | const repo = await fetchWakatimeStats({ username }); 115 | expect(repo).toStrictEqual(wakaTimeData.data); 116 | }); 117 | 118 | it("should throw error if username param missing", async () => { 119 | mock.onGet(/\/https:\/\/wakatime\.com\/api/).reply(404, wakaTimeData); 120 | 121 | await expect(fetchWakatimeStats("noone")).rejects.toThrow( 122 | 'Missing params "username" make sure you pass the parameters in URL', 123 | ); 124 | }); 125 | 126 | it("should throw error if username is not found", async () => { 127 | mock.onGet(/\/https:\/\/wakatime\.com\/api/).reply(404, wakaTimeData); 128 | 129 | await expect(fetchWakatimeStats({ username: "noone" })).rejects.toThrow( 130 | "Could not resolve to a User with the login of 'noone'", 131 | ); 132 | }); 133 | }); 134 | 135 | export { wakaTimeData }; 136 | -------------------------------------------------------------------------------- /tests/flexLayout.test.js: -------------------------------------------------------------------------------- 1 | import { flexLayout } from "../src/common/utils.js"; 2 | import { expect, it, describe } from "@jest/globals"; 3 | 4 | describe("flexLayout", () => { 5 | it("should work with row & col layouts", () => { 6 | const layout = flexLayout({ 7 | items: ["1", "2"], 8 | gap: 60, 9 | }); 10 | 11 | expect(layout).toStrictEqual([ 12 | `1`, 13 | `2`, 14 | ]); 15 | 16 | const columns = flexLayout({ 17 | items: ["1", "2"], 18 | gap: 60, 19 | direction: "column", 20 | }); 21 | 22 | expect(columns).toStrictEqual([ 23 | `1`, 24 | `2`, 25 | ]); 26 | }); 27 | 28 | it("should work with sizes", () => { 29 | const layout = flexLayout({ 30 | items: [ 31 | "1", 32 | "2", 33 | "3", 34 | "4", 35 | ], 36 | gap: 20, 37 | sizes: [200, 100, 55, 25], 38 | }); 39 | 40 | expect(layout).toStrictEqual([ 41 | `1`, 42 | `2`, 43 | `3`, 44 | `4`, 45 | ]); 46 | }); 47 | }); 48 | -------------------------------------------------------------------------------- /tests/gist.test.js: -------------------------------------------------------------------------------- 1 | import { jest } from "@jest/globals"; 2 | import "@testing-library/jest-dom"; 3 | import axios from "axios"; 4 | import MockAdapter from "axios-mock-adapter"; 5 | import { expect, it, describe, afterEach } from "@jest/globals"; 6 | import { renderGistCard } from "../src/cards/gist-card.js"; 7 | import { renderError, CONSTANTS } from "../src/common/utils.js"; 8 | import gist from "../api/gist.js"; 9 | 10 | const gist_data = { 11 | data: { 12 | viewer: { 13 | gist: { 14 | description: 15 | "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", 16 | owner: { 17 | login: "Yizack", 18 | }, 19 | stargazerCount: 33, 20 | forks: { 21 | totalCount: 11, 22 | }, 23 | files: [ 24 | { 25 | name: "countries.json", 26 | language: { 27 | name: "JSON", 28 | }, 29 | size: 85858, 30 | }, 31 | ], 32 | }, 33 | }, 34 | }, 35 | }; 36 | 37 | const gist_not_found_data = { 38 | data: { 39 | viewer: { 40 | gist: null, 41 | }, 42 | }, 43 | }; 44 | 45 | const mock = new MockAdapter(axios); 46 | 47 | afterEach(() => { 48 | mock.reset(); 49 | }); 50 | 51 | describe("Test /api/gist", () => { 52 | it("should test the request", async () => { 53 | const req = { 54 | query: { 55 | id: "bbfce31e0217a3689c8d961a356cb10d", 56 | }, 57 | }; 58 | const res = { 59 | setHeader: jest.fn(), 60 | send: jest.fn(), 61 | }; 62 | mock.onPost("https://api.github.com/graphql").reply(200, gist_data); 63 | 64 | await gist(req, res); 65 | 66 | expect(res.setHeader).toBeCalledWith("Content-Type", "image/svg+xml"); 67 | expect(res.send).toBeCalledWith( 68 | renderGistCard({ 69 | name: gist_data.data.viewer.gist.files[0].name, 70 | nameWithOwner: `${gist_data.data.viewer.gist.owner.login}/${gist_data.data.viewer.gist.files[0].name}`, 71 | description: gist_data.data.viewer.gist.description, 72 | language: gist_data.data.viewer.gist.files[0].language.name, 73 | starsCount: gist_data.data.viewer.gist.stargazerCount, 74 | forksCount: gist_data.data.viewer.gist.forks.totalCount, 75 | }), 76 | ); 77 | }); 78 | 79 | it("should get the query options", async () => { 80 | const req = { 81 | query: { 82 | id: "bbfce31e0217a3689c8d961a356cb10d", 83 | title_color: "fff", 84 | icon_color: "fff", 85 | text_color: "fff", 86 | bg_color: "fff", 87 | show_owner: true, 88 | }, 89 | }; 90 | const res = { 91 | setHeader: jest.fn(), 92 | send: jest.fn(), 93 | }; 94 | mock.onPost("https://api.github.com/graphql").reply(200, gist_data); 95 | 96 | await gist(req, res); 97 | 98 | expect(res.setHeader).toBeCalledWith("Content-Type", "image/svg+xml"); 99 | expect(res.send).toBeCalledWith( 100 | renderGistCard( 101 | { 102 | name: gist_data.data.viewer.gist.files[0].name, 103 | nameWithOwner: `${gist_data.data.viewer.gist.owner.login}/${gist_data.data.viewer.gist.files[0].name}`, 104 | description: gist_data.data.viewer.gist.description, 105 | language: gist_data.data.viewer.gist.files[0].language.name, 106 | starsCount: gist_data.data.viewer.gist.stargazerCount, 107 | forksCount: gist_data.data.viewer.gist.forks.totalCount, 108 | }, 109 | { ...req.query }, 110 | ), 111 | ); 112 | }); 113 | 114 | it("should render error if id is not provided", async () => { 115 | const req = { 116 | query: {}, 117 | }; 118 | const res = { 119 | setHeader: jest.fn(), 120 | send: jest.fn(), 121 | }; 122 | 123 | await gist(req, res); 124 | 125 | expect(res.setHeader).toBeCalledWith("Content-Type", "image/svg+xml"); 126 | expect(res.send).toBeCalledWith( 127 | renderError( 128 | 'Missing params "id" make sure you pass the parameters in URL', 129 | "/api/gist?id=GIST_ID", 130 | ), 131 | ); 132 | }); 133 | 134 | it("should render error if gist is not found", async () => { 135 | const req = { 136 | query: { 137 | id: "bbfce31e0217a3689c8d961a356cb10d", 138 | }, 139 | }; 140 | const res = { 141 | setHeader: jest.fn(), 142 | send: jest.fn(), 143 | }; 144 | mock 145 | .onPost("https://api.github.com/graphql") 146 | .reply(200, gist_not_found_data); 147 | 148 | await gist(req, res); 149 | 150 | expect(res.setHeader).toBeCalledWith("Content-Type", "image/svg+xml"); 151 | expect(res.send).toBeCalledWith(renderError("Gist not found")); 152 | }); 153 | 154 | it("should render error if wrong locale is provided", async () => { 155 | const req = { 156 | query: { 157 | id: "bbfce31e0217a3689c8d961a356cb10d", 158 | locale: "asdf", 159 | }, 160 | }; 161 | const res = { 162 | setHeader: jest.fn(), 163 | send: jest.fn(), 164 | }; 165 | 166 | await gist(req, res); 167 | 168 | expect(res.setHeader).toBeCalledWith("Content-Type", "image/svg+xml"); 169 | expect(res.send).toBeCalledWith( 170 | renderError("Something went wrong", "Language not found"), 171 | ); 172 | }); 173 | 174 | it("should have proper cache", async () => { 175 | const req = { 176 | query: { 177 | id: "bbfce31e0217a3689c8d961a356cb10d", 178 | }, 179 | }; 180 | const res = { 181 | setHeader: jest.fn(), 182 | send: jest.fn(), 183 | }; 184 | mock.onPost("https://api.github.com/graphql").reply(200, gist_data); 185 | 186 | await gist(req, res); 187 | 188 | expect(res.setHeader).toBeCalledWith("Content-Type", "image/svg+xml"); 189 | expect(res.setHeader).toBeCalledWith( 190 | "Cache-Control", 191 | `max-age=${CONSTANTS.SIX_HOURS / 2}, s-maxage=${ 192 | CONSTANTS.SIX_HOURS 193 | }, stale-while-revalidate=${CONSTANTS.ONE_DAY}`, 194 | ); 195 | }); 196 | }); 197 | -------------------------------------------------------------------------------- /tests/i18n.test.js: -------------------------------------------------------------------------------- 1 | import { expect, it, describe } from "@jest/globals"; 2 | import { I18n } from "../src/common/I18n.js"; 3 | import { statCardLocales } from "../src/translations.js"; 4 | 5 | describe("I18n", () => { 6 | it("should return translated string", () => { 7 | const i18n = new I18n({ 8 | locale: "en", 9 | translations: statCardLocales({ name: "Anurag Hazra", apostrophe: "s" }), 10 | }); 11 | expect(i18n.t("statcard.title")).toBe("Anurag Hazra's GitHub Stats"); 12 | }); 13 | 14 | it("should throw error if translation string not found", () => { 15 | const i18n = new I18n({ 16 | locale: "en", 17 | translations: statCardLocales({ name: "Anurag Hazra", apostrophe: "s" }), 18 | }); 19 | expect(() => i18n.t("statcard.title1")).toThrow( 20 | "statcard.title1 Translation string not found", 21 | ); 22 | }); 23 | 24 | it("should throw error if translation not found for locale", () => { 25 | const i18n = new I18n({ 26 | locale: "asdf", 27 | translations: statCardLocales({ name: "Anurag Hazra", apostrophe: "s" }), 28 | }); 29 | expect(() => i18n.t("statcard.title")).toThrow( 30 | "'statcard.title' translation not found for locale 'asdf'", 31 | ); 32 | }); 33 | }); 34 | -------------------------------------------------------------------------------- /tests/pat-info.test.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @file Tests for the status/pat-info cloud function. 3 | */ 4 | import dotenv from "dotenv"; 5 | dotenv.config(); 6 | 7 | import { jest } from "@jest/globals"; 8 | import axios from "axios"; 9 | import MockAdapter from "axios-mock-adapter"; 10 | import patInfo, { RATE_LIMIT_SECONDS } from "../api/status/pat-info.js"; 11 | import { expect, it, describe, afterEach, beforeAll } from "@jest/globals"; 12 | 13 | const mock = new MockAdapter(axios); 14 | 15 | const successData = { 16 | data: { 17 | rateLimit: { 18 | remaining: 4986, 19 | }, 20 | }, 21 | }; 22 | 23 | const faker = (query) => { 24 | const req = { 25 | query: { ...query }, 26 | }; 27 | const res = { 28 | setHeader: jest.fn(), 29 | send: jest.fn(), 30 | }; 31 | 32 | return { req, res }; 33 | }; 34 | 35 | const rate_limit_error = { 36 | errors: [ 37 | { 38 | type: "RATE_LIMITED", 39 | message: "API rate limit exceeded for user ID.", 40 | }, 41 | ], 42 | data: { 43 | rateLimit: { 44 | resetAt: Date.now(), 45 | }, 46 | }, 47 | }; 48 | 49 | const other_error = { 50 | errors: [ 51 | { 52 | type: "SOME_ERROR", 53 | message: "This is a error", 54 | }, 55 | ], 56 | }; 57 | 58 | const bad_credentials_error = { 59 | message: "Bad credentials", 60 | }; 61 | 62 | afterEach(() => { 63 | mock.reset(); 64 | }); 65 | 66 | describe("Test /api/status/pat-info", () => { 67 | beforeAll(() => { 68 | // reset patenv first so that dotenv doesn't populate them with local envs 69 | process.env = {}; 70 | process.env.PAT_1 = "testPAT1"; 71 | process.env.PAT_2 = "testPAT2"; 72 | process.env.PAT_3 = "testPAT3"; 73 | process.env.PAT_4 = "testPAT4"; 74 | }); 75 | 76 | it("should return only 'validPATs' if all PATs are valid", async () => { 77 | mock 78 | .onPost("https://api.github.com/graphql") 79 | .replyOnce(200, rate_limit_error) 80 | .onPost("https://api.github.com/graphql") 81 | .reply(200, successData); 82 | 83 | const { req, res } = faker({}, {}); 84 | await patInfo(req, res); 85 | 86 | expect(res.setHeader).toBeCalledWith("Content-Type", "application/json"); 87 | expect(res.send).toBeCalledWith( 88 | JSON.stringify( 89 | { 90 | validPATs: ["PAT_2", "PAT_3", "PAT_4"], 91 | expiredPATs: [], 92 | exhaustedPATs: ["PAT_1"], 93 | suspendedPATs: [], 94 | errorPATs: [], 95 | details: { 96 | PAT_1: { 97 | status: "exhausted", 98 | remaining: 0, 99 | resetIn: "0 minutes", 100 | }, 101 | PAT_2: { 102 | status: "valid", 103 | remaining: 4986, 104 | }, 105 | PAT_3: { 106 | status: "valid", 107 | remaining: 4986, 108 | }, 109 | PAT_4: { 110 | status: "valid", 111 | remaining: 4986, 112 | }, 113 | }, 114 | }, 115 | null, 116 | 2, 117 | ), 118 | ); 119 | }); 120 | 121 | it("should return `errorPATs` if a PAT causes an error to be thrown", async () => { 122 | mock 123 | .onPost("https://api.github.com/graphql") 124 | .replyOnce(200, other_error) 125 | .onPost("https://api.github.com/graphql") 126 | .reply(200, successData); 127 | 128 | const { req, res } = faker({}, {}); 129 | await patInfo(req, res); 130 | 131 | expect(res.setHeader).toBeCalledWith("Content-Type", "application/json"); 132 | expect(res.send).toBeCalledWith( 133 | JSON.stringify( 134 | { 135 | validPATs: ["PAT_2", "PAT_3", "PAT_4"], 136 | expiredPATs: [], 137 | exhaustedPATs: [], 138 | suspendedPATs: [], 139 | errorPATs: ["PAT_1"], 140 | details: { 141 | PAT_1: { 142 | status: "error", 143 | error: { 144 | type: "SOME_ERROR", 145 | message: "This is a error", 146 | }, 147 | }, 148 | PAT_2: { 149 | status: "valid", 150 | remaining: 4986, 151 | }, 152 | PAT_3: { 153 | status: "valid", 154 | remaining: 4986, 155 | }, 156 | PAT_4: { 157 | status: "valid", 158 | remaining: 4986, 159 | }, 160 | }, 161 | }, 162 | null, 163 | 2, 164 | ), 165 | ); 166 | }); 167 | 168 | it("should return `expiredPaths` if a PAT returns a 'Bad credentials' error", async () => { 169 | mock 170 | .onPost("https://api.github.com/graphql") 171 | .replyOnce(404, bad_credentials_error) 172 | .onPost("https://api.github.com/graphql") 173 | .reply(200, successData); 174 | 175 | const { req, res } = faker({}, {}); 176 | await patInfo(req, res); 177 | 178 | expect(res.setHeader).toBeCalledWith("Content-Type", "application/json"); 179 | expect(res.send).toBeCalledWith( 180 | JSON.stringify( 181 | { 182 | validPATs: ["PAT_2", "PAT_3", "PAT_4"], 183 | expiredPATs: ["PAT_1"], 184 | exhaustedPATs: [], 185 | suspendedPATs: [], 186 | errorPATs: [], 187 | details: { 188 | PAT_1: { 189 | status: "expired", 190 | }, 191 | PAT_2: { 192 | status: "valid", 193 | remaining: 4986, 194 | }, 195 | PAT_3: { 196 | status: "valid", 197 | remaining: 4986, 198 | }, 199 | PAT_4: { 200 | status: "valid", 201 | remaining: 4986, 202 | }, 203 | }, 204 | }, 205 | null, 206 | 2, 207 | ), 208 | ); 209 | }); 210 | 211 | it("should throw an error if something goes wrong", async () => { 212 | mock.onPost("https://api.github.com/graphql").networkError(); 213 | 214 | const { req, res } = faker({}, {}); 215 | await patInfo(req, res); 216 | 217 | expect(res.setHeader).toBeCalledWith("Content-Type", "application/json"); 218 | expect(res.send).toBeCalledWith("Something went wrong: Network Error"); 219 | }); 220 | 221 | it("should have proper cache when no error is thrown", async () => { 222 | mock.onPost("https://api.github.com/graphql").reply(200, successData); 223 | 224 | const { req, res } = faker({}, {}); 225 | await patInfo(req, res); 226 | 227 | expect(res.setHeader.mock.calls).toEqual([ 228 | ["Content-Type", "application/json"], 229 | ["Cache-Control", `max-age=0, s-maxage=${RATE_LIMIT_SECONDS}`], 230 | ]); 231 | }); 232 | 233 | it("should have proper cache when error is thrown", async () => { 234 | mock.reset(); 235 | mock.onPost("https://api.github.com/graphql").networkError(); 236 | 237 | const { req, res } = faker({}, {}); 238 | await patInfo(req, res); 239 | 240 | expect(res.setHeader.mock.calls).toEqual([ 241 | ["Content-Type", "application/json"], 242 | ["Cache-Control", "no-store"], 243 | ]); 244 | }); 245 | }); 246 | -------------------------------------------------------------------------------- /tests/pin.test.js: -------------------------------------------------------------------------------- 1 | import { jest } from "@jest/globals"; 2 | import "@testing-library/jest-dom"; 3 | import axios from "axios"; 4 | import MockAdapter from "axios-mock-adapter"; 5 | import pin from "../api/pin.js"; 6 | import { renderRepoCard } from "../src/cards/repo-card.js"; 7 | import { renderError, CONSTANTS } from "../src/common/utils.js"; 8 | import { expect, it, describe, afterEach } from "@jest/globals"; 9 | 10 | const data_repo = { 11 | repository: { 12 | username: "anuraghazra", 13 | name: "convoychat", 14 | stargazers: { 15 | totalCount: 38000, 16 | }, 17 | description: "Help us take over the world! React + TS + GraphQL Chat App", 18 | primaryLanguage: { 19 | color: "#2b7489", 20 | id: "MDg6TGFuZ3VhZ2UyODc=", 21 | name: "TypeScript", 22 | }, 23 | forkCount: 100, 24 | isTemplate: false, 25 | }, 26 | }; 27 | 28 | const data_user = { 29 | data: { 30 | user: { repository: data_repo.repository }, 31 | organization: null, 32 | }, 33 | }; 34 | 35 | const mock = new MockAdapter(axios); 36 | 37 | afterEach(() => { 38 | mock.reset(); 39 | }); 40 | 41 | describe("Test /api/pin", () => { 42 | it("should test the request", async () => { 43 | const req = { 44 | query: { 45 | username: "anuraghazra", 46 | repo: "convoychat", 47 | }, 48 | }; 49 | const res = { 50 | setHeader: jest.fn(), 51 | send: jest.fn(), 52 | }; 53 | mock.onPost("https://api.github.com/graphql").reply(200, data_user); 54 | 55 | await pin(req, res); 56 | 57 | expect(res.setHeader).toBeCalledWith("Content-Type", "image/svg+xml"); 58 | expect(res.send).toBeCalledWith( 59 | renderRepoCard({ 60 | ...data_repo.repository, 61 | starCount: data_repo.repository.stargazers.totalCount, 62 | }), 63 | ); 64 | }); 65 | 66 | it("should get the query options", async () => { 67 | const req = { 68 | query: { 69 | username: "anuraghazra", 70 | repo: "convoychat", 71 | title_color: "fff", 72 | icon_color: "fff", 73 | text_color: "fff", 74 | bg_color: "fff", 75 | full_name: "1", 76 | }, 77 | }; 78 | const res = { 79 | setHeader: jest.fn(), 80 | send: jest.fn(), 81 | }; 82 | mock.onPost("https://api.github.com/graphql").reply(200, data_user); 83 | 84 | await pin(req, res); 85 | 86 | expect(res.setHeader).toBeCalledWith("Content-Type", "image/svg+xml"); 87 | expect(res.send).toBeCalledWith( 88 | renderRepoCard( 89 | { 90 | ...data_repo.repository, 91 | starCount: data_repo.repository.stargazers.totalCount, 92 | }, 93 | { ...req.query }, 94 | ), 95 | ); 96 | }); 97 | 98 | it("should render error card if user repo not found", async () => { 99 | const req = { 100 | query: { 101 | username: "anuraghazra", 102 | repo: "convoychat", 103 | }, 104 | }; 105 | const res = { 106 | setHeader: jest.fn(), 107 | send: jest.fn(), 108 | }; 109 | mock 110 | .onPost("https://api.github.com/graphql") 111 | .reply(200, { data: { user: { repository: null }, organization: null } }); 112 | 113 | await pin(req, res); 114 | 115 | expect(res.setHeader).toBeCalledWith("Content-Type", "image/svg+xml"); 116 | expect(res.send).toBeCalledWith(renderError("User Repository Not found")); 117 | }); 118 | 119 | it("should render error card if org repo not found", async () => { 120 | const req = { 121 | query: { 122 | username: "anuraghazra", 123 | repo: "convoychat", 124 | }, 125 | }; 126 | const res = { 127 | setHeader: jest.fn(), 128 | send: jest.fn(), 129 | }; 130 | mock 131 | .onPost("https://api.github.com/graphql") 132 | .reply(200, { data: { user: null, organization: { repository: null } } }); 133 | 134 | await pin(req, res); 135 | 136 | expect(res.setHeader).toBeCalledWith("Content-Type", "image/svg+xml"); 137 | expect(res.send).toBeCalledWith( 138 | renderError("Organization Repository Not found"), 139 | ); 140 | }); 141 | 142 | it("should render error card if username in blacklist", async () => { 143 | const req = { 144 | query: { 145 | username: "renovate-bot", 146 | repo: "convoychat", 147 | }, 148 | }; 149 | const res = { 150 | setHeader: jest.fn(), 151 | send: jest.fn(), 152 | }; 153 | mock.onPost("https://api.github.com/graphql").reply(200, data_user); 154 | 155 | await pin(req, res); 156 | 157 | expect(res.setHeader).toBeCalledWith("Content-Type", "image/svg+xml"); 158 | expect(res.send).toBeCalledWith( 159 | renderError("Something went wrong", "This username is blacklisted"), 160 | ); 161 | }); 162 | 163 | it("should render error card if wrong locale provided", async () => { 164 | const req = { 165 | query: { 166 | username: "anuraghazra", 167 | repo: "convoychat", 168 | locale: "asdf", 169 | }, 170 | }; 171 | const res = { 172 | setHeader: jest.fn(), 173 | send: jest.fn(), 174 | }; 175 | mock.onPost("https://api.github.com/graphql").reply(200, data_user); 176 | 177 | await pin(req, res); 178 | 179 | expect(res.setHeader).toBeCalledWith("Content-Type", "image/svg+xml"); 180 | expect(res.send).toBeCalledWith( 181 | renderError("Something went wrong", "Language not found"), 182 | ); 183 | }); 184 | 185 | it("should render error card if missing required parameters", async () => { 186 | const req = { 187 | query: {}, 188 | }; 189 | const res = { 190 | setHeader: jest.fn(), 191 | send: jest.fn(), 192 | }; 193 | 194 | await pin(req, res); 195 | 196 | expect(res.setHeader).toBeCalledWith("Content-Type", "image/svg+xml"); 197 | expect(res.send).toBeCalledWith( 198 | renderError( 199 | 'Missing params "username", "repo" make sure you pass the parameters in URL', 200 | "/api/pin?username=USERNAME&repo=REPO_NAME", 201 | ), 202 | ); 203 | }); 204 | 205 | it("should have proper cache", async () => { 206 | const req = { 207 | query: { 208 | username: "anuraghazra", 209 | repo: "convoychat", 210 | }, 211 | }; 212 | const res = { 213 | setHeader: jest.fn(), 214 | send: jest.fn(), 215 | }; 216 | mock.onPost("https://api.github.com/graphql").reply(200, data_user); 217 | 218 | await pin(req, res); 219 | 220 | expect(res.setHeader).toBeCalledWith("Content-Type", "image/svg+xml"); 221 | expect(res.setHeader).toBeCalledWith( 222 | "Cache-Control", 223 | `max-age=${CONSTANTS.SIX_HOURS / 2}, s-maxage=${ 224 | CONSTANTS.SIX_HOURS 225 | }, stale-while-revalidate=${CONSTANTS.ONE_DAY}`, 226 | ); 227 | }); 228 | }); 229 | -------------------------------------------------------------------------------- /tests/renderWakatimeCard.test.js: -------------------------------------------------------------------------------- 1 | import { queryByTestId } from "@testing-library/dom"; 2 | import "@testing-library/jest-dom"; 3 | import { renderWakatimeCard } from "../src/cards/wakatime-card.js"; 4 | import { getCardColors } from "../src/common/utils.js"; 5 | import { wakaTimeData } from "./fetchWakatime.test.js"; 6 | import { expect, it, describe } from "@jest/globals"; 7 | 8 | describe("Test Render WakaTime Card", () => { 9 | it("should render correctly", () => { 10 | // const card = renderWakatimeCard(wakaTimeData.data); 11 | expect(getCardColors).toMatchSnapshot(); 12 | }); 13 | 14 | it("should render correctly with compact layout", () => { 15 | const card = renderWakatimeCard(wakaTimeData.data, { layout: "compact" }); 16 | 17 | expect(card).toMatchSnapshot(); 18 | }); 19 | 20 | it("should render correctly with compact layout when langs_count is set", () => { 21 | const card = renderWakatimeCard(wakaTimeData.data, { 22 | layout: "compact", 23 | langs_count: 2, 24 | }); 25 | 26 | expect(card).toMatchSnapshot(); 27 | }); 28 | 29 | it("should hide languages when hide is passed", () => { 30 | document.body.innerHTML = renderWakatimeCard(wakaTimeData.data, { 31 | hide: ["YAML", "Other"], 32 | }); 33 | 34 | expect(queryByTestId(document.body, /YAML/i)).toBeNull(); 35 | expect(queryByTestId(document.body, /Other/i)).toBeNull(); 36 | expect(queryByTestId(document.body, /TypeScript/i)).not.toBeNull(); 37 | }); 38 | 39 | it("should render translations", () => { 40 | document.body.innerHTML = renderWakatimeCard({}, { locale: "cn" }); 41 | expect(document.getElementsByClassName("header")[0].textContent).toBe( 42 | "WakaTime 周统计", 43 | ); 44 | expect( 45 | document.querySelector('g[transform="translate(0, 0)"]>text.stat.bold') 46 | .textContent, 47 | ).toBe("WakaTime 用户个人资料未公开"); 48 | }); 49 | 50 | it("should render without rounding", () => { 51 | document.body.innerHTML = renderWakatimeCard(wakaTimeData.data, { 52 | border_radius: "0", 53 | }); 54 | expect(document.querySelector("rect")).toHaveAttribute("rx", "0"); 55 | document.body.innerHTML = renderWakatimeCard(wakaTimeData.data, {}); 56 | expect(document.querySelector("rect")).toHaveAttribute("rx", "4.5"); 57 | }); 58 | 59 | it('should show "no coding activity this week" message when there has not been activity', () => { 60 | document.body.innerHTML = renderWakatimeCard( 61 | { 62 | ...wakaTimeData.data, 63 | languages: undefined, 64 | }, 65 | {}, 66 | ); 67 | expect(document.querySelector(".stat").textContent).toBe( 68 | "No coding activity this week", 69 | ); 70 | }); 71 | 72 | it('should show "no coding activity this week" message when using compact layout and there has not been activity', () => { 73 | document.body.innerHTML = renderWakatimeCard( 74 | { 75 | ...wakaTimeData.data, 76 | languages: undefined, 77 | }, 78 | { 79 | layout: "compact", 80 | }, 81 | ); 82 | expect(document.querySelector(".stat").textContent).toBe( 83 | "No coding activity this week", 84 | ); 85 | }); 86 | }); 87 | -------------------------------------------------------------------------------- /tests/retryer.test.js: -------------------------------------------------------------------------------- 1 | import { jest } from "@jest/globals"; 2 | import "@testing-library/jest-dom"; 3 | import { retryer, RETRIES } from "../src/common/retryer.js"; 4 | import { logger } from "../src/common/utils.js"; 5 | import { expect, it, describe } from "@jest/globals"; 6 | 7 | const fetcher = jest.fn((variables, token) => { 8 | logger.log(variables, token); 9 | return new Promise((res) => res({ data: "ok" })); 10 | }); 11 | 12 | const fetcherFail = jest.fn(() => { 13 | return new Promise((res) => 14 | res({ data: { errors: [{ type: "RATE_LIMITED" }] } }), 15 | ); 16 | }); 17 | 18 | const fetcherFailOnSecondTry = jest.fn((_vars, _token, retries) => { 19 | return new Promise((res) => { 20 | // faking rate limit 21 | if (retries < 1) { 22 | return res({ data: { errors: [{ type: "RATE_LIMITED" }] } }); 23 | } 24 | return res({ data: "ok" }); 25 | }); 26 | }); 27 | 28 | describe("Test Retryer", () => { 29 | it("retryer should return value and have zero retries on first try", async () => { 30 | let res = await retryer(fetcher, {}); 31 | 32 | expect(fetcher).toBeCalledTimes(1); 33 | expect(res).toStrictEqual({ data: "ok" }); 34 | }); 35 | 36 | it("retryer should return value and have 2 retries", async () => { 37 | let res = await retryer(fetcherFailOnSecondTry, {}); 38 | 39 | expect(fetcherFailOnSecondTry).toBeCalledTimes(2); 40 | expect(res).toStrictEqual({ data: "ok" }); 41 | }); 42 | 43 | it("retryer should throw specific error if maximum retries reached", async () => { 44 | try { 45 | await retryer(fetcherFail, {}); 46 | } catch (err) { 47 | expect(fetcherFail).toBeCalledTimes(RETRIES + 1); 48 | expect(err.message).toBe("Downtime due to GitHub API rate limiting"); 49 | } 50 | }); 51 | }); 52 | -------------------------------------------------------------------------------- /tests/status.up.test.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @file Tests for the status/up cloud function. 3 | */ 4 | import { jest } from "@jest/globals"; 5 | import axios from "axios"; 6 | import MockAdapter from "axios-mock-adapter"; 7 | import up, { RATE_LIMIT_SECONDS } from "../api/status/up.js"; 8 | import { expect, it, describe, afterEach } from "@jest/globals"; 9 | 10 | const mock = new MockAdapter(axios); 11 | 12 | const successData = { 13 | rateLimit: { 14 | remaining: 4986, 15 | }, 16 | }; 17 | 18 | const faker = (query) => { 19 | const req = { 20 | query: { ...query }, 21 | }; 22 | const res = { 23 | setHeader: jest.fn(), 24 | send: jest.fn(), 25 | }; 26 | 27 | return { req, res }; 28 | }; 29 | 30 | const rate_limit_error = { 31 | errors: [ 32 | { 33 | type: "RATE_LIMITED", 34 | }, 35 | ], 36 | }; 37 | 38 | const bad_credentials_error = { 39 | message: "Bad credentials", 40 | }; 41 | 42 | const shields_up = { 43 | schemaVersion: 1, 44 | label: "Public Instance", 45 | isError: true, 46 | message: "up", 47 | color: "brightgreen", 48 | }; 49 | const shields_down = { 50 | schemaVersion: 1, 51 | label: "Public Instance", 52 | isError: true, 53 | message: "down", 54 | color: "red", 55 | }; 56 | 57 | afterEach(() => { 58 | mock.reset(); 59 | }); 60 | 61 | describe("Test /api/status/up", () => { 62 | it("should return `true` if request was successful", async () => { 63 | mock.onPost("https://api.github.com/graphql").replyOnce(200, successData); 64 | 65 | const { req, res } = faker({}, {}); 66 | await up(req, res); 67 | 68 | expect(res.setHeader).toBeCalledWith("Content-Type", "application/json"); 69 | expect(res.send).toBeCalledWith(true); 70 | }); 71 | 72 | it("should return `false` if all PATs are rate limited", async () => { 73 | mock.onPost("https://api.github.com/graphql").reply(200, rate_limit_error); 74 | 75 | const { req, res } = faker({}, {}); 76 | await up(req, res); 77 | 78 | expect(res.setHeader).toBeCalledWith("Content-Type", "application/json"); 79 | expect(res.send).toBeCalledWith(false); 80 | }); 81 | 82 | it("should return JSON `true` if request was successful and type='json'", async () => { 83 | mock.onPost("https://api.github.com/graphql").replyOnce(200, successData); 84 | 85 | const { req, res } = faker({ type: "json" }, {}); 86 | await up(req, res); 87 | 88 | expect(res.setHeader).toBeCalledWith("Content-Type", "application/json"); 89 | expect(res.send).toBeCalledWith({ up: true }); 90 | }); 91 | 92 | it("should return JSON `false` if all PATs are rate limited and type='json'", async () => { 93 | mock.onPost("https://api.github.com/graphql").reply(200, rate_limit_error); 94 | 95 | const { req, res } = faker({ type: "json" }, {}); 96 | await up(req, res); 97 | 98 | expect(res.setHeader).toBeCalledWith("Content-Type", "application/json"); 99 | expect(res.send).toBeCalledWith({ up: false }); 100 | }); 101 | 102 | it("should return UP shields.io config if request was successful and type='shields'", async () => { 103 | mock.onPost("https://api.github.com/graphql").replyOnce(200, successData); 104 | 105 | const { req, res } = faker({ type: "shields" }, {}); 106 | await up(req, res); 107 | 108 | expect(res.setHeader).toBeCalledWith("Content-Type", "application/json"); 109 | expect(res.send).toBeCalledWith(shields_up); 110 | }); 111 | 112 | it("should return DOWN shields.io config if all PATs are rate limited and type='shields'", async () => { 113 | mock.onPost("https://api.github.com/graphql").reply(200, rate_limit_error); 114 | 115 | const { req, res } = faker({ type: "shields" }, {}); 116 | await up(req, res); 117 | 118 | expect(res.setHeader).toBeCalledWith("Content-Type", "application/json"); 119 | expect(res.send).toBeCalledWith(shields_down); 120 | }); 121 | 122 | it("should return `true` if the first PAT is rate limited but the second PATs works", async () => { 123 | mock 124 | .onPost("https://api.github.com/graphql") 125 | .replyOnce(200, rate_limit_error) 126 | .onPost("https://api.github.com/graphql") 127 | .replyOnce(200, successData); 128 | 129 | const { req, res } = faker({}, {}); 130 | await up(req, res); 131 | 132 | expect(res.setHeader).toBeCalledWith("Content-Type", "application/json"); 133 | expect(res.send).toBeCalledWith(true); 134 | }); 135 | 136 | it("should return `true` if the first PAT has 'Bad credentials' but the second PAT works", async () => { 137 | mock 138 | .onPost("https://api.github.com/graphql") 139 | .replyOnce(404, bad_credentials_error) 140 | .onPost("https://api.github.com/graphql") 141 | .replyOnce(200, successData); 142 | 143 | const { req, res } = faker({}, {}); 144 | await up(req, res); 145 | 146 | expect(res.setHeader).toBeCalledWith("Content-Type", "application/json"); 147 | expect(res.send).toBeCalledWith(true); 148 | }); 149 | 150 | it("should return `false` if all pats have 'Bad credentials'", async () => { 151 | mock 152 | .onPost("https://api.github.com/graphql") 153 | .reply(404, bad_credentials_error); 154 | 155 | const { req, res } = faker({}, {}); 156 | await up(req, res); 157 | 158 | expect(res.setHeader).toBeCalledWith("Content-Type", "application/json"); 159 | expect(res.send).toBeCalledWith(false); 160 | }); 161 | 162 | it("should throw an error if the request fails", async () => { 163 | mock.onPost("https://api.github.com/graphql").networkError(); 164 | 165 | const { req, res } = faker({}, {}); 166 | await up(req, res); 167 | 168 | expect(res.setHeader).toBeCalledWith("Content-Type", "application/json"); 169 | expect(res.send).toBeCalledWith(false); 170 | }); 171 | 172 | it("should have proper cache when no error is thrown", async () => { 173 | mock.onPost("https://api.github.com/graphql").replyOnce(200, successData); 174 | 175 | const { req, res } = faker({}, {}); 176 | await up(req, res); 177 | 178 | expect(res.setHeader.mock.calls).toEqual([ 179 | ["Content-Type", "application/json"], 180 | ["Cache-Control", `max-age=0, s-maxage=${RATE_LIMIT_SECONDS}`], 181 | ]); 182 | }); 183 | 184 | it("should have proper cache when error is thrown", async () => { 185 | mock.onPost("https://api.github.com/graphql").networkError(); 186 | 187 | const { req, res } = faker({}, {}); 188 | await up(req, res); 189 | 190 | expect(res.setHeader.mock.calls).toEqual([ 191 | ["Content-Type", "application/json"], 192 | ["Cache-Control", "no-store"], 193 | ]); 194 | }); 195 | }); 196 | -------------------------------------------------------------------------------- /tests/top-langs.test.js: -------------------------------------------------------------------------------- 1 | import { jest } from "@jest/globals"; 2 | import "@testing-library/jest-dom"; 3 | import axios from "axios"; 4 | import MockAdapter from "axios-mock-adapter"; 5 | import topLangs from "../api/top-langs.js"; 6 | import { renderTopLanguages } from "../src/cards/top-languages-card.js"; 7 | import { renderError, CONSTANTS } from "../src/common/utils.js"; 8 | import { expect, it, describe, afterEach } from "@jest/globals"; 9 | 10 | const data_langs = { 11 | data: { 12 | user: { 13 | repositories: { 14 | nodes: [ 15 | { 16 | languages: { 17 | edges: [{ size: 150, node: { color: "#0f0", name: "HTML" } }], 18 | }, 19 | }, 20 | { 21 | languages: { 22 | edges: [{ size: 100, node: { color: "#0f0", name: "HTML" } }], 23 | }, 24 | }, 25 | { 26 | languages: { 27 | edges: [ 28 | { size: 100, node: { color: "#0ff", name: "javascript" } }, 29 | ], 30 | }, 31 | }, 32 | { 33 | languages: { 34 | edges: [ 35 | { size: 100, node: { color: "#0ff", name: "javascript" } }, 36 | ], 37 | }, 38 | }, 39 | ], 40 | }, 41 | }, 42 | }, 43 | }; 44 | 45 | const error = { 46 | errors: [ 47 | { 48 | type: "NOT_FOUND", 49 | path: ["user"], 50 | locations: [], 51 | message: "Could not fetch user", 52 | }, 53 | ], 54 | }; 55 | 56 | const langs = { 57 | HTML: { 58 | color: "#0f0", 59 | name: "HTML", 60 | size: 250, 61 | }, 62 | javascript: { 63 | color: "#0ff", 64 | name: "javascript", 65 | size: 200, 66 | }, 67 | }; 68 | 69 | const mock = new MockAdapter(axios); 70 | 71 | afterEach(() => { 72 | mock.reset(); 73 | }); 74 | 75 | describe("Test /api/top-langs", () => { 76 | it("should test the request", async () => { 77 | const req = { 78 | query: { 79 | username: "anuraghazra", 80 | }, 81 | }; 82 | const res = { 83 | setHeader: jest.fn(), 84 | send: jest.fn(), 85 | }; 86 | mock.onPost("https://api.github.com/graphql").reply(200, data_langs); 87 | 88 | await topLangs(req, res); 89 | 90 | expect(res.setHeader).toBeCalledWith("Content-Type", "image/svg+xml"); 91 | expect(res.send).toBeCalledWith(renderTopLanguages(langs)); 92 | }); 93 | 94 | it("should work with the query options", async () => { 95 | const req = { 96 | query: { 97 | username: "anuraghazra", 98 | hide_title: true, 99 | card_width: 100, 100 | title_color: "fff", 101 | icon_color: "fff", 102 | text_color: "fff", 103 | bg_color: "fff", 104 | }, 105 | }; 106 | const res = { 107 | setHeader: jest.fn(), 108 | send: jest.fn(), 109 | }; 110 | mock.onPost("https://api.github.com/graphql").reply(200, data_langs); 111 | 112 | await topLangs(req, res); 113 | 114 | expect(res.setHeader).toBeCalledWith("Content-Type", "image/svg+xml"); 115 | expect(res.send).toBeCalledWith( 116 | renderTopLanguages(langs, { 117 | hide_title: true, 118 | card_width: 100, 119 | title_color: "fff", 120 | icon_color: "fff", 121 | text_color: "fff", 122 | bg_color: "fff", 123 | }), 124 | ); 125 | }); 126 | 127 | it("should render error card on user data fetch error", async () => { 128 | const req = { 129 | query: { 130 | username: "anuraghazra", 131 | }, 132 | }; 133 | const res = { 134 | setHeader: jest.fn(), 135 | send: jest.fn(), 136 | }; 137 | mock.onPost("https://api.github.com/graphql").reply(200, error); 138 | 139 | await topLangs(req, res); 140 | 141 | expect(res.setHeader).toBeCalledWith("Content-Type", "image/svg+xml"); 142 | expect(res.send).toBeCalledWith( 143 | renderError( 144 | error.errors[0].message, 145 | "Make sure the provided username is not an organization", 146 | ), 147 | ); 148 | }); 149 | 150 | it("should render error card on incorrect layout input", async () => { 151 | const req = { 152 | query: { 153 | username: "anuraghazra", 154 | layout: ["pie"], 155 | }, 156 | }; 157 | const res = { 158 | setHeader: jest.fn(), 159 | send: jest.fn(), 160 | }; 161 | mock.onPost("https://api.github.com/graphql").reply(200, data_langs); 162 | 163 | await topLangs(req, res); 164 | 165 | expect(res.setHeader).toBeCalledWith("Content-Type", "image/svg+xml"); 166 | expect(res.send).toBeCalledWith( 167 | renderError("Something went wrong", "Incorrect layout input"), 168 | ); 169 | }); 170 | 171 | it("should render error card if username in blacklist", async () => { 172 | const req = { 173 | query: { 174 | username: "renovate-bot", 175 | }, 176 | }; 177 | const res = { 178 | setHeader: jest.fn(), 179 | send: jest.fn(), 180 | }; 181 | mock.onPost("https://api.github.com/graphql").reply(200, data_langs); 182 | 183 | await topLangs(req, res); 184 | 185 | expect(res.setHeader).toBeCalledWith("Content-Type", "image/svg+xml"); 186 | expect(res.send).toBeCalledWith( 187 | renderError("Something went wrong", "This username is blacklisted"), 188 | ); 189 | }); 190 | 191 | it("should render error card if wrong locale provided", async () => { 192 | const req = { 193 | query: { 194 | username: "anuraghazra", 195 | locale: "asdf", 196 | }, 197 | }; 198 | const res = { 199 | setHeader: jest.fn(), 200 | send: jest.fn(), 201 | }; 202 | mock.onPost("https://api.github.com/graphql").reply(200, data_langs); 203 | 204 | await topLangs(req, res); 205 | 206 | expect(res.setHeader).toBeCalledWith("Content-Type", "image/svg+xml"); 207 | expect(res.send).toBeCalledWith( 208 | renderError("Something went wrong", "Locale not found"), 209 | ); 210 | }); 211 | 212 | it("should have proper cache", async () => { 213 | const req = { 214 | query: { 215 | username: "anuraghazra", 216 | }, 217 | }; 218 | const res = { 219 | setHeader: jest.fn(), 220 | send: jest.fn(), 221 | }; 222 | mock.onPost("https://api.github.com/graphql").reply(200, data_langs); 223 | 224 | await topLangs(req, res); 225 | 226 | expect(res.setHeader).toBeCalledWith("Content-Type", "image/svg+xml"); 227 | expect(res.setHeader).toBeCalledWith( 228 | "Cache-Control", 229 | `max-age=${CONSTANTS.SIX_HOURS / 2}, s-maxage=${ 230 | CONSTANTS.SIX_HOURS 231 | }, stale-while-revalidate=${CONSTANTS.ONE_DAY}`, 232 | ); 233 | }); 234 | }); 235 | -------------------------------------------------------------------------------- /tests/utils.test.js: -------------------------------------------------------------------------------- 1 | import { queryByTestId } from "@testing-library/dom"; 2 | import "@testing-library/jest-dom"; 3 | import { 4 | encodeHTML, 5 | getCardColors, 6 | kFormatter, 7 | parseBoolean, 8 | renderError, 9 | wrapTextMultiline, 10 | } from "../src/common/utils.js"; 11 | import { expect, it, describe } from "@jest/globals"; 12 | 13 | describe("Test utils.js", () => { 14 | it("should test kFormatter", () => { 15 | expect(kFormatter(1)).toBe(1); 16 | expect(kFormatter(-1)).toBe(-1); 17 | expect(kFormatter(500)).toBe(500); 18 | expect(kFormatter(1000)).toBe("1k"); 19 | expect(kFormatter(10000)).toBe("10k"); 20 | expect(kFormatter(12345)).toBe("12.3k"); 21 | expect(kFormatter(9900000)).toBe("9900k"); 22 | }); 23 | 24 | it("should test parseBoolean", () => { 25 | expect(parseBoolean(true)).toBe(true); 26 | expect(parseBoolean(false)).toBe(false); 27 | 28 | expect(parseBoolean("true")).toBe(true); 29 | expect(parseBoolean("false")).toBe(false); 30 | expect(parseBoolean("True")).toBe(true); 31 | expect(parseBoolean("False")).toBe(false); 32 | expect(parseBoolean("TRUE")).toBe(true); 33 | expect(parseBoolean("FALSE")).toBe(false); 34 | 35 | expect(parseBoolean("1")).toBe(undefined); 36 | expect(parseBoolean("0")).toBe(undefined); 37 | expect(parseBoolean("")).toBe(undefined); 38 | expect(parseBoolean(undefined)).toBe(undefined); 39 | }); 40 | 41 | it("should test encodeHTML", () => { 42 | expect(encodeHTML(`hello world<,.#4^&^@%!))`)).toBe( 43 | "<html>hello world<,.#4^&^@%!))", 44 | ); 45 | }); 46 | 47 | it("should test renderError", () => { 48 | document.body.innerHTML = renderError("Something went wrong"); 49 | expect( 50 | queryByTestId(document.body, "message").children[0], 51 | ).toHaveTextContent(/Something went wrong/gim); 52 | expect( 53 | queryByTestId(document.body, "message").children[1], 54 | ).toBeEmptyDOMElement(2); 55 | 56 | // Secondary message 57 | document.body.innerHTML = renderError( 58 | "Something went wrong", 59 | "Secondary Message", 60 | ); 61 | expect( 62 | queryByTestId(document.body, "message").children[1], 63 | ).toHaveTextContent(/Secondary Message/gim); 64 | }); 65 | 66 | it("getCardColors: should return expected values", () => { 67 | let colors = getCardColors({ 68 | title_color: "f00", 69 | text_color: "0f0", 70 | ring_color: "0000ff", 71 | icon_color: "00f", 72 | bg_color: "fff", 73 | border_color: "fff", 74 | theme: "dark", 75 | }); 76 | expect(colors).toStrictEqual({ 77 | titleColor: "#f00", 78 | textColor: "#0f0", 79 | iconColor: "#00f", 80 | ringColor: "#0000ff", 81 | bgColor: "#fff", 82 | borderColor: "#fff", 83 | }); 84 | }); 85 | 86 | it("getCardColors: should fallback to default colors if color is invalid", () => { 87 | let colors = getCardColors({ 88 | title_color: "invalidcolor", 89 | text_color: "0f0", 90 | icon_color: "00f", 91 | bg_color: "fff", 92 | border_color: "invalidColor", 93 | theme: "dark", 94 | }); 95 | expect(colors).toStrictEqual({ 96 | titleColor: "#2f80ed", 97 | textColor: "#0f0", 98 | iconColor: "#00f", 99 | ringColor: "#2f80ed", 100 | bgColor: "#fff", 101 | borderColor: "#e4e2e2", 102 | }); 103 | }); 104 | 105 | it("getCardColors: should fallback to specified theme colors if is not defined", () => { 106 | let colors = getCardColors({ 107 | theme: "dark", 108 | }); 109 | expect(colors).toStrictEqual({ 110 | titleColor: "#fff", 111 | textColor: "#9f9f9f", 112 | ringColor: "#fff", 113 | iconColor: "#79ff97", 114 | bgColor: "#151515", 115 | borderColor: "#e4e2e2", 116 | }); 117 | }); 118 | 119 | it("getCardColors: should return ring color equal to title color if not ring color is defined", () => { 120 | let colors = getCardColors({ 121 | title_color: "f00", 122 | text_color: "0f0", 123 | icon_color: "00f", 124 | bg_color: "fff", 125 | border_color: "fff", 126 | theme: "dark", 127 | }); 128 | expect(colors).toStrictEqual({ 129 | titleColor: "#f00", 130 | textColor: "#0f0", 131 | iconColor: "#00f", 132 | ringColor: "#f00", 133 | bgColor: "#fff", 134 | borderColor: "#fff", 135 | }); 136 | }); 137 | }); 138 | 139 | describe("wrapTextMultiline", () => { 140 | it("should not wrap small texts", () => { 141 | { 142 | let multiLineText = wrapTextMultiline("Small text should not wrap"); 143 | expect(multiLineText).toEqual(["Small text should not wrap"]); 144 | } 145 | }); 146 | it("should wrap large texts", () => { 147 | let multiLineText = wrapTextMultiline( 148 | "Hello world long long long text", 149 | 20, 150 | 3, 151 | ); 152 | expect(multiLineText).toEqual(["Hello world long", "long long text"]); 153 | }); 154 | it("should wrap large texts and limit max lines", () => { 155 | let multiLineText = wrapTextMultiline( 156 | "Hello world long long long text", 157 | 10, 158 | 2, 159 | ); 160 | expect(multiLineText).toEqual(["Hello", "world long..."]); 161 | }); 162 | it("should wrap chinese by punctuation", () => { 163 | let multiLineText = wrapTextMultiline( 164 | "专门为刚开始刷题的同学准备的算法基地,没有最细只有更细,立志用动画将晦涩难懂的算法说的通俗易懂!", 165 | ); 166 | expect(multiLineText.length).toEqual(3); 167 | expect(multiLineText[0].length).toEqual(18 * 8); // &#xxxxx; x 8 168 | }); 169 | }); 170 | -------------------------------------------------------------------------------- /tests/wakatime.test.js: -------------------------------------------------------------------------------- 1 | import { jest } from "@jest/globals"; 2 | import "@testing-library/jest-dom"; 3 | import axios from "axios"; 4 | import MockAdapter from "axios-mock-adapter"; 5 | import wakatime from "../api/wakatime.js"; 6 | import { renderWakatimeCard } from "../src/cards/wakatime-card.js"; 7 | import { expect, it, describe, afterEach } from "@jest/globals"; 8 | 9 | const wakaTimeData = { 10 | data: { 11 | categories: [ 12 | { 13 | digital: "22:40", 14 | hours: 22, 15 | minutes: 40, 16 | name: "Coding", 17 | percent: 100, 18 | text: "22 hrs 40 mins", 19 | total_seconds: 81643.570077, 20 | }, 21 | ], 22 | daily_average: 16095, 23 | daily_average_including_other_language: 16329, 24 | days_including_holidays: 7, 25 | days_minus_holidays: 5, 26 | editors: [ 27 | { 28 | digital: "22:40", 29 | hours: 22, 30 | minutes: 40, 31 | name: "VS Code", 32 | percent: 100, 33 | text: "22 hrs 40 mins", 34 | total_seconds: 81643.570077, 35 | }, 36 | ], 37 | holidays: 2, 38 | human_readable_daily_average: "4 hrs 28 mins", 39 | human_readable_daily_average_including_other_language: "4 hrs 32 mins", 40 | human_readable_total: "22 hrs 21 mins", 41 | human_readable_total_including_other_language: "22 hrs 40 mins", 42 | id: "random hash", 43 | is_already_updating: false, 44 | is_coding_activity_visible: true, 45 | is_including_today: false, 46 | is_other_usage_visible: true, 47 | is_stuck: false, 48 | is_up_to_date: true, 49 | languages: [ 50 | { 51 | digital: "0:19", 52 | hours: 0, 53 | minutes: 19, 54 | name: "Other", 55 | percent: 1.43, 56 | text: "19 mins", 57 | total_seconds: 1170.434361, 58 | }, 59 | { 60 | digital: "0:01", 61 | hours: 0, 62 | minutes: 1, 63 | name: "TypeScript", 64 | percent: 0.1, 65 | text: "1 min", 66 | total_seconds: 83.293809, 67 | }, 68 | { 69 | digital: "0:00", 70 | hours: 0, 71 | minutes: 0, 72 | name: "YAML", 73 | percent: 0.07, 74 | text: "0 secs", 75 | total_seconds: 54.975151, 76 | }, 77 | ], 78 | operating_systems: [ 79 | { 80 | digital: "22:40", 81 | hours: 22, 82 | minutes: 40, 83 | name: "Mac", 84 | percent: 100, 85 | text: "22 hrs 40 mins", 86 | total_seconds: 81643.570077, 87 | }, 88 | ], 89 | percent_calculated: 100, 90 | range: "last_7_days", 91 | status: "ok", 92 | timeout: 15, 93 | total_seconds: 80473.135716, 94 | total_seconds_including_other_language: 81643.570077, 95 | user_id: "random hash", 96 | username: "anuraghazra", 97 | writes_only: false, 98 | }, 99 | }; 100 | 101 | const mock = new MockAdapter(axios); 102 | 103 | afterEach(() => { 104 | mock.reset(); 105 | }); 106 | 107 | describe("Test /api/wakatime", () => { 108 | it("should test the request", async () => { 109 | const username = "anuraghazra"; 110 | const req = { query: { username } }; 111 | const res = { setHeader: jest.fn(), send: jest.fn() }; 112 | mock 113 | .onGet( 114 | `https://wakatime.com/api/v1/users/${username}/stats?is_including_today=true`, 115 | ) 116 | .reply(200, wakaTimeData); 117 | 118 | await wakatime(req, res); 119 | 120 | expect(res.setHeader).toBeCalledWith("Content-Type", "image/svg+xml"); 121 | expect(res.send).toBeCalledWith(renderWakatimeCard(wakaTimeData.data, {})); 122 | }); 123 | }); 124 | -------------------------------------------------------------------------------- /vercel.json: -------------------------------------------------------------------------------- 1 | { 2 | "functions": { 3 | "api/*.js": { 4 | "memory": 128, 5 | "maxDuration": 10 6 | } 7 | }, 8 | "redirects": [ 9 | { 10 | "source": "/", 11 | "destination": "https://github.com/anuraghazra/github-readme-stats" 12 | } 13 | ] 14 | } 15 | --------------------------------------------------------------------------------