├── .gitattributes ├── .github ├── FUNDING.yml ├── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md ├── labeler.yml ├── stale.yml └── workflows │ ├── generate-theme-doc.yml │ ├── label-pr.yml │ ├── preview-theme.yml │ └── test.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 ├── package.json ├── powered-by-vercel.svg ├── readme.md ├── scripts ├── generate-theme-doc.js ├── preview-theme.js └── push-theme-readme.sh ├── src ├── calculateRank.js ├── cards │ ├── repo-card.js │ ├── stats-card.js │ ├── top-languages-card.js │ └── 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 │ └── wakatime-fetcher.js ├── getStyles.js └── translations.js ├── tests ├── __snapshots__ │ └── renderWakatimeCard.test.js.snap ├── api.test.js ├── calculateRank.test.js ├── card.test.js ├── fetchRepo.test.js ├── fetchStats.test.js ├── fetchTopLanguages.test.js ├── fetchWakatime.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: # 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 | **Describe the bug** 10 | A clear and concise description of what the bug is. 11 | 12 | **Expected behavior** 13 | A clear and concise description of what you expected to happen. 14 | 15 | **Screenshots / Live demo link (paste the github-readme-stats link as markdown image)** 16 | If applicable, add screenshots to help explain your problem. 17 | 18 | **Additional context** 19 | Add any other context about the problem here. 20 | 21 | 42 | -------------------------------------------------------------------------------- /.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/generate-theme-doc.yml: -------------------------------------------------------------------------------- 1 | name: Generate Theme Readme 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | paths: 8 | - "themes/index.js" 9 | 10 | jobs: 11 | build: 12 | runs-on: ubuntu-latest 13 | 14 | steps: 15 | - uses: actions/checkout@v1 16 | - name: setup node 17 | uses: actions/setup-node@v1 18 | with: 19 | node-version: "12.x" 20 | 21 | - name: npm install, generate readme 22 | run: | 23 | npm install 24 | npm run theme-readme-gen 25 | env: 26 | CI: true 27 | 28 | - name: Run Script 29 | uses: skx/github-action-tester@master 30 | with: 31 | script: ./scripts/push-theme-readme.sh 32 | env: 33 | CI: true 34 | PERSONAL_TOKEN: ${{ secrets.PERSONAL_TOKEN }} 35 | GH_REPO: ${{ secrets.GH_REPO }} 36 | -------------------------------------------------------------------------------- /.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@v2 10 | with: 11 | repo-token: "${{ secrets.GITHUB_TOKEN }}" 12 | -------------------------------------------------------------------------------- /.github/workflows/preview-theme.yml: -------------------------------------------------------------------------------- 1 | name: Theme preview 2 | 3 | on: 4 | pull_request_target: 5 | types: [opened, synchronize, reopened] 6 | branches: 7 | - master 8 | - theme-preview-script 9 | - "themes/index.js" 10 | 11 | jobs: 12 | build: 13 | runs-on: ubuntu-latest 14 | name: Install & Preview 15 | 16 | steps: 17 | - uses: actions/checkout@v1 18 | - uses: bahmutov/npm-install@v1 19 | with: 20 | useLockFile: false 21 | - run: npm run preview-theme 22 | env: 23 | CI: true 24 | PERSONAL_TOKEN: ${{ secrets.PERSONAL_TOKEN }} 25 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | 3 | on: 4 | push: 5 | branches: 6 | - "*" 7 | pull_request: 8 | branches: 9 | - master 10 | 11 | jobs: 12 | build: 13 | runs-on: ubuntu-latest 14 | 15 | steps: 16 | - uses: actions/checkout@v2 17 | 18 | - name: Setup Node 19 | uses: actions/setup-node@v1 20 | with: 21 | node-version: "12.x" 22 | 23 | - name: Cache node modules 24 | uses: actions/cache@v2 25 | env: 26 | cache-name: cache-node-modules 27 | with: 28 | path: ~/.npm 29 | key: 30 | ${{ runner.os }}-npm-cache-${{ hashFiles('**/package-lock.json') }} 31 | restore-keys: | 32 | ${{ runner.os }}-npm-cache- 33 | 34 | - name: Install & Test 35 | run: | 36 | npm install 37 | npm run test 38 | 39 | - name: Code Coverage 40 | uses: codecov/codecov-action@v1 41 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .vercel 2 | .env 3 | node_modules 4 | package-lock.json 5 | *.lock 6 | .vscode/ 7 | .idea/ 8 | coverage 9 | vercel_token 10 | 11 | -------------------------------------------------------------------------------- /.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 a 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 | 1. If you've added code that should be tested, add some tests' example. 17 | 1. If you've changed APIs, update the documentation. 18 | 1. Issue that pull request! 19 | 20 | ## Local Development 21 | 22 | To run & test github-readme-stats you need to follow few simple steps :- 23 | _(make sure you already have a [vercel](https://vercel.com/) account)_ 24 | 25 | 1. Install [Vercel CLI](https://vercel.com/download) 26 | 1. Fork the repository and clone the code to your local machine 27 | 1. Run the command "vercel" in the root and follow the steps there 28 | 1. Create a `.env` file in the root of the directory 29 | 1. 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) 30 | 1. Run the command "vercel dev" to start a development server at https://localhost:3000 31 | 32 | ## Themes Contribution 33 | 34 | GitHub Readme Stats supports custom theming and you can also contribute new themes! 35 | 36 | All you need to do is edit [themes/index.js](./themes/index.js) file and add your theme at the end of the file. 37 | 38 | 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` 39 | 40 | > 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. 41 | 42 | ## Any contributions you make will be under the MIT Software License 43 | 44 | 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. 45 | 46 | ## Report issues/bugs using GitHub's [issues](https://github.com/anuraghazra/github-readme-stats/issues) 47 | 48 | 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! 49 | 50 | ## Frequently Asked Questions (FAQs) 51 | 52 | **Q:** How to hide jupyter Notebook? 53 | 54 | > **Ans:** &hide=jupyter%20notebook 55 | 56 | **Q:** I could not figure out how to deploy on my own vercel instance 57 | 58 | > **Ans:** 59 | > 60 | > - docs: https://github.com/anuraghazra/github-readme-stats/#deploy-on-your-own-vercel-instance 61 | > - YT tutorial by codeSTACKr: https://www.youtube.com/watch?v=n6d4KHSKqGk&feature=youtu.be&t=107 62 | 63 | **Q:** Language Card is incorrect 64 | 65 | > **Ans:** Please read all the related issues / comments before opening any issues regarding language card stats: 66 | > 67 | > - https://github.com/anuraghazra/github-readme-stats/issues/136#issuecomment-665164174 68 | > 69 | > - https://github.com/anuraghazra/github-readme-stats/issues/136#issuecomment-665172181 70 | 71 | **Q:** How to count private stats? 72 | 73 | > **Ans:** We can only count private commits & we cannot access any other private info of any users, so it's not possible. only way is to deploy on your own instance & use your own PAT (Personal Access Token) 74 | 75 | ### Bug Reports 76 | 77 | **Great Bug Reports** tend to have: 78 | 79 | - A quick summary and/or background 80 | - Steps to reproduce 81 | - Be specific! 82 | - Share the snapshot, if possible. 83 | - GitHub Readme Stats' live link 84 | - What actually happens 85 | - What you expected would happen 86 | - Notes (possibly including why you think this might be happening, or stuff you tried that didn't work) 87 | 88 | People _love_ thorough bug reports. I'm not even kidding. 89 | 90 | ### Feature Request 91 | 92 | **Great Feature Requests** tend to have: 93 | 94 | - A quick idea summary 95 | - What & why you wanted to add the specific feature 96 | - Additional Context like images, links to resources to implement the feature etc etc. 97 | 98 | ## License 99 | 100 | By contributing, you agree that your contributions will be licensed under its [MIT License](./LICENSE). 101 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 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 | require("dotenv").config(); 2 | const { 3 | renderError, 4 | parseBoolean, 5 | parseArray, 6 | clampValue, 7 | CONSTANTS, 8 | } = require("../src/common/utils"); 9 | const fetchStats = require("../src/fetchers/stats-fetcher"); 10 | const renderStatsCard = require("../src/cards/stats-card"); 11 | const blacklist = require("../src/common/blacklist"); 12 | const { isLocaleAvailable } = require("../src/translations"); 13 | 14 | module.exports = async (req, res) => { 15 | const { 16 | username, 17 | hide, 18 | hide_title, 19 | hide_border, 20 | hide_rank, 21 | show_icons, 22 | count_private, 23 | include_all_commits, 24 | line_height, 25 | title_color, 26 | icon_color, 27 | text_color, 28 | bg_color, 29 | theme, 30 | cache_seconds, 31 | custom_title, 32 | locale, 33 | disable_animations, 34 | border_radius, 35 | } = req.query; 36 | let stats; 37 | 38 | res.setHeader("Content-Type", "image/svg+xml"); 39 | 40 | if (blacklist.includes(username)) { 41 | return res.send(renderError("Something went wrong")); 42 | } 43 | 44 | if (locale && !isLocaleAvailable(locale)) { 45 | return res.send(renderError("Something went wrong", "Language not found")); 46 | } 47 | 48 | try { 49 | stats = await fetchStats( 50 | username, 51 | parseBoolean(count_private), 52 | parseBoolean(include_all_commits), 53 | ); 54 | 55 | const cacheSeconds = clampValue( 56 | parseInt(cache_seconds || CONSTANTS.TWO_HOURS, 10), 57 | CONSTANTS.TWO_HOURS, 58 | CONSTANTS.ONE_DAY, 59 | ); 60 | 61 | res.setHeader("Cache-Control", `public, max-age=${cacheSeconds}`); 62 | 63 | return res.send( 64 | renderStatsCard(stats, { 65 | hide: parseArray(hide), 66 | show_icons: parseBoolean(show_icons), 67 | hide_title: parseBoolean(hide_title), 68 | hide_border: parseBoolean(hide_border), 69 | hide_rank: parseBoolean(hide_rank), 70 | include_all_commits: parseBoolean(include_all_commits), 71 | line_height, 72 | title_color, 73 | icon_color, 74 | text_color, 75 | bg_color, 76 | theme, 77 | custom_title, 78 | border_radius, 79 | locale: locale ? locale.toLowerCase() : null, 80 | disable_animations: parseBoolean(disable_animations), 81 | }), 82 | ); 83 | } catch (err) { 84 | return res.send(renderError(err.message, err.secondaryMessage)); 85 | } 86 | }; 87 | -------------------------------------------------------------------------------- /api/pin.js: -------------------------------------------------------------------------------- 1 | require("dotenv").config(); 2 | const { 3 | renderError, 4 | parseBoolean, 5 | clampValue, 6 | CONSTANTS, 7 | } = require("../src/common/utils"); 8 | const fetchRepo = require("../src/fetchers/repo-fetcher"); 9 | const renderRepoCard = require("../src/cards/repo-card"); 10 | const blacklist = require("../src/common/blacklist"); 11 | const { isLocaleAvailable } = require("../src/translations"); 12 | 13 | module.exports = async (req, res) => { 14 | const { 15 | username, 16 | repo, 17 | hide_border, 18 | title_color, 19 | icon_color, 20 | text_color, 21 | bg_color, 22 | theme, 23 | show_owner, 24 | cache_seconds, 25 | locale, 26 | border_radius, 27 | } = req.query; 28 | 29 | let repoData; 30 | 31 | res.setHeader("Content-Type", "image/svg+xml"); 32 | 33 | if (blacklist.includes(username)) { 34 | return res.send(renderError("Something went wrong")); 35 | } 36 | 37 | if (locale && !isLocaleAvailable(locale)) { 38 | return res.send(renderError("Something went wrong", "Language not found")); 39 | } 40 | 41 | try { 42 | repoData = await fetchRepo(username, repo); 43 | 44 | let cacheSeconds = clampValue( 45 | parseInt(cache_seconds || CONSTANTS.TWO_HOURS, 10), 46 | CONSTANTS.TWO_HOURS, 47 | CONSTANTS.ONE_DAY, 48 | ); 49 | 50 | /* 51 | if star count & fork count is over 1k then we are kFormating the text 52 | and if both are zero we are not showing the stats 53 | so we can just make the cache longer, since there is no need to frequent updates 54 | */ 55 | const stars = repoData.stargazers.totalCount; 56 | const forks = repoData.forkCount; 57 | const isBothOver1K = stars > 1000 && forks > 1000; 58 | const isBothUnder1 = stars < 1 && forks < 1; 59 | if (!cache_seconds && (isBothOver1K || isBothUnder1)) { 60 | cacheSeconds = CONSTANTS.FOUR_HOURS; 61 | } 62 | 63 | res.setHeader("Cache-Control", `public, max-age=${cacheSeconds}`); 64 | 65 | return res.send( 66 | renderRepoCard(repoData, { 67 | hide_border, 68 | title_color, 69 | icon_color, 70 | text_color, 71 | bg_color, 72 | theme, 73 | border_radius, 74 | show_owner: parseBoolean(show_owner), 75 | locale: locale ? locale.toLowerCase() : null, 76 | }), 77 | ); 78 | } catch (err) { 79 | return res.send(renderError(err.message, err.secondaryMessage)); 80 | } 81 | }; 82 | -------------------------------------------------------------------------------- /api/top-langs.js: -------------------------------------------------------------------------------- 1 | require("dotenv").config(); 2 | const { 3 | renderError, 4 | clampValue, 5 | parseBoolean, 6 | parseArray, 7 | CONSTANTS, 8 | } = require("../src/common/utils"); 9 | const fetchTopLanguages = require("../src/fetchers/top-languages-fetcher"); 10 | const renderTopLanguages = require("../src/cards/top-languages-card"); 11 | const blacklist = require("../src/common/blacklist"); 12 | const { isLocaleAvailable } = require("../src/translations"); 13 | 14 | module.exports = async (req, res) => { 15 | const { 16 | username, 17 | hide, 18 | hide_title, 19 | hide_border, 20 | card_width, 21 | title_color, 22 | text_color, 23 | bg_color, 24 | theme, 25 | cache_seconds, 26 | layout, 27 | langs_count, 28 | exclude_repo, 29 | custom_title, 30 | locale, 31 | border_radius 32 | } = req.query; 33 | let topLangs; 34 | 35 | res.setHeader("Content-Type", "image/svg+xml"); 36 | 37 | if (blacklist.includes(username)) { 38 | return res.send(renderError("Something went wrong")); 39 | } 40 | 41 | if (locale && !isLocaleAvailable(locale)) { 42 | return res.send(renderError("Something went wrong", "Language not found")); 43 | } 44 | 45 | try { 46 | topLangs = await fetchTopLanguages( 47 | username, 48 | langs_count, 49 | parseArray(exclude_repo), 50 | ); 51 | 52 | const cacheSeconds = clampValue( 53 | parseInt(cache_seconds || CONSTANTS.TWO_HOURS, 10), 54 | CONSTANTS.TWO_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 | border_radius, 73 | locale: locale ? locale.toLowerCase() : null, 74 | }), 75 | ); 76 | } catch (err) { 77 | return res.send(renderError(err.message, err.secondaryMessage)); 78 | } 79 | }; 80 | -------------------------------------------------------------------------------- /api/wakatime.js: -------------------------------------------------------------------------------- 1 | require("dotenv").config(); 2 | const { 3 | renderError, 4 | parseBoolean, 5 | clampValue, 6 | CONSTANTS, 7 | isLocaleAvailable, 8 | } = require("../src/common/utils"); 9 | const { fetchWakatimeStats } = require("../src/fetchers/wakatime-fetcher"); 10 | const wakatimeCard = require("../src/cards/wakatime-card"); 11 | 12 | module.exports = async (req, res) => { 13 | const { 14 | username, 15 | title_color, 16 | icon_color, 17 | hide_border, 18 | line_height, 19 | text_color, 20 | bg_color, 21 | theme, 22 | cache_seconds, 23 | hide_title, 24 | hide_progress, 25 | custom_title, 26 | locale, 27 | layout, 28 | api_domain, 29 | range, 30 | border_radius, 31 | } = req.query; 32 | 33 | res.setHeader("Content-Type", "image/svg+xml"); 34 | 35 | if (locale && !isLocaleAvailable(locale)) { 36 | return res.send(renderError("Something went wrong", "Language not found")); 37 | } 38 | 39 | try { 40 | const stats = await fetchWakatimeStats({ username, api_domain, range }); 41 | 42 | let cacheSeconds = clampValue( 43 | parseInt(cache_seconds || CONSTANTS.TWO_HOURS, 10), 44 | CONSTANTS.TWO_HOURS, 45 | CONSTANTS.ONE_DAY, 46 | ); 47 | 48 | if (!cache_seconds) { 49 | cacheSeconds = CONSTANTS.FOUR_HOURS; 50 | } 51 | 52 | res.setHeader("Cache-Control", `public, max-age=${cacheSeconds}`); 53 | 54 | return res.send( 55 | wakatimeCard(stats, { 56 | custom_title, 57 | hide_title: parseBoolean(hide_title), 58 | hide_border: parseBoolean(hide_border), 59 | line_height, 60 | title_color, 61 | icon_color, 62 | text_color, 63 | bg_color, 64 | theme, 65 | hide_progress, 66 | border_radius, 67 | locale: locale ? locale.toLowerCase() : null, 68 | layout, 69 | }), 70 | ); 71 | } catch (err) { 72 | return res.send(renderError(err.message, err.secondaryMessage)); 73 | } 74 | }; 75 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /docs/readme_cn.md: -------------------------------------------------------------------------------- 1 |

2 | GitHub Readme Stats 3 |

GitHub Readme Stats

4 |

在你的 README 中获取动态生成的 GitHub 统计信息!

5 |

6 |

7 | 8 | Tests Passing 9 | 10 | 11 | 12 | 13 | 14 | Issues 15 | 16 | 17 | GitHub pull requests 18 | 19 |
20 |
21 | 22 | 23 | 24 | 25 | 26 | 27 |

28 | 29 |

30 | 查看 Demo 31 | · 32 | 报告 Bug 33 | · 34 | 请求增加功能 35 |

36 |

37 | Français 38 | · 39 | 简体中文 40 | · 41 | Español 42 | · 43 | Deutsch 44 | · 45 | 日本語 46 | · 47 | Português Brasileiro 48 | · 49 | Italiano 50 | · 51 | 한국어 52 | . 53 | Nederlands 54 | . 55 | नेपाली 56 |

57 |

58 |

喜欢这个项目?请考虑捐赠来帮助它完善! 59 | 60 | # 特性 61 | 62 | - [GitHub 统计卡片](#GitHub-统计卡片) 63 | - [GitHub 更多置顶](#GitHub-更多置顶) 64 | - [热门语言卡片](#热门语言卡片) 65 | - [主题](#主题) 66 | - [自定义](#自定义) 67 | - [自己部署](#自己部署) 68 | 69 | # GitHub 统计卡片 70 | 71 | 将这行代码复制到你的 markdown 文件中,就是如此简单! 72 | 73 | 更改 `?username=` 的值为你的 GitHub 用户名。 74 | 75 | ```md 76 | [![Anurag's GitHub stats](https://github-readme-stats.vercel.app/api?username=anuraghazra)](https://github.com/anuraghazra/github-readme-stats) 77 | ``` 78 | 79 | _注: 等级基于用户的统计信息计算得出,详见 [src/calculateRank.js](../src/calculateRank.js)_ 80 | 81 | ### 隐藏指定统计 82 | 83 | 想要隐藏指定统计信息,你可以调用参数 `?hide=`,其值用 `,` 分隔。 84 | 85 | > 选项:`&hide=stars,commits,prs,issues,contribs` 86 | 87 | ```md 88 | ![Anurag's GitHub stats](https://github-readme-stats.vercel.app/api?username=anuraghazra&hide=contribs,prs) 89 | ``` 90 | 91 | ### 将私人项目贡献添加到总提交计数中 92 | 93 | 你可以使用参数 `?count_private=true` 把私人贡献计数添加到总提交计数中。 94 | 95 | _注:如果你是自己部署本项目,私人贡献将会默认被计数,如果不是自己部署,你需要分享你的私人贡献计数。_ 96 | 97 | > 选项: `&count_private=true` 98 | 99 | ```md 100 | ![Anurag's GitHub stats](https://github-readme-stats.vercel.app/api?username=anuraghazra&count_private=true) 101 | ``` 102 | 103 | ### 显示图标 104 | 105 | 如果想要显示图标,你可以调用 `show_icons=true` 参数,像这样: 106 | 107 | ```md 108 | ![Anurag's GitHub stats](https://github-readme-stats.vercel.app/api?username=anuraghazra&show_icons=true) 109 | ``` 110 | 111 | ### 主题 112 | 113 | 你可以通过现有的主题进行卡片个性化,省去[手动自定义](#自定义)的麻烦。 114 | 115 | 通过调用 `?theme=THEME_NAME` 参数,像这样: 116 | 117 | ```md 118 | ![Anurag's GitHub stats](https://github-readme-stats.vercel.app/api?username=anuraghazra&show_icons=true&theme=radical) 119 | ``` 120 | 121 | #### 所有现有主题 122 | 123 | dark, radical, merko, gruvbox, tokyonight, onedark, cobalt, synthwave, highcontrast, dracula 124 | 125 | GitHub Readme Stat Themes 126 | 127 | 你可以预览[所有可用主题](../themes/README.md)或者签出[主题配置文件](../themes/index.js), 而且如果你喜欢, **你也可以贡献新的主题** :D 128 | 129 | ### 自定义 130 | 131 | 你可以通过使用 URL 参数的方式,为你的 `Stats Card` 或 `Repo Card` 自定义样式。 132 | 133 | 常用选项: 134 | 135 | - `title_color` - 卡片标题颜色 _(十六进制色码)_ 136 | - `text_color` - 内容文本颜色 _(十六进制色码)_ 137 | - `icon_color` - 图标颜色(如果可用)_(十六进制色码)_ 138 | - `bg_color` - 卡片背景颜色 _(十六进制色码)_ **或者** 以 _angle,start,end_ 的形式渐变 139 | - `hide_border` - 隐藏卡的边框 _(布尔值)_ 140 | - `theme` - 主题名称,从[所有可用主题](../themes/README.md)中选择 141 | - `cache_seconds` - 手动设置缓存头 _(最小值: 1800,最大值: 86400)_ 142 | - `locale` - 在卡片中设置语言 _(例如 cn, de, es, 等等)_ 143 | 144 | ##### bg_color 渐变 145 | 146 | 你可以在 bg_color 选项中提供多个逗号分隔的值来呈现渐变,渐变的格式是 :- 147 | 148 | ``` 149 | &bg_color=DEG,COLOR1,COLOR2,COLOR3...COLOR10 150 | ``` 151 | 152 | > 缓存的注意事项: 如果 fork 数和 star 数 少于 1k , Repo 卡片默认缓存是 4 小时 (14400 秒) ,否则是 2 小时(7200)。另请注意缓存被限制为最短 2 小时,最长 24 小时。 153 | 154 | #### 统计卡片专属选项: 155 | 156 | - `hide` - 隐藏特定统计信息 _(以逗号分隔)_ 157 | - `hide_title` - _(boolean)_ 158 | - `hide_rank` - _(boolean)_ 159 | - `show_icons` - _(boolean)_ 160 | - `include_all_commits` - 统计总提交次数而不是仅统计今年的提交次数 _(boolean)_ 161 | - `count_private` - 统计私人提交 _(boolean)_ 162 | - `line_height` - 设置文本之间的行高 _(number)_ 163 | 164 | #### Repo 卡片专属选项: 165 | 166 | - `show_owner` - 显示 Repo 的所有者名字 _(boolean)_ 167 | 168 | #### 语言卡片专属选项: 169 | 170 | - `hide` - 从卡片中隐藏指定语言 _(Comma seperated values)_ 171 | - `hide_title` - _(boolean)_ 172 | - `layout` - 在两个可用布局 `default` & `compact` 间切换 173 | - `card_width` - 手动设置卡片的宽度 _(number)_ 174 | 175 | > :warning: **重要:** 176 | > 如 [Percent Encoding](https://en.wikipedia.org/wiki/Percent-encoding) 所指定,语言名称应使用 uri 转义。 177 | > (例: `c++` 应该是 `c%2B%2B`, `jupyter notebook` 应该是 `jupyter%20notebook`, 等.) 178 | 179 | --- 180 | 181 | # GitHub 更多置顶 182 | 183 | GitHub 更多置顶 允许你在使用 GitHub readme profile 时,在个人资料中置顶多于 6 个 repo 。 184 | 185 | 是的!你不再受限于置顶最多 6 个存储库了。 186 | 187 | ### 使用细则 188 | 189 | 复制粘贴这段代码到你的 README 文件中,并更改链接。 190 | 191 | 端点: `api/pin?username=anuraghazra&repo=github-readme-stats` 192 | 193 | ```md 194 | [![Readme Card](https://github-readme-stats.vercel.app/api/pin/?username=anuraghazra&repo=github-readme-stats)](https://github.com/anuraghazra/github-readme-stats) 195 | ``` 196 | 197 | ### Demo 198 | 199 | [![Readme Card](https://github-readme-stats.vercel.app/api/pin/?username=anuraghazra&repo=github-readme-stats)](https://github.com/anuraghazra/github-readme-stats) 200 | 201 | 使用 [show_owner](#自定义) 变量将 Repo 所有者的用户名包含在内。 202 | 203 | [![Readme Card](https://github-readme-stats.vercel.app/api/pin/?username=anuraghazra&repo=github-readme-stats&show_owner=true)](https://github.com/anuraghazra/github-readme-stats) 204 | 205 | # 热门语言卡片 206 | 207 | 热门语言卡片显示了 GitHub 用户常用的编程语言。 208 | 209 | _注意:热门语言并不表示我的技能水平或类似的水平,它是用来衡量用户在 github 上拥有最多代码的语言的一项指标,它是 github-readme-stats 的新特性_ 210 | 211 | ### 使用细则 212 | 213 | 将此代码复制粘贴到您的 `README.md` 文件中,并修改链接。 214 | 215 | 端点: `api/top-langs?username=anuraghazra` 216 | 217 | ```md 218 | [![Top Langs](https://github-readme-stats.vercel.app/api/top-langs/?username=anuraghazra)](https://github.com/anuraghazra/github-readme-stats) 219 | ``` 220 | 221 | ### 隐藏指定语言 222 | 223 | 可以使用 `?hide=language1,language2` 参数来隐藏指定的语言。 224 | 225 | ```md 226 | [![Top Langs](https://github-readme-stats.vercel.app/api/top-langs/?username=anuraghazra&hide=javascript,html)](https://github.com/anuraghazra/github-readme-stats) 227 | ``` 228 | 229 | ### 紧凑的语言卡片布局 230 | 231 | 你可以使用 `&layout=compact` 参数来改变卡片的样式。 232 | 233 | ```md 234 | [![Top Langs](https://github-readme-stats.vercel.app/api/top-langs/?username=anuraghazra&layout=compact)](https://github.com/anuraghazra/github-readme-stats) 235 | ``` 236 | 237 | ### Demo 238 | 239 | [![Top Langs](https://github-readme-stats.vercel.app/api/top-langs/?username=anuraghazra)](https://github.com/anuraghazra/github-readme-stats) 240 | 241 | - 紧凑布局 242 | 243 | [![Top Langs](https://github-readme-stats.vercel.app/api/top-langs/?username=anuraghazra&layout=compact)](https://github.com/anuraghazra/github-readme-stats) 244 | 245 | --- 246 | 247 | ### 全部 Demos 248 | 249 | - 默认 250 | 251 | ![Anurag's GitHub stats](https://github-readme-stats.vercel.app/api?username=anuraghazra) 252 | 253 | - 隐藏指定统计 254 | 255 | ![Anurag's GitHub stats](https://github-readme-stats.vercel.app/api?username=anuraghazra&hide=contribs,issues) 256 | 257 | - 显示图标 258 | 259 | ![Anurag's GitHub stats](https://github-readme-stats.vercel.app/api?username=anuraghazra&hide=issues&show_icons=true) 260 | 261 | - 包含全部提交 262 | 263 | ![Anurag's GitHub stats](https://github-readme-stats.vercel.app/api?username=anuraghazra&include_all_commits=true) 264 | 265 | - 主题 266 | 267 | 从[默认主题](#主题)中进行选择 268 | 269 | ![Anurag's GitHub stats](https://github-readme-stats.vercel.app/api?username=anuraghazra&show_icons=true&theme=radical) 270 | 271 | - 渐变 272 | 273 | ![Anurag's GitHub stats](https://github-readme-stats.vercel.app/api?username=anuraghazra&bg_color=30,e96443,904e95&title_color=fff&text_color=fff) 274 | 275 | - 自定义统计卡片 276 | 277 | ![Anurag's GitHub stats](https://github-readme-stats.vercel.app/api/?username=anuraghazra&show_icons=true&title_color=fff&icon_color=79ff97&text_color=9f9f9f&bg_color=151515) 278 | 279 | - 自定义 repo 卡片 280 | 281 | ![Customized Card](https://github-readme-stats.vercel.app/api/pin?username=anuraghazra&repo=github-readme-stats&title_color=fff&icon_color=f9f9f9&text_color=9f9f9f&bg_color=151515) 282 | 283 | - 热门语言 284 | 285 | [![Top Langs](https://github-readme-stats.vercel.app/api/top-langs/?username=anuraghazra)](https://github.com/anuraghazra/github-readme-stats) 286 | 287 | --- 288 | 289 | ### 快速提示 (对齐 Repo 卡片) 290 | 291 | 你通常无法将图片靠边显示。为此,您可以使用以下方法: 292 | 293 | ```md 294 | 295 | 296 | 297 | 298 | 299 | 300 | ``` 301 | 302 | ## 自己部署 303 | 304 | #### [Check Out Step By Step Video Tutorial By @codeSTACKr](https://youtu.be/n6d4KHSKqGk?t=107) 305 | 306 | 因为 GitHub 的 API 每个小时只允许 5 千次请求,我的 `https://github-readme-stats.vercel.app/api` 很有可能会触发限制。如果你将其托管在自己的 Vercel 服务器上,那么你就不必为此担心。点击 deploy 按钮来开始你的部署! 307 | 308 | 注意: 从 [#58](https://github.com/anuraghazra/github-readme-stats/pull/58) 开始,我们应该能够处理超过 5 千次的请求,并且不会出现宕机问题 :D 309 | 310 | [![Deploy to Vercel](https://vercel.com/button)](https://vercel.com/import/project?template=https://github.com/anuraghazra/github-readme-stats) 311 | 312 |

313 | 设置 Vercel 的指导 314 | 315 | 1. 前往 [vercel.com](https://vercel.com/) 316 | 1. 点击 `Log in` 317 | ![](https://files.catbox.moe/tct1wg.png) 318 | 1. 点击 `Continue with GitHub` 通过 GitHub 进行登录 319 | ![](https://files.catbox.moe/btd78j.jpeg) 320 | 1. 登录 GitHub 并允许访问所有存储库(如果系统这样提示) 321 | 1. Fork 这个仓库 322 | 1. 返回到你的 [Vercel dashboard](https://vercel.com/dashboard) 323 | 1. 选择 `Import Project` 324 | ![](https://files.catbox.moe/qckos0.png) 325 | 1. 选择 `Import Git Repository` 326 | ![](https://files.catbox.moe/pqub9q.png) 327 | 1. 选择 root 并将所有内容保持不变,并且只需添加名为 PAT_1 的环境变量(如图所示),其中将包含一个个人访问令牌(PAT),你可以在[这里](https://github.com/settings/tokens/new)轻松创建(保留默认,并且只需要命名下,名字随便) 328 | ![](https://files.catbox.moe/caem5b.png) 329 | 1. 点击 deploy,这就完成了,查看你的域名就可使用 API 了! 330 | 331 |
332 | 333 | ## :sparkling_heart: 支持这个项目 334 | 335 | 我尽己所能地进行开源,并且我尽量回复每个在使用项目时需要帮助的人。很明显,这需要时间,但你可以免费享受这些。 336 | 337 | 然而, 如果你正在使用这个项目并感觉良好,或只是想要支持我继续开发,你可以通过如下方式: 338 | 339 | - 在你的 readme 中使用 github-readme-stats 时,链接指向这里 :D 340 | - Star 并 分享这个项目 :rocket: 341 | - [![paypal.me/anuraghazra](https://ionicabizau.github.io/badges/paypal.svg)](https://www.paypal.me/anuraghazra) - 你可以通过 PayPal 一次性捐款. 我多半会买一杯 ~~咖啡~~ 茶. :tea: 342 | 343 | 谢谢! :heart: 344 | 345 | --- 346 | 347 | [![https://vercel.com?utm_source=github_readme_stats_team&utm_campaign=oss](../powered-by-vercel.svg)](https://vercel.com?utm_source=github_readme_stats_team&utm_campaign=oss) 348 | 349 | 欢迎贡献! <3 350 | 351 | 用 :heart: 发电,用 JavaScript 制作。 352 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | clearMocks: true, 3 | }; 4 | -------------------------------------------------------------------------------- /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 | "scripts": { 7 | "test": "jest --coverage", 8 | "test:watch": "jest --watch", 9 | "theme-readme-gen": "node scripts/generate-theme-doc", 10 | "preview-theme": "node scripts/preview-theme" 11 | }, 12 | "author": "Anurag Hazra", 13 | "license": "MIT", 14 | "devDependencies": { 15 | "@actions/core": "^1.2.4", 16 | "@actions/github": "^4.0.0", 17 | "@testing-library/dom": "^7.20.0", 18 | "@testing-library/jest-dom": "^5.11.0", 19 | "axios": "^0.19.2", 20 | "axios-mock-adapter": "^1.18.1", 21 | "css-to-object": "^1.1.0", 22 | "husky": "^4.2.5", 23 | "jest": "^26.1.0", 24 | "parse-diff": "^0.7.0" 25 | }, 26 | "dependencies": { 27 | "dotenv": "^8.2.0", 28 | "emoji-name-map": "^1.2.8", 29 | "github-username-regex": "^1.0.0", 30 | "prettier": "^2.1.2", 31 | "word-wrap": "^1.2.3" 32 | }, 33 | "husky": { 34 | "hooks": { 35 | "pre-commit": "npm test" 36 | } 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /powered-by-vercel.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /scripts/generate-theme-doc.js: -------------------------------------------------------------------------------- 1 | const theme = require("../themes/index"); 2 | const fs = require("fs"); 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 | Wanted 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(theme) 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 themes = Object.keys(theme).filter( 69 | (name) => name !== (!isRepoCard ? "default_repocard" : "default"), 70 | ); 71 | 72 | for (let i = 0; i < themes.length; i += 3) { 73 | const one = themes[i]; 74 | const two = themes[i + 1]; 75 | const three = themes[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 === themes.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/preview-theme.js: -------------------------------------------------------------------------------- 1 | const core = require("@actions/core"); 2 | const github = require("@actions/github"); 3 | const parse = require("parse-diff"); 4 | require("dotenv").config(); 5 | 6 | function getPrNumber() { 7 | const pullRequest = github.context.payload.pull_request; 8 | if (!pullRequest) { 9 | return undefined; 10 | } 11 | 12 | return pullRequest.number; 13 | } 14 | 15 | const themeContribGuidelines = ` 16 | \r> Hi thanks for the theme contribution, please read our theme contribution guidelines 17 | 18 | \r> We are currently only accepting color combinations from any vscode theme or which has good color combination to minimize bloating the themes collection. 19 | 20 | \r> Also note that if this theme is exclusively for your personal use then instead of adding it to our theme collection you can use card [customization options](https://github.com/anuraghazra/github-readme-stats#customization) 21 | \r> Read our [contribution guidelines](https://github.com/anuraghazra/github-readme-stats/blob/master/CONTRIBUTING.md) for more info 22 | `; 23 | 24 | async function run() { 25 | try { 26 | const token = core.getInput("token"); 27 | const octokit = github.getOctokit(token || process.env.PERSONAL_TOKEN); 28 | const pullRequestId = getPrNumber(); 29 | 30 | if (!pullRequestId) { 31 | console.log("PR not found"); 32 | return; 33 | } 34 | 35 | let res = await octokit.pulls.get({ 36 | owner: "anuraghazra", 37 | repo: "github-readme-stats", 38 | pull_number: pullRequestId, 39 | mediaType: { 40 | format: "diff", 41 | }, 42 | }); 43 | 44 | let diff = parse(res.data); 45 | let colorStrings = diff 46 | .find((file) => file.to === "themes/index.js") 47 | .chunks[0].changes.filter((c) => c.type === "add") 48 | .map((c) => c.content.replace("+", "")) 49 | .join(""); 50 | 51 | let matches = colorStrings.match(/(title_color:.*bg_color.*\")/); 52 | let colors = matches && matches[0].split(","); 53 | 54 | if (!colors) { 55 | await octokit.issues.createComment({ 56 | owner: "anuraghazra", 57 | repo: "github-readme-stats", 58 | body: ` 59 | \rTheme preview (bot) 60 | 61 | \rCannot create theme preview 62 | 63 | ${themeContribGuidelines} 64 | `, 65 | issue_number: pullRequestId, 66 | }); 67 | return; 68 | } 69 | colors = colors.map((color) => 70 | color.replace(/.*\:\s/, "").replace(/\"/g, ""), 71 | ); 72 | 73 | const titleColor = colors[0]; 74 | const iconColor = colors[1]; 75 | const textColor = colors[2]; 76 | const bgColor = colors[3]; 77 | const url = `https://github-readme-stats.vercel.app/api?username=anuraghazra&title_color=${titleColor}&icon_color=${iconColor}&text_color=${textColor}&bg_color=${bgColor}&show_icons=true`; 78 | 79 | await octokit.issues.createComment({ 80 | owner: "anuraghazra", 81 | repo: "github-readme-stats", 82 | body: ` 83 | \rTheme preview (bot) 84 | 85 | \ntitle_color: #${titleColor} | icon_color: #${iconColor} | text_color: #${textColor} | bg_color: #${bgColor} 86 | 87 | \rLink: ${url} 88 | 89 | \r[![](${url})](${url}) 90 | 91 | ${themeContribGuidelines} 92 | `, 93 | issue_number: pullRequestId, 94 | }); 95 | } catch (error) { 96 | console.log(error); 97 | } 98 | } 99 | 100 | run(); 101 | -------------------------------------------------------------------------------- /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 -------------------------------------------------------------------------------- /src/calculateRank.js: -------------------------------------------------------------------------------- 1 | // https://stackoverflow.com/a/5263759/10629172 2 | function normalcdf(mean, sigma, to) { 3 | var z = (to - mean) / Math.sqrt(2 * sigma * sigma); 4 | var t = 1 / (1 + 0.3275911 * Math.abs(z)); 5 | var a1 = 0.254829592; 6 | var a2 = -0.284496736; 7 | var a3 = 1.421413741; 8 | var a4 = -1.453152027; 9 | var a5 = 1.061405429; 10 | var erf = 11 | 1 - ((((a5 * t + a4) * t + a3) * t + a2) * t + a1) * t * Math.exp(-z * z); 12 | var sign = 1; 13 | if (z < 0) { 14 | sign = -1; 15 | } 16 | return (1 / 2) * (1 + sign * erf); 17 | } 18 | 19 | function calculateRank({ 20 | totalRepos, 21 | totalCommits, 22 | contributions, 23 | followers, 24 | prs, 25 | issues, 26 | stargazers, 27 | }) { 28 | const COMMITS_OFFSET = 1.65; 29 | const CONTRIBS_OFFSET = 1.65; 30 | const ISSUES_OFFSET = 1; 31 | const STARS_OFFSET = 0.75; 32 | const PRS_OFFSET = 0.5; 33 | const FOLLOWERS_OFFSET = 0.45; 34 | const REPO_OFFSET = 1; 35 | 36 | const ALL_OFFSETS = 37 | CONTRIBS_OFFSET + 38 | ISSUES_OFFSET + 39 | STARS_OFFSET + 40 | PRS_OFFSET + 41 | FOLLOWERS_OFFSET + 42 | REPO_OFFSET; 43 | 44 | const RANK_S_VALUE = 1; 45 | const RANK_DOUBLE_A_VALUE = 25; 46 | const RANK_A2_VALUE = 45; 47 | const RANK_A3_VALUE = 60; 48 | const RANK_B_VALUE = 100; 49 | 50 | const TOTAL_VALUES = 51 | RANK_S_VALUE + RANK_A2_VALUE + RANK_A3_VALUE + RANK_B_VALUE; 52 | 53 | // prettier-ignore 54 | const score = ( 55 | totalCommits * COMMITS_OFFSET + 56 | contributions * CONTRIBS_OFFSET + 57 | issues * ISSUES_OFFSET + 58 | stargazers * STARS_OFFSET + 59 | prs * PRS_OFFSET + 60 | followers * FOLLOWERS_OFFSET + 61 | totalRepos * REPO_OFFSET 62 | ) / 100; 63 | 64 | const normalizedScore = normalcdf(score, TOTAL_VALUES, ALL_OFFSETS) * 100; 65 | 66 | let level = ""; 67 | 68 | if (normalizedScore < RANK_S_VALUE) { 69 | level = "S+"; 70 | } 71 | if ( 72 | normalizedScore >= RANK_S_VALUE && 73 | normalizedScore < RANK_DOUBLE_A_VALUE 74 | ) { 75 | level = "S"; 76 | } 77 | if ( 78 | normalizedScore >= RANK_DOUBLE_A_VALUE && 79 | normalizedScore < RANK_A2_VALUE 80 | ) { 81 | level = "A++"; 82 | } 83 | if (normalizedScore >= RANK_A2_VALUE && normalizedScore < RANK_A3_VALUE) { 84 | level = "A+"; 85 | } 86 | if (normalizedScore >= RANK_A3_VALUE && normalizedScore < RANK_B_VALUE) { 87 | level = "B+"; 88 | } 89 | 90 | return { level, score: normalizedScore }; 91 | } 92 | 93 | module.exports = calculateRank; 94 | -------------------------------------------------------------------------------- /src/cards/repo-card.js: -------------------------------------------------------------------------------- 1 | const toEmoji = require("emoji-name-map"); 2 | const { 3 | kFormatter, 4 | encodeHTML, 5 | getCardColors, 6 | FlexLayout, 7 | wrapTextMultiline, 8 | } = require("../common/utils"); 9 | const I18n = require("../common/I18n"); 10 | const Card = require("../common/Card"); 11 | const icons = require("../common/icons"); 12 | const { repoCardLocales } = require("../translations"); 13 | 14 | const renderRepoCard = (repo, options = {}) => { 15 | const { 16 | name, 17 | nameWithOwner, 18 | description, 19 | primaryLanguage, 20 | stargazers, 21 | isArchived, 22 | isTemplate, 23 | forkCount, 24 | } = repo; 25 | const { 26 | hide_border = false, 27 | title_color, 28 | icon_color, 29 | text_color, 30 | bg_color, 31 | show_owner, 32 | theme = "default_repocard", 33 | border_radius, 34 | locale, 35 | } = options; 36 | 37 | const header = show_owner ? nameWithOwner : name; 38 | const langName = (primaryLanguage && primaryLanguage.name) || "Unspecified"; 39 | const langColor = (primaryLanguage && primaryLanguage.color) || "#333"; 40 | 41 | const shiftText = langName.length > 15 ? 0 : 30; 42 | 43 | let desc = description || "No description provided"; 44 | 45 | // parse emojis to unicode 46 | desc = desc.replace(/:\w+:/gm, (emoji) => { 47 | return toEmoji.get(emoji) || ""; 48 | }); 49 | 50 | const multiLineDescription = wrapTextMultiline(desc); 51 | const descriptionLines = multiLineDescription.length; 52 | const lineHeight = 10; 53 | 54 | const height = 55 | (descriptionLines > 1 ? 120 : 110) + descriptionLines * lineHeight; 56 | 57 | const i18n = new I18n({ 58 | locale, 59 | translations: repoCardLocales, 60 | }); 61 | 62 | // returns theme based colors with proper overrides and defaults 63 | const { titleColor, textColor, iconColor, bgColor } = getCardColors({ 64 | title_color, 65 | icon_color, 66 | text_color, 67 | bg_color, 68 | theme, 69 | }); 70 | 71 | const totalStars = kFormatter(stargazers.totalCount); 72 | const totalForks = kFormatter(forkCount); 73 | 74 | const getBadgeSVG = (label) => ` 75 | 76 | 77 | 84 | ${label} 85 | 86 | 87 | `; 88 | 89 | const svgLanguage = primaryLanguage 90 | ? ` 91 | 92 | 93 | ${langName} 94 | 95 | ` 96 | : ""; 97 | 98 | const iconWithLabel = (icon, label, testid) => { 99 | return ` 100 | 101 | ${icon} 102 | 103 | ${label} 104 | `; 105 | }; 106 | const svgStars = 107 | stargazers.totalCount > 0 && 108 | iconWithLabel(icons.star, totalStars, "stargazers"); 109 | const svgForks = 110 | forkCount > 0 && iconWithLabel(icons.fork, totalForks, "forkcount"); 111 | 112 | const starAndForkCount = FlexLayout({ 113 | items: [svgStars, svgForks], 114 | gap: 65, 115 | }).join(""); 116 | 117 | const card = new Card({ 118 | defaultTitle: header, 119 | titlePrefixIcon: icons.contribs, 120 | width: 400, 121 | height, 122 | border_radius, 123 | colors: { 124 | titleColor, 125 | textColor, 126 | iconColor, 127 | bgColor, 128 | }, 129 | }); 130 | 131 | card.disableAnimations(); 132 | card.setHideBorder(hide_border); 133 | card.setHideTitle(false); 134 | card.setCSS(` 135 | .description { font: 400 13px 'Segoe UI', Ubuntu, Sans-Serif; fill: ${textColor} } 136 | .gray { font: 400 12px 'Segoe UI', Ubuntu, Sans-Serif; fill: ${textColor} } 137 | .icon { fill: ${iconColor} } 138 | .badge { font: 600 11px 'Segoe UI', Ubuntu, Sans-Serif; } 139 | .badge rect { opacity: 0.2 } 140 | `); 141 | 142 | return card.render(` 143 | ${ 144 | isTemplate 145 | ? getBadgeSVG(i18n.t("repocard.template")) 146 | : isArchived 147 | ? getBadgeSVG(i18n.t("repocard.archived")) 148 | : "" 149 | } 150 | 151 | 152 | ${multiLineDescription 153 | .map((line) => `${encodeHTML(line)}`) 154 | .join("")} 155 | 156 | 157 | 158 | ${svgLanguage} 159 | 160 | 164 | ${starAndForkCount} 165 | 166 | 167 | `); 168 | }; 169 | 170 | module.exports = renderRepoCard; 171 | -------------------------------------------------------------------------------- /src/cards/stats-card.js: -------------------------------------------------------------------------------- 1 | const I18n = require("../common/I18n"); 2 | const Card = require("../common/Card"); 3 | const icons = require("../common/icons"); 4 | const { getStyles } = require("../getStyles"); 5 | const { statCardLocales } = require("../translations"); 6 | const { 7 | kFormatter, 8 | FlexLayout, 9 | clampValue, 10 | measureText, 11 | getCardColors, 12 | } = require("../common/utils"); 13 | 14 | const createTextNode = ({ 15 | icon, 16 | label, 17 | value, 18 | id, 19 | index, 20 | showIcons, 21 | shiftValuePos, 22 | }) => { 23 | const kValue = kFormatter(value); 24 | const staggerDelay = (index + 3) * 150; 25 | 26 | const labelOffset = showIcons ? `x="25"` : ""; 27 | const iconSvg = showIcons 28 | ? ` 29 | 30 | ${icon} 31 | 32 | ` 33 | : ""; 34 | return ` 35 | 36 | ${iconSvg} 37 | ${label}: 38 | ${kValue} 44 | 45 | `; 46 | }; 47 | 48 | const renderStatsCard = (stats = {}, options = { hide: [] }) => { 49 | const { 50 | name, 51 | totalStars, 52 | totalCommits, 53 | totalIssues, 54 | totalPRs, 55 | contributedTo, 56 | rank, 57 | } = stats; 58 | const { 59 | hide = [], 60 | show_icons = false, 61 | hide_title = false, 62 | hide_border = false, 63 | hide_rank = false, 64 | include_all_commits = false, 65 | line_height = 25, 66 | title_color, 67 | icon_color, 68 | text_color, 69 | bg_color, 70 | theme = "default", 71 | custom_title, 72 | border_radius, 73 | locale, 74 | disable_animations = false, 75 | } = options; 76 | 77 | const lheight = parseInt(line_height, 10); 78 | 79 | // returns theme based colors with proper overrides and defaults 80 | const { titleColor, textColor, iconColor, bgColor } = getCardColors({ 81 | title_color, 82 | icon_color, 83 | text_color, 84 | bg_color, 85 | theme, 86 | }); 87 | 88 | const apostrophe = ["x", "s"].includes(name.slice(-1).toLocaleLowerCase()) 89 | ? "" 90 | : "s"; 91 | const i18n = new I18n({ 92 | locale, 93 | translations: statCardLocales({ name, apostrophe }), 94 | }); 95 | 96 | // Meta data for creating text nodes with createTextNode function 97 | const STATS = { 98 | stars: { 99 | icon: icons.star, 100 | label: i18n.t("statcard.totalstars"), 101 | value: totalStars, 102 | id: "stars", 103 | }, 104 | commits: { 105 | icon: icons.commits, 106 | label: `${i18n.t("statcard.commits")}${ 107 | include_all_commits ? "" : ` (${new Date().getFullYear()})` 108 | }`, 109 | value: totalCommits, 110 | id: "commits", 111 | }, 112 | prs: { 113 | icon: icons.prs, 114 | label: i18n.t("statcard.prs"), 115 | value: totalPRs, 116 | id: "prs", 117 | }, 118 | issues: { 119 | icon: icons.issues, 120 | label: i18n.t("statcard.issues"), 121 | value: totalIssues, 122 | id: "issues", 123 | }, 124 | contribs: { 125 | icon: icons.contribs, 126 | label: i18n.t("statcard.contribs"), 127 | value: contributedTo, 128 | id: "contribs", 129 | }, 130 | }; 131 | 132 | const longLocales = ["cn", "es", "fr", "pt-br", "ru", "uk-ua", "id", "my", "pl"]; 133 | const isLongLocale = longLocales.includes(locale) === true; 134 | 135 | // filter out hidden stats defined by user & create the text nodes 136 | const statItems = Object.keys(STATS) 137 | .filter((key) => !hide.includes(key)) 138 | .map((key, index) => 139 | // create the text nodes, and pass index so that we can calculate the line spacing 140 | createTextNode({ 141 | ...STATS[key], 142 | index, 143 | showIcons: show_icons, 144 | shiftValuePos: 145 | (!include_all_commits ? 50 : 20) + (isLongLocale ? 50 : 0), 146 | }), 147 | ); 148 | 149 | // Calculate the card height depending on how many items there are 150 | // but if rank circle is visible clamp the minimum height to `150` 151 | let height = Math.max( 152 | 45 + (statItems.length + 1) * lheight, 153 | hide_rank ? 0 : 150, 154 | ); 155 | 156 | // Conditionally rendered elements 157 | const rankCircle = hide_rank 158 | ? "" 159 | : ` 161 | 162 | 163 | 164 | 171 | ${rank.level} 172 | 173 | 174 | `; 175 | 176 | // the better user's score the the rank will be closer to zero so 177 | // subtracting 100 to get the progress in 100% 178 | const progress = 100 - rank.score; 179 | const cssStyles = getStyles({ 180 | titleColor, 181 | textColor, 182 | iconColor, 183 | show_icons, 184 | progress, 185 | }); 186 | 187 | const calculateTextWidth = () => { 188 | return measureText(custom_title ? custom_title : i18n.t("statcard.title")); 189 | }; 190 | 191 | const width = hide_rank 192 | ? clampValue( 193 | 50 /* padding */ + calculateTextWidth() * 2, 194 | 270 /* min */, 195 | Infinity, 196 | ) 197 | : 495; 198 | 199 | const card = new Card({ 200 | customTitle: custom_title, 201 | defaultTitle: i18n.t("statcard.title"), 202 | width, 203 | height, 204 | border_radius, 205 | colors: { 206 | titleColor, 207 | textColor, 208 | iconColor, 209 | bgColor, 210 | }, 211 | }); 212 | 213 | card.setHideBorder(hide_border); 214 | card.setHideTitle(hide_title); 215 | card.setCSS(cssStyles); 216 | 217 | if (disable_animations) card.disableAnimations(); 218 | 219 | return card.render(` 220 | ${rankCircle} 221 | 222 | 223 | ${FlexLayout({ 224 | items: statItems, 225 | gap: lheight, 226 | direction: "column", 227 | }).join("")} 228 | 229 | `); 230 | }; 231 | 232 | module.exports = renderStatsCard; 233 | -------------------------------------------------------------------------------- /src/cards/top-languages-card.js: -------------------------------------------------------------------------------- 1 | const Card = require("../common/Card"); 2 | const { getCardColors, FlexLayout } = require("../common/utils"); 3 | const { createProgressNode } = require("../common/createProgressNode"); 4 | const { langCardLocales } = require("../translations"); 5 | const I18n = require("../common/I18n"); 6 | 7 | const createProgressTextNode = ({ width, color, name, progress }) => { 8 | const paddingRight = 95; 9 | const progressTextX = width - paddingRight + 10; 10 | const progressWidth = width - paddingRight; 11 | 12 | return ` 13 | ${name} 14 | ${progress}% 15 | ${createProgressNode({ 16 | x: 0, 17 | y: 25, 18 | color, 19 | width: progressWidth, 20 | progress, 21 | progressBarBackgroundColor: "#ddd", 22 | })} 23 | `; 24 | }; 25 | 26 | const createCompactLangNode = ({ lang, totalSize, x, y }) => { 27 | const percentage = ((lang.size / totalSize) * 100).toFixed(2); 28 | const color = lang.color || "#858585"; 29 | 30 | return ` 31 | 32 | 33 | 34 | ${lang.name} ${percentage}% 35 | 36 | 37 | `; 38 | }; 39 | 40 | const createLanguageTextNode = ({ langs, totalSize, x, y }) => { 41 | return langs.map((lang, index) => { 42 | if (index % 2 === 0) { 43 | return createCompactLangNode({ 44 | lang, 45 | x, 46 | y: 12.5 * index + y, 47 | totalSize, 48 | index, 49 | }); 50 | } 51 | return createCompactLangNode({ 52 | lang, 53 | x: 150, 54 | y: 12.5 + 12.5 * index, 55 | totalSize, 56 | index, 57 | }); 58 | }); 59 | }; 60 | 61 | const lowercaseTrim = (name) => name.toLowerCase().trim(); 62 | 63 | const renderTopLanguages = (topLangs, options = {}) => { 64 | const { 65 | hide_title, 66 | hide_border, 67 | card_width, 68 | title_color, 69 | text_color, 70 | bg_color, 71 | hide, 72 | theme, 73 | layout, 74 | custom_title, 75 | locale, 76 | border_radius 77 | } = options; 78 | 79 | const i18n = new I18n({ 80 | locale, 81 | translations: langCardLocales, 82 | }); 83 | 84 | let langs = Object.values(topLangs); 85 | let langsToHide = {}; 86 | 87 | // populate langsToHide map for quick lookup 88 | // while filtering out 89 | if (hide) { 90 | hide.forEach((langName) => { 91 | langsToHide[lowercaseTrim(langName)] = true; 92 | }); 93 | } 94 | 95 | // filter out langauges to be hidden 96 | langs = langs 97 | .sort((a, b) => b.size - a.size) 98 | .filter((lang) => { 99 | return !langsToHide[lowercaseTrim(lang.name)]; 100 | }); 101 | 102 | const totalLanguageSize = langs.reduce((acc, curr) => { 103 | return acc + curr.size; 104 | }, 0); 105 | 106 | // returns theme based colors with proper overrides and defaults 107 | const { titleColor, textColor, bgColor } = getCardColors({ 108 | title_color, 109 | text_color, 110 | bg_color, 111 | theme, 112 | }); 113 | 114 | let width = isNaN(card_width) ? 300 : card_width; 115 | let height = 45 + (langs.length + 1) * 40; 116 | 117 | let finalLayout = ""; 118 | 119 | // RENDER COMPACT LAYOUT 120 | if (layout === "compact") { 121 | width = width + 50; 122 | height = 90 + Math.round(langs.length / 2) * 25; 123 | 124 | // progressOffset holds the previous language's width and used to offset the next language 125 | // so that we can stack them one after another, like this: [--][----][---] 126 | let progressOffset = 0; 127 | const compactProgressBar = langs 128 | .map((lang) => { 129 | const percentage = ( 130 | (lang.size / totalLanguageSize) * 131 | (width - 50) 132 | ).toFixed(2); 133 | 134 | const progress = 135 | percentage < 10 ? parseFloat(percentage) + 10 : percentage; 136 | 137 | const output = ` 138 | 147 | `; 148 | progressOffset += parseFloat(percentage); 149 | return output; 150 | }) 151 | .join(""); 152 | 153 | finalLayout = ` 154 | 155 | 158 | 159 | ${compactProgressBar} 160 | ${createLanguageTextNode({ 161 | x: 0, 162 | y: 25, 163 | langs, 164 | totalSize: totalLanguageSize, 165 | }).join("")} 166 | `; 167 | } else { 168 | finalLayout = FlexLayout({ 169 | items: langs.map((lang) => { 170 | return createProgressTextNode({ 171 | width: width, 172 | name: lang.name, 173 | color: lang.color || "#858585", 174 | progress: ((lang.size / totalLanguageSize) * 100).toFixed(2), 175 | }); 176 | }), 177 | gap: 40, 178 | direction: "column", 179 | }).join(""); 180 | } 181 | 182 | const card = new Card({ 183 | customTitle: custom_title, 184 | defaultTitle: i18n.t("langcard.title"), 185 | width, 186 | height, 187 | border_radius, 188 | colors: { 189 | titleColor, 190 | textColor, 191 | bgColor, 192 | }, 193 | }); 194 | 195 | card.disableAnimations(); 196 | card.setHideBorder(hide_border); 197 | card.setHideTitle(hide_title); 198 | card.setCSS(` 199 | .lang-name { font: 400 11px 'Segoe UI', Ubuntu, Sans-Serif; fill: ${textColor} } 200 | `); 201 | 202 | return card.render(` 203 | 204 | ${finalLayout} 205 | 206 | `); 207 | }; 208 | 209 | module.exports = renderTopLanguages; 210 | -------------------------------------------------------------------------------- /src/cards/wakatime-card.js: -------------------------------------------------------------------------------- 1 | const Card = require("../common/Card"); 2 | const I18n = require("../common/I18n"); 3 | const { getStyles } = require("../getStyles"); 4 | const { wakatimeCardLocales } = require("../translations"); 5 | const { getCardColors, FlexLayout } = require("../common/utils"); 6 | const { createProgressNode } = require("../common/createProgressNode"); 7 | const languageColors = require("../common/languageColors.json"); 8 | 9 | const noCodingActivityNode = ({ color, text }) => { 10 | return ` 11 | ${text} 12 | `; 13 | }; 14 | 15 | const createCompactLangNode = ({ lang, totalSize, x, y }) => { 16 | const color = languageColors[lang.name] || "#858585"; 17 | 18 | return ` 19 | 20 | 21 | 22 | ${lang.name} - ${lang.text} 23 | 24 | 25 | `; 26 | }; 27 | 28 | const createLanguageTextNode = ({ langs, totalSize, x, y }) => { 29 | return langs.map((lang, index) => { 30 | if (index % 2 === 0) { 31 | return createCompactLangNode({ 32 | lang, 33 | x: 25, 34 | y: 12.5 * index + y, 35 | totalSize, 36 | index, 37 | }); 38 | } 39 | return createCompactLangNode({ 40 | lang, 41 | x: 230, 42 | y: 12.5 + 12.5 * index, 43 | totalSize, 44 | index, 45 | }); 46 | }); 47 | }; 48 | 49 | const createTextNode = ({ 50 | id, 51 | label, 52 | value, 53 | index, 54 | percent, 55 | hideProgress, 56 | progressBarColor, 57 | progressBarBackgroundColor, 58 | }) => { 59 | const staggerDelay = (index + 3) * 150; 60 | 61 | const cardProgress = hideProgress 62 | ? null 63 | : createProgressNode({ 64 | x: 110, 65 | y: 4, 66 | progress: percent, 67 | color: progressBarColor, 68 | width: 220, 69 | name: label, 70 | progressBarBackgroundColor, 71 | }); 72 | 73 | return ` 74 | 75 | ${label}: 76 | ${value} 82 | ${cardProgress} 83 | 84 | `; 85 | }; 86 | 87 | const renderWakatimeCard = (stats = {}, options = { hide: [] }) => { 88 | const { languages } = stats; 89 | const { 90 | hide_title = false, 91 | hide_border = false, 92 | line_height = 25, 93 | title_color, 94 | icon_color, 95 | text_color, 96 | bg_color, 97 | theme = "default", 98 | hide_progress, 99 | custom_title, 100 | locale, 101 | layout, 102 | border_radius 103 | } = options; 104 | 105 | const i18n = new I18n({ 106 | locale, 107 | translations: wakatimeCardLocales, 108 | }); 109 | 110 | const lheight = parseInt(line_height, 10); 111 | 112 | // returns theme based colors with proper overrides and defaults 113 | const { titleColor, textColor, iconColor, bgColor } = getCardColors({ 114 | title_color, 115 | icon_color, 116 | text_color, 117 | bg_color, 118 | theme, 119 | }); 120 | 121 | const statItems = languages 122 | ? languages 123 | .filter((language) => language.hours || language.minutes) 124 | .map((language) => { 125 | return createTextNode({ 126 | id: language.name, 127 | label: language.name, 128 | value: language.text, 129 | percent: language.percent, 130 | progressBarColor: titleColor, 131 | progressBarBackgroundColor: textColor, 132 | hideProgress: hide_progress, 133 | }); 134 | }) 135 | : []; 136 | 137 | // Calculate the card height depending on how many items there are 138 | // but if rank circle is visible clamp the minimum height to `150` 139 | let height = Math.max(45 + (statItems.length + 1) * lheight, 150); 140 | 141 | const cssStyles = getStyles({ 142 | titleColor, 143 | textColor, 144 | iconColor, 145 | }); 146 | 147 | let finalLayout = ""; 148 | 149 | let width = 440; 150 | 151 | // RENDER COMPACT LAYOUT 152 | if (layout === "compact") { 153 | width = width + 50; 154 | height = 90 + Math.round(languages.length / 2) * 25; 155 | 156 | // progressOffset holds the previous language's width and used to offset the next language 157 | // so that we can stack them one after another, like this: [--][----][---] 158 | let progressOffset = 0; 159 | const compactProgressBar = languages 160 | .map((lang) => { 161 | // const progress = (width * lang.percent) / 100; 162 | const progress = ((width - 25) * lang.percent) / 100; 163 | 164 | const languageColor = languageColors[lang.name] || "#858585"; 165 | 166 | const output = ` 167 | 176 | `; 177 | progressOffset += progress; 178 | return output; 179 | }) 180 | .join(""); 181 | 182 | finalLayout = ` 183 | 184 | 185 | 186 | ${compactProgressBar} 187 | ${createLanguageTextNode({ 188 | x: 0, 189 | y: 25, 190 | langs: languages, 191 | totalSize: 100, 192 | }).join("")} 193 | `; 194 | } else { 195 | finalLayout = FlexLayout({ 196 | items: statItems.length 197 | ? statItems 198 | : [ 199 | noCodingActivityNode({ 200 | color: textColor, 201 | text: i18n.t("wakatimecard.nocodingactivity"), 202 | }), 203 | ], 204 | gap: lheight, 205 | direction: "column", 206 | }).join(""); 207 | } 208 | 209 | const card = new Card({ 210 | customTitle: custom_title, 211 | defaultTitle: i18n.t("wakatimecard.title"), 212 | width: 495, 213 | height, 214 | border_radius, 215 | colors: { 216 | titleColor, 217 | textColor, 218 | iconColor, 219 | bgColor, 220 | }, 221 | }); 222 | 223 | card.setHideBorder(hide_border); 224 | card.setHideTitle(hide_title); 225 | card.setCSS( 226 | ` 227 | ${cssStyles} 228 | .lang-name { font: 400 11px 'Segoe UI', Ubuntu, Sans-Serif; fill: ${textColor} } 229 | `, 230 | ); 231 | 232 | return card.render(` 233 | 234 | ${finalLayout} 235 | 236 | `); 237 | }; 238 | 239 | module.exports = renderWakatimeCard; 240 | exports.createProgressNode = createProgressNode; 241 | -------------------------------------------------------------------------------- /src/common/Card.js: -------------------------------------------------------------------------------- 1 | const { FlexLayout, encodeHTML } = require("../common/utils"); 2 | const { getAnimations } = require("../getStyles"); 3 | 4 | class Card { 5 | constructor({ 6 | width = 100, 7 | height = 100, 8 | border_radius = 4.5, 9 | colors = {}, 10 | customTitle, 11 | defaultTitle = "", 12 | titlePrefixIcon, 13 | }) { 14 | this.width = width; 15 | this.height = height; 16 | 17 | this.hideBorder = false; 18 | this.hideTitle = false; 19 | 20 | this.border_radius = border_radius; 21 | 22 | // returns theme based colors with proper overrides and defaults 23 | this.colors = colors; 24 | this.title = 25 | customTitle !== undefined 26 | ? encodeHTML(customTitle) 27 | : encodeHTML(defaultTitle); 28 | 29 | this.css = ""; 30 | 31 | this.paddingX = 25; 32 | this.paddingY = 35; 33 | this.titlePrefixIcon = titlePrefixIcon; 34 | this.animations = true; 35 | } 36 | 37 | disableAnimations() { 38 | this.animations = false; 39 | } 40 | 41 | setCSS(value) { 42 | this.css = value; 43 | } 44 | 45 | setHideBorder(value) { 46 | this.hideBorder = value; 47 | } 48 | 49 | setHideTitle(value) { 50 | this.hideTitle = value; 51 | if (value) { 52 | this.height -= 30; 53 | } 54 | } 55 | 56 | setTitle(text) { 57 | this.title = text; 58 | } 59 | 60 | renderTitle() { 61 | const titleText = ` 62 | ${this.title} 68 | `; 69 | 70 | const prefixIcon = ` 71 | 80 | ${this.titlePrefixIcon} 81 | 82 | `; 83 | return ` 84 | 88 | ${FlexLayout({ 89 | items: [this.titlePrefixIcon && prefixIcon, titleText], 90 | gap: 25, 91 | }).join("")} 92 | 93 | `; 94 | } 95 | 96 | renderGradient() { 97 | if (typeof this.colors.bgColor !== "object") return; 98 | 99 | const gradients = this.colors.bgColor.slice(1); 100 | return typeof this.colors.bgColor === "object" 101 | ? ` 102 | 103 | 107 | ${gradients.map((grad, index) => { 108 | let offset = (index * 100) / (gradients.length - 1); 109 | return ``; 110 | })} 111 | 112 | 113 | ` 114 | : ""; 115 | } 116 | 117 | render(body) { 118 | return ` 119 | 126 | 141 | 142 | ${this.renderGradient()} 143 | 144 | 159 | 160 | ${this.hideTitle ? "" : this.renderTitle()} 161 | 162 | 168 | ${body} 169 | 170 | 171 | `; 172 | } 173 | } 174 | 175 | module.exports = Card; 176 | -------------------------------------------------------------------------------- /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 | module.exports = I18n; 22 | -------------------------------------------------------------------------------- /src/common/blacklist.js: -------------------------------------------------------------------------------- 1 | const blacklist = ["renovate-bot", "technote-space", "sw-yx"]; 2 | 3 | module.exports = blacklist; 4 | -------------------------------------------------------------------------------- /src/common/createProgressNode.js: -------------------------------------------------------------------------------- 1 | const { clampValue } = require("../common/utils"); 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 | exports.createProgressNode = createProgressNode; 29 | -------------------------------------------------------------------------------- /src/common/icons.js: -------------------------------------------------------------------------------- 1 | const icons = { 2 | star: ``, 3 | commits: ``, 4 | prs: ``, 5 | issues: ``, 6 | icon: ``, 7 | contribs: ``, 8 | fork: ``, 9 | }; 10 | 11 | module.exports = icons; 12 | -------------------------------------------------------------------------------- /src/common/languageColors.json: -------------------------------------------------------------------------------- 1 | { 2 | "1C Enterprise": "#814CCC", 3 | "4D": null, 4 | "ABAP": "#E8274B", 5 | "ActionScript": "#882B0F", 6 | "Ada": "#02f88c", 7 | "Agda": "#315665", 8 | "AGS Script": "#B9D9FF", 9 | "AL": "#3AA2B5", 10 | "Alloy": "#64C800", 11 | "Alpine Abuild": null, 12 | "AMPL": "#E6EFBB", 13 | "AngelScript": "#C7D7DC", 14 | "ANTLR": "#9DC3FF", 15 | "Apex": "#1797c0", 16 | "API Blueprint": "#2ACCA8", 17 | "APL": "#5A8164", 18 | "Apollo Guidance Computer": "#0B3D91", 19 | "AppleScript": "#101F1F", 20 | "Arc": "#aa2afe", 21 | "ASL": null, 22 | "ASP.NET": "#9400ff", 23 | "AspectJ": "#a957b0", 24 | "Assembly": "#6E4C13", 25 | "Asymptote": "#ff0000", 26 | "ATS": "#1ac620", 27 | "Augeas": null, 28 | "AutoHotkey": "#6594b9", 29 | "AutoIt": "#1C3552", 30 | "Awk": null, 31 | "Ballerina": "#FF5000", 32 | "Batchfile": "#C1F12E", 33 | "Befunge": null, 34 | "Bison": "#6A463F", 35 | "BitBake": null, 36 | "Blade": "#f7523f", 37 | "BlitzBasic": null, 38 | "BlitzMax": "#cd6400", 39 | "Bluespec": null, 40 | "Boo": "#d4bec1", 41 | "Brainfuck": "#2F2530", 42 | "Brightscript": null, 43 | "C": "#555555", 44 | "C#": "#178600", 45 | "C++": "#f34b7d", 46 | "C2hs Haskell": null, 47 | "Cap'n Proto": null, 48 | "CartoCSS": null, 49 | "Ceylon": "#dfa535", 50 | "Chapel": "#8dc63f", 51 | "Charity": null, 52 | "ChucK": null, 53 | "Cirru": "#ccccff", 54 | "Clarion": "#db901e", 55 | "Classic ASP": "#6a40fd", 56 | "Clean": "#3F85AF", 57 | "Click": "#E4E6F3", 58 | "CLIPS": null, 59 | "Clojure": "#db5855", 60 | "CMake": null, 61 | "COBOL": null, 62 | "CodeQL": null, 63 | "CoffeeScript": "#244776", 64 | "ColdFusion": "#ed2cd6", 65 | "ColdFusion CFC": "#ed2cd6", 66 | "Common Lisp": "#3fb68b", 67 | "Common Workflow Language": "#B5314C", 68 | "Component Pascal": "#B0CE4E", 69 | "Cool": null, 70 | "Coq": null, 71 | "Crystal": "#000100", 72 | "CSON": "#244776", 73 | "Csound": null, 74 | "Csound Document": null, 75 | "Csound Score": null, 76 | "CSS": "#563d7c", 77 | "Cuda": "#3A4E3A", 78 | "CWeb": null, 79 | "Cycript": null, 80 | "Cython": null, 81 | "D": "#ba595e", 82 | "Dafny": "#FFEC25", 83 | "Dart": "#00B4AB", 84 | "DataWeave": "#003a52", 85 | "Dhall": "#dfafff", 86 | "DIGITAL Command Language": null, 87 | "DM": "#447265", 88 | "Dockerfile": "#384d54", 89 | "Dogescript": "#cca760", 90 | "DTrace": null, 91 | "Dylan": "#6c616e", 92 | "E": "#ccce35", 93 | "eC": "#913960", 94 | "ECL": "#8a1267", 95 | "ECLiPSe": null, 96 | "Eiffel": "#4d6977", 97 | "EJS": "#a91e50", 98 | "Elixir": "#6e4a7e", 99 | "Elm": "#60B5CC", 100 | "Emacs Lisp": "#c065db", 101 | "EmberScript": "#FFF4F3", 102 | "EQ": "#a78649", 103 | "Erlang": "#B83998", 104 | "F#": "#b845fc", 105 | "F*": "#572e30", 106 | "Factor": "#636746", 107 | "Fancy": "#7b9db4", 108 | "Fantom": "#14253c", 109 | "Faust": "#c37240", 110 | "Filebench WML": null, 111 | "Filterscript": null, 112 | "fish": null, 113 | "FLUX": "#88ccff", 114 | "Forth": "#341708", 115 | "Fortran": "#4d41b1", 116 | "Fortran Free Form": null, 117 | "FreeMarker": "#0050b2", 118 | "Frege": "#00cafe", 119 | "Futhark": "#5f021f", 120 | "G-code": "#D08CF2", 121 | "Game Maker Language": "#71b417", 122 | "GAML": "#FFC766", 123 | "GAMS": null, 124 | "GAP": null, 125 | "GCC Machine Description": null, 126 | "GDB": null, 127 | "GDScript": "#355570", 128 | "Genie": "#fb855d", 129 | "Genshi": null, 130 | "Gentoo Ebuild": null, 131 | "Gentoo Eclass": null, 132 | "Gherkin": "#5B2063", 133 | "GLSL": null, 134 | "Glyph": "#c1ac7f", 135 | "Gnuplot": "#f0a9f0", 136 | "Go": "#00ADD8", 137 | "Golo": "#88562A", 138 | "Gosu": "#82937f", 139 | "Grace": null, 140 | "Grammatical Framework": "#ff0000", 141 | "GraphQL": "#e10098", 142 | "Groovy": "#e69f56", 143 | "Groovy Server Pages": null, 144 | "Hack": "#878787", 145 | "Haml": "#ece2a9", 146 | "Handlebars": "#f7931e", 147 | "Harbour": "#0e60e3", 148 | "Haskell": "#5e5086", 149 | "Haxe": "#df7900", 150 | "HCL": null, 151 | "HiveQL": "#dce200", 152 | "HLSL": null, 153 | "HolyC": "#ffefaf", 154 | "HTML": "#e34c26", 155 | "Hy": "#7790B2", 156 | "HyPhy": null, 157 | "IDL": "#a3522f", 158 | "Idris": "#b30000", 159 | "IGOR Pro": "#0000cc", 160 | "Inform 7": null, 161 | "Inno Setup": null, 162 | "Io": "#a9188d", 163 | "Ioke": "#078193", 164 | "Isabelle": "#FEFE00", 165 | "Isabelle ROOT": null, 166 | "J": "#9EEDFF", 167 | "Jasmin": null, 168 | "Java": "#b07219", 169 | "Java Server Pages": null, 170 | "JavaScript": "#f1e05a", 171 | "JavaScript+ERB": null, 172 | "JFlex": "#DBCA00", 173 | "Jison": null, 174 | "Jison Lex": null, 175 | "Jolie": "#843179", 176 | "JSONiq": "#40d47e", 177 | "Jsonnet": "#0064bd", 178 | "JSX": null, 179 | "Julia": "#a270ba", 180 | "Jupyter Notebook": "#DA5B0B", 181 | "Kaitai Struct": "#773b37", 182 | "Kotlin": "#F18E33", 183 | "KRL": "#28430A", 184 | "LabVIEW": null, 185 | "Lasso": "#999999", 186 | "Latte": "#f2a542", 187 | "Lean": null, 188 | "Less": "#1d365d", 189 | "Lex": "#DBCA00", 190 | "LFE": "#4C3023", 191 | "LilyPond": null, 192 | "Limbo": null, 193 | "Literate Agda": null, 194 | "Literate CoffeeScript": null, 195 | "Literate Haskell": null, 196 | "LiveScript": "#499886", 197 | "LLVM": "#185619", 198 | "Logos": null, 199 | "Logtalk": null, 200 | "LOLCODE": "#cc9900", 201 | "LookML": "#652B81", 202 | "LoomScript": null, 203 | "LSL": "#3d9970", 204 | "Lua": "#000080", 205 | "M": null, 206 | "M4": null, 207 | "M4Sugar": null, 208 | "Macaulay2": "#d8ffff", 209 | "Makefile": "#427819", 210 | "Mako": null, 211 | "Markdown": "#083fa1", 212 | "Marko": "#42bff2", 213 | "Mask": "#f97732", 214 | "Mathematica": null, 215 | "MATLAB": "#e16737", 216 | "Max": "#c4a79c", 217 | "MAXScript": "#00a6a6", 218 | "mcfunction": "#E22837", 219 | "Mercury": "#ff2b2b", 220 | "Meson": "#007800", 221 | "Metal": "#8f14e9", 222 | "MiniD": null, 223 | "Mirah": "#c7a938", 224 | "mIRC Script": "#3d57c3", 225 | "MLIR": "#5EC8DB", 226 | "Modelica": null, 227 | "Modula-2": null, 228 | "Modula-3": "#223388", 229 | "Module Management System": null, 230 | "Monkey": null, 231 | "Moocode": null, 232 | "MoonScript": null, 233 | "Motorola 68K Assembly": null, 234 | "MQL4": "#62A8D6", 235 | "MQL5": "#4A76B8", 236 | "MTML": "#b7e1f4", 237 | "MUF": null, 238 | "mupad": null, 239 | "Myghty": null, 240 | "NASL": null, 241 | "NCL": "#28431f", 242 | "Nearley": "#990000", 243 | "Nemerle": "#3d3c6e", 244 | "nesC": "#94B0C7", 245 | "NetLinx": "#0aa0ff", 246 | "NetLinx+ERB": "#747faa", 247 | "NetLogo": "#ff6375", 248 | "NewLisp": "#87AED7", 249 | "Nextflow": "#3ac486", 250 | "Nim": "#ffc200", 251 | "Nit": "#009917", 252 | "Nix": "#7e7eff", 253 | "NSIS": null, 254 | "Nu": "#c9df40", 255 | "NumPy": "#9C8AF9", 256 | "Objective-C": "#438eff", 257 | "Objective-C++": "#6866fb", 258 | "Objective-J": "#ff0c5a", 259 | "ObjectScript": "#424893", 260 | "OCaml": "#3be133", 261 | "Odin": "#60AFFE", 262 | "Omgrofl": "#cabbff", 263 | "ooc": "#b0b77e", 264 | "Opa": null, 265 | "Opal": "#f7ede0", 266 | "Open Policy Agent": null, 267 | "OpenCL": null, 268 | "OpenEdge ABL": null, 269 | "OpenQASM": "#AA70FF", 270 | "OpenRC runscript": null, 271 | "OpenSCAD": null, 272 | "Ox": null, 273 | "Oxygene": "#cdd0e3", 274 | "Oz": "#fab738", 275 | "P4": "#7055b5", 276 | "Pan": "#cc0000", 277 | "Papyrus": "#6600cc", 278 | "Parrot": "#f3ca0a", 279 | "Parrot Assembly": null, 280 | "Parrot Internal Representation": null, 281 | "Pascal": "#E3F171", 282 | "Pawn": "#dbb284", 283 | "Pep8": "#C76F5B", 284 | "Perl": "#0298c3", 285 | "PHP": "#4F5D95", 286 | "PicoLisp": null, 287 | "PigLatin": "#fcd7de", 288 | "Pike": "#005390", 289 | "PLpgSQL": null, 290 | "PLSQL": "#dad8d8", 291 | "PogoScript": "#d80074", 292 | "Pony": null, 293 | "PostScript": "#da291c", 294 | "POV-Ray SDL": null, 295 | "PowerBuilder": "#8f0f8d", 296 | "PowerShell": "#012456", 297 | "Prisma": "#0c344b", 298 | "Processing": "#0096D8", 299 | "Prolog": "#74283c", 300 | "Propeller Spin": "#7fa2a7", 301 | "Pug": "#a86454", 302 | "Puppet": "#302B6D", 303 | "PureBasic": "#5a6986", 304 | "PureScript": "#1D222D", 305 | "Python": "#3572A5", 306 | "Python console": null, 307 | "q": "#0040cd", 308 | "Q#": "#fed659", 309 | "QMake": null, 310 | "QML": "#44a51c", 311 | "Qt Script": "#00b841", 312 | "Quake": "#882233", 313 | "R": "#198CE7", 314 | "Racket": "#3c5caa", 315 | "Ragel": "#9d5200", 316 | "Raku": "#0000fb", 317 | "RAML": "#77d9fb", 318 | "Rascal": "#fffaa0", 319 | "REALbasic": null, 320 | "Reason": "#ff5847", 321 | "Rebol": "#358a5b", 322 | "Red": "#f50000", 323 | "Redcode": null, 324 | "Ren'Py": "#ff7f7f", 325 | "RenderScript": null, 326 | "REXX": null, 327 | "Ring": "#2D54CB", 328 | "Riot": "#A71E49", 329 | "RobotFramework": null, 330 | "Roff": "#ecdebe", 331 | "Rouge": "#cc0088", 332 | "RPC": null, 333 | "Ruby": "#701516", 334 | "RUNOFF": "#665a4e", 335 | "Rust": "#dea584", 336 | "Sage": null, 337 | "SaltStack": "#646464", 338 | "SAS": "#B34936", 339 | "Sass": "#a53b70", 340 | "Scala": "#c22d40", 341 | "Scheme": "#1e4aec", 342 | "Scilab": null, 343 | "SCSS": "#c6538c", 344 | "sed": "#64b970", 345 | "Self": "#0579aa", 346 | "ShaderLab": null, 347 | "Shell": "#89e051", 348 | "ShellSession": null, 349 | "Shen": "#120F14", 350 | "Sieve": null, 351 | "Slash": "#007eff", 352 | "Slice": "#003fa2", 353 | "Slim": "#2b2b2b", 354 | "Smali": null, 355 | "Smalltalk": "#596706", 356 | "Smarty": null, 357 | "SmPL": "#c94949", 358 | "SMT": null, 359 | "Solidity": "#AA6746", 360 | "SourcePawn": "#f69e1d", 361 | "SQF": "#3F3F3F", 362 | "SQLPL": null, 363 | "Squirrel": "#800000", 364 | "SRecode Template": "#348a34", 365 | "Stan": "#b2011d", 366 | "Standard ML": "#dc566d", 367 | "Starlark": "#76d275", 368 | "Stata": null, 369 | "Stylus": "#ff6347", 370 | "SuperCollider": "#46390b", 371 | "Svelte": "#ff3e00", 372 | "SVG": "#ff9900", 373 | "Swift": "#ffac45", 374 | "SWIG": null, 375 | "SystemVerilog": "#DAE1C2", 376 | "Tcl": "#e4cc98", 377 | "Tcsh": null, 378 | "Terra": "#00004c", 379 | "TeX": "#3D6117", 380 | "Thrift": null, 381 | "TI Program": "#A0AA87", 382 | "TLA": null, 383 | "TSQL": null, 384 | "TSX": null, 385 | "Turing": "#cf142b", 386 | "Twig": "#c1d026", 387 | "TXL": null, 388 | "TypeScript": "#2b7489", 389 | "Unified Parallel C": "#4e3617", 390 | "Unix Assembly": null, 391 | "Uno": "#9933cc", 392 | "UnrealScript": "#a54c4d", 393 | "UrWeb": null, 394 | "V": "#4f87c4", 395 | "Vala": "#fbe5cd", 396 | "VBA": "#867db1", 397 | "VBScript": "#15dcdc", 398 | "VCL": "#148AA8", 399 | "Verilog": "#b2b7f8", 400 | "VHDL": "#adb2cb", 401 | "Vim script": "#199f4b", 402 | "Visual Basic .NET": "#945db7", 403 | "Volt": "#1F1F1F", 404 | "Vue": "#2c3e50", 405 | "wdl": "#42f1f4", 406 | "WebAssembly": "#04133b", 407 | "WebIDL": null, 408 | "wisp": "#7582D1", 409 | "Wollok": "#a23738", 410 | "X10": "#4B6BEF", 411 | "xBase": "#403a40", 412 | "XC": "#99DA07", 413 | "Xojo": null, 414 | "XProc": null, 415 | "XQuery": "#5232e7", 416 | "XS": null, 417 | "XSLT": "#EB8CEB", 418 | "Xtend": null, 419 | "Yacc": "#4B6C4B", 420 | "YAML": "#cb171e", 421 | "YARA": "#220000", 422 | "YASnippet": "#32AB90", 423 | "ZAP": "#0d665e", 424 | "Zeek": null, 425 | "ZenScript": "#00BCD1", 426 | "Zephir": "#118f9e", 427 | "Zig": "#ec915c", 428 | "ZIL": "#dc75e5", 429 | "Zimpl": null 430 | } -------------------------------------------------------------------------------- /src/common/retryer.js: -------------------------------------------------------------------------------- 1 | const { logger, CustomError } = require("../common/utils"); 2 | 3 | const retryer = async (fetcher, variables, retries = 0) => { 4 | if (retries > 7) { 5 | throw new CustomError("Maximum retries exceeded", CustomError.MAX_RETRY); 6 | } 7 | try { 8 | // try to fetch with the first token since RETRIES is 0 index i'm adding +1 9 | let response = await fetcher( 10 | variables, 11 | process.env[`PAT_${retries + 1}`], 12 | retries, 13 | ); 14 | 15 | // prettier-ignore 16 | const isRateExceeded = response.data.errors && response.data.errors[0].type === "RATE_LIMITED"; 17 | 18 | // if rate limit is hit increase the RETRIES and recursively call the retryer 19 | // with username, and current RETRIES 20 | if (isRateExceeded) { 21 | logger.log(`PAT_${retries + 1} Failed`); 22 | retries++; 23 | // directly return from the function 24 | return retryer(fetcher, variables, retries); 25 | } 26 | 27 | // finally return the response 28 | return response; 29 | } catch (err) { 30 | // prettier-ignore 31 | // also checking for bad credentials if any tokens gets invalidated 32 | const isBadCredential = err.response.data && err.response.data.message === "Bad credentials"; 33 | 34 | if (isBadCredential) { 35 | logger.log(`PAT_${retries + 1} Failed`); 36 | retries++; 37 | // directly return from the function 38 | return retryer(fetcher, variables, retries); 39 | } 40 | } 41 | }; 42 | 43 | module.exports = retryer; 44 | -------------------------------------------------------------------------------- /src/common/utils.js: -------------------------------------------------------------------------------- 1 | const axios = require("axios"); 2 | const wrap = require("word-wrap"); 3 | const themes = require("../../themes"); 4 | 5 | const renderError = (message, secondaryMessage = "") => { 6 | return ` 7 | 8 | 13 | 14 | Something went wrong! file an issue at https://git.io/JJmN9 15 | 16 | ${encodeHTML(message)} 17 | ${secondaryMessage} 18 | 19 | 20 | `; 21 | }; 22 | 23 | // https://stackoverflow.com/a/48073476/10629172 24 | function encodeHTML(str) { 25 | return str 26 | .replace(/[\u00A0-\u9999<>&](?!#)/gim, (i) => { 27 | return "&#" + i.charCodeAt(0) + ";"; 28 | }) 29 | .replace(/\u0008/gim, ""); 30 | } 31 | 32 | function kFormatter(num) { 33 | return Math.abs(num) > 999 34 | ? Math.sign(num) * (Math.abs(num) / 1000).toFixed(1) + "k" 35 | : Math.sign(num) * Math.abs(num); 36 | } 37 | 38 | function isValidHexColor(hexColor) { 39 | return new RegExp( 40 | /^([A-Fa-f0-9]{8}|[A-Fa-f0-9]{6}|[A-Fa-f0-9]{3}|[A-Fa-f0-9]{4})$/, 41 | ).test(hexColor); 42 | } 43 | 44 | function parseBoolean(value) { 45 | if (value === "true") { 46 | return true; 47 | } else if (value === "false") { 48 | return false; 49 | } else { 50 | return value; 51 | } 52 | } 53 | 54 | function parseArray(str) { 55 | if (!str) return []; 56 | return str.split(","); 57 | } 58 | 59 | function clampValue(number, min, max) { 60 | return Math.max(min, Math.min(number, max)); 61 | } 62 | 63 | function isValidGradient(colors) { 64 | return isValidHexColor(colors[1]) && isValidHexColor(colors[2]); 65 | } 66 | 67 | function fallbackColor(color, fallbackColor) { 68 | let colors = color.split(","); 69 | let gradient = null; 70 | 71 | if (colors.length > 1 && isValidGradient(colors)) { 72 | gradient = colors; 73 | } 74 | 75 | return ( 76 | (gradient ? gradient : isValidHexColor(color) && `#${color}`) || 77 | fallbackColor 78 | ); 79 | } 80 | 81 | function request(data, headers) { 82 | return axios({ 83 | url: "https://api.github.com/graphql", 84 | method: "post", 85 | headers, 86 | data, 87 | }); 88 | } 89 | 90 | /** 91 | * 92 | * @param {String[]} items 93 | * @param {Number} gap 94 | * @param {string} direction 95 | * 96 | * @description 97 | * Auto layout utility, allows us to layout things 98 | * vertically or horizontally with proper gaping 99 | */ 100 | function FlexLayout({ items, gap, direction }) { 101 | // filter() for filtering out empty strings 102 | return items.filter(Boolean).map((item, i) => { 103 | let transform = `translate(${gap * i}, 0)`; 104 | if (direction === "column") { 105 | transform = `translate(0, ${gap * i})`; 106 | } 107 | return `${item}`; 108 | }); 109 | } 110 | 111 | // returns theme based colors with proper overrides and defaults 112 | function getCardColors({ 113 | title_color, 114 | text_color, 115 | icon_color, 116 | bg_color, 117 | theme, 118 | fallbackTheme = "default", 119 | }) { 120 | const defaultTheme = themes[fallbackTheme]; 121 | const selectedTheme = themes[theme] || defaultTheme; 122 | 123 | // get the color provided by the user else the theme color 124 | // finally if both colors are invalid fallback to default theme 125 | const titleColor = fallbackColor( 126 | title_color || selectedTheme.title_color, 127 | "#" + defaultTheme.title_color, 128 | ); 129 | const iconColor = fallbackColor( 130 | icon_color || selectedTheme.icon_color, 131 | "#" + defaultTheme.icon_color, 132 | ); 133 | const textColor = fallbackColor( 134 | text_color || selectedTheme.text_color, 135 | "#" + defaultTheme.text_color, 136 | ); 137 | const bgColor = fallbackColor( 138 | bg_color || selectedTheme.bg_color, 139 | "#" + defaultTheme.bg_color, 140 | ); 141 | 142 | return { titleColor, iconColor, textColor, bgColor }; 143 | } 144 | 145 | function wrapTextMultiline(text, width = 60, maxLines = 3) { 146 | const wrapped = wrap(encodeHTML(text), { width }) 147 | .split("\n") // Split wrapped lines to get an array of lines 148 | .map((line) => line.trim()); // Remove leading and trailing whitespace of each line 149 | 150 | const lines = wrapped.slice(0, maxLines); // Only consider maxLines lines 151 | 152 | // Add "..." to the last line if the text exceeds maxLines 153 | if (wrapped.length > maxLines) { 154 | lines[maxLines - 1] += "..."; 155 | } 156 | 157 | // Remove empty lines if text fits in less than maxLines lines 158 | const multiLineText = lines.filter(Boolean); 159 | return multiLineText; 160 | } 161 | 162 | const noop = () => {}; 163 | // return console instance based on the environment 164 | const logger = 165 | process.env.NODE_ENV !== "test" ? console : { log: noop, error: noop }; 166 | 167 | const CONSTANTS = { 168 | THIRTY_MINUTES: 1800, 169 | TWO_HOURS: 7200, 170 | FOUR_HOURS: 14400, 171 | ONE_DAY: 86400, 172 | }; 173 | 174 | const SECONDARY_ERROR_MESSAGES = { 175 | MAX_RETRY: 176 | "Please add an env variable called PAT_1 with your github token in vercel", 177 | USER_NOT_FOUND: "Make sure the provided username is not an organization", 178 | }; 179 | 180 | class CustomError extends Error { 181 | constructor(message, type) { 182 | super(message); 183 | this.type = type; 184 | this.secondaryMessage = SECONDARY_ERROR_MESSAGES[type] || "adsad"; 185 | } 186 | 187 | static MAX_RETRY = "MAX_RETRY"; 188 | static USER_NOT_FOUND = "USER_NOT_FOUND"; 189 | } 190 | 191 | // https://stackoverflow.com/a/48172630/10629172 192 | function measureText(str, fontSize = 10) { 193 | // prettier-ignore 194 | const widths = [ 195 | 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 196 | 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 197 | 0, 0, 0, 0, 0.2796875, 0.2765625, 198 | 0.3546875, 0.5546875, 0.5546875, 0.8890625, 0.665625, 0.190625, 199 | 0.3328125, 0.3328125, 0.3890625, 0.5828125, 0.2765625, 0.3328125, 200 | 0.2765625, 0.3015625, 0.5546875, 0.5546875, 0.5546875, 0.5546875, 201 | 0.5546875, 0.5546875, 0.5546875, 0.5546875, 0.5546875, 0.5546875, 202 | 0.2765625, 0.2765625, 0.584375, 0.5828125, 0.584375, 0.5546875, 203 | 1.0140625, 0.665625, 0.665625, 0.721875, 0.721875, 0.665625, 204 | 0.609375, 0.7765625, 0.721875, 0.2765625, 0.5, 0.665625, 205 | 0.5546875, 0.8328125, 0.721875, 0.7765625, 0.665625, 0.7765625, 206 | 0.721875, 0.665625, 0.609375, 0.721875, 0.665625, 0.94375, 207 | 0.665625, 0.665625, 0.609375, 0.2765625, 0.3546875, 0.2765625, 208 | 0.4765625, 0.5546875, 0.3328125, 0.5546875, 0.5546875, 0.5, 209 | 0.5546875, 0.5546875, 0.2765625, 0.5546875, 0.5546875, 0.221875, 210 | 0.240625, 0.5, 0.221875, 0.8328125, 0.5546875, 0.5546875, 211 | 0.5546875, 0.5546875, 0.3328125, 0.5, 0.2765625, 0.5546875, 212 | 0.5, 0.721875, 0.5, 0.5, 0.5, 0.3546875, 0.259375, 0.353125, 0.5890625, 213 | ]; 214 | 215 | const avg = 0.5279276315789471; 216 | return ( 217 | str 218 | .split("") 219 | .map((c) => 220 | c.charCodeAt(0) < widths.length ? widths[c.charCodeAt(0)] : avg, 221 | ) 222 | .reduce((cur, acc) => acc + cur) * fontSize 223 | ); 224 | } 225 | 226 | module.exports = { 227 | renderError, 228 | kFormatter, 229 | encodeHTML, 230 | isValidHexColor, 231 | request, 232 | parseArray, 233 | parseBoolean, 234 | fallbackColor, 235 | FlexLayout, 236 | getCardColors, 237 | clampValue, 238 | wrapTextMultiline, 239 | measureText, 240 | logger, 241 | CONSTANTS, 242 | CustomError, 243 | }; 244 | -------------------------------------------------------------------------------- /src/fetchers/repo-fetcher.js: -------------------------------------------------------------------------------- 1 | const { request } = require("../common/utils"); 2 | const retryer = require("../common/retryer"); 3 | 4 | const fetcher = (variables, token) => { 5 | return request( 6 | { 7 | query: ` 8 | fragment RepoInfo on Repository { 9 | name 10 | nameWithOwner 11 | isPrivate 12 | isArchived 13 | isTemplate 14 | stargazers { 15 | totalCount 16 | } 17 | description 18 | primaryLanguage { 19 | color 20 | id 21 | name 22 | } 23 | forkCount 24 | } 25 | query getRepo($login: String!, $repo: String!) { 26 | user(login: $login) { 27 | repository(name: $repo) { 28 | ...RepoInfo 29 | } 30 | } 31 | organization(login: $login) { 32 | repository(name: $repo) { 33 | ...RepoInfo 34 | } 35 | } 36 | } 37 | `, 38 | variables, 39 | }, 40 | { 41 | Authorization: `bearer ${token}`, 42 | }, 43 | ); 44 | }; 45 | 46 | async function fetchRepo(username, reponame) { 47 | if (!username || !reponame) { 48 | throw new Error("Invalid username or reponame"); 49 | } 50 | 51 | let res = await retryer(fetcher, { login: username, repo: reponame }); 52 | 53 | const data = res.data.data; 54 | 55 | if (!data.user && !data.organization) { 56 | throw new Error("Not found"); 57 | } 58 | 59 | const isUser = data.organization === null && data.user; 60 | const isOrg = data.user === null && data.organization; 61 | 62 | if (isUser) { 63 | if (!data.user.repository || data.user.repository.isPrivate) { 64 | throw new Error("User Repository Not found"); 65 | } 66 | return data.user.repository; 67 | } 68 | 69 | if (isOrg) { 70 | if ( 71 | !data.organization.repository || 72 | data.organization.repository.isPrivate 73 | ) { 74 | throw new Error("Organization Repository Not found"); 75 | } 76 | return data.organization.repository; 77 | } 78 | } 79 | 80 | module.exports = fetchRepo; 81 | -------------------------------------------------------------------------------- /src/fetchers/stats-fetcher.js: -------------------------------------------------------------------------------- 1 | const { request, logger, CustomError } = require("../common/utils"); 2 | const axios = require("axios"); 3 | const retryer = require("../common/retryer"); 4 | const calculateRank = require("../calculateRank"); 5 | const githubUsernameRegex = require("github-username-regex"); 6 | 7 | require("dotenv").config(); 8 | 9 | const fetcher = (variables, token) => { 10 | return request( 11 | { 12 | query: ` 13 | query userInfo($login: String!) { 14 | user(login: $login) { 15 | name 16 | login 17 | contributionsCollection { 18 | totalCommitContributions 19 | restrictedContributionsCount 20 | } 21 | repositoriesContributedTo(first: 1, contributionTypes: [COMMIT, ISSUE, PULL_REQUEST, REPOSITORY]) { 22 | totalCount 23 | } 24 | pullRequests(first: 1) { 25 | totalCount 26 | } 27 | issues(first: 1) { 28 | totalCount 29 | } 30 | followers { 31 | totalCount 32 | } 33 | repositories(first: 100, ownerAffiliations: OWNER, orderBy: {direction: DESC, field: STARGAZERS}) { 34 | totalCount 35 | nodes { 36 | stargazers { 37 | totalCount 38 | } 39 | } 40 | } 41 | } 42 | } 43 | `, 44 | variables, 45 | }, 46 | { 47 | Authorization: `bearer ${token}`, 48 | }, 49 | ); 50 | }; 51 | 52 | // https://github.com/anuraghazra/github-readme-stats/issues/92#issuecomment-661026467 53 | // https://github.com/anuraghazra/github-readme-stats/pull/211/ 54 | const totalCommitsFetcher = async (username) => { 55 | if (!githubUsernameRegex.test(username)) { 56 | logger.log("Invalid username"); 57 | return 0; 58 | } 59 | 60 | // https://developer.github.com/v3/search/#search-commits 61 | const fetchTotalCommits = (variables, token) => { 62 | return axios({ 63 | method: "get", 64 | url: `https://api.github.com/search/commits?q=author:${variables.login}`, 65 | headers: { 66 | "Content-Type": "application/json", 67 | Accept: "application/vnd.github.cloak-preview", 68 | Authorization: `bearer ${token}`, 69 | }, 70 | }); 71 | }; 72 | 73 | try { 74 | let res = await retryer(fetchTotalCommits, { login: username }); 75 | if (res.data.total_count) { 76 | return res.data.total_count; 77 | } 78 | } catch (err) { 79 | logger.log(err); 80 | // just return 0 if there is something wrong so that 81 | // we don't break the whole app 82 | return 0; 83 | } 84 | }; 85 | 86 | async function fetchStats( 87 | username, 88 | count_private = false, 89 | include_all_commits = false, 90 | ) { 91 | if (!username) throw Error("Invalid username"); 92 | 93 | const stats = { 94 | name: "", 95 | totalPRs: 0, 96 | totalCommits: 0, 97 | totalIssues: 0, 98 | totalStars: 0, 99 | contributedTo: 0, 100 | rank: { level: "C", score: 0 }, 101 | }; 102 | 103 | let res = await retryer(fetcher, { login: username }); 104 | 105 | if (res.data.errors) { 106 | logger.error(res.data.errors); 107 | throw new CustomError( 108 | res.data.errors[0].message || "Could not fetch user", 109 | CustomError.USER_NOT_FOUND, 110 | ); 111 | } 112 | 113 | const user = res.data.data.user; 114 | 115 | stats.name = user.name || user.login; 116 | stats.totalIssues = user.issues.totalCount; 117 | 118 | // normal commits 119 | stats.totalCommits = user.contributionsCollection.totalCommitContributions; 120 | 121 | // if include_all_commits then just get that, 122 | // since totalCommitsFetcher already sends totalCommits no need to += 123 | if (include_all_commits) { 124 | stats.totalCommits = await totalCommitsFetcher(username); 125 | } 126 | 127 | // if count_private then add private commits to totalCommits so far. 128 | if (count_private) { 129 | stats.totalCommits += 130 | user.contributionsCollection.restrictedContributionsCount; 131 | } 132 | 133 | stats.totalPRs = user.pullRequests.totalCount; 134 | stats.contributedTo = user.repositoriesContributedTo.totalCount; 135 | 136 | stats.totalStars = user.repositories.nodes.reduce((prev, curr) => { 137 | return prev + curr.stargazers.totalCount; 138 | }, 0); 139 | 140 | stats.rank = calculateRank({ 141 | totalCommits: stats.totalCommits, 142 | totalRepos: user.repositories.totalCount, 143 | followers: user.followers.totalCount, 144 | contributions: stats.contributedTo, 145 | stargazers: stats.totalStars, 146 | prs: stats.totalPRs, 147 | issues: stats.totalIssues, 148 | }); 149 | 150 | return stats; 151 | } 152 | 153 | module.exports = fetchStats; 154 | -------------------------------------------------------------------------------- /src/fetchers/top-languages-fetcher.js: -------------------------------------------------------------------------------- 1 | const { request, logger, clampValue } = require("../common/utils"); 2 | const retryer = require("../common/retryer"); 3 | require("dotenv").config(); 4 | 5 | const fetcher = (variables, token) => { 6 | return request( 7 | { 8 | query: ` 9 | query userInfo($login: String!) { 10 | user(login: $login) { 11 | # fetch only owner repos & not forks 12 | repositories(ownerAffiliations: OWNER, isFork: false, first: 100) { 13 | nodes { 14 | name 15 | languages(first: 10, orderBy: {field: SIZE, direction: DESC}) { 16 | edges { 17 | size 18 | node { 19 | color 20 | name 21 | } 22 | } 23 | } 24 | } 25 | } 26 | } 27 | } 28 | `, 29 | variables, 30 | }, 31 | { 32 | Authorization: `bearer ${token}`, 33 | }, 34 | ); 35 | }; 36 | 37 | async function fetchTopLanguages(username, langsCount = 5, exclude_repo = []) { 38 | if (!username) throw Error("Invalid username"); 39 | 40 | langsCount = clampValue(parseInt(langsCount), 1, 10); 41 | 42 | const res = await retryer(fetcher, { login: username }); 43 | 44 | if (res.data.errors) { 45 | logger.error(res.data.errors); 46 | throw Error(res.data.errors[0].message || "Could not fetch user"); 47 | } 48 | 49 | let repoNodes = res.data.data.user.repositories.nodes; 50 | let repoToHide = {}; 51 | 52 | // populate repoToHide map for quick lookup 53 | // while filtering out 54 | if (exclude_repo) { 55 | exclude_repo.forEach((repoName) => { 56 | repoToHide[repoName] = true; 57 | }); 58 | } 59 | 60 | // filter out repositories to be hidden 61 | repoNodes = repoNodes 62 | .sort((a, b) => b.size - a.size) 63 | .filter((name) => { 64 | return !repoToHide[name.name]; 65 | }); 66 | 67 | repoNodes = repoNodes 68 | .filter((node) => { 69 | return node.languages.edges.length > 0; 70 | }) 71 | // flatten the list of language nodes 72 | .reduce((acc, curr) => curr.languages.edges.concat(acc), []) 73 | .reduce((acc, prev) => { 74 | // get the size of the language (bytes) 75 | let langSize = prev.size; 76 | 77 | // if we already have the language in the accumulator 78 | // & the current language name is same as previous name 79 | // add the size to the language size. 80 | if (acc[prev.node.name] && prev.node.name === acc[prev.node.name].name) { 81 | langSize = prev.size + acc[prev.node.name].size; 82 | } 83 | return { 84 | ...acc, 85 | [prev.node.name]: { 86 | name: prev.node.name, 87 | color: prev.node.color, 88 | size: langSize, 89 | }, 90 | }; 91 | }, {}); 92 | 93 | const topLangs = Object.keys(repoNodes) 94 | .sort((a, b) => repoNodes[b].size - repoNodes[a].size) 95 | .slice(0, langsCount) 96 | .reduce((result, key) => { 97 | result[key] = repoNodes[key]; 98 | return result; 99 | }, {}); 100 | 101 | return topLangs; 102 | } 103 | 104 | module.exports = fetchTopLanguages; 105 | -------------------------------------------------------------------------------- /src/fetchers/wakatime-fetcher.js: -------------------------------------------------------------------------------- 1 | const axios = require("axios"); 2 | 3 | const fetchWakatimeStats = async ({ username, api_domain, range }) => { 4 | try { 5 | const { data } = await axios.get( 6 | `https://${ 7 | api_domain ? api_domain.replace(/[^a-z-.0-9]/gi, "") : "wakatime.com" 8 | }/api/v1/users/${username}/stats/${range || ''}?is_including_today=true`, 9 | ); 10 | 11 | return data.data; 12 | } catch (err) { 13 | if (err.response.status < 200 || err.response.status > 299) { 14 | throw new Error( 15 | "Wakatime user not found, make sure you have a wakatime profile", 16 | ); 17 | } 18 | throw err; 19 | } 20 | }; 21 | 22 | module.exports = { 23 | fetchWakatimeStats, 24 | }; 25 | -------------------------------------------------------------------------------- /src/getStyles.js: -------------------------------------------------------------------------------- 1 | const calculateCircleProgress = (value) => { 2 | let radius = 40; 3 | let c = Math.PI * (radius * 2); 4 | 5 | if (value < 0) value = 0; 6 | if (value > 100) value = 100; 7 | 8 | let percentage = ((100 - value) / 100) * c; 9 | return percentage; 10 | }; 11 | 12 | const getProgressAnimation = ({ progress }) => { 13 | return ` 14 | @keyframes rankAnimation { 15 | from { 16 | stroke-dashoffset: ${calculateCircleProgress(0)}; 17 | } 18 | to { 19 | stroke-dashoffset: ${calculateCircleProgress(progress)}; 20 | } 21 | } 22 | `; 23 | }; 24 | 25 | const getAnimations = () => { 26 | return ` 27 | /* Animations */ 28 | @keyframes scaleInAnimation { 29 | from { 30 | transform: translate(-5px, 5px) scale(0); 31 | } 32 | to { 33 | transform: translate(-5px, 5px) scale(1); 34 | } 35 | } 36 | @keyframes fadeInAnimation { 37 | from { 38 | opacity: 0; 39 | } 40 | to { 41 | opacity: 1; 42 | } 43 | } 44 | `; 45 | }; 46 | 47 | const getStyles = ({ 48 | titleColor, 49 | textColor, 50 | iconColor, 51 | show_icons, 52 | progress, 53 | }) => { 54 | return ` 55 | .stat { 56 | font: 600 14px 'Segoe UI', Ubuntu, "Helvetica Neue", Sans-Serif; fill: ${textColor}; 57 | } 58 | .stagger { 59 | opacity: 0; 60 | animation: fadeInAnimation 0.3s ease-in-out forwards; 61 | } 62 | .rank-text { 63 | font: 800 24px 'Segoe UI', Ubuntu, Sans-Serif; fill: ${textColor}; 64 | animation: scaleInAnimation 0.3s ease-in-out forwards; 65 | } 66 | 67 | .bold { font-weight: 700 } 68 | .icon { 69 | fill: ${iconColor}; 70 | display: ${!!show_icons ? "block" : "none"}; 71 | } 72 | 73 | .rank-circle-rim { 74 | stroke: ${titleColor}; 75 | fill: none; 76 | stroke-width: 6; 77 | opacity: 0.2; 78 | } 79 | .rank-circle { 80 | stroke: ${titleColor}; 81 | stroke-dasharray: 250; 82 | fill: none; 83 | stroke-width: 6; 84 | stroke-linecap: round; 85 | opacity: 0.8; 86 | transform-origin: -10px 8px; 87 | transform: rotate(-90deg); 88 | animation: rankAnimation 1s forwards ease-in-out; 89 | } 90 | ${process.env.NODE_ENV === "test" ? "" : getProgressAnimation({ progress })} 91 | `; 92 | }; 93 | 94 | module.exports = { getStyles, getAnimations }; 95 | -------------------------------------------------------------------------------- /src/translations.js: -------------------------------------------------------------------------------- 1 | const { encodeHTML } = require("./common/utils"); 2 | 3 | const statCardLocales = ({ name, apostrophe }) => { 4 | return { 5 | "statcard.title": { 6 | cn: `${encodeHTML(name)} 的 GitHub 统计数据`, 7 | cs: `GitHub statistiky uživatele ${encodeHTML(name)}`, 8 | de: `${encodeHTML(name) + apostrophe} GitHub-Statistiken`, 9 | en: `${encodeHTML(name)}'${apostrophe} GitHub Stats`, 10 | es: `Estadísticas de GitHub de ${encodeHTML(name)}`, 11 | fr: `Statistiques GitHub de ${encodeHTML(name)}`, 12 | hu: `${encodeHTML(name)} GitHub statisztika`, 13 | it: `Statistiche GitHub di ${encodeHTML(name)}`, 14 | ja: `${encodeHTML(name)}の GitHub 統計`, 15 | kr: `${encodeHTML(name)}의 GitHub 통계`, 16 | nl: `${encodeHTML(name)}'${apostrophe} GitHub Statistieken`, 17 | "pt-pt": `Estatísticas do GitHub de ${encodeHTML(name)}`, 18 | "pt-br": `Estatísticas do GitHub de ${encodeHTML(name)}`, 19 | np: `${encodeHTML(name)}'${apostrophe} गिटहब तथ्याङ्क`, 20 | el: `Στατιστικά GitHub του ${encodeHTML(name)}`, 21 | ru: `Статистика GitHub пользователя ${encodeHTML(name)}`, 22 | "uk-ua": `Статистика GitHub користувача ${encodeHTML(name)}`, 23 | id: `Statistik GitHub ${encodeHTML(name)}`, 24 | my: `Statistik GitHub ${encodeHTML(name)}`, 25 | sk: `GitHub štatistiky používateľa ${encodeHTML(name)}`, 26 | tr: `${encodeHTML(name)} Hesabının GitHub Yıldızları`, 27 | pl: `Statystyki GitHub użytkownika ${encodeHTML(name)}`, 28 | }, 29 | "statcard.totalstars": { 30 | cn: "获标星数(star)", 31 | cs: "Celkem hvězd", 32 | de: "Sterne Insgesamt", 33 | en: "Total Stars", 34 | es: "Estrellas totales", 35 | fr: "Total d'étoiles", 36 | hu: "Csillagok", 37 | it: "Stelle totali", 38 | ja: "スターされた数", 39 | kr: "받은 스타 수", 40 | nl: "Totale Sterren", 41 | "pt-pt": "Total de estrelas", 42 | "pt-br": "Total de estrelas", 43 | np: "कुल ताराहरू", 44 | el: "Σύνολο Αστεριών", 45 | ru: "Всего звезд", 46 | "uk-ua": "Всього зірок", 47 | id: "Total Bintang", 48 | my: "Jumlah Bintang", 49 | sk: "Hviezdy", 50 | tr: "Toplam Yıldız", 51 | pl: "Liczba Gwiazdek", 52 | }, 53 | "statcard.commits": { 54 | cn: "累计提交数(commit)", 55 | cs: "Celkem commitů", 56 | de: "Anzahl Commits", 57 | en: "Total Commits", 58 | es: "Commits totales", 59 | fr: "Total des validations", 60 | hu: "Összes commit", 61 | it: "Commit totali", 62 | ja: "合計コミット数", 63 | kr: "전체 커밋 수", 64 | nl: "Totale Commits", 65 | "pt-pt": "Total de Commits", 66 | "pt-br": "Total de Commits", 67 | np: "कुल Commits", 68 | el: "Σύνολο Commits", 69 | ru: "Всего коммитов", 70 | "uk-ua": "Всього коммітов", 71 | id: "Total Komitmen", 72 | my: "Jumlah Komitmen", 73 | sk: "Všetky commity", 74 | tr: "Toplam Commit", 75 | pl: "Wszystkie commity", 76 | }, 77 | "statcard.prs": { 78 | cn: "拉取请求数(PR)", 79 | cs: "Celkem PRs", 80 | de: "PRs Insgesamt", 81 | en: "Total PRs", 82 | es: "PRs totales", 83 | fr: "Total des PR", 84 | hu: "Összes PR", 85 | it: "PR totali", 86 | ja: "合計 PR", 87 | kr: "PR 횟수", 88 | nl: "Totale PR's", 89 | "pt-pt": "Total de PRs", 90 | "pt-br": "Total de PRs", 91 | np: "कुल PRs", 92 | el: "Σύνολο PRs", 93 | ru: "Всего pull request`ов", 94 | "uk-ua": "Всього pull request`iв", 95 | id: "Total Permintaan Tarik", 96 | my: "Jumlah PR", 97 | sk: "Všetky PR", 98 | tr: "Toplam PR", 99 | pl: "Wszystkie PR", 100 | }, 101 | "statcard.issues": { 102 | cn: "指出问题数(issue)", 103 | cs: "Celkem problémů", 104 | de: "Anzahl Issues", 105 | en: "Total Issues", 106 | es: "Issues totales", 107 | fr: "Nombre total d'incidents", 108 | hu: "Összes hibajegy", 109 | it: "Segnalazioni totali", 110 | ja: "合計 issue", 111 | kr: "이슈 개수", 112 | nl: "Totale Issues", 113 | "pt-pt": "Total de Issues", 114 | "pt-br": "Total de Issues", 115 | np: "कुल मुद्दाहरू", 116 | el: "Σύνολο Ζητημάτων", 117 | ru: "Всего issue", 118 | "uk-ua": "Всього issue", 119 | id: "Total Masalah Dilaporkan", 120 | my: "Jumlah Isu Dilaporkan", 121 | sk: "Všetky problémy", 122 | tr: "Toplam Hata", 123 | pl: "Wszystkie problemy", 124 | }, 125 | "statcard.contribs": { 126 | cn: "参与项目数", 127 | cs: "Přispěl k", 128 | de: "Beigetragen zu", 129 | en: "Contributed to", 130 | es: "Contribuciones en", 131 | fr: "Contribué à", 132 | hu: "Hozzájárulások", 133 | it: "Ha contribuito a", 134 | ja: "コントリビュートしたリポジトリ", 135 | kr: "전체 기여도", 136 | nl: "Bijgedragen aan", 137 | "pt-pt": "Contribuiu em", 138 | "pt-br": "Contribuiu para", 139 | np: "कुल योगदानहरू", 140 | el: "Συνεισφέρθηκε σε", 141 | ru: "Внёс вклад в", 142 | "uk-ua": "Вніс внесок у", 143 | id: "Berkontribusi ke", 144 | my: "Menyumbang kepada", 145 | sk: "Účasti", 146 | tr: "Katkı Verildi", 147 | pl: "Udziały", 148 | }, 149 | }; 150 | }; 151 | 152 | const repoCardLocales = { 153 | "repocard.template": { 154 | cn: "模板", 155 | cs: "Šablona", 156 | de: "Vorlage", 157 | en: "Template", 158 | es: "Planitlla", 159 | fr: "Modèle", 160 | hu: "Sablon", 161 | it: "Template", 162 | ja: "テンプレート", 163 | kr: "템플릿", 164 | nl: "Sjabloon", 165 | "pt-pt": "Modelo", 166 | "pt-br": "Modelo", 167 | np: "टेम्पलेट", 168 | el: "Πρότυπο", 169 | ru: "Шаблон", 170 | "uk-ua": "Шаблон", 171 | id: "Pola", 172 | my: "Templat", 173 | sk: "Šablóna", 174 | tr: "Şablon", 175 | pl: "Szablony", 176 | }, 177 | "repocard.archived": { 178 | cn: "已归档", 179 | cs: "Archivováno", 180 | de: "Archiviert", 181 | en: "Archived", 182 | es: "Archivados", 183 | fr: "Archivé", 184 | hu: "Archivált", 185 | it: "Archiviata", 186 | ja: "アーカイブ済み", 187 | kr: "보관됨", 188 | nl: "Gearchiveerd", 189 | "pt-pt": "Arquivados", 190 | "pt-br": "Arquivados", 191 | np: "अभिलेख राखियो", 192 | el: "Αρχειοθετημένα", 193 | ru: "Архивирован", 194 | "uk-ua": "Архивирован", 195 | id: "Arsip", 196 | my: "Arkib", 197 | sk: "Archivované", 198 | tr: "Arşiv", 199 | pl: "Zarchiwizowano", 200 | }, 201 | }; 202 | 203 | const langCardLocales = { 204 | "langcard.title": { 205 | cn: "最常用的语言", 206 | cs: "Nejpoužívanější jazyky", 207 | de: "Meist verwendete Sprachen", 208 | en: "Most Used Languages", 209 | es: "Lenguajes más usados", 210 | fr: "Langages les plus utilisés", 211 | hu: "Leggyakrabban használt nyelvek", 212 | it: "Linguaggi più utilizzati", 213 | ja: "最もよく使っている言語", 214 | kr: "가장 많이 사용된 언어", 215 | nl: "Meest gebruikte talen", 216 | "pt-pt": "Idiomas mais usados", 217 | "pt-br": "Linguagens mais usadas", 218 | np: "अधिक प्रयोग गरिएको भाषाहरू", 219 | el: "Οι περισσότερο χρησιμοποιούμενες γλώσσες", 220 | ru: "Наиболее часто используемые языки", 221 | "uk-ua": "Найбільш часто використовувані мови", 222 | id: "Bahasa Yang Paling Banyak Digunakan", 223 | my: "Bahasa Paling Digunakan", 224 | sk: "Najviac používané jazyky", 225 | tr: "En Çok Kullanılan Diller", 226 | pl: "Najczęściej używane języki", 227 | }, 228 | }; 229 | 230 | const wakatimeCardLocales = { 231 | "wakatimecard.title": { 232 | cn: "Wakatime 周统计", 233 | cs: "Statistiky Wakatime", 234 | de: "Wakatime Status", 235 | en: "Wakatime Stats", 236 | es: "Estadísticas de Wakatime", 237 | fr: "Statistiques de Wakatime", 238 | hu: "Wakatime statisztika", 239 | it: "Statistiche Wakatime", 240 | ja: "Wakatime ワカタイム統計", 241 | kr: "Wakatime 주간 통계", 242 | nl: "Takwimu za Wakatime", 243 | "pt-pt": "Estatísticas Wakatime", 244 | "pt-br": "Estatísticas Wakatime", 245 | np: "Wakatime तथ्या .्क", 246 | el: "Στατιστικά Wakatime", 247 | ru: "Статистика Wakatime", 248 | "uk-ua": "Статистика Wakatime", 249 | id: "Status Wakatime", 250 | my: "Statistik Wakatime", 251 | sk: "Wakatime štatistika", 252 | tr: "Waketime İstatistikler", 253 | pl: "statystyki Wakatime", 254 | }, 255 | "wakatimecard.nocodingactivity": { 256 | cn: "本周没有编程活动", 257 | cs: "Tento týden žádná aktivita v kódování", 258 | de: "Keine Aktivitäten in dieser Woche", 259 | en: "No coding activity this week", 260 | es: "No hay actividad de codificación esta semana", 261 | fr: "Aucune activité de codage cette semaine", 262 | hu: "Nem volt aktivitás ezen a héten", 263 | it: "Nessuna attività in questa settimana", 264 | ja: "今週のコーディング活動はありません", 265 | kr: "이번 주 작업내역 없음", 266 | nl: "Geen coderings activiet deze week", 267 | "pt-pt": "Sem atividade esta semana", 268 | "pt-br": "Nenhuma atividade de codificação esta semana", 269 | np: "यस हप्ता कुनै कोडिंग गतिविधि छैन", 270 | el: "Δεν υπάρχει δραστηριότητα κώδικα γι' αυτή την εβδομάδα", 271 | ru: "На этой неделе не было активности", 272 | "uk-ua": "На цьому тижні не було активності", 273 | id: "Tidak ada aktivitas perkodingan minggu ini", 274 | my: "Tiada aktiviti pengekodan minggu ini", 275 | sk: "Žiadna kódovacia aktivita tento týždeň", 276 | tr: "Bu hafta herhangi bir kod yazma aktivitesi olmadı", 277 | pl: "Brak aktywności w tym tygodniu", 278 | }, 279 | }; 280 | 281 | const availableLocales = Object.keys(repoCardLocales["repocard.archived"]); 282 | 283 | function isLocaleAvailable(locale) { 284 | return availableLocales.includes(locale.toLowerCase()); 285 | } 286 | 287 | module.exports = { 288 | isLocaleAvailable, 289 | availableLocales, 290 | statCardLocales, 291 | repoCardLocales, 292 | langCardLocales, 293 | wakatimeCardLocales, 294 | }; 295 | -------------------------------------------------------------------------------- /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`] = ` 4 | " 5 | 12 | 63 | 64 | undefined 65 | 66 | 77 | 78 | 79 | 83 | 84 | Wakatime Stats 90 | 91 | 92 | 93 | 94 | 98 | 99 | 100 | 101 | 102 | Other: 103 | 19 mins 109 | 110 | 111 | 112 | 119 | 120 | 121 | 122 | 123 | 124 | 125 | TypeScript: 126 | 1 min 132 | 133 | 134 | 135 | 142 | 143 | 144 | 145 | 146 | 147 | 148 | 149 | 150 | 151 | " 152 | `; 153 | 154 | exports[`Test Render Wakatime Card should render correctly with compact layout 1`] = ` 155 | " 156 | 163 | 214 | 215 | undefined 216 | 217 | 228 | 229 | 230 | 234 | 235 | Wakatime Stats 241 | 242 | 243 | 244 | 245 | 249 | 250 | 251 | 252 | 253 | 254 | 255 | 256 | 265 | 266 | 275 | 276 | 285 | 286 | 287 | 288 | 289 | 290 | Other - 19 mins 291 | 292 | 293 | 294 | 295 | 296 | 297 | TypeScript - 1 min 298 | 299 | 300 | 301 | 302 | 303 | 304 | YAML - 0 secs 305 | 306 | 307 | 308 | 309 | 310 | 311 | 312 | 313 | " 314 | `; 315 | -------------------------------------------------------------------------------- /tests/api.test.js: -------------------------------------------------------------------------------- 1 | require("@testing-library/jest-dom"); 2 | const axios = require("axios"); 3 | const MockAdapter = require("axios-mock-adapter"); 4 | const api = require("../api/index"); 5 | const renderStatsCard = require("../src/cards/stats-card"); 6 | const { renderError, CONSTANTS } = require("../src/common/utils"); 7 | const calculateRank = require("../src/calculateRank"); 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 | issues: { totalCount: stats.totalIssues }, 39 | followers: { totalCount: 0 }, 40 | repositories: { 41 | totalCount: 1, 42 | nodes: [{ stargazers: { totalCount: 100 } }], 43 | }, 44 | }, 45 | }, 46 | }; 47 | 48 | const error = { 49 | errors: [ 50 | { 51 | type: "NOT_FOUND", 52 | path: ["user"], 53 | locations: [], 54 | message: "Could not fetch user", 55 | }, 56 | ], 57 | }; 58 | 59 | const mock = new MockAdapter(axios); 60 | 61 | const faker = (query, data) => { 62 | const req = { 63 | query: { 64 | username: "anuraghazra", 65 | ...query, 66 | }, 67 | }; 68 | const res = { 69 | setHeader: jest.fn(), 70 | send: jest.fn(), 71 | }; 72 | mock.onPost("https://api.github.com/graphql").reply(200, data); 73 | 74 | return { req, res }; 75 | }; 76 | 77 | afterEach(() => { 78 | mock.reset(); 79 | }); 80 | 81 | describe("Test /api/", () => { 82 | it("should test the request", async () => { 83 | const { req, res } = faker({}, data); 84 | 85 | await api(req, res); 86 | 87 | expect(res.setHeader).toBeCalledWith("Content-Type", "image/svg+xml"); 88 | expect(res.send).toBeCalledWith(renderStatsCard(stats, { ...req.query })); 89 | }); 90 | 91 | it("should render error card on error", async () => { 92 | const { req, res } = faker({}, error); 93 | 94 | await api(req, res); 95 | 96 | expect(res.setHeader).toBeCalledWith("Content-Type", "image/svg+xml"); 97 | expect(res.send).toBeCalledWith( 98 | renderError( 99 | error.errors[0].message, 100 | "Make sure the provided username is not an organization", 101 | ), 102 | ); 103 | }); 104 | 105 | it("should get the query options", async () => { 106 | const { req, res } = faker( 107 | { 108 | username: "anuraghazra", 109 | hide: "issues,prs,contribs", 110 | show_icons: true, 111 | hide_border: true, 112 | line_height: 100, 113 | title_color: "fff", 114 | icon_color: "fff", 115 | text_color: "fff", 116 | bg_color: "fff", 117 | }, 118 | data, 119 | ); 120 | 121 | await api(req, res); 122 | 123 | expect(res.setHeader).toBeCalledWith("Content-Type", "image/svg+xml"); 124 | expect(res.send).toBeCalledWith( 125 | renderStatsCard(stats, { 126 | hide: ["issues", "prs", "contribs"], 127 | show_icons: true, 128 | hide_border: true, 129 | line_height: 100, 130 | title_color: "fff", 131 | icon_color: "fff", 132 | text_color: "fff", 133 | bg_color: "fff", 134 | }), 135 | ); 136 | }); 137 | 138 | it("should have proper cache", async () => { 139 | const { req, res } = faker({}, data); 140 | mock.onPost("https://api.github.com/graphql").reply(200, data); 141 | 142 | await api(req, res); 143 | 144 | expect(res.setHeader.mock.calls).toEqual([ 145 | ["Content-Type", "image/svg+xml"], 146 | ["Cache-Control", `public, max-age=${CONSTANTS.TWO_HOURS}`], 147 | ]); 148 | }); 149 | 150 | it("should set proper cache", async () => { 151 | const { req, res } = faker({ cache_seconds: 8000 }, data); 152 | await api(req, res); 153 | 154 | expect(res.setHeader.mock.calls).toEqual([ 155 | ["Content-Type", "image/svg+xml"], 156 | ["Cache-Control", `public, max-age=${8000}`], 157 | ]); 158 | }); 159 | 160 | it("should set proper cache with clamped values", async () => { 161 | { 162 | let { req, res } = faker({ cache_seconds: 200000 }, data); 163 | await api(req, res); 164 | 165 | expect(res.setHeader.mock.calls).toEqual([ 166 | ["Content-Type", "image/svg+xml"], 167 | ["Cache-Control", `public, max-age=${CONSTANTS.ONE_DAY}`], 168 | ]); 169 | } 170 | 171 | // note i'm using block scoped vars 172 | { 173 | let { req, res } = faker({ cache_seconds: 0 }, data); 174 | await api(req, res); 175 | 176 | expect(res.setHeader.mock.calls).toEqual([ 177 | ["Content-Type", "image/svg+xml"], 178 | ["Cache-Control", `public, max-age=${CONSTANTS.TWO_HOURS}`], 179 | ]); 180 | } 181 | 182 | { 183 | let { req, res } = faker({ cache_seconds: -10000 }, data); 184 | await api(req, res); 185 | 186 | expect(res.setHeader.mock.calls).toEqual([ 187 | ["Content-Type", "image/svg+xml"], 188 | ["Cache-Control", `public, max-age=${CONSTANTS.TWO_HOURS}`], 189 | ]); 190 | } 191 | }); 192 | 193 | it("should add private contributions", async () => { 194 | const { req, res } = faker( 195 | { 196 | username: "anuraghazra", 197 | count_private: true, 198 | }, 199 | data, 200 | ); 201 | 202 | await api(req, res); 203 | 204 | expect(res.setHeader).toBeCalledWith("Content-Type", "image/svg+xml"); 205 | expect(res.send).toBeCalledWith( 206 | renderStatsCard( 207 | { 208 | ...stats, 209 | totalCommits: stats.totalCommits + 100, 210 | rank: calculateRank({ 211 | totalCommits: stats.totalCommits + 100, 212 | totalRepos: 1, 213 | followers: 0, 214 | contributions: stats.contributedTo, 215 | stargazers: stats.totalStars, 216 | prs: stats.totalPRs, 217 | issues: stats.totalIssues, 218 | }), 219 | }, 220 | {}, 221 | ), 222 | ); 223 | }); 224 | }); 225 | -------------------------------------------------------------------------------- /tests/calculateRank.test.js: -------------------------------------------------------------------------------- 1 | require("@testing-library/jest-dom"); 2 | const calculateRank = require("../src/calculateRank"); 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 | require("@testing-library/jest-dom"); 2 | const cssToObject = require("css-to-object"); 3 | const Card = require("../src/common/Card"); 4 | const icons = require("../src/common/icons"); 5 | const { getCardColors } = require("../src/common/utils"); 6 | const { queryByTestId } = require("@testing-library/dom"); 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[".header"]; 141 | 142 | expect(headerClassStyles.fill).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 linearGradient")).toHaveAttribute( 172 | "gradientTransform", 173 | "rotate(90)", 174 | ); 175 | expect( 176 | document.querySelector("defs linearGradient stop:nth-child(1)"), 177 | ).toHaveAttribute("stop-color", "#fff"); 178 | expect( 179 | document.querySelector("defs linearGradient stop:nth-child(2)"), 180 | ).toHaveAttribute("stop-color", "#000"); 181 | expect( 182 | document.querySelector("defs linearGradient stop:nth-child(3)"), 183 | ).toHaveAttribute("stop-color", "#f00"); 184 | }); 185 | }); 186 | -------------------------------------------------------------------------------- /tests/fetchRepo.test.js: -------------------------------------------------------------------------------- 1 | require("@testing-library/jest-dom"); 2 | const axios = require("axios"); 3 | const MockAdapter = require("axios-mock-adapter"); 4 | const fetchRepo = require("../src/fetchers/repo-fetcher"); 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 }, 23 | organization: null, 24 | }, 25 | }; 26 | const data_org = { 27 | data: { 28 | user: null, 29 | organization: { repository: data_repo }, 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 | expect(repo).toStrictEqual(data_repo); 45 | }); 46 | 47 | it("should fetch correct org repo", async () => { 48 | mock.onPost("https://api.github.com/graphql").reply(200, data_org); 49 | 50 | let repo = await fetchRepo("anuraghazra", "convoychat"); 51 | expect(repo).toStrictEqual(data_repo); 52 | }); 53 | 54 | it("should throw error if user is found but repo is null", async () => { 55 | mock 56 | .onPost("https://api.github.com/graphql") 57 | .reply(200, { data: { user: { repository: null }, organization: null } }); 58 | 59 | await expect(fetchRepo("anuraghazra", "convoychat")).rejects.toThrow( 60 | "User Repository Not found", 61 | ); 62 | }); 63 | 64 | it("should throw error if org is found but repo is null", async () => { 65 | mock 66 | .onPost("https://api.github.com/graphql") 67 | .reply(200, { data: { user: null, organization: { repository: null } } }); 68 | 69 | await expect(fetchRepo("anuraghazra", "convoychat")).rejects.toThrow( 70 | "Organization Repository Not found", 71 | ); 72 | }); 73 | 74 | it("should throw error if both user & org data not found", async () => { 75 | mock 76 | .onPost("https://api.github.com/graphql") 77 | .reply(200, { data: { user: null, organization: null } }); 78 | 79 | await expect(fetchRepo("anuraghazra", "convoychat")).rejects.toThrow( 80 | "Not found", 81 | ); 82 | }); 83 | 84 | it("should throw error if repository is private", async () => { 85 | mock.onPost("https://api.github.com/graphql").reply(200, { 86 | data: { 87 | user: { repository: { ...data_repo, isPrivate: true } }, 88 | organization: null, 89 | }, 90 | }); 91 | 92 | await expect(fetchRepo("anuraghazra", "convoychat")).rejects.toThrow( 93 | "User Repository Not found", 94 | ); 95 | }); 96 | }); 97 | -------------------------------------------------------------------------------- /tests/fetchStats.test.js: -------------------------------------------------------------------------------- 1 | require("@testing-library/jest-dom"); 2 | const axios = require("axios"); 3 | const MockAdapter = require("axios-mock-adapter"); 4 | const fetchStats = require("../src/fetchers/stats-fetcher"); 5 | const calculateRank = require("../src/calculateRank"); 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 | issues: { totalCount: 200 }, 18 | followers: { totalCount: 100 }, 19 | repositories: { 20 | totalCount: 5, 21 | nodes: [ 22 | { stargazers: { totalCount: 100 } }, 23 | { stargazers: { totalCount: 100 } }, 24 | { stargazers: { totalCount: 100 } }, 25 | { stargazers: { totalCount: 50 } }, 26 | { stargazers: { totalCount: 50 } }, 27 | ], 28 | }, 29 | }, 30 | }, 31 | }; 32 | 33 | const error = { 34 | errors: [ 35 | { 36 | type: "NOT_FOUND", 37 | path: ["user"], 38 | locations: [], 39 | message: "Could not resolve to a User with the login of 'noname'.", 40 | }, 41 | ], 42 | }; 43 | 44 | const mock = new MockAdapter(axios); 45 | 46 | afterEach(() => { 47 | mock.reset(); 48 | }); 49 | 50 | describe("Test fetchStats", () => { 51 | it("should fetch correct stats", async () => { 52 | mock.onPost("https://api.github.com/graphql").reply(200, data); 53 | 54 | let stats = await fetchStats("anuraghazra"); 55 | const rank = calculateRank({ 56 | totalCommits: 100, 57 | totalRepos: 5, 58 | followers: 100, 59 | contributions: 61, 60 | stargazers: 400, 61 | prs: 300, 62 | issues: 200, 63 | }); 64 | 65 | expect(stats).toStrictEqual({ 66 | contributedTo: 61, 67 | name: "Anurag Hazra", 68 | totalCommits: 100, 69 | totalIssues: 200, 70 | totalPRs: 300, 71 | totalStars: 400, 72 | rank, 73 | }); 74 | }); 75 | 76 | it("should throw error", async () => { 77 | mock.onPost("https://api.github.com/graphql").reply(200, error); 78 | 79 | await expect(fetchStats("anuraghazra")).rejects.toThrow( 80 | "Could not resolve to a User with the login of 'noname'.", 81 | ); 82 | }); 83 | 84 | it("should fetch and add private contributions", async () => { 85 | mock.onPost("https://api.github.com/graphql").reply(200, data); 86 | 87 | let stats = await fetchStats("anuraghazra", true); 88 | const rank = calculateRank({ 89 | totalCommits: 150, 90 | totalRepos: 5, 91 | followers: 100, 92 | contributions: 61, 93 | stargazers: 400, 94 | prs: 300, 95 | issues: 200, 96 | }); 97 | 98 | expect(stats).toStrictEqual({ 99 | contributedTo: 61, 100 | name: "Anurag Hazra", 101 | totalCommits: 150, 102 | totalIssues: 200, 103 | totalPRs: 300, 104 | totalStars: 400, 105 | rank, 106 | }); 107 | }); 108 | 109 | it("should fetch total commits", async () => { 110 | mock.onPost("https://api.github.com/graphql").reply(200, data); 111 | mock 112 | .onGet("https://api.github.com/search/commits?q=author:anuraghazra") 113 | .reply(200, { total_count: 1000 }); 114 | 115 | let stats = await fetchStats("anuraghazra", true, true); 116 | const rank = calculateRank({ 117 | totalCommits: 1050, 118 | totalRepos: 5, 119 | followers: 100, 120 | contributions: 61, 121 | stargazers: 400, 122 | prs: 300, 123 | issues: 200, 124 | }); 125 | 126 | expect(stats).toStrictEqual({ 127 | contributedTo: 61, 128 | name: "Anurag Hazra", 129 | totalCommits: 1050, 130 | totalIssues: 200, 131 | totalPRs: 300, 132 | totalStars: 400, 133 | rank, 134 | }); 135 | }); 136 | }); 137 | -------------------------------------------------------------------------------- /tests/fetchTopLanguages.test.js: -------------------------------------------------------------------------------- 1 | require("@testing-library/jest-dom"); 2 | const axios = require("axios"); 3 | const MockAdapter = require("axios-mock-adapter"); 4 | const fetchTopLanguages = require("../src/fetchers/top-languages-fetcher"); 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 | languages: { 19 | edges: [{ size: 100, node: { color: "#0f0", name: "HTML" } }], 20 | }, 21 | }, 22 | { 23 | languages: { 24 | edges: [{ size: 100, node: { color: "#0f0", name: "HTML" } }], 25 | }, 26 | }, 27 | { 28 | languages: { 29 | edges: [ 30 | { size: 100, node: { color: "#0ff", name: "javascript" } }, 31 | ], 32 | }, 33 | }, 34 | { 35 | languages: { 36 | edges: [ 37 | { size: 100, node: { color: "#0ff", name: "javascript" } }, 38 | ], 39 | }, 40 | }, 41 | ], 42 | }, 43 | }, 44 | }, 45 | }; 46 | 47 | const error = { 48 | errors: [ 49 | { 50 | type: "NOT_FOUND", 51 | path: ["user"], 52 | locations: [], 53 | message: "Could not resolve to a User with the login of 'noname'.", 54 | }, 55 | ], 56 | }; 57 | 58 | describe("FetchTopLanguages", () => { 59 | it("should fetch correct language data", async () => { 60 | mock.onPost("https://api.github.com/graphql").reply(200, data_langs); 61 | 62 | let repo = await fetchTopLanguages("anuraghazra"); 63 | expect(repo).toStrictEqual({ 64 | HTML: { 65 | color: "#0f0", 66 | name: "HTML", 67 | size: 200, 68 | }, 69 | javascript: { 70 | color: "#0ff", 71 | name: "javascript", 72 | size: 200, 73 | }, 74 | }); 75 | }); 76 | 77 | it("should fetch langs with specified langs_count", async () => { 78 | mock.onPost("https://api.github.com/graphql").reply(200, data_langs); 79 | 80 | let repo = await fetchTopLanguages("anuraghazra", 1); 81 | expect(repo).toStrictEqual({ 82 | javascript: { 83 | color: "#0ff", 84 | name: "javascript", 85 | size: 200, 86 | }, 87 | }); 88 | }); 89 | 90 | it("should throw error", async () => { 91 | mock.onPost("https://api.github.com/graphql").reply(200, error); 92 | 93 | await expect(fetchTopLanguages("anuraghazra")).rejects.toThrow( 94 | "Could not resolve to a User with the login of 'noname'.", 95 | ); 96 | }); 97 | }); 98 | -------------------------------------------------------------------------------- /tests/fetchWakatime.test.js: -------------------------------------------------------------------------------- 1 | require("@testing-library/jest-dom"); 2 | const axios = require("axios"); 3 | const MockAdapter = require("axios-mock-adapter"); 4 | const { fetchWakatimeStats } = require("../src/fetchers/wakatime-fetcher"); 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 | Object { 115 | "categories": Array [ 116 | Object { 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": Array [ 131 | Object { 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": Array [ 154 | Object { 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 | Object { 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 | Object { 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": Array [ 183 | Object { 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 | "Wakatime user not found, make sure you have a wakatime profile", 211 | ); 212 | }); 213 | }); 214 | 215 | module.exports = { wakaTimeData }; 216 | -------------------------------------------------------------------------------- /tests/pin.test.js: -------------------------------------------------------------------------------- 1 | require("@testing-library/jest-dom"); 2 | const axios = require("axios"); 3 | const MockAdapter = require("axios-mock-adapter"); 4 | const pin = require("../api/pin"); 5 | const renderRepoCard = require("../src/cards/repo-card"); 6 | const { renderError } = require("../src/common/utils"); 7 | 8 | const data_repo = { 9 | repository: { 10 | username: "anuraghazra", 11 | name: "convoychat", 12 | stargazers: { totalCount: 38000 }, 13 | description: "Help us take over the world! React + TS + GraphQL Chat App", 14 | primaryLanguage: { 15 | color: "#2b7489", 16 | id: "MDg6TGFuZ3VhZ2UyODc=", 17 | name: "TypeScript", 18 | }, 19 | forkCount: 100, 20 | isTemplate: false, 21 | }, 22 | }; 23 | 24 | const data_user = { 25 | data: { 26 | user: { repository: data_repo.repository }, 27 | organization: null, 28 | }, 29 | }; 30 | 31 | const mock = new MockAdapter(axios); 32 | 33 | afterEach(() => { 34 | mock.reset(); 35 | }); 36 | 37 | describe("Test /api/pin", () => { 38 | it("should test the request", async () => { 39 | const req = { 40 | query: { 41 | username: "anuraghazra", 42 | repo: "convoychat", 43 | }, 44 | }; 45 | const res = { 46 | setHeader: jest.fn(), 47 | send: jest.fn(), 48 | }; 49 | mock.onPost("https://api.github.com/graphql").reply(200, data_user); 50 | 51 | await pin(req, res); 52 | 53 | expect(res.setHeader).toBeCalledWith("Content-Type", "image/svg+xml"); 54 | expect(res.send).toBeCalledWith(renderRepoCard(data_repo.repository)); 55 | }); 56 | 57 | it("should get the query options", async () => { 58 | const req = { 59 | query: { 60 | username: "anuraghazra", 61 | repo: "convoychat", 62 | title_color: "fff", 63 | icon_color: "fff", 64 | text_color: "fff", 65 | bg_color: "fff", 66 | full_name: "1", 67 | }, 68 | }; 69 | const res = { 70 | setHeader: jest.fn(), 71 | send: jest.fn(), 72 | }; 73 | mock.onPost("https://api.github.com/graphql").reply(200, data_user); 74 | 75 | await pin(req, res); 76 | 77 | expect(res.setHeader).toBeCalledWith("Content-Type", "image/svg+xml"); 78 | expect(res.send).toBeCalledWith( 79 | renderRepoCard(data_repo.repository, { ...req.query }), 80 | ); 81 | }); 82 | 83 | it("should render error card if user repo not found", async () => { 84 | const req = { 85 | query: { 86 | username: "anuraghazra", 87 | repo: "convoychat", 88 | }, 89 | }; 90 | const res = { 91 | setHeader: jest.fn(), 92 | send: jest.fn(), 93 | }; 94 | mock 95 | .onPost("https://api.github.com/graphql") 96 | .reply(200, { data: { user: { repository: null }, organization: null } }); 97 | 98 | await pin(req, res); 99 | 100 | expect(res.setHeader).toBeCalledWith("Content-Type", "image/svg+xml"); 101 | expect(res.send).toBeCalledWith(renderError("User Repository Not found")); 102 | }); 103 | 104 | it("should render error card if org repo not found", async () => { 105 | const req = { 106 | query: { 107 | username: "anuraghazra", 108 | repo: "convoychat", 109 | }, 110 | }; 111 | const res = { 112 | setHeader: jest.fn(), 113 | send: jest.fn(), 114 | }; 115 | mock 116 | .onPost("https://api.github.com/graphql") 117 | .reply(200, { data: { user: null, organization: { repository: null } } }); 118 | 119 | await pin(req, res); 120 | 121 | expect(res.setHeader).toBeCalledWith("Content-Type", "image/svg+xml"); 122 | expect(res.send).toBeCalledWith( 123 | renderError("Organization Repository Not found"), 124 | ); 125 | }); 126 | }); 127 | -------------------------------------------------------------------------------- /tests/renderStatsCard.test.js: -------------------------------------------------------------------------------- 1 | require("@testing-library/jest-dom"); 2 | const cssToObject = require("css-to-object"); 3 | const renderStatsCard = require("../src/cards/stats-card"); 4 | 5 | const { 6 | getByTestId, 7 | queryByTestId, 8 | queryAllByTestId, 9 | } = require("@testing-library/dom"); 10 | const themes = require("../themes"); 11 | 12 | describe("Test renderStatsCard", () => { 13 | const stats = { 14 | name: "Anurag Hazra", 15 | totalStars: 100, 16 | totalCommits: 200, 17 | totalIssues: 300, 18 | totalPRs: 400, 19 | contributedTo: 500, 20 | rank: { level: "A+", score: 40 }, 21 | }; 22 | 23 | it("should render correctly", () => { 24 | document.body.innerHTML = renderStatsCard(stats); 25 | 26 | expect(document.getElementsByClassName("header")[0].textContent).toBe( 27 | "Anurag Hazra's GitHub Stats", 28 | ); 29 | 30 | expect( 31 | document.body.getElementsByTagName("svg")[0].getAttribute("height"), 32 | ).toBe("195"); 33 | expect(getByTestId(document.body, "stars").textContent).toBe("100"); 34 | expect(getByTestId(document.body, "commits").textContent).toBe("200"); 35 | expect(getByTestId(document.body, "issues").textContent).toBe("300"); 36 | expect(getByTestId(document.body, "prs").textContent).toBe("400"); 37 | expect(getByTestId(document.body, "contribs").textContent).toBe("500"); 38 | expect(queryByTestId(document.body, "card-bg")).toBeInTheDocument(); 39 | expect(queryByTestId(document.body, "rank-circle")).toBeInTheDocument(); 40 | }); 41 | 42 | it("should have proper name apostrophe", () => { 43 | document.body.innerHTML = renderStatsCard({ ...stats, name: "Anil Das" }); 44 | 45 | expect(document.getElementsByClassName("header")[0].textContent).toBe( 46 | "Anil Das' GitHub Stats", 47 | ); 48 | 49 | document.body.innerHTML = renderStatsCard({ ...stats, name: "Felix" }); 50 | 51 | expect(document.getElementsByClassName("header")[0].textContent).toBe( 52 | "Felix' GitHub Stats", 53 | ); 54 | }); 55 | 56 | it("should hide individual stats", () => { 57 | document.body.innerHTML = renderStatsCard(stats, { 58 | hide: ["issues", "prs", "contribs"], 59 | }); 60 | 61 | expect( 62 | document.body.getElementsByTagName("svg")[0].getAttribute("height"), 63 | ).toBe("150"); // height should be 150 because we clamped it. 64 | 65 | expect(queryByTestId(document.body, "stars")).toBeDefined(); 66 | expect(queryByTestId(document.body, "commits")).toBeDefined(); 67 | expect(queryByTestId(document.body, "issues")).toBeNull(); 68 | expect(queryByTestId(document.body, "prs")).toBeNull(); 69 | expect(queryByTestId(document.body, "contribs")).toBeNull(); 70 | }); 71 | 72 | it("should hide_rank", () => { 73 | document.body.innerHTML = renderStatsCard(stats, { hide_rank: true }); 74 | 75 | expect(queryByTestId(document.body, "rank-circle")).not.toBeInTheDocument(); 76 | }); 77 | 78 | it("should render default colors properly", () => { 79 | document.body.innerHTML = renderStatsCard(stats); 80 | 81 | const styleTag = document.querySelector("style"); 82 | const stylesObject = cssToObject(styleTag.textContent); 83 | 84 | const headerClassStyles = stylesObject[".header"]; 85 | const statClassStyles = stylesObject[".stat"]; 86 | const iconClassStyles = stylesObject[".icon"]; 87 | 88 | expect(headerClassStyles.fill).toBe("#2f80ed"); 89 | expect(statClassStyles.fill).toBe("#333"); 90 | expect(iconClassStyles.fill).toBe("#4c71f2"); 91 | expect(queryByTestId(document.body, "card-bg")).toHaveAttribute( 92 | "fill", 93 | "#fffefe", 94 | ); 95 | }); 96 | 97 | it("should render custom colors properly", () => { 98 | const customColors = { 99 | title_color: "5a0", 100 | icon_color: "1b998b", 101 | text_color: "9991", 102 | bg_color: "252525", 103 | }; 104 | 105 | document.body.innerHTML = renderStatsCard(stats, { ...customColors }); 106 | 107 | const styleTag = document.querySelector("style"); 108 | const stylesObject = cssToObject(styleTag.innerHTML); 109 | 110 | const headerClassStyles = stylesObject[".header"]; 111 | const statClassStyles = stylesObject[".stat"]; 112 | const iconClassStyles = stylesObject[".icon"]; 113 | 114 | expect(headerClassStyles.fill).toBe(`#${customColors.title_color}`); 115 | expect(statClassStyles.fill).toBe(`#${customColors.text_color}`); 116 | expect(iconClassStyles.fill).toBe(`#${customColors.icon_color}`); 117 | expect(queryByTestId(document.body, "card-bg")).toHaveAttribute( 118 | "fill", 119 | "#252525", 120 | ); 121 | }); 122 | 123 | it("should render custom colors with themes", () => { 124 | document.body.innerHTML = renderStatsCard(stats, { 125 | title_color: "5a0", 126 | theme: "radical", 127 | }); 128 | 129 | const styleTag = document.querySelector("style"); 130 | const stylesObject = cssToObject(styleTag.innerHTML); 131 | 132 | const headerClassStyles = stylesObject[".header"]; 133 | const statClassStyles = stylesObject[".stat"]; 134 | const iconClassStyles = stylesObject[".icon"]; 135 | 136 | expect(headerClassStyles.fill).toBe("#5a0"); 137 | expect(statClassStyles.fill).toBe(`#${themes.radical.text_color}`); 138 | expect(iconClassStyles.fill).toBe(`#${themes.radical.icon_color}`); 139 | expect(queryByTestId(document.body, "card-bg")).toHaveAttribute( 140 | "fill", 141 | `#${themes.radical.bg_color}`, 142 | ); 143 | }); 144 | 145 | it("should render with all the themes", () => { 146 | Object.keys(themes).forEach((name) => { 147 | document.body.innerHTML = renderStatsCard(stats, { 148 | theme: name, 149 | }); 150 | 151 | const styleTag = document.querySelector("style"); 152 | const stylesObject = cssToObject(styleTag.innerHTML); 153 | 154 | const headerClassStyles = stylesObject[".header"]; 155 | const statClassStyles = stylesObject[".stat"]; 156 | const iconClassStyles = stylesObject[".icon"]; 157 | 158 | expect(headerClassStyles.fill).toBe(`#${themes[name].title_color}`); 159 | expect(statClassStyles.fill).toBe(`#${themes[name].text_color}`); 160 | expect(iconClassStyles.fill).toBe(`#${themes[name].icon_color}`); 161 | expect(queryByTestId(document.body, "card-bg")).toHaveAttribute( 162 | "fill", 163 | `#${themes[name].bg_color}`, 164 | ); 165 | }); 166 | }); 167 | 168 | it("should render custom colors with themes and fallback to default colors if invalid", () => { 169 | document.body.innerHTML = renderStatsCard(stats, { 170 | title_color: "invalid color", 171 | text_color: "invalid color", 172 | theme: "radical", 173 | }); 174 | 175 | const styleTag = document.querySelector("style"); 176 | const stylesObject = cssToObject(styleTag.innerHTML); 177 | 178 | const headerClassStyles = stylesObject[".header"]; 179 | const statClassStyles = stylesObject[".stat"]; 180 | const iconClassStyles = stylesObject[".icon"]; 181 | 182 | expect(headerClassStyles.fill).toBe(`#${themes.default.title_color}`); 183 | expect(statClassStyles.fill).toBe(`#${themes.default.text_color}`); 184 | expect(iconClassStyles.fill).toBe(`#${themes.radical.icon_color}`); 185 | expect(queryByTestId(document.body, "card-bg")).toHaveAttribute( 186 | "fill", 187 | `#${themes.radical.bg_color}`, 188 | ); 189 | }); 190 | 191 | it("should render icons correctly", () => { 192 | document.body.innerHTML = renderStatsCard(stats, { 193 | show_icons: true, 194 | }); 195 | 196 | expect(queryAllByTestId(document.body, "icon")[0]).toBeDefined(); 197 | expect(queryByTestId(document.body, "stars")).toBeDefined(); 198 | expect( 199 | queryByTestId(document.body, "stars").previousElementSibling, // the label 200 | ).toHaveAttribute("x", "25"); 201 | }); 202 | 203 | it("should not have icons if show_icons is false", () => { 204 | document.body.innerHTML = renderStatsCard(stats, { show_icons: false }); 205 | 206 | expect(queryAllByTestId(document.body, "icon")[0]).not.toBeDefined(); 207 | expect(queryByTestId(document.body, "stars")).toBeDefined(); 208 | expect( 209 | queryByTestId(document.body, "stars").previousElementSibling, // the label 210 | ).not.toHaveAttribute("x"); 211 | }); 212 | 213 | it("should auto resize if hide_rank is true", () => { 214 | document.body.innerHTML = renderStatsCard(stats, { 215 | hide_rank: true, 216 | }); 217 | 218 | expect( 219 | document.body.getElementsByTagName("svg")[0].getAttribute("width"), 220 | ).toBe("305.81250000000006"); 221 | }); 222 | 223 | it("should auto resize if hide_rank is true & custom_title is set", () => { 224 | document.body.innerHTML = renderStatsCard(stats, { 225 | hide_rank: true, 226 | custom_title: "Hello world", 227 | }); 228 | 229 | expect( 230 | document.body.getElementsByTagName("svg")[0].getAttribute("width"), 231 | ).toBe("270"); 232 | }); 233 | 234 | it("should render translations", () => { 235 | document.body.innerHTML = renderStatsCard(stats, { locale: "cn" }); 236 | expect(document.getElementsByClassName("header")[0].textContent).toBe( 237 | "Anurag Hazra 的 GitHub 统计数据", 238 | ); 239 | expect( 240 | document.querySelector( 241 | 'g[transform="translate(0, 0)"]>.stagger>.stat.bold', 242 | ).textContent, 243 | ).toMatchInlineSnapshot(`"获标星数(star):"`); 244 | expect( 245 | document.querySelector( 246 | 'g[transform="translate(0, 25)"]>.stagger>.stat.bold', 247 | ).textContent, 248 | ).toMatchInlineSnapshot(`"累计提交数(commit) (2021):"`); 249 | expect( 250 | document.querySelector( 251 | 'g[transform="translate(0, 50)"]>.stagger>.stat.bold', 252 | ).textContent, 253 | ).toMatchInlineSnapshot(`"拉取请求数(PR):"`); 254 | expect( 255 | document.querySelector( 256 | 'g[transform="translate(0, 75)"]>.stagger>.stat.bold', 257 | ).textContent, 258 | ).toMatchInlineSnapshot(`"指出问题数(issue):"`); 259 | expect( 260 | document.querySelector( 261 | 'g[transform="translate(0, 100)"]>.stagger>.stat.bold', 262 | ).textContent, 263 | ).toMatchInlineSnapshot(`"参与项目数:"`); 264 | }); 265 | 266 | it("should render without rounding", () => { 267 | document.body.innerHTML = renderStatsCard(stats, { border_radius: "0" }); 268 | expect(document.querySelector("rect")).toHaveAttribute("rx", "0"); 269 | document.body.innerHTML = renderStatsCard(stats, {}); 270 | expect(document.querySelector("rect")).toHaveAttribute("rx", "4.5"); 271 | }); 272 | }); 273 | -------------------------------------------------------------------------------- /tests/renderTopLanguages.test.js: -------------------------------------------------------------------------------- 1 | require("@testing-library/jest-dom"); 2 | const cssToObject = require("css-to-object"); 3 | const renderTopLanguages = require("../src/cards/top-languages-card"); 4 | 5 | const { queryByTestId, queryAllByTestId } = require("@testing-library/dom"); 6 | const themes = require("../themes"); 7 | 8 | describe("Test renderTopLanguages", () => { 9 | const langs = { 10 | HTML: { 11 | color: "#0f0", 12 | name: "HTML", 13 | size: 200, 14 | }, 15 | javascript: { 16 | color: "#0ff", 17 | name: "javascript", 18 | size: 200, 19 | }, 20 | css: { 21 | color: "#ff0", 22 | name: "css", 23 | size: 100, 24 | }, 25 | }; 26 | 27 | it("should render correctly", () => { 28 | document.body.innerHTML = renderTopLanguages(langs); 29 | 30 | expect(queryByTestId(document.body, "header")).toHaveTextContent( 31 | "Most Used Languages", 32 | ); 33 | 34 | expect(queryAllByTestId(document.body, "lang-name")[0]).toHaveTextContent( 35 | "HTML", 36 | ); 37 | expect(queryAllByTestId(document.body, "lang-name")[1]).toHaveTextContent( 38 | "javascript", 39 | ); 40 | expect(queryAllByTestId(document.body, "lang-name")[2]).toHaveTextContent( 41 | "css", 42 | ); 43 | expect(queryAllByTestId(document.body, "lang-progress")[0]).toHaveAttribute( 44 | "width", 45 | "40%", 46 | ); 47 | expect(queryAllByTestId(document.body, "lang-progress")[1]).toHaveAttribute( 48 | "width", 49 | "40%", 50 | ); 51 | expect(queryAllByTestId(document.body, "lang-progress")[2]).toHaveAttribute( 52 | "width", 53 | "20%", 54 | ); 55 | }); 56 | 57 | it("should hide languages when hide is passed", () => { 58 | document.body.innerHTML = renderTopLanguages(langs, { 59 | hide: ["HTML"], 60 | }); 61 | expect(queryAllByTestId(document.body, "lang-name")[0]).toBeInTheDocument( 62 | "javascript", 63 | ); 64 | expect(queryAllByTestId(document.body, "lang-name")[1]).toBeInTheDocument( 65 | "css", 66 | ); 67 | expect(queryAllByTestId(document.body, "lang-name")[2]).not.toBeDefined(); 68 | 69 | // multiple languages passed 70 | document.body.innerHTML = renderTopLanguages(langs, { 71 | hide: ["HTML", "css"], 72 | }); 73 | expect(queryAllByTestId(document.body, "lang-name")[0]).toBeInTheDocument( 74 | "javascript", 75 | ); 76 | expect(queryAllByTestId(document.body, "lang-name")[1]).not.toBeDefined(); 77 | }); 78 | 79 | it("should resize the height correctly depending on langs", () => { 80 | document.body.innerHTML = renderTopLanguages(langs, {}); 81 | expect(document.querySelector("svg")).toHaveAttribute("height", "205"); 82 | 83 | document.body.innerHTML = renderTopLanguages( 84 | { 85 | ...langs, 86 | python: { 87 | color: "#ff0", 88 | name: "python", 89 | size: 100, 90 | }, 91 | }, 92 | {}, 93 | ); 94 | expect(document.querySelector("svg")).toHaveAttribute("height", "245"); 95 | }); 96 | 97 | it("should render with custom width set", () => { 98 | document.body.innerHTML = renderTopLanguages(langs, {}); 99 | 100 | expect(document.querySelector("svg")).toHaveAttribute("width", "300"); 101 | 102 | document.body.innerHTML = renderTopLanguages(langs, { card_width: 400 }); 103 | expect(document.querySelector("svg")).toHaveAttribute("width", "400"); 104 | }); 105 | 106 | it("should render default colors properly", () => { 107 | document.body.innerHTML = renderTopLanguages(langs); 108 | 109 | const styleTag = document.querySelector("style"); 110 | const stylesObject = cssToObject(styleTag.textContent); 111 | 112 | const headerStyles = stylesObject[".header"]; 113 | const langNameStyles = stylesObject[".lang-name"]; 114 | 115 | expect(headerStyles.fill).toBe("#2f80ed"); 116 | expect(langNameStyles.fill).toBe("#333"); 117 | expect(queryByTestId(document.body, "card-bg")).toHaveAttribute( 118 | "fill", 119 | "#fffefe", 120 | ); 121 | }); 122 | 123 | it("should render custom colors properly", () => { 124 | const customColors = { 125 | title_color: "5a0", 126 | icon_color: "1b998b", 127 | text_color: "9991", 128 | bg_color: "252525", 129 | }; 130 | 131 | document.body.innerHTML = renderTopLanguages(langs, { ...customColors }); 132 | 133 | const styleTag = document.querySelector("style"); 134 | const stylesObject = cssToObject(styleTag.innerHTML); 135 | 136 | const headerStyles = stylesObject[".header"]; 137 | const langNameStyles = stylesObject[".lang-name"]; 138 | 139 | expect(headerStyles.fill).toBe(`#${customColors.title_color}`); 140 | expect(langNameStyles.fill).toBe(`#${customColors.text_color}`); 141 | expect(queryByTestId(document.body, "card-bg")).toHaveAttribute( 142 | "fill", 143 | "#252525", 144 | ); 145 | }); 146 | 147 | it("should render custom colors with themes", () => { 148 | document.body.innerHTML = renderTopLanguages(langs, { 149 | title_color: "5a0", 150 | theme: "radical", 151 | }); 152 | 153 | const styleTag = document.querySelector("style"); 154 | const stylesObject = cssToObject(styleTag.innerHTML); 155 | 156 | const headerStyles = stylesObject[".header"]; 157 | const langNameStyles = stylesObject[".lang-name"]; 158 | 159 | expect(headerStyles.fill).toBe("#5a0"); 160 | expect(langNameStyles.fill).toBe(`#${themes.radical.text_color}`); 161 | expect(queryByTestId(document.body, "card-bg")).toHaveAttribute( 162 | "fill", 163 | `#${themes.radical.bg_color}`, 164 | ); 165 | }); 166 | 167 | it("should render with all the themes", () => { 168 | Object.keys(themes).forEach((name) => { 169 | document.body.innerHTML = renderTopLanguages(langs, { 170 | theme: name, 171 | }); 172 | 173 | const styleTag = document.querySelector("style"); 174 | const stylesObject = cssToObject(styleTag.innerHTML); 175 | 176 | const headerStyles = stylesObject[".header"]; 177 | const langNameStyles = stylesObject[".lang-name"]; 178 | 179 | expect(headerStyles.fill).toBe(`#${themes[name].title_color}`); 180 | expect(langNameStyles.fill).toBe(`#${themes[name].text_color}`); 181 | expect(queryByTestId(document.body, "card-bg")).toHaveAttribute( 182 | "fill", 183 | `#${themes[name].bg_color}`, 184 | ); 185 | }); 186 | }); 187 | 188 | it("should render with layout compact", () => { 189 | document.body.innerHTML = renderTopLanguages(langs, { layout: "compact" }); 190 | 191 | expect(queryByTestId(document.body, "header")).toHaveTextContent( 192 | "Most Used Languages", 193 | ); 194 | 195 | expect(queryAllByTestId(document.body, "lang-name")[0]).toHaveTextContent( 196 | "HTML 40.00%", 197 | ); 198 | expect(queryAllByTestId(document.body, "lang-progress")[0]).toHaveAttribute( 199 | "width", 200 | "120.00", 201 | ); 202 | 203 | expect(queryAllByTestId(document.body, "lang-name")[1]).toHaveTextContent( 204 | "javascript 40.00%", 205 | ); 206 | expect(queryAllByTestId(document.body, "lang-progress")[1]).toHaveAttribute( 207 | "width", 208 | "120.00", 209 | ); 210 | 211 | expect(queryAllByTestId(document.body, "lang-name")[2]).toHaveTextContent( 212 | "css 20.00%", 213 | ); 214 | expect(queryAllByTestId(document.body, "lang-progress")[2]).toHaveAttribute( 215 | "width", 216 | "60.00", 217 | ); 218 | }); 219 | 220 | it("should render a translated title", () => { 221 | document.body.innerHTML = renderTopLanguages(langs, { locale: "cn" }); 222 | expect(document.getElementsByClassName("header")[0].textContent).toBe( 223 | "最常用的语言", 224 | ); 225 | }); 226 | 227 | it("should render without rounding", () => { 228 | document.body.innerHTML = renderTopLanguages(langs, { border_radius: "0" }); 229 | expect(document.querySelector("rect")).toHaveAttribute("rx", "0"); 230 | document.body.innerHTML = renderTopLanguages(langs, { }); 231 | expect(document.querySelector("rect")).toHaveAttribute("rx", "4.5"); 232 | }); 233 | }); 234 | -------------------------------------------------------------------------------- /tests/renderWakatimeCard.test.js: -------------------------------------------------------------------------------- 1 | require("@testing-library/jest-dom"); 2 | const renderWakatimeCard = require("../src/cards/wakatime-card"); 3 | 4 | const { wakaTimeData } = require("./fetchWakatime.test"); 5 | 6 | describe("Test Render Wakatime Card", () => { 7 | it("should render correctly", () => { 8 | const card = renderWakatimeCard(wakaTimeData.data); 9 | 10 | expect(card).toMatchSnapshot(); 11 | }); 12 | 13 | it("should render correctly with compact layout", () => { 14 | const card = renderWakatimeCard(wakaTimeData.data, { layout: "compact" }); 15 | 16 | expect(card).toMatchSnapshot(); 17 | }); 18 | 19 | it("should render translations", () => { 20 | document.body.innerHTML = renderWakatimeCard({}, { locale: "cn" }); 21 | expect(document.getElementsByClassName("header")[0].textContent).toBe( 22 | "Wakatime 周统计", 23 | ); 24 | expect( 25 | document.querySelector('g[transform="translate(0, 0)"]>text.stat.bold') 26 | .textContent, 27 | ).toBe("本周没有编程活动"); 28 | }); 29 | 30 | it("should render without rounding", () => { 31 | document.body.innerHTML = renderWakatimeCard(wakaTimeData.data, { border_radius: "0" }); 32 | expect(document.querySelector("rect")).toHaveAttribute("rx", "0"); 33 | document.body.innerHTML = renderWakatimeCard(wakaTimeData.data, { }); 34 | expect(document.querySelector("rect")).toHaveAttribute("rx", "4.5"); 35 | }); 36 | }); 37 | -------------------------------------------------------------------------------- /tests/retryer.test.js: -------------------------------------------------------------------------------- 1 | require("@testing-library/jest-dom"); 2 | const retryer = require("../src/common/retryer"); 3 | const { logger } = require("../src/common/utils"); 4 | 5 | const fetcher = jest.fn((variables, token) => { 6 | logger.log(variables, token); 7 | return new Promise((res, rej) => res({ data: "ok" })); 8 | }); 9 | 10 | const fetcherFail = jest.fn(() => { 11 | return new Promise((res, rej) => 12 | res({ data: { errors: [{ type: "RATE_LIMITED" }] } }), 13 | ); 14 | }); 15 | 16 | const fetcherFailOnSecondTry = jest.fn((_vars, _token, retries) => { 17 | return new Promise((res, rej) => { 18 | // faking rate limit 19 | if (retries < 1) { 20 | return res({ data: { errors: [{ type: "RATE_LIMITED" }] } }); 21 | } 22 | return res({ data: "ok" }); 23 | }); 24 | }); 25 | 26 | describe("Test Retryer", () => { 27 | it("retryer should return value and have zero retries on first try", async () => { 28 | let res = await retryer(fetcher, {}); 29 | 30 | expect(fetcher).toBeCalledTimes(1); 31 | expect(res).toStrictEqual({ data: "ok" }); 32 | }); 33 | 34 | it("retryer should return value and have 2 retries", async () => { 35 | let res = await retryer(fetcherFailOnSecondTry, {}); 36 | 37 | expect(fetcherFailOnSecondTry).toBeCalledTimes(2); 38 | expect(res).toStrictEqual({ data: "ok" }); 39 | }); 40 | 41 | it("retryer should throw error if maximum retries reached", async () => { 42 | let res; 43 | 44 | try { 45 | res = await retryer(fetcherFail, {}); 46 | } catch (err) { 47 | expect(fetcherFail).toBeCalledTimes(8); 48 | expect(err.message).toBe("Maximum retries exceeded"); 49 | } 50 | }); 51 | }); 52 | -------------------------------------------------------------------------------- /tests/top-langs.test.js: -------------------------------------------------------------------------------- 1 | require("@testing-library/jest-dom"); 2 | const axios = require("axios"); 3 | const MockAdapter = require("axios-mock-adapter"); 4 | const topLangs = require("../api/top-langs"); 5 | const renderTopLanguages = require("../src/cards/top-languages-card"); 6 | const { renderError } = require("../src/common/utils"); 7 | 8 | const data_langs = { 9 | data: { 10 | user: { 11 | repositories: { 12 | nodes: [ 13 | { 14 | languages: { 15 | edges: [{ size: 150, node: { color: "#0f0", name: "HTML" } }], 16 | }, 17 | }, 18 | { 19 | languages: { 20 | edges: [{ size: 100, node: { color: "#0f0", name: "HTML" } }], 21 | }, 22 | }, 23 | { 24 | languages: { 25 | edges: [ 26 | { size: 100, node: { color: "#0ff", name: "javascript" } }, 27 | ], 28 | }, 29 | }, 30 | { 31 | languages: { 32 | edges: [ 33 | { size: 100, node: { color: "#0ff", name: "javascript" } }, 34 | ], 35 | }, 36 | }, 37 | ], 38 | }, 39 | }, 40 | }, 41 | }; 42 | 43 | const error = { 44 | errors: [ 45 | { 46 | type: "NOT_FOUND", 47 | path: ["user"], 48 | locations: [], 49 | message: "Could not fetch user", 50 | }, 51 | ], 52 | }; 53 | 54 | const langs = { 55 | HTML: { 56 | color: "#0f0", 57 | name: "HTML", 58 | size: 250, 59 | }, 60 | javascript: { 61 | color: "#0ff", 62 | name: "javascript", 63 | size: 200, 64 | }, 65 | }; 66 | 67 | const mock = new MockAdapter(axios); 68 | 69 | afterEach(() => { 70 | mock.reset(); 71 | }); 72 | 73 | describe("Test /api/top-langs", () => { 74 | it("should test the request", async () => { 75 | const req = { 76 | query: { 77 | username: "anuraghazra", 78 | }, 79 | }; 80 | const res = { 81 | setHeader: jest.fn(), 82 | send: jest.fn(), 83 | }; 84 | mock.onPost("https://api.github.com/graphql").reply(200, data_langs); 85 | 86 | await topLangs(req, res); 87 | 88 | expect(res.setHeader).toBeCalledWith("Content-Type", "image/svg+xml"); 89 | expect(res.send).toBeCalledWith(renderTopLanguages(langs)); 90 | }); 91 | 92 | it("should work with the query options", async () => { 93 | const req = { 94 | query: { 95 | username: "anuraghazra", 96 | hide_title: true, 97 | card_width: 100, 98 | title_color: "fff", 99 | icon_color: "fff", 100 | text_color: "fff", 101 | bg_color: "fff", 102 | }, 103 | }; 104 | const res = { 105 | setHeader: jest.fn(), 106 | send: jest.fn(), 107 | }; 108 | mock.onPost("https://api.github.com/graphql").reply(200, data_langs); 109 | 110 | await topLangs(req, res); 111 | 112 | expect(res.setHeader).toBeCalledWith("Content-Type", "image/svg+xml"); 113 | expect(res.send).toBeCalledWith( 114 | renderTopLanguages(langs, { 115 | hide_title: true, 116 | card_width: 100, 117 | title_color: "fff", 118 | icon_color: "fff", 119 | text_color: "fff", 120 | bg_color: "fff", 121 | }), 122 | ); 123 | }); 124 | 125 | it("should render error card on error", async () => { 126 | const req = { 127 | query: { 128 | username: "anuraghazra", 129 | }, 130 | }; 131 | const res = { 132 | setHeader: jest.fn(), 133 | send: jest.fn(), 134 | }; 135 | mock.onPost("https://api.github.com/graphql").reply(200, error); 136 | 137 | await topLangs(req, res); 138 | 139 | expect(res.setHeader).toBeCalledWith("Content-Type", "image/svg+xml"); 140 | expect(res.send).toBeCalledWith(renderError(error.errors[0].message)); 141 | }); 142 | }); 143 | -------------------------------------------------------------------------------- /tests/utils.test.js: -------------------------------------------------------------------------------- 1 | require("@testing-library/jest-dom"); 2 | const { 3 | kFormatter, 4 | encodeHTML, 5 | renderError, 6 | FlexLayout, 7 | getCardColors, 8 | wrapTextMultiline, 9 | } = require("../src/common/utils"); 10 | 11 | const { queryByTestId } = require("@testing-library/dom"); 12 | 13 | describe("Test utils.js", () => { 14 | it("should test kFormatter", () => { 15 | expect(kFormatter(1)).toBe(1); 16 | expect(kFormatter(-1)).toBe(-1); 17 | expect(kFormatter(500)).toBe(500); 18 | expect(kFormatter(1000)).toBe("1k"); 19 | expect(kFormatter(10000)).toBe("10k"); 20 | expect(kFormatter(12345)).toBe("12.3k"); 21 | expect(kFormatter(9900000)).toBe("9900k"); 22 | }); 23 | 24 | it("should test encodeHTML", () => { 25 | expect(encodeHTML(`hello world<,.#4^&^@%!))`)).toBe( 26 | "<html>hello world<,.#4^&^@%!))", 27 | ); 28 | }); 29 | 30 | it("should test renderError", () => { 31 | document.body.innerHTML = renderError("Something went wrong"); 32 | expect( 33 | queryByTestId(document.body, "message").children[0], 34 | ).toHaveTextContent(/Something went wrong/gim); 35 | expect(queryByTestId(document.body, "message").children[1]).toBeEmpty(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("should test FlexLayout", () => { 48 | const layout = FlexLayout({ 49 | items: ["1", "2"], 50 | gap: 60, 51 | }).join(""); 52 | 53 | expect(layout).toBe( 54 | `12`, 55 | ); 56 | 57 | const columns = FlexLayout({ 58 | items: ["1", "2"], 59 | gap: 60, 60 | direction: "column", 61 | }).join(""); 62 | 63 | expect(columns).toBe( 64 | `12`, 65 | ); 66 | }); 67 | 68 | it("getCardColors: should return expected values", () => { 69 | let colors = getCardColors({ 70 | title_color: "f00", 71 | text_color: "0f0", 72 | icon_color: "00f", 73 | bg_color: "fff", 74 | theme: "dark", 75 | }); 76 | expect(colors).toStrictEqual({ 77 | titleColor: "#f00", 78 | textColor: "#0f0", 79 | iconColor: "#00f", 80 | bgColor: "#fff", 81 | }); 82 | }); 83 | 84 | it("getCardColors: should fallback to default colors if color is invalid", () => { 85 | let colors = getCardColors({ 86 | title_color: "invalidcolor", 87 | text_color: "0f0", 88 | icon_color: "00f", 89 | bg_color: "fff", 90 | theme: "dark", 91 | }); 92 | expect(colors).toStrictEqual({ 93 | titleColor: "#2f80ed", 94 | textColor: "#0f0", 95 | iconColor: "#00f", 96 | bgColor: "#fff", 97 | }); 98 | }); 99 | 100 | it("getCardColors: should fallback to specified theme colors if is not defined", () => { 101 | let colors = getCardColors({ 102 | theme: "dark", 103 | }); 104 | expect(colors).toStrictEqual({ 105 | titleColor: "#fff", 106 | textColor: "#9f9f9f", 107 | iconColor: "#79ff97", 108 | bgColor: "#151515", 109 | }); 110 | }); 111 | }); 112 | 113 | describe("wrapTextMultiline", () => { 114 | it("should not wrap small texts", () => { 115 | { 116 | let multiLineText = wrapTextMultiline("Small text should not wrap"); 117 | expect(multiLineText).toEqual(["Small text should not wrap"]); 118 | } 119 | }); 120 | it("should wrap large texts", () => { 121 | let multiLineText = wrapTextMultiline( 122 | "Hello world long long long text", 123 | 20, 124 | 3, 125 | ); 126 | expect(multiLineText).toEqual(["Hello world long", "long long text"]); 127 | }); 128 | it("should wrap large texts and limit max lines", () => { 129 | let multiLineText = wrapTextMultiline( 130 | "Hello world long long long text", 131 | 10, 132 | 2, 133 | ); 134 | expect(multiLineText).toEqual(["Hello", "world long..."]); 135 | }); 136 | }); 137 | -------------------------------------------------------------------------------- /themes/index.js: -------------------------------------------------------------------------------- 1 | const themes = { 2 | default: { 3 | title_color: "2f80ed", 4 | icon_color: "4c71f2", 5 | text_color: "333", 6 | bg_color: "fffefe", 7 | }, 8 | default_repocard: { 9 | title_color: "2f80ed", 10 | icon_color: "586069", // icon color is different 11 | text_color: "333", 12 | bg_color: "fffefe", 13 | }, 14 | dark: { 15 | title_color: "fff", 16 | icon_color: "79ff97", 17 | text_color: "9f9f9f", 18 | bg_color: "151515", 19 | }, 20 | radical: { 21 | title_color: "fe428e", 22 | icon_color: "f8d847", 23 | text_color: "a9fef7", 24 | bg_color: "141321", 25 | }, 26 | merko: { 27 | title_color: "abd200", 28 | icon_color: "b7d364", 29 | text_color: "68b587", 30 | bg_color: "0a0f0b", 31 | }, 32 | gruvbox: { 33 | title_color: "fabd2f", 34 | icon_color: "fe8019", 35 | text_color: "8ec07c", 36 | bg_color: "282828", 37 | }, 38 | tokyonight: { 39 | title_color: "70a5fd", 40 | icon_color: "bf91f3", 41 | text_color: "38bdae", 42 | bg_color: "1a1b27", 43 | }, 44 | onedark: { 45 | title_color: "e4bf7a", 46 | icon_color: "8eb573", 47 | text_color: "df6d74", 48 | bg_color: "282c34", 49 | }, 50 | cobalt: { 51 | title_color: "e683d9", 52 | icon_color: "0480ef", 53 | text_color: "75eeb2", 54 | bg_color: "193549", 55 | }, 56 | synthwave: { 57 | title_color: "e2e9ec", 58 | icon_color: "ef8539", 59 | text_color: "e5289e", 60 | bg_color: "2b213a", 61 | }, 62 | highcontrast: { 63 | title_color: "e7f216", 64 | icon_color: "00ffff", 65 | text_color: "fff", 66 | bg_color: "000", 67 | }, 68 | dracula: { 69 | title_color: "ff6e96", 70 | icon_color: "79dafa", 71 | text_color: "f8f8f2", 72 | bg_color: "282a36", 73 | }, 74 | prussian: { 75 | title_color: "bddfff", 76 | icon_color: "38a0ff", 77 | text_color: "6e93b5", 78 | bg_color: "172f45", 79 | }, 80 | monokai: { 81 | title_color: "eb1f6a", 82 | icon_color: "e28905", 83 | text_color: "f1f1eb", 84 | bg_color: "272822", 85 | }, 86 | vue: { 87 | title_color: "41b883", 88 | icon_color: "41b883", 89 | text_color: "273849", 90 | bg_color: "fffefe", 91 | }, 92 | "vue-dark": { 93 | title_color: "41b883", 94 | icon_color: "41b883", 95 | text_color: "fffefe", 96 | bg_color: "273849", 97 | }, 98 | "shades-of-purple": { 99 | title_color: "fad000", 100 | icon_color: "b362ff", 101 | text_color: "a599e9", 102 | bg_color: "2d2b55", 103 | }, 104 | nightowl: { 105 | title_color: "c792ea", 106 | icon_color: "ffeb95", 107 | text_color: "7fdbca", 108 | bg_color: "011627", 109 | }, 110 | buefy: { 111 | title_color: "7957d5", 112 | icon_color: "ff3860", 113 | text_color: "363636", 114 | bg_color: "ffffff", 115 | }, 116 | "blue-green": { 117 | title_color: "2f97c1", 118 | icon_color: "f5b700", 119 | text_color: "0cf574", 120 | bg_color: "040f0f", 121 | }, 122 | algolia: { 123 | title_color: "00AEFF", 124 | icon_color: "2DDE98", 125 | text_color: "FFFFFF", 126 | bg_color: "050F2C", 127 | }, 128 | "great-gatsby": { 129 | title_color: "ffa726", 130 | icon_color: "ffb74d", 131 | text_color: "ffd95b", 132 | bg_color: "000000", 133 | }, 134 | darcula: { 135 | title_color: "BA5F17", 136 | icon_color: "84628F", 137 | text_color: "BEBEBE", 138 | bg_color: "242424", 139 | }, 140 | bear: { 141 | title_color: "e03c8a", 142 | icon_color: "00AEFF", 143 | text_color: "bcb28d", 144 | bg_color: "1f2023", 145 | }, 146 | "solarized-dark": { 147 | title_color: "268bd2", 148 | icon_color: "b58900", 149 | text_color: "859900", 150 | bg_color: "002b36", 151 | }, 152 | "solarized-light": { 153 | title_color: "268bd2", 154 | icon_color: "b58900", 155 | text_color: "859900", 156 | bg_color: "fdf6e3", 157 | }, 158 | "chartreuse-dark": { 159 | title_color: "7fff00", 160 | icon_color: "00AEFF", 161 | text_color: "fff", 162 | bg_color: "000", 163 | }, 164 | nord: { 165 | title_color: "81a1c1", 166 | text_color: "d8dee9", 167 | icon_color: "88c0d0", 168 | bg_color: "2e3440", 169 | }, 170 | gotham: { 171 | title_color: "2aa889", 172 | icon_color: "599cab", 173 | text_color: "99d1ce", 174 | bg_color: "0c1014", 175 | }, 176 | "material-palenight": { 177 | title_color: "c792ea", 178 | icon_color: "89ddff", 179 | text_color: "a6accd", 180 | bg_color: "292d3e", 181 | }, 182 | graywhite: { 183 | title_color: "24292e", 184 | icon_color: "24292e", 185 | text_color: "24292e", 186 | bg_color: "ffffff", 187 | }, 188 | "vision-friendly-dark": { 189 | title_color: "ffb000", 190 | icon_color: "785ef0", 191 | text_color: "ffffff", 192 | bg_color: "000000", 193 | }, 194 | "ayu-mirage": { 195 | title_color: "f4cd7c", 196 | icon_color: "73d0ff", 197 | text_color: "c7c8c2", 198 | bg_color: "1f2430", 199 | }, 200 | "midnight-purple": { 201 | title_color: "9745f5", 202 | icon_color: "9f4bff", 203 | text_color: "ffffff", 204 | bg_color: "000000", 205 | }, 206 | calm: { 207 | title_color: "e07a5f", 208 | icon_color: "edae49", 209 | text_color: "ebcfb2", 210 | bg_color: "373f51", 211 | }, 212 | "flag-india": { 213 | title_color: "ff8f1c", 214 | icon_color: "250E62", 215 | text_color: "509E2F", 216 | bg_color: "ffffff", 217 | }, 218 | omni: { 219 | title_color: "FF79C6", 220 | icon_color: "e7de79", 221 | text_color: "E1E1E6", 222 | bg_color: "191622", 223 | }, 224 | react: { 225 | title_color: "61dafb", 226 | icon_color: "61dafb", 227 | text_color: "ffffff", 228 | bg_color: "20232a", 229 | }, 230 | jolly: { 231 | title_color: "ff64da", 232 | icon_color: "a960ff", 233 | text_color: "ffffff", 234 | bg_color: "291B3E", 235 | }, 236 | maroongold: { 237 | title_color: "F7EF8A", 238 | icon_color: "F7EF8A", 239 | text_color: "E0AA3E", 240 | bg_color: "260000", 241 | }, 242 | yeblu: { 243 | title_color: "ffff00", 244 | icon_color: "ffff00", 245 | text_color: "ffffff", 246 | bg_color: "002046", 247 | }, 248 | blueberry: { 249 | title_color: "82aaff", 250 | icon_color: "89ddff", 251 | text_color: "27e8a7", 252 | bg_color: "242938", 253 | }, 254 | slateorange: { 255 | title_color: "faa627", 256 | icon_color: "faa627", 257 | text_color: "ffffff", 258 | bg_color: "36393f", 259 | }, 260 | kacho_ga: { 261 | title_color: "bf4a3f", 262 | icon_color: "a64833", 263 | text_color: "d9c8a9", 264 | bg_color: "402b23", 265 | }, 266 | outrun:{ 267 | title_color: "ffcc00", 268 | icon_color: "ff1aff", 269 | text_color: "8080ff", 270 | bg_color: "141439", 271 | }, 272 | ocean_dark:{ 273 | title_color: "8957B2", 274 | icon_color: "FFFFFF", 275 | text_color: "92D534", 276 | bg_color: "151A28", 277 | } 278 | }; 279 | 280 | module.exports = themes; 281 | -------------------------------------------------------------------------------- /vercel.json: -------------------------------------------------------------------------------- 1 | { 2 | "redirects": [ 3 | { 4 | "source": "/", 5 | "destination": "https://github.com/anuraghazra/github-readme-stats" 6 | } 7 | ] 8 | } 9 | --------------------------------------------------------------------------------