├── .husky ├── .gitignore └── pre-commit ├── .gitattributes ├── .vercelignore ├── .prettierignore ├── .github ├── labeler.yml ├── workflows │ ├── label-pr.yml │ ├── top-issues-dashboard.yml │ ├── test.yml │ ├── stale-theme-pr-closer.yaml │ ├── preview-theme.yml │ ├── e2e-test.yml │ ├── generate-theme-doc.yml │ └── empty-issues-closer.yaml ├── ISSUE_TEMPLATE │ ├── config.yml │ ├── feature_request.yml │ └── bug_report.yml ├── stale.yml └── FUNDING.yml ├── .prettierrc.json ├── src ├── common │ ├── blacklist.js │ ├── I18n.js │ ├── index.js │ ├── createProgressNode.js │ ├── retryer.js │ ├── icons.js │ └── Card.js ├── index.js ├── cards │ ├── index.js │ ├── types.d.ts │ ├── repo-card.js │ ├── stats-card.js │ ├── wakatime-card.js │ └── top-languages-card.js ├── fetchers │ ├── wakatime-fetcher.js │ ├── types.d.ts │ ├── repo-fetcher.js │ ├── top-languages-fetcher.js │ └── stats-fetcher.js ├── calculateRank.js └── getStyles.js ├── .gitignore ├── jest.e2e.config.js ├── codecov.yml ├── vercel.json ├── jest.config.js ├── tests ├── calculateRank.test.js ├── flexLayout.test.js ├── retryer.test.js ├── renderWakatimeCard.test.js ├── fetchTopLanguages.test.js ├── fetchRepo.test.js ├── top-langs.test.js ├── pin.test.js ├── utils.test.js ├── e2e │ └── e2e.test.js ├── card.test.js ├── fetchWakatime.test.js ├── api.test.js ├── fetchStats.test.js ├── __snapshots__ │ └── renderWakatimeCard.test.js.snap └── renderTopLanguages.test.js ├── scripts ├── push-theme-readme.sh ├── generate-langs-json.js ├── helpers.js ├── generate-theme-doc.js └── close-stale-theme-prs.js ├── LICENSE ├── api ├── wakatime.js ├── top-langs.js ├── pin.js └── index.js ├── package.json ├── CODE_OF_CONDUCT.md ├── powered-by-vercel.svg ├── CONTRIBUTING.md └── themes └── index.js /.husky/.gitignore: -------------------------------------------------------------------------------- 1 | _ 2 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | dist/* linguist-vendored=false 2 | -------------------------------------------------------------------------------- /.vercelignore: -------------------------------------------------------------------------------- 1 | .env 2 | package-lock.json 3 | coverage -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | *.json 3 | *.md 4 | coverage 5 | .vercel 6 | -------------------------------------------------------------------------------- /.github/labeler.yml: -------------------------------------------------------------------------------- 1 | themes: themes/index.js 2 | doc-translation: docs/* 3 | card-i18n: src/translations.js 4 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | . "$(dirname -- "$0")/_/husky.sh" 3 | 4 | npm test 5 | npx lint-staged 6 | -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "trailingComma": "all", 3 | "useTabs": false, 4 | "endOfLine": "auto", 5 | "proseWrap": "always" 6 | } -------------------------------------------------------------------------------- /src/common/blacklist.js: -------------------------------------------------------------------------------- 1 | const blacklist = ["renovate-bot", "technote-space", "sw-yx"]; 2 | 3 | export { blacklist }; 4 | export default blacklist; 5 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | export * from "./common/index.js"; 2 | export * from "./cards/index.js"; 3 | export { getStyles, getAnimations } from "./getStyles.js"; 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .vercel 2 | .env 3 | node_modules 4 | *.lock 5 | .vscode/ 6 | .idea/ 7 | coverage 8 | vercel_token 9 | 10 | # IDE 11 | .vscode 12 | *.code-workspace 13 | -------------------------------------------------------------------------------- /jest.e2e.config.js: -------------------------------------------------------------------------------- 1 | export default { 2 | clearMocks: true, 3 | transform: {}, 4 | testEnvironment: "node", 5 | coverageProvider: "v8", 6 | testMatch: ["/tests/e2e/**/*.test.js"], 7 | }; 8 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /src/cards/index.js: -------------------------------------------------------------------------------- 1 | export { renderRepoCard } from "./repo-card.js"; 2 | export { renderStatsCard } from "./stats-card.js"; 3 | export { renderTopLanguages } from "./top-languages-card.js"; 4 | export { renderWakatimeCard } from "./wakatime-card.js"; 5 | -------------------------------------------------------------------------------- /.github/workflows/label-pr.yml: -------------------------------------------------------------------------------- 1 | name: "Pull Request Labeler" 2 | on: 3 | - pull_request_target 4 | 5 | jobs: 6 | triage: 7 | runs-on: ubuntu-latest 8 | steps: 9 | - uses: actions/labeler@v4 10 | with: 11 | repo-token: "${{ secrets.GITHUB_TOKEN }}" 12 | -------------------------------------------------------------------------------- /vercel.json: -------------------------------------------------------------------------------- 1 | { 2 | "functions": { 3 | "api/*.js": { 4 | "memory": 128, 5 | "maxDuration": 10 6 | } 7 | }, 8 | "redirects": [ 9 | { 10 | "source": "/", 11 | "destination": "https://github.com/anuraghazra/github-readme-stats" 12 | } 13 | ] 14 | } 15 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | export default { 2 | clearMocks: true, 3 | transform: {}, 4 | testEnvironment: "jsdom", 5 | coverageProvider: "v8", 6 | testPathIgnorePatterns: ["/node_modules/", "/tests/e2e/"], 7 | modulePathIgnorePatterns: ["/node_modules/", "/tests/e2e/"], 8 | coveragePathIgnorePatterns: [ 9 | "/node_modules/", 10 | "/tests/E2E/", 11 | ], 12 | }; 13 | -------------------------------------------------------------------------------- /tests/calculateRank.test.js: -------------------------------------------------------------------------------- 1 | import "@testing-library/jest-dom"; 2 | import { calculateRank } from "../src/calculateRank.js"; 3 | 4 | describe("Test calculateRank", () => { 5 | it("should calculate rank correctly", () => { 6 | expect( 7 | calculateRank({ 8 | totalCommits: 100, 9 | totalRepos: 5, 10 | followers: 100, 11 | contributions: 61, 12 | stargazers: 400, 13 | prs: 300, 14 | issues: 200, 15 | }), 16 | ).toStrictEqual({ level: "A+", score: 49.16605417270399 }); 17 | }); 18 | }); 19 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | blank_issues_enabled: true 2 | contact_links: 3 | - name: Question 4 | url: https://github.com/anuraghazra/github-readme-stats/discussions 5 | about: Please ask and answer questions here. 6 | - name: Error 7 | url: https://github.com/anuraghazra/github-readme-stats/issues/1772 8 | about: 9 | Before opening a bug report, please check the 'Common Error Codes' issue. 10 | - name: FAQ 11 | url: https://github.com/anuraghazra/github-readme-stats/discussions/1770 12 | about: Please first check the FAQ before asking a question. 13 | -------------------------------------------------------------------------------- /scripts/push-theme-readme.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -x 3 | set -e 4 | 5 | export BRANCH_NAME=updated-theme-readme 6 | git --version 7 | git config --global user.email "no-reply@githubreadmestats.com" 8 | git config --global user.name "GitHub Readme Stats Bot" 9 | git branch -d $BRANCH_NAME || true 10 | git checkout -b $BRANCH_NAME 11 | git add --all 12 | git commit --message "docs(theme): Auto update theme readme" || exit 0 13 | git remote add origin-$BRANCH_NAME https://${PERSONAL_TOKEN}@github.com/${GH_REPO}.git 14 | git push --force --quiet --set-upstream origin-$BRANCH_NAME $BRANCH_NAME 15 | -------------------------------------------------------------------------------- /src/common/I18n.js: -------------------------------------------------------------------------------- 1 | /** 2 | * I18n translation class. 3 | */ 4 | class I18n { 5 | constructor({ locale, translations }) { 6 | this.locale = locale; 7 | this.translations = translations; 8 | this.fallbackLocale = "en"; 9 | } 10 | 11 | t(str) { 12 | if (!this.translations[str]) { 13 | throw new Error(`${str} Translation string not found`); 14 | } 15 | 16 | if (!this.translations[str][this.locale || this.fallbackLocale]) { 17 | throw new Error(`${str} Translation locale not found`); 18 | } 19 | 20 | return this.translations[str][this.locale || this.fallbackLocale]; 21 | } 22 | } 23 | 24 | export { I18n }; 25 | export default I18n; 26 | -------------------------------------------------------------------------------- /.github/workflows/top-issues-dashboard.yml: -------------------------------------------------------------------------------- 1 | name: Update top issues dashboard 2 | on: 3 | schedule: 4 | - cron: "0 0 */3 * *" 5 | 6 | jobs: 7 | showAndLabelTopIssues: 8 | name: Update top issues Dashboard. 9 | runs-on: ubuntu-latest 10 | steps: 11 | - name: Run top issues action 12 | uses: rickstaa/top-issues-action@v1 13 | env: 14 | github_token: ${{ secrets.GITHUB_TOKEN }} 15 | with: 16 | filter: "1772" 17 | label: false 18 | dashboard: true 19 | dashboard_show_total_reactions: true 20 | top_issues: true 21 | top_bugs: true 22 | top_features: true 23 | top_pull_requests: true 24 | -------------------------------------------------------------------------------- /src/common/index.js: -------------------------------------------------------------------------------- 1 | export { blacklist } from "./blacklist.js"; 2 | export { Card } from "./Card.js"; 3 | export { createProgressNode } from "./createProgressNode.js"; 4 | export { I18n } from "./I18n.js"; 5 | export { icons } from "./icons.js"; 6 | export { retryer } from "./retryer.js"; 7 | export { 8 | ERROR_CARD_LENGTH, 9 | renderError, 10 | encodeHTML, 11 | kFormatter, 12 | isValidHexColor, 13 | parseBoolean, 14 | parseArray, 15 | clampValue, 16 | isValidGradient, 17 | fallbackColor, 18 | request, 19 | flexLayout, 20 | getCardColors, 21 | wrapTextMultiline, 22 | logger, 23 | CONSTANTS, 24 | CustomError, 25 | MissingParamError, 26 | measureText, 27 | lowercaseTrim, 28 | chunkArray, 29 | parseEmojis, 30 | } from "./utils.js"; 31 | -------------------------------------------------------------------------------- /.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/test.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | on: 3 | push: 4 | branches: 5 | - "*" 6 | pull_request: 7 | branches: 8 | - master 9 | 10 | jobs: 11 | build: 12 | name: Perform tests 13 | runs-on: ubuntu-latest 14 | strategy: 15 | matrix: 16 | node-version: [16.x] 17 | 18 | steps: 19 | - uses: actions/checkout@v3 20 | 21 | - name: Setup Node 22 | uses: actions/setup-node@v3 23 | with: 24 | node-version: ${{ matrix.node-version }} 25 | cache: npm 26 | 27 | - name: Install & Test 28 | run: | 29 | npm ci 30 | npm run test 31 | 32 | - name: Run Prettier 33 | run: | 34 | npm run format:check 35 | 36 | - name: Code Coverage 37 | uses: codecov/codecov-action@v1 38 | -------------------------------------------------------------------------------- /.github/workflows/stale-theme-pr-closer.yaml: -------------------------------------------------------------------------------- 1 | name: Close stale theme pull requests that have the 'invalid' label. 2 | on: 3 | schedule: 4 | - cron: "0 0 */7 * *" 5 | 6 | jobs: 7 | closeOldThemePrs: 8 | name: Close stale 'invalid' theme PRs 9 | runs-on: ubuntu-latest 10 | strategy: 11 | matrix: 12 | node-version: [16.x] 13 | 14 | steps: 15 | - uses: actions/checkout@v3 16 | 17 | - name: Setup Node 18 | uses: actions/setup-node@v3 19 | with: 20 | node-version: ${{ matrix.node-version }} 21 | cache: npm 22 | 23 | - uses: bahmutov/npm-install@v1 24 | with: 25 | useLockFile: false 26 | 27 | - run: npm run close-stale-theme-prs 28 | env: 29 | STALE_DAYS: 20 30 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 31 | -------------------------------------------------------------------------------- /.github/workflows/preview-theme.yml: -------------------------------------------------------------------------------- 1 | name: Theme preview 2 | on: 3 | pull_request_target: 4 | types: [opened, edited, reopened, synchronize] 5 | branches: 6 | - master 7 | paths: 8 | - "themes/index.js" 9 | 10 | jobs: 11 | previewTheme: 12 | name: Install & Preview 13 | runs-on: ubuntu-latest 14 | strategy: 15 | matrix: 16 | node-version: [16.x] 17 | 18 | steps: 19 | - uses: actions/checkout@v3 20 | 21 | - name: Setup Node 22 | uses: actions/setup-node@v3 23 | with: 24 | node-version: ${{ matrix.node-version }} 25 | cache: npm 26 | 27 | - uses: bahmutov/npm-install@v1 28 | with: 29 | useLockFile: false 30 | 31 | - run: npm run preview-theme 32 | env: 33 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 34 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: [anuraghazra] # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2] 4 | patreon: # Replace with a single Patreon username 5 | open_collective: # Replace with a single Open Collective username 6 | ko_fi: # Replace with a single Ko-fi username 7 | tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel 8 | community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry 9 | liberapay: # Replace with a single Liberapay username 10 | issuehunt: # Replace with a single IssueHunt username 11 | otechie: # Replace with a single Otechie username 12 | custom: [ 13 | "https://www.paypal.me/anuraghazra", 14 | "https://www.buymeacoffee.com/anuraghazra", 15 | ] # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2'] 16 | -------------------------------------------------------------------------------- /.github/workflows/e2e-test.yml: -------------------------------------------------------------------------------- 1 | name: Test Deployment 2 | on: 3 | deployment_status: 4 | 5 | jobs: 6 | e2eTests: 7 | if: 8 | github.event_name == 'deployment_status' && 9 | github.event.deployment_status.state == 'success' 10 | name: Perform 2e2 tests 11 | runs-on: ubuntu-latest 12 | strategy: 13 | matrix: 14 | node-version: [16.x] 15 | 16 | steps: 17 | - uses: actions/checkout@v3 18 | 19 | - name: Setup Node 20 | uses: actions/setup-node@v3 21 | with: 22 | node-version: ${{ matrix.node-version }} 23 | cache: npm 24 | 25 | - name: Install dependencies 26 | run: npm ci 27 | env: 28 | CI: true 29 | 30 | - name: Run end-to-end tests. 31 | run: npm run test:e2e 32 | env: 33 | VERCEL_PREVIEW_URL: ${{ github.event.deployment_status.target_url }} 34 | -------------------------------------------------------------------------------- /scripts/generate-langs-json.js: -------------------------------------------------------------------------------- 1 | import axios from "axios"; 2 | import fs from "fs"; 3 | import jsYaml from "js-yaml"; 4 | 5 | const LANGS_FILEPATH = "./src/common/languageColors.json"; 6 | 7 | //Retrieve languages from github linguist repository yaml file 8 | //@ts-ignore 9 | axios 10 | .get( 11 | "https://raw.githubusercontent.com/github/linguist/master/lib/linguist/languages.yml", 12 | ) 13 | .then((response) => { 14 | //and convert them to a JS Object 15 | const languages = jsYaml.load(response.data); 16 | 17 | const languageColors = {}; 18 | 19 | //Filter only language colors from the whole file 20 | Object.keys(languages).forEach((lang) => { 21 | languageColors[lang] = languages[lang].color; 22 | }); 23 | 24 | //Debug Print 25 | //console.dir(languageColors); 26 | fs.writeFileSync( 27 | LANGS_FILEPATH, 28 | JSON.stringify(languageColors, null, " "), 29 | ); 30 | }); 31 | -------------------------------------------------------------------------------- /scripts/helpers.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @file Contains helper functions used in the scripts. 3 | */ 4 | 5 | import { getInput } from "@actions/core"; 6 | 7 | const OWNER = "anuraghazra"; 8 | const REPO = "github-readme-stats"; 9 | 10 | /** 11 | * Retrieve information about the repository that ran the action. 12 | * 13 | * @param {Object} context Action context. 14 | * @returns {Object} Repository information. 15 | */ 16 | export const getRepoInfo = (ctx) => { 17 | try { 18 | return { 19 | owner: ctx.repo.owner, 20 | repo: ctx.repo.repo, 21 | }; 22 | } catch (error) { 23 | return { 24 | owner: OWNER, 25 | repo: REPO, 26 | }; 27 | } 28 | }; 29 | 30 | /** 31 | * Retrieve github token and throw error if it is not found. 32 | * 33 | * @returns {string} GitHub token. 34 | */ 35 | export const getGithubToken = () => { 36 | const token = getInput("github_token") || process.env.GITHUB_TOKEN; 37 | if (!token) { 38 | throw Error("Could not find github token"); 39 | } 40 | return token; 41 | }; 42 | -------------------------------------------------------------------------------- /.github/workflows/generate-theme-doc.yml: -------------------------------------------------------------------------------- 1 | name: Generate Theme Readme 2 | on: 3 | push: 4 | branches: 5 | - master 6 | paths: 7 | - "themes/index.js" 8 | 9 | jobs: 10 | generateThemeDoc: 11 | runs-on: ubuntu-latest 12 | name: Generate theme doc 13 | strategy: 14 | matrix: 15 | node-version: [16.x] 16 | 17 | steps: 18 | - uses: actions/checkout@v3 19 | 20 | - name: Setup Node 21 | uses: actions/setup-node@v3 22 | with: 23 | node-version: ${{ matrix.node-version }} 24 | cache: npm 25 | 26 | - name: npm install, generate readme 27 | run: | 28 | npm ci 29 | npm run theme-readme-gen 30 | env: 31 | CI: true 32 | 33 | - name: Run Script 34 | uses: skx/github-action-tester@master 35 | with: 36 | script: ./scripts/push-theme-readme.sh 37 | env: 38 | CI: true 39 | PERSONAL_TOKEN: ${{ secrets.PERSONAL_TOKEN }} 40 | GH_REPO: ${{ secrets.GH_REPO }} 41 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.yml: -------------------------------------------------------------------------------- 1 | name: Feature request 2 | description: Suggest an idea for this project. 3 | labels: 4 | - "enhancement" 5 | body: 6 | - type: textarea 7 | attributes: 8 | label: Is your feature request related to a problem? Please describe. 9 | description: 10 | A clear and concise description of what the problem is. Ex. I'm always 11 | frustrated when [...] 12 | validations: 13 | required: true 14 | - type: textarea 15 | attributes: 16 | label: Describe the solution you'd like 17 | description: A clear and concise description of what you want to happen. 18 | - type: textarea 19 | attributes: 20 | label: Describe alternatives you've considered 21 | description: 22 | A clear and concise description of any alternative solutions or features 23 | you've considered. 24 | - type: textarea 25 | attributes: 26 | label: Additional context 27 | description: 28 | Add any other context or screenshots about the feature request here. 29 | -------------------------------------------------------------------------------- /src/fetchers/wakatime-fetcher.js: -------------------------------------------------------------------------------- 1 | import axios from "axios"; 2 | import { MissingParamError } from "../common/utils.js"; 3 | 4 | /** 5 | * WakaTime data fetcher. 6 | * 7 | * @param {{username: string, api_domain: string, range: string}} props Fetcher props. 8 | * @returns {Promise} WakaTime data response. 9 | */ 10 | const fetchWakatimeStats = async ({ username, api_domain, range }) => { 11 | if (!username) throw new MissingParamError(["username"]); 12 | 13 | try { 14 | const { data } = await axios.get( 15 | `https://${ 16 | api_domain ? api_domain.replace(/\/$/gi, "") : "wakatime.com" 17 | }/api/v1/users/${username}/stats/${range || ""}?is_including_today=true`, 18 | ); 19 | 20 | return data.data; 21 | } catch (err) { 22 | if (err.response.status < 200 || err.response.status > 299) { 23 | throw new Error( 24 | "Wakatime user not found, make sure you have a wakatime profile", 25 | ); 26 | } 27 | throw err; 28 | } 29 | }; 30 | 31 | export { fetchWakatimeStats }; 32 | export default fetchWakatimeStats; 33 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Anurag Hazra 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /.github/workflows/empty-issues-closer.yaml: -------------------------------------------------------------------------------- 1 | name: Close empty issues and templates 2 | on: 3 | issues: 4 | types: 5 | - reopened 6 | - opened 7 | - edited 8 | 9 | jobs: 10 | closeEmptyIssuesAndTemplates: 11 | name: Close empty issues 12 | runs-on: ubuntu-latest 13 | steps: 14 | - uses: actions/checkout@v3 # NOTE: Retrieve issue templates. 15 | 16 | - name: Run empty issues closer action 17 | uses: rickstaa/empty-issues-closer-action@v1 18 | env: 19 | github_token: ${{ secrets.GITHUB_TOKEN }} 20 | with: 21 | close_comment: 22 | Closing this issue because it appears to be empty. Please update the 23 | issue for it to be reopened. 24 | open_comment: 25 | Reopening this issue because the author provided more information. 26 | check_templates: true 27 | template_close_comment: 28 | Closing this issue since the issue template was not filled in. 29 | Please provide us with more information to have this issue reopened. 30 | template_open_comment: 31 | Reopening this issue because the author provided more information. 32 | -------------------------------------------------------------------------------- /src/cards/types.d.ts: -------------------------------------------------------------------------------- 1 | type ThemeNames = keyof typeof import("../../themes/index.js"); 2 | 3 | export type CommonOptions = { 4 | title_color: string; 5 | icon_color: string; 6 | text_color: string; 7 | bg_color: string; 8 | theme: ThemeNames; 9 | border_radius: number; 10 | border_color: string; 11 | locale: string; 12 | }; 13 | 14 | export type StatCardOptions = CommonOptions & { 15 | hide: string[]; 16 | show_icons: boolean; 17 | hide_title: boolean; 18 | hide_border: boolean; 19 | card_width: number; 20 | hide_rank: boolean; 21 | include_all_commits: boolean; 22 | line_height: number | string; 23 | custom_title: string; 24 | disable_animations: boolean; 25 | }; 26 | 27 | export type RepoCardOptions = CommonOptions & { 28 | hide_border: boolean; 29 | show_owner: boolean; 30 | }; 31 | 32 | export type TopLangOptions = CommonOptions & { 33 | hide_title: boolean; 34 | hide_border: boolean; 35 | card_width: number; 36 | hide: string[]; 37 | layout: "compact" | "normal"; 38 | custom_title: string; 39 | langs_count: number; 40 | }; 41 | 42 | type WakaTimeOptions = CommonOptions & { 43 | hide_title: boolean; 44 | hide_border: boolean; 45 | hide: string[]; 46 | line_height: string; 47 | hide_progress: boolean; 48 | custom_title: string; 49 | layout: "compact" | "normal"; 50 | langs_count: number; 51 | }; 52 | -------------------------------------------------------------------------------- /tests/flexLayout.test.js: -------------------------------------------------------------------------------- 1 | import { flexLayout } from "../src/common/utils.js"; 2 | 3 | describe("flexLayout", () => { 4 | it("should work with row & col layouts", () => { 5 | const layout = flexLayout({ 6 | items: ["1", "2"], 7 | gap: 60, 8 | }); 9 | 10 | expect(layout).toStrictEqual([ 11 | `1`, 12 | `2`, 13 | ]); 14 | 15 | const columns = flexLayout({ 16 | items: ["1", "2"], 17 | gap: 60, 18 | direction: "column", 19 | }); 20 | 21 | expect(columns).toStrictEqual([ 22 | `1`, 23 | `2`, 24 | ]); 25 | }); 26 | 27 | it("should work with sizes", () => { 28 | const layout = flexLayout({ 29 | items: [ 30 | "1", 31 | "2", 32 | "3", 33 | "4", 34 | ], 35 | gap: 20, 36 | sizes: [200, 100, 55, 25], 37 | }); 38 | 39 | expect(layout).toStrictEqual([ 40 | `1`, 41 | `2`, 42 | `3`, 43 | `4`, 44 | ]); 45 | }); 46 | }); 47 | -------------------------------------------------------------------------------- /src/common/createProgressNode.js: -------------------------------------------------------------------------------- 1 | import { clampValue } from "./utils.js"; 2 | 3 | /** 4 | * Create a node to indicate progress in percentage along a horizontal line. 5 | * 6 | * @param {Object} createProgressNodeParams Object that contains the createProgressNode parameters. 7 | * @param {number} createProgressNodeParams.x X-axis position. 8 | * @param {number} createProgressNodeParams.y Y-axis position. 9 | * @param {number} createProgressNodeParams.width Width of progress bar. 10 | * @param {string} createProgressNodeParams.color Progress color. 11 | * @param {string} createProgressNodeParams.progress Progress value. 12 | * @param {string} createProgressNodeParams.progressBarBackgroundColor Progress bar bg color. 13 | * @returns {string} Progress node. 14 | */ 15 | const createProgressNode = ({ 16 | x, 17 | y, 18 | width, 19 | color, 20 | progress, 21 | progressBarBackgroundColor, 22 | }) => { 23 | const progressPercentage = clampValue(progress, 2, 100); 24 | 25 | return ` 26 | 27 | 28 | 35 | 36 | 37 | `; 38 | }; 39 | 40 | export { createProgressNode }; 41 | export default createProgressNode; 42 | -------------------------------------------------------------------------------- /tests/retryer.test.js: -------------------------------------------------------------------------------- 1 | import { jest } from "@jest/globals"; 2 | import "@testing-library/jest-dom"; 3 | import { retryer } from "../src/common/retryer.js"; 4 | import { logger } from "../src/common/utils.js"; 5 | 6 | const fetcher = jest.fn((variables, token) => { 7 | logger.log(variables, token); 8 | return new Promise((res, rej) => res({ data: "ok" })); 9 | }); 10 | 11 | const fetcherFail = jest.fn(() => { 12 | return new Promise((res, rej) => 13 | res({ data: { errors: [{ type: "RATE_LIMITED" }] } }), 14 | ); 15 | }); 16 | 17 | const fetcherFailOnSecondTry = jest.fn((_vars, _token, retries) => { 18 | return new Promise((res, rej) => { 19 | // faking rate limit 20 | if (retries < 1) { 21 | return res({ data: { errors: [{ type: "RATE_LIMITED" }] } }); 22 | } 23 | return res({ data: "ok" }); 24 | }); 25 | }); 26 | 27 | describe("Test Retryer", () => { 28 | it("retryer should return value and have zero retries on first try", async () => { 29 | let res = await retryer(fetcher, {}); 30 | 31 | expect(fetcher).toBeCalledTimes(1); 32 | expect(res).toStrictEqual({ data: "ok" }); 33 | }); 34 | 35 | it("retryer should return value and have 2 retries", async () => { 36 | let res = await retryer(fetcherFailOnSecondTry, {}); 37 | 38 | expect(fetcherFailOnSecondTry).toBeCalledTimes(2); 39 | expect(res).toStrictEqual({ data: "ok" }); 40 | }); 41 | 42 | it("retryer should throw error if maximum retries reached", async () => { 43 | let res; 44 | 45 | try { 46 | res = await retryer(fetcherFail, {}); 47 | } catch (err) { 48 | expect(fetcherFail).toBeCalledTimes(8); 49 | expect(err.message).toBe("Maximum retries exceeded"); 50 | } 51 | }); 52 | }); 53 | -------------------------------------------------------------------------------- /src/common/retryer.js: -------------------------------------------------------------------------------- 1 | import { CustomError, logger } from "./utils.js"; 2 | 3 | /** 4 | * Try to execute the fetcher function until it succeeds or the max number of retries is reached. 5 | * 6 | * @param {object[]} retryerParams Object that contains the createTextNode parameters. 7 | * @param {object[]} retryerParams.fetcher The fetcher function. 8 | * @param {object[]} retryerParams.variables Object with arguments to pass to the fetcher function. 9 | * @param {number} retryerParams.retries How many times to retry. 10 | * @returns Promise 11 | */ 12 | const retryer = async (fetcher, variables, retries = 0) => { 13 | if (retries > 7) { 14 | throw new CustomError("Maximum retries exceeded", CustomError.MAX_RETRY); 15 | } 16 | try { 17 | // try to fetch with the first token since RETRIES is 0 index i'm adding +1 18 | let response = await fetcher( 19 | variables, 20 | process.env[`PAT_${retries + 1}`], 21 | retries, 22 | ); 23 | 24 | // prettier-ignore 25 | const isRateExceeded = response.data.errors && response.data.errors[0].type === "RATE_LIMITED"; 26 | 27 | // if rate limit is hit increase the RETRIES and recursively call the retryer 28 | // with username, and current RETRIES 29 | if (isRateExceeded) { 30 | logger.log(`PAT_${retries + 1} Failed`); 31 | retries++; 32 | // directly return from the function 33 | return retryer(fetcher, variables, retries); 34 | } 35 | 36 | // finally return the response 37 | return response; 38 | } catch (err) { 39 | // prettier-ignore 40 | // also checking for bad credentials if any tokens gets invalidated 41 | const isBadCredential = err.response.data && err.response.data.message === "Bad credentials"; 42 | 43 | if (isBadCredential) { 44 | logger.log(`PAT_${retries + 1} Failed`); 45 | retries++; 46 | // directly return from the function 47 | return retryer(fetcher, variables, retries); 48 | } else { 49 | return err.response; 50 | } 51 | } 52 | }; 53 | 54 | export { retryer }; 55 | export default retryer; 56 | -------------------------------------------------------------------------------- /api/wakatime.js: -------------------------------------------------------------------------------- 1 | import * as dotenv from "dotenv"; 2 | import { renderWakatimeCard } from "../src/cards/wakatime-card.js"; 3 | import { 4 | clampValue, 5 | CONSTANTS, 6 | parseArray, 7 | parseBoolean, 8 | renderError, 9 | } from "../src/common/utils.js"; 10 | import { fetchWakatimeStats } from "../src/fetchers/wakatime-fetcher.js"; 11 | import { isLocaleAvailable } from "../src/translations.js"; 12 | 13 | dotenv.config(); 14 | 15 | export default async (req, res) => { 16 | const { 17 | username, 18 | title_color, 19 | icon_color, 20 | hide_border, 21 | line_height, 22 | text_color, 23 | bg_color, 24 | theme, 25 | cache_seconds, 26 | hide_title, 27 | hide_progress, 28 | custom_title, 29 | locale, 30 | layout, 31 | langs_count, 32 | hide, 33 | api_domain, 34 | range, 35 | border_radius, 36 | border_color, 37 | } = req.query; 38 | 39 | res.setHeader("Content-Type", "image/svg+xml"); 40 | 41 | if (locale && !isLocaleAvailable(locale)) { 42 | return res.send(renderError("Something went wrong", "Language not found")); 43 | } 44 | 45 | try { 46 | const stats = await fetchWakatimeStats({ username, api_domain, range }); 47 | 48 | let cacheSeconds = clampValue( 49 | parseInt(cache_seconds || CONSTANTS.FOUR_HOURS, 10), 50 | CONSTANTS.FOUR_HOURS, 51 | CONSTANTS.ONE_DAY, 52 | ); 53 | 54 | if (!cache_seconds) { 55 | cacheSeconds = CONSTANTS.FOUR_HOURS; 56 | } 57 | 58 | res.setHeader("Cache-Control", `public, max-age=${cacheSeconds}`); 59 | 60 | return res.send( 61 | renderWakatimeCard(stats, { 62 | custom_title, 63 | hide_title: parseBoolean(hide_title), 64 | hide_border: parseBoolean(hide_border), 65 | hide: parseArray(hide), 66 | line_height, 67 | title_color, 68 | icon_color, 69 | text_color, 70 | bg_color, 71 | theme, 72 | hide_progress, 73 | border_radius, 74 | border_color, 75 | locale: locale ? locale.toLowerCase() : null, 76 | layout, 77 | langs_count, 78 | }), 79 | ); 80 | } catch (err) { 81 | res.setHeader("Cache-Control", `no-cache, no-store, must-revalidate`); // Don't cache error responses. 82 | return res.send(renderError(err.message, err.secondaryMessage)); 83 | } 84 | }; 85 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "github-readme-stats", 3 | "version": "1.0.0", 4 | "description": "Dynamically generate stats for your GitHub readme", 5 | "keywords": [ 6 | "github-readme-stats", 7 | "readme-stats", 8 | "cards", 9 | "card-generator" 10 | ], 11 | "main": "src/index.js", 12 | "type": "module", 13 | "homepage": "https://github.com/anuraghazra/github-readme-stats", 14 | "bugs": { 15 | "url": "https://github.com/anuraghazra/github-readme-stats/issues" 16 | }, 17 | "repository": { 18 | "type": "git", 19 | "url": "https://github.com/anuraghazra/github-readme-stats.git" 20 | }, 21 | "scripts": { 22 | "test": "node --experimental-vm-modules node_modules/jest/bin/jest.js --coverage", 23 | "test:watch": "node --experimental-vm-modules node_modules/jest/bin/jest.js --watch", 24 | "test:update:snapshot": "node --experimental-vm-modules node_modules/jest/bin/jest.js -u", 25 | "test:e2e": "node --experimental-vm-modules node_modules/jest/bin/jest.js --config jest.e2e.config.js", 26 | "theme-readme-gen": "node scripts/generate-theme-doc", 27 | "preview-theme": "node scripts/preview-theme", 28 | "close-stale-theme-prs": "node scripts/close-stale-theme-prs", 29 | "generate-langs-json": "node scripts/generate-langs-json", 30 | "format": "prettier --write .", 31 | "format:check": "prettier --check .", 32 | "prepare": "husky install" 33 | }, 34 | "author": "Anurag Hazra", 35 | "license": "MIT", 36 | "devDependencies": { 37 | "@actions/core": "^1.9.1", 38 | "@actions/github": "^4.0.0", 39 | "@testing-library/dom": "^8.17.1", 40 | "@testing-library/jest-dom": "^5.16.5", 41 | "@uppercod/css-to-object": "^1.1.1", 42 | "axios-mock-adapter": "^1.18.1", 43 | "color-contrast-checker": "^2.1.0", 44 | "hjson": "^3.2.2", 45 | "husky": "^8.0.0", 46 | "jest": "^29.0.3", 47 | "jest-environment-jsdom": "^29.0.3", 48 | "js-yaml": "^4.1.0", 49 | "lint-staged": "^13.0.3", 50 | "lodash.snakecase": "^4.1.1", 51 | "parse-diff": "^0.7.0", 52 | "prettier": "^2.1.2" 53 | }, 54 | "dependencies": { 55 | "axios": "^0.24.0", 56 | "dotenv": "^8.2.0", 57 | "emoji-name-map": "^1.2.8", 58 | "github-username-regex": "^1.0.0", 59 | "upgrade": "^1.1.0", 60 | "word-wrap": "^1.2.3" 61 | }, 62 | "lint-staged": { 63 | "*.{js,css,md}": "prettier --write" 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /api/top-langs.js: -------------------------------------------------------------------------------- 1 | import * as dotenv from "dotenv"; 2 | import { renderTopLanguages } from "../src/cards/top-languages-card.js"; 3 | import { blacklist } from "../src/common/blacklist.js"; 4 | import { 5 | clampValue, 6 | CONSTANTS, 7 | parseArray, 8 | parseBoolean, 9 | renderError, 10 | } from "../src/common/utils.js"; 11 | import { fetchTopLanguages } from "../src/fetchers/top-languages-fetcher.js"; 12 | import { isLocaleAvailable } from "../src/translations.js"; 13 | 14 | dotenv.config(); 15 | 16 | export default async (req, res) => { 17 | const { 18 | username, 19 | hide, 20 | hide_title, 21 | hide_border, 22 | card_width, 23 | title_color, 24 | text_color, 25 | bg_color, 26 | theme, 27 | cache_seconds, 28 | layout, 29 | langs_count, 30 | exclude_repo, 31 | custom_title, 32 | locale, 33 | border_radius, 34 | border_color, 35 | } = req.query; 36 | res.setHeader("Content-Type", "image/svg+xml"); 37 | 38 | if (blacklist.includes(username)) { 39 | return res.send(renderError("Something went wrong")); 40 | } 41 | 42 | if (locale && !isLocaleAvailable(locale)) { 43 | return res.send(renderError("Something went wrong", "Locale not found")); 44 | } 45 | 46 | try { 47 | const topLangs = await fetchTopLanguages( 48 | username, 49 | parseArray(exclude_repo), 50 | ); 51 | 52 | const cacheSeconds = clampValue( 53 | parseInt(cache_seconds || CONSTANTS.FOUR_HOURS, 10), 54 | CONSTANTS.FOUR_HOURS, 55 | CONSTANTS.ONE_DAY, 56 | ); 57 | 58 | res.setHeader("Cache-Control", `public, max-age=${cacheSeconds}`); 59 | 60 | return res.send( 61 | renderTopLanguages(topLangs, { 62 | custom_title, 63 | hide_title: parseBoolean(hide_title), 64 | hide_border: parseBoolean(hide_border), 65 | card_width: parseInt(card_width, 10), 66 | hide: parseArray(hide), 67 | title_color, 68 | text_color, 69 | bg_color, 70 | theme, 71 | layout, 72 | langs_count, 73 | border_radius, 74 | border_color, 75 | locale: locale ? locale.toLowerCase() : null, 76 | }), 77 | ); 78 | } catch (err) { 79 | res.setHeader("Cache-Control", `no-cache, no-store, must-revalidate`); // Don't cache error responses. 80 | return res.send(renderError(err.message, err.secondaryMessage)); 81 | } 82 | }; 83 | -------------------------------------------------------------------------------- /api/pin.js: -------------------------------------------------------------------------------- 1 | import { renderRepoCard } from "../src/cards/repo-card.js"; 2 | import { blacklist } from "../src/common/blacklist.js"; 3 | import { 4 | clampValue, 5 | CONSTANTS, 6 | parseBoolean, 7 | renderError, 8 | } from "../src/common/utils.js"; 9 | import { fetchRepo } from "../src/fetchers/repo-fetcher.js"; 10 | import { isLocaleAvailable } from "../src/translations.js"; 11 | 12 | export default async (req, res) => { 13 | const { 14 | username, 15 | repo, 16 | hide_border, 17 | title_color, 18 | icon_color, 19 | text_color, 20 | bg_color, 21 | theme, 22 | show_owner, 23 | cache_seconds, 24 | locale, 25 | border_radius, 26 | border_color, 27 | } = req.query; 28 | 29 | res.setHeader("Content-Type", "image/svg+xml"); 30 | 31 | if (blacklist.includes(username)) { 32 | return res.send(renderError("Something went wrong")); 33 | } 34 | 35 | if (locale && !isLocaleAvailable(locale)) { 36 | return res.send(renderError("Something went wrong", "Language not found")); 37 | } 38 | 39 | try { 40 | const repoData = await fetchRepo(username, repo); 41 | 42 | let cacheSeconds = clampValue( 43 | parseInt(cache_seconds || CONSTANTS.FOUR_HOURS, 10), 44 | CONSTANTS.FOUR_HOURS, 45 | CONSTANTS.ONE_DAY, 46 | ); 47 | 48 | /* 49 | if star count & fork count is over 1k then we are kFormating the text 50 | and if both are zero we are not showing the stats 51 | so we can just make the cache longer, since there is no need to frequent updates 52 | */ 53 | const stars = repoData.starCount; 54 | const forks = repoData.forkCount; 55 | const isBothOver1K = stars > 1000 && forks > 1000; 56 | const isBothUnder1 = stars < 1 && forks < 1; 57 | if (!cache_seconds && (isBothOver1K || isBothUnder1)) { 58 | cacheSeconds = CONSTANTS.FOUR_HOURS; 59 | } 60 | 61 | res.setHeader("Cache-Control", `public, max-age=${cacheSeconds}`); 62 | 63 | return res.send( 64 | renderRepoCard(repoData, { 65 | hide_border: parseBoolean(hide_border), 66 | title_color, 67 | icon_color, 68 | text_color, 69 | bg_color, 70 | theme, 71 | border_radius, 72 | border_color, 73 | show_owner: parseBoolean(show_owner), 74 | locale: locale ? locale.toLowerCase() : null, 75 | }), 76 | ); 77 | } catch (err) { 78 | res.setHeader("Cache-Control", `no-cache, no-store, must-revalidate`); // Don't cache error responses. 79 | return res.send(renderError(err.message, err.secondaryMessage)); 80 | } 81 | }; 82 | -------------------------------------------------------------------------------- /tests/renderWakatimeCard.test.js: -------------------------------------------------------------------------------- 1 | import { queryByTestId } from "@testing-library/dom"; 2 | import "@testing-library/jest-dom"; 3 | import { renderWakatimeCard } from "../src/cards/wakatime-card.js"; 4 | import { getCardColors } from "../src/common/utils.js"; 5 | import { wakaTimeData } from "./fetchWakatime.test.js"; 6 | 7 | describe("Test Render Wakatime Card", () => { 8 | it("should render correctly", () => { 9 | const card = renderWakatimeCard(wakaTimeData.data); 10 | expect(getCardColors).toMatchSnapshot(); 11 | }); 12 | 13 | it("should render correctly with compact layout", () => { 14 | const card = renderWakatimeCard(wakaTimeData.data, { layout: "compact" }); 15 | 16 | expect(card).toMatchSnapshot(); 17 | }); 18 | 19 | it("should render correctly with compact layout when langs_count is set", () => { 20 | const card = renderWakatimeCard(wakaTimeData.data, { 21 | layout: "compact", 22 | langs_count: 2, 23 | }); 24 | 25 | expect(card).toMatchSnapshot(); 26 | }); 27 | 28 | it("should hide languages when hide is passed", () => { 29 | document.body.innerHTML = renderWakatimeCard(wakaTimeData.data, { 30 | hide: ["YAML", "Other"], 31 | }); 32 | 33 | expect(queryByTestId(document.body, /YAML/i)).toBeNull(); 34 | expect(queryByTestId(document.body, /Other/i)).toBeNull(); 35 | expect(queryByTestId(document.body, /TypeScript/i)).not.toBeNull(); 36 | }); 37 | 38 | it("should render translations", () => { 39 | document.body.innerHTML = renderWakatimeCard({}, { locale: "cn" }); 40 | expect(document.getElementsByClassName("header")[0].textContent).toBe( 41 | "Wakatime 周统计", 42 | ); 43 | expect( 44 | document.querySelector('g[transform="translate(0, 0)"]>text.stat.bold') 45 | .textContent, 46 | ).toBe("本周没有编程活动"); 47 | }); 48 | 49 | it("should render without rounding", () => { 50 | document.body.innerHTML = renderWakatimeCard(wakaTimeData.data, { 51 | border_radius: "0", 52 | }); 53 | expect(document.querySelector("rect")).toHaveAttribute("rx", "0"); 54 | document.body.innerHTML = renderWakatimeCard(wakaTimeData.data, {}); 55 | expect(document.querySelector("rect")).toHaveAttribute("rx", "4.5"); 56 | }); 57 | 58 | it('should show "no coding activitiy this week" message when there hasn not been activity', () => { 59 | document.body.innerHTML = renderWakatimeCard( 60 | { 61 | ...wakaTimeData.data, 62 | languages: undefined, 63 | }, 64 | {}, 65 | ); 66 | expect(document.querySelector(".stat").textContent).toBe( 67 | "No coding activity this week", 68 | ); 69 | }); 70 | }); 71 | -------------------------------------------------------------------------------- /src/common/icons.js: -------------------------------------------------------------------------------- 1 | const icons = { 2 | star: ``, 3 | commits: ``, 4 | prs: ``, 5 | issues: ``, 6 | icon: ``, 7 | contribs: ``, 8 | fork: ``, 9 | }; 10 | 11 | export { icons }; 12 | export default icons; 13 | -------------------------------------------------------------------------------- /src/fetchers/types.d.ts: -------------------------------------------------------------------------------- 1 | export type RepositoryData = { 2 | name: string; 3 | nameWithOwner: string; 4 | isPrivate: boolean; 5 | isArchived: boolean; 6 | isTemplate: boolean; 7 | stargazers: { totalCount: number }; 8 | description: string; 9 | primaryLanguage: { 10 | color: string; 11 | id: string; 12 | name: string; 13 | }; 14 | forkCount: number; 15 | starCount: number; 16 | }; 17 | 18 | export type StatsData = { 19 | name: string; 20 | totalPRs: number; 21 | totalCommits: number; 22 | totalIssues: number; 23 | totalStars: number; 24 | contributedTo: number; 25 | rank: { level: string; score: number }; 26 | }; 27 | 28 | export type Lang = { 29 | name: string; 30 | color: string; 31 | size: number; 32 | }; 33 | 34 | export type TopLangData = Record; 35 | 36 | export type WakaTimeData = { 37 | categories: { 38 | digital: string; 39 | hours: number; 40 | minutes: number; 41 | name: string; 42 | percent: number; 43 | text: string; 44 | total_seconds: number; 45 | }[]; 46 | daily_average: number; 47 | daily_average_including_other_language: number; 48 | days_including_holidays: number; 49 | days_minus_holidays: number; 50 | editors: { 51 | digital: string; 52 | hours: number; 53 | minutes: number; 54 | name: string; 55 | percent: number; 56 | text: string; 57 | total_seconds: number; 58 | }[]; 59 | holidays: number; 60 | human_readable_daily_average: string; 61 | human_readable_daily_average_including_other_language: string; 62 | human_readable_total: string; 63 | human_readable_total_including_other_language: string; 64 | id: string; 65 | is_already_updating: boolean; 66 | is_coding_activity_visible: boolean; 67 | is_including_today: boolean; 68 | is_other_usage_visible: boolean; 69 | is_stuck: boolean; 70 | is_up_to_date: boolean; 71 | languages: { 72 | digital: string; 73 | hours: number; 74 | minutes: number; 75 | name: string; 76 | percent: number; 77 | text: string; 78 | total_seconds: number; 79 | }[]; 80 | operating_systems: { 81 | digital: string; 82 | hours: number; 83 | minutes: number; 84 | name: string; 85 | percent: number; 86 | text: string; 87 | total_seconds: number; 88 | }[]; 89 | percent_calculated: number; 90 | range: string; 91 | status: string; 92 | timeout: number; 93 | total_seconds: number; 94 | total_seconds_including_other_language: number; 95 | user_id: string; 96 | username: string; 97 | writes_only: boolean; 98 | }; 99 | 100 | export type WakaTimeLang = { 101 | name: string; 102 | text: string; 103 | percent: number; 104 | }; 105 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.yml: -------------------------------------------------------------------------------- 1 | name: Bug report 2 | description: Create a report to help us improve. 3 | labels: 4 | - "bug" 5 | body: 6 | - type: markdown 7 | attributes: 8 | value: | 9 | :warning: PLEASE FIRST READ THE FAQ [(#1770)](https://github.com/anuraghazra/github-readme-stats/discussions/1770) AND COMMON ERROR CODES [(#1772)](https://github.com/anuraghazra/github-readme-stats/issues/1772)!!! 10 | - type: textarea 11 | attributes: 12 | label: Describe the bug 13 | description: A clear and concise description of what the bug is. 14 | validations: 15 | required: true 16 | - type: textarea 17 | attributes: 18 | label: Expected behaviour 19 | description: 20 | A clear and concise description of what you expected to happen. 21 | - type: textarea 22 | attributes: 23 | label: Screenshots / Live demo link 24 | description: If applicable, add screenshots to help explain your problem. 25 | placeholder: Paste the github-readme-stats link as markdown image 26 | - type: textarea 27 | attributes: 28 | label: Additional context 29 | description: Add any other context about the problem here. 30 | - type: markdown 31 | attributes: 32 | value: | 33 | --- 34 | ### FAQ (Snippet) 35 | 36 | Below are some questions that are found in the FAQ. The full FAQ can be found in [#1770](https://github.com/anuraghazra/github-readme-stats/discussions/1770). 37 | 38 | #### Q: My card displays an error 39 | 40 | **Ans:** First, check the common error codes (i.e. https://github.com/anuraghazra/github-readme-stats/issues/1772) and existing issues before creating a new one. 41 | 42 | #### Q: How to hide jupyter Notebook? 43 | 44 | **Ans:** `&hide=jupyter%20notebook`. 45 | 46 | #### Q: I could not figure out how to deploy on my own vercel instance 47 | 48 | **Ans:** Please check: 49 | - Docs: https://github.com/anuraghazra/github-readme-stats/#deploy-on-your-own-vercel-instance 50 | - YT tutorial by codeSTACKr: https://www.youtube.com/watch?v=n6d4KHSKqGk&feature=youtu.be&t=107 51 | 52 | #### Q: Language Card is incorrect 53 | 54 | **Ans:** Please read these issues/comments before opening any issues regarding language card stats: 55 | - https://github.com/anuraghazra/github-readme-stats/issues/136#issuecomment-665164174 56 | - https://github.com/anuraghazra/github-readme-stats/issues/136#issuecomment-665172181 57 | 58 | #### Q: How to count private stats? 59 | 60 | **Ans:** We can only count private commits & we cannot access any other private info of any users, so it's impossible. The only way is to deploy on your own instance & use your own PAT (Personal Access Token). 61 | -------------------------------------------------------------------------------- /api/index.js: -------------------------------------------------------------------------------- 1 | import * as dotenv from "dotenv"; 2 | import { renderStatsCard } from "../src/cards/stats-card.js"; 3 | import { blacklist } from "../src/common/blacklist.js"; 4 | import { 5 | clampValue, 6 | CONSTANTS, 7 | parseArray, 8 | parseBoolean, 9 | renderError, 10 | } from "../src/common/utils.js"; 11 | import { fetchStats } from "../src/fetchers/stats-fetcher.js"; 12 | import { isLocaleAvailable } from "../src/translations.js"; 13 | 14 | dotenv.config(); 15 | 16 | export default async (req, res) => { 17 | const { 18 | username, 19 | hide, 20 | hide_title, 21 | hide_border, 22 | card_width, 23 | hide_rank, 24 | show_icons, 25 | count_private, 26 | include_all_commits, 27 | line_height, 28 | title_color, 29 | icon_color, 30 | text_color, 31 | text_bold, 32 | bg_color, 33 | theme, 34 | cache_seconds, 35 | exclude_repo, 36 | custom_title, 37 | locale, 38 | disable_animations, 39 | border_radius, 40 | border_color, 41 | } = req.query; 42 | res.setHeader("Content-Type", "image/svg+xml"); 43 | 44 | if (blacklist.includes(username)) { 45 | return res.send(renderError("Something went wrong")); 46 | } 47 | 48 | if (locale && !isLocaleAvailable(locale)) { 49 | return res.send(renderError("Something went wrong", "Language not found")); 50 | } 51 | 52 | try { 53 | const stats = await fetchStats( 54 | username, 55 | parseBoolean(count_private), 56 | parseBoolean(include_all_commits), 57 | parseArray(exclude_repo), 58 | ); 59 | 60 | const cacheSeconds = clampValue( 61 | parseInt(cache_seconds || CONSTANTS.FOUR_HOURS, 10), 62 | CONSTANTS.FOUR_HOURS, 63 | CONSTANTS.ONE_DAY, 64 | ); 65 | 66 | res.setHeader("Cache-Control", `public, max-age=${cacheSeconds}`); 67 | 68 | return res.send( 69 | renderStatsCard(stats, { 70 | hide: parseArray(hide), 71 | show_icons: parseBoolean(show_icons), 72 | hide_title: parseBoolean(hide_title), 73 | hide_border: parseBoolean(hide_border), 74 | card_width: parseInt(card_width, 10), 75 | hide_rank: parseBoolean(hide_rank), 76 | include_all_commits: parseBoolean(include_all_commits), 77 | line_height, 78 | title_color, 79 | icon_color, 80 | text_color, 81 | text_bold: parseBoolean(text_bold), 82 | bg_color, 83 | theme, 84 | custom_title, 85 | border_radius, 86 | border_color, 87 | locale: locale ? locale.toLowerCase() : null, 88 | disable_animations: parseBoolean(disable_animations), 89 | }), 90 | ); 91 | } catch (err) { 92 | res.setHeader("Cache-Control", `no-cache, no-store, must-revalidate`); // Don't cache error responses. 93 | return res.send(renderError(err.message, err.secondaryMessage)); 94 | } 95 | }; 96 | -------------------------------------------------------------------------------- /tests/fetchTopLanguages.test.js: -------------------------------------------------------------------------------- 1 | import "@testing-library/jest-dom"; 2 | import axios from "axios"; 3 | import MockAdapter from "axios-mock-adapter"; 4 | import { fetchTopLanguages } from "../src/fetchers/top-languages-fetcher.js"; 5 | 6 | const mock = new MockAdapter(axios); 7 | 8 | afterEach(() => { 9 | mock.reset(); 10 | }); 11 | 12 | const data_langs = { 13 | data: { 14 | user: { 15 | repositories: { 16 | nodes: [ 17 | { 18 | name: "test-repo-1", 19 | languages: { 20 | edges: [{ size: 100, node: { color: "#0f0", name: "HTML" } }], 21 | }, 22 | }, 23 | { 24 | name: "test-repo-2", 25 | languages: { 26 | edges: [{ size: 100, node: { color: "#0f0", name: "HTML" } }], 27 | }, 28 | }, 29 | { 30 | name: "test-repo-3", 31 | languages: { 32 | edges: [ 33 | { size: 100, node: { color: "#0ff", name: "javascript" } }, 34 | ], 35 | }, 36 | }, 37 | { 38 | name: "test-repo-4", 39 | languages: { 40 | edges: [ 41 | { size: 100, node: { color: "#0ff", name: "javascript" } }, 42 | ], 43 | }, 44 | }, 45 | ], 46 | }, 47 | }, 48 | }, 49 | }; 50 | 51 | const error = { 52 | errors: [ 53 | { 54 | type: "NOT_FOUND", 55 | path: ["user"], 56 | locations: [], 57 | message: "Could not resolve to a User with the login of 'noname'.", 58 | }, 59 | ], 60 | }; 61 | 62 | describe("FetchTopLanguages", () => { 63 | it("should fetch correct language data", async () => { 64 | mock.onPost("https://api.github.com/graphql").reply(200, data_langs); 65 | 66 | let repo = await fetchTopLanguages("anuraghazra"); 67 | expect(repo).toStrictEqual({ 68 | HTML: { 69 | color: "#0f0", 70 | name: "HTML", 71 | size: 200, 72 | }, 73 | javascript: { 74 | color: "#0ff", 75 | name: "javascript", 76 | size: 200, 77 | }, 78 | }); 79 | }); 80 | 81 | it("should fetch correct language data while excluding the 'test-repo-1' repository", async () => { 82 | mock.onPost("https://api.github.com/graphql").reply(200, data_langs); 83 | 84 | let repo = await fetchTopLanguages("anuraghazra", ["test-repo-1"]); 85 | expect(repo).toStrictEqual({ 86 | HTML: { 87 | color: "#0f0", 88 | name: "HTML", 89 | size: 100, 90 | }, 91 | javascript: { 92 | color: "#0ff", 93 | name: "javascript", 94 | size: 200, 95 | }, 96 | }); 97 | }); 98 | 99 | it("should throw error", async () => { 100 | mock.onPost("https://api.github.com/graphql").reply(200, error); 101 | 102 | await expect(fetchTopLanguages("anuraghazra")).rejects.toThrow( 103 | "Could not resolve to a User with the login of 'noname'.", 104 | ); 105 | }); 106 | }); 107 | -------------------------------------------------------------------------------- /src/fetchers/repo-fetcher.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | import { retryer } from "../common/retryer.js"; 3 | import { MissingParamError, request } from "../common/utils.js"; 4 | 5 | /** 6 | * Repo data fetcher. 7 | * 8 | * @param {import('Axios').AxiosRequestHeaders} variables Fetcher variables. 9 | * @param {string} token GitHub token. 10 | * @returns {Promise} The response. 11 | */ 12 | const fetcher = (variables, token) => { 13 | return request( 14 | { 15 | query: ` 16 | fragment RepoInfo on Repository { 17 | name 18 | nameWithOwner 19 | isPrivate 20 | isArchived 21 | isTemplate 22 | stargazers { 23 | totalCount 24 | } 25 | description 26 | primaryLanguage { 27 | color 28 | id 29 | name 30 | } 31 | forkCount 32 | } 33 | query getRepo($login: String!, $repo: String!) { 34 | user(login: $login) { 35 | repository(name: $repo) { 36 | ...RepoInfo 37 | } 38 | } 39 | organization(login: $login) { 40 | repository(name: $repo) { 41 | ...RepoInfo 42 | } 43 | } 44 | } 45 | `, 46 | variables, 47 | }, 48 | { 49 | Authorization: `token ${token}`, 50 | }, 51 | ); 52 | }; 53 | 54 | const urlExample = "/api/pin?username=USERNAME&repo=REPO_NAME"; 55 | 56 | /** 57 | * Fetch repository data. 58 | * 59 | * @param {string} username GitHub username. 60 | * @param {string} reponame GitHub repository name. 61 | * @returns {Promise} Repository data. 62 | */ 63 | async function fetchRepo(username, reponame) { 64 | if (!username && !reponame) { 65 | throw new MissingParamError(["username", "repo"], urlExample); 66 | } 67 | if (!username) throw new MissingParamError(["username"], urlExample); 68 | if (!reponame) throw new MissingParamError(["repo"], urlExample); 69 | 70 | let res = await retryer(fetcher, { login: username, repo: reponame }); 71 | 72 | const data = res.data.data; 73 | 74 | if (!data.user && !data.organization) { 75 | throw new Error("Not found"); 76 | } 77 | 78 | const isUser = data.organization === null && data.user; 79 | const isOrg = data.user === null && data.organization; 80 | 81 | if (isUser) { 82 | if (!data.user.repository || data.user.repository.isPrivate) { 83 | throw new Error("User Repository Not found"); 84 | } 85 | return { 86 | ...data.user.repository, 87 | starCount: data.user.repository.stargazers.totalCount, 88 | }; 89 | } 90 | 91 | if (isOrg) { 92 | if ( 93 | !data.organization.repository || 94 | data.organization.repository.isPrivate 95 | ) { 96 | throw new Error("Organization Repository Not found"); 97 | } 98 | return { 99 | ...data.organization.repository, 100 | starCount: data.organization.repository.stargazers.totalCount, 101 | }; 102 | } 103 | } 104 | 105 | export { fetchRepo }; 106 | export default fetchRepo; 107 | -------------------------------------------------------------------------------- /src/calculateRank.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Calculates the probability of x taking on x or a value less than x in a normal distribution 3 | * with mean and standard deviation. 4 | * 5 | * @see https://stackoverflow.com/a/5263759/10629172 6 | * 7 | * @param {string} mean The mean of the normal distribution. 8 | * @param {number} sigma The standard deviation of the normal distribution. 9 | * @param {number} to The value to calculate the probability for. 10 | * @returns {number} Probability. 11 | */ 12 | function normalcdf(mean, sigma, to) { 13 | var z = (to - mean) / Math.sqrt(2 * sigma * sigma); 14 | var t = 1 / (1 + 0.3275911 * Math.abs(z)); 15 | var a1 = 0.254829592; 16 | var a2 = -0.284496736; 17 | var a3 = 1.421413741; 18 | var a4 = -1.453152027; 19 | var a5 = 1.061405429; 20 | var erf = 21 | 1 - ((((a5 * t + a4) * t + a3) * t + a2) * t + a1) * t * Math.exp(-z * z); 22 | var sign = 1; 23 | if (z < 0) { 24 | sign = -1; 25 | } 26 | return (1 / 2) * (1 + sign * erf); 27 | } 28 | 29 | /** 30 | * Calculates the users rank. 31 | * 32 | * @param {number} totalRepos Total number of repos. 33 | * @param {number} totalCommits Total number of commits. 34 | * @param {number} contributions The number of contributions. 35 | * @param {number} followers The number of followers. 36 | * @param {number} prs The number of pull requests. 37 | * @param {number} issues The number of issues. 38 | * @param {number} stargazers The number of stars. 39 | * @returns {{level: string, score: number}}} The users rank. 40 | */ 41 | function calculateRank({ 42 | totalRepos, 43 | totalCommits, 44 | contributions, 45 | followers, 46 | prs, 47 | issues, 48 | stargazers, 49 | }) { 50 | const COMMITS_OFFSET = 1.65; 51 | const CONTRIBS_OFFSET = 1.65; 52 | const ISSUES_OFFSET = 1; 53 | const STARS_OFFSET = 0.75; 54 | const PRS_OFFSET = 0.5; 55 | const FOLLOWERS_OFFSET = 0.45; 56 | const REPO_OFFSET = 1; 57 | 58 | const ALL_OFFSETS = 59 | CONTRIBS_OFFSET + 60 | ISSUES_OFFSET + 61 | STARS_OFFSET + 62 | PRS_OFFSET + 63 | FOLLOWERS_OFFSET + 64 | REPO_OFFSET; 65 | 66 | const RANK_S_VALUE = 1; 67 | const RANK_DOUBLE_A_VALUE = 25; 68 | const RANK_A2_VALUE = 45; 69 | const RANK_A3_VALUE = 60; 70 | const RANK_B_VALUE = 100; 71 | 72 | const TOTAL_VALUES = 73 | RANK_S_VALUE + RANK_A2_VALUE + RANK_A3_VALUE + RANK_B_VALUE; 74 | 75 | // prettier-ignore 76 | const score = ( 77 | totalCommits * COMMITS_OFFSET + 78 | contributions * CONTRIBS_OFFSET + 79 | issues * ISSUES_OFFSET + 80 | stargazers * STARS_OFFSET + 81 | prs * PRS_OFFSET + 82 | followers * FOLLOWERS_OFFSET + 83 | totalRepos * REPO_OFFSET 84 | ) / 100; 85 | 86 | const normalizedScore = normalcdf(score, TOTAL_VALUES, ALL_OFFSETS) * 100; 87 | 88 | const level = (() => { 89 | if (normalizedScore < RANK_S_VALUE) return "S+"; 90 | if (normalizedScore < RANK_DOUBLE_A_VALUE) return "S"; 91 | if (normalizedScore < RANK_A2_VALUE) return "A++"; 92 | if (normalizedScore < RANK_A3_VALUE) return "A+"; 93 | return "B+"; 94 | })(); 95 | 96 | return { level, score: normalizedScore }; 97 | } 98 | 99 | export { calculateRank }; 100 | export default calculateRank; 101 | -------------------------------------------------------------------------------- /tests/fetchRepo.test.js: -------------------------------------------------------------------------------- 1 | import "@testing-library/jest-dom"; 2 | import axios from "axios"; 3 | import MockAdapter from "axios-mock-adapter"; 4 | import { fetchRepo } from "../src/fetchers/repo-fetcher.js"; 5 | 6 | const data_repo = { 7 | repository: { 8 | name: "convoychat", 9 | stargazers: { totalCount: 38000 }, 10 | description: "Help us take over the world! React + TS + GraphQL Chat App", 11 | primaryLanguage: { 12 | color: "#2b7489", 13 | id: "MDg6TGFuZ3VhZ2UyODc=", 14 | name: "TypeScript", 15 | }, 16 | forkCount: 100, 17 | }, 18 | }; 19 | 20 | const data_user = { 21 | data: { 22 | user: { repository: data_repo.repository }, 23 | organization: null, 24 | }, 25 | }; 26 | 27 | const data_org = { 28 | data: { 29 | user: null, 30 | organization: { repository: data_repo.repository }, 31 | }, 32 | }; 33 | 34 | const mock = new MockAdapter(axios); 35 | 36 | afterEach(() => { 37 | mock.reset(); 38 | }); 39 | 40 | describe("Test fetchRepo", () => { 41 | it("should fetch correct user repo", async () => { 42 | mock.onPost("https://api.github.com/graphql").reply(200, data_user); 43 | 44 | let repo = await fetchRepo("anuraghazra", "convoychat"); 45 | 46 | expect(repo).toStrictEqual({ 47 | ...data_repo.repository, 48 | starCount: data_repo.repository.stargazers.totalCount, 49 | }); 50 | }); 51 | 52 | it("should fetch correct org repo", async () => { 53 | mock.onPost("https://api.github.com/graphql").reply(200, data_org); 54 | 55 | let repo = await fetchRepo("anuraghazra", "convoychat"); 56 | expect(repo).toStrictEqual({ 57 | ...data_repo.repository, 58 | starCount: data_repo.repository.stargazers.totalCount, 59 | }); 60 | }); 61 | 62 | it("should throw error if user is found but repo is null", async () => { 63 | mock 64 | .onPost("https://api.github.com/graphql") 65 | .reply(200, { data: { user: { repository: null }, organization: null } }); 66 | 67 | await expect(fetchRepo("anuraghazra", "convoychat")).rejects.toThrow( 68 | "User Repository Not found", 69 | ); 70 | }); 71 | 72 | it("should throw error if org is found but repo is null", async () => { 73 | mock 74 | .onPost("https://api.github.com/graphql") 75 | .reply(200, { data: { user: null, organization: { repository: null } } }); 76 | 77 | await expect(fetchRepo("anuraghazra", "convoychat")).rejects.toThrow( 78 | "Organization Repository Not found", 79 | ); 80 | }); 81 | 82 | it("should throw error if both user & org data not found", async () => { 83 | mock 84 | .onPost("https://api.github.com/graphql") 85 | .reply(200, { data: { user: null, organization: null } }); 86 | 87 | await expect(fetchRepo("anuraghazra", "convoychat")).rejects.toThrow( 88 | "Not found", 89 | ); 90 | }); 91 | 92 | it("should throw error if repository is private", async () => { 93 | mock.onPost("https://api.github.com/graphql").reply(200, { 94 | data: { 95 | user: { repository: { ...data_repo, isPrivate: true } }, 96 | organization: null, 97 | }, 98 | }); 99 | 100 | await expect(fetchRepo("anuraghazra", "convoychat")).rejects.toThrow( 101 | "User Repository Not found", 102 | ); 103 | }); 104 | }); 105 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /src/getStyles.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | /** 3 | * Calculates progress along the boundary of the circle i.e it's circumference. 4 | * 5 | * @param {number} value The rank value to calculate progress for. 6 | * @returns {number} Progress value. 7 | */ 8 | const calculateCircleProgress = (value) => { 9 | const radius = 40; 10 | const c = Math.PI * (radius * 2); 11 | 12 | if (value < 0) value = 0; 13 | if (value > 100) value = 100; 14 | 15 | return ((100 - value) / 100) * c; 16 | }; 17 | 18 | /** 19 | * Retrieves the animation to display progress along the circumference of circle 20 | * from the beginning to the given value in a clockwise direction. 21 | * 22 | * @param {{progress: number}} progress The progress value to animate to. 23 | * @returns {string} Progress animation css. 24 | */ 25 | const getProgressAnimation = ({ progress }) => { 26 | return ` 27 | @keyframes rankAnimation { 28 | from { 29 | stroke-dashoffset: ${calculateCircleProgress(0)}; 30 | } 31 | to { 32 | stroke-dashoffset: ${calculateCircleProgress(progress)}; 33 | } 34 | } 35 | `; 36 | }; 37 | 38 | /** 39 | * Retrieves css animations for a card. 40 | * 41 | * @returns {string} Animation css. 42 | */ 43 | const getAnimations = () => { 44 | return ` 45 | /* Animations */ 46 | @keyframes scaleInAnimation { 47 | from { 48 | transform: translate(-5px, 5px) scale(0); 49 | } 50 | to { 51 | transform: translate(-5px, 5px) scale(1); 52 | } 53 | } 54 | @keyframes fadeInAnimation { 55 | from { 56 | opacity: 0; 57 | } 58 | to { 59 | opacity: 1; 60 | } 61 | } 62 | `; 63 | }; 64 | 65 | /** 66 | * Retrieves CSS styles for a card. 67 | * 68 | * @param {Object[]} colors The colors to use for the card. 69 | * @param {string} colors.titleColor The title color. 70 | * @param {string} colors.textColor The text color. 71 | * @param {string} colors.iconColor The icon color. 72 | * @param {boolean} colors.show_icons Whether to show icons. 73 | * @param {number} colors.progress The progress value to animate to. 74 | * @returns {string} Card CSS styles. 75 | */ 76 | const getStyles = ({ 77 | titleColor, 78 | textColor, 79 | iconColor, 80 | show_icons, 81 | progress, 82 | }) => { 83 | return ` 84 | .stat { 85 | font: 600 14px 'Segoe UI', Ubuntu, "Helvetica Neue", Sans-Serif; fill: ${textColor}; 86 | } 87 | @supports(-moz-appearance: auto) { 88 | /* Selector detects Firefox */ 89 | .stat { font-size:12px; } 90 | } 91 | .stagger { 92 | opacity: 0; 93 | animation: fadeInAnimation 0.3s ease-in-out forwards; 94 | } 95 | .rank-text { 96 | font: 800 24px 'Segoe UI', Ubuntu, Sans-Serif; fill: ${textColor}; 97 | animation: scaleInAnimation 0.3s ease-in-out forwards; 98 | } 99 | 100 | .not_bold { font-weight: 400 } 101 | .bold { font-weight: 700 } 102 | .icon { 103 | fill: ${iconColor}; 104 | display: ${!!show_icons ? "block" : "none"}; 105 | } 106 | 107 | .rank-circle-rim { 108 | stroke: ${titleColor}; 109 | fill: none; 110 | stroke-width: 6; 111 | opacity: 0.2; 112 | } 113 | .rank-circle { 114 | stroke: ${titleColor}; 115 | stroke-dasharray: 250; 116 | fill: none; 117 | stroke-width: 6; 118 | stroke-linecap: round; 119 | opacity: 0.8; 120 | transform-origin: -10px 8px; 121 | transform: rotate(-90deg); 122 | animation: rankAnimation 1s forwards ease-in-out; 123 | } 124 | ${process.env.NODE_ENV === "test" ? "" : getProgressAnimation({ progress })} 125 | `; 126 | }; 127 | 128 | export { getStyles, getAnimations }; 129 | -------------------------------------------------------------------------------- /tests/top-langs.test.js: -------------------------------------------------------------------------------- 1 | import { jest } from "@jest/globals"; 2 | import "@testing-library/jest-dom"; 3 | import axios from "axios"; 4 | import MockAdapter from "axios-mock-adapter"; 5 | import topLangs from "../api/top-langs.js"; 6 | import { renderTopLanguages } from "../src/cards/top-languages-card.js"; 7 | import { renderError } from "../src/common/utils.js"; 8 | 9 | const data_langs = { 10 | data: { 11 | user: { 12 | repositories: { 13 | nodes: [ 14 | { 15 | languages: { 16 | edges: [{ size: 150, node: { color: "#0f0", name: "HTML" } }], 17 | }, 18 | }, 19 | { 20 | languages: { 21 | edges: [{ size: 100, node: { color: "#0f0", name: "HTML" } }], 22 | }, 23 | }, 24 | { 25 | languages: { 26 | edges: [ 27 | { size: 100, node: { color: "#0ff", name: "javascript" } }, 28 | ], 29 | }, 30 | }, 31 | { 32 | languages: { 33 | edges: [ 34 | { size: 100, node: { color: "#0ff", name: "javascript" } }, 35 | ], 36 | }, 37 | }, 38 | ], 39 | }, 40 | }, 41 | }, 42 | }; 43 | 44 | const error = { 45 | errors: [ 46 | { 47 | type: "NOT_FOUND", 48 | path: ["user"], 49 | locations: [], 50 | message: "Could not fetch user", 51 | }, 52 | ], 53 | }; 54 | 55 | const langs = { 56 | HTML: { 57 | color: "#0f0", 58 | name: "HTML", 59 | size: 250, 60 | }, 61 | javascript: { 62 | color: "#0ff", 63 | name: "javascript", 64 | size: 200, 65 | }, 66 | }; 67 | 68 | const mock = new MockAdapter(axios); 69 | 70 | afterEach(() => { 71 | mock.reset(); 72 | }); 73 | 74 | describe("Test /api/top-langs", () => { 75 | it("should test the request", async () => { 76 | const req = { 77 | query: { 78 | username: "anuraghazra", 79 | }, 80 | }; 81 | const res = { 82 | setHeader: jest.fn(), 83 | send: jest.fn(), 84 | }; 85 | mock.onPost("https://api.github.com/graphql").reply(200, data_langs); 86 | 87 | await topLangs(req, res); 88 | 89 | expect(res.setHeader).toBeCalledWith("Content-Type", "image/svg+xml"); 90 | expect(res.send).toBeCalledWith(renderTopLanguages(langs)); 91 | }); 92 | 93 | it("should work with the query options", async () => { 94 | const req = { 95 | query: { 96 | username: "anuraghazra", 97 | hide_title: true, 98 | card_width: 100, 99 | title_color: "fff", 100 | icon_color: "fff", 101 | text_color: "fff", 102 | bg_color: "fff", 103 | }, 104 | }; 105 | const res = { 106 | setHeader: jest.fn(), 107 | send: jest.fn(), 108 | }; 109 | mock.onPost("https://api.github.com/graphql").reply(200, data_langs); 110 | 111 | await topLangs(req, res); 112 | 113 | expect(res.setHeader).toBeCalledWith("Content-Type", "image/svg+xml"); 114 | expect(res.send).toBeCalledWith( 115 | renderTopLanguages(langs, { 116 | hide_title: true, 117 | card_width: 100, 118 | title_color: "fff", 119 | icon_color: "fff", 120 | text_color: "fff", 121 | bg_color: "fff", 122 | }), 123 | ); 124 | }); 125 | 126 | it("should render error card on error", async () => { 127 | const req = { 128 | query: { 129 | username: "anuraghazra", 130 | }, 131 | }; 132 | const res = { 133 | setHeader: jest.fn(), 134 | send: jest.fn(), 135 | }; 136 | mock.onPost("https://api.github.com/graphql").reply(200, error); 137 | 138 | await topLangs(req, res); 139 | 140 | expect(res.setHeader).toBeCalledWith("Content-Type", "image/svg+xml"); 141 | expect(res.send).toBeCalledWith(renderError(error.errors[0].message)); 142 | }); 143 | }); 144 | -------------------------------------------------------------------------------- /scripts/generate-theme-doc.js: -------------------------------------------------------------------------------- 1 | import fs from "fs"; 2 | import { themes } from "../themes/index.js"; 3 | 4 | const TARGET_FILE = "./themes/README.md"; 5 | const REPO_CARD_LINKS_FLAG = ""; 6 | const STAT_CARD_LINKS_FLAG = ""; 7 | 8 | const STAT_CARD_TABLE_FLAG = ""; 9 | const REPO_CARD_TABLE_FLAG = ""; 10 | 11 | const THEME_TEMPLATE = `## Available Themes 12 | 13 | 14 | 15 | With inbuilt themes, you can customize the look of the card without doing any manual customization. 16 | 17 | Use \`?theme=THEME_NAME\` parameter like so :- 18 | 19 | \`\`\`md 20 | ![Anurag's GitHub stats](https://github-readme-stats.vercel.app/api?username=anuraghazra&theme=dark&show_icons=true) 21 | \`\`\` 22 | 23 | ## Stats 24 | 25 | > These themes work both for the Stats Card and Repo Card. 26 | 27 | | | | | 28 | | :--: | :--: | :--: | 29 | ${STAT_CARD_TABLE_FLAG} 30 | 31 | ## Repo Card 32 | 33 | > These themes work both for the Stats Card and Repo Card. 34 | 35 | | | | | 36 | | :--: | :--: | :--: | 37 | ${REPO_CARD_TABLE_FLAG} 38 | 39 | ${STAT_CARD_LINKS_FLAG} 40 | 41 | ${REPO_CARD_LINKS_FLAG} 42 | 43 | 44 | [add-theme]: https://github.com/anuraghazra/github-readme-stats/edit/master/themes/index.js 45 | 46 | Want to add a new theme? Consider reading the [contribution guidelines](../CONTRIBUTING.md#themes-contribution) :D 47 | `; 48 | 49 | const createRepoMdLink = (theme) => { 50 | return `\n[${theme}_repo]: https://github-readme-stats.vercel.app/api/pin/?username=anuraghazra&repo=github-readme-stats&cache_seconds=86400&theme=${theme}`; 51 | }; 52 | const createStatMdLink = (theme) => { 53 | return `\n[${theme}]: https://github-readme-stats.vercel.app/api?username=anuraghazra&show_icons=true&hide=contribs,prs&cache_seconds=86400&theme=${theme}`; 54 | }; 55 | 56 | const generateLinks = (fn) => { 57 | return Object.keys(themes) 58 | .map((name) => fn(name)) 59 | .join(""); 60 | }; 61 | 62 | const createTableItem = ({ link, label, isRepoCard }) => { 63 | if (!link || !label) return ""; 64 | return `\`${label}\` ![${link}][${link}${isRepoCard ? "_repo" : ""}]`; 65 | }; 66 | const generateTable = ({ isRepoCard }) => { 67 | const rows = []; 68 | const themesFiltered = Object.keys(themes).filter( 69 | (name) => name !== (!isRepoCard ? "default_repocard" : "default"), 70 | ); 71 | 72 | for (let i = 0; i < themesFiltered.length; i += 3) { 73 | const one = themesFiltered[i]; 74 | const two = themesFiltered[i + 1]; 75 | const three = themesFiltered[i + 2]; 76 | 77 | let tableItem1 = createTableItem({ link: one, label: one, isRepoCard }); 78 | let tableItem2 = createTableItem({ link: two, label: two, isRepoCard }); 79 | let tableItem3 = createTableItem({ link: three, label: three, isRepoCard }); 80 | 81 | if (three === undefined) { 82 | tableItem3 = `[Add your theme][add-theme]`; 83 | } 84 | rows.push(`| ${tableItem1} | ${tableItem2} | ${tableItem3} |`); 85 | 86 | // if it's the last row & the row has no empty space push a new row 87 | if (three && i + 3 === themesFiltered.length) { 88 | rows.push(`| [Add your theme][add-theme] | | |`); 89 | } 90 | } 91 | 92 | return rows.join("\n"); 93 | }; 94 | 95 | const buildReadme = () => { 96 | return THEME_TEMPLATE.split("\n") 97 | .map((line) => { 98 | if (line.includes(REPO_CARD_LINKS_FLAG)) { 99 | return generateLinks(createRepoMdLink); 100 | } 101 | if (line.includes(STAT_CARD_LINKS_FLAG)) { 102 | return generateLinks(createStatMdLink); 103 | } 104 | if (line.includes(REPO_CARD_TABLE_FLAG)) { 105 | return generateTable({ isRepoCard: true }); 106 | } 107 | if (line.includes(STAT_CARD_TABLE_FLAG)) { 108 | return generateTable({ isRepoCard: false }); 109 | } 110 | return line; 111 | }) 112 | .join("\n"); 113 | }; 114 | 115 | fs.writeFileSync(TARGET_FILE, buildReadme()); 116 | -------------------------------------------------------------------------------- /tests/pin.test.js: -------------------------------------------------------------------------------- 1 | import { jest } from "@jest/globals"; 2 | import "@testing-library/jest-dom"; 3 | import axios from "axios"; 4 | import MockAdapter from "axios-mock-adapter"; 5 | import pin from "../api/pin.js"; 6 | import { renderRepoCard } from "../src/cards/repo-card.js"; 7 | import { renderError } from "../src/common/utils.js"; 8 | 9 | const data_repo = { 10 | repository: { 11 | username: "anuraghazra", 12 | name: "convoychat", 13 | stargazers: { 14 | totalCount: 38000, 15 | }, 16 | description: "Help us take over the world! React + TS + GraphQL Chat App", 17 | primaryLanguage: { 18 | color: "#2b7489", 19 | id: "MDg6TGFuZ3VhZ2UyODc=", 20 | name: "TypeScript", 21 | }, 22 | forkCount: 100, 23 | isTemplate: false, 24 | }, 25 | }; 26 | 27 | const data_user = { 28 | data: { 29 | user: { repository: data_repo.repository }, 30 | organization: null, 31 | }, 32 | }; 33 | 34 | const mock = new MockAdapter(axios); 35 | 36 | afterEach(() => { 37 | mock.reset(); 38 | }); 39 | 40 | describe("Test /api/pin", () => { 41 | it("should test the request", async () => { 42 | const req = { 43 | query: { 44 | username: "anuraghazra", 45 | repo: "convoychat", 46 | }, 47 | }; 48 | const res = { 49 | setHeader: jest.fn(), 50 | send: jest.fn(), 51 | }; 52 | mock.onPost("https://api.github.com/graphql").reply(200, data_user); 53 | 54 | await pin(req, res); 55 | 56 | expect(res.setHeader).toBeCalledWith("Content-Type", "image/svg+xml"); 57 | expect(res.send).toBeCalledWith( 58 | renderRepoCard({ 59 | ...data_repo.repository, 60 | starCount: data_repo.repository.stargazers.totalCount, 61 | }), 62 | ); 63 | }); 64 | 65 | it("should get the query options", async () => { 66 | const req = { 67 | query: { 68 | username: "anuraghazra", 69 | repo: "convoychat", 70 | title_color: "fff", 71 | icon_color: "fff", 72 | text_color: "fff", 73 | bg_color: "fff", 74 | full_name: "1", 75 | }, 76 | }; 77 | const res = { 78 | setHeader: jest.fn(), 79 | send: jest.fn(), 80 | }; 81 | mock.onPost("https://api.github.com/graphql").reply(200, data_user); 82 | 83 | await pin(req, res); 84 | 85 | expect(res.setHeader).toBeCalledWith("Content-Type", "image/svg+xml"); 86 | expect(res.send).toBeCalledWith( 87 | renderRepoCard( 88 | { 89 | ...data_repo.repository, 90 | starCount: data_repo.repository.stargazers.totalCount, 91 | }, 92 | { ...req.query }, 93 | ), 94 | ); 95 | }); 96 | 97 | it("should render error card if user repo not found", async () => { 98 | const req = { 99 | query: { 100 | username: "anuraghazra", 101 | repo: "convoychat", 102 | }, 103 | }; 104 | const res = { 105 | setHeader: jest.fn(), 106 | send: jest.fn(), 107 | }; 108 | mock 109 | .onPost("https://api.github.com/graphql") 110 | .reply(200, { data: { user: { repository: null }, organization: null } }); 111 | 112 | await pin(req, res); 113 | 114 | expect(res.setHeader).toBeCalledWith("Content-Type", "image/svg+xml"); 115 | expect(res.send).toBeCalledWith(renderError("User Repository Not found")); 116 | }); 117 | 118 | it("should render error card if org repo not found", async () => { 119 | const req = { 120 | query: { 121 | username: "anuraghazra", 122 | repo: "convoychat", 123 | }, 124 | }; 125 | const res = { 126 | setHeader: jest.fn(), 127 | send: jest.fn(), 128 | }; 129 | mock 130 | .onPost("https://api.github.com/graphql") 131 | .reply(200, { data: { user: null, organization: { repository: null } } }); 132 | 133 | await pin(req, res); 134 | 135 | expect(res.setHeader).toBeCalledWith("Content-Type", "image/svg+xml"); 136 | expect(res.send).toBeCalledWith( 137 | renderError("Organization Repository Not found"), 138 | ); 139 | }); 140 | }); 141 | -------------------------------------------------------------------------------- /tests/utils.test.js: -------------------------------------------------------------------------------- 1 | import { queryByTestId } from "@testing-library/dom"; 2 | import "@testing-library/jest-dom"; 3 | import { 4 | encodeHTML, 5 | getCardColors, 6 | kFormatter, 7 | renderError, 8 | wrapTextMultiline, 9 | } from "../src/common/utils.js"; 10 | 11 | describe("Test utils.js", () => { 12 | it("should test kFormatter", () => { 13 | expect(kFormatter(1)).toBe(1); 14 | expect(kFormatter(-1)).toBe(-1); 15 | expect(kFormatter(500)).toBe(500); 16 | expect(kFormatter(1000)).toBe("1k"); 17 | expect(kFormatter(10000)).toBe("10k"); 18 | expect(kFormatter(12345)).toBe("12.3k"); 19 | expect(kFormatter(9900000)).toBe("9900k"); 20 | }); 21 | 22 | it("should test encodeHTML", () => { 23 | expect(encodeHTML(`hello world<,.#4^&^@%!))`)).toBe( 24 | "<html>hello world<,.#4^&^@%!))", 25 | ); 26 | }); 27 | 28 | it("should test renderError", () => { 29 | document.body.innerHTML = renderError("Something went wrong"); 30 | expect( 31 | queryByTestId(document.body, "message").children[0], 32 | ).toHaveTextContent(/Something went wrong/gim); 33 | expect( 34 | queryByTestId(document.body, "message").children[1], 35 | ).toBeEmptyDOMElement(2); 36 | 37 | // Secondary message 38 | document.body.innerHTML = renderError( 39 | "Something went wrong", 40 | "Secondary Message", 41 | ); 42 | expect( 43 | queryByTestId(document.body, "message").children[1], 44 | ).toHaveTextContent(/Secondary Message/gim); 45 | }); 46 | 47 | it("getCardColors: should return expected values", () => { 48 | let colors = getCardColors({ 49 | title_color: "f00", 50 | text_color: "0f0", 51 | icon_color: "00f", 52 | bg_color: "fff", 53 | border_color: "fff", 54 | theme: "dark", 55 | }); 56 | expect(colors).toStrictEqual({ 57 | titleColor: "#f00", 58 | textColor: "#0f0", 59 | iconColor: "#00f", 60 | bgColor: "#fff", 61 | borderColor: "#fff", 62 | }); 63 | }); 64 | 65 | it("getCardColors: should fallback to default colors if color is invalid", () => { 66 | let colors = getCardColors({ 67 | title_color: "invalidcolor", 68 | text_color: "0f0", 69 | icon_color: "00f", 70 | bg_color: "fff", 71 | border_color: "invalidColor", 72 | theme: "dark", 73 | }); 74 | expect(colors).toStrictEqual({ 75 | titleColor: "#2f80ed", 76 | textColor: "#0f0", 77 | iconColor: "#00f", 78 | bgColor: "#fff", 79 | borderColor: "#e4e2e2", 80 | }); 81 | }); 82 | 83 | it("getCardColors: should fallback to specified theme colors if is not defined", () => { 84 | let colors = getCardColors({ 85 | theme: "dark", 86 | }); 87 | expect(colors).toStrictEqual({ 88 | titleColor: "#fff", 89 | textColor: "#9f9f9f", 90 | iconColor: "#79ff97", 91 | bgColor: "#151515", 92 | borderColor: "#e4e2e2", 93 | }); 94 | }); 95 | }); 96 | 97 | describe("wrapTextMultiline", () => { 98 | it("should not wrap small texts", () => { 99 | { 100 | let multiLineText = wrapTextMultiline("Small text should not wrap"); 101 | expect(multiLineText).toEqual(["Small text should not wrap"]); 102 | } 103 | }); 104 | it("should wrap large texts", () => { 105 | let multiLineText = wrapTextMultiline( 106 | "Hello world long long long text", 107 | 20, 108 | 3, 109 | ); 110 | expect(multiLineText).toEqual(["Hello world long", "long long text"]); 111 | }); 112 | it("should wrap large texts and limit max lines", () => { 113 | let multiLineText = wrapTextMultiline( 114 | "Hello world long long long text", 115 | 10, 116 | 2, 117 | ); 118 | expect(multiLineText).toEqual(["Hello", "world long..."]); 119 | }); 120 | it("should wrap chinese by punctuation", () => { 121 | let multiLineText = wrapTextMultiline( 122 | "专门为刚开始刷题的同学准备的算法基地,没有最细只有更细,立志用动画将晦涩难懂的算法说的通俗易懂!", 123 | ); 124 | expect(multiLineText.length).toEqual(3); 125 | expect(multiLineText[0].length).toEqual(18 * 8); // &#xxxxx; x 8 126 | }); 127 | }); 128 | -------------------------------------------------------------------------------- /src/fetchers/top-languages-fetcher.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | import * as dotenv from "dotenv"; 3 | import { retryer } from "../common/retryer.js"; 4 | import { 5 | CustomError, 6 | logger, 7 | MissingParamError, 8 | request, 9 | wrapTextMultiline, 10 | } from "../common/utils.js"; 11 | 12 | dotenv.config(); 13 | 14 | /** 15 | * Top languages fetcher object. 16 | * 17 | * @param {import('Axios').AxiosRequestHeaders} variables Fetcher variables. 18 | * @param {string} token GitHub token. 19 | * @returns {Promise} Languages fetcher response. 20 | */ 21 | const fetcher = (variables, token) => { 22 | return request( 23 | { 24 | query: ` 25 | query userInfo($login: String!) { 26 | user(login: $login) { 27 | # fetch only owner repos & not forks 28 | repositories(ownerAffiliations: OWNER, isFork: false, first: 100) { 29 | nodes { 30 | name 31 | languages(first: 10, orderBy: {field: SIZE, direction: DESC}) { 32 | edges { 33 | size 34 | node { 35 | color 36 | name 37 | } 38 | } 39 | } 40 | } 41 | } 42 | } 43 | } 44 | `, 45 | variables, 46 | }, 47 | { 48 | Authorization: `token ${token}`, 49 | }, 50 | ); 51 | }; 52 | 53 | /** 54 | * Fetch top languages for a given username. 55 | * 56 | * @param {string} username GitHub username. 57 | * @param {string[]} exclude_repo List of repositories to exclude. 58 | * @returns {Promise} Top languages data. 59 | */ 60 | async function fetchTopLanguages(username, exclude_repo = []) { 61 | if (!username) throw new MissingParamError(["username"]); 62 | 63 | const res = await retryer(fetcher, { login: username }); 64 | 65 | if (res.data.errors) { 66 | logger.error(res.data.errors); 67 | throw Error(res.data.errors[0].message || "Could not fetch user"); 68 | } 69 | 70 | // Catch GraphQL errors. 71 | if (res.data.errors) { 72 | logger.error(res.data.errors); 73 | if (res.data.errors[0].type === "NOT_FOUND") { 74 | throw new CustomError( 75 | res.data.errors[0].message || "Could not fetch user.", 76 | CustomError.USER_NOT_FOUND, 77 | ); 78 | } 79 | if (res.data.errors[0].message) { 80 | throw new CustomError( 81 | wrapTextMultiline(res.data.errors[0].message, 90, 1)[0], 82 | res.statusText, 83 | ); 84 | } 85 | throw new CustomError( 86 | "Something went while trying to retrieve the language data using the GraphQL API.", 87 | CustomError.GRAPHQL_ERROR, 88 | ); 89 | } 90 | 91 | let repoNodes = res.data.data.user.repositories.nodes; 92 | let repoToHide = {}; 93 | 94 | // populate repoToHide map for quick lookup 95 | // while filtering out 96 | if (exclude_repo) { 97 | exclude_repo.forEach((repoName) => { 98 | repoToHide[repoName] = true; 99 | }); 100 | } 101 | 102 | // filter out repositories to be hidden 103 | repoNodes = repoNodes 104 | .sort((a, b) => b.size - a.size) 105 | .filter((name) => !repoToHide[name.name]); 106 | 107 | repoNodes = repoNodes 108 | .filter((node) => node.languages.edges.length > 0) 109 | // flatten the list of language nodes 110 | .reduce((acc, curr) => curr.languages.edges.concat(acc), []) 111 | .reduce((acc, prev) => { 112 | // get the size of the language (bytes) 113 | let langSize = prev.size; 114 | 115 | // if we already have the language in the accumulator 116 | // & the current language name is same as previous name 117 | // add the size to the language size. 118 | if (acc[prev.node.name] && prev.node.name === acc[prev.node.name].name) { 119 | langSize = prev.size + acc[prev.node.name].size; 120 | } 121 | return { 122 | ...acc, 123 | [prev.node.name]: { 124 | name: prev.node.name, 125 | color: prev.node.color, 126 | size: langSize, 127 | }, 128 | }; 129 | }, {}); 130 | 131 | const topLangs = Object.keys(repoNodes) 132 | .sort((a, b) => repoNodes[b].size - repoNodes[a].size) 133 | .reduce((result, key) => { 134 | result[key] = repoNodes[key]; 135 | return result; 136 | }, {}); 137 | 138 | return topLangs; 139 | } 140 | 141 | export { fetchTopLanguages }; 142 | export default fetchTopLanguages; 143 | -------------------------------------------------------------------------------- /powered-by-vercel.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to [github-readme-stats](https://github.com/anuraghazra/github-readme-stats) 2 | 3 | We love your input! We want to make contributing to this project as easy and transparent as possible, whether it's: 4 | 5 | - Reporting an issue 6 | - Discussing the current state of the code 7 | - Submitting a fix 8 | - Proposing new features 9 | - Becoming a maintainer 10 | 11 | ## All Changes Happen Through Pull Requests 12 | 13 | Pull requests are the best way to propose changes. We actively welcome your pull requests: 14 | 15 | 1. Fork the repo and create your branch from `master`. 16 | 2. If you've added code that should be tested, add some tests' examples. 17 | 3. If you've changed APIs, update the documentation. 18 | 4. Issue that pull request! 19 | 20 | ## Under the hood of github-readme-stats 21 | 22 | Interested in diving deeper into understanding how github-readme-stats works? 23 | 24 | [Bohdan](https://github.com/Bogdan-Lyashenko) wrote a fantastic in-depth post about it, check it out: 25 | 26 | **[Under the hood of github-readme-stats project](https://codecrumbs.io/library/github-readme-stats)** 27 | 28 | ## Local Development 29 | 30 | To run & test github-readme-stats, you need to follow a few simple steps:- 31 | _(make sure you already have a [Vercel](https://vercel.com/) account)_ 32 | 33 | 1. Install [Vercel CLI](https://vercel.com/download). 34 | 2. Fork the repository and clone the code to your local machine. 35 | 3. Run `npm install` in the repository root. 36 | 4. Run the command "vercel" in the root and follow the steps there. 37 | 5. Open `vercel.json` and set the maxDuration to 10. 38 | 6. Create a `.env` file in the root of the directory. 39 | 7. In the .env file add a new variable named "PAT_1" with your [GitHub Personal Access Token](https://docs.github.com/en/github/authenticating-to-github/creating-a-personal-access-token). 40 | 8. Run the command "vercel dev" to start a development server at . 41 | 42 | ## Themes Contribution 43 | 44 | GitHub Readme Stats supports custom theming, and you can also contribute new themes! 45 | 46 | All you need to do is edit the [themes/index.js](./themes/index.js) file and add your theme at the end of the file. 47 | 48 | While creating the Pull request to add a new theme **don't forget to add a screenshot of how your theme looks**, you can also test how it looks using custom URL parameters like `title_color`, `icon_color`, `bg_color`, `text_color`, `border_color` 49 | 50 | > NOTE: If you are contributing your theme just because you are using it personally, then you can [customize the looks](./readme.md#customization) of your card with URL params instead. 51 | 52 | ## Any contributions you make will be under the MIT Software License 53 | 54 | In short, when you submit changes, your submissions are understood to be under the same [MIT License](http://choosealicense.com/licenses/mit/) that covers the project. Feel free to contact the maintainers if that's a concern. 55 | 56 | ## Report issues/bugs using GitHub's [issues](https://github.com/anuraghazra/github-readme-stats/issues) 57 | 58 | We use GitHub issues to track public bugs. Report a bug by [opening a new issue](https://github.com/anuraghazra/github-readme-stats/issues/new/choose); it's that easy! 59 | 60 | ## Frequently Asked Questions (FAQs) 61 | 62 | **Q:** How to hide Jupyter Notebook? 63 | 64 | > **Ans:** &hide=jupyter%20notebook 65 | 66 | **Q:** I could not figure out how to deploy on my own Vercel instance 67 | 68 | > **Ans:** 69 | > 70 | > - docs: 71 | > - YT tutorial by codeSTACKr: 72 | 73 | **Q:** Language Card is incorrect 74 | 75 | > **Ans:** Please read all the related issues/comments before opening any issues regarding language card stats: 76 | > 77 | > - 78 | > 79 | > - 80 | 81 | **Q:** How to count private stats? 82 | 83 | > **Ans:** We can only count public commits & we cannot access any other private info of any users, so it's not possible. The only way to count your personal private stats is to deploy on your own instance & use your own PAT (Personal Access Token) 84 | 85 | ### Bug Reports 86 | 87 | **Great Bug Reports** tend to have: 88 | 89 | - A quick summary and/or background 90 | - Steps to reproduce 91 | - Be specific! 92 | - Share the snapshot, if possible. 93 | - GitHub Readme Stats' live link 94 | - What actually happens 95 | - What you expected would happen 96 | - Notes (possibly including why you think this might be happening or stuff you tried that didn't work) 97 | 98 | People _love_ thorough bug reports. I'm not even kidding. 99 | 100 | ### Feature Request 101 | 102 | **Great Feature Requests** tend to have: 103 | 104 | - A quick idea summary 105 | - What & why do you want to add the specific feature 106 | - Additional context like images, links to resources to implement the feature, etc. 107 | 108 | ## License 109 | 110 | By contributing, you agree that your contributions will be licensed under its [MIT License](./LICENSE). 111 | -------------------------------------------------------------------------------- /tests/e2e/e2e.test.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @file Contains end-to-end tests for the Vercel preview instance. 3 | */ 4 | import dotenv from "dotenv"; 5 | dotenv.config(); 6 | 7 | import { describe } from "@jest/globals"; 8 | import axios from "axios"; 9 | import { renderRepoCard } from "../../src/cards/repo-card.js"; 10 | import { renderStatsCard } from "../../src/cards/stats-card.js"; 11 | import { renderTopLanguages } from "../../src/cards/top-languages-card.js"; 12 | import { renderWakatimeCard } from "../../src/cards/wakatime-card.js"; 13 | 14 | const REPO = "dummy-cra"; 15 | const USER = "grsdummy"; 16 | const STATS_DATA = { 17 | name: "grsdummy", 18 | totalPRs: 2, 19 | totalCommits: 2, 20 | totalIssues: 1, 21 | totalStars: 1, 22 | contributedTo: 2, 23 | rank: { 24 | level: "A+", 25 | score: 51.01013099671447, 26 | }, 27 | }; 28 | 29 | const LANGS_DATA = { 30 | TypeScript: { 31 | color: "#3178c6", 32 | name: "TypeScript", 33 | size: 2049, 34 | }, 35 | HTML: { 36 | color: "#e34c26", 37 | name: "HTML", 38 | size: 1721, 39 | }, 40 | CSS: { 41 | color: "#563d7c", 42 | name: "CSS", 43 | size: 930, 44 | }, 45 | Python: { 46 | color: "#3572A5", 47 | name: "Python", 48 | size: 671, 49 | }, 50 | }; 51 | 52 | const WAKATIME_DATA = { 53 | human_readable_range: "last week", 54 | is_already_updating: false, 55 | is_coding_activity_visible: false, 56 | is_including_today: false, 57 | is_other_usage_visible: false, 58 | is_stuck: false, 59 | is_up_to_date: false, 60 | is_up_to_date_pending_future: false, 61 | percent_calculated: 0, 62 | range: "last_7_days", 63 | status: "pending_update", 64 | timeout: 15, 65 | username: "grsdummy", 66 | writes_only: false, 67 | }; 68 | 69 | const REPOSITORY_DATA = { 70 | name: "dummy-cra", 71 | nameWithOwner: "grsdummy/dummy-cra", 72 | isPrivate: false, 73 | isArchived: false, 74 | isTemplate: false, 75 | stargazers: { 76 | totalCount: 1, 77 | }, 78 | description: "Dummy create react app.", 79 | primaryLanguage: { 80 | color: "#3178c6", 81 | id: "MDg6TGFuZ3VhZ2UyODc=", 82 | name: "TypeScript", 83 | }, 84 | forkCount: 0, 85 | starCount: 1, 86 | }; 87 | 88 | const CACHE_BURST_STRING = `v=${new Date().getTime()}`; 89 | 90 | describe("Fetch Cards", () => { 91 | let VERCEL_PREVIEW_URL; 92 | 93 | beforeAll(() => { 94 | process.env.NODE_ENV = "development"; 95 | VERCEL_PREVIEW_URL = process.env.VERCEL_PREVIEW_URL; 96 | }); 97 | 98 | test("retrieve stats card", async () => { 99 | expect(VERCEL_PREVIEW_URL).toBeDefined(); 100 | 101 | // Check if the Vercel preview instance stats card function is up and running. 102 | await expect( 103 | axios.get(`${VERCEL_PREVIEW_URL}/api?username=${USER}`), 104 | ).resolves.not.toThrow(); 105 | 106 | // Get local stats card. 107 | const localStatsCardSVG = renderStatsCard(STATS_DATA); 108 | 109 | // Get the Vercel preview stats card response. 110 | const serverStatsSvg = await axios.get( 111 | `${VERCEL_PREVIEW_URL}/api?username=${USER}&${CACHE_BURST_STRING}`, 112 | ); 113 | 114 | // Check if stats card from deployment matches the stats card from local. 115 | expect(serverStatsSvg.data).toEqual(localStatsCardSVG); 116 | }, 7000); 117 | 118 | test("retrieve language card", async () => { 119 | expect(VERCEL_PREVIEW_URL).toBeDefined(); 120 | 121 | // Check if the Vercel preview instance language card function is up and running. 122 | console.log( 123 | `${VERCEL_PREVIEW_URL}/api/top-langs/?username=${USER}&${CACHE_BURST_STRING}`, 124 | ); 125 | await expect( 126 | axios.get( 127 | `${VERCEL_PREVIEW_URL}/api/top-langs/?username=${USER}&${CACHE_BURST_STRING}`, 128 | ), 129 | ).resolves.not.toThrow(); 130 | 131 | // Get local language card. 132 | const localLanguageCardSVG = renderTopLanguages(LANGS_DATA); 133 | 134 | // Get the Vercel preview language card response. 135 | const severLanguageSVG = await axios.get( 136 | `${VERCEL_PREVIEW_URL}/api/top-langs/?username=${USER}&${CACHE_BURST_STRING}`, 137 | ); 138 | 139 | // Check if language card from deployment matches the local language card. 140 | expect(severLanguageSVG.data).toEqual(localLanguageCardSVG); 141 | }); 142 | 143 | test("retrieve WakaTime card", async () => { 144 | expect(VERCEL_PREVIEW_URL).toBeDefined(); 145 | 146 | // Check if the Vercel preview instance WakaTime function is up and running. 147 | await expect( 148 | axios.get(`${VERCEL_PREVIEW_URL}/api/wakatime?username=${USER}`), 149 | ).resolves.not.toThrow(); 150 | 151 | // Get local WakaTime card. 152 | const localWakaCardSVG = renderWakatimeCard(WAKATIME_DATA); 153 | 154 | // Get the Vercel preview WakaTime card response. 155 | const serverWakaTimeSvg = await axios.get( 156 | `${VERCEL_PREVIEW_URL}/api/wakatime?username=${USER}&${CACHE_BURST_STRING}`, 157 | ); 158 | 159 | // Check if WakaTime card from deployment matches the local WakaTime card. 160 | expect(serverWakaTimeSvg.data).toEqual(localWakaCardSVG); 161 | }); 162 | 163 | test("retrieve repo card", async () => { 164 | expect(VERCEL_PREVIEW_URL).toBeDefined(); 165 | 166 | // Check if the Vercel preview instance Repo function is up and running. 167 | await expect( 168 | axios.get( 169 | `${VERCEL_PREVIEW_URL}/api/pin/?username=${USER}&repo=${REPO}&${CACHE_BURST_STRING}`, 170 | ), 171 | ).resolves.not.toThrow(); 172 | 173 | // Get local repo card. 174 | const localRepoCardSVG = renderRepoCard(REPOSITORY_DATA); 175 | 176 | // Get the Vercel preview repo card response. 177 | const serverRepoSvg = await axios.get( 178 | `${VERCEL_PREVIEW_URL}/api/pin/?username=${USER}&repo=${REPO}&${CACHE_BURST_STRING}`, 179 | ); 180 | 181 | // Check if Repo card from deployment matches the local Repo card. 182 | expect(serverRepoSvg.data).toEqual(localRepoCardSVG); 183 | }); 184 | }); 185 | -------------------------------------------------------------------------------- /scripts/close-stale-theme-prs.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @file Script that can be used to close stale theme PRs that have a `invalid` label. 3 | */ 4 | import * as dotenv from "dotenv"; 5 | dotenv.config(); 6 | 7 | import { debug, setFailed } from "@actions/core"; 8 | import github from "@actions/github"; 9 | import { RequestError } from "@octokit/request-error"; 10 | import { getGithubToken, getRepoInfo } from "./helpers.js"; 11 | 12 | const CLOSING_COMMENT = ` 13 | \rThis PR has been automatically closed due to inactivity. Please feel free to reopen it if you need to continue working on it.\ 14 | \rThank you for your contributions. 15 | `; 16 | const REVIEWER = "github-actions[bot]"; 17 | 18 | /** 19 | * Retrieve the review user. 20 | * @returns {string} review user. 21 | */ 22 | const getReviewer = () => { 23 | return process.env.REVIEWER ? process.env.REVIEWER : REVIEWER; 24 | }; 25 | 26 | /** 27 | * Fetch open PRs from a given repository. 28 | * @param user The user name of the repository owner. 29 | * @param repo The name of the repository. 30 | * @param reviewer The reviewer to filter by. 31 | * @returns The open PRs. 32 | */ 33 | export const fetchOpenPRs = async (octokit, user, repo, reviewer) => { 34 | const openPRs = []; 35 | let hasNextPage = true; 36 | let endCursor; 37 | while (hasNextPage) { 38 | try { 39 | const { repository } = await octokit.graphql( 40 | ` 41 | { 42 | repository(owner: "${user}", name: "${repo}") { 43 | open_prs: pullRequests(${ 44 | endCursor ? `after: "${endCursor}", ` : "" 45 | } 46 | first: 100, states: OPEN, orderBy: {field: CREATED_AT, direction: DESC}) { 47 | nodes { 48 | number 49 | commits(last:1){ 50 | nodes{ 51 | commit{ 52 | pushedDate 53 | } 54 | } 55 | } 56 | labels(first: 100, orderBy:{field: CREATED_AT, direction: DESC}) { 57 | nodes { 58 | name 59 | } 60 | } 61 | reviews(first: 100, states: CHANGES_REQUESTED, author: "${reviewer}") { 62 | nodes { 63 | submittedAt 64 | } 65 | } 66 | } 67 | pageInfo { 68 | endCursor 69 | hasNextPage 70 | } 71 | } 72 | } 73 | } 74 | `, 75 | ); 76 | openPRs.push(...repository.open_prs.nodes); 77 | hasNextPage = repository.open_prs.pageInfo.hasNextPage; 78 | endCursor = repository.open_prs.pageInfo.endCursor; 79 | } catch (error) { 80 | if (error instanceof RequestError) { 81 | setFailed(`Could not retrieve top PRs using GraphQl: ${error.message}`); 82 | } 83 | throw error; 84 | } 85 | } 86 | return openPRs; 87 | }; 88 | 89 | /** 90 | * Retrieve pull requests that have a given label. 91 | * @param pull The pull requests to check. 92 | * @param label The label to check for. 93 | */ 94 | export const pullsWithLabel = (pulls, label) => { 95 | return pulls.filter((pr) => { 96 | return pr.labels.nodes.some((lab) => lab.name === label); 97 | }); 98 | }; 99 | 100 | /** 101 | * Check if PR is stale. Meaning that it hasn't been updated in a given time. 102 | * @param {Object} pullRequest request object. 103 | * @param {number} days number of days. 104 | * @returns Boolean indicating if PR is stale. 105 | */ 106 | const isStale = (pullRequest, staleDays) => { 107 | const lastCommitDate = new Date( 108 | pullRequest.commits.nodes[0].commit.pushedDate, 109 | ); 110 | if (pullRequest.reviews.nodes[0]) { 111 | const lastReviewDate = new Date( 112 | pullRequest.reviews.nodes.sort((a, b) => (a < b ? 1 : -1))[0].submittedAt, 113 | ); 114 | const lastUpdateDate = 115 | lastCommitDate >= lastReviewDate ? lastCommitDate : lastReviewDate; 116 | const now = new Date(); 117 | return (now - lastUpdateDate) / (1000 * 60 * 60 * 24) >= staleDays; 118 | } else { 119 | return false; 120 | } 121 | }; 122 | 123 | /** 124 | * Main function. 125 | */ 126 | const run = async () => { 127 | try { 128 | // Create octokit client. 129 | const dryRun = process.env.DRY_RUN === "true" || false; 130 | const staleDays = process.env.STALE_DAYS || 20; 131 | debug("Creating octokit client..."); 132 | const octokit = github.getOctokit(getGithubToken()); 133 | const { owner, repo } = getRepoInfo(github.context); 134 | const reviewer = getReviewer(); 135 | 136 | // Retrieve all theme pull requests. 137 | debug("Retrieving all theme pull requests..."); 138 | const prs = await fetchOpenPRs(octokit, owner, repo, reviewer); 139 | const themePRs = pullsWithLabel(prs, "themes"); 140 | const invalidThemePRs = pullsWithLabel(themePRs, "invalid"); 141 | debug("Retrieving stale theme PRs..."); 142 | const staleThemePRs = invalidThemePRs.filter((pr) => 143 | isStale(pr, staleDays), 144 | ); 145 | const staleThemePRsNumbers = staleThemePRs.map((pr) => pr.number); 146 | debug(`Found ${staleThemePRs.length} stale theme PRs`); 147 | 148 | // Loop through all stale invalid theme pull requests and close them. 149 | for (const prNumber of staleThemePRsNumbers) { 150 | debug(`Closing #${prNumber} because it is stale...`); 151 | if (!dryRun) { 152 | await octokit.issues.createComment({ 153 | owner, 154 | repo, 155 | issue_number: prNumber, 156 | body: CLOSING_COMMENT, 157 | }); 158 | await octokit.pulls.update({ 159 | owner, 160 | repo, 161 | pull_number: prNumber, 162 | state: "closed", 163 | }); 164 | } else { 165 | debug("Dry run enabled, skipping..."); 166 | } 167 | } 168 | } catch (error) { 169 | setFailed(error.message); 170 | } 171 | }; 172 | 173 | run(); 174 | -------------------------------------------------------------------------------- /tests/card.test.js: -------------------------------------------------------------------------------- 1 | import { queryByTestId } from "@testing-library/dom"; 2 | import "@testing-library/jest-dom"; 3 | import { cssToObject } from "@uppercod/css-to-object"; 4 | import { Card } from "../src/common/Card.js"; 5 | import { icons } from "../src/common/icons.js"; 6 | import { getCardColors } from "../src/common/utils.js"; 7 | 8 | describe("Card", () => { 9 | it("should hide border", () => { 10 | const card = new Card({}); 11 | card.setHideBorder(true); 12 | 13 | document.body.innerHTML = card.render(``); 14 | expect(queryByTestId(document.body, "card-bg")).toHaveAttribute( 15 | "stroke-opacity", 16 | "0", 17 | ); 18 | }); 19 | 20 | it("should not hide border", () => { 21 | const card = new Card({}); 22 | card.setHideBorder(false); 23 | 24 | document.body.innerHTML = card.render(``); 25 | expect(queryByTestId(document.body, "card-bg")).toHaveAttribute( 26 | "stroke-opacity", 27 | "1", 28 | ); 29 | }); 30 | 31 | it("should have a custom title", () => { 32 | const card = new Card({ 33 | customTitle: "custom title", 34 | defaultTitle: "default title", 35 | }); 36 | 37 | document.body.innerHTML = card.render(``); 38 | expect(queryByTestId(document.body, "card-title")).toHaveTextContent( 39 | "custom title", 40 | ); 41 | }); 42 | 43 | it("should hide title", () => { 44 | const card = new Card({}); 45 | card.setHideTitle(true); 46 | 47 | document.body.innerHTML = card.render(``); 48 | expect(queryByTestId(document.body, "card-title")).toBeNull(); 49 | }); 50 | 51 | it("should not hide title", () => { 52 | const card = new Card({}); 53 | card.setHideTitle(false); 54 | 55 | document.body.innerHTML = card.render(``); 56 | expect(queryByTestId(document.body, "card-title")).toBeInTheDocument(); 57 | }); 58 | 59 | it("title should have prefix icon", () => { 60 | const card = new Card({ title: "ok", titlePrefixIcon: icons.contribs }); 61 | 62 | document.body.innerHTML = card.render(``); 63 | expect(document.getElementsByClassName("icon")[0]).toBeInTheDocument(); 64 | }); 65 | 66 | it("title should not have prefix icon", () => { 67 | const card = new Card({ title: "ok" }); 68 | 69 | document.body.innerHTML = card.render(``); 70 | expect(document.getElementsByClassName("icon")[0]).toBeUndefined(); 71 | }); 72 | 73 | it("should have proper height, width", () => { 74 | const card = new Card({ height: 200, width: 200, title: "ok" }); 75 | document.body.innerHTML = card.render(``); 76 | expect(document.getElementsByTagName("svg")[0]).toHaveAttribute( 77 | "height", 78 | "200", 79 | ); 80 | expect(document.getElementsByTagName("svg")[0]).toHaveAttribute( 81 | "height", 82 | "200", 83 | ); 84 | }); 85 | 86 | it("should have less height after title is hidden", () => { 87 | const card = new Card({ height: 200, title: "ok" }); 88 | card.setHideTitle(true); 89 | 90 | document.body.innerHTML = card.render(``); 91 | expect(document.getElementsByTagName("svg")[0]).toHaveAttribute( 92 | "height", 93 | "170", 94 | ); 95 | }); 96 | 97 | it("main-card-body should have proper when title is visible", () => { 98 | const card = new Card({ height: 200 }); 99 | document.body.innerHTML = card.render(``); 100 | expect(queryByTestId(document.body, "main-card-body")).toHaveAttribute( 101 | "transform", 102 | "translate(0, 55)", 103 | ); 104 | }); 105 | 106 | it("main-card-body should have proper position after title is hidden", () => { 107 | const card = new Card({ height: 200 }); 108 | card.setHideTitle(true); 109 | 110 | document.body.innerHTML = card.render(``); 111 | expect(queryByTestId(document.body, "main-card-body")).toHaveAttribute( 112 | "transform", 113 | "translate(0, 25)", 114 | ); 115 | }); 116 | 117 | it("should render with correct colors", () => { 118 | // returns theme based colors with proper overrides and defaults 119 | const { titleColor, textColor, iconColor, bgColor } = getCardColors({ 120 | title_color: "f00", 121 | icon_color: "0f0", 122 | text_color: "00f", 123 | bg_color: "fff", 124 | theme: "default", 125 | }); 126 | 127 | const card = new Card({ 128 | height: 200, 129 | colors: { 130 | titleColor, 131 | textColor, 132 | iconColor, 133 | bgColor, 134 | }, 135 | }); 136 | document.body.innerHTML = card.render(``); 137 | 138 | const styleTag = document.querySelector("style"); 139 | const stylesObject = cssToObject(styleTag.innerHTML); 140 | const headerClassStyles = stylesObject[":host"][".header "]; 141 | 142 | expect(headerClassStyles["fill"].trim()).toBe("#f00"); 143 | expect(queryByTestId(document.body, "card-bg")).toHaveAttribute( 144 | "fill", 145 | "#fff", 146 | ); 147 | }); 148 | it("should render gradient backgrounds", () => { 149 | const { titleColor, textColor, iconColor, bgColor } = getCardColors({ 150 | title_color: "f00", 151 | icon_color: "0f0", 152 | text_color: "00f", 153 | bg_color: "90,fff,000,f00", 154 | theme: "default", 155 | }); 156 | 157 | const card = new Card({ 158 | height: 200, 159 | colors: { 160 | titleColor, 161 | textColor, 162 | iconColor, 163 | bgColor, 164 | }, 165 | }); 166 | document.body.innerHTML = card.render(``); 167 | expect(queryByTestId(document.body, "card-bg")).toHaveAttribute( 168 | "fill", 169 | "url(#gradient)", 170 | ); 171 | expect(document.querySelector("defs #gradient")).toHaveAttribute( 172 | "gradientTransform", 173 | "rotate(90)", 174 | ); 175 | expect( 176 | document.querySelector("defs #gradient stop:nth-child(1)"), 177 | ).toHaveAttribute("stop-color", "#fff"); 178 | expect( 179 | document.querySelector("defs #gradient stop:nth-child(2)"), 180 | ).toHaveAttribute("stop-color", "#000"); 181 | expect( 182 | document.querySelector("defs #gradient stop:nth-child(3)"), 183 | ).toHaveAttribute("stop-color", "#f00"); 184 | }); 185 | }); 186 | -------------------------------------------------------------------------------- /src/common/Card.js: -------------------------------------------------------------------------------- 1 | import { getAnimations } from "../getStyles.js"; 2 | import { encodeHTML, flexLayout } from "./utils.js"; 3 | 4 | class Card { 5 | /** 6 | * Creates a new card instance. 7 | * 8 | * @param {object} args Card arguments. 9 | * @param {number?=} args.width Card width. 10 | * @param {number?=} args.height Card height. 11 | * @param {number?=} args.border_radius Card border radius. 12 | * @param {string?=} args.customTitle Card custom title. 13 | * @param {string?=} args.defaultTitle Card default title. 14 | * @param {string?=} args.titlePrefixIcon Card title prefix icon. 15 | * @returns {Card} Card instance. 16 | */ 17 | constructor({ 18 | width = 100, 19 | height = 100, 20 | border_radius = 4.5, 21 | colors = {}, 22 | customTitle, 23 | defaultTitle = "", 24 | titlePrefixIcon, 25 | }) { 26 | this.width = width; 27 | this.height = height; 28 | 29 | this.hideBorder = false; 30 | this.hideTitle = false; 31 | 32 | this.border_radius = border_radius; 33 | 34 | // returns theme based colors with proper overrides and defaults 35 | this.colors = colors; 36 | this.title = 37 | customTitle !== undefined 38 | ? encodeHTML(customTitle) 39 | : encodeHTML(defaultTitle); 40 | 41 | this.css = ""; 42 | 43 | this.paddingX = 25; 44 | this.paddingY = 35; 45 | this.titlePrefixIcon = titlePrefixIcon; 46 | this.animations = true; 47 | this.a11yTitle = ""; 48 | this.a11yDesc = ""; 49 | } 50 | 51 | disableAnimations() { 52 | this.animations = false; 53 | } 54 | 55 | /** 56 | * @param {{title: string, desc: string}} prop 57 | */ 58 | setAccessibilityLabel({ title, desc }) { 59 | this.a11yTitle = title; 60 | this.a11yDesc = desc; 61 | } 62 | 63 | /** 64 | * @param {string} value 65 | */ 66 | setCSS(value) { 67 | this.css = value; 68 | } 69 | 70 | /** 71 | * @param {boolean} value 72 | */ 73 | setHideBorder(value) { 74 | this.hideBorder = value; 75 | } 76 | 77 | /** 78 | * @param {boolean} value 79 | */ 80 | setHideTitle(value) { 81 | this.hideTitle = value; 82 | if (value) { 83 | this.height -= 30; 84 | } 85 | } 86 | 87 | /** 88 | * @param {string} text 89 | */ 90 | setTitle(text) { 91 | this.title = text; 92 | } 93 | 94 | renderTitle() { 95 | const titleText = ` 96 | ${this.title} 102 | `; 103 | 104 | const prefixIcon = ` 105 | 114 | ${this.titlePrefixIcon} 115 | 116 | `; 117 | return ` 118 | 122 | ${flexLayout({ 123 | items: [this.titlePrefixIcon && prefixIcon, titleText], 124 | gap: 25, 125 | }).join("")} 126 | 127 | `; 128 | } 129 | 130 | renderGradient() { 131 | if (typeof this.colors.bgColor !== "object") return ""; 132 | 133 | const gradients = this.colors.bgColor.slice(1); 134 | return typeof this.colors.bgColor === "object" 135 | ? ` 136 | 137 | 142 | ${gradients.map((grad, index) => { 143 | let offset = (index * 100) / (gradients.length - 1); 144 | return ``; 145 | })} 146 | 147 | 148 | ` 149 | : ""; 150 | } 151 | 152 | /** 153 | * @param {string} body 154 | */ 155 | render(body) { 156 | return ` 157 | 166 | ${this.a11yTitle} 167 | ${this.a11yDesc} 168 | 187 | 188 | ${this.renderGradient()} 189 | 190 | 205 | 206 | ${this.hideTitle ? "" : this.renderTitle()} 207 | 208 | 214 | ${body} 215 | 216 | 217 | `; 218 | } 219 | } 220 | 221 | export { Card }; 222 | export default Card; 223 | -------------------------------------------------------------------------------- /src/cards/repo-card.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | import { Card } from "../common/Card.js"; 3 | import { I18n } from "../common/I18n.js"; 4 | import { icons } from "../common/icons.js"; 5 | import { 6 | encodeHTML, 7 | flexLayout, 8 | getCardColors, 9 | kFormatter, 10 | measureText, 11 | parseEmojis, 12 | wrapTextMultiline, 13 | } from "../common/utils.js"; 14 | import { repoCardLocales } from "../translations.js"; 15 | 16 | /** 17 | * Retrieves the repository description and wraps it to fit the card width. 18 | * 19 | * @param {string} label The repository description. 20 | * @param {string} textColor The color of the text. 21 | * @returns {string} Wrapped repo description SVG object. 22 | */ 23 | const getBadgeSVG = (label, textColor) => ` 24 | 25 | 26 | 33 | ${label} 34 | 35 | 36 | `; 37 | 38 | /** 39 | * Creates a node to display the primary programming language of the repository. 40 | * 41 | * @param {string} langName Language name. 42 | * @param {string} langColor Language color. 43 | * @returns {string} Language display SVG object. 44 | */ 45 | const createLanguageNode = (langName, langColor) => { 46 | return ` 47 | 48 | 49 | ${langName} 50 | 51 | `; 52 | }; 53 | 54 | const ICON_SIZE = 16; 55 | 56 | /** 57 | * Creates an icon with label to display repository stats like forks, stars, etc. 58 | * 59 | * @param {string} icon The icon to display. 60 | * @param {number|string} label The label to display. 61 | * @param {string} testid The testid to assign to the label. 62 | * @returns {string} Icon with label SVG object. 63 | */ 64 | const iconWithLabel = (icon, label, testid) => { 65 | if (label <= 0) return ""; 66 | const iconSvg = ` 67 | 75 | ${icon} 76 | 77 | `; 78 | const text = `${label}`; 79 | return flexLayout({ items: [iconSvg, text], gap: 20 }).join(""); 80 | }; 81 | 82 | /** 83 | * Renders repository card details. 84 | * 85 | * @param {import('../fetchers/types').RepositoryData} repo Repository data. 86 | * @param {Partial} options Card options. 87 | * @returns {string} Repository card SVG object. 88 | */ 89 | const renderRepoCard = (repo, options = {}) => { 90 | const { 91 | name, 92 | nameWithOwner, 93 | description, 94 | primaryLanguage, 95 | isArchived, 96 | isTemplate, 97 | starCount, 98 | forkCount, 99 | } = repo; 100 | const { 101 | hide_border = false, 102 | title_color, 103 | icon_color, 104 | text_color, 105 | bg_color, 106 | show_owner = false, 107 | theme = "default_repocard", 108 | border_radius, 109 | border_color, 110 | locale, 111 | } = options; 112 | 113 | const lineHeight = 10; 114 | const header = show_owner ? nameWithOwner : name; 115 | const langName = (primaryLanguage && primaryLanguage.name) || "Unspecified"; 116 | const langColor = (primaryLanguage && primaryLanguage.color) || "#333"; 117 | 118 | const desc = parseEmojis(description || "No description provided"); 119 | const multiLineDescription = wrapTextMultiline(desc); 120 | const descriptionLines = multiLineDescription.length; 121 | const descriptionSvg = multiLineDescription 122 | .map((line) => `${encodeHTML(line)}`) 123 | .join(""); 124 | 125 | const height = 126 | (descriptionLines > 1 ? 120 : 110) + descriptionLines * lineHeight; 127 | 128 | const i18n = new I18n({ 129 | locale, 130 | translations: repoCardLocales, 131 | }); 132 | 133 | // returns theme based colors with proper overrides and defaults 134 | const colors = getCardColors({ 135 | title_color, 136 | icon_color, 137 | text_color, 138 | bg_color, 139 | border_color, 140 | theme, 141 | }); 142 | 143 | const svgLanguage = primaryLanguage 144 | ? createLanguageNode(langName, langColor) 145 | : ""; 146 | 147 | const totalStars = kFormatter(starCount); 148 | const totalForks = kFormatter(forkCount); 149 | const svgStars = iconWithLabel(icons.star, totalStars, "stargazers"); 150 | const svgForks = iconWithLabel(icons.fork, totalForks, "forkcount"); 151 | 152 | const starAndForkCount = flexLayout({ 153 | items: [svgLanguage, svgStars, svgForks], 154 | sizes: [ 155 | measureText(langName, 12), 156 | ICON_SIZE + measureText(`${totalStars}`, 12), 157 | ICON_SIZE + measureText(`${totalForks}`, 12), 158 | ], 159 | gap: 25, 160 | }).join(""); 161 | 162 | const card = new Card({ 163 | defaultTitle: header.length > 35 ? `${header.slice(0, 35)}...` : header, 164 | titlePrefixIcon: icons.contribs, 165 | width: 400, 166 | height, 167 | border_radius, 168 | colors, 169 | }); 170 | 171 | card.disableAnimations(); 172 | card.setHideBorder(hide_border); 173 | card.setHideTitle(false); 174 | card.setCSS(` 175 | .description { font: 400 13px 'Segoe UI', Ubuntu, Sans-Serif; fill: ${colors.textColor} } 176 | .gray { font: 400 12px 'Segoe UI', Ubuntu, Sans-Serif; fill: ${colors.textColor} } 177 | .icon { fill: ${colors.iconColor} } 178 | .badge { font: 600 11px 'Segoe UI', Ubuntu, Sans-Serif; } 179 | .badge rect { opacity: 0.2 } 180 | `); 181 | 182 | return card.render(` 183 | ${ 184 | isTemplate 185 | ? // @ts-ignore 186 | getBadgeSVG(i18n.t("repocard.template"), colors.textColor) 187 | : isArchived 188 | ? // @ts-ignore 189 | getBadgeSVG(i18n.t("repocard.archived"), colors.textColor) 190 | : "" 191 | } 192 | 193 | 194 | ${descriptionSvg} 195 | 196 | 197 | 198 | ${starAndForkCount} 199 | 200 | `); 201 | }; 202 | 203 | export { renderRepoCard }; 204 | export default renderRepoCard; 205 | -------------------------------------------------------------------------------- /tests/fetchWakatime.test.js: -------------------------------------------------------------------------------- 1 | import "@testing-library/jest-dom"; 2 | import axios from "axios"; 3 | import MockAdapter from "axios-mock-adapter"; 4 | import { fetchWakatimeStats } from "../src/fetchers/wakatime-fetcher.js"; 5 | const mock = new MockAdapter(axios); 6 | 7 | afterEach(() => { 8 | mock.reset(); 9 | }); 10 | 11 | const wakaTimeData = { 12 | data: { 13 | categories: [ 14 | { 15 | digital: "22:40", 16 | hours: 22, 17 | minutes: 40, 18 | name: "Coding", 19 | percent: 100, 20 | text: "22 hrs 40 mins", 21 | total_seconds: 81643.570077, 22 | }, 23 | ], 24 | daily_average: 16095, 25 | daily_average_including_other_language: 16329, 26 | days_including_holidays: 7, 27 | days_minus_holidays: 5, 28 | editors: [ 29 | { 30 | digital: "22:40", 31 | hours: 22, 32 | minutes: 40, 33 | name: "VS Code", 34 | percent: 100, 35 | text: "22 hrs 40 mins", 36 | total_seconds: 81643.570077, 37 | }, 38 | ], 39 | holidays: 2, 40 | human_readable_daily_average: "4 hrs 28 mins", 41 | human_readable_daily_average_including_other_language: "4 hrs 32 mins", 42 | human_readable_total: "22 hrs 21 mins", 43 | human_readable_total_including_other_language: "22 hrs 40 mins", 44 | id: "random hash", 45 | is_already_updating: false, 46 | is_coding_activity_visible: true, 47 | is_including_today: false, 48 | is_other_usage_visible: true, 49 | is_stuck: false, 50 | is_up_to_date: true, 51 | languages: [ 52 | { 53 | digital: "0:19", 54 | hours: 0, 55 | minutes: 19, 56 | name: "Other", 57 | percent: 1.43, 58 | text: "19 mins", 59 | total_seconds: 1170.434361, 60 | }, 61 | { 62 | digital: "0:01", 63 | hours: 0, 64 | minutes: 1, 65 | name: "TypeScript", 66 | percent: 0.1, 67 | text: "1 min", 68 | total_seconds: 83.293809, 69 | }, 70 | { 71 | digital: "0:00", 72 | hours: 0, 73 | minutes: 0, 74 | name: "YAML", 75 | percent: 0.07, 76 | text: "0 secs", 77 | total_seconds: 54.975151, 78 | }, 79 | ], 80 | operating_systems: [ 81 | { 82 | digital: "22:40", 83 | hours: 22, 84 | minutes: 40, 85 | name: "Mac", 86 | percent: 100, 87 | text: "22 hrs 40 mins", 88 | total_seconds: 81643.570077, 89 | }, 90 | ], 91 | percent_calculated: 100, 92 | range: "last_7_days", 93 | status: "ok", 94 | timeout: 15, 95 | total_seconds: 80473.135716, 96 | total_seconds_including_other_language: 81643.570077, 97 | user_id: "random hash", 98 | username: "anuraghazra", 99 | writes_only: false, 100 | }, 101 | }; 102 | 103 | describe("Wakatime fetcher", () => { 104 | it("should fetch correct wakatime data", async () => { 105 | const username = "anuraghazra"; 106 | mock 107 | .onGet( 108 | `https://wakatime.com/api/v1/users/${username}/stats/?is_including_today=true`, 109 | ) 110 | .reply(200, wakaTimeData); 111 | 112 | const repo = await fetchWakatimeStats({ username }); 113 | expect(repo).toMatchInlineSnapshot(` 114 | { 115 | "categories": [ 116 | { 117 | "digital": "22:40", 118 | "hours": 22, 119 | "minutes": 40, 120 | "name": "Coding", 121 | "percent": 100, 122 | "text": "22 hrs 40 mins", 123 | "total_seconds": 81643.570077, 124 | }, 125 | ], 126 | "daily_average": 16095, 127 | "daily_average_including_other_language": 16329, 128 | "days_including_holidays": 7, 129 | "days_minus_holidays": 5, 130 | "editors": [ 131 | { 132 | "digital": "22:40", 133 | "hours": 22, 134 | "minutes": 40, 135 | "name": "VS Code", 136 | "percent": 100, 137 | "text": "22 hrs 40 mins", 138 | "total_seconds": 81643.570077, 139 | }, 140 | ], 141 | "holidays": 2, 142 | "human_readable_daily_average": "4 hrs 28 mins", 143 | "human_readable_daily_average_including_other_language": "4 hrs 32 mins", 144 | "human_readable_total": "22 hrs 21 mins", 145 | "human_readable_total_including_other_language": "22 hrs 40 mins", 146 | "id": "random hash", 147 | "is_already_updating": false, 148 | "is_coding_activity_visible": true, 149 | "is_including_today": false, 150 | "is_other_usage_visible": true, 151 | "is_stuck": false, 152 | "is_up_to_date": true, 153 | "languages": [ 154 | { 155 | "digital": "0:19", 156 | "hours": 0, 157 | "minutes": 19, 158 | "name": "Other", 159 | "percent": 1.43, 160 | "text": "19 mins", 161 | "total_seconds": 1170.434361, 162 | }, 163 | { 164 | "digital": "0:01", 165 | "hours": 0, 166 | "minutes": 1, 167 | "name": "TypeScript", 168 | "percent": 0.1, 169 | "text": "1 min", 170 | "total_seconds": 83.293809, 171 | }, 172 | { 173 | "digital": "0:00", 174 | "hours": 0, 175 | "minutes": 0, 176 | "name": "YAML", 177 | "percent": 0.07, 178 | "text": "0 secs", 179 | "total_seconds": 54.975151, 180 | }, 181 | ], 182 | "operating_systems": [ 183 | { 184 | "digital": "22:40", 185 | "hours": 22, 186 | "minutes": 40, 187 | "name": "Mac", 188 | "percent": 100, 189 | "text": "22 hrs 40 mins", 190 | "total_seconds": 81643.570077, 191 | }, 192 | ], 193 | "percent_calculated": 100, 194 | "range": "last_7_days", 195 | "status": "ok", 196 | "timeout": 15, 197 | "total_seconds": 80473.135716, 198 | "total_seconds_including_other_language": 81643.570077, 199 | "user_id": "random hash", 200 | "username": "anuraghazra", 201 | "writes_only": false, 202 | } 203 | `); 204 | }); 205 | 206 | it("should throw error", async () => { 207 | mock.onGet(/\/https:\/\/wakatime\.com\/api/).reply(404, wakaTimeData); 208 | 209 | await expect(fetchWakatimeStats("noone")).rejects.toThrow( 210 | 'Missing params "username" make sure you pass the parameters in URL', 211 | ); 212 | }); 213 | }); 214 | 215 | export { wakaTimeData }; 216 | -------------------------------------------------------------------------------- /tests/api.test.js: -------------------------------------------------------------------------------- 1 | import { jest } from "@jest/globals"; 2 | import axios from "axios"; 3 | import MockAdapter from "axios-mock-adapter"; 4 | import api from "../api/index.js"; 5 | import { calculateRank } from "../src/calculateRank.js"; 6 | import { renderStatsCard } from "../src/cards/stats-card.js"; 7 | import { CONSTANTS, renderError } from "../src/common/utils.js"; 8 | 9 | const stats = { 10 | name: "Anurag Hazra", 11 | totalStars: 100, 12 | totalCommits: 200, 13 | totalIssues: 300, 14 | totalPRs: 400, 15 | contributedTo: 500, 16 | rank: null, 17 | }; 18 | stats.rank = calculateRank({ 19 | totalCommits: stats.totalCommits, 20 | totalRepos: 1, 21 | followers: 0, 22 | contributions: stats.contributedTo, 23 | stargazers: stats.totalStars, 24 | prs: stats.totalPRs, 25 | issues: stats.totalIssues, 26 | }); 27 | 28 | const data = { 29 | data: { 30 | user: { 31 | name: stats.name, 32 | repositoriesContributedTo: { totalCount: stats.contributedTo }, 33 | contributionsCollection: { 34 | totalCommitContributions: stats.totalCommits, 35 | restrictedContributionsCount: 100, 36 | }, 37 | pullRequests: { totalCount: stats.totalPRs }, 38 | openIssues: { totalCount: stats.totalIssues }, 39 | closedIssues: { totalCount: 0 }, 40 | followers: { totalCount: 0 }, 41 | repositories: { 42 | totalCount: 1, 43 | }, 44 | }, 45 | }, 46 | }; 47 | 48 | const repositoriesData = { 49 | data: { 50 | user: { 51 | repositories: { 52 | nodes: [{ stargazers: { totalCount: 100 } }], 53 | pageInfo: { 54 | hasNextPage: false, 55 | cursor: "cursor", 56 | }, 57 | }, 58 | }, 59 | }, 60 | }; 61 | 62 | const error = { 63 | errors: [ 64 | { 65 | type: "NOT_FOUND", 66 | path: ["user"], 67 | locations: [], 68 | message: "Could not fetch user", 69 | }, 70 | ], 71 | }; 72 | 73 | const mock = new MockAdapter(axios); 74 | 75 | const faker = (query, data) => { 76 | const req = { 77 | query: { 78 | username: "anuraghazra", 79 | ...query, 80 | }, 81 | }; 82 | const res = { 83 | setHeader: jest.fn(), 84 | send: jest.fn(), 85 | }; 86 | mock 87 | .onPost("https://api.github.com/graphql") 88 | .replyOnce(200, data) 89 | .onPost("https://api.github.com/graphql") 90 | .replyOnce(200, repositoriesData); 91 | 92 | return { req, res }; 93 | }; 94 | 95 | afterEach(() => { 96 | mock.reset(); 97 | }); 98 | 99 | describe("Test /api/", () => { 100 | it("should test the request", async () => { 101 | const { req, res } = faker({}, data); 102 | 103 | await api(req, res); 104 | 105 | expect(res.setHeader).toBeCalledWith("Content-Type", "image/svg+xml"); 106 | expect(res.send).toBeCalledWith(renderStatsCard(stats, { ...req.query })); 107 | }); 108 | 109 | it("should render error card on error", async () => { 110 | const { req, res } = faker({}, error); 111 | 112 | await api(req, res); 113 | 114 | expect(res.setHeader).toBeCalledWith("Content-Type", "image/svg+xml"); 115 | expect(res.send).toBeCalledWith( 116 | renderError( 117 | error.errors[0].message, 118 | "Make sure the provided username is not an organization", 119 | ), 120 | ); 121 | }); 122 | 123 | it("should get the query options", async () => { 124 | const { req, res } = faker( 125 | { 126 | username: "anuraghazra", 127 | hide: "issues,prs,contribs", 128 | show_icons: true, 129 | hide_border: true, 130 | line_height: 100, 131 | title_color: "fff", 132 | icon_color: "fff", 133 | text_color: "fff", 134 | bg_color: "fff", 135 | }, 136 | data, 137 | ); 138 | 139 | await api(req, res); 140 | 141 | expect(res.setHeader).toBeCalledWith("Content-Type", "image/svg+xml"); 142 | expect(res.send).toBeCalledWith( 143 | renderStatsCard(stats, { 144 | hide: ["issues", "prs", "contribs"], 145 | show_icons: true, 146 | hide_border: true, 147 | line_height: 100, 148 | title_color: "fff", 149 | icon_color: "fff", 150 | text_color: "fff", 151 | bg_color: "fff", 152 | }), 153 | ); 154 | }); 155 | 156 | it("should have proper cache", async () => { 157 | const { req, res } = faker({}, data); 158 | 159 | await api(req, res); 160 | 161 | expect(res.setHeader.mock.calls).toEqual([ 162 | ["Content-Type", "image/svg+xml"], 163 | ["Cache-Control", `public, max-age=${CONSTANTS.FOUR_HOURS}`], 164 | ]); 165 | }); 166 | 167 | it("should set proper cache", async () => { 168 | const { req, res } = faker({ cache_seconds: 15000 }, data); 169 | await api(req, res); 170 | 171 | expect(res.setHeader.mock.calls).toEqual([ 172 | ["Content-Type", "image/svg+xml"], 173 | ["Cache-Control", `public, max-age=${15000}`], 174 | ]); 175 | }); 176 | 177 | it("should not store cache when error", async () => { 178 | const { req, res } = faker({}, error); 179 | await api(req, res); 180 | 181 | expect(res.setHeader.mock.calls).toEqual([ 182 | ["Content-Type", "image/svg+xml"], 183 | ["Cache-Control", `no-cache, no-store, must-revalidate`], 184 | ]); 185 | }); 186 | 187 | it("should set proper cache with clamped values", async () => { 188 | { 189 | let { req, res } = faker({ cache_seconds: 200000 }, data); 190 | await api(req, res); 191 | 192 | expect(res.setHeader.mock.calls).toEqual([ 193 | ["Content-Type", "image/svg+xml"], 194 | ["Cache-Control", `public, max-age=${CONSTANTS.ONE_DAY}`], 195 | ]); 196 | } 197 | 198 | // note i'm using block scoped vars 199 | { 200 | let { req, res } = faker({ cache_seconds: 0 }, data); 201 | await api(req, res); 202 | 203 | expect(res.setHeader.mock.calls).toEqual([ 204 | ["Content-Type", "image/svg+xml"], 205 | ["Cache-Control", `public, max-age=${CONSTANTS.FOUR_HOURS}`], 206 | ]); 207 | } 208 | 209 | { 210 | let { req, res } = faker({ cache_seconds: -10000 }, data); 211 | await api(req, res); 212 | 213 | expect(res.setHeader.mock.calls).toEqual([ 214 | ["Content-Type", "image/svg+xml"], 215 | ["Cache-Control", `public, max-age=${CONSTANTS.FOUR_HOURS}`], 216 | ]); 217 | } 218 | }); 219 | 220 | it("should add private contributions", async () => { 221 | const { req, res } = faker( 222 | { 223 | username: "anuraghazra", 224 | count_private: true, 225 | }, 226 | data, 227 | ); 228 | 229 | await api(req, res); 230 | 231 | expect(res.setHeader).toBeCalledWith("Content-Type", "image/svg+xml"); 232 | expect(res.send).toBeCalledWith( 233 | renderStatsCard( 234 | { 235 | ...stats, 236 | totalCommits: stats.totalCommits + 100, 237 | rank: calculateRank({ 238 | totalCommits: stats.totalCommits + 100, 239 | totalRepos: 1, 240 | followers: 0, 241 | contributions: stats.contributedTo, 242 | stargazers: stats.totalStars, 243 | prs: stats.totalPRs, 244 | issues: stats.totalIssues, 245 | }), 246 | }, 247 | {}, 248 | ), 249 | ); 250 | }); 251 | }); 252 | -------------------------------------------------------------------------------- /tests/fetchStats.test.js: -------------------------------------------------------------------------------- 1 | import "@testing-library/jest-dom"; 2 | import axios from "axios"; 3 | import MockAdapter from "axios-mock-adapter"; 4 | import { calculateRank } from "../src/calculateRank.js"; 5 | import { fetchStats } from "../src/fetchers/stats-fetcher.js"; 6 | 7 | const data = { 8 | data: { 9 | user: { 10 | name: "Anurag Hazra", 11 | repositoriesContributedTo: { totalCount: 61 }, 12 | contributionsCollection: { 13 | totalCommitContributions: 100, 14 | restrictedContributionsCount: 50, 15 | }, 16 | pullRequests: { totalCount: 300 }, 17 | openIssues: { totalCount: 100 }, 18 | closedIssues: { totalCount: 100 }, 19 | followers: { totalCount: 100 }, 20 | repositories: { 21 | totalCount: 5, 22 | }, 23 | }, 24 | }, 25 | }; 26 | 27 | const firstRepositoriesData = { 28 | data: { 29 | user: { 30 | repositories: { 31 | nodes: [ 32 | { name: "test-repo-1", stargazers: { totalCount: 100 } }, 33 | { name: "test-repo-2", stargazers: { totalCount: 100 } }, 34 | { name: "test-repo-3", stargazers: { totalCount: 100 } }, 35 | ], 36 | pageInfo: { 37 | hasNextPage: true, 38 | cursor: "cursor", 39 | }, 40 | }, 41 | }, 42 | }, 43 | }; 44 | 45 | const secondRepositoriesData = { 46 | data: { 47 | user: { 48 | repositories: { 49 | nodes: [ 50 | { name: "test-repo-4", stargazers: { totalCount: 50 } }, 51 | { name: "test-repo-5", stargazers: { totalCount: 50 } }, 52 | ], 53 | pageInfo: { 54 | hasNextPage: false, 55 | cursor: "cursor", 56 | }, 57 | }, 58 | }, 59 | }, 60 | }; 61 | 62 | const repositoriesWithZeroStarsData = { 63 | data: { 64 | user: { 65 | repositories: { 66 | nodes: [ 67 | { name: "test-repo-1", stargazers: { totalCount: 100 } }, 68 | { name: "test-repo-2", stargazers: { totalCount: 100 } }, 69 | { name: "test-repo-3", stargazers: { totalCount: 100 } }, 70 | { name: "test-repo-4", stargazers: { totalCount: 0 } }, 71 | { name: "test-repo-5", stargazers: { totalCount: 0 } }, 72 | ], 73 | pageInfo: { 74 | hasNextPage: true, 75 | cursor: "cursor", 76 | }, 77 | }, 78 | }, 79 | }, 80 | }; 81 | 82 | const error = { 83 | errors: [ 84 | { 85 | type: "NOT_FOUND", 86 | path: ["user"], 87 | locations: [], 88 | message: "Could not resolve to a User with the login of 'noname'.", 89 | }, 90 | ], 91 | }; 92 | 93 | const mock = new MockAdapter(axios); 94 | 95 | beforeEach(() => { 96 | mock 97 | .onPost("https://api.github.com/graphql") 98 | .replyOnce(200, data) 99 | .onPost("https://api.github.com/graphql") 100 | .replyOnce(200, firstRepositoriesData); 101 | // .onPost("https://api.github.com/graphql") // NOTE: Temporarily disable fetching of multiple pages. Done because of #2130. 102 | // .replyOnce(200, secondRepositoriesData); // NOTE: Temporarily disable fetching of multiple pages. Done because of #2130. 103 | }); 104 | 105 | afterEach(() => { 106 | mock.reset(); 107 | }); 108 | 109 | describe("Test fetchStats", () => { 110 | it("should fetch correct stats", async () => { 111 | let stats = await fetchStats("anuraghazra"); 112 | const rank = calculateRank({ 113 | totalCommits: 100, 114 | totalRepos: 5, 115 | followers: 100, 116 | contributions: 61, 117 | // stargazers: 400, // NOTE: Temporarily disable fetching of multiple pages. Done because of #2130. 118 | stargazers: 300, // NOTE: Temporarily disable fetching of multiple pages. Done because of #2130. 119 | prs: 300, 120 | issues: 200, 121 | }); 122 | 123 | expect(stats).toStrictEqual({ 124 | contributedTo: 61, 125 | name: "Anurag Hazra", 126 | totalCommits: 100, 127 | totalIssues: 200, 128 | totalPRs: 300, 129 | // totalStars: 400, // NOTE: Temporarily disable fetching of multiple pages. Done because of #2130. 130 | totalStars: 300, // NOTE: Temporarily disable fetching of multiple pages. Done because of #2130. 131 | rank, 132 | }); 133 | }); 134 | 135 | it("should stop fetching when there are repos with zero stars", async () => { 136 | mock.reset(); 137 | mock 138 | .onPost("https://api.github.com/graphql") 139 | .replyOnce(200, data) 140 | .onPost("https://api.github.com/graphql") 141 | .replyOnce(200, repositoriesWithZeroStarsData); 142 | 143 | let stats = await fetchStats("anuraghazra"); 144 | const rank = calculateRank({ 145 | totalCommits: 100, 146 | totalRepos: 5, 147 | followers: 100, 148 | contributions: 61, 149 | stargazers: 300, 150 | prs: 300, 151 | issues: 200, 152 | }); 153 | 154 | expect(stats).toStrictEqual({ 155 | contributedTo: 61, 156 | name: "Anurag Hazra", 157 | totalCommits: 100, 158 | totalIssues: 200, 159 | totalPRs: 300, 160 | totalStars: 300, 161 | rank, 162 | }); 163 | }); 164 | 165 | it("should throw error", async () => { 166 | mock.reset(); 167 | mock.onPost("https://api.github.com/graphql").reply(200, error); 168 | 169 | await expect(fetchStats("anuraghazra")).rejects.toThrow( 170 | "Could not resolve to a User with the login of 'noname'.", 171 | ); 172 | }); 173 | 174 | it("should fetch and add private contributions", async () => { 175 | let stats = await fetchStats("anuraghazra", true); 176 | const rank = calculateRank({ 177 | totalCommits: 150, 178 | totalRepos: 5, 179 | followers: 100, 180 | contributions: 61, 181 | // stargazers: 400, // NOTE: Temporarily disable fetching of multiple pages. Done because of #2130. 182 | stargazers: 300, // NOTE: Temporarily disable fetching of multiple pages. Done because of #2130. 183 | prs: 300, 184 | issues: 200, 185 | }); 186 | 187 | expect(stats).toStrictEqual({ 188 | contributedTo: 61, 189 | name: "Anurag Hazra", 190 | totalCommits: 150, 191 | totalIssues: 200, 192 | totalPRs: 300, 193 | // totalStars: 400, // NOTE: Temporarily disable fetching of multiple pages. Done because of #2130. 194 | totalStars: 300, // NOTE: Temporarily disable fetching of multiple pages. Done because of #2130. 195 | rank, 196 | }); 197 | }); 198 | 199 | it("should fetch total commits", async () => { 200 | mock 201 | .onGet("https://api.github.com/search/commits?q=author:anuraghazra") 202 | .reply(200, { total_count: 1000 }); 203 | 204 | let stats = await fetchStats("anuraghazra", true, true); 205 | const rank = calculateRank({ 206 | totalCommits: 1050, 207 | totalRepos: 5, 208 | followers: 100, 209 | contributions: 61, 210 | // stargazers: 400, // NOTE: Temporarily disable fetching of multiple pages. Done because of #2130. 211 | stargazers: 300, // NOTE: Temporarily disable fetching of multiple pages. Done because of #2130. 212 | prs: 300, 213 | issues: 200, 214 | }); 215 | 216 | expect(stats).toStrictEqual({ 217 | contributedTo: 61, 218 | name: "Anurag Hazra", 219 | totalCommits: 1050, 220 | totalIssues: 200, 221 | totalPRs: 300, 222 | // totalStars: 400, // NOTE: Temporarily disable fetching of multiple pages. Done because of #2130. 223 | totalStars: 300, // NOTE: Temporarily disable fetching of multiple pages. Done because of #2130. 224 | rank, 225 | }); 226 | }); 227 | 228 | it("should exclude stars of the `test-repo-1` repository", async () => { 229 | mock 230 | .onGet("https://api.github.com/search/commits?q=author:anuraghazra") 231 | .reply(200, { total_count: 1000 }); 232 | 233 | let stats = await fetchStats("anuraghazra", true, true, ["test-repo-1"]); 234 | const rank = calculateRank({ 235 | totalCommits: 1050, 236 | totalRepos: 5, 237 | followers: 100, 238 | contributions: 61, 239 | // stargazers: 300, // NOTE: Temporarily disable fetching of multiple pages. Done because of #2130. 240 | stargazers: 200, // NOTE: Temporarily disable fetching of multiple pages. Done because of #2130. 241 | prs: 300, 242 | issues: 200, 243 | }); 244 | 245 | expect(stats).toStrictEqual({ 246 | contributedTo: 61, 247 | name: "Anurag Hazra", 248 | totalCommits: 1050, 249 | totalIssues: 200, 250 | totalPRs: 300, 251 | // totalStars: 300, // NOTE: Temporarily disable fetching of multiple pages. Done because of #2130. 252 | totalStars: 200, // NOTE: Temporarily disable fetching of multiple pages. Done because of #2130. 253 | rank, 254 | }); 255 | }); 256 | }); 257 | -------------------------------------------------------------------------------- /tests/__snapshots__/renderWakatimeCard.test.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`Test Render Wakatime Card should render correctly 1`] = `[Function]`; 4 | 5 | exports[`Test Render Wakatime Card should render correctly with compact layout 1`] = ` 6 | " 7 | 16 | 17 | 18 | 78 | 79 | 80 | 81 | 92 | 93 | 94 | 98 | 99 | Wakatime Stats 105 | 106 | 107 | 108 | 109 | 113 | 114 | 115 | 116 | 117 | 118 | 119 | 120 | 129 | 130 | 139 | 140 | 141 | 142 | 143 | 144 | Other - 19 mins 145 | 146 | 147 | 148 | 149 | 150 | 151 | TypeScript - 1 min 152 | 153 | 154 | 155 | 156 | 157 | 158 | 159 | 160 | " 161 | `; 162 | 163 | exports[`Test Render Wakatime Card should render correctly with compact layout when langs_count is set 1`] = ` 164 | " 165 | 174 | 175 | 176 | 236 | 237 | 238 | 239 | 250 | 251 | 252 | 256 | 257 | Wakatime Stats 263 | 264 | 265 | 266 | 267 | 271 | 272 | 273 | 274 | 275 | 276 | 277 | 278 | 287 | 288 | 297 | 298 | 299 | 300 | 301 | 302 | Other - 19 mins 303 | 304 | 305 | 306 | 307 | 308 | 309 | TypeScript - 1 min 310 | 311 | 312 | 313 | 314 | 315 | 316 | 317 | 318 | " 319 | `; 320 | -------------------------------------------------------------------------------- /src/fetchers/stats-fetcher.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | import axios from "axios"; 3 | import * as dotenv from "dotenv"; 4 | import githubUsernameRegex from "github-username-regex"; 5 | import { calculateRank } from "../calculateRank.js"; 6 | import { retryer } from "../common/retryer.js"; 7 | import { 8 | CustomError, 9 | logger, 10 | MissingParamError, 11 | request, 12 | wrapTextMultiline, 13 | } from "../common/utils.js"; 14 | 15 | dotenv.config(); 16 | 17 | /** 18 | * Stats fetcher object. 19 | * 20 | * @param {import('axios').AxiosRequestHeaders} variables Fetcher variables. 21 | * @param {string} token GitHub token. 22 | * @returns {Promise} Stats fetcher response. 23 | */ 24 | const fetcher = (variables, token) => { 25 | return request( 26 | { 27 | query: ` 28 | query userInfo($login: String!) { 29 | user(login: $login) { 30 | name 31 | login 32 | contributionsCollection { 33 | totalCommitContributions 34 | restrictedContributionsCount 35 | } 36 | repositoriesContributedTo(contributionTypes: [COMMIT, ISSUE, PULL_REQUEST, REPOSITORY]) { 37 | totalCount 38 | } 39 | pullRequests { 40 | totalCount 41 | } 42 | openIssues: issues(states: OPEN) { 43 | totalCount 44 | } 45 | closedIssues: issues(states: CLOSED) { 46 | totalCount 47 | } 48 | followers { 49 | totalCount 50 | } 51 | repositories(ownerAffiliations: OWNER) { 52 | totalCount 53 | } 54 | } 55 | } 56 | `, 57 | variables, 58 | }, 59 | { 60 | Authorization: `bearer ${token}`, 61 | }, 62 | ); 63 | }; 64 | 65 | /** 66 | * Fetch first 100 repositories for a given username. 67 | * 68 | * @param {import('axios').AxiosRequestHeaders} variables Fetcher variables. 69 | * @param {string} token GitHub token. 70 | * @returns {Promise} Repositories fetcher response. 71 | */ 72 | const repositoriesFetcher = (variables, token) => { 73 | return request( 74 | { 75 | query: ` 76 | query userInfo($login: String!, $after: String) { 77 | user(login: $login) { 78 | repositories(first: 100, ownerAffiliations: OWNER, orderBy: {direction: DESC, field: STARGAZERS}, after: $after) { 79 | nodes { 80 | name 81 | stargazers { 82 | totalCount 83 | } 84 | } 85 | pageInfo { 86 | hasNextPage 87 | endCursor 88 | } 89 | } 90 | } 91 | } 92 | `, 93 | variables, 94 | }, 95 | { 96 | Authorization: `bearer ${token}`, 97 | }, 98 | ); 99 | }; 100 | 101 | /** 102 | * Fetch all the commits for all the repositories of a given username. 103 | * 104 | * @param {*} username GitHub username. 105 | * @returns {Promise} Total commits. 106 | * 107 | * @description Done like this because the GitHub API does not provide a way to fetch all the commits. See 108 | * #92#issuecomment-661026467 and #211 for more information. 109 | */ 110 | const totalCommitsFetcher = async (username) => { 111 | if (!githubUsernameRegex.test(username)) { 112 | logger.log("Invalid username"); 113 | return 0; 114 | } 115 | 116 | // https://developer.github.com/v3/search/#search-commits 117 | const fetchTotalCommits = (variables, token) => { 118 | return axios({ 119 | method: "get", 120 | url: `https://api.github.com/search/commits?q=author:${variables.login}`, 121 | headers: { 122 | "Content-Type": "application/json", 123 | Accept: "application/vnd.github.cloak-preview", 124 | Authorization: `token ${token}`, 125 | }, 126 | }); 127 | }; 128 | 129 | try { 130 | let res = await retryer(fetchTotalCommits, { login: username }); 131 | let total_count = res.data.total_count; 132 | if (!!total_count && !isNaN(total_count)) { 133 | return res.data.total_count; 134 | } 135 | } catch (err) { 136 | logger.log(err); 137 | } 138 | // just return 0 if there is something wrong so that 139 | // we don't break the whole app 140 | return 0; 141 | }; 142 | 143 | /** 144 | * Fetch all the stars for all the repositories of a given username. 145 | * 146 | * @param {string} username GitHub username. 147 | * @param {array} repoToHide Repositories to hide. 148 | * @returns {Promise} Total stars. 149 | */ 150 | const totalStarsFetcher = async (username, repoToHide) => { 151 | let nodes = []; 152 | let hasNextPage = true; 153 | let endCursor = null; 154 | while (hasNextPage) { 155 | const variables = { login: username, first: 100, after: endCursor }; 156 | let res = await retryer(repositoriesFetcher, variables); 157 | 158 | if (res.data.errors) { 159 | logger.error(res.data.errors); 160 | throw new CustomError( 161 | res.data.errors[0].message || "Could not fetch user", 162 | CustomError.USER_NOT_FOUND, 163 | ); 164 | } 165 | 166 | const allNodes = res.data.data.user.repositories.nodes; 167 | const nodesWithStars = allNodes.filter( 168 | (node) => node.stargazers.totalCount !== 0, 169 | ); 170 | nodes.push(...nodesWithStars); 171 | // hasNextPage = 172 | // allNodes.length === nodesWithStars.length && 173 | // res.data.data.user.repositories.pageInfo.hasNextPage; 174 | hasNextPage = false; // NOTE: Temporarily disable fetching of multiple pages. Done because of #2130. 175 | endCursor = res.data.data.user.repositories.pageInfo.endCursor; 176 | } 177 | 178 | return nodes 179 | .filter((data) => !repoToHide[data.name]) 180 | .reduce((prev, curr) => prev + curr.stargazers.totalCount, 0); 181 | }; 182 | 183 | /** 184 | * Fetch stats for a given username. 185 | * 186 | * @param {string} username GitHub username. 187 | * @param {boolean} count_private Include private contributions. 188 | * @param {boolean} include_all_commits Include all commits. 189 | * @returns {Promise} Stats data. 190 | */ 191 | async function fetchStats( 192 | username, 193 | count_private = false, 194 | include_all_commits = false, 195 | exclude_repo = [], 196 | ) { 197 | if (!username) throw new MissingParamError(["username"]); 198 | 199 | const stats = { 200 | name: "", 201 | totalPRs: 0, 202 | totalCommits: 0, 203 | totalIssues: 0, 204 | totalStars: 0, 205 | contributedTo: 0, 206 | rank: { level: "C", score: 0 }, 207 | }; 208 | 209 | let res = await retryer(fetcher, { login: username }); 210 | 211 | // Catch GraphQL errors. 212 | if (res.data.errors) { 213 | logger.error(res.data.errors); 214 | if (res.data.errors[0].type === "NOT_FOUND") { 215 | throw new CustomError( 216 | res.data.errors[0].message || "Could not fetch user.", 217 | CustomError.USER_NOT_FOUND, 218 | ); 219 | } 220 | if (res.data.errors[0].message) { 221 | throw new CustomError( 222 | wrapTextMultiline(res.data.errors[0].message, 90, 1)[0], 223 | res.statusText, 224 | ); 225 | } 226 | throw new CustomError( 227 | "Something went while trying to retrieve the stats data using the GraphQL API.", 228 | CustomError.GRAPHQL_ERROR, 229 | ); 230 | } 231 | 232 | const user = res.data.data.user; 233 | 234 | // populate repoToHide map for quick lookup 235 | // while filtering out 236 | let repoToHide = {}; 237 | if (exclude_repo) { 238 | exclude_repo.forEach((repoName) => { 239 | repoToHide[repoName] = true; 240 | }); 241 | } 242 | 243 | stats.name = user.name || user.login; 244 | stats.totalIssues = user.openIssues.totalCount + user.closedIssues.totalCount; 245 | 246 | // normal commits 247 | stats.totalCommits = user.contributionsCollection.totalCommitContributions; 248 | 249 | // if include_all_commits then just get that, 250 | // since totalCommitsFetcher already sends totalCommits no need to += 251 | if (include_all_commits) { 252 | stats.totalCommits = await totalCommitsFetcher(username); 253 | } 254 | 255 | // if count_private then add private commits to totalCommits so far. 256 | if (count_private) { 257 | stats.totalCommits += 258 | user.contributionsCollection.restrictedContributionsCount; 259 | } 260 | 261 | stats.totalPRs = user.pullRequests.totalCount; 262 | stats.contributedTo = user.repositoriesContributedTo.totalCount; 263 | 264 | // Retrieve stars while filtering out repositories to be hidden 265 | stats.totalStars = await totalStarsFetcher(username, repoToHide); 266 | 267 | stats.rank = calculateRank({ 268 | totalCommits: stats.totalCommits, 269 | totalRepos: user.repositories.totalCount, 270 | followers: user.followers.totalCount, 271 | contributions: stats.contributedTo, 272 | stargazers: stats.totalStars, 273 | prs: stats.totalPRs, 274 | issues: stats.totalIssues, 275 | }); 276 | 277 | return stats; 278 | } 279 | 280 | export { fetchStats }; 281 | export default fetchStats; 282 | -------------------------------------------------------------------------------- /themes/index.js: -------------------------------------------------------------------------------- 1 | export const themes = { 2 | default: { 3 | title_color: "2f80ed", 4 | icon_color: "4c71f2", 5 | text_color: "434d58", 6 | bg_color: "fffefe", 7 | border_color: "e4e2e2", 8 | }, 9 | default_repocard: { 10 | title_color: "2f80ed", 11 | icon_color: "586069", // icon color is different 12 | text_color: "434d58", 13 | bg_color: "fffefe", 14 | }, 15 | transparent: { 16 | title_color: "006AFF", 17 | icon_color: "0579C3", 18 | text_color: "417E87", 19 | bg_color: "ffffff00", 20 | }, 21 | dark: { 22 | title_color: "fff", 23 | icon_color: "79ff97", 24 | text_color: "9f9f9f", 25 | bg_color: "151515", 26 | }, 27 | radical: { 28 | title_color: "fe428e", 29 | icon_color: "f8d847", 30 | text_color: "a9fef7", 31 | bg_color: "141321", 32 | }, 33 | merko: { 34 | title_color: "abd200", 35 | icon_color: "b7d364", 36 | text_color: "68b587", 37 | bg_color: "0a0f0b", 38 | }, 39 | gruvbox: { 40 | title_color: "fabd2f", 41 | icon_color: "fe8019", 42 | text_color: "8ec07c", 43 | bg_color: "282828", 44 | }, 45 | gruvbox_light: { 46 | title_color: "b57614", 47 | icon_color: "af3a03", 48 | text_color: "427b58", 49 | bg_color: "fbf1c7", 50 | }, 51 | tokyonight: { 52 | title_color: "70a5fd", 53 | icon_color: "bf91f3", 54 | text_color: "38bdae", 55 | bg_color: "1a1b27", 56 | }, 57 | onedark: { 58 | title_color: "e4bf7a", 59 | icon_color: "8eb573", 60 | text_color: "df6d74", 61 | bg_color: "282c34", 62 | }, 63 | cobalt: { 64 | title_color: "e683d9", 65 | icon_color: "0480ef", 66 | text_color: "75eeb2", 67 | bg_color: "193549", 68 | }, 69 | synthwave: { 70 | title_color: "e2e9ec", 71 | icon_color: "ef8539", 72 | text_color: "e5289e", 73 | bg_color: "2b213a", 74 | }, 75 | highcontrast: { 76 | title_color: "e7f216", 77 | icon_color: "00ffff", 78 | text_color: "fff", 79 | bg_color: "000", 80 | }, 81 | dracula: { 82 | title_color: "ff6e96", 83 | icon_color: "79dafa", 84 | text_color: "f8f8f2", 85 | bg_color: "282a36", 86 | }, 87 | prussian: { 88 | title_color: "bddfff", 89 | icon_color: "38a0ff", 90 | text_color: "6e93b5", 91 | bg_color: "172f45", 92 | }, 93 | monokai: { 94 | title_color: "eb1f6a", 95 | icon_color: "e28905", 96 | text_color: "f1f1eb", 97 | bg_color: "272822", 98 | }, 99 | vue: { 100 | title_color: "41b883", 101 | icon_color: "41b883", 102 | text_color: "273849", 103 | bg_color: "fffefe", 104 | }, 105 | "vue-dark": { 106 | title_color: "41b883", 107 | icon_color: "41b883", 108 | text_color: "fffefe", 109 | bg_color: "273849", 110 | }, 111 | "shades-of-purple": { 112 | title_color: "fad000", 113 | icon_color: "b362ff", 114 | text_color: "a599e9", 115 | bg_color: "2d2b55", 116 | }, 117 | nightowl: { 118 | title_color: "c792ea", 119 | icon_color: "ffeb95", 120 | text_color: "7fdbca", 121 | bg_color: "011627", 122 | }, 123 | buefy: { 124 | title_color: "7957d5", 125 | icon_color: "ff3860", 126 | text_color: "363636", 127 | bg_color: "ffffff", 128 | }, 129 | "blue-green": { 130 | title_color: "2f97c1", 131 | icon_color: "f5b700", 132 | text_color: "0cf574", 133 | bg_color: "040f0f", 134 | }, 135 | algolia: { 136 | title_color: "00AEFF", 137 | icon_color: "2DDE98", 138 | text_color: "FFFFFF", 139 | bg_color: "050F2C", 140 | }, 141 | "great-gatsby": { 142 | title_color: "ffa726", 143 | icon_color: "ffb74d", 144 | text_color: "ffd95b", 145 | bg_color: "000000", 146 | }, 147 | darcula: { 148 | title_color: "BA5F17", 149 | icon_color: "84628F", 150 | text_color: "BEBEBE", 151 | bg_color: "242424", 152 | }, 153 | bear: { 154 | title_color: "e03c8a", 155 | icon_color: "00AEFF", 156 | text_color: "bcb28d", 157 | bg_color: "1f2023", 158 | }, 159 | "solarized-dark": { 160 | title_color: "268bd2", 161 | icon_color: "b58900", 162 | text_color: "859900", 163 | bg_color: "002b36", 164 | }, 165 | "solarized-light": { 166 | title_color: "268bd2", 167 | icon_color: "b58900", 168 | text_color: "859900", 169 | bg_color: "fdf6e3", 170 | }, 171 | "chartreuse-dark": { 172 | title_color: "7fff00", 173 | icon_color: "00AEFF", 174 | text_color: "fff", 175 | bg_color: "000", 176 | }, 177 | nord: { 178 | title_color: "81a1c1", 179 | text_color: "d8dee9", 180 | icon_color: "88c0d0", 181 | bg_color: "2e3440", 182 | }, 183 | gotham: { 184 | title_color: "2aa889", 185 | icon_color: "599cab", 186 | text_color: "99d1ce", 187 | bg_color: "0c1014", 188 | }, 189 | "material-palenight": { 190 | title_color: "c792ea", 191 | icon_color: "89ddff", 192 | text_color: "a6accd", 193 | bg_color: "292d3e", 194 | }, 195 | graywhite: { 196 | title_color: "24292e", 197 | icon_color: "24292e", 198 | text_color: "24292e", 199 | bg_color: "ffffff", 200 | }, 201 | "vision-friendly-dark": { 202 | title_color: "ffb000", 203 | icon_color: "785ef0", 204 | text_color: "ffffff", 205 | bg_color: "000000", 206 | }, 207 | "ayu-mirage": { 208 | title_color: "f4cd7c", 209 | icon_color: "73d0ff", 210 | text_color: "c7c8c2", 211 | bg_color: "1f2430", 212 | }, 213 | "midnight-purple": { 214 | title_color: "9745f5", 215 | icon_color: "9f4bff", 216 | text_color: "ffffff", 217 | bg_color: "000000", 218 | }, 219 | calm: { 220 | title_color: "e07a5f", 221 | icon_color: "edae49", 222 | text_color: "ebcfb2", 223 | bg_color: "373f51", 224 | }, 225 | "flag-india": { 226 | title_color: "ff8f1c", 227 | icon_color: "250E62", 228 | text_color: "509E2F", 229 | bg_color: "ffffff", 230 | }, 231 | omni: { 232 | title_color: "FF79C6", 233 | icon_color: "e7de79", 234 | text_color: "E1E1E6", 235 | bg_color: "191622", 236 | }, 237 | react: { 238 | title_color: "61dafb", 239 | icon_color: "61dafb", 240 | text_color: "ffffff", 241 | bg_color: "20232a", 242 | }, 243 | jolly: { 244 | title_color: "ff64da", 245 | icon_color: "a960ff", 246 | text_color: "ffffff", 247 | bg_color: "291B3E", 248 | }, 249 | maroongold: { 250 | title_color: "F7EF8A", 251 | icon_color: "F7EF8A", 252 | text_color: "E0AA3E", 253 | bg_color: "260000", 254 | }, 255 | yeblu: { 256 | title_color: "ffff00", 257 | icon_color: "ffff00", 258 | text_color: "ffffff", 259 | bg_color: "002046", 260 | }, 261 | blueberry: { 262 | title_color: "82aaff", 263 | icon_color: "89ddff", 264 | text_color: "27e8a7", 265 | bg_color: "242938", 266 | }, 267 | slateorange: { 268 | title_color: "faa627", 269 | icon_color: "faa627", 270 | text_color: "ffffff", 271 | bg_color: "36393f", 272 | }, 273 | kacho_ga: { 274 | title_color: "bf4a3f", 275 | icon_color: "a64833", 276 | text_color: "d9c8a9", 277 | bg_color: "402b23", 278 | }, 279 | outrun: { 280 | title_color: "ffcc00", 281 | icon_color: "ff1aff", 282 | text_color: "8080ff", 283 | bg_color: "141439", 284 | }, 285 | ocean_dark: { 286 | title_color: "8957B2", 287 | icon_color: "FFFFFF", 288 | text_color: "92D534", 289 | bg_color: "151A28", 290 | }, 291 | city_lights: { 292 | title_color: "5D8CB3", 293 | icon_color: "4798FF", 294 | text_color: "718CA1", 295 | bg_color: "1D252C", 296 | }, 297 | github_dark: { 298 | title_color: "58A6FF", 299 | icon_color: "1F6FEB", 300 | text_color: "C3D1D9", 301 | bg_color: "0D1117", 302 | }, 303 | discord_old_blurple: { 304 | title_color: "7289DA", 305 | icon_color: "7289DA", 306 | text_color: "FFFFFF", 307 | bg_color: "2C2F33", 308 | }, 309 | aura_dark: { 310 | title_color: "ff7372", 311 | icon_color: "6cffd0", 312 | text_color: "dbdbdb", 313 | bg_color: "252334", 314 | }, 315 | panda: { 316 | title_color: "19f9d899", 317 | icon_color: "19f9d899", 318 | text_color: "FF75B5", 319 | bg_color: "31353a", 320 | }, 321 | noctis_minimus: { 322 | title_color: "d3b692", 323 | icon_color: "72b7c0", 324 | text_color: "c5cdd3", 325 | bg_color: "1b2932", 326 | }, 327 | cobalt2: { 328 | title_color: "ffc600", 329 | icon_color: "ffffff", 330 | text_color: "0088ff", 331 | bg_color: "193549", 332 | }, 333 | swift: { 334 | title_color: "000000", 335 | icon_color: "f05237", 336 | text_color: "000000", 337 | bg_color: "f7f7f7", 338 | }, 339 | aura: { 340 | title_color: "a277ff", 341 | icon_color: "ffca85", 342 | text_color: "61ffca", 343 | bg_color: "15141b", 344 | }, 345 | apprentice: { 346 | title_color: "ffffff", 347 | icon_color: "ffffaf", 348 | text_color: "bcbcbc", 349 | bg_color: "262626", 350 | }, 351 | moltack: { 352 | title_color: "86092C", 353 | icon_color: "86092C", 354 | text_color: "574038", 355 | bg_color: "F5E1C0", 356 | }, 357 | codeSTACKr: { 358 | title_color: "ff652f", 359 | icon_color: "FFE400", 360 | text_color: "ffffff", 361 | bg_color: "09131B", 362 | border_color: "0c1a25", 363 | }, 364 | rose_pine: { 365 | title_color: "9ccfd8", 366 | icon_color: "ebbcba", 367 | text_color: "e0def4", 368 | bg_color: "191724", 369 | }, 370 | }; 371 | 372 | export default themes; 373 | -------------------------------------------------------------------------------- /tests/renderTopLanguages.test.js: -------------------------------------------------------------------------------- 1 | import { queryAllByTestId, queryByTestId } from "@testing-library/dom"; 2 | import { cssToObject } from "@uppercod/css-to-object"; 3 | import { 4 | MIN_CARD_WIDTH, 5 | renderTopLanguages, 6 | } from "../src/cards/top-languages-card.js"; 7 | // adds special assertions like toHaveTextContent 8 | import "@testing-library/jest-dom"; 9 | 10 | import { themes } from "../themes/index.js"; 11 | 12 | const langs = { 13 | HTML: { 14 | color: "#0f0", 15 | name: "HTML", 16 | size: 200, 17 | }, 18 | javascript: { 19 | color: "#0ff", 20 | name: "javascript", 21 | size: 200, 22 | }, 23 | css: { 24 | color: "#ff0", 25 | name: "css", 26 | size: 100, 27 | }, 28 | }; 29 | 30 | describe("Test renderTopLanguages", () => { 31 | it("should render correctly", () => { 32 | document.body.innerHTML = renderTopLanguages(langs); 33 | 34 | expect(queryByTestId(document.body, "header")).toHaveTextContent( 35 | "Most Used Languages", 36 | ); 37 | 38 | expect(queryAllByTestId(document.body, "lang-name")[0]).toHaveTextContent( 39 | "HTML", 40 | ); 41 | expect(queryAllByTestId(document.body, "lang-name")[1]).toHaveTextContent( 42 | "javascript", 43 | ); 44 | expect(queryAllByTestId(document.body, "lang-name")[2]).toHaveTextContent( 45 | "css", 46 | ); 47 | expect(queryAllByTestId(document.body, "lang-progress")[0]).toHaveAttribute( 48 | "width", 49 | "40%", 50 | ); 51 | expect(queryAllByTestId(document.body, "lang-progress")[1]).toHaveAttribute( 52 | "width", 53 | "40%", 54 | ); 55 | expect(queryAllByTestId(document.body, "lang-progress")[2]).toHaveAttribute( 56 | "width", 57 | "20%", 58 | ); 59 | }); 60 | 61 | it("should hide languages when hide is passed", () => { 62 | document.body.innerHTML = renderTopLanguages(langs, { 63 | hide: ["HTML"], 64 | }); 65 | expect(queryAllByTestId(document.body, "lang-name")[0]).toBeInTheDocument( 66 | "javascript", 67 | ); 68 | expect(queryAllByTestId(document.body, "lang-name")[1]).toBeInTheDocument( 69 | "css", 70 | ); 71 | expect(queryAllByTestId(document.body, "lang-name")[2]).not.toBeDefined(); 72 | 73 | // multiple languages passed 74 | document.body.innerHTML = renderTopLanguages(langs, { 75 | hide: ["HTML", "css"], 76 | }); 77 | expect(queryAllByTestId(document.body, "lang-name")[0]).toBeInTheDocument( 78 | "javascript", 79 | ); 80 | expect(queryAllByTestId(document.body, "lang-name")[1]).not.toBeDefined(); 81 | }); 82 | 83 | it("should resize the height correctly depending on langs", () => { 84 | document.body.innerHTML = renderTopLanguages(langs, {}); 85 | expect(document.querySelector("svg")).toHaveAttribute("height", "205"); 86 | 87 | document.body.innerHTML = renderTopLanguages( 88 | { 89 | ...langs, 90 | python: { 91 | color: "#ff0", 92 | name: "python", 93 | size: 100, 94 | }, 95 | }, 96 | {}, 97 | ); 98 | expect(document.querySelector("svg")).toHaveAttribute("height", "245"); 99 | }); 100 | 101 | it("should render with custom width set", () => { 102 | document.body.innerHTML = renderTopLanguages(langs, {}); 103 | 104 | expect(document.querySelector("svg")).toHaveAttribute("width", "300"); 105 | 106 | document.body.innerHTML = renderTopLanguages(langs, { card_width: 400 }); 107 | expect(document.querySelector("svg")).toHaveAttribute("width", "400"); 108 | }); 109 | 110 | it("should render with min width", () => { 111 | document.body.innerHTML = renderTopLanguages(langs, { card_width: 190 }); 112 | 113 | expect(document.querySelector("svg")).toHaveAttribute( 114 | "width", 115 | MIN_CARD_WIDTH.toString(), 116 | ); 117 | 118 | document.body.innerHTML = renderTopLanguages(langs, { card_width: 100 }); 119 | expect(document.querySelector("svg")).toHaveAttribute( 120 | "width", 121 | MIN_CARD_WIDTH.toString(), 122 | ); 123 | }); 124 | 125 | it("should render default colors properly", () => { 126 | document.body.innerHTML = renderTopLanguages(langs); 127 | 128 | const styleTag = document.querySelector("style"); 129 | const stylesObject = cssToObject(styleTag.textContent); 130 | 131 | const headerStyles = stylesObject[":host"][".header "]; 132 | const langNameStyles = stylesObject[":host"][".lang-name "]; 133 | 134 | expect(headerStyles.fill.trim()).toBe("#2f80ed"); 135 | expect(langNameStyles.fill.trim()).toBe("#434d58"); 136 | expect(queryByTestId(document.body, "card-bg")).toHaveAttribute( 137 | "fill", 138 | "#fffefe", 139 | ); 140 | }); 141 | 142 | it("should render custom colors properly", () => { 143 | const customColors = { 144 | title_color: "5a0", 145 | icon_color: "1b998b", 146 | text_color: "9991", 147 | bg_color: "252525", 148 | }; 149 | 150 | document.body.innerHTML = renderTopLanguages(langs, { ...customColors }); 151 | 152 | const styleTag = document.querySelector("style"); 153 | const stylesObject = cssToObject(styleTag.innerHTML); 154 | 155 | const headerStyles = stylesObject[":host"][".header "]; 156 | const langNameStyles = stylesObject[":host"][".lang-name "]; 157 | 158 | expect(headerStyles.fill.trim()).toBe(`#${customColors.title_color}`); 159 | expect(langNameStyles.fill.trim()).toBe(`#${customColors.text_color}`); 160 | expect(queryByTestId(document.body, "card-bg")).toHaveAttribute( 161 | "fill", 162 | "#252525", 163 | ); 164 | }); 165 | 166 | it("should render custom colors with themes", () => { 167 | document.body.innerHTML = renderTopLanguages(langs, { 168 | title_color: "5a0", 169 | theme: "radical", 170 | }); 171 | 172 | const styleTag = document.querySelector("style"); 173 | const stylesObject = cssToObject(styleTag.innerHTML); 174 | 175 | const headerStyles = stylesObject[":host"][".header "]; 176 | const langNameStyles = stylesObject[":host"][".lang-name "]; 177 | 178 | expect(headerStyles.fill.trim()).toBe("#5a0"); 179 | expect(langNameStyles.fill.trim()).toBe(`#${themes.radical.text_color}`); 180 | expect(queryByTestId(document.body, "card-bg")).toHaveAttribute( 181 | "fill", 182 | `#${themes.radical.bg_color}`, 183 | ); 184 | }); 185 | 186 | it("should render with all the themes", () => { 187 | Object.keys(themes).forEach((name) => { 188 | document.body.innerHTML = renderTopLanguages(langs, { 189 | theme: name, 190 | }); 191 | 192 | const styleTag = document.querySelector("style"); 193 | const stylesObject = cssToObject(styleTag.innerHTML); 194 | 195 | const headerStyles = stylesObject[":host"][".header "]; 196 | const langNameStyles = stylesObject[":host"][".lang-name "]; 197 | 198 | expect(headerStyles.fill.trim()).toBe(`#${themes[name].title_color}`); 199 | expect(langNameStyles.fill.trim()).toBe(`#${themes[name].text_color}`); 200 | expect(queryByTestId(document.body, "card-bg")).toHaveAttribute( 201 | "fill", 202 | `#${themes[name].bg_color}`, 203 | ); 204 | }); 205 | }); 206 | 207 | it("should render with layout compact", () => { 208 | document.body.innerHTML = renderTopLanguages(langs, { layout: "compact" }); 209 | 210 | expect(queryByTestId(document.body, "header")).toHaveTextContent( 211 | "Most Used Languages", 212 | ); 213 | 214 | expect(queryAllByTestId(document.body, "lang-name")[0]).toHaveTextContent( 215 | "HTML 40.00%", 216 | ); 217 | expect(queryAllByTestId(document.body, "lang-progress")[0]).toHaveAttribute( 218 | "width", 219 | "120", 220 | ); 221 | 222 | expect(queryAllByTestId(document.body, "lang-name")[1]).toHaveTextContent( 223 | "javascript 40.00%", 224 | ); 225 | expect(queryAllByTestId(document.body, "lang-progress")[1]).toHaveAttribute( 226 | "width", 227 | "120", 228 | ); 229 | 230 | expect(queryAllByTestId(document.body, "lang-name")[2]).toHaveTextContent( 231 | "css 20.00%", 232 | ); 233 | expect(queryAllByTestId(document.body, "lang-progress")[2]).toHaveAttribute( 234 | "width", 235 | "60", 236 | ); 237 | }); 238 | 239 | it("should render a translated title", () => { 240 | document.body.innerHTML = renderTopLanguages(langs, { locale: "cn" }); 241 | expect(document.getElementsByClassName("header")[0].textContent).toBe( 242 | "最常用的语言", 243 | ); 244 | }); 245 | 246 | it("should render without rounding", () => { 247 | document.body.innerHTML = renderTopLanguages(langs, { border_radius: "0" }); 248 | expect(document.querySelector("rect")).toHaveAttribute("rx", "0"); 249 | document.body.innerHTML = renderTopLanguages(langs, {}); 250 | expect(document.querySelector("rect")).toHaveAttribute("rx", "4.5"); 251 | }); 252 | 253 | it("should render langs with specified langs_count", async () => { 254 | const options = { 255 | langs_count: 1, 256 | }; 257 | document.body.innerHTML = renderTopLanguages(langs, { ...options }); 258 | expect(queryAllByTestId(document.body, "lang-name").length).toBe( 259 | options.langs_count, 260 | ); 261 | }); 262 | 263 | it("should render langs with specified langs_count even when hide is set", async () => { 264 | const options = { 265 | hide: ["HTML"], 266 | langs_count: 2, 267 | }; 268 | document.body.innerHTML = renderTopLanguages(langs, { ...options }); 269 | expect(queryAllByTestId(document.body, "lang-name").length).toBe( 270 | options.langs_count, 271 | ); 272 | }); 273 | }); 274 | -------------------------------------------------------------------------------- /src/cards/stats-card.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | import { Card } from "../common/Card.js"; 3 | import { I18n } from "../common/I18n.js"; 4 | import { icons } from "../common/icons.js"; 5 | import { 6 | clampValue, 7 | flexLayout, 8 | getCardColors, 9 | kFormatter, 10 | measureText, 11 | } from "../common/utils.js"; 12 | import { getStyles } from "../getStyles.js"; 13 | import { statCardLocales } from "../translations.js"; 14 | 15 | /** 16 | * Create a stats card text item. 17 | * 18 | * @param {object[]} createTextNodeParams Object that contains the createTextNode parameters. 19 | * @param {string} createTextNodeParams.label The label to display. 20 | * @param {string} createTextNodeParams.value The value to display. 21 | * @param {string} createTextNodeParams.id The id of the stat. 22 | * @param {number} createTextNodeParams.index The index of the stat. 23 | * @param {boolean} createTextNodeParams.showIcons Whether to show icons. 24 | * @param {number} createTextNodeParams.shiftValuePos Number of pixels the value has to be shifted to the right. 25 | * @param {boolean} createTextNodeParams.bold Whether to bold the label. 26 | * @returns 27 | */ 28 | const createTextNode = ({ 29 | icon, 30 | label, 31 | value, 32 | id, 33 | index, 34 | showIcons, 35 | shiftValuePos, 36 | bold, 37 | }) => { 38 | const kValue = kFormatter(value); 39 | const staggerDelay = (index + 3) * 150; 40 | 41 | const labelOffset = showIcons ? `x="25"` : ""; 42 | const iconSvg = showIcons 43 | ? ` 44 | 45 | ${icon} 46 | 47 | ` 48 | : ""; 49 | return ` 50 | 51 | ${iconSvg} 52 | ${label}: 55 | ${kValue} 61 | 62 | `; 63 | }; 64 | 65 | /** 66 | * Renders the stats card. 67 | * 68 | * @param {Partial} stats The stats data. 69 | * @param {Partial} options The card options. 70 | * @returns {string} The stats card SVG object. 71 | */ 72 | const renderStatsCard = (stats = {}, options = { hide: [] }) => { 73 | const { 74 | name, 75 | totalStars, 76 | totalCommits, 77 | totalIssues, 78 | totalPRs, 79 | contributedTo, 80 | rank, 81 | } = stats; 82 | const { 83 | hide = [], 84 | show_icons = false, 85 | hide_title = false, 86 | hide_border = false, 87 | card_width, 88 | hide_rank = false, 89 | include_all_commits = false, 90 | line_height = 25, 91 | title_color, 92 | icon_color, 93 | text_color, 94 | text_bold = true, 95 | bg_color, 96 | theme = "default", 97 | custom_title, 98 | border_radius, 99 | border_color, 100 | locale, 101 | disable_animations = false, 102 | } = options; 103 | 104 | const lheight = parseInt(String(line_height), 10); 105 | 106 | // returns theme based colors with proper overrides and defaults 107 | const { titleColor, textColor, iconColor, bgColor, borderColor } = 108 | getCardColors({ 109 | title_color, 110 | icon_color, 111 | text_color, 112 | bg_color, 113 | border_color, 114 | theme, 115 | }); 116 | 117 | const apostrophe = ["x", "s"].includes(name.slice(-1).toLocaleLowerCase()) 118 | ? "" 119 | : "s"; 120 | const i18n = new I18n({ 121 | locale, 122 | translations: statCardLocales({ name, apostrophe }), 123 | }); 124 | 125 | // Meta data for creating text nodes with createTextNode function 126 | const STATS = { 127 | stars: { 128 | icon: icons.star, 129 | label: i18n.t("statcard.totalstars"), 130 | value: totalStars, 131 | id: "stars", 132 | }, 133 | commits: { 134 | icon: icons.commits, 135 | label: `${i18n.t("statcard.commits")}${ 136 | include_all_commits ? "" : ` (${new Date().getFullYear()})` 137 | }`, 138 | value: totalCommits, 139 | id: "commits", 140 | }, 141 | prs: { 142 | icon: icons.prs, 143 | label: i18n.t("statcard.prs"), 144 | value: totalPRs, 145 | id: "prs", 146 | }, 147 | issues: { 148 | icon: icons.issues, 149 | label: i18n.t("statcard.issues"), 150 | value: totalIssues, 151 | id: "issues", 152 | }, 153 | contribs: { 154 | icon: icons.contribs, 155 | label: i18n.t("statcard.contribs"), 156 | value: contributedTo, 157 | id: "contribs", 158 | }, 159 | }; 160 | 161 | const longLocales = [ 162 | "cn", 163 | "es", 164 | "fr", 165 | "pt-br", 166 | "ru", 167 | "uk-ua", 168 | "id", 169 | "my", 170 | "pl", 171 | "de", 172 | "nl", 173 | "zh-tw", 174 | ]; 175 | const isLongLocale = longLocales.includes(locale) === true; 176 | 177 | // filter out hidden stats defined by user & create the text nodes 178 | const statItems = Object.keys(STATS) 179 | .filter((key) => !hide.includes(key)) 180 | .map((key, index) => 181 | // create the text nodes, and pass index so that we can calculate the line spacing 182 | createTextNode({ 183 | ...STATS[key], 184 | index, 185 | showIcons: show_icons, 186 | shiftValuePos: 187 | (!include_all_commits ? 50 : 35) + (isLongLocale ? 50 : 0), 188 | bold: text_bold, 189 | }), 190 | ); 191 | 192 | // Calculate the card height depending on how many items there are 193 | // but if rank circle is visible clamp the minimum height to `150` 194 | let height = Math.max( 195 | 45 + (statItems.length + 1) * lheight, 196 | hide_rank ? 0 : 150, 197 | ); 198 | 199 | // the better user's score the the rank will be closer to zero so 200 | // subtracting 100 to get the progress in 100% 201 | const progress = 100 - rank.score; 202 | const cssStyles = getStyles({ 203 | titleColor, 204 | textColor, 205 | iconColor, 206 | show_icons, 207 | progress, 208 | }); 209 | 210 | const calculateTextWidth = () => { 211 | return measureText(custom_title ? custom_title : i18n.t("statcard.title")); 212 | }; 213 | 214 | /* 215 | When hide_rank=true, the minimum card width is 270 px + the title length and padding. 216 | When hide_rank=false, the minimum card_width is 340 px + the icon width (if show_icons=true). 217 | Numbers are picked by looking at existing dimensions on production. 218 | */ 219 | const iconWidth = show_icons ? 16 : 0; 220 | const minCardWidth = hide_rank 221 | ? clampValue(50 /* padding */ + calculateTextWidth() * 2, 270, Infinity) 222 | : 340 + iconWidth; 223 | const defaultCardWidth = hide_rank ? 270 : 495; 224 | let width = isNaN(card_width) ? defaultCardWidth : card_width; 225 | if (width < minCardWidth) { 226 | width = minCardWidth; 227 | } 228 | 229 | const card = new Card({ 230 | customTitle: custom_title, 231 | defaultTitle: i18n.t("statcard.title"), 232 | width, 233 | height, 234 | border_radius, 235 | colors: { 236 | titleColor, 237 | textColor, 238 | iconColor, 239 | bgColor, 240 | borderColor, 241 | }, 242 | }); 243 | 244 | card.setHideBorder(hide_border); 245 | card.setHideTitle(hide_title); 246 | card.setCSS(cssStyles); 247 | 248 | if (disable_animations) card.disableAnimations(); 249 | 250 | /** 251 | * Calculates the right rank circle translation values such that the rank circle 252 | * keeps respecting the padding. 253 | * 254 | * width > 450: The default left padding of 50 px will be used. 255 | * width < 450: The left and right padding will shrink equally. 256 | * 257 | * @returns {number} - Rank circle translation value. 258 | */ 259 | const calculateRankXTranslation = () => { 260 | if (width < 450) { 261 | return width - 95 + (45 * (450 - 340)) / 110; 262 | } else { 263 | return width - 95; 264 | } 265 | }; 266 | 267 | // Conditionally rendered elements 268 | const rankCircle = hide_rank 269 | ? "" 270 | : ` 274 | 275 | 276 | 277 | 284 | ${rank.level} 285 | 286 | 287 | `; 288 | 289 | // Accessibility Labels 290 | const labels = Object.keys(STATS) 291 | .filter((key) => !hide.includes(key)) 292 | .map((key) => { 293 | if (key === "commits") { 294 | return `${i18n.t("statcard.commits")} ${ 295 | include_all_commits ? "" : `in ${new Date().getFullYear()}` 296 | } : ${totalStars}`; 297 | } 298 | return `${STATS[key].label}: ${STATS[key].value}`; 299 | }) 300 | .join(", "); 301 | 302 | card.setAccessibilityLabel({ 303 | title: `${card.title}, Rank: ${rank.level}`, 304 | desc: labels, 305 | }); 306 | 307 | return card.render(` 308 | ${rankCircle} 309 | 310 | ${flexLayout({ 311 | items: statItems, 312 | gap: lheight, 313 | direction: "column", 314 | }).join("")} 315 | 316 | `); 317 | }; 318 | 319 | export { renderStatsCard }; 320 | export default renderStatsCard; 321 | -------------------------------------------------------------------------------- /src/cards/wakatime-card.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | import { Card } from "../common/Card.js"; 3 | import { createProgressNode } from "../common/createProgressNode.js"; 4 | import { I18n } from "../common/I18n.js"; 5 | import { 6 | clampValue, 7 | flexLayout, 8 | getCardColors, 9 | lowercaseTrim, 10 | } from "../common/utils.js"; 11 | import { getStyles } from "../getStyles.js"; 12 | import { wakatimeCardLocales } from "../translations.js"; 13 | 14 | /** Import language colors. 15 | * 16 | * @description Here we use the workaround found in 17 | * https://stackoverflow.com/questions/66726365/how-should-i-import-json-in-node 18 | * since vercel is using v16.14.0 which does not yet support json imports without the 19 | * --experimental-json-modules flag. 20 | */ 21 | import { createRequire } from "module"; 22 | const require = createRequire(import.meta.url); 23 | const languageColors = require("../common/languageColors.json"); // now works 24 | 25 | /** 26 | * Creates the no coding activity SVG node. 27 | * 28 | * @param {{color: string, text: string}} The function prop 29 | */ 30 | const noCodingActivityNode = ({ color, text }) => { 31 | return ` 32 | ${text} 33 | `; 34 | }; 35 | 36 | /** 37 | * Create compact WakaTime layout. 38 | * 39 | * @param {Object[]} args The function arguments. 40 | * @param {import("../fetchers/types").WakaTimeLang[]} languages The languages array. 41 | * @param {number} totalSize The total size of the languages. 42 | * @param {number} x The x position of the language node. 43 | * @param {number} y The y position of the language node. 44 | */ 45 | const createCompactLangNode = ({ lang, totalSize, x, y }) => { 46 | const color = languageColors[lang.name] || "#858585"; 47 | 48 | return ` 49 | 50 | 51 | 52 | ${lang.name} - ${lang.text} 53 | 54 | 55 | `; 56 | }; 57 | 58 | /** 59 | * Create WakaTime language text node item. 60 | * 61 | * @param {Object[]} args The function arguments. 62 | * @param {import("../fetchers/types").WakaTimeLang} lang The language object. 63 | * @param {number} totalSize The total size of the languages. 64 | * @param {number} x The x position of the language node. 65 | * @param {number} y The y position of the language node. 66 | */ 67 | const createLanguageTextNode = ({ langs, totalSize, x, y }) => { 68 | return langs.map((lang, index) => { 69 | if (index % 2 === 0) { 70 | return createCompactLangNode({ 71 | lang, 72 | x: 25, 73 | y: 12.5 * index + y, 74 | totalSize, 75 | }); 76 | } 77 | return createCompactLangNode({ 78 | lang, 79 | x: 230, 80 | y: 12.5 + 12.5 * index, 81 | totalSize, 82 | }); 83 | }); 84 | }; 85 | 86 | /** 87 | * Create WakaTime text item. 88 | * 89 | * @param {Object[]} args The function arguments. 90 | * @param {string} id The id of the text node item. 91 | * @param {string} label The label of the text node item. 92 | * @param {string} value The value of the text node item. 93 | * @param {number} index The index of the text node item. 94 | * @param {percent} percent Percentage of the text node item. 95 | * @param {boolean} hideProgress Whether to hide the progress bar. 96 | * @param {string} progressBarBackgroundColor The color of the progress bar background. 97 | */ 98 | const createTextNode = ({ 99 | id, 100 | label, 101 | value, 102 | index, 103 | percent, 104 | hideProgress, 105 | progressBarColor, 106 | progressBarBackgroundColor, 107 | }) => { 108 | const staggerDelay = (index + 3) * 150; 109 | 110 | const cardProgress = hideProgress 111 | ? null 112 | : createProgressNode({ 113 | x: 110, 114 | y: 4, 115 | progress: percent, 116 | color: progressBarColor, 117 | width: 220, 118 | // @ts-ignore 119 | name: label, 120 | progressBarBackgroundColor, 121 | }); 122 | 123 | return ` 124 | 125 | ${label}: 126 | ${value} 131 | ${cardProgress} 132 | 133 | `; 134 | }; 135 | 136 | /** 137 | * Recalculating percentages so that, compact layout's progress bar does not break when 138 | * hiding languages. 139 | * 140 | * @param {import("../fetchers/types").WakaTimeLang[]} languages The languages array. 141 | * @return {import("../fetchers/types").WakaTimeLang[]} The recalculated languages array. 142 | */ 143 | const recalculatePercentages = (languages) => { 144 | const totalSum = languages.reduce( 145 | (totalSum, language) => totalSum + language.percent, 146 | 0, 147 | ); 148 | const weight = +(100 / totalSum).toFixed(2); 149 | languages.forEach((language) => { 150 | language.percent = +(language.percent * weight).toFixed(2); 151 | }); 152 | }; 153 | 154 | /** 155 | * Renders WakaTime card. 156 | * 157 | * @param {Partial} stats WakaTime stats. 158 | * @param {Partial} options Card options. 159 | * @returns {string} WakaTime card SVG. 160 | */ 161 | const renderWakatimeCard = (stats = {}, options = { hide: [] }) => { 162 | let { languages = [] } = stats; 163 | const { 164 | hide_title = false, 165 | hide_border = false, 166 | hide, 167 | line_height = 25, 168 | title_color, 169 | icon_color, 170 | text_color, 171 | bg_color, 172 | theme = "default", 173 | hide_progress, 174 | custom_title, 175 | locale, 176 | layout, 177 | langs_count = languages.length, 178 | border_radius, 179 | border_color, 180 | } = options; 181 | 182 | const shouldHideLangs = Array.isArray(hide) && hide.length > 0; 183 | if (shouldHideLangs) { 184 | const languagesToHide = new Set(hide.map((lang) => lowercaseTrim(lang))); 185 | languages = languages.filter( 186 | (lang) => !languagesToHide.has(lowercaseTrim(lang.name)), 187 | ); 188 | } 189 | 190 | // Since the percentages are sorted in descending order, we can just 191 | // slice from the beginning without sorting. 192 | languages = languages.slice(0, langs_count); 193 | recalculatePercentages(languages); 194 | 195 | const i18n = new I18n({ 196 | locale, 197 | translations: wakatimeCardLocales, 198 | }); 199 | 200 | const lheight = parseInt(String(line_height), 10); 201 | 202 | const langsCount = clampValue(parseInt(String(langs_count)), 1, langs_count); 203 | 204 | // returns theme based colors with proper overrides and defaults 205 | const { titleColor, textColor, iconColor, bgColor, borderColor } = 206 | getCardColors({ 207 | title_color, 208 | icon_color, 209 | text_color, 210 | bg_color, 211 | border_color, 212 | theme, 213 | }); 214 | 215 | const filteredLanguages = languages 216 | .filter((language) => language.hours || language.minutes) 217 | .slice(0, langsCount); 218 | 219 | // Calculate the card height depending on how many items there are 220 | // but if rank circle is visible clamp the minimum height to `150` 221 | let height = Math.max(45 + (filteredLanguages.length + 1) * lheight, 150); 222 | 223 | const cssStyles = getStyles({ 224 | titleColor, 225 | textColor, 226 | iconColor, 227 | }); 228 | 229 | let finalLayout = ""; 230 | 231 | let width = 440; 232 | 233 | // RENDER COMPACT LAYOUT 234 | if (layout === "compact") { 235 | width = width + 50; 236 | height = 90 + Math.round(filteredLanguages.length / 2) * 25; 237 | 238 | // progressOffset holds the previous language's width and used to offset the next language 239 | // so that we can stack them one after another, like this: [--][----][---] 240 | let progressOffset = 0; 241 | const compactProgressBar = filteredLanguages 242 | .map((language) => { 243 | // const progress = (width * lang.percent) / 100; 244 | const progress = ((width - 25) * language.percent) / 100; 245 | 246 | const languageColor = languageColors[language.name] || "#858585"; 247 | 248 | const output = ` 249 | 258 | `; 259 | progressOffset += progress; 260 | return output; 261 | }) 262 | .join(""); 263 | 264 | finalLayout = ` 265 | 266 | 267 | 268 | ${compactProgressBar} 269 | ${createLanguageTextNode({ 270 | x: 0, 271 | y: 25, 272 | langs: filteredLanguages, 273 | totalSize: 100, 274 | }).join("")} 275 | `; 276 | } else { 277 | finalLayout = flexLayout({ 278 | items: filteredLanguages.length 279 | ? filteredLanguages.map((language) => { 280 | return createTextNode({ 281 | id: language.name, 282 | label: language.name, 283 | value: language.text, 284 | percent: language.percent, 285 | // @ts-ignore 286 | progressBarColor: titleColor, 287 | // @ts-ignore 288 | progressBarBackgroundColor: textColor, 289 | hideProgress: hide_progress, 290 | }); 291 | }) 292 | : [ 293 | noCodingActivityNode({ 294 | // @ts-ignore 295 | color: textColor, 296 | text: i18n.t("wakatimecard.nocodingactivity"), 297 | }), 298 | ], 299 | gap: lheight, 300 | direction: "column", 301 | }).join(""); 302 | } 303 | 304 | const card = new Card({ 305 | customTitle: custom_title, 306 | defaultTitle: i18n.t("wakatimecard.title"), 307 | width: 495, 308 | height, 309 | border_radius, 310 | colors: { 311 | titleColor, 312 | textColor, 313 | iconColor, 314 | bgColor, 315 | borderColor, 316 | }, 317 | }); 318 | 319 | card.setHideBorder(hide_border); 320 | card.setHideTitle(hide_title); 321 | card.setCSS( 322 | ` 323 | ${cssStyles} 324 | .lang-name { font: 400 11px 'Segoe UI', Ubuntu, Sans-Serif; fill: ${textColor} } 325 | `, 326 | ); 327 | 328 | return card.render(` 329 | 330 | ${finalLayout} 331 | 332 | `); 333 | }; 334 | 335 | export { renderWakatimeCard }; 336 | export default renderWakatimeCard; 337 | -------------------------------------------------------------------------------- /src/cards/top-languages-card.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | import { Card } from "../common/Card.js"; 3 | import { createProgressNode } from "../common/createProgressNode.js"; 4 | import { I18n } from "../common/I18n.js"; 5 | import { 6 | chunkArray, 7 | clampValue, 8 | flexLayout, 9 | getCardColors, 10 | lowercaseTrim, 11 | measureText, 12 | } from "../common/utils.js"; 13 | import { langCardLocales } from "../translations.js"; 14 | 15 | const DEFAULT_CARD_WIDTH = 300; 16 | const MIN_CARD_WIDTH = 230; 17 | const DEFAULT_LANGS_COUNT = 5; 18 | const DEFAULT_LANG_COLOR = "#858585"; 19 | const CARD_PADDING = 25; 20 | 21 | /** 22 | * @typedef {import("../fetchers/types").Lang} Lang 23 | */ 24 | 25 | /** 26 | * Retrieves the programming language whose name is the longest. 27 | * 28 | * @param {Lang[]} arr Array of programming languages. 29 | * @returns {Object} Longest programming language object. 30 | */ 31 | const getLongestLang = (arr) => 32 | arr.reduce( 33 | (savedLang, lang) => 34 | lang.name.length > savedLang.name.length ? lang : savedLang, 35 | { name: "", size: null, color: "" }, 36 | ); 37 | 38 | /** 39 | * Creates a node to display usage of a programming language in percentage 40 | * using text and a horizontal progress bar. 41 | * 42 | * @param {object[]} props Function properties. 43 | * @param {number} props.width The card width 44 | * @param {string} props.name Name of the programming language. 45 | * @param {string} props.color Color of the programming language. 46 | * @param {string} props.progress Usage of the programming language in percentage. 47 | * @returns {string} Programming language SVG node. 48 | */ 49 | const createProgressTextNode = ({ width, color, name, progress }) => { 50 | const paddingRight = 95; 51 | const progressTextX = width - paddingRight + 10; 52 | const progressWidth = width - paddingRight; 53 | 54 | return ` 55 | ${name} 56 | ${progress}% 57 | ${createProgressNode({ 58 | x: 0, 59 | y: 25, 60 | color, 61 | width: progressWidth, 62 | progress, 63 | progressBarBackgroundColor: "#ddd", 64 | })} 65 | `; 66 | }; 67 | 68 | /** 69 | * Creates a text only node to display usage of a programming language in percentage. 70 | * 71 | * @param {object[]} props Function properties. 72 | * @param {Lang} props.lang Programming language object. 73 | * @param {number} props.totalSize Total size of all languages. 74 | * @returns {string} Compact layout programming language SVG node. 75 | */ 76 | const createCompactLangNode = ({ lang, totalSize }) => { 77 | const percentage = ((lang.size / totalSize) * 100).toFixed(2); 78 | const color = lang.color || "#858585"; 79 | 80 | return ` 81 | 82 | 83 | 84 | ${lang.name} ${percentage}% 85 | 86 | 87 | `; 88 | }; 89 | 90 | /** 91 | * Creates compact layout of text only language nodes. 92 | * 93 | * @param {object[]} props Function properties. 94 | * @param {Lang[]} props.langs Array of programming languages. 95 | * @param {number} props.totalSize Total size of all languages. 96 | * @returns {string} Programming languages SVG node. 97 | */ 98 | const createLanguageTextNode = ({ langs, totalSize }) => { 99 | const longestLang = getLongestLang(langs); 100 | const chunked = chunkArray(langs, langs.length / 2); 101 | const layouts = chunked.map((array) => { 102 | // @ts-ignore 103 | const items = array.map((lang, index) => 104 | createCompactLangNode({ 105 | lang, 106 | totalSize, 107 | // @ts-ignore 108 | index, 109 | }), 110 | ); 111 | return flexLayout({ 112 | items, 113 | gap: 25, 114 | direction: "column", 115 | }).join(""); 116 | }); 117 | 118 | const percent = ((longestLang.size / totalSize) * 100).toFixed(2); 119 | const minGap = 150; 120 | const maxGap = 20 + measureText(`${longestLang.name} ${percent}%`, 11); 121 | return flexLayout({ 122 | items: layouts, 123 | gap: maxGap < minGap ? minGap : maxGap, 124 | }).join(""); 125 | }; 126 | 127 | /** 128 | * Renders layout to display user's most frequently used programming languages. 129 | * 130 | * @param {Lang[]} langs Array of programming languages. 131 | * @param {number} width Card width. 132 | * @param {number} totalLanguageSize Total size of all languages. 133 | * @returns {string} Normal layout card SVG object. 134 | */ 135 | const renderNormalLayout = (langs, width, totalLanguageSize) => { 136 | return flexLayout({ 137 | items: langs.map((lang) => { 138 | return createProgressTextNode({ 139 | width: width, 140 | name: lang.name, 141 | color: lang.color || DEFAULT_LANG_COLOR, 142 | progress: ((lang.size / totalLanguageSize) * 100).toFixed(2), 143 | }); 144 | }), 145 | gap: 40, 146 | direction: "column", 147 | }).join(""); 148 | }; 149 | 150 | /** 151 | * Renders compact layout to display user's most frequently used programming languages. 152 | * 153 | * @param {Lang[]} langs Array of programming languages. 154 | * @param {number} width Card width. 155 | * @param {number} totalLanguageSize Total size of all languages. 156 | * @returns {string} Compact layout card SVG object. 157 | */ 158 | const renderCompactLayout = (langs, width, totalLanguageSize) => { 159 | const paddingRight = 50; 160 | const offsetWidth = width - paddingRight; 161 | // progressOffset holds the previous language's width and used to offset the next language 162 | // so that we can stack them one after another, like this: [--][----][---] 163 | let progressOffset = 0; 164 | const compactProgressBar = langs 165 | .map((lang) => { 166 | const percentage = parseFloat( 167 | ((lang.size / totalLanguageSize) * offsetWidth).toFixed(2), 168 | ); 169 | 170 | const progress = percentage < 10 ? percentage + 10 : percentage; 171 | 172 | const output = ` 173 | 182 | `; 183 | progressOffset += percentage; 184 | return output; 185 | }) 186 | .join(""); 187 | 188 | return ` 189 | 190 | 191 | 192 | ${compactProgressBar} 193 | 194 | 195 | ${createLanguageTextNode({ 196 | langs, 197 | totalSize: totalLanguageSize, 198 | })} 199 | 200 | `; 201 | }; 202 | 203 | /** 204 | * Calculates height for the compact layout. 205 | * 206 | * @param {number} totalLangs Total number of languages. 207 | * @returns {number} Card height. 208 | */ 209 | const calculateCompactLayoutHeight = (totalLangs) => { 210 | return 90 + Math.round(totalLangs / 2) * 25; 211 | }; 212 | 213 | /** 214 | * Calculates height for the normal layout. 215 | * 216 | * @param {number} totalLangs Total number of languages. 217 | * @returns {number} Card height. 218 | */ 219 | const calculateNormalLayoutHeight = (totalLangs) => { 220 | return 45 + (totalLangs + 1) * 40; 221 | }; 222 | 223 | /** 224 | * Hides languages and trims the list to show only the top N languages. 225 | * 226 | * @param {Record} topLangs Top languages. 227 | * @param {string[]} hide Languages to hide. 228 | * @param {string} langs_count Number of languages to show. 229 | */ 230 | const useLanguages = (topLangs, hide, langs_count) => { 231 | let langs = Object.values(topLangs); 232 | let langsToHide = {}; 233 | let langsCount = clampValue(parseInt(langs_count), 1, 10); 234 | 235 | // populate langsToHide map for quick lookup 236 | // while filtering out 237 | if (hide) { 238 | hide.forEach((langName) => { 239 | langsToHide[lowercaseTrim(langName)] = true; 240 | }); 241 | } 242 | 243 | // filter out languages to be hidden 244 | langs = langs 245 | .sort((a, b) => b.size - a.size) 246 | .filter((lang) => { 247 | return !langsToHide[lowercaseTrim(lang.name)]; 248 | }) 249 | .slice(0, langsCount); 250 | 251 | const totalLanguageSize = langs.reduce((acc, curr) => acc + curr.size, 0); 252 | 253 | return { langs, totalLanguageSize }; 254 | }; 255 | 256 | /** 257 | * Renders card to display user's most frequently used programming languages. 258 | * 259 | * @param {import('../fetchers/types').TopLangData} topLangs User's most frequently used programming languages. 260 | * @param {Partial} options Card options. 261 | * @returns {string} Language card SVG object. 262 | */ 263 | const renderTopLanguages = (topLangs, options = {}) => { 264 | const { 265 | hide_title = false, 266 | hide_border, 267 | card_width, 268 | title_color, 269 | text_color, 270 | bg_color, 271 | hide, 272 | theme, 273 | layout, 274 | custom_title, 275 | locale, 276 | langs_count = DEFAULT_LANGS_COUNT, 277 | border_radius, 278 | border_color, 279 | } = options; 280 | 281 | const i18n = new I18n({ 282 | locale, 283 | translations: langCardLocales, 284 | }); 285 | 286 | const { langs, totalLanguageSize } = useLanguages( 287 | topLangs, 288 | hide, 289 | String(langs_count), 290 | ); 291 | 292 | let width = isNaN(card_width) 293 | ? DEFAULT_CARD_WIDTH 294 | : card_width < MIN_CARD_WIDTH 295 | ? MIN_CARD_WIDTH 296 | : card_width; 297 | let height = calculateNormalLayoutHeight(langs.length); 298 | 299 | let finalLayout = ""; 300 | if (layout === "compact") { 301 | width = width + 50; // padding 302 | height = calculateCompactLayoutHeight(langs.length); 303 | 304 | finalLayout = renderCompactLayout(langs, width, totalLanguageSize); 305 | } else { 306 | finalLayout = renderNormalLayout(langs, width, totalLanguageSize); 307 | } 308 | 309 | // returns theme based colors with proper overrides and defaults 310 | const colors = getCardColors({ 311 | title_color, 312 | text_color, 313 | bg_color, 314 | border_color, 315 | theme, 316 | }); 317 | 318 | const card = new Card({ 319 | customTitle: custom_title, 320 | defaultTitle: i18n.t("langcard.title"), 321 | width, 322 | height, 323 | border_radius, 324 | colors, 325 | }); 326 | 327 | card.disableAnimations(); 328 | card.setHideBorder(hide_border); 329 | card.setHideTitle(hide_title); 330 | card.setCSS( 331 | `.lang-name { font: 400 11px 'Segoe UI', Ubuntu, Sans-Serif; fill: ${colors.textColor} }`, 332 | ); 333 | 334 | return card.render(` 335 | 336 | ${finalLayout} 337 | 338 | `); 339 | }; 340 | 341 | export { renderTopLanguages, MIN_CARD_WIDTH }; 342 | --------------------------------------------------------------------------------