├── .deepsource.toml ├── .devcontainer ├── Dockerfile ├── devcontainer.json ├── first-run-notice.txt ├── on-create.sh └── post-create.sh ├── .env.example ├── .gitattributes ├── .github ├── FUNDING.yml ├── ISSUE_TEMPLATE │ ├── bug_report.md │ ├── feature_request.md │ ├── question.md │ └── theme.md ├── dependabot.yml ├── pull_request_template.md └── workflows │ ├── force-release.yml │ ├── phpunit-ci-coverage.yml │ ├── prettier.yml │ ├── release.yml │ └── translation-progress.yml ├── .gitignore ├── .prettierignore ├── Aptfile ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── LICENSE ├── Procfile ├── README.md ├── app.json ├── composer.json ├── composer.lock ├── docs ├── faq.md └── themes.md ├── package.json ├── scripts └── translation-progress.php ├── src ├── card.php ├── colors.php ├── demo │ ├── apple-touch-icon.png │ ├── css │ │ ├── style.css │ │ └── toggle-dark.css │ ├── favicon-16x16.png │ ├── favicon-32x32.png │ ├── icon.svg │ ├── index.php │ ├── js │ │ ├── accordion.js │ │ ├── jscolor.min.js │ │ ├── script.js │ │ └── toggle-dark.js │ └── preview.php ├── index.php ├── stats.php ├── themes.php └── translations.php └── tests ├── OptionsTest.php ├── RenderTest.php ├── StatsTest.php ├── TranslationsTest.php ├── expected ├── test_card.svg └── test_error_card.svg └── phpunit └── phpunit.xml /.deepsource.toml: -------------------------------------------------------------------------------- 1 | version = 1 2 | 3 | test_patterns = [ 4 | "tests/**" 5 | ] 6 | 7 | exclude_patterns = [ 8 | "vendor/**", 9 | "*.min.js" 10 | ] 11 | 12 | [[analyzers]] 13 | name = "php" 14 | enabled = true 15 | 16 | [analyzers.meta] 17 | skip_doc_coverage = ["class", "magic"] 18 | 19 | [[analyzers]] 20 | name = "javascript" 21 | enabled = true 22 | -------------------------------------------------------------------------------- /.devcontainer/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM mcr.microsoft.com/vscode/devcontainers/base:ubuntu-22.04 2 | 3 | ADD first-run-notice.txt /usr/local/etc/vscode-dev-containers/first-run-notice.txt 4 | 5 | RUN apt-get update -y && \ 6 | apt-get install -y php php-curl php-xml inkscape composer 7 | -------------------------------------------------------------------------------- /.devcontainer/devcontainer.json: -------------------------------------------------------------------------------- 1 | { 2 | "build": { 3 | "dockerfile": "Dockerfile" 4 | }, 5 | "onCreateCommand": "/workspaces/github-readme-streak-stats/.devcontainer/on-create.sh", 6 | "postCreateCommand": "/workspaces/github-readme-streak-stats/.devcontainer/post-create.sh"} 7 | -------------------------------------------------------------------------------- /.devcontainer/first-run-notice.txt: -------------------------------------------------------------------------------- 1 | 👋 Welcome to Codespaces! You are using the pre-configured image. 2 | 3 | Tests can be executed with: composer test 4 | -------------------------------------------------------------------------------- /.devcontainer/on-create.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -e 3 | 4 | cd /workspaces/github-readme-streak-stats 5 | composer install 6 | -------------------------------------------------------------------------------- /.devcontainer/post-create.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -e 3 | 4 | cd /workspaces/github-readme-streak-stats 5 | if [ -n "$GITHUB_TOKEN" ]; then 6 | echo "TOKEN=$GITHUB_TOKEN" > .env 7 | fi 8 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | # replace ghp_example with your GitHub PAT token 2 | TOKEN=ghp_example 3 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | # Auto detect text files and perform LF normalization 2 | * text=auto 3 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: [DenverCoder1] 2 | patreon: 3 | open_collective: 4 | ko_fi: 5 | tidelift: 6 | community_bridge: 7 | liberapay: 8 | issuehunt: 9 | otechie: 10 | custom: 11 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: "" 5 | labels: "bug" 6 | assignees: "" 7 | --- 8 | 9 | **Describe the bug** 10 | A clear and concise description of what the bug is. 11 | 12 | **To Reproduce** 13 | Steps to reproduce the behavior: 14 | 15 | 1. Go to '...' 16 | 2. Click on '....' 17 | 3. Scroll down to '....' 18 | 4. See error 19 | 20 | **Expected behavior** 21 | A clear and concise description of what you expected to happen. 22 | 23 | **Screenshots** 24 | If applicable, add screenshots to help explain your problem. 25 | 26 | **Desktop (please complete the following information):** 27 | 28 | - OS: [e.g. iOS] 29 | - Browser [e.g. chrome, safari] 30 | - Version [e.g. 22] 31 | 32 | **Smartphone (please complete the following information):** 33 | 34 | - Device: [e.g. iPhone6] 35 | - OS: [e.g. iOS8.1] 36 | - Browser [e.g. stock browser, safari] 37 | - Version [e.g. 22] 38 | 39 | **Additional context** 40 | Add any other context about the problem here. 41 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: "" 5 | labels: "enhancement" 6 | assignees: "" 7 | --- 8 | 9 | **Is your feature request related to a problem? Please describe.** 10 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 11 | 12 | **Describe the solution you'd like** 13 | A clear and concise description of what you want to happen. 14 | 15 | **Describe alternatives you've considered** 16 | A clear and concise description of any alternative solutions or features you've considered. 17 | 18 | **Additional context** 19 | Add any other context or screenshots about the feature request here. 20 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/question.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Question 3 | about: I have a question about GitHub Streak Stats 4 | title: "" 5 | labels: "question" 6 | assignees: "" 7 | --- 8 | 9 | **Description** 10 | 11 | A brief description of the question or issue: 12 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/theme.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Theme Request 3 | about: Request a theme for the project 4 | title: "" 5 | labels: "theme" 6 | assignees: "" 7 | --- 8 | 9 | **Describe your theme in detail** 10 | A clear description about what the theme would entail. 11 | 12 | **Include a screenshot / image** 13 | 14 | 15 | 16 | **Color palette** 17 | Describe the colors that could be used with this theme. 18 | 19 | Are you going to add the theme? 20 | 21 | - [ ] Check for yes 22 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # To get started with Dependabot version updates, you'll need to specify which 2 | # package ecosystems to update and where the package manifests are located. 3 | # Please see the documentation for all configuration options: 4 | # https://help.github.com/github/administering-a-repository/configuration-options-for-dependency-updates 5 | 6 | version: 2 7 | updates: 8 | - package-ecosystem: "composer" # See documentation for possible values 9 | directory: "/" # Location of package manifests 10 | schedule: 11 | interval: "daily" 12 | -------------------------------------------------------------------------------- /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | ## Description 2 | 3 | 4 | 5 | Fixes # 6 | 7 | ### Type of change 8 | 9 | 10 | 11 | - [ ] Bug fix (added a non-breaking change which fixes an issue) 12 | - [ ] New feature (added a non-breaking change which adds functionality) 13 | - [ ] Updated documentation (updated the readme, templates, or other repo files) 14 | - [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected) 15 | 16 | ## How Has This Been Tested? 17 | 18 | 22 | 23 | - [ ] Tested locally with a valid username 24 | - [ ] Tested locally with an invalid username 25 | - [ ] Ran tests with `composer test` 26 | - [ ] Added or updated test cases to test new features 27 | 28 | ## Checklist: 29 | 30 | - [ ] I have checked to make sure no other [pull requests](https://github.com/DenverCoder1/github-readme-streak-stats/pulls?q=is%3Apr+sort%3Aupdated-desc+) are open for this issue 31 | - [ ] The code is properly formatted and is consistent with the existing code style 32 | - [ ] I have commented my code, particularly in hard-to-understand areas 33 | - [ ] I have made corresponding changes to the documentation 34 | - [ ] My changes generate no new warnings 35 | 36 | ## Screenshots 37 | 38 | 39 | -------------------------------------------------------------------------------- /.github/workflows/force-release.yml: -------------------------------------------------------------------------------- 1 | name: Manual Release 2 | 3 | on: 4 | workflow_dispatch: 5 | 6 | jobs: 7 | changelog: 8 | runs-on: ubuntu-latest 9 | 10 | steps: 11 | - uses: actions/checkout@v3 12 | 13 | - name: conventional Changelog Action 14 | id: changelog 15 | uses: TriPSs/conventional-changelog-action@v3.7.1 16 | with: 17 | github-token: ${{ secrets.CHANGELOG_RELEASE }} 18 | version-file: './composer.json' 19 | output-file: 'false' 20 | skip-on-empty: 'false' 21 | 22 | - name: create release 23 | uses: actions/create-release@v1 24 | if: ${{ steps.changelog.outputs.skipped == 'false' }} 25 | env: 26 | GITHUB_TOKEN: ${{ secrets.CHANGELOG_RELEASE }} 27 | with: 28 | tag_name: ${{ steps.changelog.outputs.tag }} 29 | release_name: ${{ steps.changelog.outputs.tag }} 30 | body: ${{ steps.changelog.outputs.clean_changelog }} 31 | -------------------------------------------------------------------------------- /.github/workflows/phpunit-ci-coverage.yml: -------------------------------------------------------------------------------- 1 | name: PHPUnit CI 2 | 3 | on: 4 | workflow_dispatch: 5 | pull_request: 6 | push: 7 | branches: 8 | - main 9 | 10 | env: 11 | PHP_VERSION: 8.2 12 | 13 | jobs: 14 | build-test: 15 | runs-on: ubuntu-latest 16 | steps: 17 | - uses: actions/checkout@v3 18 | - uses: php-actions/composer@v6 19 | with: 20 | php_extensions: intl 21 | php_version: ${{ env.PHP_VERSION }} 22 | - name: PHPUnit Tests 23 | uses: php-actions/phpunit@v4 24 | with: 25 | php_extensions: intl 26 | php_version: ${{ env.PHP_VERSION }} 27 | bootstrap: vendor/autoload.php 28 | configuration: tests/phpunit/phpunit.xml 29 | args: --testdox 30 | env: 31 | TOKEN: ${{ secrets.GITHUB_TOKEN }} 32 | -------------------------------------------------------------------------------- /.github/workflows/prettier.yml: -------------------------------------------------------------------------------- 1 | name: Format with Prettier 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | paths: 9 | - "**.php" 10 | - "**.md" 11 | - "**.js" 12 | - "**.css" 13 | - ".github/workflows/prettier.yml" 14 | 15 | jobs: 16 | prettier: 17 | runs-on: ubuntu-latest 18 | steps: 19 | - name: Checkout Pull Request 20 | if: ${{ github.event_name == 'pull_request' }} 21 | uses: actions/checkout@v3 22 | with: 23 | repository: ${{ github.event.pull_request.head.repo.full_name }} 24 | ref: ${{ github.event.pull_request.head.ref }} 25 | 26 | - name: Checkout Push 27 | if: ${{ github.event_name != 'pull_request' }} 28 | uses: actions/checkout@v3 29 | 30 | - name: Install prettier and plugin-php 31 | run: npm install --global prettier@2.8.1 @prettier/plugin-php@0.18.9 32 | 33 | - name: Lint with Prettier 34 | continue-on-error: true 35 | run: composer lint 36 | 37 | - name: Prettify code 38 | run: | 39 | composer lint-fix 40 | git diff 41 | 42 | - name: Commit changes 43 | uses: EndBug/add-and-commit@v9 44 | with: 45 | message: "style: Formatted code with Prettier" 46 | default_author: github_actions 47 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Automated Releases 2 | 3 | on: 4 | workflow_dispatch: 5 | 6 | jobs: 7 | changelog: 8 | runs-on: ubuntu-latest 9 | 10 | steps: 11 | - uses: actions/checkout@v3 12 | 13 | - name: conventional Changelog Action 14 | id: changelog 15 | uses: TriPSs/conventional-changelog-action@v3.7.1 16 | with: 17 | github-token: ${{ secrets.CHANGELOG_RELEASE }} 18 | version-file: './composer.json' 19 | output-file: 'false' 20 | 21 | - name: create release 22 | uses: actions/create-release@v1 23 | if: ${{ steps.changelog.outputs.skipped == 'false' }} 24 | env: 25 | GITHUB_TOKEN: ${{ secrets.CHANGELOG_RELEASE }} 26 | with: 27 | tag_name: ${{ steps.changelog.outputs.tag }} 28 | release_name: ${{ steps.changelog.outputs.tag }} 29 | body: ${{ steps.changelog.outputs.clean_changelog }} 30 | -------------------------------------------------------------------------------- /.github/workflows/translation-progress.yml: -------------------------------------------------------------------------------- 1 | name: Update Translation Progress 2 | 3 | on: 4 | workflow_dispatch: 5 | push: 6 | branches: 7 | - main 8 | paths: 9 | - "src/translations.php" 10 | - "scripts/translation-progress.php" 11 | - ".github/workflows/translation-progress.yml" 12 | - "README.md" 13 | 14 | env: 15 | PHP_VERSION: 8.2 16 | 17 | jobs: 18 | build-test: 19 | runs-on: ubuntu-latest 20 | steps: 21 | - uses: actions/checkout@v3 22 | 23 | - uses: php-actions/composer@v6 24 | with: 25 | php_extensions: intl 26 | php_version: ${{ env.PHP_VERSION }} 27 | 28 | - name: Update Translations 29 | run: php scripts/translation-progress.php 30 | 31 | - name: Commit Changes 32 | uses: EndBug/add-and-commit@v7 33 | with: 34 | author_name: GitHub Actions 35 | author_email: github-actions[bot]@users.noreply.github.com 36 | message: "docs(readme): Update translation progress" 37 | add: "README.md" 38 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Generated files 2 | vendor/ 3 | node_modules/ 4 | *.log 5 | composer.phar 6 | yarn.lock 7 | package-lock.json 8 | .vercel 9 | 10 | # Local Configuration 11 | .DS_Store 12 | 13 | # Environment 14 | .env 15 | DOCKER_ENV 16 | docker_tag 17 | 18 | # IDE 19 | .vscode/ 20 | .idea/ -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | vendor 2 | -------------------------------------------------------------------------------- /Aptfile: -------------------------------------------------------------------------------- 1 | inkscape 2 | -------------------------------------------------------------------------------- /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 me via direct message on Twitter, Reddit, or Discord. All 59 | complaints will be reviewed and investigated and will result in a response that 60 | is deemed necessary and appropriate to the circumstances. The project team is 61 | obligated to maintain confidentiality with regard to the reporter of an incident. 62 | Further details of specific enforcement policies may be posted separately. 63 | 64 | Project maintainers who do not follow or enforce the Code of Conduct in good 65 | faith may face temporary or permanent repercussions as determined by other 66 | members of the project's leadership. 67 | 68 | ## Attribution 69 | 70 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, 71 | available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html 72 | 73 | [homepage]: https://www.contributor-covenant.org 74 | 75 | For answers to common questions about this code of conduct, see 76 | https://www.contributor-covenant.org/faq 77 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | ## Contributing Guidelines 2 | 3 | Contributions are welcome! Feel free to open an issue or submit a pull request if you have a way to improve this project. 4 | 5 | Make sure your request is meaningful and you have tested the app locally before submitting a pull request. 6 | 7 | This documentation contains a set of guidelines to help you during the contribution process. 8 | 9 | ### Need some help regarding the basics? 10 | 11 | You can refer to the following articles on the basics of Git and GitHub in case you are stuck: 12 | 13 | - [Forking a Repo](https://help.github.com/en/github/getting-started-with-github/fork-a-repo) 14 | - [Cloning a Repo](https://docs.github.com/en/repositories/creating-and-managing-repositories/cloning-a-repository) 15 | - [How to create a Pull Request](https://opensource.com/article/19/7/create-pull-request-github) 16 | - [Getting started with Git and GitHub](https://towardsdatascience.com/getting-started-with-git-and-github-6fcd0f2d4ac6) 17 | - [Learn GitHub from Scratch](https://github.com/githubtraining/introduction-to-github) 18 | 19 | ### Installing Requirements 20 | 21 | #### Requirements 22 | 23 | - [PHP 8.2+](https://www.apachefriends.org/index.html) 24 | - [Composer](https://getcomposer.org) 25 | - [Inkscape](https://inkscape.org) (for PNG rendering) 26 | 27 | #### Linux 28 | 29 | ```bash 30 | sudo apt-get install php 31 | sudo apt-get install php-curl 32 | sudo apt-get install composer 33 | sudo apt-get install inkscape 34 | ``` 35 | 36 | #### Windows 37 | 38 | Install PHP from [XAMPP](https://www.apachefriends.org/index.html) or [php.net](https://windows.php.net/download) 39 | 40 | [▶ How to install and run PHP using XAMPP (Windows)](https://www.youtube.com/watch?v=K-qXW9ymeYQ) 41 | 42 | [📥 Download Composer](https://getcomposer.org/download/) 43 | 44 | ### Clone the repository 45 | 46 | ``` 47 | git clone https://github.com/DenverCoder1/github-readme-streak-stats.git 48 | cd github-readme-streak-stats 49 | ``` 50 | 51 | ### Authorization 52 | 53 | To get the GitHub API to run locally you will need to provide a token. 54 | 55 | 1. Visit [this link](https://github.com/settings/tokens/new?description=GitHub%20Readme%20Streak%20Stats) to create a new Personal Access Token 56 | 2. Scroll to the bottom and click **"Generate token"** 57 | 3. **Make a copy** of the `.env.example` named `.env` in the root directory and add **your token** after `TOKEN=`. 58 | 59 | ```php 60 | TOKEN= 61 | ``` 62 | 63 | ### Install dependencies 64 | 65 | Run the following command to install all the required dependencies to work on this project. 66 | 67 | ```bash 68 | composer install 69 | ``` 70 | 71 | ### Running the app locally 72 | 73 | ```bash 74 | composer start 75 | ``` 76 | 77 | Open http://localhost:8000/?user=DenverCoder1 to run the project locally 78 | 79 | Open http://localhost:8000/demo/ to run the demo site 80 | 81 | ### Running the tests 82 | 83 | Run the following command to run the PHPUnit test script which will verify that the tested functionality is still working. 84 | 85 | ```bash 86 | composer test 87 | ``` 88 | 89 | ## Linting 90 | 91 | This project uses Prettier for formatting PHP, Markdown, JavaScript and CSS files. 92 | 93 | ```bash 94 | # Run prettier and show the files that need to be fixed 95 | composer lint 96 | 97 | # Run prettier and fix the files 98 | composer lint-fix 99 | ``` 100 | 101 | ## Submitting Contributions 👨‍💻 102 | 103 | Below you will find the process and workflow used to review and merge your changes. 104 | 105 | ### Step 0 : Find an issue 106 | 107 | - Take a look at the existing issues or create your **own** issues! 108 | 109 | ![issues tab](https://user-images.githubusercontent.com/63443481/136185624-24447858-de8d-4b0a-bb6b-2528d9031196.PNG) 110 | 111 | ### Step 1 : Fork the Project 112 | 113 | - Fork this repository. This will create a copy of this repository on your GitHub profile. 114 | Keep a reference to the original project in the `upstream` remote. 115 | 116 | ```bash 117 | git clone https://github.com//github-readme-streak-stats.git 118 | cd github-readme-streak-stats 119 | git remote add upstream https://github.com/DenverCoder1/github-readme-streak-stats.git 120 | ``` 121 | 122 | ![fork button](https://user-images.githubusercontent.com/63443481/136185816-0b6770d7-0b00-4951-861a-dd15e3954918.PNG) 123 | 124 | - If you have already forked the project, update your copy before working. 125 | 126 | ```bash 127 | git remote update 128 | git checkout 129 | git rebase upstream/ 130 | ``` 131 | 132 | ### Step 2 : Branch 133 | 134 | Create a new branch. Use its name to identify the issue you're addressing. 135 | 136 | ```bash 137 | # Creates a new branch with the name feature_name and switches to it 138 | git checkout -b feature_name 139 | ``` 140 | 141 | ### Step 3 : Work on the issue assigned 142 | 143 | - Work on the issue(s) assigned to you. 144 | - Make all the necessary changes to the codebase. 145 | - After you've made changes or made your contribution to the project, add changes to the branch you've just created using: 146 | 147 | ```bash 148 | # To add all new files to the branch 149 | git add . 150 | 151 | # To add only a few files to the branch 152 | git add 153 | ``` 154 | 155 | ### Step 4 : Commit 156 | 157 | - Commit a descriptive message using: 158 | 159 | ```bash 160 | # This message will get associated with all files you have changed 161 | git commit -m "message" 162 | ``` 163 | 164 | ### Step 5 : Work Remotely 165 | 166 | - Now you are ready to your work on the remote repository. 167 | - When your work is ready and complies with the project conventions, upload your changes to your fork: 168 | 169 | ```bash 170 | # To push your work to your remote repository 171 | git push -u origin Branch_Name 172 | ``` 173 | 174 | - Here is how your branch will look. 175 | 176 | ![forked branch](https://user-images.githubusercontent.com/63443481/136186235-204f5c7a-1129-44b5-af20-89aa6a68d952.PNG) 177 | 178 | ### Step 6 : Pull Request 179 | 180 | - Go to your forked repository in your browser and click on "Compare and pull request". Then add a title and description to your pull request that explains your contribution. 181 | 182 | compare and pull request 183 | 184 | opening pull request 185 | 186 | - Voila! Your Pull Request has been submitted and it's ready to be merged.🥳 187 | 188 | #### Happy Contributing! 189 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Jonah Lawrence 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 | -------------------------------------------------------------------------------- /Procfile: -------------------------------------------------------------------------------- 1 | web: vendor/bin/heroku-php-apache2 src/ 2 | -------------------------------------------------------------------------------- /app.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "GitHub Readme Streak Stats", 3 | "description": "🔥 Stay motivated and show off your contribution streak! 🌟 Display your total contributions, current streak, and longest streak on your GitHub profile README.", 4 | "repository": "https://github.com/DenverCoder1/github-readme-streak-stats/", 5 | "logo": "https://i.imgur.com/Z4bDOxC.png", 6 | "keywords": ["github", "dynamic", "readme", "contributions", "streak", "stats"], 7 | "addons": [], 8 | "env": { 9 | "TOKEN": { 10 | "description": "GitHub personal access token obtained from https://github.com/settings/tokens/new", 11 | "required": true 12 | } 13 | }, 14 | "formation": { 15 | "web": { 16 | "quantity": 1, 17 | "size": "basic" 18 | } 19 | }, 20 | "buildpacks": [ 21 | { 22 | "url": "https://github.com/heroku/heroku-buildpack-apt" 23 | }, 24 | { 25 | "url": "https://github.com/DenverCoder1/heroku-buildpack-fonts-segoe-ui" 26 | }, 27 | { 28 | "url": "heroku/php" 29 | } 30 | ] 31 | } 32 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "denvercoder1/github-readme-streak-stats", 3 | "description": "🔥 Stay motivated and show off your contribution streak! 🌟 Display your total contributions, current streak, and longest streak on your GitHub profile README.", 4 | "keywords": [ 5 | "github", 6 | "dynamic", 7 | "readme", 8 | "contributions", 9 | "streak", 10 | "stats" 11 | ], 12 | "license": "MIT", 13 | "version": "1.5.0", 14 | "homepage": "https://github.com/DenverCoder1/github-readme-streak-stats", 15 | "autoload": { 16 | "classmap": [ 17 | "src/" 18 | ] 19 | }, 20 | "require": { 21 | "php": "^8.2", 22 | "ext-intl": "*", 23 | "vlucas/phpdotenv": "^5.3" 24 | }, 25 | "require-dev": { 26 | "phpunit/phpunit": "^11" 27 | }, 28 | "scripts": { 29 | "start": [ 30 | "Composer\\Config::disableProcessTimeout", 31 | "php -S localhost:8000 -t src" 32 | ], 33 | "test": "./vendor/bin/phpunit --testdox tests", 34 | "lint": "prettier --check *.md **/*.{php,md,js,css} !**/*.min.js --print-width 120", 35 | "lint-fix": "prettier --write *.md **/*.{php,md,js,css} !**/*.min.js --print-width 120" 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /docs/faq.md: -------------------------------------------------------------------------------- 1 | # FAQ 2 | 3 | ## How do I create a Readme for my profile? 4 | 5 | A profile readme appears on your profile page when you create a repository with the same name as your username and add a `README.md` file to it. For example, the repository for the user [`DenverCoder1`](https://github.com/DenverCoder1) is located at [`DenverCoder1/DenverCoder1`](https://github.com/DenverCoder1/DenverCoder1). 6 | 7 | ## How do I include GitHub Readme Streak Stats in my Readme? 8 | 9 | Markdown files on GitHub support embedded images using Markdown or HTML. You can customize your Streak Stats image on the [demo site](https://streak-stats.demolab.com/demo/) and use the image source in either of the following ways: 10 | 11 | ### Markdown 12 | 13 | ```md 14 | [![GitHub Streak](https://streak-stats.demolab.com?user=DenverCoder1)](https://git.io/streak-stats) 15 | ``` 16 | 17 | ### HTML 18 | 19 | 20 | ```html 21 | 22 | ``` 23 | 24 | 25 | ## Why doesn't my Streak Stats match my contribution graph? 26 | 27 | GitHub Readme Streak Stats uses the GitHub API to fetch your contribution data. These stats are returned in UTC time which may not match your local time. Additionally, due to caching, the stats may not be updated immediately after a commit. You may need to wait up to a few hours to see the latest stats. 28 | 29 | If you think your stats are not showing up because of a time zone issue, you can try one of the following: 30 | 31 | 1. Change the date of the commit. You can [adjust the time](https://codewithhugo.com/change-the-date-of-a-git-commit/) of a past commit to make it in the middle of the day. 32 | 2. Create a new commit in a repository with the date set to the date that is missing from your streak stats: 33 | 34 | ```bash 35 | git commit --date="2022-08-02 12:00" -m "Test commit" --allow-empty 36 | git push 37 | ``` 38 | 39 | ## What is considered a "contribution"? 40 | 41 | Contributions include commits, pull requests, and issues that you create in standalone repositories ([Learn more about what is considered a contribution](https://docs.github.com/articles/why-are-my-contributions-not-showing-up-on-my-profile)). 42 | 43 | The longest streak is the highest number of consecutive days on which you have made at least one contribution. 44 | 45 | The current streak is the number of consecutive days ending with the current day on which you have made at least one contribution. If you have made a contribution today, it will be counted towards the current streak, however, if you have not made a contribution today, the streak will only count days before today so that your streak will not be zero. 46 | 47 | > Note: You may need to wait up to 24 hours for new contributions to show up ([Learn how contributions are counted](https://docs.github.com/articles/why-are-my-contributions-not-showing-up-on-my-profile)) 48 | 49 | ## How do I enable private contributions? 50 | 51 | To include contributions in private repositories, turn on the setting for "Private contributions" from the dropdown menu above the contribution graph on your profile page. 52 | 53 | ## How do I center the image on the page? 54 | 55 | To center align images, you must use the HTML syntax and wrap it in an element with the HTML attribute `align="center"`. 56 | 57 | 58 | ```html 59 |

60 | 61 |

62 | ``` 63 | 64 | 65 | ## How do I make different images for dark mode and light mode? 66 | 67 | You can [specify theme context](https://github.blog/changelog/2022-05-19-specify-theme-context-for-images-in-markdown-beta/) using the `` and `` elements as shown below. The dark mode version appears in the `srcset` of the `` tag and the light mode version appears in the `src` of the `` tag. 68 | 69 | 70 | ```html 71 | 72 | 73 | 74 | 75 | ``` 76 | 77 | 78 | ## Why and how do I self-host GitHub Readme Streak Stats? 79 | 80 | Self-hosting the code can be done online and only takes a couple minutes. The benefits include better uptime since it will use your own access token so will not run into ratelimiting issues and it allows you to customize the deployment for your own use case. 81 | 82 | ### [📺 Click here for a video tutorial on how to self-host on Vercel](https://www.youtube.com/watch?v=maoXtlb8t44) 83 | 84 | See [Deploying it on your own](https://github.com/DenverCoder1/github-readme-streak-stats?tab=readme-ov-file#-deploying-it-on-your-own) in the Readme for detailed instructions. 85 | -------------------------------------------------------------------------------- /docs/themes.md: -------------------------------------------------------------------------------- 1 | ## Currently supported themes 2 | 3 | To enable a theme, append `&theme=` followed by the theme name to the end of your url. 4 | 5 | You can also try out and customize these themes on the [Demo Site](https://streak-stats.demolab.com/demo/)! 6 | 7 | Note: Theme names provided are case-insensitive and any use of underscores will be treated the same as hyphens. 8 | 9 | | Theme | Preview | 10 | | :---------------------------: | :------------------------------------------------------------------------------------------------------------------------: | 11 | | `default` | ![image](https://user-images.githubusercontent.com/107488620/183304039-a1fcf05c-0112-493a-9188-778708dc9e8f.png) | 12 | | `dark` | ![image](https://user-images.githubusercontent.com/107488620/183304038-2788ab5d-4c02-45e9-a724-990f27061c54.png) | 13 | | `highcontrast` | ![image](https://user-images.githubusercontent.com/107488620/183304037-0e54b5e6-f39a-481d-806f-3369d257a391.png) | 14 | | `transparent` | ![image](https://user-images.githubusercontent.com/20955511/221571948-1b69a2cc-87af-4e96-83fa-f01278c22c33.png) | 15 | | `radical` | ![image](https://user-images.githubusercontent.com/20955511/183303809-eb8fea2f-d56b-4ad3-9f6d-ef55f8812ed2.png) | 16 | | `merko` | ![image](https://user-images.githubusercontent.com/20955511/183303806-4ce9e5bb-6bd7-4914-a4ff-47edee01bde3.png) | 17 | | `gruvbox` | ![image](https://user-images.githubusercontent.com/20955511/183303804-95ff960f-ad52-4026-8627-a67f1599cee3.png) | 18 | | `gruvbox-duo` | ![image](https://user-images.githubusercontent.com/20955511/183303801-eb1d8dea-7f89-4075-b334-542bb546dfcd.png) | 19 | | `tokyonight` | ![image](https://user-images.githubusercontent.com/20955511/183303799-e039b635-5424-437b-9f87-7ed9dca8aea6.png) | 20 | | `tokyonight-duo` | ![image](https://user-images.githubusercontent.com/20955511/183303796-03bb6eb2-667f-492b-8397-efd2ad93edeb.png) | 21 | | `onedark` | ![image](https://user-images.githubusercontent.com/20955511/183303794-54389af4-24f3-41e6-9d70-2e949d19227e.png) | 22 | | `onedark-duo` | ![image](https://user-images.githubusercontent.com/20955511/183303791-a4a6d5f0-ab3a-4f6e-b4cc-a87bb24fd135.png) | 23 | | `cobalt` | ![image](https://user-images.githubusercontent.com/20955511/183303787-eaa77366-6f13-4dc8-a0fa-637ac5333612.png) | 24 | | `synthwave` | ![image](https://user-images.githubusercontent.com/20955511/183303784-6257055f-d206-4d1a-bdb9-95e9dd7052fb.png) | 25 | | `dracula` | ![image](https://user-images.githubusercontent.com/20955511/183303782-2231d9eb-9b65-4cf9-9e26-f4cfb773abf6.png) | 26 | | `prussian` | ![image](https://user-images.githubusercontent.com/20955511/183303779-56649d30-2226-4797-b001-0ca1c3902132.png) | 27 | | `monokai` | ![image](https://user-images.githubusercontent.com/20955511/183303777-5f424f42-3c71-4802-946d-148dd4a0805f.png) | 28 | | `vue` | ![image](https://user-images.githubusercontent.com/20955511/183303773-44ea348d-973b-4d3c-967c-7152bba274d5.png) | 29 | | `vue-dark` | ![image](https://user-images.githubusercontent.com/20955511/183303769-0735cf9f-d44c-40ca-b2c1-2b56384670b4.png) | 30 | | `shades-of-purple` | ![image](https://user-images.githubusercontent.com/20955511/183303767-30426d56-e2bd-487a-98d7-7e5f5c8eb640.png) | 31 | | `nightowl` | ![image](https://user-images.githubusercontent.com/20955511/183303763-289d7a24-070f-4604-b729-8dd75eefe234.png) | 32 | | `buefy` | ![image](https://user-images.githubusercontent.com/20955511/183303761-3e0d060a-6a67-407a-9a0a-9c1e615cff87.png) | 33 | | `buefy-dark` | ![image](https://user-images.githubusercontent.com/20955511/183303760-df6fcc74-884a-404b-9966-34363a7438b3.png) | 34 | | `blue-green` | ![image](https://user-images.githubusercontent.com/20955511/183303758-c8c90e09-db0d-4179-a91f-6463489fee7e.png) | 35 | | `algolia` | ![image](https://user-images.githubusercontent.com/20955511/183303756-2b0134af-ab8b-42d4-b805-4e853f929c5e.png) | 36 | | `great-gatsby` | ![image](https://user-images.githubusercontent.com/20955511/183303754-168e88f6-80db-443b-b91b-2086b164531b.png) | 37 | | `darcula` | ![image](https://user-images.githubusercontent.com/20955511/183303753-4b91b591-4502-4a39-9554-8ed2c7eb9777.png) | 38 | | `bear` | ![image](https://user-images.githubusercontent.com/20955511/183303752-5adcd734-3cdb-44f7-8c67-e42edde5ac9c.png) | 39 | | `solarized-dark` | ![image](https://user-images.githubusercontent.com/20955511/183303751-b1570958-bb9a-4829-9588-0d94c3fb5cfe.png) | 40 | | `solarized-light` | ![image](https://user-images.githubusercontent.com/20955511/183303750-03e52dfd-b052-4acd-aee6-78a1106c147e.png) | 41 | | `chartreuse-dark` | ![image](https://user-images.githubusercontent.com/20955511/183303749-1a489c0e-7a53-4fd5-90cd-b1271aca26e3.png) | 42 | | `nord` | ![image](https://user-images.githubusercontent.com/20955511/183303748-556b28e8-2f87-4657-b164-899f3216ef51.png) | 43 | | `gotham` | ![image](https://user-images.githubusercontent.com/20955511/183303747-bf39ce32-1bdf-4712-b4fd-abd0eb54a89e.png) | 44 | | `material-palenight` | ![image](https://user-images.githubusercontent.com/20955511/183303746-e73933e0-03fa-480d-9469-296852be957a.png) | 45 | | `graywhite` | ![image](https://user-images.githubusercontent.com/20955511/183303745-185ba0c3-a840-4a4e-95e3-03325c3b3e4e.png) | 46 | | `vision-friendly-dark` | ![image](https://user-images.githubusercontent.com/20955511/183303743-0c134e67-aa99-43cb-9a56-3a8b6c9fe44a.png) | 47 | | `ayu-mirage` | ![image](https://user-images.githubusercontent.com/20955511/183303742-31e46a33-fb80-4cf4-a966-d751d98a9c93.png) | 48 | | `midnight-purple` | ![image](https://user-images.githubusercontent.com/20955511/183303740-641a4a18-da69-46a8-b218-f1a6dc04fcdf.png) | 49 | | `calm` | ![image](https://user-images.githubusercontent.com/20955511/183303737-c00375f6-e2bc-4cf5-99c2-1544366fd260.png) | 50 | | `flag-india` | ![image](https://user-images.githubusercontent.com/20955511/183303735-66e35638-0fa3-40f4-b9aa-9b6c284eac8f.png) | 51 | | `omni` | ![image](https://user-images.githubusercontent.com/20955511/183303734-67e9f9d1-82e5-4518-8105-9105c8a13e6b.png) | 52 | | `react` | ![image](https://user-images.githubusercontent.com/20955511/183303733-0d994b10-1fb3-497a-8c8c-7d901dda03ed.png) | 53 | | `jolly` | ![image](https://user-images.githubusercontent.com/20955511/183303732-2e877a4e-f609-452d-b091-d5fb48482def.png) | 54 | | `maroongold` | ![image](https://user-images.githubusercontent.com/20955511/183303731-08ca9109-551d-4052-a17f-630cbb0cf323.png) | 55 | | `yeblu` | ![image](https://user-images.githubusercontent.com/20955511/183303730-5ffad264-362d-4ee6-82b2-15b8a8669462.png) | 56 | | `blueberry` | ![image](https://user-images.githubusercontent.com/20955511/183303729-f3c89ba7-efef-437e-9a05-fa5feebb9d72.png) | 57 | | `blueberry-duo` | ![image](https://user-images.githubusercontent.com/20955511/183303728-4d209b8c-536f-4921-aa43-6371f1e313fe.png) | 58 | | `slateorange` | ![image](https://user-images.githubusercontent.com/20955511/183303727-7ffec3ef-1303-4096-bd0f-f8fc1e4949e6.png) | 59 | | `kacho-ga` | ![image](https://user-images.githubusercontent.com/20955511/183303726-9adaaf73-2ea8-4b78-a3f4-7382ce299511.png) | 60 | | `ads-juicy-fresh` | ![image](https://user-images.githubusercontent.com/20955511/183303725-25851d72-963a-4532-a5ca-1eaae6c4c224.png) | 61 | | `black-ice` | ![image](https://user-images.githubusercontent.com/20955511/183303724-de45e18a-d4f8-48ae-88c1-d54a35d2ecea.png) | 62 | | `soft-green` | ![image](https://user-images.githubusercontent.com/20955511/183303722-3ae70df8-87ff-4b3b-a941-f84cef5dddf4.png) | 63 | | `blood` | ![image](https://user-images.githubusercontent.com/20955511/183303721-a22ea310-ebab-4ef5-bab9-2f1d7e7c566d.png) | 64 | | `blood-dark` | ![image](https://user-images.githubusercontent.com/20955511/183303720-487819af-3c20-4854-8ae1-85d70115cf80.png) | 65 | | `green-nur` | ![image](https://user-images.githubusercontent.com/20955511/183303719-dc5ad223-cdd6-4830-9ffb-0ae965ec0159.png) | 66 | | `neon-dark` | ![image](https://user-images.githubusercontent.com/20955511/183303718-8b043f5f-8d87-4370-ac42-38032e230d6e.png) | 67 | | `neon-palenight` | ![image](https://user-images.githubusercontent.com/20955511/183303716-bf924275-320f-44b6-8ad7-6a5f786ee9e6.png) | 68 | | `dark-smoky` | ![image](https://user-images.githubusercontent.com/20955511/183303715-baad8600-943a-4ad6-85d9-f7c2a46eab41.png) | 69 | | `monokai-metallian` | ![image](https://user-images.githubusercontent.com/20955511/183303713-2bf8ee11-a251-4d39-8aa5-ed1fd4c545ce.png) | 70 | | `city-lights` | ![image](https://user-images.githubusercontent.com/20955511/183303712-c9aa7429-eece-4d03-8c10-fbf28c77d495.png) | 71 | | `blux` | ![image](https://user-images.githubusercontent.com/20955511/183303711-ed60bb0e-9392-468b-a344-22debb20613a.png) | 72 | | `earth` | ![image](https://user-images.githubusercontent.com/20955511/183303710-b3c336ad-df6d-4529-aa95-6808bfe907dc.png) | 73 | | `deepblue` | ![image](https://user-images.githubusercontent.com/20955511/183303709-823b626b-d9c6-4e12-a146-e641a0345a2f.png) | 74 | | `holi-theme` | ![image](https://user-images.githubusercontent.com/20955511/183303708-83f5f757-5692-4e24-8e66-daaa8bca6b5b.png) | 75 | | `ayu-light` | ![image](https://user-images.githubusercontent.com/20955511/183303707-fb381b09-9963-48c8-90b9-f6b5bc67c85a.png) | 76 | | `javascript` | ![image](https://user-images.githubusercontent.com/20955511/183303706-4b4e34ef-6d43-4255-9a58-1d35c3127ff7.png) | 77 | | `javascript-dark` | ![image](https://user-images.githubusercontent.com/20955511/183303704-65313140-d66a-4f9b-9ce6-da176ecd6ec7.png) | 78 | | `noctis-minimus` | ![image](https://user-images.githubusercontent.com/20955511/183303703-3f774a1e-573c-48a3-a7cd-1f226784d74f.png) | 79 | | `github-dark` | ![image](https://user-images.githubusercontent.com/20955511/183303702-1bd5adbb-7277-4610-ad59-e5bdf20dd1de.png) | 80 | | `github-dark-blue` | ![image](https://user-images.githubusercontent.com/20955511/183303701-34bf6b33-812d-4afd-9c1f-70b04b2e486a.png) | 81 | | `github-light` | ![image](https://user-images.githubusercontent.com/20955511/183303700-7678833c-70c1-4260-8da0-5c8db7b2c38b.png) | 82 | | `elegant` | ![image](https://user-images.githubusercontent.com/20955511/183303699-fdd92594-83ca-486f-9ed4-a555f674d59a.png) | 83 | | `leafy` | ![image](https://user-images.githubusercontent.com/20955511/183303696-5129d744-af63-4874-bc99-d603ffb03b2e.png) | 84 | | `navy-gear` | ![image](https://user-images.githubusercontent.com/20955511/183303695-633ba0b8-11c0-49f3-988d-49390862696a.png) | 85 | | `hacker` | ![image](https://user-images.githubusercontent.com/20955511/183303694-e5cd3ee9-2158-41ed-8ad6-20ca7f1298cf.png) | 86 | | `garden` | ![image](https://user-images.githubusercontent.com/20955511/183303692-ea99a78d-be75-43fa-80ca-83f3ae454a35.png) | 87 | | `github-green-purple` | ![image](https://user-images.githubusercontent.com/20955511/183303691-278ec85a-197d-4a6b-abf3-593e4cc8492b.png) | 88 | | `icegray` | ![image](https://user-images.githubusercontent.com/20955511/183303690-7d798870-dd80-4d71-b5c2-775cc3555e14.png) | 89 | | `neon-blurange` | ![image](https://user-images.githubusercontent.com/20955511/183303688-7a4ceb50-84e8-47ca-8cf0-14f212227ce6.png) | 90 | | `yellowdark` | ![image](https://user-images.githubusercontent.com/20955511/183303687-49da2ffe-5fc9-4a0b-9ca9-c46bc394ec03.png) | 91 | | `java-dark` | ![image](https://user-images.githubusercontent.com/20955511/183303686-a652b2fb-daae-4390-b245-71610aa54ef7.png) | 92 | | `android-dark` | ![image](https://user-images.githubusercontent.com/20955511/183303685-fed30ead-2660-48bc-b724-04fe3c394c7f.png) | 93 | | `deuteranopia-friendly-theme` | ![image](https://user-images.githubusercontent.com/107488620/183304765-9d423ff4-52ed-4a27-8a1c-2bcd290f4803.png) | 94 | | `windows-dark` | ![image](https://user-images.githubusercontent.com/103951737/183449796-23096f23-54b5-45af-8078-b8afd4f3baf3.png) | 95 | | `git-dark` | ![image](https://user-images.githubusercontent.com/103951737/183690748-060943ff-7b39-4229-b32d-806d654bd12d.png) | 96 | | `python-dark` | ![image](https://user-images.githubusercontent.com/103951737/183929763-ae8c93d4-0106-461c-bded-2c2adb0bd6bf.png) | 97 | | `sea` | ![image](https://user-images.githubusercontent.com/103951737/184303266-0e5f8a25-bfeb-4876-abf1-91a38ca87680.png) | 98 | | `sea-dark` | ![image](https://user-images.githubusercontent.com/103951737/184301879-953370eb-e61a-4e0f-abf4-7029c336e8f1.png) | 99 | | `violet-dark` | ![image](https://user-images.githubusercontent.com/103951737/184529784-05de7e57-b939-42f7-9852-345fa191c343.png) | 100 | | `horizon` | ![image](https://user-images.githubusercontent.com/3828247/184559656-e1f1b290-0a44-45cc-9681-010577386760.png) | 101 | | `material` | ![image](https://user-images.githubusercontent.com/20955511/193617994-dfab039d-b111-4a95-a00d-39517d9e40ab.png) | 102 | | `modern-lilac` | ![image](https://user-images.githubusercontent.com/20955511/197569406-6ff144c3-1d6e-4500-9f0b-3112a6c62584.png) | 103 | | `modern-lilac2` | ![image](https://user-images.githubusercontent.com/20955511/197575977-029fc730-9c7e-4556-be7c-a727a1715fa7.png) | 104 | | `halloween` | ![image](https://user-images.githubusercontent.com/20955511/198897937-a3c918ea-0f35-43a0-9faf-80ad8f254cdf.png) | 105 | | `violet-punch` | ![image](https://user-images.githubusercontent.com/20955511/199313653-d678d969-facd-4f8d-b36e-2d0ee2ce61a5.png) | 106 | | `submarine-flowers` | ![image](https://user-images.githubusercontent.com/20955511/201519290-14d69c90-ce17-4c63-9020-7b244ebc6fab.png) | 107 | | `rising-sun` | ![image](https://user-images.githubusercontent.com/20955511/221126697-2c47639d-23c5-4c23-b545-d883063deebf.png) | 108 | | `gruvbox-light` | ![image](https://user-images.githubusercontent.com/20955511/221585454-f9474df6-bbf4-4e3a-91e4-5e9e090e90c0.png) | 109 | | `outrun` | ![image](https://user-images.githubusercontent.com/20955511/221585435-d39df945-6387-4e3e-abdf-0af7dd0dabef.png) | 110 | | `ocean-dark` | ![image](https://user-images.githubusercontent.com/20955511/221585476-3eb2d25c-346b-4562-808e-bf09a59b17cd.png) | 111 | | `discord-old-blurple` | ![image](https://user-images.githubusercontent.com/20955511/221585526-e191cb4c-9957-4ec9-85ec-8916ac691b40.png) | 112 | | `aura-dark` | ![image](https://user-images.githubusercontent.com/20955511/221585541-88c2a657-dbe7-47a2-b6f9-9e3cdf1fbbfe.png) | 113 | | `panda` | ![image](https://user-images.githubusercontent.com/20955511/221585562-1f7edc63-41c7-43c6-ac33-fd0ecb32ec5f.png) | 114 | | `cobalt2` | ![image](https://user-images.githubusercontent.com/20955511/221585614-256d590d-9c45-43a8-be15-48231e418bf2.png) | 115 | | `swift` | ![image](https://user-images.githubusercontent.com/20955511/221585640-666641b9-cc29-435c-948f-f50e58a6b330.png) | 116 | | `aura` | ![image](https://user-images.githubusercontent.com/20955511/221585659-f4e8a547-7f98-4438-aba9-8f13ffbcc657.png) | 117 | | `apprentice` | ![image](https://user-images.githubusercontent.com/20955511/221585690-155c5b01-988e-4e1c-a588-94edb0913800.png) | 118 | | `moltack` | ![image](https://user-images.githubusercontent.com/20955511/221585716-9e9a9bb6-17cf-458d-826c-1d9a659cdcec.png) | 119 | | `codestackr` | ![image](https://user-images.githubusercontent.com/20955511/221585743-c836e303-9b9a-4caf-bd12-ef83bf39bf54.png) | 120 | | `rose-pine` | ![image](https://user-images.githubusercontent.com/20955511/221585761-b7df70e8-b2c4-446a-a6fc-4fd13aa18117.png) | 121 | | `date-night` | ![image](https://user-images.githubusercontent.com/20955511/221585779-db7f394d-b3c6-49e4-ad75-bbba97530765.png) | 122 | | `one-dark-pro` | ![image](https://user-images.githubusercontent.com/20955511/221585805-1d10928a-286c-4945-95ed-a7317e56692f.png) | 123 | | `rose` | ![image](https://user-images.githubusercontent.com/20955511/221585827-e566b73a-e0c0-4711-b48c-667e6500d44e.png) | 124 | | `neon` | ![image](https://user-images.githubusercontent.com/20955511/225303106-8c901c48-732e-49ae-a2e6-8733254536eb.png) | 125 | | `sunset-gradient` | ![image](https://user-images.githubusercontent.com/20955511/233865257-3ed2bd35-458b-46bc-a189-57b0c8a2a473.png) | 126 | | `ocean-gradient` | ![image](https://user-images.githubusercontent.com/20955511/233865264-3bb6c04d-05d2-47b1-857c-3f9a1277651f.png) | 127 | | `ambient-gradient` | ![image](https://user-images.githubusercontent.com/20955511/233865269-81583e73-c9b6-4e4b-9475-bc130de1bfdd.png) | 128 | | `catppuccin-latte` | ![image](https://user-images.githubusercontent.com/85760664/248204601-358a8a31-4ffc-4535-a617-840926ecd4f0.png) | 129 | | `catppuccin-frappe` | ![image](https://user-images.githubusercontent.com/85760664/248204858-daa7bd60-1e83-4b4e-8afc-65644055235e.png) | 130 | | `catppuccin-macchiato` | ![image](https://user-images.githubusercontent.com/85760664/248205012-15d74ba2-746a-4efd-b2f5-bc2db87b7c10.png) | 131 | | `catppuccin-mocha` | ![image](https://user-images.githubusercontent.com/85760664/248204228-9f965d12-2013-48c9-b3a8-e9717b1c4e43.png) | 132 | | `burnt-neon` | ![image](https://user-images.githubusercontent.com/112064697/250343082-de641726-1200-4264-885a-154d539cfc3f.png) | 133 | | `humoris` | ![image](https://user-images.githubusercontent.com/20955511/263020536-793bedbd-cca6-47e5-92dc-c7b38ab05bce.png) | 134 | | `shadow-red` | ![image](https://user-images.githubusercontent.com/86386385/263407052-345edfdf-b6ee-4b53-a4c4-7dcb4948f6dc.png) | 135 | | `shadow-green` | ![image](https://user-images.githubusercontent.com/86386385/263407047-d769c2cf-e435-4d46-9a34-04c16f61d200.png) | 136 | | `shadow-blue` | ![image](https://user-images.githubusercontent.com/86386385/263407038-bdcd2ed9-4d2c-4a46-b8df-1b989ee517f5.png) | 137 | | `shadow-orange` | ![image](https://user-images.githubusercontent.com/86386385/263406777-07fd919b-7b4f-4fa9-ac47-3ebd0602a80b.png) | 138 | | `shadow-purple` | ![image](https://user-images.githubusercontent.com/86386385/263406551-46e14eac-fdbc-4b90-9df8-85c0bd1eeb41.png) | 139 | | `shadow-brown` | ![image](https://user-images.githubusercontent.com/86386385/263406156-5e17541d-4dcf-4315-b68d-d36c95d53767.png) | 140 | | `github-dark-dimmed` | ![image](https://github.com/DenverCoder1/github-readme-streak-stats/assets/20955511/b29e3fe2-86ca-4bf5-81ce-9f6187b02c99) | 141 | | `blue-navy` | ![image](https://github.com/DenverCoder1/github-readme-streak-stats/assets/20955511/29a78acd-56e8-465d-aff0-f984ecc14423) | 142 | | `calm-pink` | ![image](https://github.com/DenverCoder1/github-readme-streak-stats/assets/20955511/7a789c2c-33d7-41a9-8a6e-034bbfe3e915) | 143 | | `whatsapp-light` | ![image](https://user-images.githubusercontent.com/86386385/266839259-1fe6a2b7-d2f2-46b0-b94d-397ff3f2a95a.png) | 144 | | `whatsapp-dark` | ![image](https://user-images.githubusercontent.com/86386385/266839261-d9a4a98c-ef9f-45ab-a3d6-1dca785225c3.png) | 145 | | `carbonfox` | ![image](https://github.com/DenverCoder1/github-readme-streak-stats/assets/86386385/a26f8086-91de-49d7-83ca-8453cd031e72) | 146 | | `dawnfox` | ![image](https://github.com/DenverCoder1/github-readme-streak-stats/assets/86386385/feff8dd8-d7c0-4d1d-9a84-129f1333a9e7) | 147 | | `dayfox` | ![image](https://github.com/DenverCoder1/github-readme-streak-stats/assets/86386385/74111bcb-9825-4d26-a2c8-abec3618274f) | 148 | | `duskfox` | ![image](https://github.com/DenverCoder1/github-readme-streak-stats/assets/86386385/8dfd700c-e391-4ba0-a434-db4d4455000d) | 149 | | `nightfox` | ![image](https://github.com/DenverCoder1/github-readme-streak-stats/assets/86386385/00ab9a73-67d6-430f-8b22-da49a3e49091) | 150 | | `nordfox` | ![image](https://github.com/DenverCoder1/github-readme-streak-stats/assets/86386385/6feef268-ed8f-4d60-bbd8-f7a0a7a58ce8) | 151 | | `terafox` | ![image](https://github.com/DenverCoder1/github-readme-streak-stats/assets/86386385/ef943ced-365f-4ce5-965a-a9499ce1d8e1) | 152 | | `iceberg` | ![image](https://github.com/DenverCoder1/github-readme-streak-stats/assets/86386385/912d8f6a-ba21-4668-9109-300c67a1f1c2) | 153 | | `whatsapp-light2` | ![image](https://github.com/DenverCoder1/github-readme-streak-stats/assets/86386385/45d22825-e71b-42c7-aabf-14f50d47beef) | 154 | | `whatsapp-dark2` | ![image](https://github.com/DenverCoder1/github-readme-streak-stats/assets/86386385/4b41e537-368f-4f67-a1e6-81ca757ce5f7) | 155 | | `travelers-theme` | ![image](https://github.com/DenverCoder1/github-readme-streak-stats/assets/20955511/45b0bb8c-fb88-4f2e-ad97-665db6bce4a7) | 156 | | `youtube-dark` | ![image](https://github.com/DenverCoder1/github-readme-streak-stats/assets/62086478/6f774511-2477-46d2-b7bd-de3a57a3ca78) | 157 | | `meta-light` | ![image](https://github.com/DenverCoder1/github-readme-streak-stats/assets/105522342/c9429386-0b15-4efc-9bf0-c67f4aec05d4) | 158 | | `meta-dark` | ![image](https://github.com/DenverCoder1/github-readme-streak-stats/assets/105522342/62119e5a-29fc-4285-ac5d-4125c49dff8c) | 159 | | `dark-minimalist` | ![image](https://github.com/DenverCoder1/github-readme-streak-stats/assets/77511070/11ba7899-1ad3-4c4b-880b-6f9e7c285f1b) | 160 | | `telegram` | ![image](https://github.com/user-attachments/assets/59a5d9d5-8a2a-4916-aa46-a0a49a6f0372) | 161 | | `taiga` | ![image](https://github.com/user-attachments/assets/be4e961d-a13e-401a-90f8-f2b062a8c0f9) | 162 | | `telegram-gradient` | ![image](https://github.com/user-attachments/assets/985c3e04-a5dd-4cba-a66e-d43ad9668af0) | 163 | | `microsoft` | ![image](https://github.com/user-attachments/assets/4c2cce9d-90b5-4e38-8422-656c5a78b4d9) | 164 | | `microsoft-dark` | ![image](https://github.com/user-attachments/assets/a5918d7d-f568-4012-b06f-d9cfacaece04) | 165 | | `hacker-inverted` | ![image](https://github.com/user-attachments/assets/b64c136a-827b-4177-98f9-28db59bba0ef) | 166 | | `rust-ferris-light` | ![image](https://github.com/user-attachments/assets/2e1d175f-c39d-4e56-be41-d9c277f1e83a) | 167 | | `rust-ferris-dark` | ![image](https://github.com/user-attachments/assets/05e3f9ac-708d-415d-990f-ede3d0a84bab) | 168 | | `cyber-streakglow` | ![image](https://github.com/user-attachments/assets/8c6108e1-f3a1-4653-9f68-08ed6dcfc498) | 169 | | `vitesse` | ![image](https://github.com/user-attachments/assets/baa2fa20-36ea-4158-befc-79c21f102f87) | 170 | 171 | ### Can't find the theme you like? 172 | 173 | You can now customize your stats card with the interactive [Demo Site](https://streak-stats.demolab.com/demo/) or by customizing the [url parameters](/README.md#-options). 174 | 175 | If you would like to share your theme with others, feel free to open an issue/pull request! 176 | 177 | Note: When submitting a new theme, make sure the name is all lowercase. Hyphens are allowed between words, but there should be no underscores. On the demo site, you can export a list of colors from the advanced section by clicking "Export to PHP". 178 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "engines": { 3 | "node": "18.x" 4 | }, 5 | "devDependencies": { 6 | "@prettier/plugin-php": "^0.18.8", 7 | "prettier": "^2.6.2" 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /scripts/translation-progress.php: -------------------------------------------------------------------------------- 1 | $phrases) { 26 | // skip aliases 27 | if (is_string($phrases)) { 28 | continue; 29 | } 30 | $translated = 0; 31 | foreach ($phrases_to_translate as $phrase) { 32 | if (isset($phrases[$phrase])) { 33 | $translated++; 34 | } 35 | } 36 | $percentage = round(($translated / count($phrases_to_translate)) * 100); 37 | $locale_name = Locale::getDisplayName($locale, $locale); 38 | $line_number = getLineNumber($translations_file, $locale); 39 | $progress[$locale] = [ 40 | "locale" => $locale, 41 | "locale_name" => $locale_name, 42 | "percentage" => $percentage, 43 | "line_number" => $line_number, 44 | ]; 45 | } 46 | // sort by percentage 47 | uasort($progress, function ($a, $b) { 48 | return $b["percentage"] <=> $a["percentage"]; 49 | }); 50 | return $progress; 51 | } 52 | 53 | /** 54 | * Get the line number of the locale in the translations file 55 | * 56 | * @param array $translations_file The translations file 57 | * @param string $locale The locale 58 | * @return int The line number of the locale in the translations file 59 | */ 60 | function getLineNumber(array $translations_file, string $locale): int 61 | { 62 | return key(preg_grep("/^\\s*\"$locale\"\\s*=>\\s*\\[/", $translations_file)) + 1; 63 | } 64 | 65 | /** 66 | * Convert progress to labeled badges 67 | * 68 | * @param array $progress The progress array 69 | * @return string The markdown for the image badges 70 | */ 71 | function progressToBadges(array $progress): string 72 | { 73 | $per_row = 5; 74 | $table = ""; 75 | $i = 0; 76 | foreach (array_values($progress) as $data) { 77 | if ($i % $per_row === 0) { 78 | $table .= ""; 79 | } 80 | $line_url = "https://github.com/DenverCoder1/github-readme-streak-stats/blob/main/src/translations.php#L{$data["line_number"]}"; 81 | $table .= ""; 82 | $i++; 83 | if ($i % $per_row === 0) { 84 | $table .= ""; 85 | } 86 | } 87 | if ($i % $per_row !== 0) { 88 | while ($i % $per_row !== 0) { 89 | $table .= ""; 90 | $i++; 91 | } 92 | $table .= ""; 93 | } 94 | $table .= "
{$data["locale"]} - {$data["locale_name"]}
\"{$data["locale_name"]}
\n"; 95 | return $table; 96 | } 97 | 98 | /** 99 | * Update readme by replacing the content between the start and end markers 100 | * 101 | * @param string $path The path to the readme file 102 | * @param string $start The start marker 103 | * @param string $end The end marker 104 | * @param string $content The content to replace the content between the start and end markers 105 | * @return int|false The number of bytes that were written to the file, or false on failure 106 | */ 107 | function updateReadme(string $path, string $start, string $end, string $content): int|false 108 | { 109 | $readme = file_get_contents($path); 110 | if (strpos($readme, $start) === false || strpos($readme, $end) === false) { 111 | throw new Exception("Start or end marker not found in readme"); 112 | } 113 | $start_pos = strpos($readme, $start) + strlen($start); 114 | $end_pos = strpos($readme, $end); 115 | $length = $end_pos - $start_pos; 116 | $readme = substr_replace($readme, $content, $start_pos, $length); 117 | return file_put_contents($path, $readme); 118 | } 119 | 120 | $progress = getProgress($GLOBALS["TRANSLATIONS"]); 121 | $badges = "\n" . progressToBadges($progress); 122 | $update = updateReadme( 123 | __DIR__ . "/../README.md", 124 | "", 125 | "", 126 | $badges 127 | ); 128 | exit($update === false ? 1 : 0); 129 | -------------------------------------------------------------------------------- /src/colors.php: -------------------------------------------------------------------------------- 1 | "); 171 | background-repeat: no-repeat; 172 | background-position-x: 100%; 173 | background-position-y: 5px; 174 | } 175 | 176 | [data-theme="dark"] .parameters select { 177 | background-image: url("data:image/svg+xml;utf8,"); 178 | } 179 | 180 | .parameters select option[disabled] { 181 | display: none; 182 | } 183 | 184 | .parameters label, 185 | .parameters span[data-property] { 186 | text-transform: capitalize; 187 | } 188 | 189 | .checkbox-buttons input { 190 | display: none !important; 191 | } 192 | 193 | .checkbox-buttons input[type="checkbox"] + label { 194 | font-size: 90%; 195 | display: inline-block; 196 | border-radius: 6px; 197 | height: 30px; 198 | margin: 2px 3px 2px 0; 199 | line-height: 28px; 200 | text-align: center; 201 | cursor: pointer; 202 | background: var(--card-background); 203 | color: var(--text); 204 | border: 1px solid var(--border); 205 | padding: 0 10px; 206 | } 207 | 208 | .checkbox-buttons input[type="checkbox"]:checked + label { 209 | background: var(--text); 210 | color: var(--background); 211 | } 212 | 213 | .checkbox-buttons input[type="checkbox"]:disabled + label { 214 | background: var(--card-background); 215 | color: var(--stroke); 216 | } 217 | 218 | span[title="required"] { 219 | color: var(--red); 220 | } 221 | 222 | input:focus:invalid { 223 | outline: 2px var(--red) auto; 224 | } 225 | 226 | .advanced { 227 | grid-column-start: 1; 228 | grid-column-end: -1; 229 | } 230 | 231 | .advanced summary { 232 | padding: 6px; 233 | cursor: pointer; 234 | } 235 | 236 | .advanced .parameters { 237 | margin-top: 8px; 238 | } 239 | 240 | .radio-button-group { 241 | display: flex; 242 | align-items: center; 243 | gap: 0.75em; 244 | } 245 | 246 | .advanced .color-properties { 247 | grid-template-columns: auto 1fr auto; 248 | } 249 | 250 | .advanced .grid-middle { 251 | display: grid; 252 | grid-template-columns: 30% 35% 35%; 253 | } 254 | 255 | .input-text-group { 256 | display: flex; 257 | align-items: center; 258 | justify-content: space-between; 259 | gap: 0.25em; 260 | } 261 | 262 | .input-text-group span { 263 | font-size: 0.8em; 264 | font-weight: bold; 265 | padding-right: 1.5em; 266 | } 267 | 268 | .advanced .color-properties label:first-of-type { 269 | font-weight: bold; 270 | } 271 | 272 | .plus.btn, 273 | .minus.btn { 274 | margin: 0; 275 | background: inherit; 276 | color: inherit; 277 | font-size: 22px; 278 | padding: 0; 279 | height: 40px; 280 | width: 40px; 281 | } 282 | 283 | .plus.btn:hover, 284 | .minus.btn:hover { 285 | background: var(--background); 286 | } 287 | 288 | .output img { 289 | max-width: 100%; 290 | } 291 | 292 | .output .warning { 293 | font-size: 0.84em; 294 | background: var(--yellow-light); 295 | padding: 8px 14px; 296 | border-left: 4px solid var(--yellow); 297 | } 298 | 299 | .output .code-container, 300 | .output .json { 301 | background: var(--border); 302 | border-radius: 6px; 303 | padding: 12px 16px; 304 | word-break: break-all; 305 | } 306 | 307 | .output .btn { 308 | margin-top: 16px; 309 | } 310 | 311 | .top-bottom-split { 312 | display: flex; 313 | flex-direction: column; 314 | justify-content: space-between; 315 | } 316 | 317 | /* tooltips */ 318 | .btn.tooltip { 319 | display: flex; 320 | justify-content: center; 321 | align-items: center; 322 | } 323 | 324 | /* tooltip bubble */ 325 | .btn.tooltip:before { 326 | content: attr(title); 327 | position: absolute; 328 | transform: translateY(-45px); 329 | height: auto; 330 | width: auto; 331 | background: #4a4a4afa; 332 | border-radius: 4px; 333 | color: white; 334 | line-height: 30px; 335 | font-size: 1em; 336 | padding: 0 12px; 337 | pointer-events: none; 338 | opacity: 0; 339 | } 340 | 341 | /* tooltip bottom triangle */ 342 | .btn.tooltip:after { 343 | content: ""; 344 | position: absolute; 345 | transform: translateY(-25px); 346 | border-style: solid; 347 | border-color: #4a4a4afa transparent transparent transparent; 348 | border-width: 5px; 349 | pointer-events: none; 350 | opacity: 0; 351 | } 352 | 353 | /* show tooltip on hover */ 354 | .btn.tooltip[title]:hover:before, 355 | .btn.tooltip[title]:hover:after, 356 | .btn.tooltip:disabled:hover:before, 357 | .btn.tooltip:disabled:hover:after { 358 | transition: 0.2s ease-in opacity; 359 | opacity: 1; 360 | } 361 | 362 | .btn.tooltip:disabled:before { 363 | content: "You must first input a valid username."; 364 | } 365 | 366 | textarea#exported-php { 367 | margin-top: 10px; 368 | width: 100%; 369 | resize: vertical; 370 | height: 100px; 371 | } 372 | 373 | /* link underline effect */ 374 | a.underline-hover { 375 | position: relative; 376 | text-decoration: none; 377 | color: var(--text); 378 | margin-top: 2em; 379 | display: inline-flex; 380 | align-items: center; 381 | gap: 0.25em; 382 | } 383 | .underline-hover::before { 384 | content: ""; 385 | position: absolute; 386 | bottom: 0; 387 | right: 0; 388 | width: 0; 389 | height: 1px; 390 | background-color: var(--blue-light); 391 | transition: width 0.4s cubic-bezier(0.25, 1, 0.5, 1); 392 | } 393 | @media (hover: hover) and (pointer: fine) { 394 | .underline-hover:hover::before { 395 | left: 0; 396 | right: auto; 397 | width: 100%; 398 | } 399 | } 400 | -------------------------------------------------------------------------------- /src/demo/css/toggle-dark.css: -------------------------------------------------------------------------------- 1 | a.darkmode { 2 | position: fixed; 3 | top: 2em; 4 | right: 2em; 5 | color: var(--text); 6 | background: var(--background); 7 | height: 3em; 8 | width: 3em; 9 | display: flex; 10 | align-items: center; 11 | justify-content: center; 12 | border-radius: 50%; 13 | border: 2px solid var(--border); 14 | box-shadow: 0 0 3px rgb(0 0 0 / 12%), 0 1px 2px rgb(0 0 0 / 24%); 15 | transition: 0.2s ease-in box-shadow; 16 | } 17 | 18 | a.darkmode:hover { 19 | box-shadow: 0 0 6px rgb(0 0 0 / 16%), 0 3px 6px rgb(0 0 0 / 23%); 20 | } 21 | 22 | @media only screen and (max-width: 600px) { 23 | a.darkmode { 24 | top: unset; 25 | bottom: 1em; 26 | right: 1em; 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/demo/favicon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DenverCoder1/github-readme-streak-stats/65e5e47245ad384f0adcbfa5fe0b4fad327938d4/src/demo/favicon-16x16.png -------------------------------------------------------------------------------- /src/demo/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DenverCoder1/github-readme-streak-stats/65e5e47245ad384f0adcbfa5fe0b4fad327938d4/src/demo/favicon-32x32.png -------------------------------------------------------------------------------- /src/demo/icon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /src/demo/index.php: -------------------------------------------------------------------------------- 1 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 48 | GitHub Readme Streak Stats Demo 49 | 50 | "> 51 | "> 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | > 67 |

🔥 GitHub Readme Streak Stats

68 | 69 | 70 |
71 | 72 | Sponsor 73 | 74 | View on GitHub 75 | 76 | Star 77 |
78 | 79 |
80 |
81 |

Properties

82 |
83 | 84 | 85 | 86 | 87 | 103 | 104 | 105 | 109 | 110 | 111 | 112 | 113 | 114 | 122 | 123 | 124 | 128 | 129 | 130 | 139 | 140 | 141 | 145 | 146 | Exclude Days 147 |
148 | 149 | 150 | 151 | 152 | 153 | 154 | 155 | 156 | 157 | 158 | 159 | 160 | 161 | 162 | 163 |
164 | 165 | Show Sections 166 |
167 | 168 | 169 | 170 | 171 | 172 | 173 | 174 |
175 | 176 | 177 | 178 | 179 | 180 | 181 | 182 | 183 | 188 | 189 |
190 | ⚙ Advanced Options 191 |
192 |
193 | 194 | Background Type 195 |
196 |
197 | 198 | 199 |
200 |
201 | 202 | 203 |
204 |
205 |
206 |
207 | 208 | 213 | 214 |
215 |
216 | 217 | 218 | 219 |
220 | 221 | 222 |
223 |
224 | 225 |
226 |
227 |

Preview

228 | GitHub Readme Streak Stats 229 | 232 |

233 | Note: The stats above are just examples and not from your GitHub profile. 234 |

235 | 236 |
237 |

Markdown

238 |
239 | 240 |
241 | 242 | 245 |
246 | 247 |
248 |

HTML

249 |
250 | 251 |
252 | 253 | 256 |
257 | 258 |
259 |

JSON

260 |
261 | 262 |
263 | 264 | 267 |
268 |
269 | 280 |
281 |
282 | 283 | 284 | "> 285 | 286 | 287 | 288 | 289 | -------------------------------------------------------------------------------- /src/demo/js/accordion.js: -------------------------------------------------------------------------------- 1 | // Based on https://css-tricks.com/how-to-animate-the-details-element-using-waapi/ 2 | class Accordion { 3 | constructor(el) { 4 | // Store the
element 5 | this.el = el; 6 | // Store the element 7 | this.summary = el.querySelector("summary"); 8 | // Store the
element 9 | this.content = el.querySelector(".content"); 10 | // Store the animation object (so we can cancel it if needed) 11 | this.animation = null; 12 | // Store if the element is closing 13 | this.isClosing = false; 14 | // Store if the element is expanding 15 | this.isExpanding = false; 16 | } 17 | 18 | init() { 19 | // Detect user clicks on the summary element 20 | this.summary.addEventListener("click", (e) => this.onClick(e)); 21 | } 22 | 23 | onClick(e) { 24 | // Stop default behaviour from the browser 25 | e.preventDefault(); 26 | // Add an overflow on the
to avoid content overflowing 27 | this.el.style.overflow = "hidden"; 28 | // Check if the element is being closed or is already closed 29 | if (this.isClosing || !this.el.open) { 30 | this.open(); 31 | // Check if the element is being openned or is already open 32 | } else if (this.isExpanding || this.el.open) { 33 | this.shrink(); 34 | } 35 | } 36 | 37 | shrink() { 38 | // Set the element as "being closed" 39 | this.isClosing = true; 40 | // Store the current height of the element 41 | const startHeight = `${this.el.offsetHeight}px`; 42 | // Calculate the height of the summary 43 | const endHeight = `${this.summary.offsetHeight}px`; 44 | // If there is already an animation running 45 | if (this.animation) { 46 | // Cancel the current animation 47 | this.animation.cancel(); 48 | } 49 | // Start a WAAPI animation 50 | this.animation = this.el.animate( 51 | { 52 | // Set the keyframes from the startHeight to endHeight 53 | height: [startHeight, endHeight], 54 | }, 55 | { 56 | duration: 400, 57 | easing: "ease-out", 58 | } 59 | ); 60 | // When the animation is complete, call onAnimationFinish() 61 | this.animation.onfinish = () => this.onAnimationFinish(false); 62 | // If the animation is cancelled, isClosing variable is set to false 63 | this.animation.oncancel = () => (this.isClosing = false); 64 | } 65 | 66 | open() { 67 | // Apply a fixed height on the element 68 | this.el.style.height = `${this.el.offsetHeight}px`; 69 | // Force the [open] attribute on the details element 70 | this.el.open = true; 71 | // Wait for the next frame to call the expand function 72 | window.requestAnimationFrame(() => this.expand()); 73 | } 74 | 75 | expand() { 76 | // Set the element as "being expanding" 77 | this.isExpanding = true; 78 | // Get the current fixed height of the element 79 | const startHeight = `${this.el.offsetHeight}px`; 80 | // Calculate the open height of the element (summary height + content height) 81 | const endHeight = `${this.summary.offsetHeight + this.content.offsetHeight}px`; 82 | // If there is already an animation running 83 | if (this.animation) { 84 | // Cancel the current animation 85 | this.animation.cancel(); 86 | } 87 | // Start a WAAPI animation 88 | this.animation = this.el.animate( 89 | { 90 | // Set the keyframes from the startHeight to endHeight 91 | height: [startHeight, endHeight], 92 | }, 93 | { 94 | duration: 400, 95 | easing: "ease-out", 96 | } 97 | ); 98 | // When the animation is complete, call onAnimationFinish() 99 | this.animation.onfinish = () => this.onAnimationFinish(true); 100 | // If the animation is cancelled, isExpanding variable is set to false 101 | this.animation.oncancel = () => (this.isExpanding = false); 102 | } 103 | 104 | onAnimationFinish(open) { 105 | // Set the open attribute based on the parameter 106 | this.el.open = open; 107 | // Clear the stored animation 108 | this.animation = null; 109 | // Reset isClosing & isExpanding 110 | this.isClosing = false; 111 | this.isExpanding = false; 112 | // Remove the overflow hidden and the fixed height 113 | this.el.style.height = this.el.style.overflow = ""; 114 | } 115 | } 116 | 117 | document.querySelectorAll("details").forEach((el) => { 118 | const accordion = new Accordion(el); 119 | accordion.init(); 120 | }); 121 | -------------------------------------------------------------------------------- /src/demo/js/script.js: -------------------------------------------------------------------------------- 1 | /*global jscolor*/ 2 | /*eslint no-undef: "error"*/ 3 | 4 | const preview = { 5 | /** 6 | * Default values - if set to these values, the params do not need to appear in the query string 7 | */ 8 | defaults: { 9 | theme: "default", 10 | hide_border: "false", 11 | date_format: "", 12 | locale: "en", 13 | border_radius: "4.5", 14 | mode: "daily", 15 | type: "svg", 16 | exclude_days: "", 17 | card_width: "495", 18 | card_height: "195", 19 | hide_total_contributions: "false", 20 | hide_current_streak: "false", 21 | hide_longest_streak: "false", 22 | short_numbers: "false", 23 | }, 24 | 25 | /** 26 | * Update the preview with the current parameters 27 | */ 28 | update() { 29 | // get parameter values from all .param elements 30 | const params = this.objectFromElements(document.querySelectorAll(".param")); 31 | // convert sections to hide_... parameters 32 | params.hide_total_contributions = String(!params.sections.includes("total")); 33 | params.hide_current_streak = String(!params.sections.includes("current")); 34 | params.hide_longest_streak = String(!params.sections.includes("longest")); 35 | delete params.sections; 36 | // convert parameters to query string 37 | const query = Object.keys(params) 38 | .filter((key) => params[key] !== this.defaults[key]) 39 | .map((key) => `${encodeURIComponent(key)}=${encodeURIComponent(params[key])}`) 40 | .join("&"); 41 | // generate links and markdown 42 | const imageURL = `${window.location.origin}?${query}`; 43 | const demoImageURL = `preview.php?${query}`; 44 | // update preview 45 | if (params.type !== "json") { 46 | const repoLink = "https://git.io/streak-stats"; 47 | const md = `[![GitHub Streak](${imageURL})](${repoLink})`; 48 | const html = `GitHub Streak`; 49 | document.querySelector(".output img").src = demoImageURL; 50 | document.querySelector(".md code").innerText = md; 51 | document.querySelector(".html code").innerText = html; 52 | document.querySelector(".copy-md").parentElement.style.display = "block"; 53 | document.querySelector(".copy-html").parentElement.style.display = "block"; 54 | document.querySelector(".output img").style.display = "block"; 55 | document.querySelector(".output .json").style.display = "none"; 56 | document.querySelector(".copy-json").parentElement.style.display = "none"; 57 | } else { 58 | fetch(demoImageURL) 59 | .then((response) => response.json()) 60 | .then((data) => (document.querySelector(".output .json pre").innerText = JSON.stringify(data, null, 2))) 61 | .catch(console.error); 62 | document.querySelector(".json code").innerText = imageURL; 63 | document.querySelector(".copy-md").parentElement.style.display = "none"; 64 | document.querySelector(".copy-html").parentElement.style.display = "none"; 65 | document.querySelector(".output img").style.display = "none"; 66 | document.querySelector(".output .json").style.display = "block"; 67 | document.querySelector(".copy-json").parentElement.style.display = "block"; 68 | } 69 | // disable copy button if username is invalid 70 | const copyButtons = document.querySelectorAll(".copy-button"); 71 | copyButtons.forEach((button) => { 72 | button.disabled = Boolean(document.querySelector("#user:invalid") || !document.querySelector("#user").value); 73 | }); 74 | // disable clear button if no added advanced options 75 | const clearButton = document.querySelector("#clear-button"); 76 | clearButton.disabled = !document.querySelectorAll(".minus").length; 77 | }, 78 | 79 | /** 80 | * Add a property in the advanced section 81 | * @param {string} property - the name of the property, selected element is used if not provided 82 | * @param {string} value - the value to set the property to 83 | */ 84 | addProperty(property, value = "#EB5454FF") { 85 | const selectElement = document.querySelector("#properties"); 86 | // if no property passed, get the currently selected property 87 | const propertyName = property || selectElement.value; 88 | if (!selectElement.disabled) { 89 | // disable option in menu 90 | Array.prototype.find.call(selectElement.options, (o) => o.value === propertyName).disabled = true; 91 | // select first unselected option 92 | const firstAvailable = Array.prototype.find.call(selectElement.options, (o) => !o.disabled); 93 | if (firstAvailable) { 94 | firstAvailable.selected = true; 95 | } else { 96 | selectElement.disabled = true; 97 | } 98 | // color picker 99 | const jscolorConfig = { 100 | format: "hexa", 101 | onChange: `preview.pickerChange(this, '${propertyName}')`, 102 | onInput: `preview.pickerChange(this, '${propertyName}')`, 103 | }; 104 | 105 | const parent = document.querySelector(".advanced .color-properties"); 106 | if (propertyName === "background" && document.querySelector("#background-type-gradient").checked) { 107 | const valueParts = value.split(","); 108 | let angleValue = "45"; 109 | let color1Value = "#EB5454FF"; 110 | let color2Value = "#EB5454FF"; 111 | if (valueParts.length === 3) { 112 | angleValue = valueParts[0]; 113 | color1Value = valueParts[1]; 114 | color2Value = valueParts[2]; 115 | } 116 | 117 | const input = document.createElement("span"); 118 | input.className = "grid-middle"; 119 | input.setAttribute("data-property", propertyName); 120 | 121 | const rotateInputGroup = document.createElement("div"); 122 | rotateInputGroup.className = "input-text-group"; 123 | 124 | const rotate = document.createElement("input"); 125 | rotate.className = "param"; 126 | rotate.type = "number"; 127 | rotate.id = "rotate"; 128 | rotate.placeholder = "45"; 129 | rotate.value = angleValue; 130 | 131 | const degText = document.createElement("span"); 132 | degText.innerText = "\u00B0"; // degree symbol 133 | 134 | rotateInputGroup.appendChild(rotate); 135 | rotateInputGroup.appendChild(degText); 136 | 137 | const color1 = document.createElement("input"); 138 | color1.className = "param jscolor"; 139 | color1.id = "background-color1"; 140 | color1.setAttribute( 141 | "data-jscolor", 142 | JSON.stringify({ 143 | format: "hexa", 144 | onChange: `preview.pickerChange(this, '${color1.id}')`, 145 | onInput: `preview.pickerChange(this, '${color1.id}')`, 146 | }) 147 | ); 148 | const color2 = document.createElement("input"); 149 | color2.className = "param jscolor"; 150 | color2.id = "background-color2"; 151 | color2.setAttribute( 152 | "data-jscolor", 153 | JSON.stringify({ 154 | format: "hexa", 155 | onChange: `preview.pickerChange(this, '${color2.id}')`, 156 | onInput: `preview.pickerChange(this, '${color2.id}')`, 157 | }) 158 | ); 159 | rotate.name = color1.name = color2.name = propertyName; 160 | color1.value = color1Value; 161 | color2.value = color2Value; 162 | // label 163 | const label = document.createElement("span"); 164 | label.innerText = propertyName; 165 | label.setAttribute("data-property", propertyName); 166 | label.id = "background-label"; 167 | input.setAttribute("role", "group"); 168 | input.setAttribute("aria-labelledby", "background-label"); 169 | // add elements 170 | parent.appendChild(label); 171 | input.appendChild(rotateInputGroup); 172 | input.appendChild(color1); 173 | input.appendChild(color2); 174 | parent.appendChild(input); 175 | // initialise jscolor on elements 176 | jscolor.install(input); 177 | // check initial color values 178 | this.checkColor(color1.value, color1.id); 179 | this.checkColor(color2.value, color2.id); 180 | } else { 181 | const input = document.createElement("input"); 182 | input.className = "param jscolor"; 183 | input.id = propertyName; 184 | input.name = propertyName; 185 | input.setAttribute("data-property", propertyName); 186 | input.setAttribute("data-jscolor", JSON.stringify(jscolorConfig)); 187 | input.value = value; 188 | // label 189 | const label = document.createElement("label"); 190 | label.innerText = propertyName; 191 | label.setAttribute("data-property", propertyName); 192 | label.setAttribute("for", propertyName); 193 | // add elements 194 | parent.appendChild(label); 195 | parent.appendChild(input); 196 | // initialise jscolor on element 197 | jscolor.install(parent); 198 | // check initial color value 199 | this.checkColor(value, propertyName); 200 | } 201 | // removal button 202 | const minus = document.createElement("button"); 203 | minus.className = "minus btn"; 204 | minus.setAttribute("onclick", "return preview.removeProperty(this.getAttribute('data-property'));"); 205 | minus.setAttribute("type", "button"); 206 | minus.innerText = "−"; 207 | minus.setAttribute("data-property", propertyName); 208 | parent.appendChild(minus); 209 | 210 | // update and exit 211 | this.update(); 212 | } 213 | }, 214 | 215 | /** 216 | * Remove a property from the advanced section 217 | * @param {string} property - the name of the property to remove 218 | */ 219 | removeProperty(property) { 220 | const parent = document.querySelector(".advanced .color-properties"); 221 | const selectElement = document.querySelector("#properties"); 222 | // remove all elements for given property 223 | parent.querySelectorAll(`[data-property="${property}"]`).forEach((x) => parent.removeChild(x)); 224 | // enable option in menu 225 | const option = Array.prototype.find.call(selectElement.options, (o) => o.value === property); 226 | selectElement.disabled = false; 227 | option.disabled = false; 228 | selectElement.value = option.value; 229 | // update and exit 230 | this.update(); 231 | }, 232 | 233 | /** 234 | * Removes all properties from the advanced section 235 | */ 236 | removeAllProperties() { 237 | const parent = document.querySelector(".advanced .color-properties"); 238 | const activeProperties = parent.querySelectorAll("[data-property]"); 239 | // select active and unique property names 240 | const propertyNames = Array.prototype.map 241 | .call(activeProperties, (prop) => prop.getAttribute("data-property")) 242 | .filter((value, index, self) => self.indexOf(value) === index); 243 | // remove each active property name 244 | propertyNames.forEach((prop) => this.removeProperty(prop)); 245 | }, 246 | 247 | /** 248 | * Create a key-value mapping of names to values from all elements in a Node list 249 | * @param {NodeList} elements - the elements to get the values from 250 | * @returns {Object} the key-value mapping 251 | */ 252 | objectFromElements(elements) { 253 | return Array.from(elements).reduce((acc, next) => { 254 | const obj = { ...acc }; 255 | let value = next.value; 256 | if (value.indexOf("#") >= 0) { 257 | // if the value is colour, remove the hash sign 258 | value = value.replace(/#/g, ""); 259 | if (value.length > 6) { 260 | // if the value is in hexa and opacity is 1, remove FF 261 | value = value.replace(/[Ff]{2}$/, ""); 262 | } 263 | } 264 | // if the property already exists, append the value to the existing one 265 | if (next.name in obj) { 266 | obj[next.name] = `${obj[next.name]},${value}`; 267 | return obj; 268 | } 269 | // otherwise, add the value to the object 270 | obj[next.name] = value; 271 | return obj; 272 | }, {}); 273 | }, 274 | 275 | /** 276 | * Export the advanced parameters to PHP code for creating a new theme 277 | */ 278 | exportPhp() { 279 | // get default values from the currently selected theme 280 | const themeSelect = document.querySelector("#theme"); 281 | const selectedOption = themeSelect.options[themeSelect.selectedIndex]; 282 | const defaultParams = selectedOption.dataset; 283 | // get parameters with the advanced options 284 | const advancedParams = this.objectFromElements(document.querySelectorAll(".advanced .param")); 285 | // update default values with the advanced options 286 | const params = { ...defaultParams, ...advancedParams }; 287 | // convert parameters to PHP code 288 | const mappings = Object.keys(params) 289 | .map((key) => { 290 | const value = params[key].includes(",") ? params[key] : `#${params[key]}`; 291 | return ` "${key}" => "${value}",`; 292 | }) 293 | .join("\n"); 294 | const output = `[\n${mappings}\n]`; 295 | // set the textarea value to the output 296 | const textarea = document.getElementById("exported-php"); 297 | textarea.value = output; 298 | textarea.hidden = false; 299 | }, 300 | 301 | /** 302 | * Remove "FF" from a hex color if opacity is 1 303 | * @param {string} color - the hex color 304 | * @param {string} input - the property name, or id of the element to update 305 | */ 306 | checkColor(color, input) { 307 | // if color has hex alpha value -> remove it 308 | if (color.length === 9 && color.slice(-2) === "FF") { 309 | document.querySelector(`#${input}`).value = color.slice(0, -2); 310 | } 311 | }, 312 | 313 | /** 314 | * Check a color when the picker changes 315 | * @param {Object} picker - the JSColor picker object 316 | * @param {string} input - the property name, or id of the element to update 317 | */ 318 | pickerChange(picker, input) { 319 | // color was changed by picker - check it 320 | this.checkColor(picker.toHEXAString(), input); 321 | // update preview 322 | this.update(); 323 | }, 324 | 325 | /** 326 | * Update checkboxes based on the query string parameter 327 | * 328 | * @param {string|null} param - the query string parameter to read 329 | * @param {string} selector - the selector of the parent container to find the checkboxes 330 | */ 331 | updateCheckboxes(param, selector) { 332 | if (!param) { 333 | return; 334 | } 335 | // uncheck all checkboxes 336 | [...document.querySelectorAll(`${selector} input[value]`)].forEach((checkbox) => { 337 | checkbox.checked = false; 338 | }); 339 | // check checkboxes based on values in the query string 340 | param.split(",").forEach((value) => { 341 | const checkbox = document.querySelector(`${selector} input[value="${value}"]`); 342 | if (checkbox) { 343 | checkbox.checked = true; 344 | } 345 | }); 346 | }, 347 | 348 | /** 349 | * Assign values to input boxes based on the query string 350 | * 351 | * @param {URLSearchParams} searchParams - the query string parameters or empty to use the current URL 352 | */ 353 | updateFormInputs(searchParams) { 354 | searchParams = searchParams || new URLSearchParams(window.location.search); 355 | const backgroundParams = searchParams.getAll("background"); 356 | // set background-type 357 | if (backgroundParams.length > 1) { 358 | document.querySelector("#background-type-gradient").checked = true; 359 | } 360 | // set input field and select values 361 | searchParams.forEach((val, key) => { 362 | const paramInput = document.querySelector(`[name="${key}"]`); 363 | if (paramInput) { 364 | // set parameter value 365 | paramInput.value = val; 366 | } else { 367 | // add advanced property 368 | document.querySelector("details.advanced").open = true; 369 | preview.addProperty(key, searchParams.getAll(key).join(",")); 370 | } 371 | }); 372 | // set background angle and gradient colors 373 | if (backgroundParams.length > 1) { 374 | document.querySelector("#rotate").value = backgroundParams[0]; 375 | document.querySelector("#background-color1").value = backgroundParams[1]; 376 | document.querySelector("#background-color2").value = backgroundParams[2]; 377 | preview.checkColor(backgroundParams[1], "background-color1"); 378 | preview.checkColor(backgroundParams[2], "background-color2"); 379 | } 380 | // set weekday checkboxes 381 | this.updateCheckboxes(searchParams.get("exclude_days"), ".weekdays"); 382 | // set show sections checkboxes 383 | this.updateCheckboxes(searchParams.get("sections"), ".sections"); 384 | }, 385 | }; 386 | 387 | const clipboard = { 388 | /** 389 | * Copy the content of an element to the clipboard 390 | * @param {Element} el - the element to copy 391 | */ 392 | copy(el) { 393 | // create input box to copy from 394 | const input = document.createElement("input"); 395 | if (el.classList.contains("copy-md")) { 396 | input.value = document.querySelector(".md code").innerText; 397 | } else if (el.classList.contains("copy-html")) { 398 | input.value = document.querySelector(".html code").innerText; 399 | } else if (el.classList.contains("copy-json")) { 400 | input.value = document.querySelector(".json code").innerText; 401 | } 402 | document.body.appendChild(input); 403 | // select all 404 | input.select(); 405 | input.setSelectionRange(0, 99999); 406 | // copy 407 | document.execCommand("copy"); 408 | // remove input box 409 | input.parentElement.removeChild(input); 410 | // set tooltip text 411 | el.title = "Copied!"; 412 | }, 413 | }; 414 | 415 | const tooltip = { 416 | /** 417 | * Reset the tooltip text 418 | * @param {Element} el - the element to reset the tooltip for 419 | */ 420 | reset(el) { 421 | // remove tooltip text 422 | el.removeAttribute("title"); 423 | }, 424 | }; 425 | 426 | // when the page loads 427 | window.addEventListener( 428 | "load", 429 | () => { 430 | // refresh preview on interactions with the page 431 | const refresh = () => preview.update(); 432 | document.addEventListener("keyup", refresh, false); 433 | [...document.querySelectorAll("select:not(#properties)")].forEach((element) => { 434 | element.addEventListener("change", refresh, false); 435 | }); 436 | // when the background-type changes, remove the background and replace it 437 | const toggleBackgroundType = () => { 438 | const value = document.querySelector("input#background, input#background-color1")?.value; 439 | preview.removeProperty("background"); 440 | if (value && document.querySelector("#background-type-gradient").checked) { 441 | preview.addProperty("background", `45,${value},${value}`); 442 | } else if (value) { 443 | preview.addProperty("background", value); 444 | } 445 | }; 446 | document.querySelector("#background-type-solid").addEventListener("change", toggleBackgroundType, false); 447 | document.querySelector("#background-type-gradient").addEventListener("change", toggleBackgroundType, false); 448 | // function to update the hidden input box when checkboxes are clicked 449 | const updateCheckboxTextField = (parentSelector, inputSelector) => { 450 | const checked = document.querySelectorAll(`${parentSelector} input:checked`); 451 | document.querySelector(inputSelector).value = [...checked].map((node) => node.value).join(","); 452 | preview.update(); 453 | }; 454 | // when weekdays are toggled, update the input field 455 | document.querySelectorAll(".weekdays input[type='checkbox']").forEach((el) => { 456 | el.addEventListener("click", () => { 457 | updateCheckboxTextField(".weekdays", "#exclude-days"); 458 | }); 459 | }); 460 | // when sections are toggled, update the input field 461 | document.querySelectorAll(".sections input[type='checkbox']").forEach((el) => { 462 | el.addEventListener("click", () => { 463 | updateCheckboxTextField(".sections", "#sections"); 464 | }); 465 | }); 466 | // when mode is set to "weekly", disable checkboxes, otherwise enable them 467 | const toggleExcludedDaysCheckboxes = () => { 468 | const mode = document.querySelector("#mode").value; 469 | document.querySelectorAll(".weekdays input[type='checkbox']").forEach((el) => { 470 | const labelEl = el.nextElementSibling; 471 | if (mode === "weekly") { 472 | el.disabled = true; 473 | labelEl.title = "Disabled in weekly mode"; 474 | } else { 475 | el.disabled = false; 476 | labelEl.title = labelEl.dataset.tooltip; 477 | } 478 | }); 479 | }; 480 | document.querySelector("#mode").addEventListener("change", toggleExcludedDaysCheckboxes, false); 481 | // set input boxes to match URL parameters 482 | preview.updateFormInputs(); 483 | toggleExcludedDaysCheckboxes(); 484 | // update previews 485 | preview.update(); 486 | }, 487 | false 488 | ); 489 | -------------------------------------------------------------------------------- /src/demo/js/toggle-dark.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Set a cookie 3 | * @param {string} cname - cookie name 4 | * @param {string} cvalue - cookie value 5 | * @param {number} exdays - number of days to expire 6 | */ 7 | function setCookie(cname, cvalue, exdays) { 8 | const d = new Date(); 9 | d.setTime(d.getTime() + exdays * 24 * 60 * 60 * 1000); 10 | const expires = `expires=${d.toUTCString()}`; 11 | document.cookie = `${cname}=${cvalue}; ${expires}; path=/`; 12 | } 13 | 14 | /** 15 | * Get a cookie 16 | * @param {string} cname - cookie name 17 | * @returns {string} the cookie's value 18 | */ 19 | function getCookie(name) { 20 | const dc = document.cookie; 21 | const prefix = `${name}=`; 22 | let begin = dc.indexOf(`; ${prefix}`); 23 | /** @type {Number?} */ 24 | let end = null; 25 | if (begin === -1) { 26 | begin = dc.indexOf(prefix); 27 | if (begin !== 0) return null; 28 | } else { 29 | begin += 2; 30 | end = document.cookie.indexOf(";", begin); 31 | if (end === -1) { 32 | end = dc.length; 33 | } 34 | } 35 | return decodeURI(dc.substring(begin + prefix.length, end)); 36 | } 37 | 38 | /** 39 | * Turn on dark mode 40 | */ 41 | function darkmode() { 42 | document.querySelector(".darkmode i").className = "gg-sun"; 43 | setCookie("darkmode", "on", 9999); 44 | document.body.setAttribute("data-theme", "dark"); 45 | } 46 | 47 | /** 48 | * Turn on light mode 49 | */ 50 | function lightmode() { 51 | document.querySelector(".darkmode i").className = "gg-moon"; 52 | setCookie("darkmode", "off", 9999); 53 | document.body.removeAttribute("data-theme"); 54 | } 55 | 56 | /** 57 | * Toggle theme between light and dark 58 | */ 59 | function toggleTheme() { 60 | if (document.body.getAttribute("data-theme") !== "dark") { 61 | /* dark mode on */ 62 | darkmode(); 63 | } else { 64 | /* dark mode off */ 65 | lightmode(); 66 | } 67 | } 68 | 69 | // set the theme based on the cookie 70 | if (getCookie("darkmode") === null && window.matchMedia("(prefers-color-scheme: dark)").matches) { 71 | darkmode(); 72 | } 73 | -------------------------------------------------------------------------------- /src/demo/preview.php: -------------------------------------------------------------------------------- 1 | "daily", 13 | "totalContributions" => 2048, 14 | "firstContribution" => "2016-08-10", 15 | "longestStreak" => [ 16 | "start" => "2021-12-19", 17 | "end" => "2022-03-14", 18 | "length" => 86, 19 | ], 20 | "currentStreak" => [ 21 | "start" => date("Y-m-d", strtotime("-15 days")), 22 | "end" => date("Y-m-d"), 23 | "length" => 16, 24 | ], 25 | "excludedDays" => normalizeDays(explode(",", $_GET["exclude_days"] ?? "")), 26 | ]; 27 | 28 | if ($mode == "weekly") { 29 | $demoStats["mode"] = "weekly"; 30 | $demoStats["longestStreak"] = [ 31 | "start" => "2021-12-19", 32 | "end" => "2022-03-13", 33 | "length" => 13, 34 | ]; 35 | $demoStats["currentStreak"] = [ 36 | "start" => getPreviousSunday(date("Y-m-d", strtotime("-15 days"))), 37 | "end" => getPreviousSunday(date("Y-m-d")), 38 | "length" => 3, 39 | ]; 40 | unset($demoStats["excludedDays"]); 41 | } 42 | 43 | // set content type to SVG image 44 | header("Content-Type: image/svg+xml"); 45 | 46 | try { 47 | renderOutput($demoStats); 48 | } catch (InvalidArgumentException | AssertionError $error) { 49 | error_log("Error {$error->getCode()}: {$error->getMessage()}"); 50 | if ($error->getCode() >= 500) { 51 | error_log($error->getTraceAsString()); 52 | } 53 | renderOutput($error->getMessage(), $error->getCode()); 54 | } 55 | -------------------------------------------------------------------------------- /src/index.php: -------------------------------------------------------------------------------- 1 | safeLoad(); 13 | 14 | // if environment variables are not loaded, display error 15 | if (!isset($_SERVER["TOKEN"])) { 16 | $message = file_exists(dirname(__DIR__ . "../.env", 1)) 17 | ? "Missing token in config. Check Contributing.md for details." 18 | : ".env was not found. Check Contributing.md for details."; 19 | renderOutput($message, 500); 20 | } 21 | 22 | // set cache to refresh once per three horus 23 | $cacheMinutes = 3 * 60 * 60; 24 | header("Expires: " . gmdate("D, d M Y H:i:s", time() + $cacheMinutes) . " GMT"); 25 | header("Last-Modified: " . gmdate("D, d M Y H:i:s") . " GMT"); 26 | header("Cache-Control: public, max-age=$cacheMinutes"); 27 | 28 | // redirect to demo site if user is not given 29 | if (!isset($_REQUEST["user"])) { 30 | header("Location: demo/"); 31 | exit(); 32 | } 33 | 34 | try { 35 | // get streak stats for user given in query string 36 | $user = preg_replace("/[^a-zA-Z0-9\-]/", "", $_REQUEST["user"]); 37 | $startingYear = isset($_REQUEST["starting_year"]) ? intval($_REQUEST["starting_year"]) : null; 38 | $contributionGraphs = getContributionGraphs($user, $startingYear); 39 | $contributions = getContributionDates($contributionGraphs); 40 | if (isset($_GET["mode"]) && $_GET["mode"] === "weekly") { 41 | $stats = getWeeklyContributionStats($contributions); 42 | } else { 43 | // split and normalize excluded days 44 | $excludeDays = normalizeDays(explode(",", $_GET["exclude_days"] ?? "")); 45 | $stats = getContributionStats($contributions, $excludeDays); 46 | } 47 | renderOutput($stats); 48 | } catch (InvalidArgumentException | AssertionError $error) { 49 | error_log("Error {$error->getCode()}: {$error->getMessage()}"); 50 | if ($error->getCode() >= 500) { 51 | error_log($error->getTraceAsString()); 52 | } 53 | renderOutput($error->getMessage(), $error->getCode()); 54 | } 55 | -------------------------------------------------------------------------------- /src/stats.php: -------------------------------------------------------------------------------- 1 | $years Years to get graphs for 39 | * @return array List of GraphQL response objects with years as keys 40 | */ 41 | function executeContributionGraphRequests(string $user, array $years): array 42 | { 43 | $tokens = []; 44 | $requests = []; 45 | // build handles for each year 46 | foreach ($years as $year) { 47 | $tokens[$year] = getGitHubToken(); 48 | $query = buildContributionGraphQuery($user, $year); 49 | $requests[$year] = getGraphQLCurlHandle($query, $tokens[$year]); 50 | } 51 | // build multi-curl handle 52 | $multi = curl_multi_init(); 53 | foreach ($requests as $handle) { 54 | curl_multi_add_handle($multi, $handle); 55 | } 56 | // execute queries 57 | $running = null; 58 | do { 59 | curl_multi_exec($multi, $running); 60 | } while ($running); 61 | // collect responses 62 | $responses = []; 63 | foreach ($requests as $year => $handle) { 64 | $contents = curl_multi_getcontent($handle); 65 | $decoded = is_string($contents) ? json_decode($contents) : null; 66 | // if response is empty or invalid, retry request one time or throw an error 67 | if (empty($decoded) || empty($decoded->data) || !empty($decoded->errors)) { 68 | $message = $decoded->errors[0]->message ?? ($decoded->message ?? "An API error occurred."); 69 | $error_type = $decoded->errors[0]->type ?? ""; 70 | // Missing SSL certificate 71 | if (curl_errno($handle) === 60) { 72 | throw new AssertionError("You don't have a valid SSL Certificate installed or XAMPP.", 500); 73 | } 74 | // Other cURL error 75 | elseif (curl_errno($handle)) { 76 | throw new AssertionError("cURL error: " . curl_error($handle), 500); 77 | } 78 | // GitHub API error - Not Found 79 | elseif ($error_type === "NOT_FOUND") { 80 | throw new InvalidArgumentException("Could not find a user with that name.", 404); 81 | } 82 | // if rate limit is exceeded, don't retry with same token 83 | if (str_contains($message, "rate limit exceeded")) { 84 | removeGitHubToken($tokens[$year]); 85 | } 86 | error_log("First attempt to decode response for $user's $year contributions failed. $message"); 87 | error_log("Contents: $contents"); 88 | // retry request 89 | $query = buildContributionGraphQuery($user, $year); 90 | $token = getGitHubToken(); 91 | $request = getGraphQLCurlHandle($query, $token); 92 | $contents = curl_exec($request); 93 | $decoded = is_string($contents) ? json_decode($contents) : null; 94 | // if the response is still empty or invalid, log an error and skip the year 95 | if (empty($decoded) || empty($decoded->data)) { 96 | $message = $decoded->errors[0]->message ?? ($decoded->message ?? "An API error occurred."); 97 | if (str_contains($message, "rate limit exceeded")) { 98 | removeGitHubToken($token); 99 | } 100 | error_log("Failed to decode response for $user's $year contributions after 2 attempts. $message"); 101 | error_log("Contents: $contents"); 102 | continue; 103 | } 104 | } 105 | $responses[$year] = $decoded; 106 | } 107 | // close the handles 108 | foreach ($requests as $request) { 109 | curl_multi_remove_handle($multi, $handle); 110 | } 111 | curl_multi_close($multi); 112 | return $responses; 113 | } 114 | 115 | /** 116 | * Get all HTTP request responses for user's contributions 117 | * 118 | * @param string $user GitHub username to get graphs for 119 | * @param int|null $startingYear Override the minimum year to get graphs for 120 | * @return array List of contribution graph response objects 121 | */ 122 | function getContributionGraphs(string $user, ?int $startingYear = null): array 123 | { 124 | // get the list of years the user has contributed and the current year's contribution graph 125 | $currentYear = intval(date("Y")); 126 | $responses = executeContributionGraphRequests($user, [$currentYear]); 127 | // get user's created date (YYYY-MM-DDTHH:MM:SSZ format) 128 | $userCreatedDateTimeString = $responses[$currentYear]->data->user->createdAt ?? null; 129 | // if there are no contribution years, an API error must have occurred 130 | if (empty($userCreatedDateTimeString)) { 131 | throw new AssertionError("Failed to retrieve contributions. This is likely a GitHub API issue.", 500); 132 | } 133 | // extract the year from the created datetime string 134 | $userCreatedYear = intval(explode("-", $userCreatedDateTimeString)[0]); 135 | // if override parameter is null then define starting year 136 | // as the user created year; else use the provided override year 137 | $minimumYear = $startingYear ?: $userCreatedYear; 138 | // make sure the minimum year is not before 2005 (the year Git was created) 139 | $minimumYear = max($minimumYear, 2005); 140 | // create an array of years from the user's created year to one year before the current year 141 | $yearsToRequest = range($minimumYear, $currentYear - 1); 142 | // also check the first contribution year if the year is before 2005 (the year Git was created) 143 | // since the user may have backdated some commits to a specific year such as 1970 (see #448) 144 | $contributionYears = $responses[$currentYear]->data->user->contributionsCollection->contributionYears ?? []; 145 | $firstContributionYear = $contributionYears[count($contributionYears) - 1] ?? $userCreatedYear; 146 | if ($firstContributionYear < 2005) { 147 | array_unshift($yearsToRequest, $firstContributionYear); 148 | } 149 | // get the contribution graphs for the previous years 150 | $responses += executeContributionGraphRequests($user, $yearsToRequest); 151 | return $responses; 152 | } 153 | 154 | /** 155 | * Get all tokens from environment variables (TOKEN, TOKEN2, TOKEN3, etc.) if they are set 156 | * 157 | * @return array List of tokens 158 | */ 159 | function getGitHubTokens(): array 160 | { 161 | // result is already calculated 162 | if (isset($GLOBALS["ALL_TOKENS"])) { 163 | return $GLOBALS["ALL_TOKENS"]; 164 | } 165 | // find all tokens in environment variables 166 | $tokens = isset($_SERVER["TOKEN"]) ? [$_SERVER["TOKEN"]] : []; 167 | $index = 2; 168 | while (isset($_SERVER["TOKEN{$index}"])) { 169 | // add token to list 170 | $tokens[] = $_SERVER["TOKEN{$index}"]; 171 | $index++; 172 | } 173 | // store for future use 174 | $GLOBALS["ALL_TOKENS"] = $tokens; 175 | return $tokens; 176 | } 177 | 178 | /** 179 | * Get a token from the token pool 180 | * 181 | * @return string GitHub token 182 | * 183 | * @throws AssertionError if no tokens are available 184 | */ 185 | function getGitHubToken(): string 186 | { 187 | $all_tokens = getGitHubTokens(); 188 | // if there is no available token, throw an error (this should never happen) 189 | if (empty($all_tokens)) { 190 | throw new AssertionError("There is no GitHub token available.", 500); 191 | } 192 | return $all_tokens[array_rand($all_tokens)]; 193 | } 194 | 195 | /** 196 | * Remove a token from the token pool 197 | * 198 | * @param string $token Token to remove 199 | * @return void 200 | * 201 | * @throws AssertionError if no tokens are available after removing the token 202 | */ 203 | function removeGitHubToken(string $token): void 204 | { 205 | $index = array_search($token, $GLOBALS["ALL_TOKENS"]); 206 | if ($index !== false) { 207 | unset($GLOBALS["ALL_TOKENS"][$index]); 208 | } 209 | // if there is no available token, throw an error 210 | if (empty($GLOBALS["ALL_TOKENS"])) { 211 | throw new AssertionError( 212 | "We are being rate-limited! Check git.io/streak-ratelimit for details.", 213 | 429 214 | ); 215 | } 216 | } 217 | 218 | /** Create a CurlHandle for a POST request to GitHub's GraphQL API 219 | * 220 | * @param string $query GraphQL query 221 | * @param string $token GitHub token to use for the request 222 | * @return CurlHandle The curl handle for the request 223 | */ 224 | function getGraphQLCurlHandle(string $query, string $token): CurlHandle 225 | { 226 | $headers = [ 227 | "Authorization: bearer $token", 228 | "Content-Type: application/json", 229 | "Accept: application/vnd.github.v4.idl", 230 | "User-Agent: GitHub-Readme-Streak-Stats", 231 | ]; 232 | $body = ["query" => $query]; 233 | // create curl request 234 | $ch = curl_init(); 235 | curl_setopt($ch, CURLOPT_URL, "https://api.github.com/graphql"); 236 | curl_setopt($ch, CURLOPT_HTTPHEADER, $headers); 237 | curl_setopt($ch, CURLOPT_POST, true); 238 | curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($body)); 239 | curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); 240 | curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, true); 241 | curl_setopt($ch, CURLOPT_VERBOSE, false); 242 | return $ch; 243 | } 244 | 245 | /** 246 | * Get an array of all dates with the number of contributions 247 | * 248 | * @param array $contributionCalendars List of GraphQL response objects by year 249 | * @return array Y-M-D dates mapped to the number of contributions 250 | */ 251 | function getContributionDates(array $contributionGraphs): array 252 | { 253 | $contributions = []; 254 | $today = date("Y-m-d"); 255 | $tomorrow = date("Y-m-d", strtotime("tomorrow")); 256 | // sort contribution calendars by year key 257 | ksort($contributionGraphs); 258 | foreach ($contributionGraphs as $graph) { 259 | $weeks = $graph->data->user->contributionsCollection->contributionCalendar->weeks; 260 | foreach ($weeks as $week) { 261 | foreach ($week->contributionDays as $day) { 262 | $date = $day->date; 263 | $count = $day->contributionCount; 264 | // count contributions up until today 265 | // also count next day if user contributed already 266 | if ($date <= $today || ($date == $tomorrow && $count > 0)) { 267 | // add contributions to the array 268 | $contributions[$date] = $count; 269 | } 270 | } 271 | } 272 | } 273 | return $contributions; 274 | } 275 | 276 | /** 277 | * Normalize names of days of the week (eg. ["Sunday", " mon", "TUE"] -> ["Sun", "Mon", "Tue"]) 278 | * 279 | * @param array $days List of days of the week 280 | * @return array List of normalized days of the week 281 | */ 282 | function normalizeDays(array $days): array 283 | { 284 | return array_filter( 285 | array_map(function ($dayOfWeek) { 286 | // trim whitespace, capitalize first letter only, return first 3 characters 287 | $dayOfWeek = substr(ucfirst(strtolower(trim($dayOfWeek))), 0, 3); 288 | // return day if valid, otherwise return null 289 | return in_array($dayOfWeek, ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"]) ? $dayOfWeek : null; 290 | }, $days) 291 | ); 292 | } 293 | 294 | /** 295 | * Check if a day is an excluded day of the week 296 | * 297 | * @param string $date Date to check (Y-m-d) 298 | * @param array $excludedDays List of days of the week to exclude 299 | * @return bool True if the day is excluded, false otherwise 300 | */ 301 | function isExcludedDay(string $date, array $excludedDays): bool 302 | { 303 | if (empty($excludedDays)) { 304 | return false; 305 | } 306 | $day = date("D", strtotime($date)); // "D" = Mon, Tue, Wed, etc. 307 | return in_array($day, $excludedDays); 308 | } 309 | 310 | /** 311 | * Get a stats array with the contribution count, daily streak, and dates 312 | * 313 | * @param array $contributions Y-M-D contribution dates with contribution counts 314 | * @param array $excludedDays List of days of the week to exclude 315 | * @return array Streak stats 316 | */ 317 | function getContributionStats(array $contributions, array $excludedDays = []): array 318 | { 319 | // if no contributions, display error 320 | if (empty($contributions)) { 321 | throw new AssertionError("No contributions found.", 204); 322 | } 323 | $today = array_key_last($contributions); 324 | $first = array_key_first($contributions); 325 | $stats = [ 326 | "mode" => "daily", 327 | "totalContributions" => 0, 328 | "firstContribution" => "", 329 | "longestStreak" => [ 330 | "start" => $first, 331 | "end" => $first, 332 | "length" => 0, 333 | ], 334 | "currentStreak" => [ 335 | "start" => $first, 336 | "end" => $first, 337 | "length" => 0, 338 | ], 339 | "excludedDays" => $excludedDays, 340 | ]; 341 | 342 | // calculate the stats from the contributions array 343 | foreach ($contributions as $date => $count) { 344 | // add contribution count to total 345 | $stats["totalContributions"] += $count; 346 | // check if still in streak 347 | if ($count > 0 || ($stats["currentStreak"]["length"] > 0 && isExcludedDay($date, $excludedDays))) { 348 | // increment streak 349 | ++$stats["currentStreak"]["length"]; 350 | $stats["currentStreak"]["end"] = $date; 351 | // set start on first day of streak 352 | if ($stats["currentStreak"]["length"] == 1) { 353 | $stats["currentStreak"]["start"] = $date; 354 | } 355 | // set first contribution date the first time 356 | if (!$stats["firstContribution"]) { 357 | $stats["firstContribution"] = $date; 358 | } 359 | // update longestStreak 360 | if ($stats["currentStreak"]["length"] > $stats["longestStreak"]["length"]) { 361 | // copy current streak start, end, and length into longest streak 362 | $stats["longestStreak"]["start"] = $stats["currentStreak"]["start"]; 363 | $stats["longestStreak"]["end"] = $stats["currentStreak"]["end"]; 364 | $stats["longestStreak"]["length"] = $stats["currentStreak"]["length"]; 365 | } 366 | } 367 | // reset streak but give exception for today 368 | elseif ($date != $today) { 369 | // reset streak 370 | $stats["currentStreak"]["length"] = 0; 371 | $stats["currentStreak"]["start"] = $today; 372 | $stats["currentStreak"]["end"] = $today; 373 | } 374 | } 375 | return $stats; 376 | } 377 | 378 | /** 379 | * Get the previous Sunday of a given date 380 | * 381 | * @param string $date Date to get previous Sunday of (Y-m-d) 382 | * @return string Previous Sunday 383 | */ 384 | function getPreviousSunday(string $date): string 385 | { 386 | $dayOfWeek = date("w", strtotime($date)); 387 | return date("Y-m-d", strtotime("-$dayOfWeek days", strtotime($date))); 388 | } 389 | 390 | /** 391 | * Get a stats array with the contribution count, weekly streak, and dates 392 | * 393 | * @param array $contributions Y-M-D contribution dates with contribution counts 394 | * @return array Streak stats 395 | */ 396 | function getWeeklyContributionStats(array $contributions): array 397 | { 398 | // if no contributions, display error 399 | if (empty($contributions)) { 400 | throw new AssertionError("No contributions found.", 204); 401 | } 402 | $thisWeek = getPreviousSunday(array_key_last($contributions)); 403 | $first = array_key_first($contributions); 404 | $firstWeek = getPreviousSunday($first); 405 | $stats = [ 406 | "mode" => "weekly", 407 | "totalContributions" => 0, 408 | "firstContribution" => "", 409 | "longestStreak" => [ 410 | "start" => $firstWeek, 411 | "end" => $firstWeek, 412 | "length" => 0, 413 | ], 414 | "currentStreak" => [ 415 | "start" => $firstWeek, 416 | "end" => $firstWeek, 417 | "length" => 0, 418 | ], 419 | ]; 420 | 421 | // calculate contributions per week 422 | $weeks = []; 423 | foreach ($contributions as $date => $count) { 424 | $week = getPreviousSunday($date); 425 | if (!isset($weeks[$week])) { 426 | $weeks[$week] = 0; 427 | } 428 | if ($count > 0) { 429 | $weeks[$week] += $count; 430 | // set first contribution date the first time 431 | if (!$stats["firstContribution"]) { 432 | $stats["firstContribution"] = $date; 433 | } 434 | } 435 | } 436 | 437 | // calculate the stats from the contributions array 438 | foreach ($weeks as $week => $count) { 439 | // add contribution count to total 440 | $stats["totalContributions"] += $count; 441 | // check if still in streak 442 | if ($count > 0) { 443 | // increment streak 444 | ++$stats["currentStreak"]["length"]; 445 | $stats["currentStreak"]["end"] = $week; 446 | // set start on first week of streak 447 | if ($stats["currentStreak"]["length"] == 1) { 448 | $stats["currentStreak"]["start"] = $week; 449 | } 450 | // update longestStreak 451 | if ($stats["currentStreak"]["length"] > $stats["longestStreak"]["length"]) { 452 | // copy current streak start, end, and length into longest streak 453 | $stats["longestStreak"]["start"] = $stats["currentStreak"]["start"]; 454 | $stats["longestStreak"]["end"] = $stats["currentStreak"]["end"]; 455 | $stats["longestStreak"]["length"] = $stats["currentStreak"]["length"]; 456 | } 457 | } 458 | // reset streak but give exception for this week 459 | elseif ($week != $thisWeek) { 460 | // reset streak 461 | $stats["currentStreak"]["length"] = 0; 462 | $stats["currentStreak"]["start"] = $thisWeek; 463 | $stats["currentStreak"]["end"] = $thisWeek; 464 | } 465 | } 466 | return $stats; 467 | } 468 | -------------------------------------------------------------------------------- /src/translations.php: -------------------------------------------------------------------------------- 1 | true` to the locale array (see "he" for an example). 24 | * 25 | * Comma Separator 26 | * --------------- 27 | * To change the comma separator in the enumeration of excluded days, add `"comma_separator" => ", "` to the locale array with the desired separator as the value. 28 | * 29 | * Aliases 30 | * ------- 31 | * To add an alias for a locale, add the alias as a key to the locale array with the locale it should redirect to as the value. 32 | * For example, if "zh" is an alias for "zh_Hans", then `"zh" => "zh_Hans"` would be added to the locale array. 33 | */ 34 | 35 | return [ 36 | // "en" is the default locale 37 | "en" => [ 38 | "Total Contributions" => "Total Contributions", 39 | "Current Streak" => "Current Streak", 40 | "Longest Streak" => "Longest Streak", 41 | "Week Streak" => "Week Streak", 42 | "Longest Week Streak" => "Longest Week Streak", 43 | "Present" => "Present", 44 | "Excluding {days}" => "Excluding {days}", 45 | ], 46 | // Locales below are sorted alphabetically 47 | "am" => [ 48 | "Total Contributions" => "ጠቅላላ አስተዋጽዖዎች", 49 | "Current Streak" => "የአሁን ድግግሞሽ", 50 | "Longest Streak" => "በጣም ረጅሙ ድግግሞሽ", 51 | "Week Streak" => "የሳምንት ድግግሞሽ", 52 | "Longest Week Streak" => "በጣም ረጅሙ የሳምንት ድግግሞሽ", 53 | "Present" => "ያሁኑ", 54 | "Excluding {days}" => "ሳይጨምር {days}", 55 | ], 56 | "ar" => [ 57 | "rtl" => true, 58 | "Total Contributions" => "إجمالي المساهمات", 59 | "Current Streak" => "السلسلة المتتالية الحالية", 60 | "Longest Streak" => "أُطول سلسلة متتالية", 61 | "Week Streak" => "السلسلة المتتالية الأُسبوعية", 62 | "Longest Week Streak" => "أُطول سلسلة متتالية أُسبوعية", 63 | "Present" => "الحاضر", 64 | "Excluding {days}" => "باستثناء {days}", 65 | "comma_separator" => "، ", 66 | ], 67 | "bg" => [ 68 | "Total Contributions" => "Общ принос", 69 | "Current Streak" => "Дневна серия", 70 | "Longest Streak" => "Най-дълга дневна серия", 71 | "Week Streak" => "Седмична серия", 72 | "Longest Week Streak" => "Най-дълга седмична серия", 73 | "Present" => "Сега", 74 | ], 75 | "bho" => [ 76 | "Total Contributions" => "कुल योगदान", 77 | "Current Streak" => "चालू रोजाना योगदान", 78 | "Longest Streak" => "सबसे लंबा रोजाना योगदान", 79 | "Week Streak" => "सप्ताहिक योगदान", 80 | "Longest Week Streak" => "सबसे लंबा सप्ताहिक योगदान", 81 | "Present" => "आज ले", 82 | "Excluding {days}" => "{days} के छोड़के", 83 | ], 84 | "bn" => [ 85 | "Total Contributions" => "মোট অবদান", 86 | "Current Streak" => "বর্তমান স্ট্রিক", 87 | "Longest Streak" => "দীর্ঘতম স্ট্রিক", 88 | "Week Streak" => "সপ্তাহ স্ট্রিক", 89 | "Longest Week Streak" => "দীর্ঘতম সপ্তাহ স্ট্রিক", 90 | "Present" => "বর্তমান", 91 | "Excluding {days}" => "{days} বাদে", 92 | ], 93 | "ca" => [ 94 | "Total Contributions" => "Aportacions totals", 95 | "Current Streak" => "Ratxa actual", 96 | "Longest Streak" => "Ratxa més llarga", 97 | "Week Streak" => "Ratxa setmanal", 98 | "Longest Week Streak" => "Ratxa setmanal més llarga", 99 | "Present" => "Actual", 100 | "Excluding {days}" => "Excloent {days}", 101 | ], 102 | "ceb" => [ 103 | "Total Contributions" => "Kinatibuk-ang Kontribusyon", 104 | "Current Streak" => "Kasamtangan nga Streak", 105 | "Longest Streak" => "Pinakataas nga Streak", 106 | "Week Streak" => "Sinemana nga Streak", 107 | "Longest Week Streak" => "Pinakataas nga Semana nga Streak", 108 | "Present" => "Karon", 109 | "Excluding {days}" => "Wala'y Labot {days}", 110 | ], 111 | "da" => [ 112 | "Total Contributions" => "Samlet antal bidrag", 113 | "Current Streak" => "Bidrag i træk", 114 | "Longest Streak" => "Flest bidrag i træk", 115 | "Week Streak" => "Ugentlige bidrag i træk", 116 | "Longest Week Streak" => "Flest ugentlige bidrag i træk", 117 | "Present" => "Nuværende", 118 | "Excluding {days}" => "Ekskluderer {days}", 119 | ], 120 | "de" => [ 121 | "Total Contributions" => "Gesamte Beiträge", 122 | "Current Streak" => "Aktuelle Serie", 123 | "Longest Streak" => "Längste Serie", 124 | "Week Streak" => "Wochenserie", 125 | "Longest Week Streak" => "Längste Wochenserie", 126 | "Present" => "Heute", 127 | "Excluding {days}" => "Ausgenommen {days}", 128 | ], 129 | "el" => [ 130 | "Total Contributions" => "Συνολικές Συνεισφορές", 131 | "Current Streak" => "Τρέχουσα Σειρά", 132 | "Longest Streak" => "Μεγαλύτερη Σειρά", 133 | "Week Streak" => "Εβδομαδιαία Σειρά", 134 | "Longest Week Streak" => "Μεγαλύτερη Εβδομαδιαία Σειρά", 135 | "Present" => "Σήμερα", 136 | "Excluding {days}" => "Εξαιρούνται {days}", 137 | ], 138 | "es" => [ 139 | "Total Contributions" => "Contribuciones Totales", 140 | "Current Streak" => "Racha Actual", 141 | "Longest Streak" => "Racha Más Larga", 142 | "Week Streak" => "Racha Semanal", 143 | "Longest Week Streak" => "Racha Semanal Más Larga", 144 | "Present" => "Presente", 145 | "Excluding {days}" => "Excluyendo {days}", 146 | ], 147 | "fa" => [ 148 | "rtl" => true, 149 | "Total Contributions" => "مجموع مشارکت ها", 150 | "Current Streak" => "پی‌رفت فعلی", 151 | "Longest Streak" => "طولانی ترین پی‌رفت", 152 | "Week Streak" => "پی‌رفت هفته", 153 | "Longest Week Streak" => "طولانی ترین پی‌رفت هفته", 154 | "Present" => "اکنون", 155 | "Excluding {days}" => "{days} مستثنی کردن", 156 | "comma_separator" => "، ", 157 | ], 158 | "fil" => [ 159 | "Total Contributions" => "Kabuuang Kontribusyon", 160 | "Current Streak" => "Kasalukuyang Streak", 161 | "Longest Streak" => "Pinakamahabang Streak", 162 | "Week Streak" => "Linggong Streak", 163 | "Longest Week Streak" => "Pinakamahabang Linggong Streak", 164 | "Present" => "Kasalukuyan", 165 | "Excluding {days}" => "Hindi Kasama {days}", 166 | ], 167 | "fr" => [ 168 | "Total Contributions" => "Contributions totales", 169 | "Current Streak" => "Séquence actuelle", 170 | "Longest Streak" => "Plus longue séquence", 171 | "Week Streak" => "Séquence de la semaine", 172 | "Longest Week Streak" => "Plus longue séquence hebdomadaire", 173 | "Present" => "Aujourd'hui", 174 | "Excluding {days}" => "À l'exclusion de {days}", 175 | ], 176 | "gu" => [ 177 | "Total Contributions" => "કુલ યોગદાન", 178 | "Current Streak" => "સતત દૈનિક યોગદાન", 179 | "Longest Streak" => "સૌથી લાંબુ દૈનિક યોગદાન", 180 | "Week Streak" => "અઠવાડીક યોગદાન", 181 | "Longest Week Streak" => "સૌથી લાંબુ અઠવાડીક યોગદાન", 182 | "Present" => "અત્યાર સુધી", 183 | "Excluding {days}" => "સિવાય {days}", 184 | ], 185 | "he" => [ 186 | "rtl" => true, 187 | "Total Contributions" => "סכום התרומות", 188 | "Current Streak" => "רצף נוכחי", 189 | "Longest Streak" => "רצף הכי ארוך", 190 | "Week Streak" => "רצף שבועי", 191 | "Longest Week Streak" => "רצף שבועי הכי ארוך", 192 | "Present" => "היום", 193 | "Excluding {days}" => "לא כולל {days}", 194 | ], 195 | "hi" => [ 196 | "Total Contributions" => "कुल योगदान", 197 | "Current Streak" => "निरंतर दैनिक योगदान", 198 | "Longest Streak" => "सबसे लंबा दैनिक योगदान", 199 | "Week Streak" => "सप्ताहिक योगदान", 200 | "Longest Week Streak" => "दीर्घ साप्ताहिक योगदान", 201 | "Present" => "आज तक", 202 | "Excluding {days}" => "के सिवा {days}", 203 | ], 204 | "ht" => [ 205 | "Total Contributions" => "kontribisyon total", 206 | "Current Streak" => "tras aktyèl", 207 | "Longest Streak" => "tras ki pi long", 208 | "Week Streak" => "tras semèn", 209 | "Longest Week Streak" => "pi long tras semèn", 210 | "Present" => "Prezan", 211 | ], 212 | "hu" => [ 213 | "Total Contributions" => "Összes hozzájárulás", 214 | "Current Streak" => "Jelenlegi sorozat", 215 | "Longest Streak" => "Leghosszabb sorozat", 216 | "Week Streak" => "Heti sorozat", 217 | "Longest Week Streak" => "Leghosszabb heti sorozat", 218 | "Present" => "Jelen", 219 | "Excluding {days}" => "Kivéve {days}", 220 | ], 221 | "hy" => [ 222 | "Total Contributions" => "Ընդհանուր\nներդրումը", 223 | "Current Streak" => "Ընթացիկ շարք", 224 | "Longest Streak" => "Ամենաերկար շարք", 225 | "Week Streak" => "Ընթացիկ\nշաբաթների շարք", 226 | "Longest Week Streak" => "Ամենաերկար\nշաբաթների շարք", 227 | "Present" => "Այժմ", 228 | ], 229 | "id" => [ 230 | "Total Contributions" => "Total Kontribusi", 231 | "Current Streak" => "Aksi Saat Ini", 232 | "Longest Streak" => "Aksi Terpanjang", 233 | "Week Streak" => "Aksi Mingguan", 234 | "Longest Week Streak" => "Aksi Mingguan Terpanjang", 235 | "Present" => "Sekarang", 236 | "Excluding {days}" => "Kecuali {days}", 237 | ], 238 | "it" => [ 239 | "Total Contributions" => "Contributi Totali", 240 | "Current Streak" => "Serie Corrente", 241 | "Longest Streak" => "Serie più Lunga", 242 | "Week Streak" => "Serie Settimanale", 243 | "Longest Week Streak" => "Serie Settimanale più Lunga", 244 | "Present" => "Presente", 245 | "Excluding {days}" => "Escludendo {days}", 246 | ], 247 | "ja" => [ 248 | "date_format" => "[Y.]n.j", 249 | "Total Contributions" => "総コントリビューション数", 250 | "Current Streak" => "現在のストリーク", 251 | "Longest Streak" => "最長のストリーク", 252 | "Week Streak" => "週間ストリーク", 253 | "Longest Week Streak" => "最長の週間ストリーク", 254 | "Present" => "今", 255 | "Excluding {days}" => "{days}を除く", 256 | "comma_separator" => "・", 257 | ], 258 | "jv" => [ 259 | "Total Contributions" => "Total Kontribusi", 260 | "Current Streak" => "Tumindak Saiki", 261 | "Longest Streak" => "Tumindak Paling Dawa", 262 | "Week Streak" => "Tumindak Saben Minggu", 263 | "Longest Week Streak" => "Tumindak Saben Minggu Paling Dawa", 264 | "Present" => "Saiki", 265 | "Excluding {days}" => "Ora kelebu {days}", 266 | ], 267 | "kn" => [ 268 | "Total Contributions" => "ಒಟ್ಟು ಕೊಡುಗೆ", 269 | "Current Streak" => "ಪ್ರಸ್ತುತ ಸ್ಟ್ರೀಕ್", 270 | "Longest Streak" => "ಅತ್ಯಧಿಕ ಸ್ಟ್ರೀಕ್", 271 | "Week Streak" => "ವಾರದ ಸ್ಟ್ರೀಕ್", 272 | "Longest Week Streak" => "ಅತ್ಯಧಿಕ ವಾರದ ಸ್ಟ್ರೀಕ್", 273 | "Present" => "ಪ್ರಸ್ತುತ", 274 | "Excluding {days}" => "ಹೊರತುಪಡಿಸಿ {days}", 275 | ], 276 | "ko" => [ 277 | "Total Contributions" => "총 기여 수", 278 | "Current Streak" => "현재 연속 기여 수", 279 | "Longest Streak" => "최장 연속 기여 수", 280 | "Week Streak" => "주간 연속 기여 수", 281 | "Longest Week Streak" => "최장 주간 연속 기여 수", 282 | "Present" => "현재", 283 | "Excluding {days}" => "{days} 제외하고", 284 | ], 285 | "mr" => [ 286 | "Total Contributions" => "एकूण योगदान", 287 | "Current Streak" => "साध्यकालीन सातत्यता", 288 | "Longest Streak" => "दीर्घकालीन सातत्यता", 289 | "Week Streak" => "साप्ताहिक सातत्यता", 290 | "Longest Week Streak" => "दीर्घकालीन साप्ताहिक सातत्यता", 291 | "Present" => "आज पर्यंत", 292 | "Excluding {days}" => "वगळून {days}", 293 | ], 294 | "ms" => [ 295 | "Total Contributions" => "Jumlah Sumbangan", 296 | "Current Streak" => "Tindakan Semasa", 297 | "Longest Streak" => "Tindakan Terpanjang", 298 | "Week Streak" => "Tindakan Setiap Minggu", 299 | "Longest Week Streak" => "Tindakan Setiap Minggu Terpanjang", 300 | "Present" => "Sekarang", 301 | "Excluding {days}" => "Kecuali {days}", 302 | ], 303 | "my" => [ 304 | "Total Contributions" => "စုစုပေါင်း ပံ့ပိုးမှုများ", 305 | "Current Streak" => "ယနေ့ထိ မပျက်မကွက် ပံ့ပိုးမှုရက်ပေါင်း", 306 | "Longest Streak" => "အကြာဆုံးမပျက်မကွက် ပံ့ပိုးမှုရက်ပေါင်း", 307 | "Week Streak" => "အပတ်စဉ် ပံ့ပိုးမှု", 308 | "Longest Week Streak" => "အကြာဆုံးမပျက်မကွက် ပံ့ပိုးမှုအပတ်ပေါင်း", 309 | "Present" => "လက်ရှိ", 310 | "Excluding {days}" => "{days} မှလွဲ၍", 311 | ], 312 | "ne" => [ 313 | "Total Contributions" => "कुल योगदान", 314 | "Current Streak" => "हालको दैनिक योगदान", 315 | "Longest Streak" => "सबैभन्दा लामो दैनिक योगदान", 316 | "Week Streak" => "सप्ताहिक योगदान", 317 | "Longest Week Streak" => "सबैभन्दा लामो साप्ताहिक योगदान", 318 | "Present" => "आज सम्म", 319 | "Excluding {days}" => "बाहेक {days}", 320 | ], 321 | "nl" => [ 322 | "Total Contributions" => "Totale Bijdrage", 323 | "Current Streak" => "Huidige Serie", 324 | "Longest Streak" => "Langste Serie", 325 | "Week Streak" => "Week Serie", 326 | "Longest Week Streak" => "Langste Week Serie", 327 | "Present" => "Vandaag", 328 | "Excluding {days}" => "Exclusief {days}", 329 | ], 330 | "no" => [ 331 | "Total Contributions" => "Totalt Antall Bidrag", 332 | "Current Streak" => "Nåværende\nBidragsrekke", 333 | "Longest Streak" => "Lengste Bidragsrekke", 334 | "Week Streak" => "Ukentlig\nBidragsrekke", 335 | "Longest Week Streak" => "Lengste Ukentlige\nBidragsrekke", 336 | "Present" => "I dag", 337 | "Excluding {days}" => "Ekskluderer {days}", 338 | ], 339 | "pl" => [ 340 | "Total Contributions" => "Suma Kontrybucji", 341 | "Current Streak" => "Aktualna Seria", 342 | "Longest Streak" => "Najdłuższa Seria", 343 | "Week Streak" => "Seria Tygodni", 344 | "Longest Week Streak" => "Najdłuższa Seria Tygodni", 345 | "Present" => "Dziś", 346 | "Excluding {days}" => "Wykluczono {days}", 347 | ], 348 | "ps" => [ 349 | "rtl" => true, 350 | "Total Contributions" => "ټولې ونډې", 351 | "Current Streak" => "اوسنی پرمختګ", 352 | "Longest Streak" => "تر ټولو اوږد پرمختګ", 353 | "Week Streak" => "د اونۍ پرمختګ", 354 | "Longest Week Streak" => "د اونۍ تر ټولو اوږد پرمختګ", 355 | "Present" => "اوس", 356 | "comma_separator" => "، ", 357 | "Excluding {days}" => "پرته {days}", 358 | ], 359 | "pt_BR" => [ 360 | "Total Contributions" => "Total de Contribuições", 361 | "Current Streak" => "Sequência Atual", 362 | "Longest Streak" => "Maior Sequência", 363 | "Week Streak" => "Sequência Semanal", 364 | "Longest Week Streak" => "Maior Sequência Semanal", 365 | "Present" => "Presente", 366 | "Excluding {days}" => "Exceto {days}", 367 | ], 368 | "ru" => [ 369 | "Total Contributions" => "Общий вклад", 370 | "Current Streak" => "Текущая серия", 371 | "Longest Streak" => "Самая длинная серия", 372 | "Week Streak" => "Текущая серия недель", 373 | "Longest Week Streak" => "Самая длинная серия недель", 374 | "Present" => "Сейчас", 375 | "Excluding {days}" => "Не включая {days}", 376 | ], 377 | "rw" => [ 378 | "Total Contributions" => "Imisanzu yose", 379 | "Current Streak" => "Igihe gishize ntaguhagarara", 380 | "Longest Streak" => "Igihe cyirecyire cyashize ntaguhagarara", 381 | "Week Streak" => "Igihe gishize ntaguhagarara mu cyumweru", 382 | "Longest Week Streak" => "Igihe cyirecyire cyashize ntaguhagarara mu byumweru", 383 | "Present" => "None", 384 | ], 385 | "sa" => [ 386 | "Total Contributions" => "कुल योगदानम्", 387 | "Current Streak" => "क्रमशः दिवसान् चालयन्तु", 388 | "Longest Streak" => "दीर्घतमाः क्रमशः दिवसाः", 389 | "Week Streak" => "निरन्तरसप्ताहाः", 390 | "Longest Week Streak" => "दीर्घतमाः निरन्तरसप्ताहाः", 391 | "Present" => "वर्तमान", 392 | "Excluding {days}" => "बहिष्करणम् {days}", 393 | ], 394 | "sd_PK" => [ 395 | "rtl" => true, 396 | "Total Contributions" => "کل حصہ داری", 397 | "Current Streak" => "موجوده سلسلو", 398 | "Longest Streak" => "تمام پري جو سلسلو", 399 | "Week Streak" => "ھفتي جو سلسلو", 400 | "Longest Week Streak" => "تمام پري جو ھفتيوار سلسلو", 401 | "Present" => "موجوده", 402 | "Excluding {days}" => "نڪتل {days}", 403 | "comma_separator" => "، ", 404 | ], 405 | "sr" => [ 406 | "Total Contributions" => "Укупно додавања", 407 | "Current Streak" => "Тренутна серија", 408 | "Longest Streak" => "Најдужа серија", 409 | "Week Streak" => "Недељна серија", 410 | "Longest Week Streak" => "Најдужа недељена серија", 411 | "Present" => "Данас", 412 | "Excluding {days}" => "Искључујући {days}", 413 | ], 414 | "su" => [ 415 | "Total Contributions" => "Total Kontribusi", 416 | "Current Streak" => "Aksi Ayeuna", 417 | "Longest Streak" => "Aksi Pangpanjangna", 418 | "Week Streak" => "Aksi Unggal Minggon", 419 | "Longest Week Streak" => "Aksi Unggal Minggon Pangpanjangna", 420 | "Present" => "Ayeuna", 421 | "Excluding {days}" => "Teu Kaasup {days}", 422 | ], 423 | "sv" => [ 424 | "Total Contributions" => "Totalt antal uppladningar", 425 | "Current Streak" => "Dagar uppladdat i rad just nu", 426 | "Longest Streak" => "Längst antal dagar uppladdat i rad", 427 | "Week Streak" => "Antal veckor i rad", 428 | "Longest Week Streak" => "Längst antal veckor i rad", 429 | "Present" => "Just nu", 430 | ], 431 | "sw" => [ 432 | "Total Contributions" => "Jumla ya Michango", 433 | "Current Streak" => "Mfululizo wa sasa", 434 | "Longest Streak" => "Mfululizo mrefu zaidi", 435 | "Week Streak" => "Mfululizo wa wiki", 436 | "Longest Week Streak" => "Mfululizo mrefu zaidi wa wiki", 437 | "Present" => "Sasa", 438 | "Excluding {days}" => "Ukiondoa {days}", 439 | ], 440 | "ta" => [ 441 | "Total Contributions" => "மொத்த\nபங்களிப்புகள்", 442 | "Current Streak" => "மிக சமீபத்திய பங்களிப்புகள்", 443 | "Longest Streak" => "நீண்ட\nபங்களிப்புகள்", 444 | "Week Streak" => "வார\nபங்களிப்புகள்", 445 | "Longest Week Streak" => "நீண்ட வார\nபங்களிப்புகள்", 446 | "Present" => "இன்றுவரை", 447 | ], 448 | "th" => [ 449 | "Total Contributions" => "คอนทริบิ้วต์ทั้งหมด", 450 | "Current Streak" => "สตรีคปัจจุบัน", 451 | "Longest Streak" => "สตรีคที่ยาวนานที่สุด", 452 | "Week Streak" => "สตรีคประจำสัปดาห์", 453 | "Longest Week Streak" => "สตรีคประจำสัปดาห์\nที่ยาวนานที่สุด", 454 | "Present" => "ปัจจุบัน", 455 | "Excluding {days}" => "ยกเว้น {days}", 456 | ], 457 | "tr" => [ 458 | "Total Contributions" => "Toplam Katkı", 459 | "Current Streak" => "Güncel Seri", 460 | "Longest Streak" => "En Uzun Seri", 461 | "Week Streak" => "Haftalık Seri", 462 | "Longest Week Streak" => "En Uzun Haftalık Seri", 463 | "Present" => "Şu an", 464 | "Excluding {days}" => "Hariç {days}", 465 | ], 466 | "uk" => [ 467 | "Total Contributions" => "Загальний вклад", 468 | "Current Streak" => "Поточна діяльність", 469 | "Longest Streak" => "Найдовша діяльність", 470 | "Week Streak" => "Діяльність за тиждень", 471 | "Longest Week Streak" => "Найбільша к-сть тижнів", 472 | "Present" => "Наразі", 473 | "Excluding {days}" => "Виключаючи {days}", 474 | ], 475 | "ur_PK" => [ 476 | "rtl" => true, 477 | "Total Contributions" => "کل حصہ داری", 478 | "Current Streak" => "موجودہ تسلسل", 479 | "Longest Streak" => "طویل ترین تسلسل", 480 | "Week Streak" => "ہفتہ وار تسلسل", 481 | "Longest Week Streak" => "طویل ترین ہفتہ وار تسلسل", 482 | "Present" => "حاظر", 483 | "Excluding {days}" => "خارج {days}", 484 | "comma_separator" => "، ", 485 | ], 486 | "vi" => [ 487 | "Total Contributions" => "Tổng số đóng góp", 488 | "Current Streak" => "Chuỗi đóng góp\nhiện tại", 489 | "Longest Streak" => "Chuỗi đóng góp lớn nhất", 490 | "Week Streak" => "Chuỗi tuần", 491 | "Longest Week Streak" => "Chuỗi tuần lớn nhất", 492 | "Present" => "Hiện tại", 493 | "Excluding {days}" => "Ngoại trừ {days}", 494 | ], 495 | "yo" => [ 496 | "Total Contributions" => "Lapapọ ilowosi", 497 | "Current Streak" => "ṣiṣan lọwọlọwọ", 498 | "Longest Streak" => "ṣiṣan ti o gun julọ", 499 | "Week Streak" => "ṣiṣan ọsẹ", 500 | "Longest Week Streak" => "gunjulo ọsẹ ṣiṣan", 501 | "Present" => "lọwọlọwọ", 502 | "Excluding {days}" => "Yato si {days}", 503 | ], 504 | "zh" => "zh_Hans", 505 | "zh_Hans" => [ 506 | "Total Contributions" => "合计贡献", 507 | "Current Streak" => "目前连续贡献", 508 | "Longest Streak" => "最长连续贡献", 509 | "Week Streak" => "周连续贡献", 510 | "Longest Week Streak" => "最长周连续贡献", 511 | "Present" => "至今", 512 | "Excluding {days}" => "除外 {days}", 513 | "comma_separator" => "、", 514 | ], 515 | "zh_Hant" => [ 516 | "Total Contributions" => "合計貢獻", 517 | "Current Streak" => "目前連續貢獻", 518 | "Longest Streak" => "最長連續貢獻", 519 | "Week Streak" => "周連續貢獻", 520 | "Longest Week Streak" => "最長周連續貢獻", 521 | "Present" => "至今", 522 | "Excluding {days}" => "除外 {days}", 523 | "comma_separator" => "、", 524 | ], 525 | ]; 526 | -------------------------------------------------------------------------------- /tests/OptionsTest.php: -------------------------------------------------------------------------------- 1 | "#FFFEFE", 14 | "border" => "#E4E2E2", 15 | "stroke" => "#E4E2E2", 16 | "ring" => "#FB8C00", 17 | "fire" => "#FB8C00", 18 | "currStreakNum" => "#151515", 19 | "sideNums" => "#151515", 20 | "currStreakLabel" => "#FB8C00", 21 | "sideLabels" => "#151515", 22 | "dates" => "#464646", 23 | "excludeDaysLabel" => "#464646", 24 | ]; 25 | 26 | /** 27 | * Test theme request parameters return colors for theme 28 | */ 29 | public function testThemes(): void 30 | { 31 | // check that getRequestedTheme returns correct colors for each theme 32 | $themes = include "src/themes.php"; 33 | foreach ($themes as $theme => $colors) { 34 | $actualColors = getRequestedTheme(["theme" => $theme]); 35 | $expectedColors = $colors; 36 | if (strpos($colors["background"], ",") !== false) { 37 | $expectedColors["background"] = "url(#gradient)"; 38 | // check that the background gradient is correct 39 | $this->assertStringContainsString("assertEquals($expectedColors, $actualColors); 44 | } 45 | } 46 | 47 | /** 48 | * Test fallback to default theme 49 | */ 50 | public function testFallbackToDefaultTheme(): void 51 | { 52 | // check that getRequestedTheme returns default for invalid theme 53 | // request parameters 54 | $params = ["theme" => "not a theme name"]; 55 | // test that invalid theme name gives default values 56 | $actual = getRequestedTheme($params); 57 | $expected = $this->defaultTheme; 58 | $expected["backgroundGradient"] = ""; 59 | $this->assertEquals($expected, $actual); 60 | } 61 | 62 | /** 63 | * Check that all themes have valid values for all parameters 64 | */ 65 | public function testThemesHaveValidParameters(): void 66 | { 67 | // check that all themes contain all parameters and have valid values 68 | $themes = include "src/themes.php"; 69 | $hexPartialRegex = "(?:[A-F0-9]{3}|[A-F0-9]{4}|[A-F0-9]{6}|[A-F0-9]{8})"; 70 | $hexRegex = "/^#{$hexPartialRegex}$/"; 71 | $backgroundRegex = "/^#{$hexPartialRegex}|-?\d+(?:,{$hexPartialRegex})+$/"; 72 | foreach ($themes as $theme => $colors) { 73 | // check that there are no extra keys in the theme 74 | $this->assertEquals( 75 | array_diff_key($colors, $this->defaultTheme), 76 | [], 77 | "The theme '$theme' contains invalid parameters." 78 | ); 79 | # check that no parameters are missing and all values are valid 80 | foreach (array_keys($this->defaultTheme) as $param) { 81 | // check that the key exists 82 | $this->assertArrayHasKey($param, $colors, "The theme '$theme' is missing the key '$param'."); 83 | if ($param === "background") { 84 | // check that the key is a valid background value 85 | $this->assertMatchesRegularExpression( 86 | $backgroundRegex, 87 | $colors[$param], 88 | "The parameter '$param' of '$theme' is not a valid background value." 89 | ); 90 | continue; 91 | } 92 | // check that the key is a valid hex color 93 | $this->assertMatchesRegularExpression( 94 | $hexRegex, 95 | strtoupper($colors[$param]), 96 | "The parameter '$param' of '$theme' is not a valid hex color." 97 | ); 98 | // check that the key is a valid hex color in uppercase 99 | $this->assertMatchesRegularExpression( 100 | $hexRegex, 101 | $colors[$param], 102 | "The parameter '$param' of '$theme' should not contain lowercase letters." 103 | ); 104 | } 105 | } 106 | } 107 | 108 | /** 109 | * Test parameters to override specific color 110 | */ 111 | public function testColorOverrideParameters(): void 112 | { 113 | // clear request parameters 114 | $params = []; 115 | // set default expected value 116 | $expected = $this->defaultTheme; 117 | foreach (array_keys($this->defaultTheme) as $param) { 118 | // set request parameter 119 | $params[$param] = "f00"; 120 | // update parameter in expected result 121 | $expected = array_merge($expected, [$param => "#f00"]); 122 | // test color change 123 | $actual = getRequestedTheme($params); 124 | $expected["backgroundGradient"] = ""; 125 | $this->assertEquals($expected, $actual); 126 | } 127 | } 128 | 129 | /** 130 | * Test color override parameters - all valid color inputs 131 | */ 132 | public function testValidColorInputs(): void 133 | { 134 | // valid color inputs and what the output color will be 135 | $validInputTypes = [ 136 | "f00" => "#f00", 137 | "f00f" => "#f00f", 138 | "ff0000" => "#ff0000", 139 | "FF0000" => "#ff0000", 140 | "ff0000ff" => "#ff0000ff", 141 | "red" => "red", 142 | ]; 143 | // set default expected value 144 | $expected = $this->defaultTheme; 145 | foreach ($validInputTypes as $input => $output) { 146 | // set request parameter 147 | $params = ["background" => $input]; 148 | // update parameter in expected result 149 | $expected = array_merge($expected, ["background" => $output]); 150 | // test color change 151 | $actual = getRequestedTheme($params); 152 | $expected["backgroundGradient"] = ""; 153 | $this->assertEquals($expected, $actual); 154 | } 155 | } 156 | 157 | /** 158 | * Test color override parameters - invalid color inputs 159 | */ 160 | public function testInvalidColorInputs(): void 161 | { 162 | // invalid color inputs 163 | $invalidInputTypes = [ 164 | "g00", # not 0-9, A-F 165 | "f00f0", # invalid number of characters 166 | "fakecolor", # invalid color name 167 | ]; 168 | foreach ($invalidInputTypes as $input) { 169 | // set request parameter 170 | $params = ["background" => $input]; 171 | // test that theme is still default 172 | $actual = getRequestedTheme($params); 173 | $expected = $this->defaultTheme; 174 | $expected["backgroundGradient"] = ""; 175 | $this->assertEquals($expected, $actual); 176 | } 177 | } 178 | 179 | /** 180 | * Test hide_border parameter 181 | */ 182 | public function testHideBorder(): void 183 | { 184 | // check that getRequestedTheme returns transparent border when hide_border is true 185 | $params = ["hide_border" => "true"]; 186 | $theme = getRequestedTheme($params); 187 | $this->assertEquals("#0000", $theme["border"]); 188 | // check that getRequestedTheme returns solid border when hide_border is not true 189 | $params = ["hide_border" => "false"]; 190 | $theme = getRequestedTheme($params); 191 | $this->assertEquals($this->defaultTheme["border"], $theme["border"]); 192 | } 193 | 194 | /** 195 | * Test date formatter for same year 196 | */ 197 | public function testDateFormatSameYear(): void 198 | { 199 | $year = date("Y"); 200 | $formatted = formatDate("$year-04-12", "M j[, Y]", "en"); 201 | $this->assertEquals("Apr 12", $formatted); 202 | } 203 | 204 | /** 205 | * Test date formatter for different year 206 | */ 207 | public function testDateFormatDifferentYear(): void 208 | { 209 | $formatted = formatDate("2000-04-12", "M j[, Y]", "en"); 210 | $this->assertEquals("Apr 12, 2000", $formatted); 211 | } 212 | 213 | /** 214 | * Test date formatter no brackets different year 215 | */ 216 | public function testDateFormatNoBracketsDiffYear(): void 217 | { 218 | $formatted = formatDate("2000-04-12", "Y/m/d", "en"); 219 | $this->assertEquals("2000/04/12", $formatted); 220 | } 221 | 222 | /** 223 | * Test date formatter no brackets same year 224 | */ 225 | public function testDateFormatNoBracketsSameYear(): void 226 | { 227 | $year = date("Y"); 228 | $formatted = formatDate("$year-04-12", "Y/m/d", "en"); 229 | $this->assertEquals("$year/04/12", $formatted); 230 | } 231 | 232 | /** 233 | * Test normalizing theme name 234 | */ 235 | public function testNormalizeThemeName(): void 236 | { 237 | $this->assertEquals("mytheme", normalizeThemeName("myTheme")); 238 | $this->assertEquals("my-theme", normalizeThemeName("My_Theme")); 239 | $this->assertEquals("my-theme", normalizeThemeName("my_theme")); 240 | $this->assertEquals("my-theme", normalizeThemeName("my-theme")); 241 | } 242 | 243 | /** 244 | * Test all theme names are normalized 245 | */ 246 | public function testAllThemeNamesNormalized(): void 247 | { 248 | $themes = include "src/themes.php"; 249 | foreach (array_keys($themes) as $theme) { 250 | $normalized = normalizeThemeName($theme); 251 | $this->assertEquals( 252 | $theme, 253 | $normalized, 254 | "Theme name '$theme' is not normalized. It should contain only lowercase letters, numbers, and dashes. Consider renaming it to '$normalized'." 255 | ); 256 | } 257 | } 258 | } 259 | -------------------------------------------------------------------------------- /tests/RenderTest.php: -------------------------------------------------------------------------------- 1 | "000000", 14 | "border" => "111111", 15 | "stroke" => "222222", 16 | "ring" => "333333", 17 | "fire" => "444444", 18 | "currStreakNum" => "555555", 19 | "sideNums" => "666666", 20 | "currStreakLabel" => "777777", 21 | "sideLabels" => "888888", 22 | "dates" => "999999", 23 | "excludeDaysLabel" => "aaaaaa", 24 | ]; 25 | 26 | private $testStats = [ 27 | "mode" => "daily", 28 | "totalContributions" => 2048, 29 | "firstContribution" => "2016-08-10", 30 | "longestStreak" => [ 31 | "start" => "2016-12-19", 32 | "end" => "2016-03-14", 33 | "length" => 86, 34 | ], 35 | "currentStreak" => [ 36 | "start" => "2019-03-28", 37 | "end" => "2019-04-12", 38 | "length" => 16, 39 | ], 40 | "excludedDays" => [], 41 | ]; 42 | 43 | /** 44 | * Test normal card render 45 | */ 46 | public function testCardRender(): void 47 | { 48 | // Check that the card is rendered as expected 49 | $render = generateCard($this->testStats, $this->testParams); 50 | $expected = file_get_contents("tests/expected/test_card.svg"); 51 | $this->assertEquals($expected, $render); 52 | 53 | // Test short_numbers parameter 54 | $this->testParams["short_numbers"] = "true"; 55 | $render = generateCard($this->testStats, $this->testParams); 56 | $this->assertStringContainsString("2K", $render); 57 | } 58 | 59 | /** 60 | * Test error card render 61 | */ 62 | public function testErrorCardRender(): void 63 | { 64 | // Check that error card is returned when no stats are provided 65 | $render = generateErrorCard("An unknown error occurred", $this->testParams); 66 | $expected = file_get_contents("tests/expected/test_error_card.svg"); 67 | $this->assertEquals($expected, $render); 68 | } 69 | 70 | /** 71 | * Test date_format parameter in render 72 | */ 73 | public function testDateFormatRender(): void 74 | { 75 | $year = date("Y"); 76 | $this->testStats["currentStreak"]["end"] = "$year-04-12"; 77 | $this->testParams["date_format"] = "[Y-]m-d"; 78 | // Check that the card is rendered as expected 79 | $render = generateCard($this->testStats, $this->testParams); 80 | $this->assertStringContainsString("2016-08-10 - Present", $render); 81 | $this->assertStringContainsString("2019-03-28 - 04-12", $render); 82 | $this->assertStringContainsString("2016-12-19 - 2016-03-14", $render); 83 | } 84 | 85 | /** 86 | * Test locale parameter in render with date_format in translation file 87 | */ 88 | public function testLocaleRenderDateFormat(): void 89 | { 90 | $this->testParams["locale"] = "ja"; 91 | // Check that the card is rendered as expected 92 | $render = generateCard($this->testStats, $this->testParams); 93 | $this->assertStringContainsString("2,048", $render); 94 | $this->assertStringContainsString("総コントリビューション数", $render); 95 | $this->assertStringContainsString("2016.8.10 - 今", $render); 96 | $this->assertStringContainsString("16", $render); 97 | $this->assertStringContainsString("現在のストリーク", $render); 98 | $this->assertStringContainsString("2019.3.28 - 2019.4.12", $render); 99 | $this->assertStringContainsString("86", $render); 100 | $this->assertStringContainsString("最長のストリーク", $render); 101 | $this->assertStringContainsString("2016.12.19 - 2016.3.14", $render); 102 | } 103 | 104 | /** 105 | * Test border radius 106 | */ 107 | public function testBorderRadius(): void 108 | { 109 | $this->testParams["border_radius"] = "16"; 110 | // Check that the card is rendered as expected 111 | $render = generateCard($this->testStats, $this->testParams); 112 | $this->assertStringContainsString("", $render); 113 | $this->assertStringContainsString( 114 | "", 115 | $render 116 | ); 117 | } 118 | 119 | /** 120 | * Test split lines function 121 | */ 122 | public function testSplitLines(): void 123 | { 124 | // Check normal label, no split 125 | $this->assertEquals("Total Contributions", splitLines("Total Contributions", 24, -9)); 126 | // Check label that is too long, split 127 | $this->assertEquals( 128 | "Chuỗi đóng góp hiệntại", 129 | splitLines("Chuỗi đóng góp hiện tại", 22, -9) 130 | ); 131 | // Check label with manually inserted line break, split 132 | $this->assertEquals( 133 | "Chuỗi đóng góphiện tại", 134 | splitLines("Chuỗi đóng góp\nhiện tại", 22, -9) 135 | ); 136 | // Check date range label, no split 137 | $this->assertEquals("Mar 28, 2019 – Apr 12, 2019", splitLines("Mar 28, 2019 – Apr 12, 2019", 28, 0)); 138 | // Check date range label that is too long, split 139 | $this->assertEquals( 140 | "19 de dez. de 2021- 14 de mar.", 141 | splitLines("19 de dez. de 2021 - 14 de mar.", 24, 0) 142 | ); 143 | } 144 | 145 | /** 146 | * Test disable_animations parameter 147 | */ 148 | public function testDisableAnimations(): void 149 | { 150 | $this->testParams["disable_animations"] = "true"; 151 | // Check that the card is rendered as expected 152 | $response = generateOutput($this->testStats, $this->testParams); 153 | $render = $response["body"]; 154 | $this->assertStringNotContainsString("opacity: 0;", $render); 155 | $this->assertStringContainsString("opacity: 1;", $render); 156 | $this->assertStringContainsString("font-size: 28px;", $render); 157 | $this->assertStringNotContainsString("animation:", $render); 158 | $this->assertStringNotContainsString(" 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 2,048 37 | 38 | 39 | 40 | 41 | 42 | 43 | Total Contributions 44 | 45 | 46 | 47 | 48 | 49 | 50 | Aug 10, 2016 - Present 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | Current Streak 59 | 60 | 61 | 62 | 63 | 64 | 65 | Mar 28, 2019 - Apr 12, 2019 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 16 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 86 92 | 93 | 94 | 95 | 96 | 97 | 98 | Longest Streak 99 | 100 | 101 | 102 | 103 | 104 | 105 | Dec 19, 2016 - Mar 14, 2016 106 | 107 | 108 | 109 | 110 | 111 | 112 | -------------------------------------------------------------------------------- /tests/expected/test_error_card.svg: -------------------------------------------------------------------------------- 1 | 2 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | An unknown error occurred 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | -------------------------------------------------------------------------------- /tests/phpunit/phpunit.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | ../ 6 | 7 | 8 | 9 | --------------------------------------------------------------------------------