├── .gitattributes ├── .github ├── FUNDING.yml ├── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md ├── labeler.yml ├── stale.yml └── workflows │ ├── e2e-test.yml │ ├── empty-issues-closer.yaml │ ├── generate-theme-doc.yml │ ├── label-pr.yml │ ├── preview-theme.yml │ ├── stale-theme-pr-closer.yaml │ ├── test.yml │ └── top-issues-dashboard.yml ├── .gitignore ├── .prettierignore ├── .prettierrc.json ├── .vercelignore ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── LICENSE ├── api ├── index.js ├── pin.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 ├── 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 │ ├── 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 │ ├── 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 └── 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 ├── pin.test.js ├── renderRepoCard.test.js ├── renderStatsCard.test.js ├── renderTopLanguages.test.js ├── renderWakatimeCard.test.js ├── retryer.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.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: "" 5 | labels: "" 6 | assignees: "" 7 | --- 8 | 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **Expected behaviour** 14 | A clear and concise description of what you expected to happen. 15 | 16 | **Screenshots / Live demo link (paste the github-readme-stats link as markdown image)** 17 | If applicable, add screenshots to help explain your problem. 18 | 19 | **Additional context** 20 | Add any other context about the problem here. 21 | 22 | 44 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: "" 5 | labels: "" 6 | assignees: "" 7 | --- 8 | 9 | **Is your feature request related to a problem? Please describe.** 10 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 11 | 12 | **Describe the solution you'd like** 13 | A clear and concise description of what you want to happen. 14 | 15 | **Describe alternatives you've considered** 16 | A clear and concise description of any alternative solutions or features you've considered. 17 | 18 | **Additional context** 19 | Add any other context or screenshots about the feature request here. 20 | 21 | 42 | -------------------------------------------------------------------------------- /.github/labeler.yml: -------------------------------------------------------------------------------- 1 | themes: themes/index.js 2 | doc-translation: docs/* 3 | card-i18n: src/translations.js 4 | -------------------------------------------------------------------------------- /.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/e2e-test.yml: -------------------------------------------------------------------------------- 1 | name: Test Deployment 2 | on: 3 | deployment_status: 4 | 5 | jobs: 6 | e2eTests: 7 | if: 8 | github.event_name == 'deployment_status' && 9 | github.event.deployment_status.state == 'success' 10 | name: Perform 2e2 tests 11 | runs-on: ubuntu-latest 12 | strategy: 13 | matrix: 14 | node-version: [16.x] 15 | 16 | steps: 17 | - uses: actions/checkout@v3 18 | 19 | - name: Setup Node 20 | uses: actions/setup-node@v3 21 | with: 22 | node-version: ${{ matrix.node-version }} 23 | cache: npm 24 | 25 | - name: Install dependencies 26 | run: npm ci 27 | env: 28 | CI: true 29 | 30 | - name: Run end-to-end tests. 31 | run: npm run test:e2e 32 | env: 33 | VERCEL_PREVIEW_URL: ${{ github.event.deployment_status.target_url }} 34 | -------------------------------------------------------------------------------- /.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 | name: Close empty issues 12 | runs-on: ubuntu-latest 13 | steps: 14 | - uses: actions/checkout@v3 # NOTE: Retrieve issue templates. 15 | 16 | - name: Run empty issues closer action 17 | uses: rickstaa/empty-issues-closer-action@v1 18 | env: 19 | github_token: ${{ secrets.GITHUB_TOKEN }} 20 | with: 21 | close_comment: 22 | Closing this issue because it appears to be empty. Please update the 23 | issue for it to be reopened. 24 | open_comment: 25 | Reopening this issue because the author provided more information. 26 | check_templates: true 27 | template_close_comment: 28 | Closing this issue since the issue template was not filled in. 29 | Please provide us with more information to have this issue reopened. 30 | template_open_comment: 31 | Reopening this issue because the author provided more information. 32 | -------------------------------------------------------------------------------- /.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 | - name: npm install, generate readme 27 | run: | 28 | npm ci 29 | npm run theme-readme-gen 30 | env: 31 | CI: true 32 | 33 | - name: Run Script 34 | uses: skx/github-action-tester@master 35 | with: 36 | script: ./scripts/push-theme-readme.sh 37 | env: 38 | CI: true 39 | PERSONAL_TOKEN: ${{ secrets.PERSONAL_TOKEN }} 40 | GH_REPO: ${{ secrets.GH_REPO }} 41 | -------------------------------------------------------------------------------- /.github/workflows/label-pr.yml: -------------------------------------------------------------------------------- 1 | name: "Pull Request Labeler" 2 | on: 3 | - pull_request_target 4 | 5 | jobs: 6 | triage: 7 | runs-on: ubuntu-latest 8 | steps: 9 | - uses: actions/labeler@v4 10 | with: 11 | repo-token: "${{ secrets.GITHUB_TOKEN }}" 12 | -------------------------------------------------------------------------------- /.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/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 | name: Close stale 'invalid' theme PRs 9 | runs-on: ubuntu-latest 10 | strategy: 11 | matrix: 12 | node-version: [16.x] 13 | 14 | steps: 15 | - uses: actions/checkout@v3 16 | 17 | - name: Setup Node 18 | uses: actions/setup-node@v3 19 | with: 20 | node-version: ${{ matrix.node-version }} 21 | cache: npm 22 | 23 | - uses: bahmutov/npm-install@v1 24 | with: 25 | useLockFile: false 26 | 27 | - run: npm run close-stale-theme-prs 28 | env: 29 | STALE_DAYS: 15 30 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 31 | -------------------------------------------------------------------------------- /.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@v1 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 | name: Update top issues Dashboard. 9 | runs-on: ubuntu-latest 10 | steps: 11 | - name: Run top issues action 12 | uses: rickstaa/top-issues-action@v1 13 | env: 14 | github_token: ${{ secrets.GITHUB_TOKEN }} 15 | with: 16 | filter: "1772" 17 | label: false 18 | dashboard: true 19 | dashboard_show_total_reactions: true 20 | top_issues: true 21 | top_bugs: true 22 | top_features: true 23 | top_pull_requests: true 24 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /.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 6 | - Discussing the current state of the code 7 | - Submitting a fix 8 | - Proposing new features 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 | 42 | ## Themes Contribution 43 | 44 | GitHub Readme Stats supports custom theming, and you can also contribute new themes! 45 | 46 | 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. 47 | 48 | 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` 49 | 50 | > 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. 51 | 52 | ## Any contributions you make will be under the MIT Software License 53 | 54 | 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. 55 | 56 | ## Report issues/bugs using GitHub's [issues](https://github.com/anuraghazra/github-readme-stats/issues) 57 | 58 | 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! 59 | 60 | ## Frequently Asked Questions (FAQs) 61 | 62 | **Q:** How to hide Jupyter Notebook? 63 | 64 | > **Ans:** &hide=jupyter%20notebook 65 | 66 | **Q:** I could not figure out how to deploy on my own Vercel instance 67 | 68 | > **Ans:** 69 | > 70 | > - docs: 71 | > - YT tutorial by codeSTACKr: 72 | 73 | **Q:** Language Card is incorrect 74 | 75 | > **Ans:** Please read all the related issues/comments before opening any issues regarding language card stats: 76 | > 77 | > - 78 | > 79 | > - 80 | 81 | **Q:** How to count private stats? 82 | 83 | > **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) 84 | 85 | ### Bug Reports 86 | 87 | **Great Bug Reports** tend to have: 88 | 89 | - A quick summary and/or background 90 | - Steps to reproduce 91 | - Be specific! 92 | - Share the snapshot, if possible. 93 | - GitHub Readme Stats' live link 94 | - What actually happens 95 | - What you expected would happen 96 | - Notes (possibly including why you think this might be happening or stuff you tried that didn't work) 97 | 98 | People _love_ thorough bug reports. I'm not even kidding. 99 | 100 | ### Feature Request 101 | 102 | **Great Feature Requests** tend to have: 103 | 104 | - A quick idea summary 105 | - What & why do you want to add the specific feature 106 | - Additional context like images, links to resources to implement the feature, etc. 107 | 108 | ## License 109 | 110 | By contributing, you agree that your contributions will be licensed under its [MIT License](./LICENSE). 111 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Anurag Hazra 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /api/index.js: -------------------------------------------------------------------------------- 1 | import * as dotenv from "dotenv"; 2 | import { renderStatsCard } from "../src/cards/stats-card.js"; 3 | import { blacklist } from "../src/common/blacklist.js"; 4 | import { 5 | clampValue, 6 | CONSTANTS, 7 | parseArray, 8 | parseBoolean, 9 | renderError, 10 | } from "../src/common/utils.js"; 11 | import { fetchStats } from "../src/fetchers/stats-fetcher.js"; 12 | import { isLocaleAvailable } from "../src/translations.js"; 13 | 14 | dotenv.config(); 15 | 16 | export default async (req, res) => { 17 | const { 18 | username, 19 | hide, 20 | hide_title, 21 | hide_border, 22 | card_width, 23 | hide_rank, 24 | show_icons, 25 | count_private, 26 | include_all_commits, 27 | line_height, 28 | title_color, 29 | icon_color, 30 | text_color, 31 | text_bold, 32 | bg_color, 33 | theme, 34 | cache_seconds, 35 | exclude_repo, 36 | custom_title, 37 | locale, 38 | disable_animations, 39 | border_radius, 40 | border_color, 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("Cache-Control", `public, max-age=${cacheSeconds}`); 67 | 68 | return res.send( 69 | renderStatsCard(stats, { 70 | hide: parseArray(hide), 71 | show_icons: parseBoolean(show_icons), 72 | hide_title: parseBoolean(hide_title), 73 | hide_border: parseBoolean(hide_border), 74 | card_width: parseInt(card_width, 10), 75 | hide_rank: parseBoolean(hide_rank), 76 | include_all_commits: parseBoolean(include_all_commits), 77 | line_height, 78 | title_color, 79 | icon_color, 80 | text_color, 81 | text_bold: parseBoolean(text_bold), 82 | bg_color, 83 | theme, 84 | custom_title, 85 | border_radius, 86 | border_color, 87 | locale: locale ? locale.toLowerCase() : null, 88 | disable_animations: parseBoolean(disable_animations), 89 | }), 90 | ); 91 | } catch (err) { 92 | return res.send(renderError(err.message, err.secondaryMessage)); 93 | } 94 | }; 95 | -------------------------------------------------------------------------------- /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("Cache-Control", `public, max-age=${cacheSeconds}`); 62 | 63 | return res.send( 64 | renderRepoCard(repoData, { 65 | hide_border: parseBoolean(hide_border), 66 | title_color, 67 | icon_color, 68 | text_color, 69 | bg_color, 70 | theme, 71 | border_radius, 72 | border_color, 73 | show_owner: parseBoolean(show_owner), 74 | locale: locale ? locale.toLowerCase() : null, 75 | }), 76 | ); 77 | } catch (err) { 78 | return res.send(renderError(err.message, err.secondaryMessage)); 79 | } 80 | }; 81 | -------------------------------------------------------------------------------- /api/top-langs.js: -------------------------------------------------------------------------------- 1 | import * as dotenv from "dotenv"; 2 | import { renderTopLanguages } from "../src/cards/top-languages-card.js"; 3 | import { blacklist } from "../src/common/blacklist.js"; 4 | import { 5 | clampValue, 6 | CONSTANTS, 7 | parseArray, 8 | parseBoolean, 9 | renderError, 10 | } from "../src/common/utils.js"; 11 | import { fetchTopLanguages } from "../src/fetchers/top-languages-fetcher.js"; 12 | import { isLocaleAvailable } from "../src/translations.js"; 13 | 14 | dotenv.config(); 15 | 16 | export default async (req, res) => { 17 | const { 18 | username, 19 | hide, 20 | hide_title, 21 | hide_border, 22 | card_width, 23 | title_color, 24 | text_color, 25 | bg_color, 26 | theme, 27 | cache_seconds, 28 | layout, 29 | langs_count, 30 | exclude_repo, 31 | custom_title, 32 | locale, 33 | border_radius, 34 | border_color, 35 | } = req.query; 36 | res.setHeader("Content-Type", "image/svg+xml"); 37 | 38 | if (blacklist.includes(username)) { 39 | return res.send(renderError("Something went wrong")); 40 | } 41 | 42 | if (locale && !isLocaleAvailable(locale)) { 43 | return res.send(renderError("Something went wrong", "Locale not found")); 44 | } 45 | 46 | try { 47 | const topLangs = await fetchTopLanguages( 48 | username, 49 | parseArray(exclude_repo), 50 | ); 51 | 52 | const cacheSeconds = clampValue( 53 | parseInt(cache_seconds || CONSTANTS.FOUR_HOURS, 10), 54 | CONSTANTS.FOUR_HOURS, 55 | CONSTANTS.ONE_DAY, 56 | ); 57 | 58 | res.setHeader("Cache-Control", `public, max-age=${cacheSeconds}`); 59 | 60 | return res.send( 61 | renderTopLanguages(topLangs, { 62 | custom_title, 63 | hide_title: parseBoolean(hide_title), 64 | hide_border: parseBoolean(hide_border), 65 | card_width: parseInt(card_width, 10), 66 | hide: parseArray(hide), 67 | title_color, 68 | text_color, 69 | bg_color, 70 | theme, 71 | layout, 72 | langs_count, 73 | border_radius, 74 | border_color, 75 | locale: locale ? locale.toLowerCase() : null, 76 | }), 77 | ); 78 | } catch (err) { 79 | return res.send(renderError(err.message, err.secondaryMessage)); 80 | } 81 | }; 82 | -------------------------------------------------------------------------------- /api/wakatime.js: -------------------------------------------------------------------------------- 1 | import * as dotenv from "dotenv"; 2 | import { renderWakatimeCard } from "../src/cards/wakatime-card.js"; 3 | import { 4 | clampValue, 5 | CONSTANTS, 6 | parseArray, 7 | parseBoolean, 8 | renderError, 9 | } from "../src/common/utils.js"; 10 | import { fetchWakatimeStats } from "../src/fetchers/wakatime-fetcher.js"; 11 | import { isLocaleAvailable } from "../src/translations.js"; 12 | 13 | dotenv.config(); 14 | 15 | export default async (req, res) => { 16 | const { 17 | username, 18 | title_color, 19 | icon_color, 20 | hide_border, 21 | line_height, 22 | text_color, 23 | bg_color, 24 | theme, 25 | cache_seconds, 26 | hide_title, 27 | hide_progress, 28 | custom_title, 29 | locale, 30 | layout, 31 | langs_count, 32 | hide, 33 | api_domain, 34 | range, 35 | border_radius, 36 | border_color, 37 | } = req.query; 38 | 39 | res.setHeader("Content-Type", "image/svg+xml"); 40 | 41 | if (locale && !isLocaleAvailable(locale)) { 42 | return res.send(renderError("Something went wrong", "Language not found")); 43 | } 44 | 45 | try { 46 | const stats = await fetchWakatimeStats({ username, api_domain, range }); 47 | 48 | let cacheSeconds = clampValue( 49 | parseInt(cache_seconds || CONSTANTS.FOUR_HOURS, 10), 50 | CONSTANTS.FOUR_HOURS, 51 | CONSTANTS.ONE_DAY, 52 | ); 53 | 54 | if (!cache_seconds) { 55 | cacheSeconds = CONSTANTS.FOUR_HOURS; 56 | } 57 | 58 | res.setHeader("Cache-Control", `public, max-age=${cacheSeconds}`); 59 | 60 | return res.send( 61 | renderWakatimeCard(stats, { 62 | custom_title, 63 | hide_title: parseBoolean(hide_title), 64 | hide_border: parseBoolean(hide_border), 65 | hide: parseArray(hide), 66 | line_height, 67 | title_color, 68 | icon_color, 69 | text_color, 70 | bg_color, 71 | theme, 72 | hide_progress, 73 | border_radius, 74 | border_color, 75 | locale: locale ? locale.toLowerCase() : null, 76 | layout, 77 | langs_count, 78 | }), 79 | ); 80 | } catch (err) { 81 | return res.send(renderError(err.message, err.secondaryMessage)); 82 | } 83 | }; 84 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 readmes", 5 | "main": "index.js", 6 | "type": "module", 7 | "scripts": { 8 | "test": "node --experimental-vm-modules node_modules/jest/bin/jest.js --coverage", 9 | "test:watch": "node --experimental-vm-modules node_modules/jest/bin/jest.js --watch", 10 | "test:update:snapshot": "node --experimental-vm-modules node_modules/jest/bin/jest.js -u", 11 | "test:e2e": "node --experimental-vm-modules node_modules/jest/bin/jest.js --config jest.e2e.config.js", 12 | "theme-readme-gen": "node scripts/generate-theme-doc", 13 | "preview-theme": "node scripts/preview-theme", 14 | "close-stale-theme-prs": "node scripts/close-stale-theme-prs", 15 | "generate-langs-json": "node scripts/generate-langs-json", 16 | "format": "./node_modules/.bin/prettier --write .", 17 | "format:check": "./node_modules/.bin/prettier --check ." 18 | }, 19 | "author": "Anurag Hazra", 20 | "license": "MIT", 21 | "devDependencies": { 22 | "@actions/core": "^1.9.1", 23 | "@actions/github": "^4.0.0", 24 | "@testing-library/dom": "^8.17.1", 25 | "@testing-library/jest-dom": "^5.16.5", 26 | "@uppercod/css-to-object": "^1.1.1", 27 | "axios-mock-adapter": "^1.18.1", 28 | "color-contrast-checker": "^2.1.0", 29 | "hjson": "^3.2.2", 30 | "husky": "^4.2.5", 31 | "jest": "^29.0.3", 32 | "jest-environment-jsdom": "^29.0.3", 33 | "js-yaml": "^4.1.0", 34 | "lodash.snakecase": "^4.1.1", 35 | "parse-diff": "^0.7.0", 36 | "prettier": "^2.1.2" 37 | }, 38 | "dependencies": { 39 | "axios": "^0.24.0", 40 | "dotenv": "^8.2.0", 41 | "emoji-name-map": "^1.2.8", 42 | "github-username-regex": "^1.0.0", 43 | "upgrade": "^1.1.0", 44 | "word-wrap": "^1.2.3" 45 | }, 46 | "husky": { 47 | "hooks": { 48 | "pre-commit": "npm test" 49 | } 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /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 | // Script parameters 13 | const CLOSING_COMMENT = ` 14 | \rThis PR has been automatically closed due to inactivity. Please feel free to reopen it if you need to continue working on it.\ 15 | \rThank you for your contributions. 16 | `; 17 | 18 | /** 19 | * Fetch open PRs from a given repository. 20 | * @param user The user name of the repository owner. 21 | * @param repo The name of the repository. 22 | * @returns The open PRs. 23 | */ 24 | export const fetchOpenPRs = async (octokit, user, repo) => { 25 | const openPRs = []; 26 | let hasNextPage = true; 27 | let endCursor; 28 | while (hasNextPage) { 29 | try { 30 | const { repository } = await octokit.graphql( 31 | ` 32 | { 33 | repository(owner: "${user}", name: "${repo}") { 34 | open_prs: pullRequests(${ 35 | endCursor ? `after: "${endCursor}", ` : "" 36 | } 37 | first: 100, states: OPEN, orderBy: {field: CREATED_AT, direction: DESC}) { 38 | nodes { 39 | number 40 | commits(last:1){ 41 | nodes{ 42 | commit{ 43 | pushedDate 44 | } 45 | } 46 | } 47 | labels(first: 100, orderBy:{field: CREATED_AT, direction: DESC}) { 48 | nodes { 49 | name 50 | } 51 | } 52 | reviews(first: 1, states: CHANGES_REQUESTED, author: "github-actions[bot]") { 53 | nodes { 54 | updatedAt 55 | } 56 | } 57 | } 58 | pageInfo { 59 | endCursor 60 | hasNextPage 61 | } 62 | } 63 | } 64 | } 65 | `, 66 | ); 67 | openPRs.push(...repository.open_prs.nodes); 68 | hasNextPage = repository.open_prs.pageInfo.hasNextPage; 69 | endCursor = repository.open_prs.pageInfo.endCursor; 70 | } catch (error) { 71 | if (error instanceof RequestError) { 72 | setFailed(`Could not retrieve top PRs using GraphQl: ${error.message}`); 73 | } 74 | throw error; 75 | } 76 | } 77 | return openPRs; 78 | }; 79 | 80 | /** 81 | * Retrieve pull requests that have a given label. 82 | * @param pull The pull requests to check. 83 | * @param label The label to check for. 84 | */ 85 | export const pullsWithLabel = (pulls, label) => { 86 | return pulls.filter((pr) => { 87 | return pr.labels.nodes.some((lab) => lab.name === label); 88 | }); 89 | }; 90 | 91 | /** 92 | * Check if PR is stale. Meaning that it hasn't been updated in a given time. 93 | * @param {Object} pullRequest request object. 94 | * @param {number} days number of days. 95 | * @returns Boolean indicating if PR is stale. 96 | */ 97 | const isStale = (pullRequest, staleDays) => { 98 | const lastCommitDate = new Date( 99 | pullRequest.commits.nodes[0].commit.pushedDate, 100 | ); 101 | if (pullRequest.reviews.nodes[0]) { 102 | const lastReviewDate = new Date(pullRequest.reviews.nodes[0].updatedAt); 103 | const lastUpdateDate = 104 | lastCommitDate >= lastReviewDate ? lastCommitDate : lastReviewDate; 105 | const now = new Date(); 106 | return now - lastUpdateDate > 1000 * 60 * 60 * 24 * staleDays; 107 | } else { 108 | return false; 109 | } 110 | }; 111 | 112 | /** 113 | * Main function. 114 | */ 115 | const run = async () => { 116 | try { 117 | // Create octokit client. 118 | const dryRun = process.env.DRY_RUN === "true" || false; 119 | const staleDays = process.env.STALE_DAYS || 15; 120 | debug("Creating octokit client..."); 121 | const octokit = github.getOctokit(getGithubToken()); 122 | const { owner, repo } = getRepoInfo(github.context); 123 | 124 | // Retrieve all theme pull requests. 125 | debug("Retrieving all theme pull requests..."); 126 | const prs = await fetchOpenPRs(octokit, owner, repo); 127 | const themePRs = pullsWithLabel(prs, "themes"); 128 | const invalidThemePRs = pullsWithLabel(themePRs, "invalid"); 129 | debug("Retrieving stale theme PRs..."); 130 | const staleThemePRs = invalidThemePRs.filter((pr) => 131 | isStale(pr, staleDays), 132 | ); 133 | const staleThemePRsNumbers = staleThemePRs.map((pr) => pr.number); 134 | debug(`Found ${staleThemePRs.length} stale theme PRs`); 135 | 136 | // Loop through all stale invalid theme pull requests and close them. 137 | for (const prNumber of staleThemePRsNumbers) { 138 | debug(`Closing #${prNumber} because it is stale...`); 139 | if (!dryRun) { 140 | await octokit.issues.createComment({ 141 | owner, 142 | repo, 143 | issue_number: prNumber, 144 | body: CLOSING_COMMENT, 145 | }); 146 | await octokit.pulls.update({ 147 | owner, 148 | repo, 149 | pull_number: prNumber, 150 | state: "closed", 151 | }); 152 | } else { 153 | debug("Dry run enabled, skipping..."); 154 | } 155 | } 156 | } catch (error) { 157 | setFailed(error.message); 158 | } 159 | }; 160 | 161 | run(); 162 | -------------------------------------------------------------------------------- /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 | // Script variables. 8 | const OWNER = "anuraghazra"; 9 | const REPO = "github-readme-stats"; 10 | 11 | /** 12 | * Retrieve information about the repository that ran the action. 13 | * 14 | * @param {Object} context Action context. 15 | * @returns {Object} Repository information. 16 | */ 17 | export const getRepoInfo = (ctx) => { 18 | try { 19 | return { 20 | owner: ctx.repo.owner, 21 | repo: ctx.repo.repo, 22 | }; 23 | } catch (error) { 24 | return { 25 | owner: OWNER, 26 | repo: REPO, 27 | }; 28 | } 29 | }; 30 | 31 | /** 32 | * Retrieve github token and throw error if it is not found. 33 | * 34 | * @returns {string} Github token. 35 | */ 36 | export const getGithubToken = () => { 37 | const token = getInput("github_token") || process.env.GITHUB_TOKEN; 38 | if (!token) { 39 | throw Error("Could not find github token"); 40 | } 41 | return token; 42 | }; 43 | -------------------------------------------------------------------------------- /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 branch -d $BRANCH_NAME || true 10 | git checkout -b $BRANCH_NAME 11 | git add --all 12 | git commit --message "docs(theme): Auto update theme readme" || exit 0 13 | git remote add origin-$BRANCH_NAME https://${PERSONAL_TOKEN}@github.com/${GH_REPO}.git 14 | git push --force --quiet --set-upstream origin-$BRANCH_NAME $BRANCH_NAME 15 | -------------------------------------------------------------------------------- /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 8 | * @param {number} sigma 9 | * @param {number} to 10 | * @returns {number} Probability. 11 | */ 12 | function 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 {number} totalRepos 33 | * @param {number} totalCommits 34 | * @param {number} contributions 35 | * @param {number} followers 36 | * @param {number} prs 37 | * @param {number} issues 38 | * @param {number} stargazers 39 | * @returns {{level: string, score: number}}} The users rank. 40 | */ 41 | function calculateRank({ 42 | totalRepos, 43 | totalCommits, 44 | contributions, 45 | followers, 46 | prs, 47 | issues, 48 | stargazers, 49 | }) { 50 | const COMMITS_OFFSET = 1.65; 51 | const CONTRIBS_OFFSET = 1.65; 52 | const ISSUES_OFFSET = 1; 53 | const STARS_OFFSET = 0.75; 54 | const PRS_OFFSET = 0.5; 55 | const FOLLOWERS_OFFSET = 0.45; 56 | const REPO_OFFSET = 1; 57 | 58 | const ALL_OFFSETS = 59 | CONTRIBS_OFFSET + 60 | ISSUES_OFFSET + 61 | STARS_OFFSET + 62 | PRS_OFFSET + 63 | FOLLOWERS_OFFSET + 64 | REPO_OFFSET; 65 | 66 | const RANK_S_VALUE = 1; 67 | const RANK_DOUBLE_A_VALUE = 25; 68 | const RANK_A2_VALUE = 45; 69 | const RANK_A3_VALUE = 60; 70 | const RANK_B_VALUE = 100; 71 | 72 | const TOTAL_VALUES = 73 | RANK_S_VALUE + RANK_A2_VALUE + RANK_A3_VALUE + RANK_B_VALUE; 74 | 75 | // prettier-ignore 76 | const score = ( 77 | totalCommits * COMMITS_OFFSET + 78 | contributions * CONTRIBS_OFFSET + 79 | issues * ISSUES_OFFSET + 80 | stargazers * STARS_OFFSET + 81 | prs * PRS_OFFSET + 82 | followers * FOLLOWERS_OFFSET + 83 | totalRepos * REPO_OFFSET 84 | ) / 100; 85 | 86 | const normalizedScore = normalcdf(score, TOTAL_VALUES, ALL_OFFSETS) * 100; 87 | 88 | const level = (() => { 89 | if (normalizedScore < RANK_S_VALUE) return "S+"; 90 | if (normalizedScore < RANK_DOUBLE_A_VALUE) return "S"; 91 | if (normalizedScore < RANK_A2_VALUE) return "A++"; 92 | if (normalizedScore < RANK_A3_VALUE) return "A+"; 93 | return "B+"; 94 | })(); 95 | 96 | return { level, score: normalizedScore }; 97 | } 98 | 99 | export { calculateRank }; 100 | export default calculateRank; 101 | -------------------------------------------------------------------------------- /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 | * @param {string} label 18 | * @param {string} textColor 19 | * @returns {string} 20 | */ 21 | const getBadgeSVG = (label, textColor) => ` 22 | 23 | 24 | 31 | ${label} 32 | 33 | 34 | `; 35 | 36 | /** 37 | * @param {string} langName 38 | * @param {string} langColor 39 | * @returns {string} 40 | */ 41 | const createLanguageNode = (langName, langColor) => { 42 | return ` 43 | 44 | 45 | ${langName} 46 | 47 | `; 48 | }; 49 | 50 | const ICON_SIZE = 16; 51 | const iconWithLabel = (icon, label, testid) => { 52 | if (label <= 0) return ""; 53 | const iconSvg = ` 54 | 62 | ${icon} 63 | 64 | `; 65 | const text = `${label}`; 66 | return flexLayout({ items: [iconSvg, text], gap: 20 }).join(""); 67 | }; 68 | 69 | /** 70 | * @param {import('../fetchers/types').RepositoryData} repo 71 | * @param {Partial} options 72 | * @returns {string} 73 | */ 74 | const renderRepoCard = (repo, options = {}) => { 75 | const { 76 | name, 77 | nameWithOwner, 78 | description, 79 | primaryLanguage, 80 | isArchived, 81 | isTemplate, 82 | starCount, 83 | forkCount, 84 | } = repo; 85 | const { 86 | hide_border = false, 87 | title_color, 88 | icon_color, 89 | text_color, 90 | bg_color, 91 | show_owner = false, 92 | theme = "default_repocard", 93 | border_radius, 94 | border_color, 95 | locale, 96 | } = options; 97 | 98 | const lineHeight = 10; 99 | const header = show_owner ? nameWithOwner : name; 100 | const langName = (primaryLanguage && primaryLanguage.name) || "Unspecified"; 101 | const langColor = (primaryLanguage && primaryLanguage.color) || "#333"; 102 | 103 | const desc = parseEmojis(description || "No description provided"); 104 | const multiLineDescription = wrapTextMultiline(desc); 105 | const descriptionLines = multiLineDescription.length; 106 | const descriptionSvg = multiLineDescription 107 | .map((line) => `${encodeHTML(line)}`) 108 | .join(""); 109 | 110 | const height = 111 | (descriptionLines > 1 ? 120 : 110) + descriptionLines * lineHeight; 112 | 113 | const i18n = new I18n({ 114 | locale, 115 | translations: repoCardLocales, 116 | }); 117 | 118 | // returns theme based colors with proper overrides and defaults 119 | const colors = getCardColors({ 120 | title_color, 121 | icon_color, 122 | text_color, 123 | bg_color, 124 | border_color, 125 | theme, 126 | }); 127 | 128 | const svgLanguage = primaryLanguage 129 | ? createLanguageNode(langName, langColor) 130 | : ""; 131 | 132 | const totalStars = kFormatter(starCount); 133 | const totalForks = kFormatter(forkCount); 134 | const svgStars = iconWithLabel(icons.star, totalStars, "stargazers"); 135 | const svgForks = iconWithLabel(icons.fork, totalForks, "forkcount"); 136 | 137 | const starAndForkCount = flexLayout({ 138 | items: [svgLanguage, svgStars, svgForks], 139 | sizes: [ 140 | measureText(langName, 12), 141 | ICON_SIZE + measureText(`${totalStars}`, 12), 142 | ICON_SIZE + measureText(`${totalForks}`, 12), 143 | ], 144 | gap: 25, 145 | }).join(""); 146 | 147 | const card = new Card({ 148 | defaultTitle: header.length > 35 ? `${header.slice(0, 35)}...` : header, 149 | titlePrefixIcon: icons.contribs, 150 | width: 400, 151 | height, 152 | border_radius, 153 | colors, 154 | }); 155 | 156 | card.disableAnimations(); 157 | card.setHideBorder(hide_border); 158 | card.setHideTitle(false); 159 | card.setCSS(` 160 | .description { font: 400 13px 'Segoe UI', Ubuntu, Sans-Serif; fill: ${colors.textColor} } 161 | .gray { font: 400 12px 'Segoe UI', Ubuntu, Sans-Serif; fill: ${colors.textColor} } 162 | .icon { fill: ${colors.iconColor} } 163 | .badge { font: 600 11px 'Segoe UI', Ubuntu, Sans-Serif; } 164 | .badge rect { opacity: 0.2 } 165 | `); 166 | 167 | return card.render(` 168 | ${ 169 | isTemplate 170 | ? // @ts-ignore 171 | getBadgeSVG(i18n.t("repocard.template"), colors.textColor) 172 | : isArchived 173 | ? // @ts-ignore 174 | getBadgeSVG(i18n.t("repocard.archived"), colors.textColor) 175 | : "" 176 | } 177 | 178 | 179 | ${descriptionSvg} 180 | 181 | 182 | 183 | ${starAndForkCount} 184 | 185 | `); 186 | }; 187 | 188 | export { renderRepoCard }; 189 | export default renderRepoCard; 190 | -------------------------------------------------------------------------------- /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 } 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 | /** 16 | * Create a stats card text item. 17 | * 18 | * @param {object[]} createTextNodeParams Object that contains the createTextNode parameters. 19 | * @param {string} createTextNodeParams.label The label to display. 20 | * @param {string} createTextNodeParams.value The value to display. 21 | * @param {string} createTextNodeParams.id The id of the stat. 22 | * @param {number} createTextNodeParams.index The index of the stat. 23 | * @param {boolean} createTextNodeParams.showIcons Whether to show icons. 24 | * @param {number} createTextNodeParams.shiftValuePos Number of pixels the value has to be shifted to the right. 25 | * @param {boolean} createTextNodeParams.bold Whether to bold the label. 26 | * @returns 27 | */ 28 | const createTextNode = ({ 29 | icon, 30 | label, 31 | value, 32 | id, 33 | index, 34 | showIcons, 35 | shiftValuePos, 36 | bold, 37 | }) => { 38 | const kValue = kFormatter(value); 39 | const staggerDelay = (index + 3) * 150; 40 | 41 | const labelOffset = showIcons ? `x="25"` : ""; 42 | const iconSvg = showIcons 43 | ? ` 44 | 45 | ${icon} 46 | 47 | ` 48 | : ""; 49 | return ` 50 | 51 | ${iconSvg} 52 | ${label}: 55 | ${kValue} 61 | 62 | `; 63 | }; 64 | 65 | /** 66 | * @param {Partial} stats 67 | * @param {Partial} options 68 | * @returns {string} 69 | */ 70 | const renderStatsCard = (stats = {}, options = { hide: [] }) => { 71 | const { 72 | name, 73 | totalStars, 74 | totalCommits, 75 | totalIssues, 76 | totalPRs, 77 | contributedTo, 78 | rank, 79 | } = stats; 80 | const { 81 | hide = [], 82 | show_icons = false, 83 | hide_title = false, 84 | hide_border = false, 85 | card_width, 86 | hide_rank = false, 87 | include_all_commits = false, 88 | line_height = 25, 89 | title_color, 90 | icon_color, 91 | text_color, 92 | text_bold = true, 93 | bg_color, 94 | theme = "default", 95 | custom_title, 96 | border_radius, 97 | border_color, 98 | locale, 99 | disable_animations = false, 100 | } = options; 101 | 102 | const lheight = parseInt(String(line_height), 10); 103 | 104 | // returns theme based colors with proper overrides and defaults 105 | const { titleColor, textColor, iconColor, bgColor, borderColor } = 106 | getCardColors({ 107 | title_color, 108 | icon_color, 109 | text_color, 110 | bg_color, 111 | border_color, 112 | theme, 113 | }); 114 | 115 | const apostrophe = ["x", "s"].includes(name.slice(-1).toLocaleLowerCase()) 116 | ? "" 117 | : "s"; 118 | const i18n = new I18n({ 119 | locale, 120 | translations: statCardLocales({ name, apostrophe }), 121 | }); 122 | 123 | // Meta data for creating text nodes with createTextNode function 124 | const STATS = { 125 | stars: { 126 | icon: icons.star, 127 | label: i18n.t("statcard.totalstars"), 128 | value: totalStars, 129 | id: "stars", 130 | }, 131 | commits: { 132 | icon: icons.commits, 133 | label: `${i18n.t("statcard.commits")}${ 134 | include_all_commits ? "" : ` (${new Date().getFullYear()})` 135 | }`, 136 | value: totalCommits, 137 | id: "commits", 138 | }, 139 | prs: { 140 | icon: icons.prs, 141 | label: i18n.t("statcard.prs"), 142 | value: totalPRs, 143 | id: "prs", 144 | }, 145 | issues: { 146 | icon: icons.issues, 147 | label: i18n.t("statcard.issues"), 148 | value: totalIssues, 149 | id: "issues", 150 | }, 151 | contribs: { 152 | icon: icons.contribs, 153 | label: i18n.t("statcard.contribs"), 154 | value: contributedTo, 155 | id: "contribs", 156 | }, 157 | }; 158 | 159 | const longLocales = [ 160 | "cn", 161 | "es", 162 | "fr", 163 | "pt-br", 164 | "ru", 165 | "uk-ua", 166 | "id", 167 | "my", 168 | "pl", 169 | "de", 170 | "nl", 171 | ]; 172 | const isLongLocale = longLocales.includes(locale) === true; 173 | 174 | // filter out hidden stats defined by user & create the text nodes 175 | const statItems = Object.keys(STATS) 176 | .filter((key) => !hide.includes(key)) 177 | .map((key, index) => 178 | // create the text nodes, and pass index so that we can calculate the line spacing 179 | createTextNode({ 180 | ...STATS[key], 181 | index, 182 | showIcons: show_icons, 183 | shiftValuePos: 184 | (!include_all_commits ? 50 : 35) + (isLongLocale ? 50 : 0), 185 | bold: text_bold, 186 | }), 187 | ); 188 | 189 | // Calculate the card height depending on how many items there are 190 | // but if rank circle is visible clamp the minimum height to `150` 191 | let height = Math.max( 192 | 45 + (statItems.length + 1) * lheight, 193 | hide_rank ? 0 : 150, 194 | ); 195 | 196 | // the better user's score the the rank will be closer to zero so 197 | // subtracting 100 to get the progress in 100% 198 | const progress = 100 - rank.score; 199 | const cssStyles = getStyles({ 200 | titleColor, 201 | textColor, 202 | iconColor, 203 | show_icons, 204 | progress, 205 | }); 206 | 207 | const calculateTextWidth = () => { 208 | return measureText(custom_title ? custom_title : i18n.t("statcard.title")); 209 | }; 210 | 211 | /* 212 | When hide_rank=true, the minimum card width is 270 px + the title length and padding. 213 | When hide_rank=false, the minimum card_width is 340 px + the icon width (if show_icons=true). 214 | Numbers are picked by looking at existing dimensions on production. 215 | */ 216 | const iconWidth = show_icons ? 16 : 0; 217 | const minCardWidth = hide_rank 218 | ? clampValue(50 /* padding */ + calculateTextWidth() * 2, 270, Infinity) 219 | : 340 + iconWidth; 220 | const defaultCardWidth = hide_rank ? 270 : 495; 221 | let width = isNaN(card_width) ? defaultCardWidth : card_width; 222 | if (width < minCardWidth) { 223 | width = minCardWidth; 224 | } 225 | 226 | const card = new Card({ 227 | customTitle: custom_title, 228 | defaultTitle: i18n.t("statcard.title"), 229 | width, 230 | height, 231 | border_radius, 232 | colors: { 233 | titleColor, 234 | textColor, 235 | iconColor, 236 | bgColor, 237 | borderColor, 238 | }, 239 | }); 240 | 241 | card.setHideBorder(hide_border); 242 | card.setHideTitle(hide_title); 243 | card.setCSS(cssStyles); 244 | 245 | if (disable_animations) card.disableAnimations(); 246 | 247 | /** 248 | * Calculates the right rank circle translation values such that the rank circle 249 | * keeps respecting the padding. 250 | * 251 | * width > 450: The default left padding of 50 px will be used. 252 | * width < 450: The left and right padding will shrink equally. 253 | * 254 | * @returns {number} - Rank circle translation value. 255 | */ 256 | const calculateRankXTranslation = () => { 257 | if (width < 450) { 258 | return width - 95 + (45 * (450 - 340)) / 110; 259 | } else { 260 | return width - 95; 261 | } 262 | }; 263 | 264 | // Conditionally rendered elements 265 | const rankCircle = hide_rank 266 | ? "" 267 | : ` 271 | 272 | 273 | 274 | 281 | ${rank.level} 282 | 283 | 284 | `; 285 | 286 | // Accessibility Labels 287 | const labels = Object.keys(STATS) 288 | .filter((key) => !hide.includes(key)) 289 | .map((key) => { 290 | if (key === "commits") { 291 | return `${i18n.t("statcard.commits")} ${ 292 | include_all_commits ? "" : `in ${new Date().getFullYear()}` 293 | } : ${totalStars}`; 294 | } 295 | return `${STATS[key].label}: ${STATS[key].value}`; 296 | }) 297 | .join(", "); 298 | 299 | card.setAccessibilityLabel({ 300 | title: `${card.title}, Rank: ${rank.level}`, 301 | desc: labels, 302 | }); 303 | 304 | return card.render(` 305 | ${rankCircle} 306 | 307 | ${flexLayout({ 308 | items: statItems, 309 | gap: lheight, 310 | direction: "column", 311 | }).join("")} 312 | 313 | `); 314 | }; 315 | 316 | export { renderStatsCard }; 317 | export default renderStatsCard; 318 | -------------------------------------------------------------------------------- /src/cards/top-languages-card.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | import { Card } from "../common/Card.js"; 3 | import { createProgressNode } from "../common/createProgressNode.js"; 4 | import { I18n } from "../common/I18n.js"; 5 | import { 6 | chunkArray, 7 | clampValue, 8 | flexLayout, 9 | getCardColors, 10 | lowercaseTrim, 11 | measureText, 12 | } from "../common/utils.js"; 13 | import { langCardLocales } from "../translations.js"; 14 | 15 | const DEFAULT_CARD_WIDTH = 300; 16 | const MIN_CARD_WIDTH = 230; 17 | const DEFAULT_LANGS_COUNT = 5; 18 | const DEFAULT_LANG_COLOR = "#858585"; 19 | const CARD_PADDING = 25; 20 | 21 | /** 22 | * @typedef {import("../fetchers/types").Lang} Lang 23 | */ 24 | 25 | /** 26 | * @param {Lang[]} arr 27 | */ 28 | const getLongestLang = (arr) => 29 | arr.reduce( 30 | (savedLang, lang) => 31 | lang.name.length > savedLang.name.length ? lang : savedLang, 32 | { name: "", size: null, color: "" }, 33 | ); 34 | 35 | /** 36 | * @param {{ 37 | * width: number, 38 | * color: string, 39 | * name: string, 40 | * progress: string 41 | * }} props 42 | */ 43 | const createProgressTextNode = ({ width, color, name, progress }) => { 44 | const paddingRight = 95; 45 | const progressTextX = width - paddingRight + 10; 46 | const progressWidth = width - paddingRight; 47 | 48 | return ` 49 | ${name} 50 | ${progress}% 51 | ${createProgressNode({ 52 | x: 0, 53 | y: 25, 54 | color, 55 | width: progressWidth, 56 | progress, 57 | progressBarBackgroundColor: "#ddd", 58 | })} 59 | `; 60 | }; 61 | 62 | /** 63 | * @param {{ lang: Lang, totalSize: number }} props 64 | */ 65 | const createCompactLangNode = ({ lang, totalSize }) => { 66 | const percentage = ((lang.size / totalSize) * 100).toFixed(2); 67 | const color = lang.color || "#858585"; 68 | 69 | return ` 70 | 71 | 72 | 73 | ${lang.name} ${percentage}% 74 | 75 | 76 | `; 77 | }; 78 | 79 | /** 80 | * @param {{ langs: Lang[], totalSize: number }} props 81 | */ 82 | const createLanguageTextNode = ({ langs, totalSize }) => { 83 | const longestLang = getLongestLang(langs); 84 | const chunked = chunkArray(langs, langs.length / 2); 85 | const layouts = chunked.map((array) => { 86 | // @ts-ignore 87 | const items = array.map((lang, index) => 88 | createCompactLangNode({ 89 | lang, 90 | totalSize, 91 | // @ts-ignore 92 | index, 93 | }), 94 | ); 95 | return flexLayout({ 96 | items, 97 | gap: 25, 98 | direction: "column", 99 | }).join(""); 100 | }); 101 | 102 | const percent = ((longestLang.size / totalSize) * 100).toFixed(2); 103 | const minGap = 150; 104 | const maxGap = 20 + measureText(`${longestLang.name} ${percent}%`, 11); 105 | return flexLayout({ 106 | items: layouts, 107 | gap: maxGap < minGap ? minGap : maxGap, 108 | }).join(""); 109 | }; 110 | 111 | /** 112 | * @param {Lang[]} langs 113 | * @param {number} width 114 | * @param {number} totalLanguageSize 115 | * @returns {string} 116 | */ 117 | const renderNormalLayout = (langs, width, totalLanguageSize) => { 118 | return flexLayout({ 119 | items: langs.map((lang) => { 120 | return createProgressTextNode({ 121 | width: width, 122 | name: lang.name, 123 | color: lang.color || DEFAULT_LANG_COLOR, 124 | progress: ((lang.size / totalLanguageSize) * 100).toFixed(2), 125 | }); 126 | }), 127 | gap: 40, 128 | direction: "column", 129 | }).join(""); 130 | }; 131 | 132 | /** 133 | * @param {Lang[]} langs 134 | * @param {number} width 135 | * @param {number} totalLanguageSize 136 | * @returns {string} 137 | */ 138 | const renderCompactLayout = (langs, width, totalLanguageSize) => { 139 | const paddingRight = 50; 140 | const offsetWidth = width - paddingRight; 141 | // progressOffset holds the previous language's width and used to offset the next language 142 | // so that we can stack them one after another, like this: [--][----][---] 143 | let progressOffset = 0; 144 | const compactProgressBar = langs 145 | .map((lang) => { 146 | const percentage = parseFloat( 147 | ((lang.size / totalLanguageSize) * offsetWidth).toFixed(2), 148 | ); 149 | 150 | const progress = percentage < 10 ? percentage + 10 : percentage; 151 | 152 | const output = ` 153 | 162 | `; 163 | progressOffset += percentage; 164 | return output; 165 | }) 166 | .join(""); 167 | 168 | return ` 169 | 170 | 171 | 172 | ${compactProgressBar} 173 | 174 | 175 | ${createLanguageTextNode({ 176 | langs, 177 | totalSize: totalLanguageSize, 178 | })} 179 | 180 | `; 181 | }; 182 | 183 | /** 184 | * @param {number} totalLangs 185 | * @returns {number} 186 | */ 187 | const calculateCompactLayoutHeight = (totalLangs) => { 188 | return 90 + Math.round(totalLangs / 2) * 25; 189 | }; 190 | 191 | /** 192 | * @param {number} totalLangs 193 | * @returns {number} 194 | */ 195 | const calculateNormalLayoutHeight = (totalLangs) => { 196 | return 45 + (totalLangs + 1) * 40; 197 | }; 198 | 199 | /** 200 | * 201 | * @param {Record} topLangs 202 | * @param {string[]} hide 203 | * @param {string} langs_count 204 | */ 205 | const useLanguages = (topLangs, hide, langs_count) => { 206 | let langs = Object.values(topLangs); 207 | let langsToHide = {}; 208 | let langsCount = clampValue(parseInt(langs_count), 1, 10); 209 | 210 | // populate langsToHide map for quick lookup 211 | // while filtering out 212 | if (hide) { 213 | hide.forEach((langName) => { 214 | langsToHide[lowercaseTrim(langName)] = true; 215 | }); 216 | } 217 | 218 | // filter out languages to be hidden 219 | langs = langs 220 | .sort((a, b) => b.size - a.size) 221 | .filter((lang) => { 222 | return !langsToHide[lowercaseTrim(lang.name)]; 223 | }) 224 | .slice(0, langsCount); 225 | 226 | const totalLanguageSize = langs.reduce((acc, curr) => acc + curr.size, 0); 227 | 228 | return { langs, totalLanguageSize }; 229 | }; 230 | 231 | /** 232 | * @param {import('../fetchers/types').TopLangData} topLangs 233 | * @param {Partial} options 234 | * @returns {string} 235 | */ 236 | const renderTopLanguages = (topLangs, options = {}) => { 237 | const { 238 | hide_title = false, 239 | hide_border, 240 | card_width, 241 | title_color, 242 | text_color, 243 | bg_color, 244 | hide, 245 | theme, 246 | layout, 247 | custom_title, 248 | locale, 249 | langs_count = DEFAULT_LANGS_COUNT, 250 | border_radius, 251 | border_color, 252 | } = options; 253 | 254 | const i18n = new I18n({ 255 | locale, 256 | translations: langCardLocales, 257 | }); 258 | 259 | const { langs, totalLanguageSize } = useLanguages( 260 | topLangs, 261 | hide, 262 | String(langs_count), 263 | ); 264 | 265 | let width = isNaN(card_width) 266 | ? DEFAULT_CARD_WIDTH 267 | : card_width < MIN_CARD_WIDTH 268 | ? MIN_CARD_WIDTH 269 | : card_width; 270 | let height = calculateNormalLayoutHeight(langs.length); 271 | 272 | let finalLayout = ""; 273 | if (layout === "compact") { 274 | width = width + 50; // padding 275 | height = calculateCompactLayoutHeight(langs.length); 276 | 277 | finalLayout = renderCompactLayout(langs, width, totalLanguageSize); 278 | } else { 279 | finalLayout = renderNormalLayout(langs, width, totalLanguageSize); 280 | } 281 | 282 | // returns theme based colors with proper overrides and defaults 283 | const colors = getCardColors({ 284 | title_color, 285 | text_color, 286 | bg_color, 287 | border_color, 288 | theme, 289 | }); 290 | 291 | const card = new Card({ 292 | customTitle: custom_title, 293 | defaultTitle: i18n.t("langcard.title"), 294 | width, 295 | height, 296 | border_radius, 297 | colors, 298 | }); 299 | 300 | card.disableAnimations(); 301 | card.setHideBorder(hide_border); 302 | card.setHideTitle(hide_title); 303 | card.setCSS( 304 | `.lang-name { font: 400 11px 'Segoe UI', Ubuntu, Sans-Serif; fill: ${colors.textColor} }`, 305 | ); 306 | 307 | return card.render(` 308 | 309 | ${finalLayout} 310 | 311 | `); 312 | }; 313 | 314 | export { renderTopLanguages, MIN_CARD_WIDTH }; 315 | -------------------------------------------------------------------------------- /src/cards/types.d.ts: -------------------------------------------------------------------------------- 1 | type ThemeNames = keyof typeof import("../../themes/index.js"); 2 | 3 | export type CommonOptions = { 4 | title_color: string; 5 | icon_color: string; 6 | text_color: string; 7 | bg_color: string; 8 | theme: ThemeNames; 9 | border_radius: number; 10 | border_color: string; 11 | locale: string; 12 | }; 13 | 14 | export type StatCardOptions = CommonOptions & { 15 | hide: string[]; 16 | show_icons: boolean; 17 | hide_title: boolean; 18 | hide_border: boolean; 19 | card_width: number; 20 | hide_rank: boolean; 21 | include_all_commits: boolean; 22 | line_height: number | string; 23 | custom_title: string; 24 | disable_animations: boolean; 25 | }; 26 | 27 | export type RepoCardOptions = CommonOptions & { 28 | hide_border: boolean; 29 | show_owner: boolean; 30 | }; 31 | 32 | export type TopLangOptions = CommonOptions & { 33 | hide_title: boolean; 34 | hide_border: boolean; 35 | card_width: number; 36 | hide: string[]; 37 | layout: "compact" | "normal"; 38 | custom_title: string; 39 | langs_count: number; 40 | }; 41 | 42 | type WakaTimeOptions = CommonOptions & { 43 | hide_title: boolean; 44 | hide_border: boolean; 45 | hide: string[]; 46 | line_height: string; 47 | hide_progress: boolean; 48 | custom_title: string; 49 | layout: "compact" | "normal"; 50 | langs_count: number; 51 | }; 52 | -------------------------------------------------------------------------------- /src/cards/wakatime-card.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | import { Card } from "../common/Card.js"; 3 | import { createProgressNode } from "../common/createProgressNode.js"; 4 | import { I18n } from "../common/I18n.js"; 5 | import { 6 | clampValue, 7 | flexLayout, 8 | getCardColors, 9 | lowercaseTrim, 10 | } from "../common/utils.js"; 11 | import { getStyles } from "../getStyles.js"; 12 | import { wakatimeCardLocales } from "../translations.js"; 13 | 14 | /** Import language colors. 15 | * 16 | * @description Here we use the workaround found in 17 | * https://stackoverflow.com/questions/66726365/how-should-i-import-json-in-node 18 | * since vercel is using v16.14.0 which does not yet support json imports without the 19 | * --experimental-json-modules flag. 20 | */ 21 | import { createRequire } from "module"; 22 | const require = createRequire(import.meta.url); 23 | const languageColors = require("../common/languageColors.json"); // now works 24 | 25 | /** 26 | * @param {{color: string, text: string}} param0 27 | */ 28 | const noCodingActivityNode = ({ color, text }) => { 29 | return ` 30 | ${text} 31 | `; 32 | }; 33 | 34 | /** 35 | * 36 | * @param {{ 37 | * lang: import("../fetchers/types").WakaTimeLang, 38 | * totalSize: number, 39 | * x: number, 40 | * y: number 41 | * }} props 42 | */ 43 | const createCompactLangNode = ({ lang, totalSize, x, y }) => { 44 | const color = languageColors[lang.name] || "#858585"; 45 | 46 | return ` 47 | 48 | 49 | 50 | ${lang.name} - ${lang.text} 51 | 52 | 53 | `; 54 | }; 55 | 56 | /** 57 | * @param {{ 58 | * langs: import("../fetchers/types").WakaTimeLang[], 59 | * totalSize: number, 60 | * x: number, 61 | * y: number 62 | * }} props 63 | */ 64 | const createLanguageTextNode = ({ langs, totalSize, x, y }) => { 65 | return langs.map((lang, index) => { 66 | if (index % 2 === 0) { 67 | return createCompactLangNode({ 68 | lang, 69 | x: 25, 70 | y: 12.5 * index + y, 71 | totalSize, 72 | }); 73 | } 74 | return createCompactLangNode({ 75 | lang, 76 | x: 230, 77 | y: 12.5 + 12.5 * index, 78 | totalSize, 79 | }); 80 | }); 81 | }; 82 | 83 | /** 84 | * 85 | * @param {{ 86 | * id: string; 87 | * label: string; 88 | * value: string; 89 | * index: number; 90 | * percent: number; 91 | * hideProgress: boolean; 92 | * progressBarColor: string; 93 | * progressBarBackgroundColor: string 94 | * }} props 95 | */ 96 | const createTextNode = ({ 97 | id, 98 | label, 99 | value, 100 | index, 101 | percent, 102 | hideProgress, 103 | progressBarColor, 104 | progressBarBackgroundColor, 105 | }) => { 106 | const staggerDelay = (index + 3) * 150; 107 | 108 | const cardProgress = hideProgress 109 | ? null 110 | : createProgressNode({ 111 | x: 110, 112 | y: 4, 113 | progress: percent, 114 | color: progressBarColor, 115 | width: 220, 116 | // @ts-ignore 117 | name: label, 118 | progressBarBackgroundColor, 119 | }); 120 | 121 | return ` 122 | 123 | ${label}: 124 | ${value} 129 | ${cardProgress} 130 | 131 | `; 132 | }; 133 | 134 | /** 135 | * @param {import("../fetchers/types").WakaTimeLang[]} languages 136 | */ 137 | const recalculatePercentages = (languages) => { 138 | // recalculating percentages so that, 139 | // compact layout's progress bar does not break when hiding languages 140 | const totalSum = languages.reduce( 141 | (totalSum, language) => totalSum + language.percent, 142 | 0, 143 | ); 144 | const weight = +(100 / totalSum).toFixed(2); 145 | languages.forEach((language) => { 146 | language.percent = +(language.percent * weight).toFixed(2); 147 | }); 148 | }; 149 | 150 | /** 151 | * @param {Partial} stats 152 | * @param {Partial} options 153 | * @returns {string} 154 | */ 155 | const renderWakatimeCard = (stats = {}, options = { hide: [] }) => { 156 | let { languages } = stats; 157 | const { 158 | hide_title = false, 159 | hide_border = false, 160 | hide, 161 | line_height = 25, 162 | title_color, 163 | icon_color, 164 | text_color, 165 | bg_color, 166 | theme = "default", 167 | hide_progress, 168 | custom_title, 169 | locale, 170 | layout, 171 | langs_count = languages ? languages.length : 0, 172 | border_radius, 173 | border_color, 174 | } = options; 175 | 176 | const shouldHideLangs = Array.isArray(hide) && hide.length > 0; 177 | if (shouldHideLangs && languages !== undefined) { 178 | const languagesToHide = new Set(hide.map((lang) => lowercaseTrim(lang))); 179 | languages = languages.filter( 180 | (lang) => !languagesToHide.has(lowercaseTrim(lang.name)), 181 | ); 182 | recalculatePercentages(languages); 183 | } 184 | 185 | const i18n = new I18n({ 186 | locale, 187 | translations: wakatimeCardLocales, 188 | }); 189 | 190 | const lheight = parseInt(String(line_height), 10); 191 | 192 | const langsCount = clampValue(parseInt(String(langs_count)), 1, langs_count); 193 | 194 | // returns theme based colors with proper overrides and defaults 195 | const { titleColor, textColor, iconColor, bgColor, borderColor } = 196 | getCardColors({ 197 | title_color, 198 | icon_color, 199 | text_color, 200 | bg_color, 201 | border_color, 202 | theme, 203 | }); 204 | 205 | const filteredLanguages = languages 206 | ? languages 207 | .filter((language) => language.hours || language.minutes) 208 | .slice(0, langsCount) 209 | : []; 210 | 211 | // Calculate the card height depending on how many items there are 212 | // but if rank circle is visible clamp the minimum height to `150` 213 | let height = Math.max(45 + (filteredLanguages.length + 1) * lheight, 150); 214 | 215 | const cssStyles = getStyles({ 216 | titleColor, 217 | textColor, 218 | iconColor, 219 | }); 220 | 221 | let finalLayout = ""; 222 | 223 | let width = 440; 224 | 225 | // RENDER COMPACT LAYOUT 226 | if (layout === "compact") { 227 | width = width + 50; 228 | height = 90 + Math.round(filteredLanguages.length / 2) * 25; 229 | 230 | // progressOffset holds the previous language's width and used to offset the next language 231 | // so that we can stack them one after another, like this: [--][----][---] 232 | let progressOffset = 0; 233 | const compactProgressBar = filteredLanguages 234 | .map((language) => { 235 | // const progress = (width * lang.percent) / 100; 236 | const progress = ((width - 25) * language.percent) / 100; 237 | 238 | const languageColor = languageColors[language.name] || "#858585"; 239 | 240 | const output = ` 241 | 250 | `; 251 | progressOffset += progress; 252 | return output; 253 | }) 254 | .join(""); 255 | 256 | finalLayout = ` 257 | 258 | 259 | 260 | ${compactProgressBar} 261 | ${createLanguageTextNode({ 262 | x: 0, 263 | y: 25, 264 | langs: filteredLanguages, 265 | totalSize: 100, 266 | }).join("")} 267 | `; 268 | } else { 269 | finalLayout = flexLayout({ 270 | items: filteredLanguages.length 271 | ? filteredLanguages.map((language) => { 272 | return createTextNode({ 273 | id: language.name, 274 | label: language.name, 275 | value: language.text, 276 | percent: language.percent, 277 | // @ts-ignore 278 | progressBarColor: titleColor, 279 | // @ts-ignore 280 | progressBarBackgroundColor: textColor, 281 | hideProgress: hide_progress, 282 | }); 283 | }) 284 | : [ 285 | noCodingActivityNode({ 286 | // @ts-ignore 287 | color: textColor, 288 | text: i18n.t("wakatimecard.nocodingactivity"), 289 | }), 290 | ], 291 | gap: lheight, 292 | direction: "column", 293 | }).join(""); 294 | } 295 | 296 | const card = new Card({ 297 | customTitle: custom_title, 298 | defaultTitle: i18n.t("wakatimecard.title"), 299 | width: 495, 300 | height, 301 | border_radius, 302 | colors: { 303 | titleColor, 304 | textColor, 305 | iconColor, 306 | bgColor, 307 | borderColor, 308 | }, 309 | }); 310 | 311 | card.setHideBorder(hide_border); 312 | card.setHideTitle(hide_title); 313 | card.setCSS( 314 | ` 315 | ${cssStyles} 316 | .lang-name { font: 400 11px 'Segoe UI', Ubuntu, Sans-Serif; fill: ${textColor} } 317 | `, 318 | ); 319 | 320 | return card.render(` 321 | 322 | ${finalLayout} 323 | 324 | `); 325 | }; 326 | 327 | export { renderWakatimeCard }; 328 | export default renderWakatimeCard; 329 | -------------------------------------------------------------------------------- /src/common/Card.js: -------------------------------------------------------------------------------- 1 | import { getAnimations } from "../getStyles.js"; 2 | import { encodeHTML, flexLayout } from "./utils.js"; 3 | 4 | class Card { 5 | /** 6 | * @param {object} args 7 | * @param {number?=} args.width 8 | * @param {number?=} args.height 9 | * @param {number?=} args.border_radius 10 | * @param {string?=} args.customTitle 11 | * @param {string?=} args.defaultTitle 12 | * @param {string?=} args.titlePrefixIcon 13 | * @param {ReturnType?=} args.colors 14 | */ 15 | constructor({ 16 | width = 100, 17 | height = 100, 18 | border_radius = 4.5, 19 | colors = {}, 20 | customTitle, 21 | defaultTitle = "", 22 | titlePrefixIcon, 23 | }) { 24 | this.width = width; 25 | this.height = height; 26 | 27 | this.hideBorder = false; 28 | this.hideTitle = false; 29 | 30 | this.border_radius = border_radius; 31 | 32 | // returns theme based colors with proper overrides and defaults 33 | this.colors = colors; 34 | this.title = 35 | customTitle !== undefined 36 | ? encodeHTML(customTitle) 37 | : encodeHTML(defaultTitle); 38 | 39 | this.css = ""; 40 | 41 | this.paddingX = 25; 42 | this.paddingY = 35; 43 | this.titlePrefixIcon = titlePrefixIcon; 44 | this.animations = true; 45 | this.a11yTitle = ""; 46 | this.a11yDesc = ""; 47 | } 48 | 49 | disableAnimations() { 50 | this.animations = false; 51 | } 52 | 53 | /** 54 | * @param {{title: string, desc: string}} prop 55 | */ 56 | setAccessibilityLabel({ title, desc }) { 57 | this.a11yTitle = title; 58 | this.a11yDesc = desc; 59 | } 60 | 61 | /** 62 | * @param {string} value 63 | */ 64 | setCSS(value) { 65 | this.css = value; 66 | } 67 | 68 | /** 69 | * @param {boolean} value 70 | */ 71 | setHideBorder(value) { 72 | this.hideBorder = value; 73 | } 74 | 75 | /** 76 | * @param {boolean} value 77 | */ 78 | setHideTitle(value) { 79 | this.hideTitle = value; 80 | if (value) { 81 | this.height -= 30; 82 | } 83 | } 84 | 85 | /** 86 | * @param {string} text 87 | */ 88 | setTitle(text) { 89 | this.title = text; 90 | } 91 | 92 | renderTitle() { 93 | const titleText = ` 94 | ${this.title} 100 | `; 101 | 102 | const prefixIcon = ` 103 | 112 | ${this.titlePrefixIcon} 113 | 114 | `; 115 | return ` 116 | 120 | ${flexLayout({ 121 | items: [this.titlePrefixIcon && prefixIcon, titleText], 122 | gap: 25, 123 | }).join("")} 124 | 125 | `; 126 | } 127 | 128 | renderGradient() { 129 | if (typeof this.colors.bgColor !== "object") return ""; 130 | 131 | const gradients = this.colors.bgColor.slice(1); 132 | return typeof this.colors.bgColor === "object" 133 | ? ` 134 | 135 | 140 | ${gradients.map((grad, index) => { 141 | let offset = (index * 100) / (gradients.length - 1); 142 | return ``; 143 | })} 144 | 145 | 146 | ` 147 | : ""; 148 | } 149 | 150 | /** 151 | * @param {string} body 152 | */ 153 | render(body) { 154 | return ` 155 | 164 | ${this.a11yTitle} 165 | ${this.a11yDesc} 166 | 185 | 186 | ${this.renderGradient()} 187 | 188 | 203 | 204 | ${this.hideTitle ? "" : this.renderTitle()} 205 | 206 | 212 | ${body} 213 | 214 | 215 | `; 216 | } 217 | } 218 | 219 | export { Card }; 220 | export default Card; 221 | -------------------------------------------------------------------------------- /src/common/I18n.js: -------------------------------------------------------------------------------- 1 | class I18n { 2 | constructor({ locale, translations }) { 3 | this.locale = locale; 4 | this.translations = translations; 5 | this.fallbackLocale = "en"; 6 | } 7 | 8 | t(str) { 9 | if (!this.translations[str]) { 10 | throw new Error(`${str} Translation string not found`); 11 | } 12 | 13 | if (!this.translations[str][this.locale || this.fallbackLocale]) { 14 | throw new Error(`${str} Translation locale not found`); 15 | } 16 | 17 | return this.translations[str][this.locale || this.fallbackLocale]; 18 | } 19 | } 20 | 21 | export { I18n }; 22 | export default I18n; 23 | -------------------------------------------------------------------------------- /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 | const createProgressNode = ({ 4 | x, 5 | y, 6 | width, 7 | color, 8 | progress, 9 | progressBarBackgroundColor, 10 | }) => { 11 | const progressPercentage = clampValue(progress, 2, 100); 12 | 13 | return ` 14 | 15 | 16 | 23 | 24 | 25 | `; 26 | }; 27 | 28 | export { createProgressNode }; 29 | export default createProgressNode; 30 | -------------------------------------------------------------------------------- /src/common/icons.js: -------------------------------------------------------------------------------- 1 | const icons = { 2 | star: ``, 3 | commits: ``, 4 | prs: ``, 5 | issues: ``, 6 | icon: ``, 7 | contribs: ``, 8 | fork: ``, 9 | }; 10 | 11 | export { icons }; 12 | export default icons; 13 | -------------------------------------------------------------------------------- /src/common/retryer.js: -------------------------------------------------------------------------------- 1 | import { CustomError, logger } from "./utils.js"; 2 | 3 | /** 4 | * Try to execute the fetcher function until it succeeds or the max number of retries is reached. 5 | * 6 | * @param {object[]} retryerParams Object that contains the createTextNode parameters. 7 | * @param {object[]} retryerParams.fetcher The fetcher function. 8 | * @param {object[]} retryerParams.variables Object with arguments to pass to the fetcher function. 9 | * @param {number} retryerParams.retries How many times to retry. 10 | * @returns Promise 11 | */ 12 | const retryer = async (fetcher, variables, retries = 0) => { 13 | if (retries > 7) { 14 | throw new CustomError("Maximum retries exceeded", CustomError.MAX_RETRY); 15 | } 16 | try { 17 | // try to fetch with the first token since RETRIES is 0 index i'm adding +1 18 | let response = await fetcher( 19 | variables, 20 | process.env[`PAT_${retries + 1}`], 21 | retries, 22 | ); 23 | 24 | // prettier-ignore 25 | const isRateExceeded = response.data.errors && response.data.errors[0].type === "RATE_LIMITED"; 26 | 27 | // if rate limit is hit increase the RETRIES and recursively call the retryer 28 | // with username, and current RETRIES 29 | if (isRateExceeded) { 30 | logger.log(`PAT_${retries + 1} Failed`); 31 | retries++; 32 | // directly return from the function 33 | return retryer(fetcher, variables, retries); 34 | } 35 | 36 | // finally return the response 37 | return response; 38 | } catch (err) { 39 | // prettier-ignore 40 | // also checking for bad credentials if any tokens gets invalidated 41 | const isBadCredential = err.response.data && err.response.data.message === "Bad credentials"; 42 | 43 | if (isBadCredential) { 44 | logger.log(`PAT_${retries + 1} Failed`); 45 | retries++; 46 | // directly return from the function 47 | return retryer(fetcher, variables, retries); 48 | } 49 | } 50 | }; 51 | 52 | export { retryer }; 53 | export default retryer; 54 | -------------------------------------------------------------------------------- /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 | * @param {import('Axios').AxiosRequestHeaders} variables 7 | * @param {string} token 8 | */ 9 | const fetcher = (variables, token) => { 10 | return request( 11 | { 12 | query: ` 13 | fragment RepoInfo on Repository { 14 | name 15 | nameWithOwner 16 | isPrivate 17 | isArchived 18 | isTemplate 19 | stargazers { 20 | totalCount 21 | } 22 | description 23 | primaryLanguage { 24 | color 25 | id 26 | name 27 | } 28 | forkCount 29 | } 30 | query getRepo($login: String!, $repo: String!) { 31 | user(login: $login) { 32 | repository(name: $repo) { 33 | ...RepoInfo 34 | } 35 | } 36 | organization(login: $login) { 37 | repository(name: $repo) { 38 | ...RepoInfo 39 | } 40 | } 41 | } 42 | `, 43 | variables, 44 | }, 45 | { 46 | Authorization: `token ${token}`, 47 | }, 48 | ); 49 | }; 50 | 51 | const urlExample = "/api/pin?username=USERNAME&repo=REPO_NAME"; 52 | 53 | /** 54 | * @param {string} username 55 | * @param {string} reponame 56 | * @returns {Promise} 57 | */ 58 | async function fetchRepo(username, reponame) { 59 | if (!username && !reponame) { 60 | throw new MissingParamError(["username", "repo"], urlExample); 61 | } 62 | if (!username) throw new MissingParamError(["username"], urlExample); 63 | if (!reponame) throw new MissingParamError(["repo"], urlExample); 64 | 65 | let res = await retryer(fetcher, { login: username, repo: reponame }); 66 | 67 | const data = res.data.data; 68 | 69 | if (!data.user && !data.organization) { 70 | throw new Error("Not found"); 71 | } 72 | 73 | const isUser = data.organization === null && data.user; 74 | const isOrg = data.user === null && data.organization; 75 | 76 | if (isUser) { 77 | if (!data.user.repository || data.user.repository.isPrivate) { 78 | throw new Error("User Repository Not found"); 79 | } 80 | return { 81 | ...data.user.repository, 82 | starCount: data.user.repository.stargazers.totalCount, 83 | }; 84 | } 85 | 86 | if (isOrg) { 87 | if ( 88 | !data.organization.repository || 89 | data.organization.repository.isPrivate 90 | ) { 91 | throw new Error("Organization Repository Not found"); 92 | } 93 | return { 94 | ...data.organization.repository, 95 | starCount: data.organization.repository.stargazers.totalCount, 96 | }; 97 | } 98 | } 99 | 100 | export { fetchRepo }; 101 | export default fetchRepo; 102 | -------------------------------------------------------------------------------- /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 | } from "../common/utils.js"; 13 | 14 | dotenv.config(); 15 | 16 | /** 17 | * @param {import('axios').AxiosRequestHeaders} variables 18 | * @param {string} token 19 | */ 20 | const fetcher = (variables, token) => { 21 | return request( 22 | { 23 | query: ` 24 | query userInfo($login: String!) { 25 | user(login: $login) { 26 | name 27 | login 28 | contributionsCollection { 29 | totalCommitContributions 30 | restrictedContributionsCount 31 | } 32 | repositoriesContributedTo(contributionTypes: [COMMIT, ISSUE, PULL_REQUEST, REPOSITORY]) { 33 | totalCount 34 | } 35 | pullRequests { 36 | totalCount 37 | } 38 | openIssues: issues(states: OPEN) { 39 | totalCount 40 | } 41 | closedIssues: issues(states: CLOSED) { 42 | totalCount 43 | } 44 | followers { 45 | totalCount 46 | } 47 | repositories(ownerAffiliations: OWNER) { 48 | totalCount 49 | } 50 | } 51 | } 52 | `, 53 | variables, 54 | }, 55 | { 56 | Authorization: `bearer ${token}`, 57 | }, 58 | ); 59 | }; 60 | 61 | /** 62 | * @param {import('axios').AxiosRequestHeaders} variables 63 | * @param {string} token 64 | */ 65 | const repositoriesFetcher = (variables, token) => { 66 | return request( 67 | { 68 | query: ` 69 | query userInfo($login: String!, $after: String) { 70 | user(login: $login) { 71 | repositories(first: 100, ownerAffiliations: OWNER, orderBy: {direction: DESC, field: STARGAZERS}, after: $after) { 72 | nodes { 73 | name 74 | stargazers { 75 | totalCount 76 | } 77 | } 78 | pageInfo { 79 | hasNextPage 80 | endCursor 81 | } 82 | } 83 | } 84 | } 85 | `, 86 | variables, 87 | }, 88 | { 89 | Authorization: `bearer ${token}`, 90 | }, 91 | ); 92 | }; 93 | 94 | // https://github.com/anuraghazra/github-readme-stats/issues/92#issuecomment-661026467 95 | // https://github.com/anuraghazra/github-readme-stats/pull/211/ 96 | const totalCommitsFetcher = async (username) => { 97 | if (!githubUsernameRegex.test(username)) { 98 | logger.log("Invalid username"); 99 | return 0; 100 | } 101 | 102 | // https://developer.github.com/v3/search/#search-commits 103 | const fetchTotalCommits = (variables, token) => { 104 | return axios({ 105 | method: "get", 106 | url: `https://api.github.com/search/commits?q=author:${variables.login}`, 107 | headers: { 108 | "Content-Type": "application/json", 109 | Accept: "application/vnd.github.cloak-preview", 110 | Authorization: `token ${token}`, 111 | }, 112 | }); 113 | }; 114 | 115 | try { 116 | let res = await retryer(fetchTotalCommits, { login: username }); 117 | let total_count = res.data.total_count; 118 | if (!!total_count && !isNaN(total_count)) { 119 | return res.data.total_count; 120 | } 121 | } catch (err) { 122 | logger.log(err); 123 | } 124 | // just return 0 if there is something wrong so that 125 | // we don't break the whole app 126 | return 0; 127 | }; 128 | 129 | /** 130 | * Fetch all the stars for all the repositories of a given username 131 | * @param {string} username 132 | * @param {array} repoToHide 133 | */ 134 | const totalStarsFetcher = async (username, repoToHide) => { 135 | let nodes = []; 136 | let hasNextPage = true; 137 | let endCursor = null; 138 | while (hasNextPage) { 139 | const variables = { login: username, first: 100, after: endCursor }; 140 | let res = await retryer(repositoriesFetcher, variables); 141 | 142 | if (res.data.errors) { 143 | logger.error(res.data.errors); 144 | throw new CustomError( 145 | res.data.errors[0].message || "Could not fetch user", 146 | CustomError.USER_NOT_FOUND, 147 | ); 148 | } 149 | 150 | const allNodes = res.data.data.user.repositories.nodes; 151 | const nodesWithStars = allNodes.filter( 152 | (node) => node.stargazers.totalCount !== 0, 153 | ); 154 | nodes.push(...nodesWithStars); 155 | // hasNextPage = 156 | // allNodes.length === nodesWithStars.length && 157 | // res.data.data.user.repositories.pageInfo.hasNextPage; 158 | hasNextPage = false // NOTE: Temporarily disable fetching of multiple pages. 159 | endCursor = res.data.data.user.repositories.pageInfo.endCursor; 160 | } 161 | 162 | return nodes 163 | .filter((data) => !repoToHide[data.name]) 164 | .reduce((prev, curr) => prev + curr.stargazers.totalCount, 0); 165 | }; 166 | 167 | /** 168 | * @param {string} username 169 | * @param {boolean} count_private 170 | * @param {boolean} include_all_commits 171 | * @returns {Promise} 172 | */ 173 | async function fetchStats( 174 | username, 175 | count_private = false, 176 | include_all_commits = false, 177 | exclude_repo = [], 178 | ) { 179 | if (!username) throw new MissingParamError(["username"]); 180 | 181 | const stats = { 182 | name: "", 183 | totalPRs: 0, 184 | totalCommits: 0, 185 | totalIssues: 0, 186 | totalStars: 0, 187 | contributedTo: 0, 188 | rank: { level: "C", score: 0 }, 189 | }; 190 | 191 | let res = await retryer(fetcher, { login: username }); 192 | 193 | if (res.data.errors) { 194 | logger.error(res.data.errors); 195 | throw new CustomError( 196 | res.data.errors[0].message || "Could not fetch user", 197 | CustomError.USER_NOT_FOUND, 198 | ); 199 | } 200 | 201 | const user = res.data.data.user; 202 | 203 | // populate repoToHide map for quick lookup 204 | // while filtering out 205 | let repoToHide = {}; 206 | if (exclude_repo) { 207 | exclude_repo.forEach((repoName) => { 208 | repoToHide[repoName] = true; 209 | }); 210 | } 211 | 212 | stats.name = user.name || user.login; 213 | stats.totalIssues = user.openIssues.totalCount + user.closedIssues.totalCount; 214 | 215 | // normal commits 216 | stats.totalCommits = user.contributionsCollection.totalCommitContributions; 217 | 218 | // if include_all_commits then just get that, 219 | // since totalCommitsFetcher already sends totalCommits no need to += 220 | if (include_all_commits) { 221 | stats.totalCommits = await totalCommitsFetcher(username); 222 | } 223 | 224 | // if count_private then add private commits to totalCommits so far. 225 | if (count_private) { 226 | stats.totalCommits += 227 | user.contributionsCollection.restrictedContributionsCount; 228 | } 229 | 230 | stats.totalPRs = user.pullRequests.totalCount; 231 | stats.contributedTo = user.repositoriesContributedTo.totalCount; 232 | 233 | // Retrieve stars while filtering out repositories to be hidden 234 | stats.totalStars = await totalStarsFetcher(username, repoToHide); 235 | 236 | stats.rank = calculateRank({ 237 | totalCommits: stats.totalCommits, 238 | totalRepos: user.repositories.totalCount, 239 | followers: user.followers.totalCount, 240 | contributions: stats.contributedTo, 241 | stargazers: stats.totalStars, 242 | prs: stats.totalPRs, 243 | issues: stats.totalIssues, 244 | }); 245 | 246 | return stats; 247 | } 248 | 249 | export { fetchStats }; 250 | export default fetchStats; 251 | -------------------------------------------------------------------------------- /src/fetchers/top-languages-fetcher.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | import * as dotenv from "dotenv"; 3 | import { retryer } from "../common/retryer.js"; 4 | import { logger, MissingParamError, request } from "../common/utils.js"; 5 | 6 | dotenv.config(); 7 | 8 | /** 9 | * @param {import('Axios').AxiosRequestHeaders} variables 10 | * @param {string} token 11 | */ 12 | const fetcher = (variables, token) => { 13 | return request( 14 | { 15 | query: ` 16 | query userInfo($login: String!) { 17 | user(login: $login) { 18 | # fetch only owner repos & not forks 19 | repositories(ownerAffiliations: OWNER, isFork: false, first: 100) { 20 | nodes { 21 | name 22 | languages(first: 10, orderBy: {field: SIZE, direction: DESC}) { 23 | edges { 24 | size 25 | node { 26 | color 27 | name 28 | } 29 | } 30 | } 31 | } 32 | } 33 | } 34 | } 35 | `, 36 | variables, 37 | }, 38 | { 39 | Authorization: `token ${token}`, 40 | }, 41 | ); 42 | }; 43 | 44 | /** 45 | * @param {string} username 46 | * @param {string[]} exclude_repo 47 | * @returns {Promise} 48 | */ 49 | async function fetchTopLanguages(username, exclude_repo = []) { 50 | if (!username) throw new MissingParamError(["username"]); 51 | 52 | const res = await retryer(fetcher, { login: username }); 53 | 54 | if (res.data.errors) { 55 | logger.error(res.data.errors); 56 | throw Error(res.data.errors[0].message || "Could not fetch user"); 57 | } 58 | 59 | let repoNodes = res.data.data.user.repositories.nodes; 60 | let repoToHide = {}; 61 | 62 | // populate repoToHide map for quick lookup 63 | // while filtering out 64 | if (exclude_repo) { 65 | exclude_repo.forEach((repoName) => { 66 | repoToHide[repoName] = true; 67 | }); 68 | } 69 | 70 | // filter out repositories to be hidden 71 | repoNodes = repoNodes 72 | .sort((a, b) => b.size - a.size) 73 | .filter((name) => !repoToHide[name.name]); 74 | 75 | repoNodes = repoNodes 76 | .filter((node) => node.languages.edges.length > 0) 77 | // flatten the list of language nodes 78 | .reduce((acc, curr) => curr.languages.edges.concat(acc), []) 79 | .reduce((acc, prev) => { 80 | // get the size of the language (bytes) 81 | let langSize = prev.size; 82 | 83 | // if we already have the language in the accumulator 84 | // & the current language name is same as previous name 85 | // add the size to the language size. 86 | if (acc[prev.node.name] && prev.node.name === acc[prev.node.name].name) { 87 | langSize = prev.size + acc[prev.node.name].size; 88 | } 89 | return { 90 | ...acc, 91 | [prev.node.name]: { 92 | name: prev.node.name, 93 | color: prev.node.color, 94 | size: langSize, 95 | }, 96 | }; 97 | }, {}); 98 | 99 | const topLangs = Object.keys(repoNodes) 100 | .sort((a, b) => repoNodes[b].size - repoNodes[a].size) 101 | .reduce((result, key) => { 102 | result[key] = repoNodes[key]; 103 | return result; 104 | }, {}); 105 | 106 | return topLangs; 107 | } 108 | 109 | export { fetchTopLanguages }; 110 | export default fetchTopLanguages; 111 | -------------------------------------------------------------------------------- /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 | * @param {{username: string, api_domain: string, range: string}} props 6 | * @returns {Promise} 7 | */ 8 | const fetchWakatimeStats = async ({ username, api_domain, range }) => { 9 | if (!username) throw new MissingParamError(["username"]); 10 | 11 | try { 12 | const { data } = await axios.get( 13 | `https://${ 14 | api_domain ? api_domain.replace(/\/$/gi, "") : "wakatime.com" 15 | }/api/v1/users/${username}/stats/${range || ""}?is_including_today=true`, 16 | ); 17 | 18 | return data.data; 19 | } catch (err) { 20 | if (err.response.status < 200 || err.response.status > 299) { 21 | throw new Error( 22 | "Wakatime user not found, make sure you have a wakatime profile", 23 | ); 24 | } 25 | throw err; 26 | } 27 | }; 28 | 29 | export { fetchWakatimeStats }; 30 | export default fetchWakatimeStats; 31 | -------------------------------------------------------------------------------- /src/getStyles.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | /** 3 | * @param {number} value 4 | */ 5 | const calculateCircleProgress = (value) => { 6 | const radius = 40; 7 | const c = Math.PI * (radius * 2); 8 | 9 | if (value < 0) value = 0; 10 | if (value > 100) value = 100; 11 | 12 | return ((100 - value) / 100) * c; 13 | }; 14 | 15 | /** 16 | * 17 | * @param {{progress: number}} param0 18 | * @returns 19 | */ 20 | const getProgressAnimation = ({ progress }) => { 21 | return ` 22 | @keyframes rankAnimation { 23 | from { 24 | stroke-dashoffset: ${calculateCircleProgress(0)}; 25 | } 26 | to { 27 | stroke-dashoffset: ${calculateCircleProgress(progress)}; 28 | } 29 | } 30 | `; 31 | }; 32 | 33 | const getAnimations = () => { 34 | return ` 35 | /* Animations */ 36 | @keyframes scaleInAnimation { 37 | from { 38 | transform: translate(-5px, 5px) scale(0); 39 | } 40 | to { 41 | transform: translate(-5px, 5px) scale(1); 42 | } 43 | } 44 | @keyframes fadeInAnimation { 45 | from { 46 | opacity: 0; 47 | } 48 | to { 49 | opacity: 1; 50 | } 51 | } 52 | `; 53 | }; 54 | 55 | /** 56 | * @param {{ 57 | * titleColor?: string | string[] 58 | * textColor?: string | string[] 59 | * iconColor?: string | string[] 60 | * show_icons?: boolean; 61 | * progress?: number; 62 | * }} args 63 | */ 64 | const getStyles = ({ 65 | titleColor, 66 | textColor, 67 | iconColor, 68 | show_icons, 69 | progress, 70 | }) => { 71 | return ` 72 | .stat { 73 | font: 600 14px 'Segoe UI', Ubuntu, "Helvetica Neue", Sans-Serif; fill: ${textColor}; 74 | } 75 | @supports(-moz-appearance: auto) { 76 | /* Selector detects Firefox */ 77 | .stat { font-size:12px; } 78 | } 79 | .stagger { 80 | opacity: 0; 81 | animation: fadeInAnimation 0.3s ease-in-out forwards; 82 | } 83 | .rank-text { 84 | font: 800 24px 'Segoe UI', Ubuntu, Sans-Serif; fill: ${textColor}; 85 | animation: scaleInAnimation 0.3s ease-in-out forwards; 86 | } 87 | 88 | .not_bold { font-weight: 400 } 89 | .bold { font-weight: 700 } 90 | .icon { 91 | fill: ${iconColor}; 92 | display: ${!!show_icons ? "block" : "none"}; 93 | } 94 | 95 | .rank-circle-rim { 96 | stroke: ${titleColor}; 97 | fill: none; 98 | stroke-width: 6; 99 | opacity: 0.2; 100 | } 101 | .rank-circle { 102 | stroke: ${titleColor}; 103 | stroke-dasharray: 250; 104 | fill: none; 105 | stroke-width: 6; 106 | stroke-linecap: round; 107 | opacity: 0.8; 108 | transform-origin: -10px 8px; 109 | transform: rotate(-90deg); 110 | animation: rankAnimation 1s forwards ease-in-out; 111 | } 112 | ${process.env.NODE_ENV === "test" ? "" : getProgressAnimation({ progress })} 113 | `; 114 | }; 115 | 116 | export { getStyles, getAnimations }; 117 | -------------------------------------------------------------------------------- /src/translations.js: -------------------------------------------------------------------------------- 1 | import { encodeHTML } from "./common/utils.js"; 2 | 3 | const statCardLocales = ({ name, apostrophe }) => { 4 | const encodedName = encodeHTML(name); 5 | return { 6 | "statcard.title": { 7 | ar: `${encodedName} إحصائيات غيت هاب`, 8 | cn: `${encodedName} 的 GitHub 统计数据`, 9 | "zh-tw": `${encodedName} 的 GitHub 統計數據`, 10 | cs: `GitHub statistiky uživatele ${encodedName}`, 11 | de: `${encodedName + apostrophe} GitHub-Statistiken`, 12 | en: `${encodedName}'${apostrophe} GitHub Stats`, 13 | bn: `${encodedName} এর GitHub পরিসংখ্যান`, 14 | es: `Estadísticas de GitHub de ${encodedName}`, 15 | fr: `Statistiques GitHub de ${encodedName}`, 16 | hu: `${encodedName} GitHub statisztika`, 17 | it: `Statistiche GitHub di ${encodedName}`, 18 | ja: `${encodedName}の GitHub 統計`, 19 | kr: `${encodedName}의 GitHub 통계`, 20 | nl: `${encodedName}'${apostrophe} GitHub-statistieken`, 21 | "pt-pt": `Estatísticas do GitHub de ${encodedName}`, 22 | "pt-br": `Estatísticas do GitHub de ${encodedName}`, 23 | np: `${encodedName}'${apostrophe} गिटहब तथ्याङ्क`, 24 | el: `Στατιστικά GitHub του ${encodedName}`, 25 | ru: `Статистика GitHub пользователя ${encodedName}`, 26 | "uk-ua": `Статистика GitHub користувача ${encodedName}`, 27 | id: `Statistik GitHub ${encodedName}`, 28 | ml: `${encodedName}'${apostrophe} ഗിറ്റ്ഹബ് സ്ഥിതിവിവരക്കണക്കുകൾ`, 29 | my: `Statistik GitHub ${encodedName}`, 30 | sk: `GitHub štatistiky používateľa ${encodedName}`, 31 | tr: `${encodedName} Hesabının GitHub Yıldızları`, 32 | pl: `Statystyki GitHub użytkownika ${encodedName}`, 33 | uz: `${encodedName}ning Github'dagi statistikasi`, 34 | vi: `Thống Kê GitHub ${encodedName}`, 35 | se: `GitHubstatistik för ${encodedName}`, 36 | }, 37 | "statcard.totalstars": { 38 | ar: "مجموع النجوم", 39 | cn: "获标星数(star)", 40 | "zh-tw": "獲標星數(star)", 41 | cs: "Celkem hvězd", 42 | de: "Insgesamt erhaltene Sterne", 43 | en: "Total Stars Earned", 44 | bn: "সর্বমোট Star", 45 | es: "Estrellas totales", 46 | fr: "Total d'étoiles", 47 | hu: "Csillagok", 48 | it: "Stelle totali", 49 | ja: "スターされた数", 50 | kr: "받은 스타 수", 51 | nl: "Totaal Sterren Ontvangen", 52 | "pt-pt": "Total de estrelas", 53 | "pt-br": "Total de estrelas", 54 | np: "कुल ताराहरू", 55 | el: "Σύνολο Αστεριών", 56 | ru: "Всего звезд", 57 | "uk-ua": "Всього зірок", 58 | id: "Total Bintang", 59 | ml: "ആകെ നക്ഷത്രങ്ങൾ", 60 | my: "Jumlah Bintang", 61 | sk: "Hviezdy", 62 | tr: "Toplam Yıldız", 63 | pl: "Liczba Gwiazdek dostanych", 64 | uz: "Yulduzchalar", 65 | vi: "Tổng Số Sao", 66 | se: "Antal intjänade stjärnor", 67 | }, 68 | "statcard.commits": { 69 | ar: "مجموع الحفظ", 70 | cn: "累计提交数(commit)", 71 | "zh-tw": "累計提交數(commit)", 72 | cs: "Celkem commitů", 73 | de: "Anzahl Commits", 74 | en: "Total Commits", 75 | bn: "সর্বমোট Commit", 76 | es: "Commits totales", 77 | fr: "Total des Commits", 78 | hu: "Összes commit", 79 | it: "Commit totali", 80 | ja: "合計コミット数", 81 | kr: "전체 커밋 수", 82 | nl: "Aantal commits", 83 | "pt-pt": "Total de Commits", 84 | "pt-br": "Total de Commits", 85 | np: "कुल Commits", 86 | el: "Σύνολο Commits", 87 | ru: "Всего коммитов", 88 | "uk-ua": "Всього коммітов", 89 | id: "Total Komitmen", 90 | ml: "ആകെ കമ്മിറ്റുകൾ", 91 | my: "Jumlah Komitmen", 92 | sk: "Všetky commity", 93 | tr: "Toplam Commit", 94 | pl: "Wszystkie commity", 95 | uz: "'Commit'lar", 96 | vi: "Tổng Số Cam Kết", 97 | se: "Totalt antal commits", 98 | }, 99 | "statcard.prs": { 100 | ar: "مجموع طلبات السحب", 101 | cn: "拉取请求数(PR)", 102 | "zh-tw": "拉取請求數(PR)", 103 | cs: "Celkem PRs", 104 | de: "PRs Insgesamt", 105 | en: "Total PRs", 106 | bn: "সর্বমোট PR", 107 | es: "PRs totales", 108 | fr: "Total des PRs", 109 | hu: "Összes PR", 110 | it: "PR totali", 111 | ja: "合計 PR", 112 | kr: "PR 횟수", 113 | nl: "Aantal PR's", 114 | "pt-pt": "Total de PRs", 115 | "pt-br": "Total de PRs", 116 | np: "कुल PRs", 117 | el: "Σύνολο PRs", 118 | ru: "Всего pull request`ов", 119 | "uk-ua": "Всього pull request`iв", 120 | id: "Total Permintaan Tarik", 121 | ml: "ആകെ പുൾ അഭ്യർത്ഥനകൾ", 122 | my: "Jumlah PR", 123 | sk: "Všetky PR", 124 | tr: "Toplam PR", 125 | pl: "Wszystkie PR-y", 126 | uz: "'Pull Request'lar", 127 | vi: "Tổng Số PR", 128 | se: "Totalt antal PR", 129 | }, 130 | "statcard.issues": { 131 | ar: "مجموع التحسينات", 132 | cn: "指出问题数(issue)", 133 | "zh-tw": "指出問題數(issue)", 134 | cs: "Celkem problémů", 135 | de: "Anzahl Issues", 136 | en: "Total Issues", 137 | bn: "সর্বমোট Issue", 138 | es: "Issues totales", 139 | fr: "Nombre total d'incidents", 140 | hu: "Összes hibajegy", 141 | it: "Segnalazioni totali", 142 | ja: "合計 issue", 143 | kr: "이슈 개수", 144 | nl: "Aantal kwesties", 145 | "pt-pt": "Total de Issues", 146 | "pt-br": "Total de Issues", 147 | np: "कुल मुद्दाहरू", 148 | el: "Σύνολο Ζητημάτων", 149 | ru: "Всего issue", 150 | "uk-ua": "Всього issue", 151 | id: "Total Masalah Dilaporkan", 152 | ml: "ആകെ ലക്കങ്ങൾ", 153 | my: "Jumlah Isu Dilaporkan", 154 | sk: "Všetky problémy", 155 | tr: "Toplam Hata", 156 | pl: "Wszystkie Issues", 157 | uz: "'Issue'lar", 158 | vi: "Tổng Số Vấn Đề", 159 | se: "Total antal issues", 160 | }, 161 | "statcard.contribs": { 162 | ar: "ساهم في", 163 | cn: "参与项目数", 164 | "zh-tw": "參與項目數", 165 | cs: "Přispěl k", 166 | de: "Beigetragen zu", 167 | en: "Contributed to", 168 | bn: "অবদান রেখেছেন", 169 | es: "Contribuciones en", 170 | fr: "Contribué à", 171 | hu: "Hozzájárulások", 172 | it: "Ha contribuito a", 173 | ja: "貢献したリポジトリ", 174 | kr: "전체 기여도", 175 | nl: "Bijgedragen aan", 176 | "pt-pt": "Contribuiu em", 177 | "pt-br": "Contribuiu para", 178 | np: "कुल योगदानहरू", 179 | el: "Συνεισφέρθηκε σε", 180 | ru: "Внёс вклад в", 181 | "uk-ua": "Вніс внесок у", 182 | id: "Berkontribusi ke", 183 | ml: "സമർപ്പിച്ചിരിക്കുന്നത്", 184 | my: "Menyumbang kepada", 185 | sk: "Účasti", 186 | tr: "Katkı Verildi", 187 | pl: "Kontrybucje", 188 | uz: "Hissa qoʻshgan", 189 | vi: "Đã Đóng Góp", 190 | se: "Bidragit till", 191 | }, 192 | }; 193 | }; 194 | 195 | const repoCardLocales = { 196 | "repocard.template": { 197 | ar: "قالب", 198 | bn: "টেমপ্লেট", 199 | cn: "模板", 200 | "zh-tw": "模板", 201 | cs: "Šablona", 202 | de: "Vorlage", 203 | en: "Template", 204 | es: "Plantilla", 205 | fr: "Modèle", 206 | hu: "Sablon", 207 | it: "Template", 208 | ja: "テンプレート", 209 | kr: "템플릿", 210 | nl: "Sjabloon", 211 | "pt-pt": "Modelo", 212 | "pt-br": "Modelo", 213 | np: "टेम्पलेट", 214 | el: "Πρότυπο", 215 | ru: "Шаблон", 216 | "uk-ua": "Шаблон", 217 | id: "Pola", 218 | ml: "ടെംപ്ലേറ്റ്", 219 | my: "Templat", 220 | sk: "Šablóna", 221 | tr: "Şablon", 222 | pl: "Szablony", 223 | vi: "Mẫu", 224 | se: "Mall", 225 | }, 226 | "repocard.archived": { 227 | ar: "محفوظ", 228 | bn: "আর্কাইভড", 229 | cn: "已归档", 230 | "zh-tw": "已歸檔", 231 | cs: "Archivováno", 232 | de: "Archiviert", 233 | en: "Archived", 234 | es: "Archivados", 235 | fr: "Archivé", 236 | hu: "Archivált", 237 | it: "Archiviata", 238 | ja: "アーカイブ済み", 239 | kr: "보관됨", 240 | nl: "Gearchiveerd", 241 | "pt-pt": "Arquivados", 242 | "pt-br": "Arquivados", 243 | np: "अभिलेख राखियो", 244 | el: "Αρχειοθετημένα", 245 | ru: "Архивирован", 246 | "uk-ua": "Архивирован", 247 | id: "Arsip", 248 | ml: "ശേഖരിച്ചത്", 249 | my: "Arkib", 250 | sk: "Archivované", 251 | tr: "Arşiv", 252 | pl: "Zarchiwizowano", 253 | vi: "Đã Lưu Trữ", 254 | se: "Arkiverade", 255 | }, 256 | }; 257 | 258 | const langCardLocales = { 259 | "langcard.title": { 260 | ar: "أكثر اللغات إستخداماً", 261 | cn: "最常用的语言", 262 | "zh-tw": "最常用的語言", 263 | cs: "Nejpoužívanější jazyky", 264 | de: "Meist verwendete Sprachen", 265 | bn: "সর্বাধিক ব্যবহৃত ভাষা সমূহ", 266 | en: "Most Used Languages", 267 | es: "Lenguajes más usados", 268 | fr: "Langages les plus utilisés", 269 | hu: "Leggyakrabban használt nyelvek", 270 | it: "Linguaggi più utilizzati", 271 | ja: "最もよく使っている言語", 272 | kr: "가장 많이 사용된 언어", 273 | nl: "Meest gebruikte talen", 274 | "pt-pt": "Idiomas mais usados", 275 | "pt-br": "Linguagens mais usadas", 276 | np: "अधिक प्रयोग गरिएको भाषाहरू", 277 | el: "Οι περισσότερο χρησιμοποιούμενες γλώσσες", 278 | ru: "Наиболее часто используемые языки", 279 | "uk-ua": "Найбільш часто використовувані мови", 280 | id: "Bahasa Yang Paling Banyak Digunakan", 281 | ml: "കൂടുതൽ ഉപയോഗിച്ച ഭാഷകൾ", 282 | my: "Bahasa Paling Digunakan", 283 | sk: "Najviac používané jazyky", 284 | tr: "En Çok Kullanılan Diller", 285 | pl: "Najczęściej używane języki", 286 | vi: "Ngôn Ngữ Thường Sử Dụng", 287 | se: "Mest använda språken", 288 | }, 289 | }; 290 | 291 | const wakatimeCardLocales = { 292 | "wakatimecard.title": { 293 | ar: "إحصائيات واكا تايم", 294 | cn: "Wakatime 周统计", 295 | "zh-tw": "Wakatime 周統計", 296 | cs: "Statistiky Wakatime", 297 | de: "Wakatime Status", 298 | en: "Wakatime Stats", 299 | bn: "Wakatime স্ট্যাটাস", 300 | es: "Estadísticas de Wakatime", 301 | fr: "Statistiques de Wakatime", 302 | hu: "Wakatime statisztika", 303 | it: "Statistiche Wakatime", 304 | ja: "Wakatime ワカタイム統計", 305 | kr: "Wakatime 주간 통계", 306 | nl: "Wakatime-statistieken", 307 | "pt-pt": "Estatísticas Wakatime", 308 | "pt-br": "Estatísticas Wakatime", 309 | np: "Wakatime तथ्या .्क", 310 | el: "Στατιστικά Wakatime", 311 | ru: "Статистика Wakatime", 312 | "uk-ua": "Статистика Wakatime", 313 | id: "Status Wakatime", 314 | ml: "വേക്ക് ടൈം സ്ഥിതിവിവരക്കണക്കുകൾ", 315 | my: "Statistik Wakatime", 316 | sk: "Wakatime štatistika", 317 | tr: "Waketime İstatistikler", 318 | pl: "statystyki Wakatime", 319 | vi: "Thống Kê Wakatime", 320 | se: "Wakatime statistik", 321 | }, 322 | "wakatimecard.nocodingactivity": { 323 | ar: "لا يوجد نشاط برمجي لهذا الأسبوع", 324 | cn: "本周没有编程活动", 325 | "zh-tw": "本周沒有編程活動", 326 | cs: "Tento týden žádná aktivita v kódování", 327 | de: "Keine Aktivitäten in dieser Woche", 328 | en: "No coding activity this week", 329 | bn: "এই সপ্তাহে কোন কোডিং অ্যাক্টিভিটি নেই", 330 | es: "No hay actividad de codificación esta semana", 331 | fr: "Aucune activité de codage cette semaine", 332 | hu: "Nem volt aktivitás ezen a héten", 333 | it: "Nessuna attività in questa settimana", 334 | ja: "今週のコーディング活動はありません", 335 | kr: "이번 주 작업내역 없음", 336 | nl: "Geen programmeeractiviteit deze week", 337 | "pt-pt": "Sem atividade esta semana", 338 | "pt-br": "Nenhuma atividade de codificação esta semana", 339 | np: "यस हप्ता कुनै कोडिंग गतिविधि छैन", 340 | el: "Δεν υπάρχει δραστηριότητα κώδικα γι' αυτή την εβδομάδα", 341 | ru: "На этой неделе не было активности", 342 | "uk-ua": "На цьому тижні не було активності", 343 | id: "Tidak ada aktivitas perkodingan minggu ini", 344 | ml: "ഈ ആഴ്ച കോഡിംഗ് പ്രവർത്തനങ്ങളൊന്നുമില്ല", 345 | my: "Tiada aktiviti pengekodan minggu ini", 346 | sk: "Žiadna kódovacia aktivita tento týždeň", 347 | tr: "Bu hafta herhangi bir kod yazma aktivitesi olmadı", 348 | pl: "Brak aktywności w tym tygodniu", 349 | uz: "Bu hafta faol bo'lmadi", 350 | vi: "Không Có Hoạt Động Trong Tuần Này", 351 | se: "Ingen aktivitet denna vecka", 352 | }, 353 | }; 354 | 355 | const availableLocales = Object.keys(repoCardLocales["repocard.archived"]); 356 | 357 | function isLocaleAvailable(locale) { 358 | return availableLocales.includes(locale.toLowerCase()); 359 | } 360 | 361 | export { 362 | isLocaleAvailable, 363 | availableLocales, 364 | statCardLocales, 365 | repoCardLocales, 366 | langCardLocales, 367 | wakatimeCardLocales, 368 | }; 369 | -------------------------------------------------------------------------------- /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 | 78 | 79 | 80 | 81 | 92 | 93 | 94 | 98 | 99 | Wakatime Stats 105 | 106 | 107 | 108 | 109 | 113 | 114 | 115 | 116 | 117 | 118 | 119 | 120 | 129 | 130 | 139 | 140 | 141 | 142 | 143 | 144 | Other - 19 mins 145 | 146 | 147 | 148 | 149 | 150 | 151 | TypeScript - 1 min 152 | 153 | 154 | 155 | 156 | 157 | 158 | 159 | 160 | " 161 | `; 162 | -------------------------------------------------------------------------------- /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 = { 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 | }, 44 | }, 45 | }, 46 | }; 47 | 48 | const repositoriesData = { 49 | data: { 50 | user: { 51 | repositories: { 52 | nodes: [{ stargazers: { totalCount: 100 } }], 53 | pageInfo: { 54 | hasNextPage: false, 55 | cursor: "cursor", 56 | }, 57 | }, 58 | }, 59 | }, 60 | }; 61 | 62 | const error = { 63 | errors: [ 64 | { 65 | type: "NOT_FOUND", 66 | path: ["user"], 67 | locations: [], 68 | message: "Could not fetch user", 69 | }, 70 | ], 71 | }; 72 | 73 | const mock = new MockAdapter(axios); 74 | 75 | const faker = (query, data) => { 76 | const req = { 77 | query: { 78 | username: "anuraghazra", 79 | ...query, 80 | }, 81 | }; 82 | const res = { 83 | setHeader: jest.fn(), 84 | send: jest.fn(), 85 | }; 86 | mock 87 | .onPost("https://api.github.com/graphql") 88 | .replyOnce(200, data) 89 | .onPost("https://api.github.com/graphql") 90 | .replyOnce(200, repositoriesData); 91 | 92 | return { req, res }; 93 | }; 94 | 95 | afterEach(() => { 96 | mock.reset(); 97 | }); 98 | 99 | describe("Test /api/", () => { 100 | it("should test the request", async () => { 101 | const { req, res } = faker({}, data); 102 | 103 | await api(req, res); 104 | 105 | expect(res.setHeader).toBeCalledWith("Content-Type", "image/svg+xml"); 106 | expect(res.send).toBeCalledWith(renderStatsCard(stats, { ...req.query })); 107 | }); 108 | 109 | it("should render error card on error", async () => { 110 | const { req, res } = faker({}, error); 111 | 112 | await api(req, res); 113 | 114 | expect(res.setHeader).toBeCalledWith("Content-Type", "image/svg+xml"); 115 | expect(res.send).toBeCalledWith( 116 | renderError( 117 | error.errors[0].message, 118 | "Make sure the provided username is not an organization", 119 | ), 120 | ); 121 | }); 122 | 123 | it("should get the query options", async () => { 124 | const { req, res } = faker( 125 | { 126 | username: "anuraghazra", 127 | hide: "issues,prs,contribs", 128 | show_icons: true, 129 | hide_border: true, 130 | line_height: 100, 131 | title_color: "fff", 132 | icon_color: "fff", 133 | text_color: "fff", 134 | bg_color: "fff", 135 | }, 136 | data, 137 | ); 138 | 139 | await api(req, res); 140 | 141 | expect(res.setHeader).toBeCalledWith("Content-Type", "image/svg+xml"); 142 | expect(res.send).toBeCalledWith( 143 | renderStatsCard(stats, { 144 | hide: ["issues", "prs", "contribs"], 145 | show_icons: true, 146 | hide_border: true, 147 | line_height: 100, 148 | title_color: "fff", 149 | icon_color: "fff", 150 | text_color: "fff", 151 | bg_color: "fff", 152 | }), 153 | ); 154 | }); 155 | 156 | it("should have proper cache", async () => { 157 | const { req, res } = faker({}, data); 158 | 159 | await api(req, res); 160 | 161 | expect(res.setHeader.mock.calls).toEqual([ 162 | ["Content-Type", "image/svg+xml"], 163 | ["Cache-Control", `public, max-age=${CONSTANTS.FOUR_HOURS}`], 164 | ]); 165 | }); 166 | 167 | it("should set proper cache", async () => { 168 | const { req, res } = faker({ cache_seconds: 15000 }, data); 169 | await api(req, res); 170 | 171 | expect(res.setHeader.mock.calls).toEqual([ 172 | ["Content-Type", "image/svg+xml"], 173 | ["Cache-Control", `public, max-age=${15000}`], 174 | ]); 175 | }); 176 | 177 | it("should set proper cache with clamped values", async () => { 178 | { 179 | let { req, res } = faker({ cache_seconds: 200000 }, data); 180 | await api(req, res); 181 | 182 | expect(res.setHeader.mock.calls).toEqual([ 183 | ["Content-Type", "image/svg+xml"], 184 | ["Cache-Control", `public, max-age=${CONSTANTS.ONE_DAY}`], 185 | ]); 186 | } 187 | 188 | // note i'm using block scoped vars 189 | { 190 | let { req, res } = faker({ cache_seconds: 0 }, data); 191 | await api(req, res); 192 | 193 | expect(res.setHeader.mock.calls).toEqual([ 194 | ["Content-Type", "image/svg+xml"], 195 | ["Cache-Control", `public, max-age=${CONSTANTS.FOUR_HOURS}`], 196 | ]); 197 | } 198 | 199 | { 200 | let { req, res } = faker({ cache_seconds: -10000 }, data); 201 | await api(req, res); 202 | 203 | expect(res.setHeader.mock.calls).toEqual([ 204 | ["Content-Type", "image/svg+xml"], 205 | ["Cache-Control", `public, max-age=${CONSTANTS.FOUR_HOURS}`], 206 | ]); 207 | } 208 | }); 209 | 210 | it("should add private contributions", async () => { 211 | const { req, res } = faker( 212 | { 213 | username: "anuraghazra", 214 | count_private: true, 215 | }, 216 | data, 217 | ); 218 | 219 | await api(req, res); 220 | 221 | expect(res.setHeader).toBeCalledWith("Content-Type", "image/svg+xml"); 222 | expect(res.send).toBeCalledWith( 223 | renderStatsCard( 224 | { 225 | ...stats, 226 | totalCommits: stats.totalCommits + 100, 227 | rank: calculateRank({ 228 | totalCommits: stats.totalCommits + 100, 229 | totalRepos: 1, 230 | followers: 0, 231 | contributions: stats.contributedTo, 232 | stargazers: stats.totalStars, 233 | prs: stats.totalPRs, 234 | issues: stats.totalIssues, 235 | }), 236 | }, 237 | {}, 238 | ), 239 | ); 240 | }); 241 | }); 242 | -------------------------------------------------------------------------------- /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.16605417270399 }); 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 | // Script variables 15 | const REPO = "dummy-cra"; 16 | const USER = "grsdummy"; 17 | const STATS_DATA = { 18 | name: "grsdummy", 19 | totalPRs: 2, 20 | totalCommits: 2, 21 | totalIssues: 1, 22 | totalStars: 1, 23 | contributedTo: 2, 24 | rank: { 25 | level: "A+", 26 | score: 51.01013099671447, 27 | }, 28 | }; 29 | const LANGS_DATA = { 30 | TypeScript: { 31 | color: "#3178c6", 32 | name: "TypeScript", 33 | size: 2049, 34 | }, 35 | HTML: { 36 | color: "#e34c26", 37 | name: "HTML", 38 | size: 1721, 39 | }, 40 | CSS: { 41 | color: "#563d7c", 42 | name: "CSS", 43 | size: 930, 44 | }, 45 | Python: { 46 | color: "#3572A5", 47 | name: "Python", 48 | size: 671, 49 | }, 50 | }; 51 | const WAKATIME_DATA = { 52 | human_readable_range: "last week", 53 | is_already_updating: false, 54 | is_coding_activity_visible: false, 55 | is_including_today: false, 56 | is_other_usage_visible: false, 57 | is_stuck: false, 58 | is_up_to_date: false, 59 | is_up_to_date_pending_future: false, 60 | percent_calculated: 0, 61 | range: "last_7_days", 62 | status: "pending_update", 63 | timeout: 15, 64 | username: "grsdummy", 65 | writes_only: false, 66 | }; 67 | const REPOSITORY_DATA = { 68 | name: "dummy-cra", 69 | nameWithOwner: "grsdummy/dummy-cra", 70 | isPrivate: false, 71 | isArchived: false, 72 | isTemplate: false, 73 | stargazers: { 74 | totalCount: 1, 75 | }, 76 | description: "Dummy create react app.", 77 | primaryLanguage: { 78 | color: "#3178c6", 79 | id: "MDg6TGFuZ3VhZ2UyODc=", 80 | name: "TypeScript", 81 | }, 82 | forkCount: 0, 83 | starCount: 1, 84 | }; 85 | 86 | describe("Fetch Cards", () => { 87 | let VERCEL_PREVIEW_URL; 88 | 89 | beforeAll(() => { 90 | process.env.NODE_ENV = "development"; 91 | VERCEL_PREVIEW_URL = process.env.VERCEL_PREVIEW_URL; 92 | }); 93 | 94 | test("retrieve stats card", async () => { 95 | expect(VERCEL_PREVIEW_URL).toBeDefined(); 96 | 97 | // Check if the Vercel preview instance stats card function is up and running. 98 | await expect( 99 | axios.get(`${VERCEL_PREVIEW_URL}/api?username=${USER}`), 100 | ).resolves.not.toThrow(); 101 | 102 | // Get local stats card. 103 | const localStatsCardSVG = renderStatsCard(STATS_DATA); 104 | 105 | // Get the Vercel preview stats card response. 106 | const serverStatsSvg = await axios.get( 107 | `${VERCEL_PREVIEW_URL}/api?username=${USER}`, 108 | ); 109 | 110 | // Check if stats card from deployment matches the stats card from local. 111 | expect(serverStatsSvg.data).toEqual(localStatsCardSVG); 112 | }); 113 | 114 | test("retrieve language card", async () => { 115 | expect(VERCEL_PREVIEW_URL).toBeDefined(); 116 | 117 | // Check if the Vercel preview instance language card function is up and running. 118 | await expect( 119 | axios.get(`${VERCEL_PREVIEW_URL}/api/top-langs/?username=${USER}`), 120 | ).resolves.not.toThrow(); 121 | 122 | // Get local language card. 123 | const localLanguageCardSVG = renderTopLanguages(LANGS_DATA); 124 | 125 | // Get the Vercel preview language card response. 126 | const severLanguageSVG = await axios.get( 127 | `${VERCEL_PREVIEW_URL}/api/top-langs/?username=${USER}`, 128 | ); 129 | 130 | // Check if language card from deployment matches the local language card. 131 | expect(severLanguageSVG.data).toEqual(localLanguageCardSVG); 132 | }); 133 | 134 | test("retrieve WakaTime card", async () => { 135 | expect(VERCEL_PREVIEW_URL).toBeDefined(); 136 | 137 | // Check if the Vercel preview instance WakaTime function is up and running. 138 | await expect( 139 | axios.get(`${VERCEL_PREVIEW_URL}/api/wakatime?username=${USER}`), 140 | ).resolves.not.toThrow(); 141 | 142 | // Get local WakaTime card. 143 | const localWakaCardSVG = renderWakatimeCard(WAKATIME_DATA); 144 | 145 | // Get the Vercel preview WakaTime card response. 146 | const serverWakaTimeSvg = await axios.get( 147 | `${VERCEL_PREVIEW_URL}/api/wakatime?username=${USER}`, 148 | ); 149 | 150 | // Check if WakaTime card from deployment matches the local WakaTime card. 151 | expect(serverWakaTimeSvg.data).toEqual(localWakaCardSVG); 152 | }); 153 | 154 | test("retrieve repo card", async () => { 155 | expect(VERCEL_PREVIEW_URL).toBeDefined(); 156 | 157 | // Check if the Vercel preview instance Repo function is up and running. 158 | await expect( 159 | axios.get(`${VERCEL_PREVIEW_URL}/api/pin/?username=${USER}&repo=${REPO}`), 160 | ).resolves.not.toThrow(); 161 | 162 | // Get local repo card. 163 | const localRepoCardSVG = renderRepoCard(REPOSITORY_DATA); 164 | 165 | // Get the Vercel preview repo card response. 166 | const serverRepoSvg = await axios.get( 167 | `${VERCEL_PREVIEW_URL}/api/pin/?username=${USER}&repo=${REPO}`, 168 | ); 169 | 170 | // Check if Repo card from deployment matches the local Repo card. 171 | expect(serverRepoSvg.data).toEqual(localRepoCardSVG); 172 | }); 173 | }); 174 | -------------------------------------------------------------------------------- /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 | const data_org = { 27 | data: { 28 | user: null, 29 | organization: { repository: data_repo.repository }, 30 | }, 31 | }; 32 | 33 | const mock = new MockAdapter(axios); 34 | 35 | afterEach(() => { 36 | mock.reset(); 37 | }); 38 | 39 | describe("Test fetchRepo", () => { 40 | it("should fetch correct user repo", async () => { 41 | mock.onPost("https://api.github.com/graphql").reply(200, data_user); 42 | 43 | let repo = await fetchRepo("anuraghazra", "convoychat"); 44 | 45 | expect(repo).toStrictEqual({ 46 | ...data_repo.repository, 47 | starCount: data_repo.repository.stargazers.totalCount, 48 | }); 49 | }); 50 | 51 | it("should fetch correct org repo", async () => { 52 | mock.onPost("https://api.github.com/graphql").reply(200, data_org); 53 | 54 | let repo = await fetchRepo("anuraghazra", "convoychat"); 55 | expect(repo).toStrictEqual({ 56 | ...data_repo.repository, 57 | starCount: data_repo.repository.stargazers.totalCount, 58 | }); 59 | }); 60 | 61 | it("should throw error if user is found but repo is null", async () => { 62 | mock 63 | .onPost("https://api.github.com/graphql") 64 | .reply(200, { data: { user: { repository: null }, organization: null } }); 65 | 66 | await expect(fetchRepo("anuraghazra", "convoychat")).rejects.toThrow( 67 | "User Repository Not found", 68 | ); 69 | }); 70 | 71 | it("should throw error if org is found but repo is null", async () => { 72 | mock 73 | .onPost("https://api.github.com/graphql") 74 | .reply(200, { data: { user: null, organization: { repository: null } } }); 75 | 76 | await expect(fetchRepo("anuraghazra", "convoychat")).rejects.toThrow( 77 | "Organization Repository Not found", 78 | ); 79 | }); 80 | 81 | it("should throw error if both user & org data not found", async () => { 82 | mock 83 | .onPost("https://api.github.com/graphql") 84 | .reply(200, { data: { user: null, organization: null } }); 85 | 86 | await expect(fetchRepo("anuraghazra", "convoychat")).rejects.toThrow( 87 | "Not found", 88 | ); 89 | }); 90 | 91 | it("should throw error if repository is private", async () => { 92 | mock.onPost("https://api.github.com/graphql").reply(200, { 93 | data: { 94 | user: { repository: { ...data_repo, isPrivate: true } }, 95 | organization: null, 96 | }, 97 | }); 98 | 99 | await expect(fetchRepo("anuraghazra", "convoychat")).rejects.toThrow( 100 | "User Repository Not found", 101 | ); 102 | }); 103 | }); 104 | -------------------------------------------------------------------------------- /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 | const data = { 8 | data: { 9 | user: { 10 | name: "Anurag Hazra", 11 | repositoriesContributedTo: { totalCount: 61 }, 12 | contributionsCollection: { 13 | totalCommitContributions: 100, 14 | restrictedContributionsCount: 50, 15 | }, 16 | pullRequests: { totalCount: 300 }, 17 | openIssues: { totalCount: 100 }, 18 | closedIssues: { totalCount: 100 }, 19 | followers: { totalCount: 100 }, 20 | repositories: { 21 | totalCount: 5, 22 | }, 23 | }, 24 | }, 25 | }; 26 | 27 | const firstRepositoriesData = { 28 | data: { 29 | user: { 30 | repositories: { 31 | nodes: [ 32 | { name: "test-repo-1", stargazers: { totalCount: 100 } }, 33 | { name: "test-repo-2", stargazers: { totalCount: 100 } }, 34 | { name: "test-repo-3", stargazers: { totalCount: 100 } }, 35 | ], 36 | pageInfo: { 37 | hasNextPage: true, 38 | cursor: "cursor", 39 | }, 40 | }, 41 | }, 42 | }, 43 | }; 44 | 45 | const secondRepositoriesData = { 46 | data: { 47 | user: { 48 | repositories: { 49 | nodes: [ 50 | { name: "test-repo-4", stargazers: { totalCount: 50 } }, 51 | { name: "test-repo-5", stargazers: { totalCount: 50 } }, 52 | ], 53 | pageInfo: { 54 | hasNextPage: false, 55 | cursor: "cursor", 56 | }, 57 | }, 58 | }, 59 | }, 60 | }; 61 | 62 | const repositoriesWithZeroStarsData = { 63 | data: { 64 | user: { 65 | repositories: { 66 | nodes: [ 67 | { name: "test-repo-1", stargazers: { totalCount: 100 } }, 68 | { name: "test-repo-2", stargazers: { totalCount: 100 } }, 69 | { name: "test-repo-3", stargazers: { totalCount: 100 } }, 70 | { name: "test-repo-4", stargazers: { totalCount: 0 } }, 71 | { name: "test-repo-5", stargazers: { totalCount: 0 } }, 72 | ], 73 | pageInfo: { 74 | hasNextPage: true, 75 | cursor: "cursor", 76 | }, 77 | }, 78 | }, 79 | }, 80 | }; 81 | 82 | const error = { 83 | errors: [ 84 | { 85 | type: "NOT_FOUND", 86 | path: ["user"], 87 | locations: [], 88 | message: "Could not resolve to a User with the login of 'noname'.", 89 | }, 90 | ], 91 | }; 92 | 93 | const mock = new MockAdapter(axios); 94 | 95 | beforeEach(() => { 96 | mock 97 | .onPost("https://api.github.com/graphql") 98 | .replyOnce(200, data) 99 | .onPost("https://api.github.com/graphql") 100 | .replyOnce(200, firstRepositoriesData) 101 | .onPost("https://api.github.com/graphql") 102 | .replyOnce(200, secondRepositoriesData); 103 | }); 104 | 105 | afterEach(() => { 106 | mock.reset(); 107 | }); 108 | 109 | describe("Test fetchStats", () => { 110 | it("should fetch correct stats", async () => { 111 | let stats = await fetchStats("anuraghazra"); 112 | const rank = calculateRank({ 113 | totalCommits: 100, 114 | totalRepos: 5, 115 | followers: 100, 116 | contributions: 61, 117 | stargazers: 400, 118 | prs: 300, 119 | issues: 200, 120 | }); 121 | 122 | expect(stats).toStrictEqual({ 123 | contributedTo: 61, 124 | name: "Anurag Hazra", 125 | totalCommits: 100, 126 | totalIssues: 200, 127 | totalPRs: 300, 128 | totalStars: 400, 129 | rank, 130 | }); 131 | }); 132 | 133 | it("should stop fetching when there are repos with zero stars", async () => { 134 | mock.reset(); 135 | mock 136 | .onPost("https://api.github.com/graphql") 137 | .replyOnce(200, data) 138 | .onPost("https://api.github.com/graphql") 139 | .replyOnce(200, repositoriesWithZeroStarsData); 140 | 141 | let stats = await fetchStats("anuraghazra"); 142 | const rank = calculateRank({ 143 | totalCommits: 100, 144 | totalRepos: 5, 145 | followers: 100, 146 | contributions: 61, 147 | stargazers: 300, 148 | prs: 300, 149 | issues: 200, 150 | }); 151 | 152 | expect(stats).toStrictEqual({ 153 | contributedTo: 61, 154 | name: "Anurag Hazra", 155 | totalCommits: 100, 156 | totalIssues: 200, 157 | totalPRs: 300, 158 | totalStars: 300, 159 | rank, 160 | }); 161 | }); 162 | 163 | it("should throw error", async () => { 164 | mock.reset(); 165 | mock.onPost("https://api.github.com/graphql").reply(200, error); 166 | 167 | await expect(fetchStats("anuraghazra")).rejects.toThrow( 168 | "Could not resolve to a User with the login of 'noname'.", 169 | ); 170 | }); 171 | 172 | it("should fetch and add private contributions", async () => { 173 | let stats = await fetchStats("anuraghazra", true); 174 | const rank = calculateRank({ 175 | totalCommits: 150, 176 | totalRepos: 5, 177 | followers: 100, 178 | contributions: 61, 179 | stargazers: 400, 180 | prs: 300, 181 | issues: 200, 182 | }); 183 | 184 | expect(stats).toStrictEqual({ 185 | contributedTo: 61, 186 | name: "Anurag Hazra", 187 | totalCommits: 150, 188 | totalIssues: 200, 189 | totalPRs: 300, 190 | totalStars: 400, 191 | rank, 192 | }); 193 | }); 194 | 195 | it("should fetch total commits", async () => { 196 | mock 197 | .onGet("https://api.github.com/search/commits?q=author:anuraghazra") 198 | .reply(200, { total_count: 1000 }); 199 | 200 | let stats = await fetchStats("anuraghazra", true, true); 201 | const rank = calculateRank({ 202 | totalCommits: 1050, 203 | totalRepos: 5, 204 | followers: 100, 205 | contributions: 61, 206 | stargazers: 400, 207 | prs: 300, 208 | issues: 200, 209 | }); 210 | 211 | expect(stats).toStrictEqual({ 212 | contributedTo: 61, 213 | name: "Anurag Hazra", 214 | totalCommits: 1050, 215 | totalIssues: 200, 216 | totalPRs: 300, 217 | totalStars: 400, 218 | rank, 219 | }); 220 | }); 221 | 222 | it("should exclude stars of the `test-repo-1` repository", async () => { 223 | mock 224 | .onGet("https://api.github.com/search/commits?q=author:anuraghazra") 225 | .reply(200, { total_count: 1000 }); 226 | 227 | let stats = await fetchStats("anuraghazra", true, true, ["test-repo-1"]); 228 | const rank = calculateRank({ 229 | totalCommits: 1050, 230 | totalRepos: 5, 231 | followers: 100, 232 | contributions: 61, 233 | stargazers: 300, 234 | prs: 300, 235 | issues: 200, 236 | }); 237 | 238 | expect(stats).toStrictEqual({ 239 | contributedTo: 61, 240 | name: "Anurag Hazra", 241 | totalCommits: 1050, 242 | totalIssues: 200, 243 | totalPRs: 300, 244 | totalStars: 300, 245 | rank, 246 | }); 247 | }); 248 | }); 249 | -------------------------------------------------------------------------------- /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", async () => { 64 | mock.onPost("https://api.github.com/graphql").reply(200, data_langs); 65 | 66 | let repo = await fetchTopLanguages("anuraghazra"); 67 | expect(repo).toStrictEqual({ 68 | HTML: { 69 | color: "#0f0", 70 | name: "HTML", 71 | size: 200, 72 | }, 73 | javascript: { 74 | color: "#0ff", 75 | name: "javascript", 76 | size: 200, 77 | }, 78 | }); 79 | }); 80 | 81 | it("should fetch correct language data while excluding the 'test-repo-1' repository", async () => { 82 | mock.onPost("https://api.github.com/graphql").reply(200, data_langs); 83 | 84 | let repo = await fetchTopLanguages("anuraghazra", ["test-repo-1"]); 85 | expect(repo).toStrictEqual({ 86 | HTML: { 87 | color: "#0f0", 88 | name: "HTML", 89 | size: 100, 90 | }, 91 | javascript: { 92 | color: "#0ff", 93 | name: "javascript", 94 | size: 200, 95 | }, 96 | }); 97 | }); 98 | 99 | it("should throw error", async () => { 100 | mock.onPost("https://api.github.com/graphql").reply(200, error); 101 | 102 | await expect(fetchTopLanguages("anuraghazra")).rejects.toThrow( 103 | "Could not resolve to a User with the login of 'noname'.", 104 | ); 105 | }); 106 | }); 107 | -------------------------------------------------------------------------------- /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/?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/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/renderTopLanguages.test.js: -------------------------------------------------------------------------------- 1 | import { queryAllByTestId, queryByTestId } from "@testing-library/dom"; 2 | import { cssToObject } from "@uppercod/css-to-object"; 3 | import { 4 | MIN_CARD_WIDTH, 5 | renderTopLanguages, 6 | } from "../src/cards/top-languages-card.js"; 7 | // adds special assertions like toHaveTextContent 8 | import "@testing-library/jest-dom"; 9 | 10 | import { themes } from "../themes/index.js"; 11 | 12 | describe("Test renderTopLanguages", () => { 13 | const langs = { 14 | HTML: { 15 | color: "#0f0", 16 | name: "HTML", 17 | size: 200, 18 | }, 19 | javascript: { 20 | color: "#0ff", 21 | name: "javascript", 22 | size: 200, 23 | }, 24 | css: { 25 | color: "#ff0", 26 | name: "css", 27 | size: 100, 28 | }, 29 | }; 30 | 31 | it("should render correctly", () => { 32 | document.body.innerHTML = renderTopLanguages(langs); 33 | 34 | expect(queryByTestId(document.body, "header")).toHaveTextContent( 35 | "Most Used Languages", 36 | ); 37 | 38 | expect(queryAllByTestId(document.body, "lang-name")[0]).toHaveTextContent( 39 | "HTML", 40 | ); 41 | expect(queryAllByTestId(document.body, "lang-name")[1]).toHaveTextContent( 42 | "javascript", 43 | ); 44 | expect(queryAllByTestId(document.body, "lang-name")[2]).toHaveTextContent( 45 | "css", 46 | ); 47 | expect(queryAllByTestId(document.body, "lang-progress")[0]).toHaveAttribute( 48 | "width", 49 | "40%", 50 | ); 51 | expect(queryAllByTestId(document.body, "lang-progress")[1]).toHaveAttribute( 52 | "width", 53 | "40%", 54 | ); 55 | expect(queryAllByTestId(document.body, "lang-progress")[2]).toHaveAttribute( 56 | "width", 57 | "20%", 58 | ); 59 | }); 60 | 61 | it("should hide languages when hide is passed", () => { 62 | document.body.innerHTML = renderTopLanguages(langs, { 63 | hide: ["HTML"], 64 | }); 65 | expect(queryAllByTestId(document.body, "lang-name")[0]).toBeInTheDocument( 66 | "javascript", 67 | ); 68 | expect(queryAllByTestId(document.body, "lang-name")[1]).toBeInTheDocument( 69 | "css", 70 | ); 71 | expect(queryAllByTestId(document.body, "lang-name")[2]).not.toBeDefined(); 72 | 73 | // multiple languages passed 74 | document.body.innerHTML = renderTopLanguages(langs, { 75 | hide: ["HTML", "css"], 76 | }); 77 | expect(queryAllByTestId(document.body, "lang-name")[0]).toBeInTheDocument( 78 | "javascript", 79 | ); 80 | expect(queryAllByTestId(document.body, "lang-name")[1]).not.toBeDefined(); 81 | }); 82 | 83 | it("should resize the height correctly depending on langs", () => { 84 | document.body.innerHTML = renderTopLanguages(langs, {}); 85 | expect(document.querySelector("svg")).toHaveAttribute("height", "205"); 86 | 87 | document.body.innerHTML = renderTopLanguages( 88 | { 89 | ...langs, 90 | python: { 91 | color: "#ff0", 92 | name: "python", 93 | size: 100, 94 | }, 95 | }, 96 | {}, 97 | ); 98 | expect(document.querySelector("svg")).toHaveAttribute("height", "245"); 99 | }); 100 | 101 | it("should render with custom width set", () => { 102 | document.body.innerHTML = renderTopLanguages(langs, {}); 103 | 104 | expect(document.querySelector("svg")).toHaveAttribute("width", "300"); 105 | 106 | document.body.innerHTML = renderTopLanguages(langs, { card_width: 400 }); 107 | expect(document.querySelector("svg")).toHaveAttribute("width", "400"); 108 | }); 109 | 110 | it("should render with min width", () => { 111 | document.body.innerHTML = renderTopLanguages(langs, { card_width: 190 }); 112 | 113 | expect(document.querySelector("svg")).toHaveAttribute( 114 | "width", 115 | MIN_CARD_WIDTH.toString(), 116 | ); 117 | 118 | document.body.innerHTML = renderTopLanguages(langs, { card_width: 100 }); 119 | expect(document.querySelector("svg")).toHaveAttribute( 120 | "width", 121 | MIN_CARD_WIDTH.toString(), 122 | ); 123 | }); 124 | 125 | it("should render default colors properly", () => { 126 | document.body.innerHTML = renderTopLanguages(langs); 127 | 128 | const styleTag = document.querySelector("style"); 129 | const stylesObject = cssToObject(styleTag.textContent); 130 | 131 | const headerStyles = stylesObject[":host"][".header "]; 132 | const langNameStyles = stylesObject[":host"][".lang-name "]; 133 | 134 | expect(headerStyles.fill.trim()).toBe("#2f80ed"); 135 | expect(langNameStyles.fill.trim()).toBe("#434d58"); 136 | expect(queryByTestId(document.body, "card-bg")).toHaveAttribute( 137 | "fill", 138 | "#fffefe", 139 | ); 140 | }); 141 | 142 | it("should render custom colors properly", () => { 143 | const customColors = { 144 | title_color: "5a0", 145 | icon_color: "1b998b", 146 | text_color: "9991", 147 | bg_color: "252525", 148 | }; 149 | 150 | document.body.innerHTML = renderTopLanguages(langs, { ...customColors }); 151 | 152 | const styleTag = document.querySelector("style"); 153 | const stylesObject = cssToObject(styleTag.innerHTML); 154 | 155 | const headerStyles = stylesObject[":host"][".header "]; 156 | const langNameStyles = stylesObject[":host"][".lang-name "]; 157 | 158 | expect(headerStyles.fill.trim()).toBe(`#${customColors.title_color}`); 159 | expect(langNameStyles.fill.trim()).toBe(`#${customColors.text_color}`); 160 | expect(queryByTestId(document.body, "card-bg")).toHaveAttribute( 161 | "fill", 162 | "#252525", 163 | ); 164 | }); 165 | 166 | it("should render custom colors with themes", () => { 167 | document.body.innerHTML = renderTopLanguages(langs, { 168 | title_color: "5a0", 169 | theme: "radical", 170 | }); 171 | 172 | const styleTag = document.querySelector("style"); 173 | const stylesObject = cssToObject(styleTag.innerHTML); 174 | 175 | const headerStyles = stylesObject[":host"][".header "]; 176 | const langNameStyles = stylesObject[":host"][".lang-name "]; 177 | 178 | expect(headerStyles.fill.trim()).toBe("#5a0"); 179 | expect(langNameStyles.fill.trim()).toBe(`#${themes.radical.text_color}`); 180 | expect(queryByTestId(document.body, "card-bg")).toHaveAttribute( 181 | "fill", 182 | `#${themes.radical.bg_color}`, 183 | ); 184 | }); 185 | 186 | it("should render with all the themes", () => { 187 | Object.keys(themes).forEach((name) => { 188 | document.body.innerHTML = renderTopLanguages(langs, { 189 | theme: name, 190 | }); 191 | 192 | const styleTag = document.querySelector("style"); 193 | const stylesObject = cssToObject(styleTag.innerHTML); 194 | 195 | const headerStyles = stylesObject[":host"][".header "]; 196 | const langNameStyles = stylesObject[":host"][".lang-name "]; 197 | 198 | expect(headerStyles.fill.trim()).toBe(`#${themes[name].title_color}`); 199 | expect(langNameStyles.fill.trim()).toBe(`#${themes[name].text_color}`); 200 | expect(queryByTestId(document.body, "card-bg")).toHaveAttribute( 201 | "fill", 202 | `#${themes[name].bg_color}`, 203 | ); 204 | }); 205 | }); 206 | 207 | it("should render with layout compact", () => { 208 | document.body.innerHTML = renderTopLanguages(langs, { layout: "compact" }); 209 | 210 | expect(queryByTestId(document.body, "header")).toHaveTextContent( 211 | "Most Used Languages", 212 | ); 213 | 214 | expect(queryAllByTestId(document.body, "lang-name")[0]).toHaveTextContent( 215 | "HTML 40.00%", 216 | ); 217 | expect(queryAllByTestId(document.body, "lang-progress")[0]).toHaveAttribute( 218 | "width", 219 | "120", 220 | ); 221 | 222 | expect(queryAllByTestId(document.body, "lang-name")[1]).toHaveTextContent( 223 | "javascript 40.00%", 224 | ); 225 | expect(queryAllByTestId(document.body, "lang-progress")[1]).toHaveAttribute( 226 | "width", 227 | "120", 228 | ); 229 | 230 | expect(queryAllByTestId(document.body, "lang-name")[2]).toHaveTextContent( 231 | "css 20.00%", 232 | ); 233 | expect(queryAllByTestId(document.body, "lang-progress")[2]).toHaveAttribute( 234 | "width", 235 | "60", 236 | ); 237 | }); 238 | 239 | it("should render a translated title", () => { 240 | document.body.innerHTML = renderTopLanguages(langs, { locale: "cn" }); 241 | expect(document.getElementsByClassName("header")[0].textContent).toBe( 242 | "最常用的语言", 243 | ); 244 | }); 245 | 246 | it("should render without rounding", () => { 247 | document.body.innerHTML = renderTopLanguages(langs, { border_radius: "0" }); 248 | expect(document.querySelector("rect")).toHaveAttribute("rx", "0"); 249 | document.body.innerHTML = renderTopLanguages(langs, {}); 250 | expect(document.querySelector("rect")).toHaveAttribute("rx", "4.5"); 251 | }); 252 | 253 | it("should render langs with specified langs_count", async () => { 254 | const options = { 255 | langs_count: 1, 256 | }; 257 | document.body.innerHTML = renderTopLanguages(langs, { ...options }); 258 | expect(queryAllByTestId(document.body, "lang-name").length).toBe( 259 | options.langs_count, 260 | ); 261 | }); 262 | 263 | it("should render langs with specified langs_count even when hide is set", async () => { 264 | const options = { 265 | hide: ["HTML"], 266 | langs_count: 2, 267 | }; 268 | document.body.innerHTML = renderTopLanguages(langs, { ...options }); 269 | expect(queryAllByTestId(document.body, "lang-name").length).toBe( 270 | options.langs_count, 271 | ); 272 | }); 273 | }); 274 | -------------------------------------------------------------------------------- /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 hide languages when hide is passed", () => { 20 | document.body.innerHTML = renderWakatimeCard(wakaTimeData.data, { 21 | hide: ["YAML", "Other"], 22 | }); 23 | 24 | expect(queryByTestId(document.body, /YAML/i)).toBeNull(); 25 | expect(queryByTestId(document.body, /Other/i)).toBeNull(); 26 | expect(queryByTestId(document.body, /TypeScript/i)).not.toBeNull(); 27 | }); 28 | 29 | it("should render translations", () => { 30 | document.body.innerHTML = renderWakatimeCard({}, { locale: "cn" }); 31 | expect(document.getElementsByClassName("header")[0].textContent).toBe( 32 | "Wakatime 周统计", 33 | ); 34 | expect( 35 | document.querySelector('g[transform="translate(0, 0)"]>text.stat.bold') 36 | .textContent, 37 | ).toBe("本周没有编程活动"); 38 | }); 39 | 40 | it("should render without rounding", () => { 41 | document.body.innerHTML = renderWakatimeCard(wakaTimeData.data, { 42 | border_radius: "0", 43 | }); 44 | expect(document.querySelector("rect")).toHaveAttribute("rx", "0"); 45 | document.body.innerHTML = renderWakatimeCard(wakaTimeData.data, {}); 46 | expect(document.querySelector("rect")).toHaveAttribute("rx", "4.5"); 47 | }); 48 | 49 | it('should show "no coding activitiy this week" message when there hasn not been activity', () => { 50 | document.body.innerHTML = renderWakatimeCard( 51 | { 52 | ...wakaTimeData.data, 53 | languages: undefined, 54 | }, 55 | {}, 56 | ); 57 | expect(document.querySelector(".stat").textContent).toBe( 58 | "No coding activity this week", 59 | ); 60 | }); 61 | }); 62 | -------------------------------------------------------------------------------- /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/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 | renderError, 8 | wrapTextMultiline, 9 | } from "../src/common/utils.js"; 10 | 11 | describe("Test utils.js", () => { 12 | it("should test kFormatter", () => { 13 | expect(kFormatter(1)).toBe(1); 14 | expect(kFormatter(-1)).toBe(-1); 15 | expect(kFormatter(500)).toBe(500); 16 | expect(kFormatter(1000)).toBe("1k"); 17 | expect(kFormatter(10000)).toBe("10k"); 18 | expect(kFormatter(12345)).toBe("12.3k"); 19 | expect(kFormatter(9900000)).toBe("9900k"); 20 | }); 21 | 22 | it("should test encodeHTML", () => { 23 | expect(encodeHTML(`hello world<,.#4^&^@%!))`)).toBe( 24 | "<html>hello world<,.#4^&^@%!))", 25 | ); 26 | }); 27 | 28 | it("should test renderError", () => { 29 | document.body.innerHTML = renderError("Something went wrong"); 30 | expect( 31 | queryByTestId(document.body, "message").children[0], 32 | ).toHaveTextContent(/Something went wrong/gim); 33 | expect( 34 | queryByTestId(document.body, "message").children[1], 35 | ).toBeEmptyDOMElement(2); 36 | 37 | // Secondary message 38 | document.body.innerHTML = renderError( 39 | "Something went wrong", 40 | "Secondary Message", 41 | ); 42 | expect( 43 | queryByTestId(document.body, "message").children[1], 44 | ).toHaveTextContent(/Secondary Message/gim); 45 | }); 46 | 47 | it("getCardColors: should return expected values", () => { 48 | let colors = getCardColors({ 49 | title_color: "f00", 50 | text_color: "0f0", 51 | icon_color: "00f", 52 | bg_color: "fff", 53 | border_color: "fff", 54 | theme: "dark", 55 | }); 56 | expect(colors).toStrictEqual({ 57 | titleColor: "#f00", 58 | textColor: "#0f0", 59 | iconColor: "#00f", 60 | bgColor: "#fff", 61 | borderColor: "#fff", 62 | }); 63 | }); 64 | 65 | it("getCardColors: should fallback to default colors if color is invalid", () => { 66 | let colors = getCardColors({ 67 | title_color: "invalidcolor", 68 | text_color: "0f0", 69 | icon_color: "00f", 70 | bg_color: "fff", 71 | border_color: "invalidColor", 72 | theme: "dark", 73 | }); 74 | expect(colors).toStrictEqual({ 75 | titleColor: "#2f80ed", 76 | textColor: "#0f0", 77 | iconColor: "#00f", 78 | bgColor: "#fff", 79 | borderColor: "#e4e2e2", 80 | }); 81 | }); 82 | 83 | it("getCardColors: should fallback to specified theme colors if is not defined", () => { 84 | let colors = getCardColors({ 85 | theme: "dark", 86 | }); 87 | expect(colors).toStrictEqual({ 88 | titleColor: "#fff", 89 | textColor: "#9f9f9f", 90 | iconColor: "#79ff97", 91 | bgColor: "#151515", 92 | borderColor: "#e4e2e2", 93 | }); 94 | }); 95 | }); 96 | 97 | describe("wrapTextMultiline", () => { 98 | it("should not wrap small texts", () => { 99 | { 100 | let multiLineText = wrapTextMultiline("Small text should not wrap"); 101 | expect(multiLineText).toEqual(["Small text should not wrap"]); 102 | } 103 | }); 104 | it("should wrap large texts", () => { 105 | let multiLineText = wrapTextMultiline( 106 | "Hello world long long long text", 107 | 20, 108 | 3, 109 | ); 110 | expect(multiLineText).toEqual(["Hello world long", "long long text"]); 111 | }); 112 | it("should wrap large texts and limit max lines", () => { 113 | let multiLineText = wrapTextMultiline( 114 | "Hello world long long long text", 115 | 10, 116 | 2, 117 | ); 118 | expect(multiLineText).toEqual(["Hello", "world long..."]); 119 | }); 120 | it("should wrap chinese by punctuation", () => { 121 | let multiLineText = wrapTextMultiline( 122 | "专门为刚开始刷题的同学准备的算法基地,没有最细只有更细,立志用动画将晦涩难懂的算法说的通俗易懂!", 123 | ); 124 | expect(multiLineText.length).toEqual(3); 125 | expect(multiLineText[0].length).toEqual(18 * 8); // &#xxxxx; x 8 126 | }); 127 | }); 128 | -------------------------------------------------------------------------------- /themes/index.js: -------------------------------------------------------------------------------- 1 | export const themes = { 2 | default: { 3 | title_color: "2f80ed", 4 | icon_color: "4c71f2", 5 | text_color: "434d58", 6 | bg_color: "fffefe", 7 | border_color: "e4e2e2", 8 | }, 9 | default_repocard: { 10 | title_color: "2f80ed", 11 | icon_color: "586069", // icon color is different 12 | text_color: "434d58", 13 | bg_color: "fffefe", 14 | }, 15 | transparent: { 16 | title_color: "006AFF", 17 | icon_color: "0579C3", 18 | text_color: "417E87", 19 | bg_color: "ffffff00", 20 | }, 21 | dark: { 22 | title_color: "fff", 23 | icon_color: "79ff97", 24 | text_color: "9f9f9f", 25 | bg_color: "151515", 26 | }, 27 | radical: { 28 | title_color: "fe428e", 29 | icon_color: "f8d847", 30 | text_color: "a9fef7", 31 | bg_color: "141321", 32 | }, 33 | merko: { 34 | title_color: "abd200", 35 | icon_color: "b7d364", 36 | text_color: "68b587", 37 | bg_color: "0a0f0b", 38 | }, 39 | gruvbox: { 40 | title_color: "fabd2f", 41 | icon_color: "fe8019", 42 | text_color: "8ec07c", 43 | bg_color: "282828", 44 | }, 45 | gruvbox_light: { 46 | title_color: "b57614", 47 | icon_color: "af3a03", 48 | text_color: "427b58", 49 | bg_color: "fbf1c7", 50 | }, 51 | tokyonight: { 52 | title_color: "70a5fd", 53 | icon_color: "bf91f3", 54 | text_color: "38bdae", 55 | bg_color: "1a1b27", 56 | }, 57 | onedark: { 58 | title_color: "e4bf7a", 59 | icon_color: "8eb573", 60 | text_color: "df6d74", 61 | bg_color: "282c34", 62 | }, 63 | cobalt: { 64 | title_color: "e683d9", 65 | icon_color: "0480ef", 66 | text_color: "75eeb2", 67 | bg_color: "193549", 68 | }, 69 | synthwave: { 70 | title_color: "e2e9ec", 71 | icon_color: "ef8539", 72 | text_color: "e5289e", 73 | bg_color: "2b213a", 74 | }, 75 | highcontrast: { 76 | title_color: "e7f216", 77 | icon_color: "00ffff", 78 | text_color: "fff", 79 | bg_color: "000", 80 | }, 81 | dracula: { 82 | title_color: "ff6e96", 83 | icon_color: "79dafa", 84 | text_color: "f8f8f2", 85 | bg_color: "282a36", 86 | }, 87 | prussian: { 88 | title_color: "bddfff", 89 | icon_color: "38a0ff", 90 | text_color: "6e93b5", 91 | bg_color: "172f45", 92 | }, 93 | monokai: { 94 | title_color: "eb1f6a", 95 | icon_color: "e28905", 96 | text_color: "f1f1eb", 97 | bg_color: "272822", 98 | }, 99 | vue: { 100 | title_color: "41b883", 101 | icon_color: "41b883", 102 | text_color: "273849", 103 | bg_color: "fffefe", 104 | }, 105 | "vue-dark": { 106 | title_color: "41b883", 107 | icon_color: "41b883", 108 | text_color: "fffefe", 109 | bg_color: "273849", 110 | }, 111 | "shades-of-purple": { 112 | title_color: "fad000", 113 | icon_color: "b362ff", 114 | text_color: "a599e9", 115 | bg_color: "2d2b55", 116 | }, 117 | nightowl: { 118 | title_color: "c792ea", 119 | icon_color: "ffeb95", 120 | text_color: "7fdbca", 121 | bg_color: "011627", 122 | }, 123 | buefy: { 124 | title_color: "7957d5", 125 | icon_color: "ff3860", 126 | text_color: "363636", 127 | bg_color: "ffffff", 128 | }, 129 | "blue-green": { 130 | title_color: "2f97c1", 131 | icon_color: "f5b700", 132 | text_color: "0cf574", 133 | bg_color: "040f0f", 134 | }, 135 | algolia: { 136 | title_color: "00AEFF", 137 | icon_color: "2DDE98", 138 | text_color: "FFFFFF", 139 | bg_color: "050F2C", 140 | }, 141 | "great-gatsby": { 142 | title_color: "ffa726", 143 | icon_color: "ffb74d", 144 | text_color: "ffd95b", 145 | bg_color: "000000", 146 | }, 147 | darcula: { 148 | title_color: "BA5F17", 149 | icon_color: "84628F", 150 | text_color: "BEBEBE", 151 | bg_color: "242424", 152 | }, 153 | bear: { 154 | title_color: "e03c8a", 155 | icon_color: "00AEFF", 156 | text_color: "bcb28d", 157 | bg_color: "1f2023", 158 | }, 159 | "solarized-dark": { 160 | title_color: "268bd2", 161 | icon_color: "b58900", 162 | text_color: "859900", 163 | bg_color: "002b36", 164 | }, 165 | "solarized-light": { 166 | title_color: "268bd2", 167 | icon_color: "b58900", 168 | text_color: "859900", 169 | bg_color: "fdf6e3", 170 | }, 171 | "chartreuse-dark": { 172 | title_color: "7fff00", 173 | icon_color: "00AEFF", 174 | text_color: "fff", 175 | bg_color: "000", 176 | }, 177 | nord: { 178 | title_color: "81a1c1", 179 | text_color: "d8dee9", 180 | icon_color: "88c0d0", 181 | bg_color: "2e3440", 182 | }, 183 | gotham: { 184 | title_color: "2aa889", 185 | icon_color: "599cab", 186 | text_color: "99d1ce", 187 | bg_color: "0c1014", 188 | }, 189 | "material-palenight": { 190 | title_color: "c792ea", 191 | icon_color: "89ddff", 192 | text_color: "a6accd", 193 | bg_color: "292d3e", 194 | }, 195 | graywhite: { 196 | title_color: "24292e", 197 | icon_color: "24292e", 198 | text_color: "24292e", 199 | bg_color: "ffffff", 200 | }, 201 | "vision-friendly-dark": { 202 | title_color: "ffb000", 203 | icon_color: "785ef0", 204 | text_color: "ffffff", 205 | bg_color: "000000", 206 | }, 207 | "ayu-mirage": { 208 | title_color: "f4cd7c", 209 | icon_color: "73d0ff", 210 | text_color: "c7c8c2", 211 | bg_color: "1f2430", 212 | }, 213 | "midnight-purple": { 214 | title_color: "9745f5", 215 | icon_color: "9f4bff", 216 | text_color: "ffffff", 217 | bg_color: "000000", 218 | }, 219 | calm: { 220 | title_color: "e07a5f", 221 | icon_color: "edae49", 222 | text_color: "ebcfb2", 223 | bg_color: "373f51", 224 | }, 225 | "flag-india": { 226 | title_color: "ff8f1c", 227 | icon_color: "250E62", 228 | text_color: "509E2F", 229 | bg_color: "ffffff", 230 | }, 231 | omni: { 232 | title_color: "FF79C6", 233 | icon_color: "e7de79", 234 | text_color: "E1E1E6", 235 | bg_color: "191622", 236 | }, 237 | react: { 238 | title_color: "61dafb", 239 | icon_color: "61dafb", 240 | text_color: "ffffff", 241 | bg_color: "20232a", 242 | }, 243 | jolly: { 244 | title_color: "ff64da", 245 | icon_color: "a960ff", 246 | text_color: "ffffff", 247 | bg_color: "291B3E", 248 | }, 249 | maroongold: { 250 | title_color: "F7EF8A", 251 | icon_color: "F7EF8A", 252 | text_color: "E0AA3E", 253 | bg_color: "260000", 254 | }, 255 | yeblu: { 256 | title_color: "ffff00", 257 | icon_color: "ffff00", 258 | text_color: "ffffff", 259 | bg_color: "002046", 260 | }, 261 | blueberry: { 262 | title_color: "82aaff", 263 | icon_color: "89ddff", 264 | text_color: "27e8a7", 265 | bg_color: "242938", 266 | }, 267 | slateorange: { 268 | title_color: "faa627", 269 | icon_color: "faa627", 270 | text_color: "ffffff", 271 | bg_color: "36393f", 272 | }, 273 | kacho_ga: { 274 | title_color: "bf4a3f", 275 | icon_color: "a64833", 276 | text_color: "d9c8a9", 277 | bg_color: "402b23", 278 | }, 279 | outrun: { 280 | title_color: "ffcc00", 281 | icon_color: "ff1aff", 282 | text_color: "8080ff", 283 | bg_color: "141439", 284 | }, 285 | ocean_dark: { 286 | title_color: "8957B2", 287 | icon_color: "FFFFFF", 288 | text_color: "92D534", 289 | bg_color: "151A28", 290 | }, 291 | city_lights: { 292 | title_color: "5D8CB3", 293 | icon_color: "4798FF", 294 | text_color: "718CA1", 295 | bg_color: "1D252C", 296 | }, 297 | github_dark: { 298 | title_color: "58A6FF", 299 | icon_color: "1F6FEB", 300 | text_color: "C3D1D9", 301 | bg_color: "0D1117", 302 | }, 303 | discord_old_blurple: { 304 | title_color: "7289DA", 305 | icon_color: "7289DA", 306 | text_color: "FFFFFF", 307 | bg_color: "2C2F33", 308 | }, 309 | aura_dark: { 310 | title_color: "ff7372", 311 | icon_color: "6cffd0", 312 | text_color: "dbdbdb", 313 | bg_color: "252334", 314 | }, 315 | panda: { 316 | title_color: "19f9d899", 317 | icon_color: "19f9d899", 318 | text_color: "FF75B5", 319 | bg_color: "31353a", 320 | }, 321 | noctis_minimus: { 322 | title_color: "d3b692", 323 | icon_color: "72b7c0", 324 | text_color: "c5cdd3", 325 | bg_color: "1b2932", 326 | }, 327 | cobalt2: { 328 | title_color: "ffc600", 329 | icon_color: "ffffff", 330 | text_color: "0088ff", 331 | bg_color: "193549", 332 | }, 333 | swift: { 334 | title_color: "000000", 335 | icon_color: "f05237", 336 | text_color: "000000", 337 | bg_color: "f7f7f7", 338 | }, 339 | aura: { 340 | title_color: "a277ff", 341 | icon_color: "ffca85", 342 | text_color: "61ffca", 343 | bg_color: "15141b", 344 | }, 345 | apprentice: { 346 | title_color: "ffffff", 347 | icon_color: "ffffaf", 348 | text_color: "bcbcbc", 349 | bg_color: "262626", 350 | }, 351 | moltack: { 352 | title_color: "86092C", 353 | icon_color: "86092C", 354 | text_color: "574038", 355 | bg_color: "F5E1C0", 356 | }, 357 | codeSTACKr: { 358 | title_color: "ff652f", 359 | icon_color: "FFE400", 360 | text_color: "ffffff", 361 | bg_color: "09131B", 362 | border_color: "0c1a25", 363 | }, 364 | rose_pine: { 365 | title_color: "9ccfd8", 366 | icon_color: "ebbcba", 367 | text_color: "e0def4", 368 | bg_color: "191724", 369 | }, 370 | }; 371 | 372 | export default themes; 373 | -------------------------------------------------------------------------------- /vercel.json: -------------------------------------------------------------------------------- 1 | { 2 | "functions": { 3 | "api/*.js": { 4 | "memory": 128, 5 | "maxDuration": 30 6 | } 7 | }, 8 | "redirects": [ 9 | { 10 | "source": "/", 11 | "destination": "https://github.com/anuraghazra/github-readme-stats" 12 | } 13 | ] 14 | } 15 | --------------------------------------------------------------------------------