├── .gitattributes ├── .github ├── FUNDING.yml ├── ISSUE_TEMPLATE │ ├── bug_report.yml │ ├── config.yml │ └── feature_request.yml ├── labeler.yml ├── stale.yml └── workflows │ ├── deploy-prep.py │ ├── deploy-prep.yml │ ├── e2e-test.yml │ ├── empty-issues-closer.yaml │ ├── generate-theme-doc.yml │ ├── label-pr.yml │ ├── preview-theme.yml │ ├── prs-cache-clean.yml │ ├── stale-theme-pr-closer.yaml │ ├── test.yml │ ├── top-issues-dashboard.yml │ └── update-langs.yaml ├── .gitignore ├── .husky ├── .gitignore └── pre-commit ├── .prettierignore ├── .prettierrc.json ├── .vercelignore ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── LICENSE ├── api ├── 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.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 │ ├── 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 │ ├── repo-fetcher.js │ ├── stats-fetcher.js │ ├── top-languages-fetcher.js │ ├── types.d.ts │ └── wakatime-fetcher.js ├── getStyles.js ├── index.js └── translations.js ├── tests ├── __snapshots__ │ └── renderWakatimeCard.test.js.snap ├── api.test.js ├── calculateRank.test.js ├── card.test.js ├── e2e │ └── e2e.test.js ├── fetchRepo.test.js ├── fetchStats.test.js ├── fetchTopLanguages.test.js ├── fetchWakatime.test.js ├── flexLayout.test.js ├── pat-info.test.js ├── pin.test.js ├── renderRepoCard.test.js ├── renderStatsCard.test.js ├── renderTopLanguages.test.js ├── renderWakatimeCard.test.js ├── retryer.test.js ├── status.up.test.js ├── top-langs.test.js └── utils.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/labeler.yml: -------------------------------------------------------------------------------- 1 | themes: themes/index.js 2 | doc-translation: docs/* 3 | card-i18n: src/translations.js 4 | documentation: readme.md 5 | -------------------------------------------------------------------------------- /.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/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@v3 14 | - name: Deployment Prep 15 | run: python ./.github/workflows/deploy-prep.py 16 | - uses: stefanzweifel/git-auto-commit-action@v4 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 | jobs: 6 | e2eTests: 7 | if: 8 | github.repository == 'anuraghazra/github-readme-stats' && 9 | github.event_name == 'deployment_status' && 10 | github.event.deployment_status.state == 'success' 11 | name: Perform 2e2 tests 12 | runs-on: ubuntu-latest 13 | strategy: 14 | matrix: 15 | node-version: [16.x] 16 | 17 | steps: 18 | - uses: actions/checkout@v3 19 | 20 | - name: Setup Node 21 | uses: actions/setup-node@v3 22 | with: 23 | node-version: ${{ matrix.node-version }} 24 | cache: npm 25 | 26 | - name: Install dependencies 27 | run: npm ci 28 | env: 29 | CI: true 30 | 31 | - name: Run end-to-end tests. 32 | run: npm run test:e2e 33 | env: 34 | VERCEL_PREVIEW_URL: ${{ github.event.deployment_status.target_url }} 35 | -------------------------------------------------------------------------------- /.github/workflows/empty-issues-closer.yaml: -------------------------------------------------------------------------------- 1 | name: Close empty issues and templates 2 | on: 3 | issues: 4 | types: 5 | - reopened 6 | - opened 7 | - edited 8 | 9 | jobs: 10 | closeEmptyIssuesAndTemplates: 11 | if: github.repository == 'anuraghazra/github-readme-stats' 12 | name: Close empty issues 13 | runs-on: ubuntu-latest 14 | steps: 15 | - uses: actions/checkout@v3 # NOTE: Retrieve issue templates. 16 | 17 | - name: Run empty issues closer action 18 | uses: rickstaa/empty-issues-closer-action@v1 19 | env: 20 | github_token: ${{ secrets.GITHUB_TOKEN }} 21 | with: 22 | close_comment: 23 | Closing this issue because it appears to be empty. Please update the 24 | issue for it to be reopened. 25 | open_comment: 26 | Reopening this issue because the author provided more information. 27 | check_templates: true 28 | template_close_comment: 29 | Closing this issue since the issue template was not filled in. 30 | Please provide us with more information to have this issue reopened. 31 | template_open_comment: 32 | Reopening this issue because the author provided more information. 33 | -------------------------------------------------------------------------------- /.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 | jobs: 10 | generateThemeDoc: 11 | runs-on: ubuntu-latest 12 | name: Generate theme doc 13 | strategy: 14 | matrix: 15 | node-version: [16.x] 16 | 17 | steps: 18 | - uses: actions/checkout@v3 19 | 20 | - name: Setup Node 21 | uses: actions/setup-node@v3 22 | with: 23 | node-version: ${{ matrix.node-version }} 24 | cache: npm 25 | 26 | # Fix the unsafe repo error which was introduced by the CVE-2022-24765 git patches. 27 | - name: Fix unsafe repo error 28 | run: git config --global --add safe.directory ${{ github.workspace }} 29 | 30 | - name: npm install, generate readme 31 | run: | 32 | npm ci 33 | npm run theme-readme-gen 34 | env: 35 | CI: true 36 | 37 | - name: Run Script 38 | uses: skx/github-action-tester@master 39 | with: 40 | script: ./scripts/push-theme-readme.sh 41 | env: 42 | CI: true 43 | PERSONAL_TOKEN: ${{ secrets.PERSONAL_TOKEN }} 44 | GH_REPO: ${{ secrets.GH_REPO }} 45 | -------------------------------------------------------------------------------- /.github/workflows/label-pr.yml: -------------------------------------------------------------------------------- 1 | name: "Pull Request Labeler" 2 | on: 3 | - pull_request_target 4 | 5 | jobs: 6 | triage: 7 | if: github.repository == 'anuraghazra/github-readme-stats' 8 | runs-on: ubuntu-latest 9 | steps: 10 | - uses: actions/labeler@v4 11 | with: 12 | repo-token: "${{ secrets.GITHUB_TOKEN }}" 13 | -------------------------------------------------------------------------------- /.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 | jobs: 11 | previewTheme: 12 | name: Install & Preview 13 | runs-on: ubuntu-latest 14 | strategy: 15 | matrix: 16 | node-version: [16.x] 17 | 18 | steps: 19 | - uses: actions/checkout@v3 20 | 21 | - name: Setup Node 22 | uses: actions/setup-node@v3 23 | with: 24 | node-version: ${{ matrix.node-version }} 25 | cache: npm 26 | 27 | - uses: bahmutov/npm-install@v1 28 | with: 29 | useLockFile: false 30 | 31 | - run: npm run preview-theme 32 | env: 33 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 34 | -------------------------------------------------------------------------------- /.github/workflows/prs-cache-clean.yml: -------------------------------------------------------------------------------- 1 | name: prs cache clean 2 | on: 3 | pull_request: 4 | types: 5 | - closed 6 | 7 | jobs: 8 | cleanup: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - name: Checkout 12 | uses: actions/checkout@v3 13 | 14 | - name: Cleanup 15 | run: | 16 | gh extension install actions/gh-actions-cache 17 | 18 | REPO=${{ github.repository }} 19 | BRANCH="refs/pull/${{ github.event.pull_request.number }}/merge" 20 | 21 | echo "Fetching list of cache key" 22 | cacheKeysForPR=$(gh actions-cache list -R $REPO -B $BRANCH | cut -f 1 ) 23 | 24 | ## Setting this to not fail the workflow while deleting cache keys. 25 | set +e 26 | echo "Deleting caches..." 27 | for cacheKey in $cacheKeysForPR 28 | do 29 | gh actions-cache delete $cacheKey -R $REPO -B $BRANCH --confirm 30 | done 31 | echo "Done" 32 | env: 33 | GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} 34 | -------------------------------------------------------------------------------- /.github/workflows/stale-theme-pr-closer.yaml: -------------------------------------------------------------------------------- 1 | name: Close stale theme pull requests that have the 'invalid' label. 2 | on: 3 | schedule: 4 | - cron: "0 0 */7 * *" 5 | 6 | jobs: 7 | closeOldThemePrs: 8 | if: github.repository == 'anuraghazra/github-readme-stats' 9 | name: Close stale 'invalid' theme PRs 10 | runs-on: ubuntu-latest 11 | strategy: 12 | matrix: 13 | node-version: [16.x] 14 | 15 | steps: 16 | - uses: actions/checkout@v3 17 | 18 | - name: Setup Node 19 | uses: actions/setup-node@v3 20 | with: 21 | node-version: ${{ matrix.node-version }} 22 | cache: npm 23 | 24 | - uses: bahmutov/npm-install@v1 25 | with: 26 | useLockFile: false 27 | 28 | - run: npm run close-stale-theme-prs 29 | env: 30 | STALE_DAYS: 20 31 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 32 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | on: 3 | push: 4 | branches: 5 | - "*" 6 | pull_request: 7 | branches: 8 | - master 9 | 10 | jobs: 11 | build: 12 | name: Perform tests 13 | runs-on: ubuntu-latest 14 | strategy: 15 | matrix: 16 | node-version: [16.x] 17 | 18 | steps: 19 | - uses: actions/checkout@v3 20 | 21 | - name: Setup Node 22 | uses: actions/setup-node@v3 23 | with: 24 | node-version: ${{ matrix.node-version }} 25 | cache: npm 26 | 27 | - name: Install & Test 28 | run: | 29 | npm ci 30 | npm run test 31 | 32 | - name: Run Prettier 33 | run: | 34 | npm run format:check 35 | 36 | - name: Code Coverage 37 | uses: codecov/codecov-action@v3 38 | -------------------------------------------------------------------------------- /.github/workflows/top-issues-dashboard.yml: -------------------------------------------------------------------------------- 1 | name: Update top issues dashboard 2 | on: 3 | schedule: 4 | - cron: "0 0 */3 * *" 5 | 6 | jobs: 7 | showAndLabelTopIssues: 8 | if: github.repository == 'anuraghazra/github-readme-stats' 9 | name: Update top issues Dashboard. 10 | runs-on: ubuntu-latest 11 | steps: 12 | - name: Run top issues action 13 | uses: rickstaa/top-issues-action@v1 14 | env: 15 | github_token: ${{ secrets.GITHUB_TOKEN }} 16 | with: 17 | filter: "1772" 18 | label: false 19 | dashboard: true 20 | dashboard_show_total_reactions: true 21 | top_issues: true 22 | top_bugs: true 23 | top_features: true 24 | top_pull_requests: true 25 | -------------------------------------------------------------------------------- /.github/workflows/update-langs.yaml: -------------------------------------------------------------------------------- 1 | name: Update supported languages 2 | on: 3 | schedule: 4 | - cron: "0 0 */30 * *" 5 | 6 | jobs: 7 | updateLanguages: 8 | if: github.repository == 'anuraghazra/github-readme-stats' 9 | name: Update supported languages 10 | runs-on: ubuntu-latest 11 | strategy: 12 | matrix: 13 | node-version: [16.x] 14 | 15 | steps: 16 | - uses: actions/checkout@v3 17 | 18 | - name: Setup Node 19 | uses: actions/setup-node@v3 20 | with: 21 | node-version: ${{ matrix.node-version }} 22 | cache: npm 23 | 24 | - name: Install dependencies 25 | run: npm ci 26 | env: 27 | CI: true 28 | 29 | - name: Run update-languages-json.js script 30 | run: npm run generate-langs-json 31 | 32 | - name: Create Pull Request if upstream language file is changed 33 | uses: peter-evans/create-pull-request@v4 34 | with: 35 | commit-message: "refactor: update languages JSON" 36 | branch: "update_langs/patch" 37 | delete-branch: true 38 | title: Update languages JSON 39 | body: 40 | "The 41 | [update-langs](https://github.com/anuraghazra/github-readme-stats/actions/workflows/update-langs.yaml) 42 | action found new/updated languages in the [upstream languages JSON 43 | file](https://raw.githubusercontent.com/github/linguist/master/lib/linguist/languages.yml)." 44 | labels: "ci, lang-card" 45 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .vercel 2 | .env 3 | node_modules 4 | *.lock 5 | .vscode/ 6 | .idea/ 7 | coverage 8 | vercel_token 9 | 10 | # IDE 11 | .vscode 12 | *.code-workspace 13 | 14 | .vercel 15 | -------------------------------------------------------------------------------- /.husky/.gitignore: -------------------------------------------------------------------------------- 1 | _ 2 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | . "$(dirname -- "$0")/_/husky.sh" 3 | 4 | npm test 5 | npx lint-staged 6 | -------------------------------------------------------------------------------- /.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 -------------------------------------------------------------------------------- /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. Open `vercel.json` and set the maxDuration to 10. 38 | 6. Create a `.env` file in the root of the directory. 39 | 7. In the .env file add a new variable named `PAT_1` with your [GitHub Personal Access Token](https://docs.github.com/en/github/authenticating-to-github/creating-a-personal-access-token). 40 | 8. Run the command `vercel dev` to start a development server at . 41 | 9. The cards will then be available from this local endpoint (i.e. `https://localhost:3000/api?username=anuraghazra`). 42 | 43 | > **Note** 44 | > 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. 45 | 46 | ## Themes Contribution 47 | 48 | GitHub Readme Stats supports custom theming, and you can also contribute new themes! 49 | 50 | All you need to do is edit the [themes/index.js](./themes/index.js) file and add your theme at the end of the file. 51 | 52 | While creating the Pull request to add a new theme **don't forget to add a screenshot of how your theme looks**, you can also test how it looks using custom URL parameters like `title_color`, `icon_color`, `bg_color`, `text_color`, `border_color` 53 | 54 | > NOTE: If you are contributing your theme just because you are using it personally, then you can [customize the looks](./readme.md#customization) of your card with URL params instead. 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](http://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 | 112 | ## License 113 | 114 | By contributing, you agree that your contributions will be licensed under its [MIT License](./LICENSE). 115 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | count_private, 23 | include_all_commits, 24 | line_height, 25 | title_color, 26 | ring_color, 27 | icon_color, 28 | text_color, 29 | text_bold, 30 | bg_color, 31 | theme, 32 | cache_seconds, 33 | exclude_repo, 34 | custom_title, 35 | locale, 36 | disable_animations, 37 | border_radius, 38 | number_format, 39 | border_color, 40 | rank_icon, 41 | } = req.query; 42 | res.setHeader("Content-Type", "image/svg+xml"); 43 | 44 | if (blacklist.includes(username)) { 45 | return res.send(renderError("Something went wrong")); 46 | } 47 | 48 | if (locale && !isLocaleAvailable(locale)) { 49 | return res.send(renderError("Something went wrong", "Language not found")); 50 | } 51 | 52 | try { 53 | const stats = await fetchStats( 54 | username, 55 | parseBoolean(count_private), 56 | parseBoolean(include_all_commits), 57 | parseArray(exclude_repo), 58 | ); 59 | 60 | const cacheSeconds = clampValue( 61 | parseInt(cache_seconds || CONSTANTS.FOUR_HOURS, 10), 62 | CONSTANTS.FOUR_HOURS, 63 | CONSTANTS.ONE_DAY, 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 | renderStatsCard(stats, { 75 | hide: parseArray(hide), 76 | show_icons: parseBoolean(show_icons), 77 | hide_title: parseBoolean(hide_title), 78 | hide_border: parseBoolean(hide_border), 79 | card_width: parseInt(card_width, 10), 80 | hide_rank: parseBoolean(hide_rank), 81 | include_all_commits: parseBoolean(include_all_commits), 82 | line_height, 83 | title_color, 84 | ring_color, 85 | icon_color, 86 | text_color, 87 | text_bold: parseBoolean(text_bold), 88 | bg_color, 89 | theme, 90 | custom_title, 91 | border_radius, 92 | border_color, 93 | number_format, 94 | locale: locale ? locale.toLowerCase() : null, 95 | disable_animations: parseBoolean(disable_animations), 96 | rank_icon, 97 | }), 98 | ); 99 | } catch (err) { 100 | res.setHeader("Cache-Control", `no-cache, no-store, must-revalidate`); // Don't cache error responses. 101 | return res.send(renderError(err.message, err.secondaryMessage)); 102 | } 103 | }; 104 | -------------------------------------------------------------------------------- /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 | } = req.query; 28 | 29 | res.setHeader("Content-Type", "image/svg+xml"); 30 | 31 | if (blacklist.includes(username)) { 32 | return res.send(renderError("Something went wrong")); 33 | } 34 | 35 | if (locale && !isLocaleAvailable(locale)) { 36 | return res.send(renderError("Something went wrong", "Language not found")); 37 | } 38 | 39 | try { 40 | const repoData = await fetchRepo(username, repo); 41 | 42 | let cacheSeconds = clampValue( 43 | parseInt(cache_seconds || CONSTANTS.FOUR_HOURS, 10), 44 | CONSTANTS.FOUR_HOURS, 45 | CONSTANTS.ONE_DAY, 46 | ); 47 | 48 | /* 49 | if star count & fork count is over 1k then we are kFormating the text 50 | and if both are zero we are not showing the stats 51 | so we can just make the cache longer, since there is no need to frequent updates 52 | */ 53 | const stars = repoData.starCount; 54 | const forks = repoData.forkCount; 55 | const isBothOver1K = stars > 1000 && forks > 1000; 56 | const isBothUnder1 = stars < 1 && forks < 1; 57 | if (!cache_seconds && (isBothOver1K || isBothUnder1)) { 58 | cacheSeconds = CONSTANTS.FOUR_HOURS; 59 | } 60 | 61 | res.setHeader( 62 | "Cache-Control", 63 | `max-age=${ 64 | cacheSeconds / 2 65 | }, s-maxage=${cacheSeconds}, stale-while-revalidate=${CONSTANTS.ONE_DAY}`, 66 | ); 67 | 68 | return res.send( 69 | renderRepoCard(repoData, { 70 | hide_border: parseBoolean(hide_border), 71 | title_color, 72 | icon_color, 73 | text_color, 74 | bg_color, 75 | theme, 76 | border_radius, 77 | border_color, 78 | show_owner: parseBoolean(show_owner), 79 | locale: locale ? locale.toLowerCase() : null, 80 | }), 81 | ); 82 | } catch (err) { 83 | res.setHeader("Cache-Control", `no-cache, no-store, must-revalidate`); // Don't cache error responses. 84 | return res.send(renderError(err.message, err.secondaryMessage)); 85 | } 86 | }; 87 | -------------------------------------------------------------------------------- /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 | * Simple uptime check fetcher for the PATs. 13 | * 14 | * @param {import('axios').AxiosRequestHeaders} variables 15 | * @param {string} token 16 | */ 17 | const uptimeFetcher = (variables, token) => { 18 | return request( 19 | { 20 | query: ` 21 | query { 22 | rateLimit { 23 | remaining 24 | resetAt 25 | }, 26 | }`, 27 | variables, 28 | }, 29 | { 30 | Authorization: `bearer ${token}`, 31 | }, 32 | ); 33 | }; 34 | 35 | const getAllPATs = () => { 36 | return Object.keys(process.env).filter((key) => /PAT_\d*$/.exec(key)); 37 | }; 38 | 39 | /** 40 | * Check whether any of the PATs is expired. 41 | */ 42 | const getPATInfo = async (fetcher, variables) => { 43 | const details = {}; 44 | const PATs = getAllPATs(); 45 | 46 | for (const pat of PATs) { 47 | try { 48 | const response = await fetcher(variables, process.env[pat]); 49 | const errors = response.data.errors; 50 | const hasErrors = Boolean(errors); 51 | const errorType = errors?.[0]?.type; 52 | const isRateLimited = 53 | (hasErrors && errorType === "RATE_LIMITED") || 54 | response.data.data?.rateLimit?.remaining === 0; 55 | 56 | // Store PATs with errors. 57 | if (hasErrors && errorType !== "RATE_LIMITED") { 58 | details[pat] = { 59 | status: "error", 60 | error: { 61 | type: errors[0].type, 62 | message: errors[0].message, 63 | }, 64 | }; 65 | continue; 66 | } else if (isRateLimited) { 67 | const date1 = new Date(); 68 | const date2 = new Date(response.data?.data?.rateLimit?.resetAt); 69 | details[pat] = { 70 | status: "exhausted", 71 | remaining: 0, 72 | resetIn: dateDiff(date2, date1) + " minutes", 73 | }; 74 | } else { 75 | details[pat] = { 76 | status: "valid", 77 | remaining: response.data.data.rateLimit.remaining, 78 | }; 79 | } 80 | } catch (err) { 81 | // Store the PAT if it is expired. 82 | const errorMessage = err.response?.data?.message?.toLowerCase(); 83 | if (errorMessage === "bad credentials") { 84 | details[pat] = { 85 | status: "expired", 86 | }; 87 | } else if (errorMessage === "sorry. your account was suspended.") { 88 | details[pat] = { 89 | status: "suspended", 90 | }; 91 | } else { 92 | throw err; 93 | } 94 | } 95 | } 96 | 97 | const filterPATsByStatus = (status) => { 98 | return Object.keys(details).filter((pat) => details[pat].status === status); 99 | }; 100 | 101 | const sortedDetails = Object.keys(details) 102 | .sort() 103 | .reduce((obj, key) => { 104 | obj[key] = details[key]; 105 | return obj; 106 | }, {}); 107 | 108 | return { 109 | validPATs: filterPATsByStatus("valid"), 110 | expiredPATs: filterPATsByStatus("expired"), 111 | exhaustedPATs: filterPATsByStatus("exhausted"), 112 | suspendedPATs: filterPATsByStatus("suspended"), 113 | errorPATs: filterPATsByStatus("error"), 114 | details: sortedDetails, 115 | }; 116 | }; 117 | 118 | /** 119 | * Cloud function that returns information about the used PATs. 120 | */ 121 | export default async (_, res) => { 122 | res.setHeader("Content-Type", "application/json"); 123 | try { 124 | // Add header to prevent abuse. 125 | const PATsInfo = await getPATInfo(uptimeFetcher, {}); 126 | if (PATsInfo) { 127 | res.setHeader( 128 | "Cache-Control", 129 | `max-age=0, s-maxage=${RATE_LIMIT_SECONDS}`, 130 | ); 131 | } 132 | res.send(JSON.stringify(PATsInfo, null, 2)); 133 | } catch (err) { 134 | // Throw error if something went wrong. 135 | logger.error(err); 136 | res.setHeader("Cache-Control", "no-store"); 137 | res.send("Something went wrong: " + err.message); 138 | } 139 | }; 140 | -------------------------------------------------------------------------------- /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 | * Simple uptime check fetcher for the PATs. 15 | * 16 | * @param {import('axios').AxiosRequestHeaders} variables 17 | * @param {string} token 18 | */ 19 | const uptimeFetcher = (variables, token) => { 20 | return request( 21 | { 22 | query: ` 23 | query { 24 | rateLimit { 25 | remaining 26 | } 27 | } 28 | `, 29 | variables, 30 | }, 31 | { 32 | Authorization: `bearer ${token}`, 33 | }, 34 | ); 35 | }; 36 | 37 | /** 38 | * Creates Json response that can be used for shields.io dynamic card generation. 39 | * 40 | * @param {*} up Whether the PATs are up or not. 41 | * @returns Dynamic shields.io JSON response object. 42 | * 43 | * @see https://shields.io/endpoint. 44 | */ 45 | const shieldsUptimeBadge = (up) => { 46 | const schemaVersion = 1; 47 | const isError = true; 48 | const label = "Public Instance"; 49 | const message = up ? "up" : "down"; 50 | const color = up ? "brightgreen" : "red"; 51 | return { 52 | schemaVersion, 53 | label, 54 | message, 55 | color, 56 | isError, 57 | }; 58 | }; 59 | 60 | /** 61 | * Cloud function that returns whether the PATs are still functional. 62 | */ 63 | export default async (req, res) => { 64 | let { type } = req.query; 65 | type = type ? type.toLowerCase() : "boolean"; 66 | 67 | res.setHeader("Content-Type", "application/json"); 68 | 69 | try { 70 | let PATsValid = true; 71 | try { 72 | await retryer(uptimeFetcher, {}); 73 | } catch (err) { 74 | PATsValid = false; 75 | } 76 | 77 | if (PATsValid) { 78 | res.setHeader( 79 | "Cache-Control", 80 | `max-age=0, s-maxage=${RATE_LIMIT_SECONDS}`, 81 | ); 82 | } else { 83 | res.setHeader("Cache-Control", "no-store"); 84 | } 85 | 86 | switch (type) { 87 | case "shields": 88 | res.send(shieldsUptimeBadge(PATsValid)); 89 | break; 90 | case "json": 91 | res.send({ up: PATsValid }); 92 | break; 93 | default: 94 | res.send(PATsValid); 95 | break; 96 | } 97 | } catch (err) { 98 | // Return fail boolean if something went wrong. 99 | logger.error(err); 100 | res.setHeader("Cache-Control", "no-store"); 101 | res.send("Something went wrong: " + err.message); 102 | } 103 | }; 104 | -------------------------------------------------------------------------------- /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(renderError("Something went wrong")); 41 | } 42 | 43 | if (locale && !isLocaleAvailable(locale)) { 44 | return res.send(renderError("Something went wrong", "Locale not found")); 45 | } 46 | 47 | try { 48 | const topLangs = await fetchTopLanguages( 49 | username, 50 | parseArray(exclude_repo), 51 | size_weight, 52 | count_weight, 53 | ); 54 | 55 | const cacheSeconds = clampValue( 56 | parseInt(cache_seconds || CONSTANTS.FOUR_HOURS, 10), 57 | CONSTANTS.FOUR_HOURS, 58 | CONSTANTS.ONE_DAY, 59 | ); 60 | 61 | res.setHeader( 62 | "Cache-Control", 63 | `max-age=${ 64 | cacheSeconds / 2 65 | }, s-maxage=${cacheSeconds}, stale-while-revalidate=${CONSTANTS.ONE_DAY}`, 66 | ); 67 | 68 | return res.send( 69 | renderTopLanguages(topLangs, { 70 | custom_title, 71 | hide_title: parseBoolean(hide_title), 72 | hide_border: parseBoolean(hide_border), 73 | card_width: parseInt(card_width, 10), 74 | hide: parseArray(hide), 75 | title_color, 76 | text_color, 77 | bg_color, 78 | theme, 79 | layout, 80 | langs_count, 81 | border_radius, 82 | border_color, 83 | locale: locale ? locale.toLowerCase() : null, 84 | disable_animations: parseBoolean(disable_animations), 85 | hide_progress: parseBoolean(hide_progress), 86 | }), 87 | ); 88 | } catch (err) { 89 | res.setHeader("Cache-Control", `no-cache, no-store, must-revalidate`); // Don't cache error responses. 90 | return res.send(renderError(err.message, err.secondaryMessage)); 91 | } 92 | }; 93 | -------------------------------------------------------------------------------- /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 | range, 32 | border_radius, 33 | border_color, 34 | } = req.query; 35 | 36 | res.setHeader("Content-Type", "image/svg+xml"); 37 | 38 | if (locale && !isLocaleAvailable(locale)) { 39 | return res.send(renderError("Something went wrong", "Language not found")); 40 | } 41 | 42 | try { 43 | const stats = await fetchWakatimeStats({ username, api_domain, range }); 44 | 45 | let cacheSeconds = clampValue( 46 | parseInt(cache_seconds || CONSTANTS.FOUR_HOURS, 10), 47 | CONSTANTS.FOUR_HOURS, 48 | CONSTANTS.ONE_DAY, 49 | ); 50 | 51 | if (!cache_seconds) { 52 | cacheSeconds = CONSTANTS.FOUR_HOURS; 53 | } 54 | 55 | res.setHeader( 56 | "Cache-Control", 57 | `max-age=${ 58 | cacheSeconds / 2 59 | }, s-maxage=${cacheSeconds}, stale-while-revalidate=${CONSTANTS.ONE_DAY}`, 60 | ); 61 | 62 | return res.send( 63 | renderWakatimeCard(stats, { 64 | custom_title, 65 | hide_title: parseBoolean(hide_title), 66 | hide_border: parseBoolean(hide_border), 67 | hide: parseArray(hide), 68 | line_height, 69 | title_color, 70 | icon_color, 71 | text_color, 72 | bg_color, 73 | theme, 74 | hide_progress, 75 | border_radius, 76 | border_color, 77 | locale: locale ? locale.toLowerCase() : null, 78 | layout, 79 | langs_count, 80 | }), 81 | ); 82 | } catch (err) { 83 | res.setHeader("Cache-Control", `no-cache, no-store, must-revalidate`); // Don't cache error responses. 84 | return res.send(renderError(err.message, err.secondaryMessage)); 85 | } 86 | }; 87 | -------------------------------------------------------------------------------- /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 statsCard from "./api/index.js"; 2 | import repoCard from "./api/pin.js"; 3 | import langCard from "./api/top-langs.js"; 4 | import wakatimeCard from "./api/wakatime.js"; 5 | import express from "express"; 6 | import dotenv from "dotenv"; 7 | 8 | dotenv.config(); 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 | -------------------------------------------------------------------------------- /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 install" 33 | }, 34 | "author": "Anurag Hazra", 35 | "license": "MIT", 36 | "devDependencies": { 37 | "@actions/core": "^1.9.1", 38 | "@actions/github": "^4.0.0", 39 | "@testing-library/dom": "^8.17.1", 40 | "@testing-library/jest-dom": "^5.16.5", 41 | "@uppercod/css-to-object": "^1.1.1", 42 | "axios-mock-adapter": "^1.21.2", 43 | "color-contrast-checker": "^2.1.0", 44 | "hjson": "^3.2.2", 45 | "husky": "^8.0.0", 46 | "jest": "^29.0.3", 47 | "jest-environment-jsdom": "^29.0.3", 48 | "js-yaml": "^4.1.0", 49 | "lint-staged": "^13.0.3", 50 | "lodash.snakecase": "^4.1.1", 51 | "parse-diff": "^0.7.0", 52 | "prettier": "^2.1.2" 53 | }, 54 | "dependencies": { 55 | "axios": "^0.24.0", 56 | "dotenv": "^8.2.0", 57 | "emoji-name-map": "^1.2.8", 58 | "github-username-regex": "^1.0.0", 59 | "upgrade": "^1.1.0", 60 | "word-wrap": "^1.2.3" 61 | }, 62 | "lint-staged": { 63 | "*.{js,css,md}": "prettier --write" 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /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 | * @param user The user name of the repository owner. 29 | * @param repo The name of the repository. 30 | * @param reviewer The reviewer to filter by. 31 | * @returns The open PRs. 32 | */ 33 | export const fetchOpenPRs = async (octokit, user, repo, reviewer) => { 34 | const openPRs = []; 35 | let hasNextPage = true; 36 | let endCursor; 37 | while (hasNextPage) { 38 | try { 39 | const { repository } = await octokit.graphql( 40 | ` 41 | { 42 | repository(owner: "${user}", name: "${repo}") { 43 | open_prs: pullRequests(${ 44 | endCursor ? `after: "${endCursor}", ` : "" 45 | } 46 | first: 100, states: OPEN, orderBy: {field: CREATED_AT, direction: DESC}) { 47 | nodes { 48 | number 49 | commits(last:1){ 50 | nodes{ 51 | commit{ 52 | pushedDate 53 | } 54 | } 55 | } 56 | labels(first: 100, orderBy:{field: CREATED_AT, direction: DESC}) { 57 | nodes { 58 | name 59 | } 60 | } 61 | reviews(first: 100, states: CHANGES_REQUESTED, author: "${reviewer}") { 62 | nodes { 63 | submittedAt 64 | } 65 | } 66 | } 67 | pageInfo { 68 | endCursor 69 | hasNextPage 70 | } 71 | } 72 | } 73 | } 74 | `, 75 | ); 76 | openPRs.push(...repository.open_prs.nodes); 77 | hasNextPage = repository.open_prs.pageInfo.hasNextPage; 78 | endCursor = repository.open_prs.pageInfo.endCursor; 79 | } catch (error) { 80 | if (error instanceof RequestError) { 81 | setFailed(`Could not retrieve top PRs using GraphQl: ${error.message}`); 82 | } 83 | throw error; 84 | } 85 | } 86 | return openPRs; 87 | }; 88 | 89 | /** 90 | * Retrieve pull requests that have a given label. 91 | * @param pull The pull requests to check. 92 | * @param label The label to check for. 93 | */ 94 | export const pullsWithLabel = (pulls, label) => { 95 | return pulls.filter((pr) => { 96 | return pr.labels.nodes.some((lab) => lab.name === label); 97 | }); 98 | }; 99 | 100 | /** 101 | * Check if PR is stale. Meaning that it hasn't been updated in a given time. 102 | * @param {Object} pullRequest request object. 103 | * @param {number} days number of days. 104 | * @returns Boolean indicating if PR is stale. 105 | */ 106 | const isStale = (pullRequest, staleDays) => { 107 | const lastCommitDate = new Date( 108 | pullRequest.commits.nodes[0].commit.pushedDate, 109 | ); 110 | if (pullRequest.reviews.nodes[0]) { 111 | const lastReviewDate = new Date( 112 | pullRequest.reviews.nodes.sort((a, b) => (a < b ? 1 : -1))[0].submittedAt, 113 | ); 114 | const lastUpdateDate = 115 | lastCommitDate >= lastReviewDate ? lastCommitDate : lastReviewDate; 116 | const now = new Date(); 117 | return (now - lastUpdateDate) / (1000 * 60 * 60 * 24) >= staleDays; 118 | } else { 119 | return false; 120 | } 121 | }; 122 | 123 | /** 124 | * Main function. 125 | */ 126 | const run = async () => { 127 | try { 128 | // Create octokit client. 129 | const dryRun = process.env.DRY_RUN === "true" || false; 130 | const staleDays = process.env.STALE_DAYS || 20; 131 | debug("Creating octokit client..."); 132 | const octokit = github.getOctokit(getGithubToken()); 133 | const { owner, repo } = getRepoInfo(github.context); 134 | const reviewer = getReviewer(); 135 | 136 | // Retrieve all theme pull requests. 137 | debug("Retrieving all theme pull requests..."); 138 | const prs = await fetchOpenPRs(octokit, owner, repo, reviewer); 139 | const themePRs = pullsWithLabel(prs, "themes"); 140 | const invalidThemePRs = pullsWithLabel(themePRs, "invalid"); 141 | debug("Retrieving stale theme PRs..."); 142 | const staleThemePRs = invalidThemePRs.filter((pr) => 143 | isStale(pr, staleDays), 144 | ); 145 | const staleThemePRsNumbers = staleThemePRs.map((pr) => pr.number); 146 | debug(`Found ${staleThemePRs.length} stale theme PRs`); 147 | 148 | // Loop through all stale invalid theme pull requests and close them. 149 | for (const prNumber of staleThemePRsNumbers) { 150 | debug(`Closing #${prNumber} because it is stale...`); 151 | if (!dryRun) { 152 | await octokit.issues.createComment({ 153 | owner, 154 | repo, 155 | issue_number: prNumber, 156 | body: CLOSING_COMMENT, 157 | }); 158 | await octokit.pulls.update({ 159 | owner, 160 | repo, 161 | pull_number: prNumber, 162 | state: "closed", 163 | }); 164 | } else { 165 | debug("Dry run enabled, skipping..."); 166 | } 167 | } 168 | } catch (error) { 169 | setFailed(error.message); 170 | } 171 | }; 172 | 173 | run(); 174 | -------------------------------------------------------------------------------- /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 work both for the Stats Card and Repo Card. 26 | 27 | | | | | 28 | | :--: | :--: | :--: | 29 | ${STAT_CARD_TABLE_FLAG} 30 | 31 | ## Repo Card 32 | 33 | > These themes work both for the Stats Card and Repo 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) return ""; 64 | return `\`${label}\` ![${link}][${link}${isRepoCard ? "_repo" : ""}]`; 65 | }; 66 | const generateTable = ({ isRepoCard }) => { 67 | const rows = []; 68 | const themesFiltered = Object.keys(themes).filter( 69 | (name) => name !== (!isRepoCard ? "default_repocard" : "default"), 70 | ); 71 | 72 | for (let i = 0; i < themesFiltered.length; i += 3) { 73 | const one = themesFiltered[i]; 74 | const two = themesFiltered[i + 1]; 75 | const three = themesFiltered[i + 2]; 76 | 77 | let tableItem1 = createTableItem({ link: one, label: one, isRepoCard }); 78 | let tableItem2 = createTableItem({ link: two, label: two, isRepoCard }); 79 | let tableItem3 = createTableItem({ link: three, label: three, isRepoCard }); 80 | 81 | if (three === undefined) { 82 | tableItem3 = `[Add your theme][add-theme]`; 83 | } 84 | rows.push(`| ${tableItem1} | ${tableItem2} | ${tableItem3} |`); 85 | 86 | // if it's the last row & the row has no empty space push a new row 87 | if (three && i + 3 === themesFiltered.length) { 88 | rows.push(`| [Add your theme][add-theme] | | |`); 89 | } 90 | } 91 | 92 | return rows.join("\n"); 93 | }; 94 | 95 | const buildReadme = () => { 96 | return THEME_TEMPLATE.split("\n") 97 | .map((line) => { 98 | if (line.includes(REPO_CARD_LINKS_FLAG)) { 99 | return generateLinks(createRepoMdLink); 100 | } 101 | if (line.includes(STAT_CARD_LINKS_FLAG)) { 102 | return generateLinks(createStatMdLink); 103 | } 104 | if (line.includes(REPO_CARD_TABLE_FLAG)) { 105 | return generateTable({ isRepoCard: true }); 106 | } 107 | if (line.includes(STAT_CARD_TABLE_FLAG)) { 108 | return generateTable({ isRepoCard: false }); 109 | } 110 | return line; 111 | }) 112 | .join("\n"); 113 | }; 114 | 115 | fs.writeFileSync(TARGET_FILE, buildReadme()); 116 | -------------------------------------------------------------------------------- /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} context 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 probability of x taking on x or a value less than x in a normal distribution 3 | * with mean and standard deviation. 4 | * 5 | * @see https://stackoverflow.com/a/5263759/10629172 6 | * 7 | * @param {string} mean The mean of the normal distribution. 8 | * @param {number} sigma The standard deviation of the normal distribution. 9 | * @param {number} to The value to calculate the probability for. 10 | * @returns {number} Probability. 11 | */ 12 | const normalcdf = (mean, sigma, to) => { 13 | var z = (to - mean) / Math.sqrt(2 * sigma * sigma); 14 | var t = 1 / (1 + 0.3275911 * Math.abs(z)); 15 | var a1 = 0.254829592; 16 | var a2 = -0.284496736; 17 | var a3 = 1.421413741; 18 | var a4 = -1.453152027; 19 | var a5 = 1.061405429; 20 | var erf = 21 | 1 - ((((a5 * t + a4) * t + a3) * t + a2) * t + a1) * t * Math.exp(-z * z); 22 | var sign = 1; 23 | if (z < 0) { 24 | sign = -1; 25 | } 26 | return (1 / 2) * (1 + sign * erf); 27 | }; 28 | 29 | /** 30 | * Calculates the users rank. 31 | * 32 | * @param {object} params Parameters on which the user's rank depends. 33 | * @param {number} params.totalRepos Total number of repos. 34 | * @param {number} params.totalCommits Total number of commits. 35 | * @param {number} params.contributions The number of contributions. 36 | * @param {number} params.followers The number of followers. 37 | * @param {number} params.prs The number of pull requests. 38 | * @param {number} params.issues The number of issues. 39 | * @param {number} params.stargazers The number of stars. 40 | * @returns {{level: string, score: number}}} The users rank. 41 | */ 42 | const calculateRank = ({ 43 | totalRepos, 44 | totalCommits, 45 | contributions, 46 | followers, 47 | prs, 48 | issues, 49 | stargazers, 50 | }) => { 51 | const COMMITS_OFFSET = 1.65; 52 | const CONTRIBS_OFFSET = 1.65; 53 | const ISSUES_OFFSET = 1; 54 | const STARS_OFFSET = 0.75; 55 | const PRS_OFFSET = 0.5; 56 | const FOLLOWERS_OFFSET = 0.45; 57 | const REPO_OFFSET = 1; 58 | 59 | const ALL_OFFSETS = 60 | CONTRIBS_OFFSET + 61 | ISSUES_OFFSET + 62 | STARS_OFFSET + 63 | PRS_OFFSET + 64 | FOLLOWERS_OFFSET + 65 | REPO_OFFSET; 66 | 67 | const RANK_S_VALUE = 1; 68 | const RANK_DOUBLE_A_VALUE = 25; 69 | const RANK_A2_VALUE = 45; 70 | const RANK_A3_VALUE = 60; 71 | const RANK_B_VALUE = 100; 72 | 73 | const TOTAL_VALUES = 74 | RANK_S_VALUE + 75 | RANK_DOUBLE_A_VALUE + 76 | RANK_A2_VALUE + 77 | RANK_A3_VALUE + 78 | RANK_B_VALUE; 79 | 80 | // prettier-ignore 81 | const score = ( 82 | totalCommits * COMMITS_OFFSET + 83 | contributions * CONTRIBS_OFFSET + 84 | issues * ISSUES_OFFSET + 85 | stargazers * STARS_OFFSET + 86 | prs * PRS_OFFSET + 87 | followers * FOLLOWERS_OFFSET + 88 | totalRepos * REPO_OFFSET 89 | ) / 100; 90 | 91 | const normalizedScore = normalcdf(score, TOTAL_VALUES, ALL_OFFSETS) * 100; 92 | 93 | const level = (() => { 94 | if (normalizedScore < RANK_S_VALUE) return "S+"; 95 | if (normalizedScore < RANK_DOUBLE_A_VALUE) return "S"; 96 | if (normalizedScore < RANK_A2_VALUE) return "A++"; 97 | if (normalizedScore < RANK_A3_VALUE) return "A+"; 98 | return "B+"; 99 | })(); 100 | 101 | return { level, score: normalizedScore }; 102 | }; 103 | 104 | export { calculateRank }; 105 | export default calculateRank; 106 | -------------------------------------------------------------------------------- /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 | } from "../common/utils.js"; 14 | import { repoCardLocales } from "../translations.js"; 15 | 16 | /** 17 | * Retrieves the repository description and wraps it to fit the card width. 18 | * 19 | * @param {string} label The repository description. 20 | * @param {string} textColor The color of the text. 21 | * @returns {string} Wrapped repo description SVG object. 22 | */ 23 | const getBadgeSVG = (label, textColor) => ` 24 | 25 | 26 | 33 | ${label} 34 | 35 | 36 | `; 37 | 38 | /** 39 | * Creates a node to display the primary programming language of the repository. 40 | * 41 | * @param {string} langName Language name. 42 | * @param {string} langColor Language color. 43 | * @returns {string} Language display SVG object. 44 | */ 45 | const createLanguageNode = (langName, langColor) => { 46 | return ` 47 | 48 | 49 | ${langName} 50 | 51 | `; 52 | }; 53 | 54 | const ICON_SIZE = 16; 55 | 56 | /** 57 | * Creates an icon with label to display repository stats like forks, stars, etc. 58 | * 59 | * @param {string} icon The icon to display. 60 | * @param {number|string} label The label to display. 61 | * @param {string} testid The testid to assign to the label. 62 | * @returns {string} Icon with label SVG object. 63 | */ 64 | const iconWithLabel = (icon, label, testid) => { 65 | if (label <= 0) return ""; 66 | const iconSvg = ` 67 | 75 | ${icon} 76 | 77 | `; 78 | const text = `${label}`; 79 | return flexLayout({ items: [iconSvg, text], gap: 20 }).join(""); 80 | }; 81 | 82 | /** 83 | * Renders repository card details. 84 | * 85 | * @param {import('../fetchers/types').RepositoryData} repo Repository data. 86 | * @param {Partial} options Card options. 87 | * @returns {string} Repository card SVG object. 88 | */ 89 | const renderRepoCard = (repo, options = {}) => { 90 | const { 91 | name, 92 | nameWithOwner, 93 | description, 94 | primaryLanguage, 95 | isArchived, 96 | isTemplate, 97 | starCount, 98 | forkCount, 99 | } = repo; 100 | const { 101 | hide_border = false, 102 | title_color, 103 | icon_color, 104 | text_color, 105 | bg_color, 106 | show_owner = false, 107 | theme = "default_repocard", 108 | border_radius, 109 | border_color, 110 | locale, 111 | } = options; 112 | 113 | const lineHeight = 10; 114 | const header = show_owner ? nameWithOwner : name; 115 | const langName = (primaryLanguage && primaryLanguage.name) || "Unspecified"; 116 | const langColor = (primaryLanguage && primaryLanguage.color) || "#333"; 117 | 118 | const desc = parseEmojis(description || "No description provided"); 119 | const multiLineDescription = wrapTextMultiline(desc); 120 | const descriptionLines = multiLineDescription.length; 121 | const descriptionSvg = multiLineDescription 122 | .map((line) => `${encodeHTML(line)}`) 123 | .join(""); 124 | 125 | const height = 126 | (descriptionLines > 1 ? 120 : 110) + descriptionLines * lineHeight; 127 | 128 | const i18n = new I18n({ 129 | locale, 130 | translations: repoCardLocales, 131 | }); 132 | 133 | // returns theme based colors with proper overrides and defaults 134 | const colors = getCardColors({ 135 | title_color, 136 | icon_color, 137 | text_color, 138 | bg_color, 139 | border_color, 140 | theme, 141 | }); 142 | 143 | const svgLanguage = primaryLanguage 144 | ? createLanguageNode(langName, langColor) 145 | : ""; 146 | 147 | const totalStars = kFormatter(starCount); 148 | const totalForks = kFormatter(forkCount); 149 | const svgStars = iconWithLabel(icons.star, totalStars, "stargazers"); 150 | const svgForks = iconWithLabel(icons.fork, totalForks, "forkcount"); 151 | 152 | const starAndForkCount = flexLayout({ 153 | items: [svgLanguage, svgStars, svgForks], 154 | sizes: [ 155 | measureText(langName, 12), 156 | ICON_SIZE + measureText(`${totalStars}`, 12), 157 | ICON_SIZE + measureText(`${totalForks}`, 12), 158 | ], 159 | gap: 25, 160 | }).join(""); 161 | 162 | const card = new Card({ 163 | defaultTitle: header.length > 35 ? `${header.slice(0, 35)}...` : header, 164 | titlePrefixIcon: icons.contribs, 165 | width: 400, 166 | height, 167 | border_radius, 168 | colors, 169 | }); 170 | 171 | card.disableAnimations(); 172 | card.setHideBorder(hide_border); 173 | card.setHideTitle(false); 174 | card.setCSS(` 175 | .description { font: 400 13px 'Segoe UI', Ubuntu, Sans-Serif; fill: ${colors.textColor} } 176 | .gray { font: 400 12px 'Segoe UI', Ubuntu, Sans-Serif; fill: ${colors.textColor} } 177 | .icon { fill: ${colors.iconColor} } 178 | .badge { font: 600 11px 'Segoe UI', Ubuntu, Sans-Serif; } 179 | .badge rect { opacity: 0.2 } 180 | `); 181 | 182 | return card.render(` 183 | ${ 184 | isTemplate 185 | ? // @ts-ignore 186 | getBadgeSVG(i18n.t("repocard.template"), colors.textColor) 187 | : isArchived 188 | ? // @ts-ignore 189 | getBadgeSVG(i18n.t("repocard.archived"), colors.textColor) 190 | : "" 191 | } 192 | 193 | 194 | ${descriptionSvg} 195 | 196 | 197 | 198 | ${starAndForkCount} 199 | 200 | `); 201 | }; 202 | 203 | export { renderRepoCard }; 204 | export default renderRepoCard; 205 | -------------------------------------------------------------------------------- /src/cards/stats-card.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | import { Card } from "../common/Card.js"; 3 | import { I18n } from "../common/I18n.js"; 4 | import { icons, rankIcon } from "../common/icons.js"; 5 | import { 6 | clampValue, 7 | flexLayout, 8 | getCardColors, 9 | kFormatter, 10 | measureText, 11 | } from "../common/utils.js"; 12 | import { getStyles } from "../getStyles.js"; 13 | import { statCardLocales } from "../translations.js"; 14 | 15 | const CARD_MIN_WIDTH = 287; 16 | const CARD_DEFAULT_WIDTH = 287; 17 | const RANK_CARD_MIN_WIDTH = 420; 18 | const RANK_CARD_DEFAULT_WIDTH = 450; 19 | 20 | /** 21 | * Create a stats card text item. 22 | * 23 | * @param {object} createTextNodeParams Object that contains the createTextNode parameters. 24 | * @param {string} createTextNodeParams.icon The icon to display. 25 | * @param {string} createTextNodeParams.label The label to display. 26 | * @param {number} createTextNodeParams.value The value to display. 27 | * @param {string} createTextNodeParams.id The id of the stat. 28 | * @param {number} createTextNodeParams.index The index of the stat. 29 | * @param {boolean} createTextNodeParams.showIcons Whether to show icons. 30 | * @param {number} createTextNodeParams.shiftValuePos Number of pixels the value has to be shifted to the right. 31 | * @param {boolean} createTextNodeParams.bold Whether to bold the label. 32 | * @param {string} createTextNodeParams.number_format The format of numbers on card. 33 | * @returns 34 | */ 35 | const createTextNode = ({ 36 | icon, 37 | label, 38 | value, 39 | id, 40 | index, 41 | showIcons, 42 | shiftValuePos, 43 | bold, 44 | number_format, 45 | }) => { 46 | const kValue = 47 | number_format.toLowerCase() === "long" ? value : kFormatter(value); 48 | const staggerDelay = (index + 3) * 150; 49 | 50 | const labelOffset = showIcons ? `x="25"` : ""; 51 | const iconSvg = showIcons 52 | ? ` 53 | 54 | ${icon} 55 | 56 | ` 57 | : ""; 58 | return ` 59 | 60 | ${iconSvg} 61 | ${label}: 64 | ${kValue} 70 | 71 | `; 72 | }; 73 | 74 | /** 75 | * Renders the stats card. 76 | * 77 | * @param {Partial} stats The stats data. 78 | * @param {Partial} options The card options. 79 | * @returns {string} The stats card SVG object. 80 | */ 81 | const renderStatsCard = (stats = {}, options = { hide: [] }) => { 82 | const { 83 | name, 84 | totalStars, 85 | totalCommits, 86 | totalIssues, 87 | totalPRs, 88 | contributedTo, 89 | rank, 90 | } = stats; 91 | const { 92 | hide = [], 93 | show_icons = false, 94 | hide_title = false, 95 | hide_border = false, 96 | card_width, 97 | hide_rank = false, 98 | include_all_commits = false, 99 | line_height = 25, 100 | title_color, 101 | ring_color, 102 | icon_color, 103 | text_color, 104 | text_bold = true, 105 | bg_color, 106 | theme = "default", 107 | custom_title, 108 | border_radius, 109 | border_color, 110 | number_format = "short", 111 | locale, 112 | disable_animations = false, 113 | rank_icon = "default", 114 | } = options; 115 | 116 | const lheight = parseInt(String(line_height), 10); 117 | 118 | // returns theme based colors with proper overrides and defaults 119 | const { titleColor, iconColor, textColor, bgColor, borderColor, ringColor } = 120 | getCardColors({ 121 | title_color, 122 | text_color, 123 | icon_color, 124 | bg_color, 125 | border_color, 126 | ring_color, 127 | theme, 128 | }); 129 | 130 | const apostrophe = ["x", "s"].includes(name.slice(-1).toLocaleLowerCase()) 131 | ? "" 132 | : "s"; 133 | const i18n = new I18n({ 134 | locale, 135 | translations: statCardLocales({ name, apostrophe }), 136 | }); 137 | 138 | // Meta data for creating text nodes with createTextNode function 139 | const STATS = { 140 | stars: { 141 | icon: icons.star, 142 | label: i18n.t("statcard.totalstars"), 143 | value: totalStars, 144 | id: "stars", 145 | }, 146 | commits: { 147 | icon: icons.commits, 148 | label: `${i18n.t("statcard.commits")}${ 149 | include_all_commits ? "" : ` (${new Date().getFullYear()})` 150 | }`, 151 | value: totalCommits, 152 | id: "commits", 153 | }, 154 | prs: { 155 | icon: icons.prs, 156 | label: i18n.t("statcard.prs"), 157 | value: totalPRs, 158 | id: "prs", 159 | }, 160 | issues: { 161 | icon: icons.issues, 162 | label: i18n.t("statcard.issues"), 163 | value: totalIssues, 164 | id: "issues", 165 | }, 166 | contribs: { 167 | icon: icons.contribs, 168 | label: i18n.t("statcard.contribs") + " (last year)", 169 | value: contributedTo, 170 | id: "contribs", 171 | }, 172 | }; 173 | 174 | const longLocales = [ 175 | "cn", 176 | "es", 177 | "fr", 178 | "pt-br", 179 | "ru", 180 | "uk-ua", 181 | "id", 182 | "my", 183 | "pl", 184 | "de", 185 | "nl", 186 | "zh-tw", 187 | ]; 188 | const isLongLocale = longLocales.includes(locale); 189 | 190 | // filter out hidden stats defined by user & create the text nodes 191 | const statItems = Object.keys(STATS) 192 | .filter((key) => !hide.includes(key)) 193 | .map((key, index) => 194 | // create the text nodes, and pass index so that we can calculate the line spacing 195 | createTextNode({ 196 | ...STATS[key], 197 | index, 198 | showIcons: show_icons, 199 | shiftValuePos: 79.01 + (isLongLocale ? 50 : 0), 200 | bold: text_bold, 201 | number_format, 202 | }), 203 | ); 204 | 205 | // Calculate the card height depending on how many items there are 206 | // but if rank circle is visible clamp the minimum height to `150` 207 | let height = Math.max( 208 | 45 + (statItems.length + 1) * lheight, 209 | hide_rank ? 0 : 150, 210 | ); 211 | 212 | // the better user's score the the rank will be closer to zero so 213 | // subtracting 100 to get the progress in 100% 214 | const progress = 100 - rank.score; 215 | const cssStyles = getStyles({ 216 | titleColor, 217 | ringColor, 218 | textColor, 219 | iconColor, 220 | show_icons, 221 | progress, 222 | }); 223 | 224 | const calculateTextWidth = () => { 225 | return measureText(custom_title ? custom_title : i18n.t("statcard.title")); 226 | }; 227 | 228 | /* 229 | When hide_rank=true, the minimum card width is 270 px + the title length and padding. 230 | When hide_rank=false, the minimum card_width is 340 px + the icon width (if show_icons=true). 231 | Numbers are picked by looking at existing dimensions on production. 232 | */ 233 | const iconWidth = show_icons ? 16 + /* padding */ 1 : 0; 234 | const minCardWidth = 235 | (hide_rank 236 | ? clampValue( 237 | 50 /* padding */ + calculateTextWidth() * 2, 238 | CARD_MIN_WIDTH, 239 | Infinity, 240 | ) 241 | : RANK_CARD_MIN_WIDTH) + iconWidth; 242 | const defaultCardWidth = 243 | (hide_rank ? CARD_DEFAULT_WIDTH : RANK_CARD_DEFAULT_WIDTH) + iconWidth; 244 | let width = isNaN(card_width) ? defaultCardWidth : card_width; 245 | if (width < minCardWidth) { 246 | width = minCardWidth; 247 | } 248 | 249 | const card = new Card({ 250 | customTitle: custom_title, 251 | defaultTitle: i18n.t("statcard.title"), 252 | width, 253 | height, 254 | border_radius, 255 | colors: { 256 | titleColor, 257 | textColor, 258 | iconColor, 259 | bgColor, 260 | borderColor, 261 | }, 262 | }); 263 | 264 | card.setHideBorder(hide_border); 265 | card.setHideTitle(hide_title); 266 | card.setCSS(cssStyles); 267 | 268 | if (disable_animations) card.disableAnimations(); 269 | 270 | /** 271 | * Calculates the right rank circle translation values such that the rank circle 272 | * keeps respecting the following padding: 273 | * 274 | * width > RANK_CARD_DEFAULT_WIDTH: The default right padding of 70 px will be used. 275 | * width < RANK_CARD_DEFAULT_WIDTH: The left and right padding will be enlarged 276 | * equally from a certain minimum at RANK_CARD_MIN_WIDTH. 277 | * 278 | * @returns {number} - Rank circle translation value. 279 | */ 280 | const calculateRankXTranslation = () => { 281 | const minXTranslation = RANK_CARD_MIN_WIDTH + iconWidth - 70; 282 | if (width > RANK_CARD_DEFAULT_WIDTH) { 283 | const xMaxExpansion = minXTranslation + (450 - minCardWidth) / 2; 284 | return xMaxExpansion + width - RANK_CARD_DEFAULT_WIDTH; 285 | } else { 286 | return minXTranslation + (width - minCardWidth) / 2; 287 | } 288 | }; 289 | 290 | // Conditionally rendered elements 291 | const rankCircle = hide_rank 292 | ? "" 293 | : ` 297 | 298 | 299 | 300 | ${rankIcon(rank_icon, rank?.level)} 301 | 302 | `; 303 | 304 | // Accessibility Labels 305 | const labels = Object.keys(STATS) 306 | .filter((key) => !hide.includes(key)) 307 | .map((key) => { 308 | if (key === "commits") { 309 | return `${i18n.t("statcard.commits")} ${ 310 | include_all_commits ? "" : `in ${new Date().getFullYear()}` 311 | } : ${totalStars}`; 312 | } 313 | return `${STATS[key].label}: ${STATS[key].value}`; 314 | }) 315 | .join(", "); 316 | 317 | card.setAccessibilityLabel({ 318 | title: `${card.title}, Rank: ${rank.level}`, 319 | desc: labels, 320 | }); 321 | 322 | return card.render(` 323 | ${rankCircle} 324 | 325 | ${flexLayout({ 326 | items: statItems, 327 | gap: lheight, 328 | direction: "column", 329 | }).join("")} 330 | 331 | `); 332 | }; 333 | 334 | export { renderStatsCard }; 335 | export default renderStatsCard; 336 | -------------------------------------------------------------------------------- /src/cards/types.d.ts: -------------------------------------------------------------------------------- 1 | type ThemeNames = keyof typeof import("../../themes/index.js"); 2 | type RankIcon = "default" | "github"; 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 | }; 14 | 15 | export type StatCardOptions = CommonOptions & { 16 | hide: string[]; 17 | show_icons: boolean; 18 | hide_title: boolean; 19 | hide_border: 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 | }; 31 | 32 | export type RepoCardOptions = CommonOptions & { 33 | hide_border: boolean; 34 | show_owner: boolean; 35 | }; 36 | 37 | export type TopLangOptions = CommonOptions & { 38 | hide_title: boolean; 39 | hide_border: 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 | type WakaTimeOptions = CommonOptions & { 50 | hide_title: boolean; 51 | hide_border: boolean; 52 | hide: string[]; 53 | line_height: string; 54 | hide_progress: boolean; 55 | custom_title: string; 56 | layout: "compact" | "normal"; 57 | langs_count: number; 58 | }; 59 | -------------------------------------------------------------------------------- /src/common/Card.js: -------------------------------------------------------------------------------- 1 | import { getAnimations } from "../getStyles.js"; 2 | import { encodeHTML, flexLayout } from "./utils.js"; 3 | 4 | class Card { 5 | /** 6 | * Creates a new card instance. 7 | * 8 | * @param {object} args Card arguments. 9 | * @param {number?=} args.width Card width. 10 | * @param {number?=} args.height Card height. 11 | * @param {number?=} args.border_radius Card border radius. 12 | * @param {string?=} args.customTitle Card custom title. 13 | * @param {string?=} args.defaultTitle Card default title. 14 | * @param {string?=} args.titlePrefixIcon Card title prefix icon. 15 | * @param {object?=} args.colors Card colors arguments. 16 | * @param {string} args.colors.titleColor Card title color. 17 | * @param {string} args.colors.textColor Card text color. 18 | * @param {string} args.colors.iconColor Card icon color. 19 | * @param {string|Array} args.colors.bgColor Card background color. 20 | * @param {string} args.colors.borderColor Card border color. 21 | * @returns {Card} Card instance. 22 | */ 23 | constructor({ 24 | width = 100, 25 | height = 100, 26 | border_radius = 4.5, 27 | colors = {}, 28 | customTitle, 29 | defaultTitle = "", 30 | titlePrefixIcon, 31 | }) { 32 | this.width = width; 33 | this.height = height; 34 | 35 | this.hideBorder = false; 36 | this.hideTitle = false; 37 | 38 | this.border_radius = border_radius; 39 | 40 | // returns theme based colors with proper overrides and defaults 41 | this.colors = colors; 42 | this.title = 43 | customTitle !== undefined 44 | ? encodeHTML(customTitle) 45 | : encodeHTML(defaultTitle); 46 | 47 | this.css = ""; 48 | 49 | this.paddingX = 25; 50 | this.paddingY = 35; 51 | this.titlePrefixIcon = titlePrefixIcon; 52 | this.animations = true; 53 | this.a11yTitle = ""; 54 | this.a11yDesc = ""; 55 | } 56 | 57 | disableAnimations() { 58 | this.animations = false; 59 | } 60 | 61 | /** 62 | * @param {{title: string, desc: string}} prop 63 | */ 64 | setAccessibilityLabel({ title, desc }) { 65 | this.a11yTitle = title; 66 | this.a11yDesc = desc; 67 | } 68 | 69 | /** 70 | * @param {string} value 71 | */ 72 | setCSS(value) { 73 | this.css = value; 74 | } 75 | 76 | /** 77 | * @param {boolean} value 78 | */ 79 | setHideBorder(value) { 80 | this.hideBorder = value; 81 | } 82 | 83 | /** 84 | * @param {boolean} value 85 | */ 86 | setHideTitle(value) { 87 | this.hideTitle = value; 88 | if (value) { 89 | this.height -= 30; 90 | } 91 | } 92 | 93 | /** 94 | * @param {string} text 95 | */ 96 | setTitle(text) { 97 | this.title = text; 98 | } 99 | 100 | renderTitle() { 101 | const titleText = ` 102 | ${this.title} 108 | `; 109 | 110 | const prefixIcon = ` 111 | 120 | ${this.titlePrefixIcon} 121 | 122 | `; 123 | return ` 124 | 128 | ${flexLayout({ 129 | items: [this.titlePrefixIcon && prefixIcon, titleText], 130 | gap: 25, 131 | }).join("")} 132 | 133 | `; 134 | } 135 | 136 | renderGradient() { 137 | if (typeof this.colors.bgColor !== "object") return ""; 138 | 139 | const gradients = this.colors.bgColor.slice(1); 140 | return typeof this.colors.bgColor === "object" 141 | ? ` 142 | 143 | 148 | ${gradients.map((grad, index) => { 149 | let offset = (index * 100) / (gradients.length - 1); 150 | return ``; 151 | })} 152 | 153 | 154 | ` 155 | : ""; 156 | } 157 | 158 | /** 159 | * @param {string} body 160 | */ 161 | render(body) { 162 | return ` 163 | 172 | ${this.a11yTitle} 173 | ${this.a11yDesc} 174 | 193 | 194 | ${this.renderGradient()} 195 | 196 | 211 | 212 | ${this.hideTitle ? "" : this.renderTitle()} 213 | 214 | 220 | ${body} 221 | 222 | 223 | `; 224 | } 225 | } 226 | 227 | export { Card }; 228 | export default Card; 229 | -------------------------------------------------------------------------------- /src/common/I18n.js: -------------------------------------------------------------------------------- 1 | /** 2 | * I18n translation class. 3 | */ 4 | class I18n { 5 | constructor({ locale, translations }) { 6 | this.locale = locale; 7 | this.translations = translations; 8 | this.fallbackLocale = "en"; 9 | } 10 | 11 | t(str) { 12 | if (!this.translations[str]) { 13 | throw new Error(`${str} Translation string not found`); 14 | } 15 | 16 | if (!this.translations[str][this.locale || this.fallbackLocale]) { 17 | throw new Error(`${str} Translation locale not found`); 18 | } 19 | 20 | return this.translations[str][this.locale || this.fallbackLocale]; 21 | } 22 | } 23 | 24 | export { I18n }; 25 | export default I18n; 26 | -------------------------------------------------------------------------------- /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 | import { clampValue } from "./utils.js"; 2 | 3 | /** 4 | * Create a node to indicate progress in percentage along a horizontal line. 5 | * 6 | * @param {Object} createProgressNodeParams Object that contains the createProgressNode parameters. 7 | * @param {number} createProgressNodeParams.x X-axis position. 8 | * @param {number} createProgressNodeParams.y Y-axis position. 9 | * @param {number} createProgressNodeParams.width Width of progress bar. 10 | * @param {string} createProgressNodeParams.color Progress color. 11 | * @param {string} createProgressNodeParams.progress Progress value. 12 | * @param {string} createProgressNodeParams.progressBarBackgroundColor Progress bar bg color. 13 | * @param {number} createProgressNodeParams.delay Delay before animation starts. 14 | * @returns {string} Progress node. 15 | */ 16 | const createProgressNode = ({ 17 | x, 18 | y, 19 | width, 20 | color, 21 | progress, 22 | progressBarBackgroundColor, 23 | delay, 24 | }) => { 25 | const progressPercentage = clampValue(progress, 2, 100); 26 | 27 | return ` 28 | 29 | 30 | 31 | 38 | 39 | 40 | `; 41 | }; 42 | 43 | export { createProgressNode }; 44 | export default createProgressNode; 45 | -------------------------------------------------------------------------------- /src/common/icons.js: -------------------------------------------------------------------------------- 1 | const icons = { 2 | star: ``, 3 | commits: ``, 4 | prs: ``, 5 | issues: ``, 6 | icon: ``, 7 | contribs: ``, 8 | fork: ``, 9 | }; 10 | 11 | /** 12 | * Get rank icon 13 | * 14 | * @returns {string} - The SVG code of the rank icon 15 | */ 16 | const rankIcon = (rankIcon, rankLevel) => { 17 | switch (rankIcon) { 18 | case "github": 19 | return ` 20 | 23 | `; 24 | case "default": 25 | default: 26 | return ` 27 | 28 | ${rankLevel} 29 | 30 | `; 31 | } 32 | }; 33 | 34 | export { icons, rankIcon }; 35 | export default icons; 36 | -------------------------------------------------------------------------------- /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 | const PATs = Object.keys(process.env).filter((key) => 5 | /PAT_\d*$/.exec(key), 6 | ).length; 7 | const RETRIES = PATs ? PATs : 7; 8 | 9 | /** 10 | * Try to execute the fetcher function until it succeeds or the max number of retries is reached. 11 | * 12 | * @param {object[]} retryerParams Object that contains the createTextNode parameters. 13 | * @param {object[]} retryerParams.fetcher The fetcher function. 14 | * @param {object[]} retryerParams.variables Object with arguments to pass to the fetcher function. 15 | * @param {number} retryerParams.retries How many times to retry. 16 | * @returns Promise 17 | */ 18 | const retryer = async (fetcher, variables, retries = 0) => { 19 | if (retries > RETRIES) { 20 | throw new CustomError("Maximum retries exceeded", CustomError.MAX_RETRY); 21 | } 22 | try { 23 | // try to fetch with the first token since RETRIES is 0 index i'm adding +1 24 | let response = await fetcher( 25 | variables, 26 | process.env[`PAT_${retries + 1}`], 27 | retries, 28 | ); 29 | 30 | // prettier-ignore 31 | const isRateExceeded = response.data.errors && response.data.errors[0].type === "RATE_LIMITED"; 32 | 33 | // if rate limit is hit increase the RETRIES and recursively call the retryer 34 | // with username, and current RETRIES 35 | if (isRateExceeded) { 36 | logger.log(`PAT_${retries + 1} Failed`); 37 | retries++; 38 | // directly return from the function 39 | return retryer(fetcher, variables, retries); 40 | } 41 | 42 | // finally return the response 43 | return response; 44 | } catch (err) { 45 | // prettier-ignore 46 | // also checking for bad credentials if any tokens gets invalidated 47 | const isBadCredential = err.response.data && err.response.data.message === "Bad credentials"; 48 | const isAccountSuspended = 49 | err.response.data && 50 | err.response.data.message === "Sorry. Your account was suspended."; 51 | 52 | if (isBadCredential || isAccountSuspended) { 53 | logger.log(`PAT_${retries + 1} Failed`); 54 | retries++; 55 | // directly return from the function 56 | return retryer(fetcher, variables, retries); 57 | } else { 58 | return err.response; 59 | } 60 | } 61 | }; 62 | 63 | export { retryer }; 64 | export default retryer; 65 | -------------------------------------------------------------------------------- /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 | * Repo data fetcher. 7 | * 8 | * @param {import('axios').AxiosRequestHeaders} variables Fetcher variables. 9 | * @param {string} token GitHub token. 10 | * @returns {Promise} The response. 11 | */ 12 | const fetcher = (variables, token) => { 13 | return request( 14 | { 15 | query: ` 16 | fragment RepoInfo on Repository { 17 | name 18 | nameWithOwner 19 | isPrivate 20 | isArchived 21 | isTemplate 22 | stargazers { 23 | totalCount 24 | } 25 | description 26 | primaryLanguage { 27 | color 28 | id 29 | name 30 | } 31 | forkCount 32 | } 33 | query getRepo($login: String!, $repo: String!) { 34 | user(login: $login) { 35 | repository(name: $repo) { 36 | ...RepoInfo 37 | } 38 | } 39 | organization(login: $login) { 40 | repository(name: $repo) { 41 | ...RepoInfo 42 | } 43 | } 44 | } 45 | `, 46 | variables, 47 | }, 48 | { 49 | Authorization: `token ${token}`, 50 | }, 51 | ); 52 | }; 53 | 54 | const urlExample = "/api/pin?username=USERNAME&repo=REPO_NAME"; 55 | 56 | /** 57 | * Fetch repository data. 58 | * 59 | * @param {string} username GitHub username. 60 | * @param {string} reponame GitHub repository name. 61 | * @returns {Promise} Repository data. 62 | */ 63 | const fetchRepo = async (username, reponame) => { 64 | if (!username && !reponame) { 65 | throw new MissingParamError(["username", "repo"], urlExample); 66 | } 67 | if (!username) throw new MissingParamError(["username"], urlExample); 68 | if (!reponame) throw new MissingParamError(["repo"], urlExample); 69 | 70 | let res = await retryer(fetcher, { login: username, repo: reponame }); 71 | 72 | const data = res.data.data; 73 | 74 | if (!data.user && !data.organization) { 75 | throw new Error("Not found"); 76 | } 77 | 78 | const isUser = data.organization === null && data.user; 79 | const isOrg = data.user === null && data.organization; 80 | 81 | if (isUser) { 82 | if (!data.user.repository || data.user.repository.isPrivate) { 83 | throw new Error("User Repository Not found"); 84 | } 85 | return { 86 | ...data.user.repository, 87 | starCount: data.user.repository.stargazers.totalCount, 88 | }; 89 | } 90 | 91 | if (isOrg) { 92 | if ( 93 | !data.organization.repository || 94 | data.organization.repository.isPrivate 95 | ) { 96 | throw new Error("Organization Repository Not found"); 97 | } 98 | return { 99 | ...data.organization.repository, 100 | starCount: data.organization.repository.stargazers.totalCount, 101 | }; 102 | } 103 | }; 104 | 105 | export { fetchRepo }; 106 | export default fetchRepo; 107 | -------------------------------------------------------------------------------- /src/fetchers/stats-fetcher.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | import axios from "axios"; 3 | import * as dotenv from "dotenv"; 4 | import githubUsernameRegex from "github-username-regex"; 5 | import { calculateRank } from "../calculateRank.js"; 6 | import { retryer } from "../common/retryer.js"; 7 | import { 8 | CustomError, 9 | logger, 10 | MissingParamError, 11 | request, 12 | wrapTextMultiline, 13 | } from "../common/utils.js"; 14 | 15 | dotenv.config(); 16 | 17 | // GraphQL queries. 18 | const GRAPHQL_REPOS_FIELD = ` 19 | repositories(first: 100, ownerAffiliations: OWNER, orderBy: {direction: DESC, field: STARGAZERS}, after: $after) { 20 | totalCount 21 | nodes { 22 | name 23 | stargazers { 24 | totalCount 25 | } 26 | } 27 | pageInfo { 28 | hasNextPage 29 | endCursor 30 | } 31 | } 32 | `; 33 | 34 | const GRAPHQL_REPOS_QUERY = ` 35 | query userInfo($login: String!, $after: String) { 36 | user(login: $login) { 37 | ${GRAPHQL_REPOS_FIELD} 38 | } 39 | } 40 | `; 41 | 42 | const GRAPHQL_STATS_QUERY = ` 43 | query userInfo($login: String!, $after: String) { 44 | user(login: $login) { 45 | name 46 | login 47 | contributionsCollection { 48 | totalCommitContributions 49 | restrictedContributionsCount 50 | } 51 | repositoriesContributedTo(first: 1, contributionTypes: [COMMIT, ISSUE, PULL_REQUEST, REPOSITORY]) { 52 | totalCount 53 | } 54 | pullRequests(first: 1) { 55 | totalCount 56 | } 57 | openIssues: issues(states: OPEN) { 58 | totalCount 59 | } 60 | closedIssues: issues(states: CLOSED) { 61 | totalCount 62 | } 63 | followers { 64 | totalCount 65 | } 66 | ${GRAPHQL_REPOS_FIELD} 67 | } 68 | } 69 | `; 70 | 71 | /** 72 | * Stats fetcher object. 73 | * 74 | * @param {import('axios').AxiosRequestHeaders} variables Fetcher variables. 75 | * @param {string} token GitHub token. 76 | * @returns {Promise} Stats fetcher response. 77 | */ 78 | const fetcher = (variables, token) => { 79 | const query = !variables.after ? GRAPHQL_STATS_QUERY : GRAPHQL_REPOS_QUERY; 80 | return request( 81 | { 82 | query, 83 | variables, 84 | }, 85 | { 86 | Authorization: `bearer ${token}`, 87 | }, 88 | ); 89 | }; 90 | 91 | /** 92 | * Fetch stats information for a given username. 93 | * 94 | * @param {string} username Github username. 95 | * @returns {Promise} GraphQL Stats object. 96 | * 97 | * @description This function supports multi-page fetching if the 'FETCH_MULTI_PAGE_STARS' environment variable is set to true. 98 | */ 99 | const statsFetcher = async (username) => { 100 | let stats; 101 | let hasNextPage = true; 102 | let endCursor = null; 103 | while (hasNextPage) { 104 | const variables = { login: username, first: 100, after: endCursor }; 105 | let res = await retryer(fetcher, variables); 106 | if (res.data.errors) return res; 107 | 108 | // Store stats data. 109 | const repoNodes = res.data.data.user.repositories.nodes; 110 | if (!stats) { 111 | stats = res; 112 | } else { 113 | stats.data.data.user.repositories.nodes.push(...repoNodes); 114 | } 115 | 116 | // Disable multi page fetching on public Vercel instance due to rate limits. 117 | const repoNodesWithStars = repoNodes.filter( 118 | (node) => node.stargazers.totalCount !== 0, 119 | ); 120 | hasNextPage = 121 | process.env.FETCH_MULTI_PAGE_STARS === "true" && 122 | repoNodes.length === repoNodesWithStars.length && 123 | res.data.data.user.repositories.pageInfo.hasNextPage; 124 | endCursor = res.data.data.user.repositories.pageInfo.endCursor; 125 | } 126 | 127 | return stats; 128 | }; 129 | 130 | /** 131 | * Fetch all the commits for all the repositories of a given username. 132 | * 133 | * @param {*} username GitHub username. 134 | * @returns {Promise} Total commits. 135 | * 136 | * @description Done like this because the GitHub API does not provide a way to fetch all the commits. See 137 | * #92#issuecomment-661026467 and #211 for more information. 138 | */ 139 | const totalCommitsFetcher = async (username) => { 140 | if (!githubUsernameRegex.test(username)) { 141 | logger.log("Invalid username"); 142 | return 0; 143 | } 144 | 145 | // https://developer.github.com/v3/search/#search-commits 146 | const fetchTotalCommits = (variables, token) => { 147 | return axios({ 148 | method: "get", 149 | url: `https://api.github.com/search/commits?q=author:${variables.login}`, 150 | headers: { 151 | "Content-Type": "application/json", 152 | Accept: "application/vnd.github.cloak-preview", 153 | Authorization: `token ${token}`, 154 | }, 155 | }); 156 | }; 157 | 158 | try { 159 | let res = await retryer(fetchTotalCommits, { login: username }); 160 | let total_count = res.data.total_count; 161 | if (!!total_count && !isNaN(total_count)) { 162 | return res.data.total_count; 163 | } 164 | } catch (err) { 165 | logger.log(err); 166 | } 167 | // just return 0 if there is something wrong so that 168 | // we don't break the whole app 169 | return 0; 170 | }; 171 | 172 | /** 173 | * Fetch stats for a given username. 174 | * 175 | * @param {string} username GitHub username. 176 | * @param {boolean} count_private Include private contributions. 177 | * @param {boolean} include_all_commits Include all commits. 178 | * @returns {Promise} Stats data. 179 | */ 180 | const fetchStats = async ( 181 | username, 182 | count_private = false, 183 | include_all_commits = false, 184 | exclude_repo = [], 185 | ) => { 186 | if (!username) throw new MissingParamError(["username"]); 187 | 188 | const stats = { 189 | name: "", 190 | totalPRs: 0, 191 | totalCommits: 0, 192 | totalIssues: 0, 193 | totalStars: 0, 194 | contributedTo: 0, 195 | rank: { level: "C", score: 0 }, 196 | }; 197 | 198 | let res = await statsFetcher(username); 199 | 200 | // Catch GraphQL errors. 201 | if (res.data.errors) { 202 | logger.error(res.data.errors); 203 | if (res.data.errors[0].type === "NOT_FOUND") { 204 | throw new CustomError( 205 | res.data.errors[0].message || "Could not fetch user.", 206 | CustomError.USER_NOT_FOUND, 207 | ); 208 | } 209 | if (res.data.errors[0].message) { 210 | throw new CustomError( 211 | wrapTextMultiline(res.data.errors[0].message, 90, 1)[0], 212 | res.statusText, 213 | ); 214 | } 215 | throw new CustomError( 216 | "Something went wrong while trying to retrieve the stats data using the GraphQL API.", 217 | CustomError.GRAPHQL_ERROR, 218 | ); 219 | } 220 | 221 | const user = res.data.data.user; 222 | 223 | // populate repoToHide map for quick lookup 224 | // while filtering out 225 | let repoToHide = {}; 226 | if (exclude_repo) { 227 | exclude_repo.forEach((repoName) => { 228 | repoToHide[repoName] = true; 229 | }); 230 | } 231 | 232 | stats.name = user.name || user.login; 233 | stats.totalIssues = user.openIssues.totalCount + user.closedIssues.totalCount; 234 | 235 | // normal commits 236 | stats.totalCommits = user.contributionsCollection.totalCommitContributions; 237 | 238 | // if include_all_commits then just get that, 239 | // since totalCommitsFetcher already sends totalCommits no need to += 240 | if (include_all_commits) { 241 | stats.totalCommits = await totalCommitsFetcher(username); 242 | } 243 | 244 | // if count_private then add private commits to totalCommits so far. 245 | if (count_private) { 246 | stats.totalCommits += 247 | user.contributionsCollection.restrictedContributionsCount; 248 | } 249 | 250 | stats.totalPRs = user.pullRequests.totalCount; 251 | stats.contributedTo = user.repositoriesContributedTo.totalCount; 252 | 253 | // Retrieve stars while filtering out repositories to be hidden 254 | stats.totalStars = user.repositories.nodes 255 | .filter((data) => { 256 | return !repoToHide[data.name]; 257 | }) 258 | .reduce((prev, curr) => { 259 | return prev + curr.stargazers.totalCount; 260 | }, 0); 261 | 262 | stats.rank = calculateRank({ 263 | totalCommits: stats.totalCommits, 264 | totalRepos: user.repositories.totalCount, 265 | followers: user.followers.totalCount, 266 | contributions: stats.contributedTo, 267 | stargazers: stats.totalStars, 268 | prs: stats.totalPRs, 269 | issues: stats.totalIssues, 270 | }); 271 | 272 | return stats; 273 | }; 274 | 275 | export { fetchStats }; 276 | export default fetchStats; 277 | -------------------------------------------------------------------------------- /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 | * Top languages fetcher object. 13 | * 14 | * @param {import('axios').AxiosRequestHeaders} variables Fetcher variables. 15 | * @param {string} token GitHub token. 16 | * @returns {Promise} Languages fetcher response. 17 | */ 18 | const fetcher = (variables, token) => { 19 | return request( 20 | { 21 | query: ` 22 | query userInfo($login: String!) { 23 | user(login: $login) { 24 | # fetch only owner repos & not forks 25 | repositories(ownerAffiliations: OWNER, isFork: false, first: 100) { 26 | nodes { 27 | name 28 | languages(first: 10, orderBy: {field: SIZE, direction: DESC}) { 29 | edges { 30 | size 31 | node { 32 | color 33 | name 34 | } 35 | } 36 | } 37 | } 38 | } 39 | } 40 | } 41 | `, 42 | variables, 43 | }, 44 | { 45 | Authorization: `token ${token}`, 46 | }, 47 | ); 48 | }; 49 | 50 | /** 51 | * Fetch top languages for a given username. 52 | * 53 | * @param {string} username GitHub username. 54 | * @param {string[]} exclude_repo List of repositories to exclude. 55 | * @returns {Promise} Top languages data. 56 | */ 57 | const fetchTopLanguages = async ( 58 | username, 59 | exclude_repo = [], 60 | size_weight = 1, 61 | count_weight = 0, 62 | ) => { 63 | if (!username) throw new MissingParamError(["username"]); 64 | 65 | const res = await retryer(fetcher, { login: username }); 66 | 67 | if (res.data.errors) { 68 | logger.error(res.data.errors); 69 | throw Error(res.data.errors[0].message || "Could not fetch user"); 70 | } 71 | 72 | // Catch GraphQL errors. 73 | if (res.data.errors) { 74 | logger.error(res.data.errors); 75 | if (res.data.errors[0].type === "NOT_FOUND") { 76 | throw new CustomError( 77 | res.data.errors[0].message || "Could not fetch user.", 78 | CustomError.USER_NOT_FOUND, 79 | ); 80 | } 81 | if (res.data.errors[0].message) { 82 | throw new CustomError( 83 | wrapTextMultiline(res.data.errors[0].message, 90, 1)[0], 84 | res.statusText, 85 | ); 86 | } 87 | throw new CustomError( 88 | "Something went while trying to retrieve the language data using the GraphQL API.", 89 | CustomError.GRAPHQL_ERROR, 90 | ); 91 | } 92 | 93 | let repoNodes = res.data.data.user.repositories.nodes; 94 | let repoToHide = {}; 95 | 96 | // populate repoToHide map for quick lookup 97 | // while filtering out 98 | if (exclude_repo) { 99 | exclude_repo.forEach((repoName) => { 100 | repoToHide[repoName] = true; 101 | }); 102 | } 103 | 104 | // filter out repositories to be hidden 105 | repoNodes = repoNodes 106 | .sort((a, b) => b.size - a.size) 107 | .filter((name) => !repoToHide[name.name]); 108 | 109 | let repoCount = 0; 110 | 111 | repoNodes = repoNodes 112 | .filter((node) => node.languages.edges.length > 0) 113 | // flatten the list of language nodes 114 | .reduce((acc, curr) => curr.languages.edges.concat(acc), []) 115 | .reduce((acc, prev) => { 116 | // get the size of the language (bytes) 117 | let langSize = prev.size; 118 | 119 | // if we already have the language in the accumulator 120 | // & the current language name is same as previous name 121 | // add the size to the language size and increase repoCount. 122 | if (acc[prev.node.name] && prev.node.name === acc[prev.node.name].name) { 123 | langSize = prev.size + acc[prev.node.name].size; 124 | repoCount += 1; 125 | } else { 126 | // reset repoCount to 1 127 | // language must exist in at least one repo to be detected 128 | repoCount = 1; 129 | } 130 | return { 131 | ...acc, 132 | [prev.node.name]: { 133 | name: prev.node.name, 134 | color: prev.node.color, 135 | size: langSize, 136 | count: repoCount, 137 | }, 138 | }; 139 | }, {}); 140 | 141 | Object.keys(repoNodes).forEach((name) => { 142 | // comparison index calculation 143 | repoNodes[name].size = 144 | Math.pow(repoNodes[name].size, size_weight) * 145 | Math.pow(repoNodes[name].count, count_weight); 146 | }); 147 | 148 | const topLangs = Object.keys(repoNodes) 149 | .sort((a, b) => repoNodes[b].size - repoNodes[a].size) 150 | .reduce((result, key) => { 151 | result[key] = repoNodes[key]; 152 | return result; 153 | }, {}); 154 | 155 | return topLangs; 156 | }; 157 | 158 | export { fetchTopLanguages }; 159 | export default fetchTopLanguages; 160 | -------------------------------------------------------------------------------- /src/fetchers/types.d.ts: -------------------------------------------------------------------------------- 1 | export type RepositoryData = { 2 | name: string; 3 | nameWithOwner: string; 4 | isPrivate: boolean; 5 | isArchived: boolean; 6 | isTemplate: boolean; 7 | stargazers: { totalCount: number }; 8 | description: string; 9 | primaryLanguage: { 10 | color: string; 11 | id: string; 12 | name: string; 13 | }; 14 | forkCount: number; 15 | starCount: number; 16 | }; 17 | 18 | export type StatsData = { 19 | name: string; 20 | totalPRs: number; 21 | totalCommits: number; 22 | totalIssues: number; 23 | totalStars: number; 24 | contributedTo: number; 25 | rank: { level: string; score: number }; 26 | }; 27 | 28 | export type Lang = { 29 | name: string; 30 | color: string; 31 | size: number; 32 | }; 33 | 34 | export type TopLangData = Record; 35 | 36 | export type WakaTimeData = { 37 | categories: { 38 | digital: string; 39 | hours: number; 40 | minutes: number; 41 | name: string; 42 | percent: number; 43 | text: string; 44 | total_seconds: number; 45 | }[]; 46 | daily_average: number; 47 | daily_average_including_other_language: number; 48 | days_including_holidays: number; 49 | days_minus_holidays: number; 50 | editors: { 51 | digital: string; 52 | hours: number; 53 | minutes: number; 54 | name: string; 55 | percent: number; 56 | text: string; 57 | total_seconds: number; 58 | }[]; 59 | holidays: number; 60 | human_readable_daily_average: string; 61 | human_readable_daily_average_including_other_language: string; 62 | human_readable_total: string; 63 | human_readable_total_including_other_language: string; 64 | id: string; 65 | is_already_updating: boolean; 66 | is_coding_activity_visible: boolean; 67 | is_including_today: boolean; 68 | is_other_usage_visible: boolean; 69 | is_stuck: boolean; 70 | is_up_to_date: boolean; 71 | languages: { 72 | digital: string; 73 | hours: number; 74 | minutes: number; 75 | name: string; 76 | percent: number; 77 | text: string; 78 | total_seconds: number; 79 | }[]; 80 | operating_systems: { 81 | digital: string; 82 | hours: number; 83 | minutes: number; 84 | name: string; 85 | percent: number; 86 | text: string; 87 | total_seconds: number; 88 | }[]; 89 | percent_calculated: number; 90 | range: string; 91 | status: string; 92 | timeout: number; 93 | total_seconds: number; 94 | total_seconds_including_other_language: number; 95 | user_id: string; 96 | username: string; 97 | writes_only: boolean; 98 | }; 99 | 100 | export type WakaTimeLang = { 101 | name: string; 102 | text: string; 103 | percent: number; 104 | }; 105 | -------------------------------------------------------------------------------- /src/fetchers/wakatime-fetcher.js: -------------------------------------------------------------------------------- 1 | import axios from "axios"; 2 | import { MissingParamError } from "../common/utils.js"; 3 | 4 | /** 5 | * WakaTime data fetcher. 6 | * 7 | * @param {{username: string, api_domain: string, range: string}} props Fetcher props. 8 | * @returns {Promise} WakaTime data response. 9 | */ 10 | const fetchWakatimeStats = async ({ username, api_domain, range }) => { 11 | if (!username) throw new MissingParamError(["username"]); 12 | 13 | try { 14 | const { data } = await axios.get( 15 | `https://${ 16 | api_domain ? api_domain.replace(/\/$/gi, "") : "wakatime.com" 17 | }/api/v1/users/${username}/stats/${ 18 | range || "all_time" 19 | }?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 Error( 26 | "Wakatime user not found, make sure you have a wakatime profile", 27 | ); 28 | } 29 | throw err; 30 | } 31 | }; 32 | 33 | export { fetchWakatimeStats }; 34 | export default fetchWakatimeStats; 35 | -------------------------------------------------------------------------------- /src/getStyles.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | /** 3 | * Calculates progress along the boundary of the circle i.e it's circumference. 4 | * 5 | * @param {number} value The rank value to calculate progress for. 6 | * @returns {number} Progress value. 7 | */ 8 | const calculateCircleProgress = (value) => { 9 | const radius = 40; 10 | const c = Math.PI * (radius * 2); 11 | 12 | if (value < 0) value = 0; 13 | if (value > 100) value = 100; 14 | 15 | return ((100 - value) / 100) * c; 16 | }; 17 | 18 | /** 19 | * Retrieves the animation to display progress along the circumference of circle 20 | * from the beginning to the given value in a clockwise direction. 21 | * 22 | * @param {{progress: number}} progress The progress value to animate to. 23 | * @returns {string} Progress animation css. 24 | */ 25 | const getProgressAnimation = ({ progress }) => { 26 | return ` 27 | @keyframes rankAnimation { 28 | from { 29 | stroke-dashoffset: ${calculateCircleProgress(0)}; 30 | } 31 | to { 32 | stroke-dashoffset: ${calculateCircleProgress(progress)}; 33 | } 34 | } 35 | `; 36 | }; 37 | 38 | /** 39 | * Retrieves css animations for a card. 40 | * 41 | * @returns {string} Animation css. 42 | */ 43 | const getAnimations = () => { 44 | return ` 45 | /* Animations */ 46 | @keyframes scaleInAnimation { 47 | from { 48 | transform: translate(-5px, 5px) scale(0); 49 | } 50 | to { 51 | transform: translate(-5px, 5px) scale(1); 52 | } 53 | } 54 | @keyframes fadeInAnimation { 55 | from { 56 | opacity: 0; 57 | } 58 | to { 59 | opacity: 1; 60 | } 61 | } 62 | `; 63 | }; 64 | 65 | /** 66 | * Retrieves CSS styles for a card. 67 | * 68 | * @param {Object} colors The colors to use for the card. 69 | * @param {string} colors.titleColor The title color. 70 | * @param {string} colors.textColor The text color. 71 | * @param {string} colors.iconColor The icon color. 72 | * @param {string} colors.ringColor The ring color. 73 | * @param {boolean} colors.show_icons Whether to show icons. 74 | * @param {number} colors.progress The progress value to animate to. 75 | * @returns {string} Card CSS styles. 76 | */ 77 | const getStyles = ({ 78 | titleColor, 79 | textColor, 80 | iconColor, 81 | ringColor, 82 | show_icons, 83 | progress, 84 | }) => { 85 | return ` 86 | .stat { 87 | font: 600 14px 'Segoe UI', Ubuntu, "Helvetica Neue", Sans-Serif; fill: ${textColor}; 88 | } 89 | @supports(-moz-appearance: auto) { 90 | /* Selector detects Firefox */ 91 | .stat { font-size:12px; } 92 | } 93 | .stagger { 94 | opacity: 0; 95 | animation: fadeInAnimation 0.3s ease-in-out forwards; 96 | } 97 | .rank-text { 98 | font: 800 24px 'Segoe UI', Ubuntu, Sans-Serif; fill: ${textColor}; 99 | animation: scaleInAnimation 0.3s ease-in-out forwards; 100 | } 101 | 102 | .not_bold { font-weight: 400 } 103 | .bold { font-weight: 700 } 104 | .icon { 105 | fill: ${iconColor}; 106 | display: ${!!show_icons ? "block" : "none"}; 107 | } 108 | 109 | .rank-circle-rim { 110 | stroke: ${ringColor}; 111 | fill: none; 112 | stroke-width: 6; 113 | opacity: 0.2; 114 | } 115 | .rank-circle { 116 | stroke: ${ringColor}; 117 | stroke-dasharray: 250; 118 | fill: none; 119 | stroke-width: 6; 120 | stroke-linecap: round; 121 | opacity: 0.8; 122 | transform-origin: -10px 8px; 123 | transform: rotate(-90deg); 124 | animation: rankAnimation 1s forwards ease-in-out; 125 | } 126 | ${process.env.NODE_ENV === "test" ? "" : getProgressAnimation({ progress })} 127 | `; 128 | }; 129 | 130 | export { getStyles, getAnimations }; 131 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | export * from "./common/index.js"; 2 | export * from "./cards/index.js"; 3 | export { getStyles, getAnimations } from "./getStyles.js"; 4 | -------------------------------------------------------------------------------- /tests/__snapshots__/renderWakatimeCard.test.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`Test Render Wakatime Card should render correctly 1`] = `[Function]`; 4 | 5 | exports[`Test Render Wakatime Card should render correctly with compact layout 1`] = ` 6 | " 7 | 16 | 17 | 18 | 100 | 101 | 102 | 103 | 114 | 115 | 116 | 120 | 121 | Wakatime Stats 127 | 128 | 129 | 130 | 131 | 135 | 136 | 137 | 138 | 139 | 140 | 141 | 142 | 151 | 152 | 161 | 162 | 163 | 164 | 165 | 166 | Other - 19 mins 167 | 168 | 169 | 170 | 171 | 172 | 173 | TypeScript - 1 min 174 | 175 | 176 | 177 | 178 | 179 | 180 | 181 | 182 | " 183 | `; 184 | 185 | exports[`Test Render Wakatime Card should render correctly with compact layout when langs_count is set 1`] = ` 186 | " 187 | 196 | 197 | 198 | 280 | 281 | 282 | 283 | 294 | 295 | 296 | 300 | 301 | Wakatime Stats 307 | 308 | 309 | 310 | 311 | 315 | 316 | 317 | 318 | 319 | 320 | 321 | 322 | 331 | 332 | 341 | 342 | 343 | 344 | 345 | 346 | Other - 19 mins 347 | 348 | 349 | 350 | 351 | 352 | 353 | TypeScript - 1 min 354 | 355 | 356 | 357 | 358 | 359 | 360 | 361 | 362 | " 363 | `; 364 | -------------------------------------------------------------------------------- /tests/api.test.js: -------------------------------------------------------------------------------- 1 | import { jest } from "@jest/globals"; 2 | import axios from "axios"; 3 | import MockAdapter from "axios-mock-adapter"; 4 | import api from "../api/index.js"; 5 | import { calculateRank } from "../src/calculateRank.js"; 6 | import { renderStatsCard } from "../src/cards/stats-card.js"; 7 | import { CONSTANTS, renderError } from "../src/common/utils.js"; 8 | 9 | const stats = { 10 | name: "Anurag Hazra", 11 | totalStars: 100, 12 | totalCommits: 200, 13 | totalIssues: 300, 14 | totalPRs: 400, 15 | contributedTo: 500, 16 | rank: null, 17 | }; 18 | stats.rank = calculateRank({ 19 | totalCommits: stats.totalCommits, 20 | totalRepos: 1, 21 | followers: 0, 22 | contributions: stats.contributedTo, 23 | stargazers: stats.totalStars, 24 | prs: stats.totalPRs, 25 | issues: stats.totalIssues, 26 | }); 27 | 28 | const data_stats = { 29 | data: { 30 | user: { 31 | name: stats.name, 32 | repositoriesContributedTo: { totalCount: stats.contributedTo }, 33 | contributionsCollection: { 34 | totalCommitContributions: stats.totalCommits, 35 | restrictedContributionsCount: 100, 36 | }, 37 | pullRequests: { totalCount: stats.totalPRs }, 38 | openIssues: { totalCount: stats.totalIssues }, 39 | closedIssues: { totalCount: 0 }, 40 | followers: { totalCount: 0 }, 41 | repositories: { 42 | totalCount: 1, 43 | nodes: [{ stargazers: { totalCount: 100 } }], 44 | pageInfo: { 45 | hasNextPage: false, 46 | endCursor: "cursor", 47 | }, 48 | }, 49 | }, 50 | }, 51 | }; 52 | 53 | const error = { 54 | errors: [ 55 | { 56 | type: "NOT_FOUND", 57 | path: ["user"], 58 | locations: [], 59 | message: "Could not fetch user", 60 | }, 61 | ], 62 | }; 63 | 64 | const mock = new MockAdapter(axios); 65 | 66 | const faker = (query, data) => { 67 | const req = { 68 | query: { 69 | username: "anuraghazra", 70 | ...query, 71 | }, 72 | }; 73 | const res = { 74 | setHeader: jest.fn(), 75 | send: jest.fn(), 76 | }; 77 | mock.onPost("https://api.github.com/graphql").replyOnce(200, data); 78 | 79 | return { req, res }; 80 | }; 81 | 82 | afterEach(() => { 83 | mock.reset(); 84 | }); 85 | 86 | describe("Test /api/", () => { 87 | it("should test the request", async () => { 88 | const { req, res } = faker({}, data_stats); 89 | 90 | await api(req, res); 91 | 92 | expect(res.setHeader).toBeCalledWith("Content-Type", "image/svg+xml"); 93 | expect(res.send).toBeCalledWith(renderStatsCard(stats, { ...req.query })); 94 | }); 95 | 96 | it("should render error card on error", async () => { 97 | const { req, res } = faker({}, error); 98 | 99 | await api(req, res); 100 | 101 | expect(res.setHeader).toBeCalledWith("Content-Type", "image/svg+xml"); 102 | expect(res.send).toBeCalledWith( 103 | renderError( 104 | error.errors[0].message, 105 | "Make sure the provided username is not an organization", 106 | ), 107 | ); 108 | }); 109 | 110 | it("should get the query options", async () => { 111 | const { req, res } = faker( 112 | { 113 | username: "anuraghazra", 114 | hide: "issues,prs,contribs", 115 | show_icons: true, 116 | hide_border: true, 117 | line_height: 100, 118 | title_color: "fff", 119 | icon_color: "fff", 120 | text_color: "fff", 121 | bg_color: "fff", 122 | }, 123 | data_stats, 124 | ); 125 | 126 | await api(req, res); 127 | 128 | expect(res.setHeader).toBeCalledWith("Content-Type", "image/svg+xml"); 129 | expect(res.send).toBeCalledWith( 130 | renderStatsCard(stats, { 131 | hide: ["issues", "prs", "contribs"], 132 | show_icons: true, 133 | hide_border: true, 134 | line_height: 100, 135 | title_color: "fff", 136 | icon_color: "fff", 137 | text_color: "fff", 138 | bg_color: "fff", 139 | }), 140 | ); 141 | }); 142 | 143 | it("should have proper cache", async () => { 144 | const { req, res } = faker({}, data_stats); 145 | 146 | await api(req, res); 147 | 148 | expect(res.setHeader.mock.calls).toEqual([ 149 | ["Content-Type", "image/svg+xml"], 150 | [ 151 | "Cache-Control", 152 | `max-age=${CONSTANTS.FOUR_HOURS / 2}, s-maxage=${ 153 | CONSTANTS.FOUR_HOURS 154 | }, stale-while-revalidate=${CONSTANTS.ONE_DAY}`, 155 | ], 156 | ]); 157 | }); 158 | 159 | it("should set proper cache", async () => { 160 | const { req, res } = faker({ cache_seconds: 15000 }, data_stats); 161 | await api(req, res); 162 | 163 | expect(res.setHeader.mock.calls).toEqual([ 164 | ["Content-Type", "image/svg+xml"], 165 | [ 166 | "Cache-Control", 167 | `max-age=7500, s-maxage=${15000}, stale-while-revalidate=${ 168 | CONSTANTS.ONE_DAY 169 | }`, 170 | ], 171 | ]); 172 | }); 173 | 174 | it("should not store cache when error", async () => { 175 | const { req, res } = faker({}, error); 176 | await api(req, res); 177 | 178 | expect(res.setHeader.mock.calls).toEqual([ 179 | ["Content-Type", "image/svg+xml"], 180 | ["Cache-Control", `no-cache, no-store, must-revalidate`], 181 | ]); 182 | }); 183 | 184 | it("should set proper cache with clamped values", async () => { 185 | { 186 | let { req, res } = faker({ cache_seconds: 200000 }, data_stats); 187 | await api(req, res); 188 | 189 | expect(res.setHeader.mock.calls).toEqual([ 190 | ["Content-Type", "image/svg+xml"], 191 | [ 192 | "Cache-Control", 193 | `max-age=${CONSTANTS.ONE_DAY / 2}, s-maxage=${ 194 | CONSTANTS.ONE_DAY 195 | }, stale-while-revalidate=${CONSTANTS.ONE_DAY}`, 196 | ], 197 | ]); 198 | } 199 | 200 | // note i'm using block scoped vars 201 | { 202 | let { req, res } = faker({ cache_seconds: 0 }, data_stats); 203 | await api(req, res); 204 | 205 | expect(res.setHeader.mock.calls).toEqual([ 206 | ["Content-Type", "image/svg+xml"], 207 | [ 208 | "Cache-Control", 209 | `max-age=${CONSTANTS.FOUR_HOURS / 2}, s-maxage=${ 210 | CONSTANTS.FOUR_HOURS 211 | }, stale-while-revalidate=${CONSTANTS.ONE_DAY}`, 212 | ], 213 | ]); 214 | } 215 | 216 | { 217 | let { req, res } = faker({ cache_seconds: -10000 }, data_stats); 218 | await api(req, res); 219 | 220 | expect(res.setHeader.mock.calls).toEqual([ 221 | ["Content-Type", "image/svg+xml"], 222 | [ 223 | "Cache-Control", 224 | `max-age=${CONSTANTS.FOUR_HOURS / 2}, s-maxage=${ 225 | CONSTANTS.FOUR_HOURS 226 | }, stale-while-revalidate=${CONSTANTS.ONE_DAY}`, 227 | ], 228 | ]); 229 | } 230 | }); 231 | 232 | it("should add private contributions", async () => { 233 | const { req, res } = faker( 234 | { 235 | username: "anuraghazra", 236 | count_private: true, 237 | }, 238 | data_stats, 239 | ); 240 | 241 | await api(req, res); 242 | 243 | expect(res.setHeader).toBeCalledWith("Content-Type", "image/svg+xml"); 244 | expect(res.send).toBeCalledWith( 245 | renderStatsCard( 246 | { 247 | ...stats, 248 | totalCommits: stats.totalCommits + 100, 249 | rank: calculateRank({ 250 | totalCommits: stats.totalCommits + 100, 251 | totalRepos: 1, 252 | followers: 0, 253 | contributions: stats.contributedTo, 254 | stargazers: stats.totalStars, 255 | prs: stats.totalPRs, 256 | issues: stats.totalIssues, 257 | }), 258 | }, 259 | {}, 260 | ), 261 | ); 262 | }); 263 | 264 | it("should allow changing ring_color", async () => { 265 | const { req, res } = faker( 266 | { 267 | username: "anuraghazra", 268 | hide: "issues,prs,contribs", 269 | show_icons: true, 270 | hide_border: true, 271 | line_height: 100, 272 | title_color: "fff", 273 | ring_color: "0000ff", 274 | icon_color: "fff", 275 | text_color: "fff", 276 | bg_color: "fff", 277 | }, 278 | data_stats, 279 | ); 280 | 281 | await api(req, res); 282 | 283 | expect(res.setHeader).toBeCalledWith("Content-Type", "image/svg+xml"); 284 | expect(res.send).toBeCalledWith( 285 | renderStatsCard(stats, { 286 | hide: ["issues", "prs", "contribs"], 287 | show_icons: true, 288 | hide_border: true, 289 | line_height: 100, 290 | title_color: "fff", 291 | ring_color: "0000ff", 292 | icon_color: "fff", 293 | text_color: "fff", 294 | bg_color: "fff", 295 | }), 296 | ); 297 | }); 298 | }); 299 | -------------------------------------------------------------------------------- /tests/calculateRank.test.js: -------------------------------------------------------------------------------- 1 | import "@testing-library/jest-dom"; 2 | import { calculateRank } from "../src/calculateRank.js"; 3 | 4 | describe("Test calculateRank", () => { 5 | it("should calculate rank correctly", () => { 6 | expect( 7 | calculateRank({ 8 | totalCommits: 100, 9 | totalRepos: 5, 10 | followers: 100, 11 | contributions: 61, 12 | stargazers: 400, 13 | prs: 300, 14 | issues: 200, 15 | }), 16 | ).toStrictEqual({ level: "A+", score: 49.25629684876535 }); 17 | }); 18 | }); 19 | -------------------------------------------------------------------------------- /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 | 8 | describe("Card", () => { 9 | it("should hide border", () => { 10 | const card = new Card({}); 11 | card.setHideBorder(true); 12 | 13 | document.body.innerHTML = card.render(``); 14 | expect(queryByTestId(document.body, "card-bg")).toHaveAttribute( 15 | "stroke-opacity", 16 | "0", 17 | ); 18 | }); 19 | 20 | it("should not hide border", () => { 21 | const card = new Card({}); 22 | card.setHideBorder(false); 23 | 24 | document.body.innerHTML = card.render(``); 25 | expect(queryByTestId(document.body, "card-bg")).toHaveAttribute( 26 | "stroke-opacity", 27 | "1", 28 | ); 29 | }); 30 | 31 | it("should have a custom title", () => { 32 | const card = new Card({ 33 | customTitle: "custom title", 34 | defaultTitle: "default title", 35 | }); 36 | 37 | document.body.innerHTML = card.render(``); 38 | expect(queryByTestId(document.body, "card-title")).toHaveTextContent( 39 | "custom title", 40 | ); 41 | }); 42 | 43 | it("should hide title", () => { 44 | const card = new Card({}); 45 | card.setHideTitle(true); 46 | 47 | document.body.innerHTML = card.render(``); 48 | expect(queryByTestId(document.body, "card-title")).toBeNull(); 49 | }); 50 | 51 | it("should not hide title", () => { 52 | const card = new Card({}); 53 | card.setHideTitle(false); 54 | 55 | document.body.innerHTML = card.render(``); 56 | expect(queryByTestId(document.body, "card-title")).toBeInTheDocument(); 57 | }); 58 | 59 | it("title should have prefix icon", () => { 60 | const card = new Card({ title: "ok", titlePrefixIcon: icons.contribs }); 61 | 62 | document.body.innerHTML = card.render(``); 63 | expect(document.getElementsByClassName("icon")[0]).toBeInTheDocument(); 64 | }); 65 | 66 | it("title should not have prefix icon", () => { 67 | const card = new Card({ title: "ok" }); 68 | 69 | document.body.innerHTML = card.render(``); 70 | expect(document.getElementsByClassName("icon")[0]).toBeUndefined(); 71 | }); 72 | 73 | it("should have proper height, width", () => { 74 | const card = new Card({ height: 200, width: 200, title: "ok" }); 75 | document.body.innerHTML = card.render(``); 76 | expect(document.getElementsByTagName("svg")[0]).toHaveAttribute( 77 | "height", 78 | "200", 79 | ); 80 | expect(document.getElementsByTagName("svg")[0]).toHaveAttribute( 81 | "height", 82 | "200", 83 | ); 84 | }); 85 | 86 | it("should have less height after title is hidden", () => { 87 | const card = new Card({ height: 200, title: "ok" }); 88 | card.setHideTitle(true); 89 | 90 | document.body.innerHTML = card.render(``); 91 | expect(document.getElementsByTagName("svg")[0]).toHaveAttribute( 92 | "height", 93 | "170", 94 | ); 95 | }); 96 | 97 | it("main-card-body should have proper when title is visible", () => { 98 | const card = new Card({ height: 200 }); 99 | document.body.innerHTML = card.render(``); 100 | expect(queryByTestId(document.body, "main-card-body")).toHaveAttribute( 101 | "transform", 102 | "translate(0, 55)", 103 | ); 104 | }); 105 | 106 | it("main-card-body should have proper position after title is hidden", () => { 107 | const card = new Card({ height: 200 }); 108 | card.setHideTitle(true); 109 | 110 | document.body.innerHTML = card.render(``); 111 | expect(queryByTestId(document.body, "main-card-body")).toHaveAttribute( 112 | "transform", 113 | "translate(0, 25)", 114 | ); 115 | }); 116 | 117 | it("should render with correct colors", () => { 118 | // returns theme based colors with proper overrides and defaults 119 | const { titleColor, textColor, iconColor, bgColor } = getCardColors({ 120 | title_color: "f00", 121 | icon_color: "0f0", 122 | text_color: "00f", 123 | bg_color: "fff", 124 | theme: "default", 125 | }); 126 | 127 | const card = new Card({ 128 | height: 200, 129 | colors: { 130 | titleColor, 131 | textColor, 132 | iconColor, 133 | bgColor, 134 | }, 135 | }); 136 | document.body.innerHTML = card.render(``); 137 | 138 | const styleTag = document.querySelector("style"); 139 | const stylesObject = cssToObject(styleTag.innerHTML); 140 | const headerClassStyles = stylesObject[":host"][".header "]; 141 | 142 | expect(headerClassStyles["fill"].trim()).toBe("#f00"); 143 | expect(queryByTestId(document.body, "card-bg")).toHaveAttribute( 144 | "fill", 145 | "#fff", 146 | ); 147 | }); 148 | it("should render gradient backgrounds", () => { 149 | const { titleColor, textColor, iconColor, bgColor } = getCardColors({ 150 | title_color: "f00", 151 | icon_color: "0f0", 152 | text_color: "00f", 153 | bg_color: "90,fff,000,f00", 154 | theme: "default", 155 | }); 156 | 157 | const card = new Card({ 158 | height: 200, 159 | colors: { 160 | titleColor, 161 | textColor, 162 | iconColor, 163 | bgColor, 164 | }, 165 | }); 166 | document.body.innerHTML = card.render(``); 167 | expect(queryByTestId(document.body, "card-bg")).toHaveAttribute( 168 | "fill", 169 | "url(#gradient)", 170 | ); 171 | expect(document.querySelector("defs #gradient")).toHaveAttribute( 172 | "gradientTransform", 173 | "rotate(90)", 174 | ); 175 | expect( 176 | document.querySelector("defs #gradient stop:nth-child(1)"), 177 | ).toHaveAttribute("stop-color", "#fff"); 178 | expect( 179 | document.querySelector("defs #gradient stop:nth-child(2)"), 180 | ).toHaveAttribute("stop-color", "#000"); 181 | expect( 182 | document.querySelector("defs #gradient stop:nth-child(3)"), 183 | ).toHaveAttribute("stop-color", "#f00"); 184 | }); 185 | }); 186 | -------------------------------------------------------------------------------- /tests/e2e/e2e.test.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @file Contains end-to-end tests for the Vercel preview instance. 3 | */ 4 | import dotenv from "dotenv"; 5 | dotenv.config(); 6 | 7 | import { describe } from "@jest/globals"; 8 | import axios from "axios"; 9 | import { renderRepoCard } from "../../src/cards/repo-card.js"; 10 | import { renderStatsCard } from "../../src/cards/stats-card.js"; 11 | import { renderTopLanguages } from "../../src/cards/top-languages-card.js"; 12 | import { renderWakatimeCard } from "../../src/cards/wakatime-card.js"; 13 | 14 | const REPO = "curly-fiesta"; 15 | const USER = "catelinemnemosyne"; 16 | const STATS_DATA = { 17 | name: "Cateline Mnemosyne", 18 | totalPRs: 2, 19 | totalCommits: 8, 20 | totalIssues: 1, 21 | totalStars: 1, 22 | contributedTo: 1, 23 | rank: { 24 | level: "A+", 25 | score: 50.88831151384285, 26 | }, 27 | }; 28 | 29 | const LANGS_DATA = { 30 | HTML: { 31 | color: "#e34c26", 32 | name: "HTML", 33 | size: 1721, 34 | }, 35 | CSS: { 36 | color: "#563d7c", 37 | name: "CSS", 38 | size: 930, 39 | }, 40 | JavaScript: { 41 | color: "#f1e05a", 42 | name: "JavaScript", 43 | size: 1912, 44 | }, 45 | }; 46 | 47 | const WAKATIME_DATA = { 48 | human_readable_range: "last week", 49 | is_already_updating: false, 50 | is_coding_activity_visible: true, 51 | is_including_today: false, 52 | is_other_usage_visible: true, 53 | is_stuck: false, 54 | is_up_to_date: false, 55 | is_up_to_date_pending_future: false, 56 | percent_calculated: 0, 57 | range: "last_7_days", 58 | status: "pending_update", 59 | timeout: 15, 60 | username: USER, 61 | writes_only: false, 62 | }; 63 | 64 | const REPOSITORY_DATA = { 65 | name: REPO, 66 | nameWithOwner: `${USER}/cra-test`, 67 | isPrivate: false, 68 | isArchived: false, 69 | isTemplate: false, 70 | stargazers: { 71 | totalCount: 1, 72 | }, 73 | description: "Simple cra test repo.", 74 | primaryLanguage: { 75 | color: "#f1e05a", 76 | id: "MDg6TGFuZ3VhZ2UxNDA=", 77 | name: "JavaScript", 78 | }, 79 | forkCount: 0, 80 | starCount: 1, 81 | }; 82 | 83 | const CACHE_BURST_STRING = `v=${new Date().getTime()}`; 84 | 85 | describe("Fetch Cards", () => { 86 | let VERCEL_PREVIEW_URL; 87 | 88 | beforeAll(() => { 89 | process.env.NODE_ENV = "development"; 90 | VERCEL_PREVIEW_URL = process.env.VERCEL_PREVIEW_URL; 91 | }); 92 | 93 | test("retrieve stats card", async () => { 94 | expect(VERCEL_PREVIEW_URL).toBeDefined(); 95 | 96 | // Check if the Vercel preview instance stats card function is up and running. 97 | await expect( 98 | axios.get(`${VERCEL_PREVIEW_URL}/api?username=${USER}`), 99 | ).resolves.not.toThrow(); 100 | 101 | // Get local stats card. 102 | const localStatsCardSVG = renderStatsCard(STATS_DATA); 103 | 104 | // Get the Vercel preview stats card response. 105 | const serverStatsSvg = await axios.get( 106 | `${VERCEL_PREVIEW_URL}/api?username=${USER}&${CACHE_BURST_STRING}`, 107 | ); 108 | 109 | // Check if stats card from deployment matches the stats card from local. 110 | expect(serverStatsSvg.data).toEqual(localStatsCardSVG); 111 | }, 7000); 112 | 113 | test("retrieve language card", async () => { 114 | expect(VERCEL_PREVIEW_URL).toBeDefined(); 115 | 116 | // Check if the Vercel preview instance language card function is up and running. 117 | console.log( 118 | `${VERCEL_PREVIEW_URL}/api/top-langs/?username=${USER}&${CACHE_BURST_STRING}`, 119 | ); 120 | await expect( 121 | axios.get( 122 | `${VERCEL_PREVIEW_URL}/api/top-langs/?username=${USER}&${CACHE_BURST_STRING}`, 123 | ), 124 | ).resolves.not.toThrow(); 125 | 126 | // Get local language card. 127 | const localLanguageCardSVG = renderTopLanguages(LANGS_DATA); 128 | 129 | // Get the Vercel preview language card response. 130 | const severLanguageSVG = await axios.get( 131 | `${VERCEL_PREVIEW_URL}/api/top-langs/?username=${USER}&${CACHE_BURST_STRING}`, 132 | ); 133 | 134 | // Check if language card from deployment matches the local language card. 135 | expect(severLanguageSVG.data).toEqual(localLanguageCardSVG); 136 | }); 137 | 138 | test("retrieve WakaTime card", async () => { 139 | expect(VERCEL_PREVIEW_URL).toBeDefined(); 140 | 141 | // Check if the Vercel preview instance WakaTime function is up and running. 142 | await expect( 143 | axios.get(`${VERCEL_PREVIEW_URL}/api/wakatime?username=${USER}`), 144 | ).resolves.not.toThrow(); 145 | 146 | // Get local WakaTime card. 147 | const localWakaCardSVG = renderWakatimeCard(WAKATIME_DATA); 148 | 149 | // Get the Vercel preview WakaTime card response. 150 | const serverWakaTimeSvg = await axios.get( 151 | `${VERCEL_PREVIEW_URL}/api/wakatime?username=${USER}&${CACHE_BURST_STRING}`, 152 | ); 153 | 154 | // Check if WakaTime card from deployment matches the local WakaTime card. 155 | expect(serverWakaTimeSvg.data).toEqual(localWakaCardSVG); 156 | }); 157 | 158 | test("retrieve repo card", async () => { 159 | expect(VERCEL_PREVIEW_URL).toBeDefined(); 160 | 161 | // Check if the Vercel preview instance Repo function is up and running. 162 | await expect( 163 | axios.get( 164 | `${VERCEL_PREVIEW_URL}/api/pin/?username=${USER}&repo=${REPO}&${CACHE_BURST_STRING}`, 165 | ), 166 | ).resolves.not.toThrow(); 167 | 168 | // Get local repo card. 169 | const localRepoCardSVG = renderRepoCard(REPOSITORY_DATA); 170 | 171 | // Get the Vercel preview repo card response. 172 | const serverRepoSvg = await axios.get( 173 | `${VERCEL_PREVIEW_URL}/api/pin/?username=${USER}&repo=${REPO}&${CACHE_BURST_STRING}`, 174 | ); 175 | 176 | // Check if Repo card from deployment matches the local Repo card. 177 | expect(serverRepoSvg.data).toEqual(localRepoCardSVG); 178 | }); 179 | }); 180 | -------------------------------------------------------------------------------- /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 | 6 | const data_repo = { 7 | repository: { 8 | name: "convoychat", 9 | stargazers: { totalCount: 38000 }, 10 | description: "Help us take over the world! React + TS + GraphQL Chat App", 11 | primaryLanguage: { 12 | color: "#2b7489", 13 | id: "MDg6TGFuZ3VhZ2UyODc=", 14 | name: "TypeScript", 15 | }, 16 | forkCount: 100, 17 | }, 18 | }; 19 | 20 | const data_user = { 21 | data: { 22 | user: { repository: data_repo.repository }, 23 | organization: null, 24 | }, 25 | }; 26 | 27 | const data_org = { 28 | data: { 29 | user: null, 30 | organization: { repository: data_repo.repository }, 31 | }, 32 | }; 33 | 34 | const mock = new MockAdapter(axios); 35 | 36 | afterEach(() => { 37 | mock.reset(); 38 | }); 39 | 40 | describe("Test fetchRepo", () => { 41 | it("should fetch correct user repo", async () => { 42 | mock.onPost("https://api.github.com/graphql").reply(200, data_user); 43 | 44 | let repo = await fetchRepo("anuraghazra", "convoychat"); 45 | 46 | expect(repo).toStrictEqual({ 47 | ...data_repo.repository, 48 | starCount: data_repo.repository.stargazers.totalCount, 49 | }); 50 | }); 51 | 52 | it("should fetch correct org repo", async () => { 53 | mock.onPost("https://api.github.com/graphql").reply(200, data_org); 54 | 55 | let repo = await fetchRepo("anuraghazra", "convoychat"); 56 | expect(repo).toStrictEqual({ 57 | ...data_repo.repository, 58 | starCount: data_repo.repository.stargazers.totalCount, 59 | }); 60 | }); 61 | 62 | it("should throw error if user is found but repo is null", async () => { 63 | mock 64 | .onPost("https://api.github.com/graphql") 65 | .reply(200, { data: { user: { repository: null }, organization: null } }); 66 | 67 | await expect(fetchRepo("anuraghazra", "convoychat")).rejects.toThrow( 68 | "User Repository Not found", 69 | ); 70 | }); 71 | 72 | it("should throw error if org is found but repo is null", async () => { 73 | mock 74 | .onPost("https://api.github.com/graphql") 75 | .reply(200, { data: { user: null, organization: { repository: null } } }); 76 | 77 | await expect(fetchRepo("anuraghazra", "convoychat")).rejects.toThrow( 78 | "Organization Repository Not found", 79 | ); 80 | }); 81 | 82 | it("should throw error if both user & org data not found", async () => { 83 | mock 84 | .onPost("https://api.github.com/graphql") 85 | .reply(200, { data: { user: null, organization: null } }); 86 | 87 | await expect(fetchRepo("anuraghazra", "convoychat")).rejects.toThrow( 88 | "Not found", 89 | ); 90 | }); 91 | 92 | it("should throw error if repository is private", async () => { 93 | mock.onPost("https://api.github.com/graphql").reply(200, { 94 | data: { 95 | user: { repository: { ...data_repo, isPrivate: true } }, 96 | organization: null, 97 | }, 98 | }); 99 | 100 | await expect(fetchRepo("anuraghazra", "convoychat")).rejects.toThrow( 101 | "User Repository Not found", 102 | ); 103 | }); 104 | }); 105 | -------------------------------------------------------------------------------- /tests/fetchStats.test.js: -------------------------------------------------------------------------------- 1 | import "@testing-library/jest-dom"; 2 | import axios from "axios"; 3 | import MockAdapter from "axios-mock-adapter"; 4 | import { calculateRank } from "../src/calculateRank.js"; 5 | import { fetchStats } from "../src/fetchers/stats-fetcher.js"; 6 | 7 | // Test parameters. 8 | const data_stats = { 9 | data: { 10 | user: { 11 | name: "Anurag Hazra", 12 | repositoriesContributedTo: { totalCount: 61 }, 13 | contributionsCollection: { 14 | totalCommitContributions: 100, 15 | restrictedContributionsCount: 50, 16 | }, 17 | pullRequests: { totalCount: 300 }, 18 | openIssues: { totalCount: 100 }, 19 | closedIssues: { totalCount: 100 }, 20 | followers: { totalCount: 100 }, 21 | repositories: { 22 | totalCount: 5, 23 | nodes: [ 24 | { name: "test-repo-1", stargazers: { totalCount: 100 } }, 25 | { name: "test-repo-2", stargazers: { totalCount: 100 } }, 26 | { name: "test-repo-3", stargazers: { totalCount: 100 } }, 27 | ], 28 | pageInfo: { 29 | hasNextPage: true, 30 | endCursor: "cursor", 31 | }, 32 | }, 33 | }, 34 | }, 35 | }; 36 | 37 | const data_repo = { 38 | data: { 39 | user: { 40 | repositories: { 41 | nodes: [ 42 | { name: "test-repo-4", stargazers: { totalCount: 50 } }, 43 | { name: "test-repo-5", stargazers: { totalCount: 50 } }, 44 | ], 45 | pageInfo: { 46 | hasNextPage: false, 47 | endCursor: "cursor", 48 | }, 49 | }, 50 | }, 51 | }, 52 | }; 53 | 54 | const data_repo_zero_stars = { 55 | data: { 56 | user: { 57 | repositories: { 58 | nodes: [ 59 | { name: "test-repo-1", stargazers: { totalCount: 100 } }, 60 | { name: "test-repo-2", stargazers: { totalCount: 100 } }, 61 | { name: "test-repo-3", stargazers: { totalCount: 100 } }, 62 | { name: "test-repo-4", stargazers: { totalCount: 0 } }, 63 | { name: "test-repo-5", stargazers: { totalCount: 0 } }, 64 | ], 65 | pageInfo: { 66 | hasNextPage: true, 67 | endCursor: "cursor", 68 | }, 69 | }, 70 | }, 71 | }, 72 | }; 73 | 74 | const error = { 75 | errors: [ 76 | { 77 | type: "NOT_FOUND", 78 | path: ["user"], 79 | locations: [], 80 | message: "Could not resolve to a User with the login of 'noname'.", 81 | }, 82 | ], 83 | }; 84 | 85 | const mock = new MockAdapter(axios); 86 | 87 | beforeEach(() => { 88 | process.env.FETCH_MULTI_PAGE_STARS = "false"; // Set to `false` to fetch only one page of stars. 89 | mock.onPost("https://api.github.com/graphql").reply((cfg) => { 90 | return [ 91 | 200, 92 | cfg.data.includes("contributionsCollection") ? data_stats : data_repo, 93 | ]; 94 | }); 95 | }); 96 | 97 | afterEach(() => { 98 | mock.reset(); 99 | }); 100 | 101 | describe("Test fetchStats", () => { 102 | it("should fetch correct stats", async () => { 103 | let stats = await fetchStats("anuraghazra"); 104 | const rank = calculateRank({ 105 | totalCommits: 100, 106 | totalRepos: 5, 107 | followers: 100, 108 | contributions: 61, 109 | stargazers: 300, 110 | prs: 300, 111 | issues: 200, 112 | }); 113 | 114 | expect(stats).toStrictEqual({ 115 | contributedTo: 61, 116 | name: "Anurag Hazra", 117 | totalCommits: 100, 118 | totalIssues: 200, 119 | totalPRs: 300, 120 | totalStars: 300, 121 | rank, 122 | }); 123 | }); 124 | 125 | it("should stop fetching when there are repos with zero stars", async () => { 126 | mock.reset(); 127 | mock 128 | .onPost("https://api.github.com/graphql") 129 | .replyOnce(200, data_stats) 130 | .onPost("https://api.github.com/graphql") 131 | .replyOnce(200, data_repo_zero_stars); 132 | 133 | let stats = await fetchStats("anuraghazra"); 134 | const rank = calculateRank({ 135 | totalCommits: 100, 136 | totalRepos: 5, 137 | followers: 100, 138 | contributions: 61, 139 | stargazers: 300, 140 | prs: 300, 141 | issues: 200, 142 | }); 143 | 144 | expect(stats).toStrictEqual({ 145 | contributedTo: 61, 146 | name: "Anurag Hazra", 147 | totalCommits: 100, 148 | totalIssues: 200, 149 | totalPRs: 300, 150 | totalStars: 300, 151 | rank, 152 | }); 153 | }); 154 | 155 | it("should throw error", async () => { 156 | mock.reset(); 157 | mock.onPost("https://api.github.com/graphql").reply(200, error); 158 | 159 | await expect(fetchStats("anuraghazra")).rejects.toThrow( 160 | "Could not resolve to a User with the login of 'noname'.", 161 | ); 162 | }); 163 | 164 | it("should fetch and add private contributions", async () => { 165 | let stats = await fetchStats("anuraghazra", true); 166 | const rank = calculateRank({ 167 | totalCommits: 150, 168 | totalRepos: 5, 169 | followers: 100, 170 | contributions: 61, 171 | stargazers: 300, 172 | prs: 300, 173 | issues: 200, 174 | }); 175 | 176 | expect(stats).toStrictEqual({ 177 | contributedTo: 61, 178 | name: "Anurag Hazra", 179 | totalCommits: 150, 180 | totalIssues: 200, 181 | totalPRs: 300, 182 | totalStars: 300, 183 | rank, 184 | }); 185 | }); 186 | 187 | it("should fetch total commits", async () => { 188 | mock 189 | .onGet("https://api.github.com/search/commits?q=author:anuraghazra") 190 | .reply(200, { total_count: 1000 }); 191 | 192 | let stats = await fetchStats("anuraghazra", true, true); 193 | const rank = calculateRank({ 194 | totalCommits: 1050, 195 | totalRepos: 5, 196 | followers: 100, 197 | contributions: 61, 198 | stargazers: 300, 199 | prs: 300, 200 | issues: 200, 201 | }); 202 | 203 | expect(stats).toStrictEqual({ 204 | contributedTo: 61, 205 | name: "Anurag Hazra", 206 | totalCommits: 1050, 207 | totalIssues: 200, 208 | totalPRs: 300, 209 | totalStars: 300, 210 | rank, 211 | }); 212 | }); 213 | 214 | it("should exclude stars of the `test-repo-1` repository", async () => { 215 | mock 216 | .onGet("https://api.github.com/search/commits?q=author:anuraghazra") 217 | .reply(200, { total_count: 1000 }); 218 | 219 | let stats = await fetchStats("anuraghazra", true, true, ["test-repo-1"]); 220 | const rank = calculateRank({ 221 | totalCommits: 1050, 222 | totalRepos: 5, 223 | followers: 100, 224 | contributions: 61, 225 | stargazers: 200, 226 | prs: 300, 227 | issues: 200, 228 | }); 229 | 230 | expect(stats).toStrictEqual({ 231 | contributedTo: 61, 232 | name: "Anurag Hazra", 233 | totalCommits: 1050, 234 | totalIssues: 200, 235 | totalPRs: 300, 236 | totalStars: 200, 237 | rank, 238 | }); 239 | }); 240 | 241 | it("should fetch two pages of stars if 'FETCH_MULTI_PAGE_STARS' env variable is set to `true`", async () => { 242 | process.env.FETCH_MULTI_PAGE_STARS = true; 243 | 244 | let stats = await fetchStats("anuraghazra"); 245 | const rank = calculateRank({ 246 | totalCommits: 100, 247 | totalRepos: 5, 248 | followers: 100, 249 | contributions: 61, 250 | stargazers: 400, 251 | prs: 300, 252 | issues: 200, 253 | }); 254 | 255 | expect(stats).toStrictEqual({ 256 | contributedTo: 61, 257 | name: "Anurag Hazra", 258 | totalCommits: 100, 259 | totalIssues: 200, 260 | totalPRs: 300, 261 | totalStars: 400, 262 | rank, 263 | }); 264 | }); 265 | 266 | it("should fetch one page of stars if 'FETCH_MULTI_PAGE_STARS' env variable is set to `false`", async () => { 267 | process.env.FETCH_MULTI_PAGE_STARS = "false"; 268 | 269 | let stats = await fetchStats("anuraghazra"); 270 | const rank = calculateRank({ 271 | totalCommits: 100, 272 | totalRepos: 5, 273 | followers: 100, 274 | contributions: 61, 275 | stargazers: 300, 276 | prs: 300, 277 | issues: 200, 278 | }); 279 | 280 | expect(stats).toStrictEqual({ 281 | contributedTo: 61, 282 | name: "Anurag Hazra", 283 | totalCommits: 100, 284 | totalIssues: 200, 285 | totalPRs: 300, 286 | totalStars: 300, 287 | rank, 288 | }); 289 | }); 290 | 291 | it("should fetch one page of stars if 'FETCH_MULTI_PAGE_STARS' env variable is not set", async () => { 292 | process.env.FETCH_MULTI_PAGE_STARS = undefined; 293 | 294 | let stats = await fetchStats("anuraghazra"); 295 | const rank = calculateRank({ 296 | totalCommits: 100, 297 | totalRepos: 5, 298 | followers: 100, 299 | contributions: 61, 300 | stargazers: 300, 301 | prs: 300, 302 | issues: 200, 303 | }); 304 | 305 | expect(stats).toStrictEqual({ 306 | contributedTo: 61, 307 | name: "Anurag Hazra", 308 | totalCommits: 100, 309 | totalIssues: 200, 310 | totalPRs: 300, 311 | totalStars: 300, 312 | rank, 313 | }); 314 | }); 315 | }); 316 | -------------------------------------------------------------------------------- /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 | 6 | const mock = new MockAdapter(axios); 7 | 8 | afterEach(() => { 9 | mock.reset(); 10 | }); 11 | 12 | const data_langs = { 13 | data: { 14 | user: { 15 | repositories: { 16 | nodes: [ 17 | { 18 | name: "test-repo-1", 19 | languages: { 20 | edges: [{ size: 100, node: { color: "#0f0", name: "HTML" } }], 21 | }, 22 | }, 23 | { 24 | name: "test-repo-2", 25 | languages: { 26 | edges: [{ size: 100, node: { color: "#0f0", name: "HTML" } }], 27 | }, 28 | }, 29 | { 30 | name: "test-repo-3", 31 | languages: { 32 | edges: [ 33 | { size: 100, node: { color: "#0ff", name: "javascript" } }, 34 | ], 35 | }, 36 | }, 37 | { 38 | name: "test-repo-4", 39 | languages: { 40 | edges: [ 41 | { size: 100, node: { color: "#0ff", name: "javascript" } }, 42 | ], 43 | }, 44 | }, 45 | ], 46 | }, 47 | }, 48 | }, 49 | }; 50 | 51 | const error = { 52 | errors: [ 53 | { 54 | type: "NOT_FOUND", 55 | path: ["user"], 56 | locations: [], 57 | message: "Could not resolve to a User with the login of 'noname'.", 58 | }, 59 | ], 60 | }; 61 | 62 | describe("FetchTopLanguages", () => { 63 | it("should fetch correct language data while using the new calculation", async () => { 64 | mock.onPost("https://api.github.com/graphql").reply(200, data_langs); 65 | 66 | let repo = await fetchTopLanguages("anuraghazra", [], 0.5, 0.5); 67 | expect(repo).toStrictEqual({ 68 | HTML: { 69 | color: "#0f0", 70 | count: 2, 71 | name: "HTML", 72 | size: 20.000000000000004, 73 | }, 74 | javascript: { 75 | color: "#0ff", 76 | count: 2, 77 | name: "javascript", 78 | size: 20.000000000000004, 79 | }, 80 | }); 81 | }); 82 | 83 | it("should fetch correct language data while excluding the 'test-repo-1' repository", async () => { 84 | mock.onPost("https://api.github.com/graphql").reply(200, data_langs); 85 | 86 | let repo = await fetchTopLanguages("anuraghazra", ["test-repo-1"]); 87 | expect(repo).toStrictEqual({ 88 | HTML: { 89 | color: "#0f0", 90 | count: 1, 91 | name: "HTML", 92 | size: 100, 93 | }, 94 | javascript: { 95 | color: "#0ff", 96 | count: 2, 97 | name: "javascript", 98 | size: 200, 99 | }, 100 | }); 101 | }); 102 | 103 | it("should fetch correct language data while using the old calculation", async () => { 104 | mock.onPost("https://api.github.com/graphql").reply(200, data_langs); 105 | 106 | let repo = await fetchTopLanguages("anuraghazra", [], 1, 0); 107 | expect(repo).toStrictEqual({ 108 | HTML: { 109 | color: "#0f0", 110 | count: 2, 111 | name: "HTML", 112 | size: 200, 113 | }, 114 | javascript: { 115 | color: "#0ff", 116 | count: 2, 117 | name: "javascript", 118 | size: 200, 119 | }, 120 | }); 121 | }); 122 | 123 | it("should rank languages by the number of repositories they appear in", async () => { 124 | mock.onPost("https://api.github.com/graphql").reply(200, data_langs); 125 | 126 | let repo = await fetchTopLanguages("anuraghazra", [], 0, 1); 127 | expect(repo).toStrictEqual({ 128 | HTML: { 129 | color: "#0f0", 130 | count: 2, 131 | name: "HTML", 132 | size: 2, 133 | }, 134 | javascript: { 135 | color: "#0ff", 136 | count: 2, 137 | name: "javascript", 138 | size: 2, 139 | }, 140 | }); 141 | }); 142 | 143 | it("should throw error", async () => { 144 | mock.onPost("https://api.github.com/graphql").reply(200, error); 145 | 146 | await expect(fetchTopLanguages("anuraghazra")).rejects.toThrow( 147 | "Could not resolve to a User with the login of 'noname'.", 148 | ); 149 | }); 150 | }); 151 | -------------------------------------------------------------------------------- /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 | const mock = new MockAdapter(axios); 6 | 7 | afterEach(() => { 8 | mock.reset(); 9 | }); 10 | 11 | const wakaTimeData = { 12 | data: { 13 | categories: [ 14 | { 15 | digital: "22:40", 16 | hours: 22, 17 | minutes: 40, 18 | name: "Coding", 19 | percent: 100, 20 | text: "22 hrs 40 mins", 21 | total_seconds: 81643.570077, 22 | }, 23 | ], 24 | daily_average: 16095, 25 | daily_average_including_other_language: 16329, 26 | days_including_holidays: 7, 27 | days_minus_holidays: 5, 28 | editors: [ 29 | { 30 | digital: "22:40", 31 | hours: 22, 32 | minutes: 40, 33 | name: "VS Code", 34 | percent: 100, 35 | text: "22 hrs 40 mins", 36 | total_seconds: 81643.570077, 37 | }, 38 | ], 39 | holidays: 2, 40 | human_readable_daily_average: "4 hrs 28 mins", 41 | human_readable_daily_average_including_other_language: "4 hrs 32 mins", 42 | human_readable_total: "22 hrs 21 mins", 43 | human_readable_total_including_other_language: "22 hrs 40 mins", 44 | id: "random hash", 45 | is_already_updating: false, 46 | is_coding_activity_visible: true, 47 | is_including_today: false, 48 | is_other_usage_visible: true, 49 | is_stuck: false, 50 | is_up_to_date: true, 51 | languages: [ 52 | { 53 | digital: "0:19", 54 | hours: 0, 55 | minutes: 19, 56 | name: "Other", 57 | percent: 1.43, 58 | text: "19 mins", 59 | total_seconds: 1170.434361, 60 | }, 61 | { 62 | digital: "0:01", 63 | hours: 0, 64 | minutes: 1, 65 | name: "TypeScript", 66 | percent: 0.1, 67 | text: "1 min", 68 | total_seconds: 83.293809, 69 | }, 70 | { 71 | digital: "0:00", 72 | hours: 0, 73 | minutes: 0, 74 | name: "YAML", 75 | percent: 0.07, 76 | text: "0 secs", 77 | total_seconds: 54.975151, 78 | }, 79 | ], 80 | operating_systems: [ 81 | { 82 | digital: "22:40", 83 | hours: 22, 84 | minutes: 40, 85 | name: "Mac", 86 | percent: 100, 87 | text: "22 hrs 40 mins", 88 | total_seconds: 81643.570077, 89 | }, 90 | ], 91 | percent_calculated: 100, 92 | range: "last_7_days", 93 | status: "ok", 94 | timeout: 15, 95 | total_seconds: 80473.135716, 96 | total_seconds_including_other_language: 81643.570077, 97 | user_id: "random hash", 98 | username: "anuraghazra", 99 | writes_only: false, 100 | }, 101 | }; 102 | 103 | describe("Wakatime fetcher", () => { 104 | it("should fetch correct wakatime data", async () => { 105 | const username = "anuraghazra"; 106 | mock 107 | .onGet( 108 | `https://wakatime.com/api/v1/users/${username}/stats/all_time?is_including_today=true`, 109 | ) 110 | .reply(200, wakaTimeData); 111 | 112 | const repo = await fetchWakatimeStats({ username }); 113 | expect(repo).toMatchInlineSnapshot(` 114 | { 115 | "categories": [ 116 | { 117 | "digital": "22:40", 118 | "hours": 22, 119 | "minutes": 40, 120 | "name": "Coding", 121 | "percent": 100, 122 | "text": "22 hrs 40 mins", 123 | "total_seconds": 81643.570077, 124 | }, 125 | ], 126 | "daily_average": 16095, 127 | "daily_average_including_other_language": 16329, 128 | "days_including_holidays": 7, 129 | "days_minus_holidays": 5, 130 | "editors": [ 131 | { 132 | "digital": "22:40", 133 | "hours": 22, 134 | "minutes": 40, 135 | "name": "VS Code", 136 | "percent": 100, 137 | "text": "22 hrs 40 mins", 138 | "total_seconds": 81643.570077, 139 | }, 140 | ], 141 | "holidays": 2, 142 | "human_readable_daily_average": "4 hrs 28 mins", 143 | "human_readable_daily_average_including_other_language": "4 hrs 32 mins", 144 | "human_readable_total": "22 hrs 21 mins", 145 | "human_readable_total_including_other_language": "22 hrs 40 mins", 146 | "id": "random hash", 147 | "is_already_updating": false, 148 | "is_coding_activity_visible": true, 149 | "is_including_today": false, 150 | "is_other_usage_visible": true, 151 | "is_stuck": false, 152 | "is_up_to_date": true, 153 | "languages": [ 154 | { 155 | "digital": "0:19", 156 | "hours": 0, 157 | "minutes": 19, 158 | "name": "Other", 159 | "percent": 1.43, 160 | "text": "19 mins", 161 | "total_seconds": 1170.434361, 162 | }, 163 | { 164 | "digital": "0:01", 165 | "hours": 0, 166 | "minutes": 1, 167 | "name": "TypeScript", 168 | "percent": 0.1, 169 | "text": "1 min", 170 | "total_seconds": 83.293809, 171 | }, 172 | { 173 | "digital": "0:00", 174 | "hours": 0, 175 | "minutes": 0, 176 | "name": "YAML", 177 | "percent": 0.07, 178 | "text": "0 secs", 179 | "total_seconds": 54.975151, 180 | }, 181 | ], 182 | "operating_systems": [ 183 | { 184 | "digital": "22:40", 185 | "hours": 22, 186 | "minutes": 40, 187 | "name": "Mac", 188 | "percent": 100, 189 | "text": "22 hrs 40 mins", 190 | "total_seconds": 81643.570077, 191 | }, 192 | ], 193 | "percent_calculated": 100, 194 | "range": "last_7_days", 195 | "status": "ok", 196 | "timeout": 15, 197 | "total_seconds": 80473.135716, 198 | "total_seconds_including_other_language": 81643.570077, 199 | "user_id": "random hash", 200 | "username": "anuraghazra", 201 | "writes_only": false, 202 | } 203 | `); 204 | }); 205 | 206 | it("should throw error", async () => { 207 | mock.onGet(/\/https:\/\/wakatime\.com\/api/).reply(404, wakaTimeData); 208 | 209 | await expect(fetchWakatimeStats("noone")).rejects.toThrow( 210 | 'Missing params "username" make sure you pass the parameters in URL', 211 | ); 212 | }); 213 | }); 214 | 215 | export { wakaTimeData }; 216 | -------------------------------------------------------------------------------- /tests/flexLayout.test.js: -------------------------------------------------------------------------------- 1 | import { flexLayout } from "../src/common/utils.js"; 2 | 3 | describe("flexLayout", () => { 4 | it("should work with row & col layouts", () => { 5 | const layout = flexLayout({ 6 | items: ["1", "2"], 7 | gap: 60, 8 | }); 9 | 10 | expect(layout).toStrictEqual([ 11 | `1`, 12 | `2`, 13 | ]); 14 | 15 | const columns = flexLayout({ 16 | items: ["1", "2"], 17 | gap: 60, 18 | direction: "column", 19 | }); 20 | 21 | expect(columns).toStrictEqual([ 22 | `1`, 23 | `2`, 24 | ]); 25 | }); 26 | 27 | it("should work with sizes", () => { 28 | const layout = flexLayout({ 29 | items: [ 30 | "1", 31 | "2", 32 | "3", 33 | "4", 34 | ], 35 | gap: 20, 36 | sizes: [200, 100, 55, 25], 37 | }); 38 | 39 | expect(layout).toStrictEqual([ 40 | `1`, 41 | `2`, 42 | `3`, 43 | `4`, 44 | ]); 45 | }); 46 | }); 47 | -------------------------------------------------------------------------------- /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 | 12 | const mock = new MockAdapter(axios); 13 | 14 | const successData = { 15 | data: { 16 | rateLimit: { 17 | remaining: 4986, 18 | }, 19 | }, 20 | }; 21 | 22 | const faker = (query) => { 23 | const req = { 24 | query: { ...query }, 25 | }; 26 | const res = { 27 | setHeader: jest.fn(), 28 | send: jest.fn(), 29 | }; 30 | 31 | return { req, res }; 32 | }; 33 | 34 | const rate_limit_error = { 35 | errors: [ 36 | { 37 | type: "RATE_LIMITED", 38 | message: "API rate limit exceeded for user ID.", 39 | }, 40 | ], 41 | data: { 42 | rateLimit: { 43 | resetAt: Date.now(), 44 | }, 45 | }, 46 | }; 47 | 48 | const other_error = { 49 | errors: [ 50 | { 51 | type: "SOME_ERROR", 52 | message: "This is a error", 53 | }, 54 | ], 55 | }; 56 | 57 | const bad_credentials_error = { 58 | message: "Bad credentials", 59 | }; 60 | 61 | afterEach(() => { 62 | mock.reset(); 63 | }); 64 | 65 | describe("Test /api/status/pat-info", () => { 66 | beforeAll(() => { 67 | // reset patenv first so that dotenv doesn't populate them with local envs 68 | process.env = {}; 69 | process.env.PAT_1 = "testPAT1"; 70 | process.env.PAT_2 = "testPAT2"; 71 | process.env.PAT_3 = "testPAT3"; 72 | process.env.PAT_4 = "testPAT4"; 73 | }); 74 | 75 | it("should return only 'validPATs' if all PATs are valid", async () => { 76 | mock 77 | .onPost("https://api.github.com/graphql") 78 | .replyOnce(200, rate_limit_error) 79 | .onPost("https://api.github.com/graphql") 80 | .reply(200, successData); 81 | 82 | const { req, res } = faker({}, {}); 83 | await patInfo(req, res); 84 | 85 | expect(res.setHeader).toBeCalledWith("Content-Type", "application/json"); 86 | expect(res.send).toBeCalledWith( 87 | JSON.stringify( 88 | { 89 | validPATs: ["PAT_2", "PAT_3", "PAT_4"], 90 | expiredPATs: [], 91 | exhaustedPATs: ["PAT_1"], 92 | suspendedPATs: [], 93 | errorPATs: [], 94 | details: { 95 | PAT_1: { 96 | status: "exhausted", 97 | remaining: 0, 98 | resetIn: "0 minutes", 99 | }, 100 | PAT_2: { 101 | status: "valid", 102 | remaining: 4986, 103 | }, 104 | PAT_3: { 105 | status: "valid", 106 | remaining: 4986, 107 | }, 108 | PAT_4: { 109 | status: "valid", 110 | remaining: 4986, 111 | }, 112 | }, 113 | }, 114 | null, 115 | 2, 116 | ), 117 | ); 118 | }); 119 | 120 | it("should return `errorPATs` if a PAT causes an error to be thrown", async () => { 121 | mock 122 | .onPost("https://api.github.com/graphql") 123 | .replyOnce(200, other_error) 124 | .onPost("https://api.github.com/graphql") 125 | .reply(200, successData); 126 | 127 | const { req, res } = faker({}, {}); 128 | await patInfo(req, res); 129 | 130 | expect(res.setHeader).toBeCalledWith("Content-Type", "application/json"); 131 | expect(res.send).toBeCalledWith( 132 | JSON.stringify( 133 | { 134 | validPATs: ["PAT_2", "PAT_3", "PAT_4"], 135 | expiredPATs: [], 136 | exhaustedPATs: [], 137 | suspendedPATs: [], 138 | errorPATs: ["PAT_1"], 139 | details: { 140 | PAT_1: { 141 | status: "error", 142 | error: { 143 | type: "SOME_ERROR", 144 | message: "This is a error", 145 | }, 146 | }, 147 | PAT_2: { 148 | status: "valid", 149 | remaining: 4986, 150 | }, 151 | PAT_3: { 152 | status: "valid", 153 | remaining: 4986, 154 | }, 155 | PAT_4: { 156 | status: "valid", 157 | remaining: 4986, 158 | }, 159 | }, 160 | }, 161 | null, 162 | 2, 163 | ), 164 | ); 165 | }); 166 | 167 | it("should return `expiredPaths` if a PAT returns a 'Bad credentials' error", async () => { 168 | mock 169 | .onPost("https://api.github.com/graphql") 170 | .replyOnce(404, bad_credentials_error) 171 | .onPost("https://api.github.com/graphql") 172 | .reply(200, successData); 173 | 174 | const { req, res } = faker({}, {}); 175 | await patInfo(req, res); 176 | 177 | expect(res.setHeader).toBeCalledWith("Content-Type", "application/json"); 178 | expect(res.send).toBeCalledWith( 179 | JSON.stringify( 180 | { 181 | validPATs: ["PAT_2", "PAT_3", "PAT_4"], 182 | expiredPATs: ["PAT_1"], 183 | exhaustedPATs: [], 184 | suspendedPATs: [], 185 | errorPATs: [], 186 | details: { 187 | PAT_1: { 188 | status: "expired", 189 | }, 190 | PAT_2: { 191 | status: "valid", 192 | remaining: 4986, 193 | }, 194 | PAT_3: { 195 | status: "valid", 196 | remaining: 4986, 197 | }, 198 | PAT_4: { 199 | status: "valid", 200 | remaining: 4986, 201 | }, 202 | }, 203 | }, 204 | null, 205 | 2, 206 | ), 207 | ); 208 | }); 209 | 210 | it("should throw an error if something goes wrong", async () => { 211 | mock.onPost("https://api.github.com/graphql").networkError(); 212 | 213 | const { req, res } = faker({}, {}); 214 | await patInfo(req, res); 215 | 216 | expect(res.setHeader).toBeCalledWith("Content-Type", "application/json"); 217 | expect(res.send).toBeCalledWith("Something went wrong: Network Error"); 218 | }); 219 | 220 | it("should have proper cache when no error is thrown", async () => { 221 | mock.onPost("https://api.github.com/graphql").reply(200, successData); 222 | 223 | const { req, res } = faker({}, {}); 224 | await patInfo(req, res); 225 | 226 | expect(res.setHeader.mock.calls).toEqual([ 227 | ["Content-Type", "application/json"], 228 | ["Cache-Control", `max-age=0, s-maxage=${RATE_LIMIT_SECONDS}`], 229 | ]); 230 | }); 231 | 232 | it("should have proper cache when error is thrown", async () => { 233 | mock.reset(); 234 | mock.onPost("https://api.github.com/graphql").networkError(); 235 | 236 | const { req, res } = faker({}, {}); 237 | await patInfo(req, res); 238 | 239 | expect(res.setHeader.mock.calls).toEqual([ 240 | ["Content-Type", "application/json"], 241 | ["Cache-Control", "no-store"], 242 | ]); 243 | }); 244 | }); 245 | -------------------------------------------------------------------------------- /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 } from "../src/common/utils.js"; 8 | 9 | const data_repo = { 10 | repository: { 11 | username: "anuraghazra", 12 | name: "convoychat", 13 | stargazers: { 14 | totalCount: 38000, 15 | }, 16 | description: "Help us take over the world! React + TS + GraphQL Chat App", 17 | primaryLanguage: { 18 | color: "#2b7489", 19 | id: "MDg6TGFuZ3VhZ2UyODc=", 20 | name: "TypeScript", 21 | }, 22 | forkCount: 100, 23 | isTemplate: false, 24 | }, 25 | }; 26 | 27 | const data_user = { 28 | data: { 29 | user: { repository: data_repo.repository }, 30 | organization: null, 31 | }, 32 | }; 33 | 34 | const mock = new MockAdapter(axios); 35 | 36 | afterEach(() => { 37 | mock.reset(); 38 | }); 39 | 40 | describe("Test /api/pin", () => { 41 | it("should test the request", async () => { 42 | const req = { 43 | query: { 44 | username: "anuraghazra", 45 | repo: "convoychat", 46 | }, 47 | }; 48 | const res = { 49 | setHeader: jest.fn(), 50 | send: jest.fn(), 51 | }; 52 | mock.onPost("https://api.github.com/graphql").reply(200, data_user); 53 | 54 | await pin(req, res); 55 | 56 | expect(res.setHeader).toBeCalledWith("Content-Type", "image/svg+xml"); 57 | expect(res.send).toBeCalledWith( 58 | renderRepoCard({ 59 | ...data_repo.repository, 60 | starCount: data_repo.repository.stargazers.totalCount, 61 | }), 62 | ); 63 | }); 64 | 65 | it("should get the query options", async () => { 66 | const req = { 67 | query: { 68 | username: "anuraghazra", 69 | repo: "convoychat", 70 | title_color: "fff", 71 | icon_color: "fff", 72 | text_color: "fff", 73 | bg_color: "fff", 74 | full_name: "1", 75 | }, 76 | }; 77 | const res = { 78 | setHeader: jest.fn(), 79 | send: jest.fn(), 80 | }; 81 | mock.onPost("https://api.github.com/graphql").reply(200, data_user); 82 | 83 | await pin(req, res); 84 | 85 | expect(res.setHeader).toBeCalledWith("Content-Type", "image/svg+xml"); 86 | expect(res.send).toBeCalledWith( 87 | renderRepoCard( 88 | { 89 | ...data_repo.repository, 90 | starCount: data_repo.repository.stargazers.totalCount, 91 | }, 92 | { ...req.query }, 93 | ), 94 | ); 95 | }); 96 | 97 | it("should render error card if user repo not found", async () => { 98 | const req = { 99 | query: { 100 | username: "anuraghazra", 101 | repo: "convoychat", 102 | }, 103 | }; 104 | const res = { 105 | setHeader: jest.fn(), 106 | send: jest.fn(), 107 | }; 108 | mock 109 | .onPost("https://api.github.com/graphql") 110 | .reply(200, { data: { user: { repository: null }, organization: null } }); 111 | 112 | await pin(req, res); 113 | 114 | expect(res.setHeader).toBeCalledWith("Content-Type", "image/svg+xml"); 115 | expect(res.send).toBeCalledWith(renderError("User Repository Not found")); 116 | }); 117 | 118 | it("should render error card if org repo not found", async () => { 119 | const req = { 120 | query: { 121 | username: "anuraghazra", 122 | repo: "convoychat", 123 | }, 124 | }; 125 | const res = { 126 | setHeader: jest.fn(), 127 | send: jest.fn(), 128 | }; 129 | mock 130 | .onPost("https://api.github.com/graphql") 131 | .reply(200, { data: { user: null, organization: { repository: null } } }); 132 | 133 | await pin(req, res); 134 | 135 | expect(res.setHeader).toBeCalledWith("Content-Type", "image/svg+xml"); 136 | expect(res.send).toBeCalledWith( 137 | renderError("Organization Repository Not found"), 138 | ); 139 | }); 140 | }); 141 | -------------------------------------------------------------------------------- /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 | 7 | describe("Test Render Wakatime Card", () => { 8 | it("should render correctly", () => { 9 | const card = renderWakatimeCard(wakaTimeData.data); 10 | expect(getCardColors).toMatchSnapshot(); 11 | }); 12 | 13 | it("should render correctly with compact layout", () => { 14 | const card = renderWakatimeCard(wakaTimeData.data, { layout: "compact" }); 15 | 16 | expect(card).toMatchSnapshot(); 17 | }); 18 | 19 | it("should render correctly with compact layout when langs_count is set", () => { 20 | const card = renderWakatimeCard(wakaTimeData.data, { 21 | layout: "compact", 22 | langs_count: 2, 23 | }); 24 | 25 | expect(card).toMatchSnapshot(); 26 | }); 27 | 28 | it("should hide languages when hide is passed", () => { 29 | document.body.innerHTML = renderWakatimeCard(wakaTimeData.data, { 30 | hide: ["YAML", "Other"], 31 | }); 32 | 33 | expect(queryByTestId(document.body, /YAML/i)).toBeNull(); 34 | expect(queryByTestId(document.body, /Other/i)).toBeNull(); 35 | expect(queryByTestId(document.body, /TypeScript/i)).not.toBeNull(); 36 | }); 37 | 38 | it("should render translations", () => { 39 | document.body.innerHTML = renderWakatimeCard({}, { locale: "cn" }); 40 | expect(document.getElementsByClassName("header")[0].textContent).toBe( 41 | "Wakatime 周统计", 42 | ); 43 | expect( 44 | document.querySelector('g[transform="translate(0, 0)"]>text.stat.bold') 45 | .textContent, 46 | ).toBe("本周没有编程活动"); 47 | }); 48 | 49 | it("should render without rounding", () => { 50 | document.body.innerHTML = renderWakatimeCard(wakaTimeData.data, { 51 | border_radius: "0", 52 | }); 53 | expect(document.querySelector("rect")).toHaveAttribute("rx", "0"); 54 | document.body.innerHTML = renderWakatimeCard(wakaTimeData.data, {}); 55 | expect(document.querySelector("rect")).toHaveAttribute("rx", "4.5"); 56 | }); 57 | 58 | it('should show "no coding activity this week" message when there has not been activity', () => { 59 | document.body.innerHTML = renderWakatimeCard( 60 | { 61 | ...wakaTimeData.data, 62 | languages: undefined, 63 | }, 64 | {}, 65 | ); 66 | expect(document.querySelector(".stat").textContent).toBe( 67 | "No coding activity this week", 68 | ); 69 | }); 70 | 71 | it('should show "no coding activity this week" message when using compact layout and there has not been activity', () => { 72 | document.body.innerHTML = renderWakatimeCard( 73 | { 74 | ...wakaTimeData.data, 75 | languages: undefined, 76 | }, 77 | { 78 | layout: "compact", 79 | }, 80 | ); 81 | expect(document.querySelector(".stat").textContent).toBe( 82 | "No coding activity this week", 83 | ); 84 | }); 85 | }); 86 | -------------------------------------------------------------------------------- /tests/retryer.test.js: -------------------------------------------------------------------------------- 1 | import { jest } from "@jest/globals"; 2 | import "@testing-library/jest-dom"; 3 | import { retryer } from "../src/common/retryer.js"; 4 | import { logger } from "../src/common/utils.js"; 5 | 6 | const fetcher = jest.fn((variables, token) => { 7 | logger.log(variables, token); 8 | return new Promise((res, rej) => res({ data: "ok" })); 9 | }); 10 | 11 | const fetcherFail = jest.fn(() => { 12 | return new Promise((res, rej) => 13 | res({ data: { errors: [{ type: "RATE_LIMITED" }] } }), 14 | ); 15 | }); 16 | 17 | const fetcherFailOnSecondTry = jest.fn((_vars, _token, retries) => { 18 | return new Promise((res, rej) => { 19 | // faking rate limit 20 | if (retries < 1) { 21 | return res({ data: { errors: [{ type: "RATE_LIMITED" }] } }); 22 | } 23 | return res({ data: "ok" }); 24 | }); 25 | }); 26 | 27 | describe("Test Retryer", () => { 28 | it("retryer should return value and have zero retries on first try", async () => { 29 | let res = await retryer(fetcher, {}); 30 | 31 | expect(fetcher).toBeCalledTimes(1); 32 | expect(res).toStrictEqual({ data: "ok" }); 33 | }); 34 | 35 | it("retryer should return value and have 2 retries", async () => { 36 | let res = await retryer(fetcherFailOnSecondTry, {}); 37 | 38 | expect(fetcherFailOnSecondTry).toBeCalledTimes(2); 39 | expect(res).toStrictEqual({ data: "ok" }); 40 | }); 41 | 42 | it("retryer should throw error if maximum retries reached", async () => { 43 | let res; 44 | 45 | try { 46 | res = await retryer(fetcherFail, {}); 47 | } catch (err) { 48 | expect(fetcherFail).toBeCalledTimes(8); 49 | expect(err.message).toBe("Maximum retries exceeded"); 50 | } 51 | }); 52 | }); 53 | -------------------------------------------------------------------------------- /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 | 9 | const mock = new MockAdapter(axios); 10 | 11 | const successData = { 12 | rateLimit: { 13 | remaining: 4986, 14 | }, 15 | }; 16 | 17 | const faker = (query) => { 18 | const req = { 19 | query: { ...query }, 20 | }; 21 | const res = { 22 | setHeader: jest.fn(), 23 | send: jest.fn(), 24 | }; 25 | 26 | return { req, res }; 27 | }; 28 | 29 | const rate_limit_error = { 30 | errors: [ 31 | { 32 | type: "RATE_LIMITED", 33 | }, 34 | ], 35 | }; 36 | 37 | const bad_credentials_error = { 38 | message: "Bad credentials", 39 | }; 40 | 41 | const shields_up = { 42 | schemaVersion: 1, 43 | label: "Public Instance", 44 | isError: true, 45 | message: "up", 46 | color: "brightgreen", 47 | }; 48 | const shields_down = { 49 | schemaVersion: 1, 50 | label: "Public Instance", 51 | isError: true, 52 | message: "down", 53 | color: "red", 54 | }; 55 | 56 | afterEach(() => { 57 | mock.reset(); 58 | }); 59 | 60 | describe("Test /api/status/up", () => { 61 | it("should return `true` if request was successful", async () => { 62 | mock.onPost("https://api.github.com/graphql").replyOnce(200, successData); 63 | 64 | const { req, res } = faker({}, {}); 65 | await up(req, res); 66 | 67 | expect(res.setHeader).toBeCalledWith("Content-Type", "application/json"); 68 | expect(res.send).toBeCalledWith(true); 69 | }); 70 | 71 | it("should return `false` if all PATs are rate limited", async () => { 72 | mock.onPost("https://api.github.com/graphql").reply(200, rate_limit_error); 73 | 74 | const { req, res } = faker({}, {}); 75 | await up(req, res); 76 | 77 | expect(res.setHeader).toBeCalledWith("Content-Type", "application/json"); 78 | expect(res.send).toBeCalledWith(false); 79 | }); 80 | 81 | it("should return JSON `true` if request was successful and type='json'", async () => { 82 | mock.onPost("https://api.github.com/graphql").replyOnce(200, successData); 83 | 84 | const { req, res } = faker({ type: "json" }, {}); 85 | await up(req, res); 86 | 87 | expect(res.setHeader).toBeCalledWith("Content-Type", "application/json"); 88 | expect(res.send).toBeCalledWith({ up: true }); 89 | }); 90 | 91 | it("should return JSON `false` if all PATs are rate limited and type='json'", async () => { 92 | mock.onPost("https://api.github.com/graphql").reply(200, rate_limit_error); 93 | 94 | const { req, res } = faker({ type: "json" }, {}); 95 | await up(req, res); 96 | 97 | expect(res.setHeader).toBeCalledWith("Content-Type", "application/json"); 98 | expect(res.send).toBeCalledWith({ up: false }); 99 | }); 100 | 101 | it("should return UP shields.io config if request was successful and type='shields'", async () => { 102 | mock.onPost("https://api.github.com/graphql").replyOnce(200, successData); 103 | 104 | const { req, res } = faker({ type: "shields" }, {}); 105 | await up(req, res); 106 | 107 | expect(res.setHeader).toBeCalledWith("Content-Type", "application/json"); 108 | expect(res.send).toBeCalledWith(shields_up); 109 | }); 110 | 111 | it("should return DOWN shields.io config if all PATs are rate limited and type='shields'", async () => { 112 | mock.onPost("https://api.github.com/graphql").reply(200, rate_limit_error); 113 | 114 | const { req, res } = faker({ type: "shields" }, {}); 115 | await up(req, res); 116 | 117 | expect(res.setHeader).toBeCalledWith("Content-Type", "application/json"); 118 | expect(res.send).toBeCalledWith(shields_down); 119 | }); 120 | 121 | it("should return `true` if the first PAT is rate limited but the second PATs works", async () => { 122 | mock 123 | .onPost("https://api.github.com/graphql") 124 | .replyOnce(200, rate_limit_error) 125 | .onPost("https://api.github.com/graphql") 126 | .replyOnce(200, successData); 127 | 128 | const { req, res } = faker({}, {}); 129 | await up(req, res); 130 | 131 | expect(res.setHeader).toBeCalledWith("Content-Type", "application/json"); 132 | expect(res.send).toBeCalledWith(true); 133 | }); 134 | 135 | it("should return `true` if the first PAT has 'Bad credentials' but the second PAT works", async () => { 136 | mock 137 | .onPost("https://api.github.com/graphql") 138 | .replyOnce(404, bad_credentials_error) 139 | .onPost("https://api.github.com/graphql") 140 | .replyOnce(200, successData); 141 | 142 | const { req, res } = faker({}, {}); 143 | await up(req, res); 144 | 145 | expect(res.setHeader).toBeCalledWith("Content-Type", "application/json"); 146 | expect(res.send).toBeCalledWith(true); 147 | }); 148 | 149 | it("should return `false` if all pats have 'Bad credentials'", async () => { 150 | mock 151 | .onPost("https://api.github.com/graphql") 152 | .reply(404, bad_credentials_error); 153 | 154 | const { req, res } = faker({}, {}); 155 | await up(req, res); 156 | 157 | expect(res.setHeader).toBeCalledWith("Content-Type", "application/json"); 158 | expect(res.send).toBeCalledWith(false); 159 | }); 160 | 161 | it("should throw an error if the request fails", async () => { 162 | mock.onPost("https://api.github.com/graphql").networkError(); 163 | 164 | const { req, res } = faker({}, {}); 165 | await up(req, res); 166 | 167 | expect(res.setHeader).toBeCalledWith("Content-Type", "application/json"); 168 | expect(res.send).toBeCalledWith(false); 169 | }); 170 | 171 | it("should have proper cache when no error is thrown", async () => { 172 | mock.onPost("https://api.github.com/graphql").replyOnce(200, successData); 173 | 174 | const { req, res } = faker({}, {}); 175 | await up(req, res); 176 | 177 | expect(res.setHeader.mock.calls).toEqual([ 178 | ["Content-Type", "application/json"], 179 | ["Cache-Control", `max-age=0, s-maxage=${RATE_LIMIT_SECONDS}`], 180 | ]); 181 | }); 182 | 183 | it("should have proper cache when error is thrown", async () => { 184 | mock.onPost("https://api.github.com/graphql").networkError(); 185 | 186 | const { req, res } = faker({}, {}); 187 | await up(req, res); 188 | 189 | expect(res.setHeader.mock.calls).toEqual([ 190 | ["Content-Type", "application/json"], 191 | ["Cache-Control", "no-store"], 192 | ]); 193 | }); 194 | }); 195 | -------------------------------------------------------------------------------- /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 } from "../src/common/utils.js"; 8 | 9 | const data_langs = { 10 | data: { 11 | user: { 12 | repositories: { 13 | nodes: [ 14 | { 15 | languages: { 16 | edges: [{ size: 150, node: { color: "#0f0", name: "HTML" } }], 17 | }, 18 | }, 19 | { 20 | languages: { 21 | edges: [{ size: 100, node: { color: "#0f0", name: "HTML" } }], 22 | }, 23 | }, 24 | { 25 | languages: { 26 | edges: [ 27 | { size: 100, node: { color: "#0ff", name: "javascript" } }, 28 | ], 29 | }, 30 | }, 31 | { 32 | languages: { 33 | edges: [ 34 | { size: 100, node: { color: "#0ff", name: "javascript" } }, 35 | ], 36 | }, 37 | }, 38 | ], 39 | }, 40 | }, 41 | }, 42 | }; 43 | 44 | const error = { 45 | errors: [ 46 | { 47 | type: "NOT_FOUND", 48 | path: ["user"], 49 | locations: [], 50 | message: "Could not fetch user", 51 | }, 52 | ], 53 | }; 54 | 55 | const langs = { 56 | HTML: { 57 | color: "#0f0", 58 | name: "HTML", 59 | size: 250, 60 | }, 61 | javascript: { 62 | color: "#0ff", 63 | name: "javascript", 64 | size: 200, 65 | }, 66 | }; 67 | 68 | const mock = new MockAdapter(axios); 69 | 70 | afterEach(() => { 71 | mock.reset(); 72 | }); 73 | 74 | describe("Test /api/top-langs", () => { 75 | it("should test the request", async () => { 76 | const req = { 77 | query: { 78 | username: "anuraghazra", 79 | }, 80 | }; 81 | const res = { 82 | setHeader: jest.fn(), 83 | send: jest.fn(), 84 | }; 85 | mock.onPost("https://api.github.com/graphql").reply(200, data_langs); 86 | 87 | await topLangs(req, res); 88 | 89 | expect(res.setHeader).toBeCalledWith("Content-Type", "image/svg+xml"); 90 | expect(res.send).toBeCalledWith(renderTopLanguages(langs)); 91 | }); 92 | 93 | it("should work with the query options", async () => { 94 | const req = { 95 | query: { 96 | username: "anuraghazra", 97 | hide_title: true, 98 | card_width: 100, 99 | title_color: "fff", 100 | icon_color: "fff", 101 | text_color: "fff", 102 | bg_color: "fff", 103 | }, 104 | }; 105 | const res = { 106 | setHeader: jest.fn(), 107 | send: jest.fn(), 108 | }; 109 | mock.onPost("https://api.github.com/graphql").reply(200, data_langs); 110 | 111 | await topLangs(req, res); 112 | 113 | expect(res.setHeader).toBeCalledWith("Content-Type", "image/svg+xml"); 114 | expect(res.send).toBeCalledWith( 115 | renderTopLanguages(langs, { 116 | hide_title: true, 117 | card_width: 100, 118 | title_color: "fff", 119 | icon_color: "fff", 120 | text_color: "fff", 121 | bg_color: "fff", 122 | }), 123 | ); 124 | }); 125 | 126 | it("should render error card on error", async () => { 127 | const req = { 128 | query: { 129 | username: "anuraghazra", 130 | }, 131 | }; 132 | const res = { 133 | setHeader: jest.fn(), 134 | send: jest.fn(), 135 | }; 136 | mock.onPost("https://api.github.com/graphql").reply(200, error); 137 | 138 | await topLangs(req, res); 139 | 140 | expect(res.setHeader).toBeCalledWith("Content-Type", "image/svg+xml"); 141 | expect(res.send).toBeCalledWith(renderError(error.errors[0].message)); 142 | }); 143 | }); 144 | -------------------------------------------------------------------------------- /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 | 12 | describe("Test utils.js", () => { 13 | it("should test kFormatter", () => { 14 | expect(kFormatter(1)).toBe(1); 15 | expect(kFormatter(-1)).toBe(-1); 16 | expect(kFormatter(500)).toBe(500); 17 | expect(kFormatter(1000)).toBe("1k"); 18 | expect(kFormatter(10000)).toBe("10k"); 19 | expect(kFormatter(12345)).toBe("12.3k"); 20 | expect(kFormatter(9900000)).toBe("9900k"); 21 | }); 22 | 23 | it("should test parseBoolean", () => { 24 | expect(parseBoolean(true)).toBe(true); 25 | expect(parseBoolean(false)).toBe(false); 26 | 27 | expect(parseBoolean("true")).toBe(true); 28 | expect(parseBoolean("false")).toBe(false); 29 | expect(parseBoolean("True")).toBe(true); 30 | expect(parseBoolean("False")).toBe(false); 31 | expect(parseBoolean("TRUE")).toBe(true); 32 | expect(parseBoolean("FALSE")).toBe(false); 33 | 34 | expect(parseBoolean("1")).toBe(undefined); 35 | expect(parseBoolean("0")).toBe(undefined); 36 | expect(parseBoolean("")).toBe(undefined); 37 | expect(parseBoolean(undefined)).toBe(undefined); 38 | }); 39 | 40 | it("should test encodeHTML", () => { 41 | expect(encodeHTML(`hello world<,.#4^&^@%!))`)).toBe( 42 | "<html>hello world<,.#4^&^@%!))", 43 | ); 44 | }); 45 | 46 | it("should test renderError", () => { 47 | document.body.innerHTML = renderError("Something went wrong"); 48 | expect( 49 | queryByTestId(document.body, "message").children[0], 50 | ).toHaveTextContent(/Something went wrong/gim); 51 | expect( 52 | queryByTestId(document.body, "message").children[1], 53 | ).toBeEmptyDOMElement(2); 54 | 55 | // Secondary message 56 | document.body.innerHTML = renderError( 57 | "Something went wrong", 58 | "Secondary Message", 59 | ); 60 | expect( 61 | queryByTestId(document.body, "message").children[1], 62 | ).toHaveTextContent(/Secondary Message/gim); 63 | }); 64 | 65 | it("getCardColors: should return expected values", () => { 66 | let colors = getCardColors({ 67 | title_color: "f00", 68 | text_color: "0f0", 69 | ring_color: "0000ff", 70 | icon_color: "00f", 71 | bg_color: "fff", 72 | border_color: "fff", 73 | theme: "dark", 74 | }); 75 | expect(colors).toStrictEqual({ 76 | titleColor: "#f00", 77 | textColor: "#0f0", 78 | iconColor: "#00f", 79 | ringColor: "#0000ff", 80 | bgColor: "#fff", 81 | borderColor: "#fff", 82 | }); 83 | }); 84 | 85 | it("getCardColors: should fallback to default colors if color is invalid", () => { 86 | let colors = getCardColors({ 87 | title_color: "invalidcolor", 88 | text_color: "0f0", 89 | icon_color: "00f", 90 | bg_color: "fff", 91 | border_color: "invalidColor", 92 | theme: "dark", 93 | }); 94 | expect(colors).toStrictEqual({ 95 | titleColor: "#2f80ed", 96 | textColor: "#0f0", 97 | iconColor: "#00f", 98 | ringColor: "#2f80ed", 99 | bgColor: "#fff", 100 | borderColor: "#e4e2e2", 101 | }); 102 | }); 103 | 104 | it("getCardColors: should fallback to specified theme colors if is not defined", () => { 105 | let colors = getCardColors({ 106 | theme: "dark", 107 | }); 108 | expect(colors).toStrictEqual({ 109 | titleColor: "#fff", 110 | textColor: "#9f9f9f", 111 | ringColor: "#fff", 112 | iconColor: "#79ff97", 113 | bgColor: "#151515", 114 | borderColor: "#e4e2e2", 115 | }); 116 | }); 117 | 118 | it("getCardColors: should return ring color equal to title color if not ring color is defined", () => { 119 | let colors = getCardColors({ 120 | title_color: "f00", 121 | text_color: "0f0", 122 | icon_color: "00f", 123 | bg_color: "fff", 124 | border_color: "fff", 125 | theme: "dark", 126 | }); 127 | expect(colors).toStrictEqual({ 128 | titleColor: "#f00", 129 | textColor: "#0f0", 130 | iconColor: "#00f", 131 | ringColor: "#f00", 132 | bgColor: "#fff", 133 | borderColor: "#fff", 134 | }); 135 | }); 136 | }); 137 | 138 | describe("wrapTextMultiline", () => { 139 | it("should not wrap small texts", () => { 140 | { 141 | let multiLineText = wrapTextMultiline("Small text should not wrap"); 142 | expect(multiLineText).toEqual(["Small text should not wrap"]); 143 | } 144 | }); 145 | it("should wrap large texts", () => { 146 | let multiLineText = wrapTextMultiline( 147 | "Hello world long long long text", 148 | 20, 149 | 3, 150 | ); 151 | expect(multiLineText).toEqual(["Hello world long", "long long text"]); 152 | }); 153 | it("should wrap large texts and limit max lines", () => { 154 | let multiLineText = wrapTextMultiline( 155 | "Hello world long long long text", 156 | 10, 157 | 2, 158 | ); 159 | expect(multiLineText).toEqual(["Hello", "world long..."]); 160 | }); 161 | it("should wrap chinese by punctuation", () => { 162 | let multiLineText = wrapTextMultiline( 163 | "专门为刚开始刷题的同学准备的算法基地,没有最细只有更细,立志用动画将晦涩难懂的算法说的通俗易懂!", 164 | ); 165 | expect(multiLineText.length).toEqual(3); 166 | expect(multiLineText[0].length).toEqual(18 * 8); // &#xxxxx; x 8 167 | }); 168 | }); 169 | -------------------------------------------------------------------------------- /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 | --------------------------------------------------------------------------------