feat: setup monorepo using pnpm (#20)
* tests: fix float problems and PAT problem * extend CONTRIBUTING.md * extend build documentation, add GH action, fix build on MacOS * fix build on Vercel * update .gitignore and CONTRIBUTING.md * feat: begin work on monorepo * chore(actions/basic-build): use pnpm * fix: vercel-deploy * docs: updates with new folders * chore: apply PR review comments --------- Co-authored-by: martin-mfg <2026226+martin-mfg@users.noreply.github.com>
This commit is contained in:
co-authored by
martin-mfg
parent
d08730aafd
commit
8c065b473f
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"name": "GitHub Readme Stats Dev",
|
||||
"image": "mcr.microsoft.com/devcontainers/base:ubuntu",
|
||||
"features": {
|
||||
"ghcr.io/devcontainers/features/node:1": { "version": "22" }
|
||||
},
|
||||
"forwardPorts": [3000],
|
||||
"portsAttributes": {
|
||||
"3000": { "label": "HTTP" }
|
||||
},
|
||||
"appPort": [],
|
||||
|
||||
// Use 'postCreateCommand' to run commands after the container is created.
|
||||
"postCreateCommand": "npm install -g vercel",
|
||||
|
||||
// Use 'postStartCommand' to run commands after the container is started.
|
||||
"postStartCommand": "hostname dev && npm install",
|
||||
|
||||
// Configure tool-specific properties.
|
||||
"customizations": {
|
||||
"vscode": {
|
||||
"extensions": [
|
||||
"yzhang.markdown-all-in-one",
|
||||
"esbenp.prettier-vscode",
|
||||
"dbaeumer.vscode-eslint",
|
||||
"github.vscode-github-actions"
|
||||
]
|
||||
}
|
||||
},
|
||||
|
||||
"remoteUser": "root",
|
||||
"privileged": true
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
name: "Static code analysis workflow (CodeQL)"
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
pull_request:
|
||||
branches:
|
||||
- master
|
||||
|
||||
permissions:
|
||||
actions: read
|
||||
checks: read
|
||||
contents: read
|
||||
deployments: read
|
||||
issues: read
|
||||
discussions: read
|
||||
packages: read
|
||||
pages: read
|
||||
pull-requests: read
|
||||
repository-projects: read
|
||||
security-events: write
|
||||
statuses: read
|
||||
|
||||
jobs:
|
||||
CodeQL-Build:
|
||||
if: github.repository == 'anuraghazra/github-readme-stats'
|
||||
|
||||
# CodeQL runs on ubuntu-latest, windows-latest, and macos-latest
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
|
||||
# Initializes the CodeQL tools for scanning.
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@46a6823b81f2d7c67ddf123851eea88365bc8a67 # v2.13.5
|
||||
with:
|
||||
languages: javascript
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@46a6823b81f2d7c67ddf123851eea88365bc8a67 # v2.13.5
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
import os
|
||||
|
||||
file = open('./vercel.json', 'r')
|
||||
str = file.read()
|
||||
file = open('./vercel.json', 'w')
|
||||
|
||||
str = str.replace('"maxDuration": 10', '"maxDuration": 15')
|
||||
|
||||
file.write(str)
|
||||
file.close()
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
name: Deployment Prep
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
|
||||
jobs:
|
||||
config:
|
||||
if: github.repository == 'anuraghazra/github-readme-stats'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
- name: Deployment Prep
|
||||
run: python ./.github/workflows/deploy-prep.py
|
||||
- uses: stefanzweifel/git-auto-commit-action@28e16e81777b558cc906c8750092100bbb34c5e3 # v7.0.0
|
||||
with:
|
||||
branch: vercel
|
||||
create_branch: true
|
||||
push_options: "--force"
|
||||
+41
@@ -0,0 +1,41 @@
|
||||
name: Test Deployment
|
||||
on:
|
||||
# Temporarily disabled automatic triggers; manual-only for now.
|
||||
workflow_dispatch:
|
||||
# Original trigger (restore to re-enable):
|
||||
# deployment_status:
|
||||
|
||||
permissions: read-all
|
||||
|
||||
jobs:
|
||||
e2eTests:
|
||||
# Temporarily disabled; set to the original condition to re-enable.
|
||||
# if:
|
||||
# github.repository == 'anuraghazra/github-readme-stats' &&
|
||||
# github.event_name == 'deployment_status' &&
|
||||
# github.event.deployment_status.state == 'success'
|
||||
if: false
|
||||
name: Perform e2e tests
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
node-version: [22.x]
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
|
||||
with:
|
||||
node-version: ${{ matrix.node-version }}
|
||||
cache: npm
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
env:
|
||||
CI: true
|
||||
|
||||
- name: Run end-to-end tests.
|
||||
run: npm run test:e2e
|
||||
# env:
|
||||
# VERCEL_PREVIEW_URL: ${{ github.event.deployment_status.target_url }}
|
||||
@@ -0,0 +1,47 @@
|
||||
name: Close empty issues and templates
|
||||
on:
|
||||
issues:
|
||||
types:
|
||||
- reopened
|
||||
- opened
|
||||
- edited
|
||||
|
||||
permissions:
|
||||
actions: read
|
||||
checks: read
|
||||
contents: read
|
||||
deployments: read
|
||||
issues: write
|
||||
discussions: read
|
||||
packages: read
|
||||
pages: read
|
||||
pull-requests: read
|
||||
repository-projects: read
|
||||
security-events: read
|
||||
statuses: read
|
||||
|
||||
jobs:
|
||||
closeEmptyIssuesAndTemplates:
|
||||
if: github.repository == 'anuraghazra/github-readme-stats'
|
||||
name: Close empty issues
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
# NOTE: Retrieve issue templates.
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
|
||||
- name: Run empty issues closer action
|
||||
uses: rickstaa/empty-issues-closer-action@e96914613221511279ca25f50fd4acc85e331d99 # v1.1.74
|
||||
env:
|
||||
github_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
with:
|
||||
close_comment:
|
||||
Closing this issue because it appears to be empty. Please update the
|
||||
issue for it to be reopened.
|
||||
open_comment:
|
||||
Reopening this issue because the author provided more information.
|
||||
check_templates: true
|
||||
template_close_comment:
|
||||
Closing this issue since the issue template was not filled in.
|
||||
Please provide us with more information to have this issue reopened.
|
||||
template_open_comment:
|
||||
Reopening this issue because the author provided more information.
|
||||
@@ -0,0 +1,59 @@
|
||||
name: Generate Theme Readme
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
paths:
|
||||
- "themes/index.js"
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
actions: read
|
||||
checks: read
|
||||
contents: write
|
||||
deployments: read
|
||||
issues: read
|
||||
discussions: read
|
||||
packages: read
|
||||
pages: read
|
||||
pull-requests: read
|
||||
repository-projects: read
|
||||
security-events: read
|
||||
statuses: read
|
||||
|
||||
jobs:
|
||||
generateThemeDoc:
|
||||
runs-on: ubuntu-latest
|
||||
name: Generate theme doc
|
||||
strategy:
|
||||
matrix:
|
||||
node-version: [22.x]
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
|
||||
with:
|
||||
node-version: ${{ matrix.node-version }}
|
||||
cache: npm
|
||||
|
||||
# Fix the unsafe repo error which was introduced by the CVE-2022-24765 git patches.
|
||||
- name: Fix unsafe repo error
|
||||
run: git config --global --add safe.directory ${{ github.workspace }}
|
||||
|
||||
- name: npm install, generate readme
|
||||
run: |
|
||||
npm ci
|
||||
npm run theme-readme-gen
|
||||
env:
|
||||
CI: true
|
||||
|
||||
- name: Run Script
|
||||
uses: skx/github-action-tester@e29768ff4ff67be9d1fdbccd8836ab83233bebb1 # v0.10.0
|
||||
with:
|
||||
script: ./scripts/push-theme-readme.sh
|
||||
env:
|
||||
CI: true
|
||||
PERSONAL_TOKEN: ${{ secrets.PERSONAL_TOKEN }}
|
||||
GH_REPO: ${{ secrets.GH_REPO }}
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
name: "Pull Request Labeler"
|
||||
on:
|
||||
- pull_request_target
|
||||
|
||||
permissions:
|
||||
actions: read
|
||||
checks: read
|
||||
contents: read
|
||||
deployments: read
|
||||
issues: read
|
||||
discussions: read
|
||||
packages: read
|
||||
pages: read
|
||||
pull-requests: write
|
||||
repository-projects: read
|
||||
security-events: read
|
||||
statuses: read
|
||||
|
||||
jobs:
|
||||
triage:
|
||||
if: github.repository == 'anuraghazra/github-readme-stats'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/labeler@634933edcd8ababfe52f92936142cc22ac488b1b # v6.0.1
|
||||
with:
|
||||
repo-token: "${{ secrets.GITHUB_TOKEN }}"
|
||||
sync-labels: true
|
||||
@@ -0,0 +1,49 @@
|
||||
name: OSSF Scorecard analysis workflow
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
pull_request:
|
||||
branches:
|
||||
- master
|
||||
|
||||
permissions: read-all
|
||||
|
||||
jobs:
|
||||
analysis:
|
||||
if: github.repository == 'anuraghazra/github-readme-stats'
|
||||
name: Scorecard analysis
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
# Needed if using Code scanning alerts
|
||||
security-events: write
|
||||
# Needed for GitHub OIDC token if publish_results is true
|
||||
id-token: write
|
||||
|
||||
steps:
|
||||
- name: "Checkout code"
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: "Run analysis"
|
||||
uses: ossf/scorecard-action@4eaacf0543bb3f2c246792bd56e8cdeffafb205a # v2.4.3
|
||||
with:
|
||||
results_file: results.sarif
|
||||
results_format: sarif
|
||||
publish_results: true
|
||||
|
||||
# Upload the results as artifacts (optional). Commenting out will disable uploads of run results in SARIF
|
||||
# format to the repository Actions tab.
|
||||
- name: "Upload artifact"
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
with:
|
||||
name: SARIF file
|
||||
path: results.sarif
|
||||
retention-days: 5
|
||||
|
||||
# required for Code scanning alerts
|
||||
- name: "Upload SARIF results to code scanning"
|
||||
uses: github/codeql-action/upload-sarif@fdcae64e1484d349b3366718cdfef3d404390e85 # v2.22.1
|
||||
with:
|
||||
sarif_file: results.sarif
|
||||
@@ -0,0 +1,50 @@
|
||||
name: Theme preview
|
||||
on:
|
||||
# Temporary disabled due to paused themes addition.
|
||||
# See: https://github.com/anuraghazra/github-readme-stats/issues/3404
|
||||
# pull_request_target:
|
||||
# types: [opened, edited, reopened, synchronize]
|
||||
# branches:
|
||||
# - master
|
||||
# paths:
|
||||
# - "themes/index.js"
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
actions: read
|
||||
checks: read
|
||||
contents: read
|
||||
deployments: read
|
||||
issues: read
|
||||
discussions: read
|
||||
packages: read
|
||||
pages: read
|
||||
pull-requests: write
|
||||
repository-projects: read
|
||||
security-events: read
|
||||
statuses: read
|
||||
|
||||
jobs:
|
||||
previewTheme:
|
||||
name: Install & Preview
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
node-version: [22.x]
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
|
||||
with:
|
||||
node-version: ${{ matrix.node-version }}
|
||||
cache: npm
|
||||
|
||||
- uses: bahmutov/npm-install@3e063b974f0d209807684aa23e534b3dde517fd9 # v1.11.2
|
||||
with:
|
||||
useLockFile: false
|
||||
|
||||
- run: npm run preview-theme
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
@@ -0,0 +1,44 @@
|
||||
name: Cleanup closed pull requests cache
|
||||
on:
|
||||
pull_request:
|
||||
types:
|
||||
- closed
|
||||
|
||||
permissions:
|
||||
actions: write
|
||||
checks: read
|
||||
contents: read
|
||||
deployments: read
|
||||
issues: read
|
||||
discussions: read
|
||||
packages: read
|
||||
pages: read
|
||||
pull-requests: read
|
||||
repository-projects: read
|
||||
security-events: read
|
||||
statuses: read
|
||||
|
||||
jobs:
|
||||
cleanup:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Cleanup
|
||||
run: |
|
||||
gh extension install actions/gh-actions-cache
|
||||
|
||||
REPO=${{ github.repository }}
|
||||
BRANCH="refs/pull/${{ github.event.pull_request.number }}/merge"
|
||||
|
||||
echo "Fetching list of cache key"
|
||||
cacheKeysForPR=$(gh actions-cache list -R $REPO -B $BRANCH | cut -f 1 )
|
||||
|
||||
## Setting this to not fail the workflow while deleting cache keys.
|
||||
set +e
|
||||
echo "Deleting caches..."
|
||||
for cacheKey in $cacheKeysForPR
|
||||
do
|
||||
gh actions-cache delete $cacheKey -R $REPO -B $BRANCH --confirm
|
||||
done
|
||||
echo "Done"
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
@@ -0,0 +1,57 @@
|
||||
name: Close stale theme pull requests that have the 'invalid' label.
|
||||
on:
|
||||
# Temporary disabled due to paused themes addition.
|
||||
# See: https://github.com/anuraghazra/github-readme-stats/issues/3404
|
||||
# schedule:
|
||||
# # ┌───────────── minute (0 - 59)
|
||||
# # │ ┌───────────── hour (0 - 23)
|
||||
# # │ │ ┌───────────── day of the month (1 - 31)
|
||||
# # │ │ │ ┌───────────── month (1 - 12 or JAN-DEC)
|
||||
# # │ │ │ │ ┌───────────── day of the week (0 - 6 or SUN-SAT)
|
||||
# # │ │ │ │ │
|
||||
# # │ │ │ │ │
|
||||
# # │ │ │ │ │
|
||||
# # * * * * *
|
||||
# - cron: "0 0 */7 * *"
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
actions: read
|
||||
checks: read
|
||||
contents: read
|
||||
deployments: read
|
||||
issues: read
|
||||
discussions: read
|
||||
packages: read
|
||||
pages: read
|
||||
pull-requests: write
|
||||
repository-projects: read
|
||||
security-events: read
|
||||
statuses: read
|
||||
|
||||
jobs:
|
||||
closeOldThemePrs:
|
||||
if: github.repository == 'anuraghazra/github-readme-stats'
|
||||
name: Close stale 'invalid' theme PRs
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
node-version: [22.x]
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
|
||||
with:
|
||||
node-version: ${{ matrix.node-version }}
|
||||
cache: npm
|
||||
|
||||
- uses: bahmutov/npm-install@3e063b974f0d209807684aa23e534b3dde517fd9 # v1.11.2
|
||||
with:
|
||||
useLockFile: false
|
||||
|
||||
- run: npm run close-stale-theme-prs
|
||||
env:
|
||||
STALE_DAYS: 20
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
+47
@@ -0,0 +1,47 @@
|
||||
name: Test
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
pull_request:
|
||||
branches:
|
||||
- master
|
||||
|
||||
permissions: read-all
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: Perform tests
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
node-version: [22.x]
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
|
||||
with:
|
||||
node-version: ${{ matrix.node-version }}
|
||||
cache: npm
|
||||
|
||||
- name: Install & Test
|
||||
run: |
|
||||
npm ci
|
||||
npm run test
|
||||
|
||||
- name: Run ESLint
|
||||
run: |
|
||||
npm run lint
|
||||
|
||||
- name: Run bench tests
|
||||
run: |
|
||||
npm run bench
|
||||
|
||||
- name: Run Prettier
|
||||
run: |
|
||||
npm run format:check
|
||||
|
||||
- name: Code Coverage
|
||||
uses: codecov/codecov-action@4fe8c5f003fae66aa5ebb77cfd3e7bfbbda0b6b0 # v3.1.5
|
||||
@@ -0,0 +1,41 @@
|
||||
name: Theme Pull Requests Closer
|
||||
|
||||
on:
|
||||
- pull_request_target
|
||||
|
||||
permissions:
|
||||
actions: read
|
||||
checks: read
|
||||
contents: read
|
||||
deployments: read
|
||||
issues: read
|
||||
discussions: read
|
||||
packages: read
|
||||
pages: read
|
||||
pull-requests: write
|
||||
repository-projects: read
|
||||
security-events: read
|
||||
statuses: read
|
||||
|
||||
jobs:
|
||||
close-prs:
|
||||
if: github.repository == 'anuraghazra/github-readme-stats'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out the code
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
|
||||
- name: Set up Git
|
||||
run: |
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||
|
||||
- name: Close Pull Requests
|
||||
run: |
|
||||
comment_message="We are currently pausing addition of new themes. If this theme is exclusively for your personal use, then instead of adding it to our theme collection, you can use card [customization options](https://github.com/anuraghazra/github-readme-stats?tab=readme-ov-file#customization)."
|
||||
|
||||
for pr_number in $(gh pr list -l "themes" -q is:open --json number -q ".[].number"); do
|
||||
gh pr close $pr_number -c "$comment_message"
|
||||
done
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
@@ -0,0 +1,49 @@
|
||||
name: Update top issues dashboard
|
||||
on:
|
||||
schedule:
|
||||
# ┌───────────── minute (0 - 59)
|
||||
# │ ┌───────────── hour (0 - 23)
|
||||
# │ │ ┌───────────── day of the month (1 - 31)
|
||||
# │ │ │ ┌───────────── month (1 - 12 or JAN-DEC)
|
||||
# │ │ │ │ ┌───────────── day of the week (0 - 6 or SUN-SAT)
|
||||
# │ │ │ │ │
|
||||
# │ │ │ │ │
|
||||
# │ │ │ │ │
|
||||
# * * * * *
|
||||
- cron: "0 0 */3 * *"
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
actions: read
|
||||
checks: read
|
||||
contents: read
|
||||
deployments: read
|
||||
issues: write
|
||||
discussions: read
|
||||
packages: read
|
||||
pages: read
|
||||
pull-requests: write
|
||||
repository-projects: read
|
||||
security-events: read
|
||||
statuses: read
|
||||
|
||||
jobs:
|
||||
showAndLabelTopIssues:
|
||||
if: github.repository == 'anuraghazra/github-readme-stats'
|
||||
name: Update top issues Dashboard.
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Run top issues action
|
||||
uses: rickstaa/top-issues-action@7e8dda5d5ae3087670f9094b9724a9a091fc3ba1 # v1.3.101
|
||||
env:
|
||||
github_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
with:
|
||||
top_list_size: 10
|
||||
filter: "1772"
|
||||
label: true
|
||||
dashboard: true
|
||||
dashboard_show_total_reactions: true
|
||||
top_issues: true
|
||||
top_bugs: true
|
||||
top_features: true
|
||||
top_pull_requests: true
|
||||
+67
@@ -0,0 +1,67 @@
|
||||
name: Update supported languages
|
||||
on:
|
||||
schedule:
|
||||
# ┌───────────── minute (0 - 59)
|
||||
# │ ┌───────────── hour (0 - 23)
|
||||
# │ │ ┌───────────── day of the month (1 - 31)
|
||||
# │ │ │ ┌───────────── month (1 - 12 or JAN-DEC)
|
||||
# │ │ │ │ ┌───────────── day of the week (0 - 6 or SUN-SAT)
|
||||
# │ │ │ │ │
|
||||
# │ │ │ │ │
|
||||
# │ │ │ │ │
|
||||
# * * * * *
|
||||
- cron: "0 0 */30 * *"
|
||||
|
||||
permissions:
|
||||
actions: read
|
||||
checks: read
|
||||
contents: write
|
||||
deployments: read
|
||||
issues: read
|
||||
discussions: read
|
||||
packages: read
|
||||
pages: read
|
||||
pull-requests: write
|
||||
repository-projects: read
|
||||
security-events: read
|
||||
statuses: read
|
||||
|
||||
jobs:
|
||||
updateLanguages:
|
||||
if: github.repository == 'anuraghazra/github-readme-stats'
|
||||
name: Update supported languages
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
node-version: [22.x]
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
|
||||
with:
|
||||
node-version: ${{ matrix.node-version }}
|
||||
cache: npm
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
env:
|
||||
CI: true
|
||||
|
||||
- name: Run update-languages-json.js script
|
||||
run: npm run generate-langs-json
|
||||
|
||||
- name: Create Pull Request if upstream language file is changed
|
||||
uses: peter-evans/create-pull-request@98357b18bf14b5342f975ff684046ec3b2a07725 # v8.0.0
|
||||
with:
|
||||
commit-message: "refactor: update languages JSON"
|
||||
branch: "update_langs/patch"
|
||||
delete-branch: true
|
||||
title: Update languages JSON
|
||||
body:
|
||||
"The
|
||||
[update-langs](https://github.com/anuraghazra/github-readme-stats/actions/workflows/update-langs.yaml)
|
||||
action found new/updated languages in the [upstream languages JSON
|
||||
file](https://raw.githubusercontent.com/github/linguist/master/lib/linguist/languages.yml)."
|
||||
labels: "ci, lang-card"
|
||||
@@ -0,0 +1 @@
|
||||
22
|
||||
@@ -0,0 +1,5 @@
|
||||
node_modules
|
||||
*.json
|
||||
*.md
|
||||
coverage
|
||||
.vercel
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"trailingComma": "all",
|
||||
"useTabs": false,
|
||||
"endOfLine": "auto",
|
||||
"proseWrap": "always"
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
.devcontainer
|
||||
.github
|
||||
.husky
|
||||
.vscode
|
||||
benchmarks
|
||||
coverage
|
||||
scripts
|
||||
tests
|
||||
.env
|
||||
**/*.md
|
||||
**/*.svg
|
||||
.eslintrc.json
|
||||
.prettierignore
|
||||
.pretterrc.json
|
||||
codecov.yml
|
||||
Vendored
+9
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"recommendations": [
|
||||
"yzhang.markdown-all-in-one",
|
||||
"esbenp.prettier-vscode",
|
||||
"dbaeumer.vscode-eslint",
|
||||
"ms-azuretools.vscode-containers",
|
||||
"github.vscode-github-actions"
|
||||
]
|
||||
}
|
||||
Vendored
+8
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"markdown.extension.toc.levels": "1..3",
|
||||
"editor.formatOnSave": true,
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode",
|
||||
"[javascript]": {
|
||||
"editor.tabSize": 2
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"version": 3
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"runtime": "nodejs22.x",
|
||||
"handler": "router.js",
|
||||
"launcherType": "Nodejs"
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import { default as api } from "./api-renamed/index.js";
|
||||
import { default as gist } from "./api-renamed/gist.js";
|
||||
import { default as pin } from "./api-renamed/pin.js";
|
||||
import { default as topLangs } from "./api-renamed/top-langs.js";
|
||||
import { default as wakatime } from "./api-renamed/wakatime.js";
|
||||
import { default as wakatimeProxy } from "./api-renamed/wakatime-proxy.js";
|
||||
import { default as repeatRecent } from "./api-renamed/repeat-recent.js";
|
||||
import { default as patInfo } from "./api-renamed/status/pat-info.js";
|
||||
import { default as statusUp } from "./api-renamed/status/up.js";
|
||||
import { default as authenticate } from "./api-renamed/authenticate.js";
|
||||
import { default as deleteUser } from "./api-renamed/delete-user.js";
|
||||
import { default as userAccess } from "./api-renamed/user-access.js";
|
||||
import { default as downgrade } from "./api-renamed/downgrade.js";
|
||||
|
||||
export default async (req, res) => {
|
||||
// remaining code expects express.js-like request and response objects
|
||||
res.send = function (data) {
|
||||
if (typeof data === "object") {
|
||||
res.setHeader("Content-Type", "application/json");
|
||||
res.end(JSON.stringify(data));
|
||||
} else if (typeof data === "string") {
|
||||
res.end(data);
|
||||
} else {
|
||||
res.end(String(data));
|
||||
}
|
||||
};
|
||||
const url = new URL(req.url, "https://localhost");
|
||||
req.query = Object.fromEntries(url.searchParams.entries());
|
||||
|
||||
switch (url.pathname) {
|
||||
case "/api":
|
||||
await api(req, res);
|
||||
break;
|
||||
case "/api/gist":
|
||||
await gist(req, res);
|
||||
break;
|
||||
case "/api/pin":
|
||||
await pin(req, res);
|
||||
break;
|
||||
case "/api/top-langs":
|
||||
await topLangs(req, res);
|
||||
break;
|
||||
case "/api/wakatime":
|
||||
await wakatime(req, res);
|
||||
break;
|
||||
case "/api/wakatime-proxy":
|
||||
await wakatimeProxy(req, res);
|
||||
break;
|
||||
case "/api/repeat-recent":
|
||||
await repeatRecent(req, res);
|
||||
break;
|
||||
case "/api/status/pat-info":
|
||||
await patInfo(req, res);
|
||||
break;
|
||||
case "/api/status/up":
|
||||
await statusUp(req, res);
|
||||
break;
|
||||
case "/api/authenticate":
|
||||
await authenticate(req, res);
|
||||
break;
|
||||
case "/api/delete-user":
|
||||
await deleteUser(req, res);
|
||||
break;
|
||||
case "/api/user-access":
|
||||
await userAccess(req, res);
|
||||
break;
|
||||
case "/api/downgrade":
|
||||
await downgrade(req, res);
|
||||
break;
|
||||
default:
|
||||
res.statusCode = 404;
|
||||
res.end("Not Found");
|
||||
break;
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"expiration": 39600,
|
||||
"bypassToken": "r3fr3shT0k3n-r3fr3shT0k3n-r3fr3shT0k3n",
|
||||
"passQuery": true
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
../api.func
|
||||
@@ -0,0 +1 @@
|
||||
../api.func
|
||||
@@ -0,0 +1 @@
|
||||
../api.func
|
||||
@@ -0,0 +1 @@
|
||||
../api.func
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"expiration": 39600,
|
||||
"bypassToken": "r3fr3shT0k3n-r3fr3shT0k3n-r3fr3shT0k3n",
|
||||
"passQuery": true
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
../api.func
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"expiration": 39600,
|
||||
"bypassToken": "r3fr3shT0k3n-r3fr3shT0k3n-r3fr3shT0k3n",
|
||||
"passQuery": true
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
../api.func
|
||||
@@ -0,0 +1 @@
|
||||
../../api.func
|
||||
@@ -0,0 +1 @@
|
||||
../../api.func
|
||||
@@ -0,0 +1 @@
|
||||
../api.func
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"expiration": 39600,
|
||||
"bypassToken": "r3fr3shT0k3n-r3fr3shT0k3n-r3fr3shT0k3n",
|
||||
"passQuery": true
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
../api.func
|
||||
@@ -0,0 +1 @@
|
||||
../api.func
|
||||
@@ -0,0 +1 @@
|
||||
../api.func
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"expiration": 39600,
|
||||
"bypassToken": "r3fr3shT0k3n-r3fr3shT0k3n-r3fr3shT0k3n",
|
||||
"passQuery": true
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { logger } from "../src/common/log.js";
|
||||
import { authenticate } from "../src/users.js";
|
||||
|
||||
/**
|
||||
* @param {any} req The request.
|
||||
* @param {any} res The response.
|
||||
*/
|
||||
export default async (req, res) => {
|
||||
const { code, private_access, user_key } = req.query;
|
||||
try {
|
||||
let { userId, needDowngrade } = await authenticate(
|
||||
code,
|
||||
private_access === "true",
|
||||
user_key,
|
||||
);
|
||||
res.send({ userId, needDowngrade });
|
||||
} catch (err) {
|
||||
logger.error(err);
|
||||
res.send("Something went wrong: " + err.message);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,17 @@
|
||||
import { logger } from "../src/common/log.js";
|
||||
import { deleteUser } from "../src/common/database.js";
|
||||
|
||||
/**
|
||||
* @param {any} req The request.
|
||||
* @param {any} res The response.
|
||||
*/
|
||||
export default async (req, res) => {
|
||||
const { user_key } = req.query;
|
||||
try {
|
||||
await deleteUser(user_key);
|
||||
} catch (err) {
|
||||
logger.error(err);
|
||||
res.send("Something went wrong: " + err.message);
|
||||
}
|
||||
res.send("ok");
|
||||
};
|
||||
@@ -0,0 +1,73 @@
|
||||
import { getUserAccessByKey, deleteUser } from "../src/common/database.js";
|
||||
import axios from "axios";
|
||||
import { logger } from "../src/common/log.js";
|
||||
|
||||
export default async (req, res) => {
|
||||
// We could optimize this method by doing both database operations in one statement, using "DELETE ... RETURNING ..."
|
||||
|
||||
const { user_key } = req.query;
|
||||
if (!user_key) {
|
||||
res.statusCode = 400;
|
||||
res.send("missing user_key");
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
!process.env.OAUTH_CLIENT_ID ||
|
||||
!process.env.OAUTH_CLIENT_SECRET ||
|
||||
!process.env.OAUTH_REDIRECT_URI
|
||||
) {
|
||||
throw new Error(
|
||||
"OAuth Error: One or more required environment variables (OAUTH_CLIENT_ID, OAUTH_CLIENT_SECRET, OAUTH_REDIRECT_URI) are not set.",
|
||||
);
|
||||
}
|
||||
|
||||
// get token and private access status
|
||||
const userAccess = await getUserAccessByKey(user_key);
|
||||
if (!userAccess) {
|
||||
res.statusCode = 404;
|
||||
res.send("user not found");
|
||||
return;
|
||||
}
|
||||
|
||||
// verify that user has private access
|
||||
if (!userAccess.privateAccess) {
|
||||
res.statusCode = 400;
|
||||
res.send("user does not have private access");
|
||||
return;
|
||||
}
|
||||
|
||||
// delete existing app authorization via GitHub API
|
||||
try {
|
||||
await axios.delete(
|
||||
`https://api.github.com/applications/${process.env.OAUTH_CLIENT_ID}/grant`,
|
||||
{
|
||||
auth: {
|
||||
username: process.env.OAUTH_CLIENT_ID,
|
||||
password: process.env.OAUTH_CLIENT_SECRET,
|
||||
},
|
||||
data: { access_token: userAccess.token },
|
||||
headers: {
|
||||
Accept: "application/vnd.github+json",
|
||||
},
|
||||
},
|
||||
);
|
||||
} catch (err) {
|
||||
logger.error(err);
|
||||
res.statusCode = 500;
|
||||
res.send("Failed to delete GitHub authorization with private access");
|
||||
return;
|
||||
}
|
||||
|
||||
await deleteUser(user_key);
|
||||
|
||||
// redirect to GitHub OAuth for public access
|
||||
const params = new URLSearchParams({
|
||||
client_id: process.env.OAUTH_CLIENT_ID,
|
||||
redirect_uri: process.env.OAUTH_REDIRECT_URI,
|
||||
}).toString();
|
||||
|
||||
res.statusCode = 302;
|
||||
res.setHeader("Location", `https://github.com/login/oauth/authorize?${params}`);
|
||||
res.end();
|
||||
};
|
||||
@@ -0,0 +1,129 @@
|
||||
// @ts-check
|
||||
|
||||
import { renderError } from "../src/common/render.js";
|
||||
import { isLocaleAvailable } from "../src/translations.js";
|
||||
import { renderGistCard } from "../src/cards/gist.js";
|
||||
import { fetchGist } from "../src/fetchers/gist.js";
|
||||
import { storeRequest } from "../src/common/database.js";
|
||||
import {
|
||||
CACHE_TTL,
|
||||
resolveCacheSeconds,
|
||||
setCacheHeaders,
|
||||
setErrorCacheHeaders,
|
||||
} from "../src/common/cache.js";
|
||||
import { guardAccess } from "../src/common/access.js";
|
||||
import {
|
||||
MissingParamError,
|
||||
retrieveSecondaryMessage,
|
||||
} from "../src/common/error.js";
|
||||
import { parseBoolean } from "../src/common/ops.js";
|
||||
|
||||
// @ts-ignore
|
||||
export default async (req, res) => {
|
||||
const {
|
||||
id,
|
||||
title_color,
|
||||
icon_color,
|
||||
text_color,
|
||||
bg_color,
|
||||
theme,
|
||||
cache_seconds,
|
||||
locale,
|
||||
border_radius,
|
||||
border_color,
|
||||
show_owner,
|
||||
hide_border,
|
||||
} = req.query;
|
||||
|
||||
res.setHeader("Content-Type", "image/svg+xml");
|
||||
|
||||
const access = guardAccess({
|
||||
res,
|
||||
id,
|
||||
type: "gist",
|
||||
colors: {
|
||||
title_color,
|
||||
text_color,
|
||||
bg_color,
|
||||
border_color,
|
||||
theme,
|
||||
},
|
||||
});
|
||||
if (!access.isPassed) {
|
||||
return access.result;
|
||||
}
|
||||
|
||||
if (locale && !isLocaleAvailable(locale)) {
|
||||
return res.send(
|
||||
renderError({
|
||||
message: "Something went wrong",
|
||||
secondaryMessage: "Language not found",
|
||||
renderOptions: {
|
||||
title_color,
|
||||
text_color,
|
||||
bg_color,
|
||||
border_color,
|
||||
theme,
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
await storeRequest(req);
|
||||
const gistData = await fetchGist(id);
|
||||
const cacheSeconds = resolveCacheSeconds({
|
||||
requested: parseInt(cache_seconds, 10),
|
||||
def: CACHE_TTL.GIST_CARD.DEFAULT,
|
||||
min: CACHE_TTL.GIST_CARD.MIN,
|
||||
max: CACHE_TTL.GIST_CARD.MAX,
|
||||
});
|
||||
|
||||
setCacheHeaders(res, cacheSeconds);
|
||||
|
||||
return res.send(
|
||||
renderGistCard(gistData, {
|
||||
title_color,
|
||||
icon_color,
|
||||
text_color,
|
||||
bg_color,
|
||||
theme,
|
||||
border_radius,
|
||||
border_color,
|
||||
locale: locale ? locale.toLowerCase() : null,
|
||||
show_owner: parseBoolean(show_owner),
|
||||
hide_border: parseBoolean(hide_border),
|
||||
}),
|
||||
);
|
||||
} catch (err) {
|
||||
setErrorCacheHeaders(res);
|
||||
if (err instanceof Error) {
|
||||
return res.send(
|
||||
renderError({
|
||||
message: err.message,
|
||||
secondaryMessage: retrieveSecondaryMessage(err),
|
||||
renderOptions: {
|
||||
title_color,
|
||||
text_color,
|
||||
bg_color,
|
||||
border_color,
|
||||
theme,
|
||||
show_repo_link: !(err instanceof MissingParamError),
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
return res.send(
|
||||
renderError({
|
||||
message: "An unknown error occurred",
|
||||
renderOptions: {
|
||||
title_color,
|
||||
text_color,
|
||||
bg_color,
|
||||
border_color,
|
||||
theme,
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,213 @@
|
||||
// @ts-check
|
||||
|
||||
import { renderStatsCard } from "../src/cards/stats.js";
|
||||
import { guardAccess } from "../src/common/access.js";
|
||||
import {
|
||||
CACHE_TTL,
|
||||
resolveCacheSeconds,
|
||||
setCacheHeaders,
|
||||
setErrorCacheHeaders,
|
||||
} from "../src/common/cache.js";
|
||||
import {
|
||||
MissingParamError,
|
||||
retrieveSecondaryMessage,
|
||||
} from "../src/common/error.js";
|
||||
import { parseArray, parseBoolean } from "../src/common/ops.js";
|
||||
import { renderError } from "../src/common/render.js";
|
||||
import { fetchStats } from "../src/fetchers/stats.js";
|
||||
import { isLocaleAvailable } from "../src/translations.js";
|
||||
import { storeRequest } from "../src/common/database.js";
|
||||
|
||||
// @ts-ignore
|
||||
export default async (req, res) => {
|
||||
const {
|
||||
username,
|
||||
repo,
|
||||
owner,
|
||||
hide,
|
||||
hide_title,
|
||||
hide_border,
|
||||
card_width,
|
||||
hide_rank,
|
||||
show_icons,
|
||||
include_all_commits,
|
||||
commits_year,
|
||||
line_height,
|
||||
title_color,
|
||||
ring_color,
|
||||
icon_color,
|
||||
text_color,
|
||||
text_bold,
|
||||
bg_color,
|
||||
theme,
|
||||
cache_seconds,
|
||||
exclude_repo,
|
||||
custom_title,
|
||||
locale,
|
||||
disable_animations,
|
||||
border_radius,
|
||||
number_format,
|
||||
role,
|
||||
number_precision,
|
||||
border_color,
|
||||
rank_icon,
|
||||
show,
|
||||
} = req.query;
|
||||
res.setHeader("Content-Type", "image/svg+xml");
|
||||
|
||||
const access = guardAccess({
|
||||
res,
|
||||
id: username,
|
||||
type: "username",
|
||||
colors: {
|
||||
title_color,
|
||||
text_color,
|
||||
bg_color,
|
||||
border_color,
|
||||
theme,
|
||||
},
|
||||
});
|
||||
if (!access.isPassed) {
|
||||
return access.result;
|
||||
}
|
||||
|
||||
if (locale && !isLocaleAvailable(locale)) {
|
||||
return res.send(
|
||||
renderError({
|
||||
message: "Something went wrong",
|
||||
secondaryMessage: "Language not found",
|
||||
renderOptions: {
|
||||
title_color,
|
||||
text_color,
|
||||
bg_color,
|
||||
border_color,
|
||||
theme,
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
const safePattern = /^[-\w\/.,]+$/;
|
||||
if (
|
||||
(username && !safePattern.test(username)) ||
|
||||
(repo && !safePattern.test(repo)) ||
|
||||
(owner && !safePattern.test(owner))
|
||||
) {
|
||||
return res.send(
|
||||
renderError(
|
||||
{
|
||||
message: "Something went wrong",
|
||||
secondaryMessage: "Username, repository or owner contains unsafe characters",
|
||||
renderOptions: {
|
||||
title_color,
|
||||
text_color,
|
||||
bg_color,
|
||||
border_color,
|
||||
theme,
|
||||
},
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
await storeRequest(req);
|
||||
const showStats = parseArray(show);
|
||||
const repoOwner = parseArray(owner);
|
||||
let repository = parseArray(repo);
|
||||
repository = repository.map((repo) =>
|
||||
repo.includes("/") ? repo : `${username}/${repo}`,
|
||||
);
|
||||
|
||||
const stats = await fetchStats(
|
||||
username,
|
||||
parseBoolean(include_all_commits),
|
||||
parseArray(exclude_repo),
|
||||
showStats.includes("prs_merged") ||
|
||||
showStats.includes("prs_merged_percentage"),
|
||||
showStats.includes("discussions_started"),
|
||||
showStats.includes("discussions_answered"),
|
||||
parseInt(commits_year, 10),
|
||||
repository,
|
||||
repoOwner,
|
||||
showStats.includes("prs_authored"),
|
||||
showStats.includes("prs_commented"),
|
||||
showStats.includes("prs_reviewed"),
|
||||
showStats.includes("issues_authored"),
|
||||
showStats.includes("issues_commented"),
|
||||
parseArray(role),
|
||||
);
|
||||
const cacheSeconds = resolveCacheSeconds({
|
||||
requested: parseInt(cache_seconds, 10),
|
||||
def: CACHE_TTL.STATS_CARD.DEFAULT,
|
||||
min: CACHE_TTL.STATS_CARD.MIN,
|
||||
max: CACHE_TTL.STATS_CARD.MAX,
|
||||
});
|
||||
|
||||
setCacheHeaders(res, cacheSeconds);
|
||||
|
||||
return res.send(
|
||||
renderStatsCard(stats, {
|
||||
hide: parseArray(hide),
|
||||
show_icons: parseBoolean(show_icons),
|
||||
hide_title: parseBoolean(hide_title),
|
||||
hide_border: parseBoolean(hide_border),
|
||||
card_width: parseInt(card_width, 10),
|
||||
hide_rank: parseBoolean(hide_rank),
|
||||
include_all_commits: parseBoolean(include_all_commits),
|
||||
commits_year: parseInt(commits_year, 10),
|
||||
line_height,
|
||||
title_color,
|
||||
ring_color,
|
||||
icon_color,
|
||||
text_color,
|
||||
text_bold: parseBoolean(text_bold),
|
||||
bg_color,
|
||||
theme,
|
||||
custom_title,
|
||||
border_radius,
|
||||
border_color,
|
||||
number_format,
|
||||
number_precision: parseInt(number_precision, 10),
|
||||
locale: locale ? locale.toLowerCase() : null,
|
||||
disable_animations: parseBoolean(disable_animations),
|
||||
rank_icon,
|
||||
show: showStats,
|
||||
},
|
||||
username,
|
||||
repository,
|
||||
repoOwner,
|
||||
),
|
||||
);
|
||||
} catch (err) {
|
||||
setErrorCacheHeaders(res);
|
||||
if (err instanceof Error) {
|
||||
return res.send(
|
||||
renderError({
|
||||
message: err.message,
|
||||
secondaryMessage: retrieveSecondaryMessage(err),
|
||||
renderOptions: {
|
||||
title_color,
|
||||
text_color,
|
||||
bg_color,
|
||||
border_color,
|
||||
theme,
|
||||
show_repo_link: !(err instanceof MissingParamError),
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
return res.send(
|
||||
renderError({
|
||||
message: "An unknown error occurred",
|
||||
renderOptions: {
|
||||
title_color,
|
||||
text_color,
|
||||
bg_color,
|
||||
border_color,
|
||||
theme,
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,176 @@
|
||||
// @ts-check
|
||||
|
||||
import { renderRepoCard } from "../src/cards/repo.js";
|
||||
import { guardAccess } from "../src/common/access.js";
|
||||
import {
|
||||
CACHE_TTL,
|
||||
resolveCacheSeconds,
|
||||
setCacheHeaders,
|
||||
setErrorCacheHeaders,
|
||||
} from "../src/common/cache.js";
|
||||
import {
|
||||
MissingParamError,
|
||||
retrieveSecondaryMessage,
|
||||
} from "../src/common/error.js";
|
||||
import { parseBoolean } from "../src/common/ops.js";
|
||||
import { parseArray } from "../src/common/ops.js";
|
||||
import { renderError } from "../src/common/render.js";
|
||||
import { fetchRepo } from "../src/fetchers/repo.js";
|
||||
import { isLocaleAvailable } from "../src/translations.js";
|
||||
import { storeRequest } from "../src/common/database.js";
|
||||
|
||||
// @ts-ignore
|
||||
export default async (req, res) => {
|
||||
const {
|
||||
username,
|
||||
repo,
|
||||
hide_border,
|
||||
title_color,
|
||||
icon_color,
|
||||
text_color,
|
||||
bg_color,
|
||||
card_width,
|
||||
theme,
|
||||
show_owner,
|
||||
show,
|
||||
show_icons,
|
||||
number_format,
|
||||
text_bold,
|
||||
line_height,
|
||||
cache_seconds,
|
||||
locale,
|
||||
border_radius,
|
||||
border_color,
|
||||
description_lines_count,
|
||||
} = req.query;
|
||||
|
||||
res.setHeader("Content-Type", "image/svg+xml");
|
||||
|
||||
const access = guardAccess({
|
||||
res,
|
||||
id: username,
|
||||
type: "username",
|
||||
colors: {
|
||||
title_color,
|
||||
text_color,
|
||||
bg_color,
|
||||
border_color,
|
||||
theme,
|
||||
},
|
||||
});
|
||||
if (!access.isPassed) {
|
||||
return access.result;
|
||||
}
|
||||
|
||||
if (locale && !isLocaleAvailable(locale)) {
|
||||
return res.send(
|
||||
renderError({
|
||||
message: "Something went wrong",
|
||||
secondaryMessage: "Language not found",
|
||||
renderOptions: {
|
||||
title_color,
|
||||
text_color,
|
||||
bg_color,
|
||||
border_color,
|
||||
theme,
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
const safePattern = /^[-\w\/.,]+$/;
|
||||
if (
|
||||
(username && !safePattern.test(username)) ||
|
||||
(repo && !safePattern.test(repo))
|
||||
) {
|
||||
return res.send(
|
||||
renderError({
|
||||
message: "Something went wrong",
|
||||
secondaryMessage: "Username or repository contains unsafe characters",
|
||||
renderOptions: {
|
||||
title_color,
|
||||
text_color,
|
||||
bg_color,
|
||||
border_color,
|
||||
theme,
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
await storeRequest(req);
|
||||
const showStats = parseArray(show);
|
||||
const repoData = await fetchRepo(
|
||||
username,
|
||||
repo,
|
||||
showStats.includes("prs_authored"),
|
||||
showStats.includes("prs_commented"),
|
||||
showStats.includes("prs_reviewed"),
|
||||
showStats.includes("issues_authored"),
|
||||
showStats.includes("issues_commented"),
|
||||
);
|
||||
|
||||
const cacheSeconds = resolveCacheSeconds({
|
||||
requested: parseInt(cache_seconds, 10),
|
||||
def: CACHE_TTL.PIN_CARD.DEFAULT,
|
||||
min: CACHE_TTL.PIN_CARD.MIN,
|
||||
max: CACHE_TTL.PIN_CARD.MAX,
|
||||
});
|
||||
|
||||
setCacheHeaders(res, cacheSeconds);
|
||||
|
||||
return res.send(
|
||||
renderRepoCard(repoData, {
|
||||
hide_border: parseBoolean(hide_border),
|
||||
title_color,
|
||||
icon_color,
|
||||
text_color,
|
||||
bg_color,
|
||||
theme,
|
||||
border_radius,
|
||||
border_color,
|
||||
card_width_input: parseInt(card_width, 10),
|
||||
show_owner: parseBoolean(show_owner),
|
||||
show: showStats,
|
||||
show_icons: parseBoolean(show_icons),
|
||||
number_format,
|
||||
text_bold: parseBoolean(text_bold),
|
||||
line_height,
|
||||
username,
|
||||
locale: locale ? locale.toLowerCase() : null,
|
||||
description_lines_count,
|
||||
}),
|
||||
);
|
||||
} catch (err) {
|
||||
setErrorCacheHeaders(res);
|
||||
if (err instanceof Error) {
|
||||
return res.send(
|
||||
renderError({
|
||||
message: err.message,
|
||||
secondaryMessage: retrieveSecondaryMessage(err),
|
||||
renderOptions: {
|
||||
title_color,
|
||||
text_color,
|
||||
bg_color,
|
||||
border_color,
|
||||
theme,
|
||||
show_repo_link: !(err instanceof MissingParamError),
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
return res.send(
|
||||
renderError({
|
||||
message: "An unknown error occurred",
|
||||
renderOptions: {
|
||||
title_color,
|
||||
text_color,
|
||||
bg_color,
|
||||
border_color,
|
||||
theme,
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,18 @@
|
||||
import { repeatRecentRequests } from "../src/repeatRequests.js";
|
||||
|
||||
export default async (req, res) => {
|
||||
if (req.method !== "POST") {
|
||||
res.statusCode = 405;
|
||||
res.send({ error: "Method Not Allowed" });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await repeatRecentRequests();
|
||||
res.statusCode = 200;
|
||||
res.send({ message: "Recent requests repeated successfully." });
|
||||
} catch (error) {
|
||||
console.error("Error repeating recent requests:", error);
|
||||
res.statusCode = 500;
|
||||
res.send({ error: error.message || "Internal Server Error" });
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,159 @@
|
||||
// @ts-check
|
||||
|
||||
/**
|
||||
* @file Contains a simple cloud function that can be used to check which PATs are no
|
||||
* longer working. It returns a list of valid PATs, expired PATs and PATs with errors.
|
||||
*
|
||||
* @description This function is currently rate limited to 1 request per 3 minutes.
|
||||
*/
|
||||
|
||||
import { request } from "../../src/common/http.js";
|
||||
import { logger } from "../../src/common/log.js";
|
||||
import { dateDiff } from "../../src/common/ops.js";
|
||||
|
||||
export const RATE_LIMIT_SECONDS = 60 * 3; // 1 request per 3 minutes
|
||||
|
||||
/**
|
||||
* Simple uptime check fetcher for the PATs.
|
||||
*
|
||||
* @param {any} variables Fetcher variables.
|
||||
* @param {string} token GitHub token.
|
||||
* @returns {Promise<import('axios').AxiosResponse>} The response.
|
||||
*/
|
||||
const uptimeFetcher = (variables, token) => {
|
||||
return request(
|
||||
{
|
||||
query: `
|
||||
query {
|
||||
rateLimit {
|
||||
remaining
|
||||
resetAt
|
||||
},
|
||||
}`,
|
||||
variables,
|
||||
},
|
||||
{
|
||||
Authorization: `bearer ${token}`,
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
const getAllPATs = () => {
|
||||
return Object.keys(process.env).filter((key) => /PAT_\d*$/.exec(key));
|
||||
};
|
||||
|
||||
/**
|
||||
* @typedef {(variables: any, token: string) => Promise<import('axios').AxiosResponse>} Fetcher The fetcher function.
|
||||
* @typedef {{validPATs: string[], expiredPATs: string[], exhaustedPATs: string[], suspendedPATs: string[], errorPATs: string[], details: any}} PATInfo The PAT info.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Check whether any of the PATs is expired.
|
||||
*
|
||||
* @param {Fetcher} fetcher The fetcher function.
|
||||
* @param {any} variables Fetcher variables.
|
||||
* @returns {Promise<PATInfo>} The response.
|
||||
*/
|
||||
const getPATInfo = async (fetcher, variables) => {
|
||||
/** @type {Record<string, any>} */
|
||||
const details = {};
|
||||
const PATs = getAllPATs();
|
||||
|
||||
for (const pat of PATs) {
|
||||
try {
|
||||
const response = await fetcher(variables, process.env[pat]);
|
||||
const errors = response.data.errors;
|
||||
const hasErrors = Boolean(errors);
|
||||
const errorType = errors?.[0]?.type;
|
||||
const isRateLimited =
|
||||
(hasErrors && errorType === "RATE_LIMITED") ||
|
||||
response.data.data?.rateLimit?.remaining === 0;
|
||||
|
||||
// Store PATs with errors.
|
||||
if (hasErrors && errorType !== "RATE_LIMITED") {
|
||||
details[pat] = {
|
||||
status: "error",
|
||||
error: {
|
||||
type: errors[0].type,
|
||||
message: errors[0].message,
|
||||
},
|
||||
};
|
||||
continue;
|
||||
} else if (isRateLimited) {
|
||||
const date1 = new Date();
|
||||
const date2 = new Date(response.data?.data?.rateLimit?.resetAt);
|
||||
details[pat] = {
|
||||
status: "exhausted",
|
||||
remaining: 0,
|
||||
resetIn: dateDiff(date2, date1) + " minutes",
|
||||
};
|
||||
} else {
|
||||
details[pat] = {
|
||||
status: "valid",
|
||||
remaining: response.data.data.rateLimit.remaining,
|
||||
};
|
||||
}
|
||||
} catch (err) {
|
||||
// Store the PAT if it is expired.
|
||||
const errorMessage = err.response?.data?.message?.toLowerCase();
|
||||
if (errorMessage === "bad credentials") {
|
||||
details[pat] = {
|
||||
status: "expired",
|
||||
};
|
||||
} else if (errorMessage === "sorry. your account was suspended.") {
|
||||
details[pat] = {
|
||||
status: "suspended",
|
||||
};
|
||||
} else {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const filterPATsByStatus = (status) => {
|
||||
return Object.keys(details).filter((pat) => details[pat].status === status);
|
||||
};
|
||||
|
||||
const sortedDetails = Object.keys(details)
|
||||
.sort()
|
||||
.reduce((obj, key) => {
|
||||
obj[key] = details[key];
|
||||
return obj;
|
||||
}, {});
|
||||
|
||||
return {
|
||||
validPATs: filterPATsByStatus("valid"),
|
||||
expiredPATs: filterPATsByStatus("expired"),
|
||||
exhaustedPATs: filterPATsByStatus("exhausted"),
|
||||
suspendedPATs: filterPATsByStatus("suspended"),
|
||||
errorPATs: filterPATsByStatus("error"),
|
||||
details: sortedDetails,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Cloud function that returns information about the used PATs.
|
||||
*
|
||||
* @param {any} _ The request.
|
||||
* @param {any} res The response.
|
||||
* @returns {Promise<void>} The response.
|
||||
*/
|
||||
export default async (_, res) => {
|
||||
res.setHeader("Content-Type", "application/json");
|
||||
try {
|
||||
// Add header to prevent abuse.
|
||||
const PATsInfo = await getPATInfo(uptimeFetcher, {});
|
||||
if (PATsInfo) {
|
||||
res.setHeader(
|
||||
"Cache-Control",
|
||||
`max-age=0, s-maxage=${RATE_LIMIT_SECONDS}`,
|
||||
);
|
||||
}
|
||||
res.send(JSON.stringify(PATsInfo, null, 2));
|
||||
} catch (err) {
|
||||
// Throw error if something went wrong.
|
||||
logger.error(err);
|
||||
res.setHeader("Cache-Control", "no-store");
|
||||
res.send("Something went wrong: " + err.message);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,124 @@
|
||||
// @ts-check
|
||||
|
||||
/**
|
||||
* @file Contains a simple cloud function that can be used to check if the PATs are still
|
||||
* functional.
|
||||
*
|
||||
* @description This function is currently rate limited to 1 request per 3 minutes.
|
||||
*/
|
||||
|
||||
import { request } from "../../src/common/http.js";
|
||||
import retryer from "../../src/common/retryer.js";
|
||||
import { logger } from "../../src/common/log.js";
|
||||
|
||||
export const RATE_LIMIT_SECONDS = 60 * 3; // 1 request per 3 minutes
|
||||
|
||||
/**
|
||||
* Simple uptime check fetcher for the PATs.
|
||||
*
|
||||
* @param {any} variables Fetcher variables.
|
||||
* @param {string} token GitHub token.
|
||||
* @returns {Promise<import('axios').AxiosResponse>} The response.
|
||||
*/
|
||||
const uptimeFetcher = (variables, token) => {
|
||||
return request(
|
||||
{
|
||||
query: `
|
||||
query {
|
||||
rateLimit {
|
||||
remaining
|
||||
}
|
||||
}
|
||||
`,
|
||||
variables,
|
||||
},
|
||||
{
|
||||
Authorization: `bearer ${token}`,
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* @typedef {{
|
||||
* schemaVersion: number;
|
||||
* label: string;
|
||||
* message: "up" | "down";
|
||||
* color: "brightgreen" | "red";
|
||||
* isError: boolean
|
||||
* }} ShieldsResponse Shields.io response object.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Creates Json response that can be used for shields.io dynamic card generation.
|
||||
*
|
||||
* @param {boolean} up Whether the PATs are up or not.
|
||||
* @returns {ShieldsResponse} Dynamic shields.io JSON response object.
|
||||
*
|
||||
* @see https://shields.io/endpoint.
|
||||
*/
|
||||
const shieldsUptimeBadge = (up) => {
|
||||
const schemaVersion = 1;
|
||||
const isError = true;
|
||||
const label = "Public Instance";
|
||||
const message = up ? "up" : "down";
|
||||
const color = up ? "brightgreen" : "red";
|
||||
return {
|
||||
schemaVersion,
|
||||
label,
|
||||
message,
|
||||
color,
|
||||
isError,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Cloud function that returns whether the PATs are still functional.
|
||||
*
|
||||
* @param {any} req The request.
|
||||
* @param {any} res The response.
|
||||
* @returns {Promise<void>} Nothing.
|
||||
*/
|
||||
export default async (req, res) => {
|
||||
let { type } = req.query;
|
||||
type = type ? type.toLowerCase() : "boolean";
|
||||
|
||||
res.setHeader("Content-Type", "application/json");
|
||||
|
||||
try {
|
||||
let PATsValid = true;
|
||||
try {
|
||||
await retryer(uptimeFetcher, null, {});
|
||||
} catch (err) {
|
||||
// Resolve eslint no-unused-vars
|
||||
err;
|
||||
|
||||
PATsValid = false;
|
||||
}
|
||||
|
||||
if (PATsValid) {
|
||||
res.setHeader(
|
||||
"Cache-Control",
|
||||
`max-age=0, s-maxage=${RATE_LIMIT_SECONDS}`,
|
||||
);
|
||||
} else {
|
||||
res.setHeader("Cache-Control", "no-store");
|
||||
}
|
||||
|
||||
switch (type) {
|
||||
case "shields":
|
||||
res.send(shieldsUptimeBadge(PATsValid));
|
||||
break;
|
||||
case "json":
|
||||
res.send({ up: PATsValid });
|
||||
break;
|
||||
default:
|
||||
res.send(PATsValid);
|
||||
break;
|
||||
}
|
||||
} catch (err) {
|
||||
// Return fail boolean if something went wrong.
|
||||
logger.error(err);
|
||||
res.setHeader("Cache-Control", "no-store");
|
||||
res.send("Something went wrong: " + err.message);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,192 @@
|
||||
// @ts-check
|
||||
|
||||
import { renderTopLanguages } from "../src/cards/top-languages.js";
|
||||
import { guardAccess } from "../src/common/access.js";
|
||||
import {
|
||||
CACHE_TTL,
|
||||
resolveCacheSeconds,
|
||||
setCacheHeaders,
|
||||
setErrorCacheHeaders,
|
||||
} from "../src/common/cache.js";
|
||||
import {
|
||||
MissingParamError,
|
||||
retrieveSecondaryMessage,
|
||||
} from "../src/common/error.js";
|
||||
import { parseArray, parseBoolean } from "../src/common/ops.js";
|
||||
import { renderError } from "../src/common/render.js";
|
||||
import { fetchTopLanguages } from "../src/fetchers/top-languages.js";
|
||||
import { isLocaleAvailable } from "../src/translations.js";
|
||||
import { storeRequest } from "../src/common/database.js";
|
||||
|
||||
// @ts-ignore
|
||||
export default async (req, res) => {
|
||||
const {
|
||||
username,
|
||||
hide,
|
||||
hide_title,
|
||||
hide_border,
|
||||
card_width,
|
||||
title_color,
|
||||
text_color,
|
||||
bg_color,
|
||||
theme,
|
||||
cache_seconds,
|
||||
layout,
|
||||
langs_count,
|
||||
exclude_repo,
|
||||
size_weight,
|
||||
count_weight,
|
||||
custom_title,
|
||||
locale,
|
||||
border_radius,
|
||||
border_color,
|
||||
role,
|
||||
disable_animations,
|
||||
hide_progress,
|
||||
stats_format,
|
||||
} = req.query;
|
||||
res.setHeader("Content-Type", "image/svg+xml");
|
||||
|
||||
const access = guardAccess({
|
||||
res,
|
||||
id: username,
|
||||
type: "username",
|
||||
colors: {
|
||||
title_color,
|
||||
text_color,
|
||||
bg_color,
|
||||
border_color,
|
||||
theme,
|
||||
},
|
||||
});
|
||||
if (!access.isPassed) {
|
||||
return access.result;
|
||||
}
|
||||
|
||||
if (locale && !isLocaleAvailable(locale)) {
|
||||
return res.send(
|
||||
renderError({
|
||||
message: "Something went wrong",
|
||||
secondaryMessage: "Locale not found",
|
||||
renderOptions: {
|
||||
title_color,
|
||||
text_color,
|
||||
bg_color,
|
||||
border_color,
|
||||
theme,
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
layout !== undefined &&
|
||||
(typeof layout !== "string" ||
|
||||
!["compact", "normal", "donut", "donut-vertical", "pie"].includes(layout))
|
||||
) {
|
||||
return res.send(
|
||||
renderError({
|
||||
message: "Something went wrong",
|
||||
secondaryMessage: "Incorrect layout input",
|
||||
renderOptions: {
|
||||
title_color,
|
||||
text_color,
|
||||
bg_color,
|
||||
border_color,
|
||||
theme,
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
stats_format !== undefined &&
|
||||
(typeof stats_format !== "string" ||
|
||||
!["bytes", "percentages"].includes(stats_format))
|
||||
) {
|
||||
return res.send(
|
||||
renderError({
|
||||
message: "Something went wrong",
|
||||
secondaryMessage: "Incorrect stats_format input",
|
||||
renderOptions: {
|
||||
title_color,
|
||||
text_color,
|
||||
bg_color,
|
||||
border_color,
|
||||
theme,
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
await storeRequest(req);
|
||||
const topLangs = await fetchTopLanguages(
|
||||
username,
|
||||
parseArray(exclude_repo),
|
||||
size_weight,
|
||||
count_weight,
|
||||
parseArray(role),
|
||||
);
|
||||
const cacheSeconds = resolveCacheSeconds({
|
||||
requested: parseInt(cache_seconds, 10),
|
||||
def: CACHE_TTL.TOP_LANGS_CARD.DEFAULT,
|
||||
min: CACHE_TTL.TOP_LANGS_CARD.MIN,
|
||||
max: CACHE_TTL.TOP_LANGS_CARD.MAX,
|
||||
});
|
||||
|
||||
setCacheHeaders(res, cacheSeconds);
|
||||
|
||||
return res.send(
|
||||
renderTopLanguages(topLangs, {
|
||||
custom_title,
|
||||
hide_title: parseBoolean(hide_title),
|
||||
hide_border: parseBoolean(hide_border),
|
||||
card_width: parseInt(card_width, 10),
|
||||
hide: parseArray(hide),
|
||||
title_color,
|
||||
text_color,
|
||||
bg_color,
|
||||
theme,
|
||||
layout,
|
||||
langs_count,
|
||||
border_radius,
|
||||
border_color,
|
||||
locale: locale ? locale.toLowerCase() : null,
|
||||
disable_animations: parseBoolean(disable_animations),
|
||||
hide_progress: parseBoolean(hide_progress),
|
||||
stats_format,
|
||||
}),
|
||||
);
|
||||
} catch (err) {
|
||||
setErrorCacheHeaders(res);
|
||||
if (err instanceof Error) {
|
||||
return res.send(
|
||||
renderError({
|
||||
message: err.message,
|
||||
secondaryMessage: retrieveSecondaryMessage(err),
|
||||
renderOptions: {
|
||||
title_color,
|
||||
text_color,
|
||||
bg_color,
|
||||
border_color,
|
||||
theme,
|
||||
show_repo_link: !(err instanceof MissingParamError),
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
return res.send(
|
||||
renderError({
|
||||
message: "An unknown error occurred",
|
||||
renderOptions: {
|
||||
title_color,
|
||||
text_color,
|
||||
bg_color,
|
||||
border_color,
|
||||
theme,
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,27 @@
|
||||
import { logger } from "../src/common/log.js";
|
||||
import { getUserAccessByKey } from "../src/common/database.js";
|
||||
|
||||
/**
|
||||
* @param {any} req The request.
|
||||
* @param {any} res The response.
|
||||
*/
|
||||
export default async (req, res) => {
|
||||
const { user_key } = req.query;
|
||||
try {
|
||||
const result = await getUserAccessByKey(user_key);
|
||||
|
||||
if (!result) {
|
||||
res.statusCode = 404;
|
||||
res.send("user not found");
|
||||
return;
|
||||
}
|
||||
|
||||
res.send({
|
||||
privateAccess: result.privateAccess,
|
||||
token: result.token
|
||||
});
|
||||
} catch (err) {
|
||||
logger.error(err);
|
||||
res.send("Something went wrong: " + err.message);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,18 @@
|
||||
import { fetchWakatimeStats } from "../src/fetchers/wakatime.js";
|
||||
import { logger } from "../src/common/log.js";
|
||||
|
||||
/**
|
||||
* @param {any} req The request.
|
||||
* @param {any} res The response.
|
||||
*/
|
||||
export default async (req, res) => {
|
||||
const { username } = req.query;
|
||||
res.setHeader("Content-Type", "application/json");
|
||||
try {
|
||||
const stats = await fetchWakatimeStats({ username });
|
||||
res.send(stats);
|
||||
} catch (err) {
|
||||
logger.error(err);
|
||||
res.send("Something went wrong: " + err.message);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,148 @@
|
||||
// @ts-check
|
||||
|
||||
import { renderWakatimeCard } from "../src/cards/wakatime.js";
|
||||
import { renderError } from "../src/common/render.js";
|
||||
import { fetchWakatimeStats } from "../src/fetchers/wakatime.js";
|
||||
import { isLocaleAvailable } from "../src/translations.js";
|
||||
import { storeRequest } from "../src/common/database.js";
|
||||
import {
|
||||
CACHE_TTL,
|
||||
resolveCacheSeconds,
|
||||
setCacheHeaders,
|
||||
setErrorCacheHeaders,
|
||||
} from "../src/common/cache.js";
|
||||
import { guardAccess } from "../src/common/access.js";
|
||||
import {
|
||||
MissingParamError,
|
||||
retrieveSecondaryMessage,
|
||||
} from "../src/common/error.js";
|
||||
import { parseArray, parseBoolean } from "../src/common/ops.js";
|
||||
|
||||
// @ts-ignore
|
||||
export default async (req, res) => {
|
||||
const {
|
||||
username,
|
||||
title_color,
|
||||
icon_color,
|
||||
hide_border,
|
||||
card_width,
|
||||
line_height,
|
||||
text_color,
|
||||
bg_color,
|
||||
theme,
|
||||
cache_seconds,
|
||||
hide_title,
|
||||
hide_progress,
|
||||
custom_title,
|
||||
locale,
|
||||
layout,
|
||||
langs_count,
|
||||
hide,
|
||||
api_domain,
|
||||
border_radius,
|
||||
border_color,
|
||||
display_format,
|
||||
disable_animations,
|
||||
} = req.query;
|
||||
|
||||
res.setHeader("Content-Type", "image/svg+xml");
|
||||
|
||||
const access = guardAccess({
|
||||
res,
|
||||
id: username,
|
||||
type: "wakatime",
|
||||
colors: {
|
||||
title_color,
|
||||
text_color,
|
||||
bg_color,
|
||||
border_color,
|
||||
theme,
|
||||
},
|
||||
});
|
||||
if (!access.isPassed) {
|
||||
return access.result;
|
||||
}
|
||||
|
||||
if (locale && !isLocaleAvailable(locale)) {
|
||||
return res.send(
|
||||
renderError({
|
||||
message: "Something went wrong",
|
||||
secondaryMessage: "Language not found",
|
||||
renderOptions: {
|
||||
title_color,
|
||||
text_color,
|
||||
bg_color,
|
||||
border_color,
|
||||
theme,
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
await storeRequest(req);
|
||||
const stats = await fetchWakatimeStats({ username, api_domain });
|
||||
const cacheSeconds = resolveCacheSeconds({
|
||||
requested: parseInt(cache_seconds, 10),
|
||||
def: CACHE_TTL.WAKATIME_CARD.DEFAULT,
|
||||
min: CACHE_TTL.WAKATIME_CARD.MIN,
|
||||
max: CACHE_TTL.WAKATIME_CARD.MAX,
|
||||
});
|
||||
|
||||
setCacheHeaders(res, cacheSeconds);
|
||||
|
||||
return res.send(
|
||||
renderWakatimeCard(stats, {
|
||||
custom_title,
|
||||
hide_title: parseBoolean(hide_title),
|
||||
hide_border: parseBoolean(hide_border),
|
||||
card_width: parseInt(card_width, 10),
|
||||
hide: parseArray(hide),
|
||||
line_height,
|
||||
title_color,
|
||||
icon_color,
|
||||
text_color,
|
||||
bg_color,
|
||||
theme,
|
||||
hide_progress,
|
||||
border_radius,
|
||||
border_color,
|
||||
locale: locale ? locale.toLowerCase() : null,
|
||||
layout,
|
||||
langs_count,
|
||||
display_format,
|
||||
disable_animations: parseBoolean(disable_animations),
|
||||
}),
|
||||
);
|
||||
} catch (err) {
|
||||
setErrorCacheHeaders(res);
|
||||
if (err instanceof Error) {
|
||||
return res.send(
|
||||
renderError({
|
||||
message: err.message,
|
||||
secondaryMessage: retrieveSecondaryMessage(err),
|
||||
renderOptions: {
|
||||
title_color,
|
||||
text_color,
|
||||
bg_color,
|
||||
border_color,
|
||||
theme,
|
||||
show_repo_link: !(err instanceof MissingParamError),
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
return res.send(
|
||||
renderError({
|
||||
message: "An unknown error occurred",
|
||||
renderOptions: {
|
||||
title_color,
|
||||
text_color,
|
||||
bg_color,
|
||||
border_color,
|
||||
theme,
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,13 @@
|
||||
codecov:
|
||||
require_ci_to_pass: yes
|
||||
|
||||
coverage:
|
||||
precision: 2
|
||||
round: down
|
||||
range: "70...100"
|
||||
|
||||
status:
|
||||
project:
|
||||
default:
|
||||
threshold: 5
|
||||
patch: false
|
||||
@@ -0,0 +1,83 @@
|
||||
import globals from "globals";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import js from "@eslint/js";
|
||||
import { FlatCompat } from "@eslint/eslintrc";
|
||||
import jsdoc from "eslint-plugin-jsdoc";
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
const compat = new FlatCompat({
|
||||
baseDirectory: __dirname,
|
||||
recommendedConfig: js.configs.recommended,
|
||||
allConfig: js.configs.all,
|
||||
});
|
||||
|
||||
export default [
|
||||
...compat.extends("prettier"),
|
||||
{
|
||||
languageOptions: {
|
||||
globals: {
|
||||
...globals.node,
|
||||
...globals.browser,
|
||||
},
|
||||
|
||||
ecmaVersion: 2022,
|
||||
sourceType: "module",
|
||||
},
|
||||
plugins: {
|
||||
jsdoc,
|
||||
},
|
||||
rules: {
|
||||
"no-unexpected-multiline": "error",
|
||||
"accessor-pairs": [
|
||||
"error",
|
||||
{
|
||||
getWithoutSet: false,
|
||||
setWithoutGet: true,
|
||||
},
|
||||
],
|
||||
"block-scoped-var": "warn",
|
||||
"consistent-return": "error",
|
||||
curly: "error",
|
||||
"no-alert": "error",
|
||||
"no-caller": "error",
|
||||
"no-warning-comments": [
|
||||
"warn",
|
||||
{
|
||||
terms: ["TODO", "FIXME"],
|
||||
location: "start",
|
||||
},
|
||||
],
|
||||
"no-with": "warn",
|
||||
radix: "warn",
|
||||
"no-delete-var": "error",
|
||||
"no-undef-init": "off",
|
||||
"no-undef": "error",
|
||||
"no-undefined": "off",
|
||||
"no-unused-vars": "warn",
|
||||
"no-use-before-define": "error",
|
||||
"constructor-super": "error",
|
||||
"no-class-assign": "error",
|
||||
"no-const-assign": "error",
|
||||
"no-dupe-class-members": "error",
|
||||
"no-this-before-super": "error",
|
||||
"object-shorthand": ["warn"],
|
||||
"no-mixed-spaces-and-tabs": "warn",
|
||||
"no-multiple-empty-lines": "warn",
|
||||
"no-negated-condition": "warn",
|
||||
"no-unneeded-ternary": "warn",
|
||||
"keyword-spacing": [
|
||||
"error",
|
||||
{
|
||||
before: true,
|
||||
after: true,
|
||||
},
|
||||
],
|
||||
"jsdoc/require-returns": "warn",
|
||||
"jsdoc/require-returns-description": "warn",
|
||||
"jsdoc/require-param-description": "warn",
|
||||
"jsdoc/require-jsdoc": "warn",
|
||||
},
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,23 @@
|
||||
import "dotenv/config";
|
||||
import statsCard from "./api/index.js";
|
||||
import repoCard from "./api/pin.js";
|
||||
import langCard from "./api/top-langs.js";
|
||||
import wakatimeCard from "./api/wakatime.js";
|
||||
import gistCard from "./api/gist.js";
|
||||
import express from "express";
|
||||
|
||||
const app = express();
|
||||
const router = express.Router();
|
||||
|
||||
router.get("/", statsCard);
|
||||
router.get("/pin", repoCard);
|
||||
router.get("/top-langs", langCard);
|
||||
router.get("/wakatime", wakatimeCard);
|
||||
router.get("/gist", gistCard);
|
||||
|
||||
app.use("/api", router);
|
||||
|
||||
const port = process.env.PORT || process.env.port || 9000;
|
||||
app.listen(port, "0.0.0.0", () => {
|
||||
console.log(`Server running on port ${port}`);
|
||||
});
|
||||
@@ -0,0 +1,13 @@
|
||||
export default {
|
||||
clearMocks: true,
|
||||
transform: {},
|
||||
testEnvironment: "jsdom",
|
||||
coverageProvider: "v8",
|
||||
testPathIgnorePatterns: ["<rootDir>/node_modules/", "<rootDir>/tests/e2e/"],
|
||||
modulePathIgnorePatterns: ["<rootDir>/node_modules/", "<rootDir>/tests/e2e/"],
|
||||
coveragePathIgnorePatterns: [
|
||||
"<rootDir>/node_modules/",
|
||||
"<rootDir>/tests/e2e/",
|
||||
],
|
||||
testRegex: "(\\.bench)\\.(ts|tsx|js)$",
|
||||
};
|
||||
@@ -0,0 +1,13 @@
|
||||
export default {
|
||||
clearMocks: true,
|
||||
transform: {},
|
||||
testEnvironment: "jsdom",
|
||||
coverageProvider: "v8",
|
||||
setupFiles: ["<rootDir>/tests/setup.jest.js"],
|
||||
testPathIgnorePatterns: ["<rootDir>/node_modules/", "<rootDir>/tests/e2e/"],
|
||||
modulePathIgnorePatterns: ["<rootDir>/node_modules/", "<rootDir>/tests/e2e/"],
|
||||
coveragePathIgnorePatterns: [
|
||||
"<rootDir>/node_modules/",
|
||||
"<rootDir>/tests/E2E/",
|
||||
],
|
||||
};
|
||||
@@ -0,0 +1,7 @@
|
||||
export default {
|
||||
clearMocks: true,
|
||||
transform: {},
|
||||
testEnvironment: "node",
|
||||
coverageProvider: "v8",
|
||||
testMatch: ["<rootDir>/tests/e2e/**/*.test.js"],
|
||||
};
|
||||
@@ -0,0 +1,75 @@
|
||||
{
|
||||
"name": "github-readme-stats",
|
||||
"version": "1.0.0",
|
||||
"description": "Dynamically generate stats for your GitHub readme",
|
||||
"keywords": [
|
||||
"github-readme-stats",
|
||||
"readme-stats",
|
||||
"cards",
|
||||
"card-generator"
|
||||
],
|
||||
"main": "src/index.js",
|
||||
"type": "module",
|
||||
"homepage": "https://github-stats-extended.vercel.app/frontend",
|
||||
"bugs": {
|
||||
"url": "https://github.com/stats-organization/github-stats-extended/issues"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/stats-organization/github-stats-extended.git"
|
||||
},
|
||||
"scripts": {
|
||||
"test": "node --experimental-vm-modules node_modules/jest/bin/jest.js --coverage",
|
||||
"test:watch": "node --experimental-vm-modules node_modules/jest/bin/jest.js --watch",
|
||||
"test:update:snapshot": "node --experimental-vm-modules node_modules/jest/bin/jest.js -u",
|
||||
"test:e2e": "node --experimental-vm-modules node_modules/jest/bin/jest.js --config jest.e2e.config.js",
|
||||
"theme-readme-gen": "node scripts/generate-theme-doc",
|
||||
"preview-theme": "node scripts/preview-theme",
|
||||
"close-stale-theme-prs": "node scripts/close-stale-theme-prs",
|
||||
"generate-langs-json": "node scripts/generate-langs-json",
|
||||
"format": "prettier --write .",
|
||||
"format:check": "prettier --check .",
|
||||
"lint": "npx eslint --max-warnings 0 \"./src/**/*.js\" \"./scripts/**/*.js\" \"./tests/**/*.js\" \"./api-renamed/**/*.js\" \"./themes/**/*.js\"",
|
||||
"bench": "node --experimental-vm-modules node_modules/jest/bin/jest.js --config jest.bench.config.js"
|
||||
},
|
||||
"author": "Anurag Hazra",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"@actions/core": "^2.0.1",
|
||||
"@actions/github": "^6.0.1",
|
||||
"@eslint/eslintrc": "^3.3.3",
|
||||
"@eslint/js": "^9.39.2",
|
||||
"@testing-library/dom": "^10.4.1",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@uppercod/css-to-object": "^1.1.1",
|
||||
"axios-mock-adapter": "^2.1.0",
|
||||
"color-contrast-checker": "^2.1.0",
|
||||
"eslint": "^9.39.2",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"eslint-plugin-jsdoc": "^61.5.0",
|
||||
"express": "^5.2.1",
|
||||
"globals": "^16.5.0",
|
||||
"hjson": "^3.2.2",
|
||||
"jest": "^30.2.0",
|
||||
"jest-environment-jsdom": "^30.2.0",
|
||||
"js-yaml": "^4.1.1",
|
||||
"lint-staged": "^16.2.7",
|
||||
"lodash.snakecase": "^4.1.1",
|
||||
"parse-diff": "^0.11.1",
|
||||
"prettier": "^3.7.3"
|
||||
},
|
||||
"dependencies": {
|
||||
"axios": "^1.13.1",
|
||||
"dotenv": "^17.2.3",
|
||||
"emoji-name-map": "^2.0.3",
|
||||
"github-username-regex": "^1.0.0",
|
||||
"pg": "^8.16.2",
|
||||
"word-wrap": "^1.2.5"
|
||||
},
|
||||
"lint-staged": {
|
||||
"*.{js,css,md}": "prettier --write"
|
||||
},
|
||||
"engines": {
|
||||
"node": "24.x"
|
||||
}
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 5.2 KiB |
@@ -0,0 +1,180 @@
|
||||
/**
|
||||
* @file Script that can be used to close stale theme PRs that have a `invalid` label.
|
||||
*/
|
||||
import * as dotenv from "dotenv";
|
||||
dotenv.config();
|
||||
|
||||
import { debug, setFailed } from "@actions/core";
|
||||
import github from "@actions/github";
|
||||
import { RequestError } from "@octokit/request-error";
|
||||
import { getGithubToken, getRepoInfo } from "./helpers.js";
|
||||
|
||||
const CLOSING_COMMENT = `
|
||||
\rThis theme PR has been automatically closed due to inactivity. Please reopen it if you want to continue working on it.\
|
||||
\rThank you for your contributions.
|
||||
`;
|
||||
const REVIEWER = "github-actions[bot]";
|
||||
|
||||
/**
|
||||
* Retrieve the review user.
|
||||
* @returns {string} review user.
|
||||
*/
|
||||
const getReviewer = () => {
|
||||
return process.env.REVIEWER ? process.env.REVIEWER : REVIEWER;
|
||||
};
|
||||
|
||||
/**
|
||||
* Fetch open PRs from a given repository.
|
||||
*
|
||||
* @param {module:@actions/github.Octokit} octokit The octokit client.
|
||||
* @param {string} user The user name of the repository owner.
|
||||
* @param {string} repo The name of the repository.
|
||||
* @param {string} reviewer The reviewer to filter by.
|
||||
* @returns {Promise<Object[]>} The open PRs.
|
||||
*/
|
||||
export const fetchOpenPRs = async (octokit, user, repo, reviewer) => {
|
||||
const openPRs = [];
|
||||
let hasNextPage = true;
|
||||
let endCursor;
|
||||
while (hasNextPage) {
|
||||
try {
|
||||
const { repository } = await octokit.graphql(
|
||||
`
|
||||
{
|
||||
repository(owner: "${user}", name: "${repo}") {
|
||||
open_prs: pullRequests(${
|
||||
endCursor ? `after: "${endCursor}", ` : ""
|
||||
}
|
||||
first: 100, states: OPEN, orderBy: {field: CREATED_AT, direction: DESC}) {
|
||||
nodes {
|
||||
number
|
||||
commits(last:1){
|
||||
nodes{
|
||||
commit{
|
||||
pushedDate
|
||||
}
|
||||
}
|
||||
}
|
||||
labels(first: 100, orderBy:{field: CREATED_AT, direction: DESC}) {
|
||||
nodes {
|
||||
name
|
||||
}
|
||||
}
|
||||
reviews(first: 100, states: CHANGES_REQUESTED, author: "${reviewer}") {
|
||||
nodes {
|
||||
submittedAt
|
||||
}
|
||||
}
|
||||
}
|
||||
pageInfo {
|
||||
endCursor
|
||||
hasNextPage
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
);
|
||||
openPRs.push(...repository.open_prs.nodes);
|
||||
hasNextPage = repository.open_prs.pageInfo.hasNextPage;
|
||||
endCursor = repository.open_prs.pageInfo.endCursor;
|
||||
} catch (error) {
|
||||
if (error instanceof RequestError) {
|
||||
setFailed(`Could not retrieve top PRs using GraphQl: ${error.message}`);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
return openPRs;
|
||||
};
|
||||
|
||||
/**
|
||||
* Retrieve pull requests that have a given label.
|
||||
*
|
||||
* @param {Object[]} pulls The pull requests to check.
|
||||
* @param {string} label The label to check for.
|
||||
* @returns {Object[]} The pull requests that have the given label.
|
||||
*/
|
||||
export const pullsWithLabel = (pulls, label) => {
|
||||
return pulls.filter((pr) => {
|
||||
return pr.labels.nodes.some((lab) => lab.name === label);
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Check if PR is stale. Meaning that it hasn't been updated in a given time.
|
||||
*
|
||||
* @param {Object} pullRequest request object.
|
||||
* @param {number} staleDays number of days.
|
||||
* @returns {boolean} indicating if PR is stale.
|
||||
*/
|
||||
const isStale = (pullRequest, staleDays) => {
|
||||
const lastCommitDate = new Date(
|
||||
pullRequest.commits.nodes[0].commit.pushedDate,
|
||||
);
|
||||
if (pullRequest.reviews.nodes[0]) {
|
||||
const lastReviewDate = new Date(
|
||||
pullRequest.reviews.nodes.sort((a, b) => (a < b ? 1 : -1))[0].submittedAt,
|
||||
);
|
||||
const lastUpdateDate =
|
||||
lastCommitDate >= lastReviewDate ? lastCommitDate : lastReviewDate;
|
||||
const now = new Date();
|
||||
return (now - lastUpdateDate) / (1000 * 60 * 60 * 24) >= staleDays;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Main function.
|
||||
*
|
||||
* @returns {Promise<void>} A promise.
|
||||
*/
|
||||
const run = async () => {
|
||||
try {
|
||||
// Create octokit client.
|
||||
const dryRun = process.env.DRY_RUN === "true" || false;
|
||||
const staleDays = process.env.STALE_DAYS || 20;
|
||||
debug("Creating octokit client...");
|
||||
const octokit = github.getOctokit(getGithubToken());
|
||||
const { owner, repo } = getRepoInfo(github.context);
|
||||
const reviewer = getReviewer();
|
||||
|
||||
// Retrieve all theme pull requests.
|
||||
debug("Retrieving all theme pull requests...");
|
||||
const prs = await fetchOpenPRs(octokit, owner, repo, reviewer);
|
||||
const themePRs = pullsWithLabel(prs, "themes");
|
||||
const invalidThemePRs = pullsWithLabel(themePRs, "invalid");
|
||||
debug("Retrieving stale theme PRs...");
|
||||
const staleThemePRs = invalidThemePRs.filter((pr) =>
|
||||
isStale(pr, staleDays),
|
||||
);
|
||||
const staleThemePRsNumbers = staleThemePRs.map((pr) => pr.number);
|
||||
debug(`Found ${staleThemePRs.length} stale theme PRs`);
|
||||
|
||||
// Loop through all stale invalid theme pull requests and close them.
|
||||
for (const prNumber of staleThemePRsNumbers) {
|
||||
debug(`Closing #${prNumber} because it is stale...`);
|
||||
if (dryRun) {
|
||||
debug("Dry run enabled, skipping...");
|
||||
} else {
|
||||
await octokit.rest.issues.createComment({
|
||||
owner,
|
||||
repo,
|
||||
issue_number: prNumber,
|
||||
body: CLOSING_COMMENT,
|
||||
});
|
||||
await octokit.rest.pulls.update({
|
||||
owner,
|
||||
repo,
|
||||
pull_number: prNumber,
|
||||
state: "closed",
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
setFailed(error.message);
|
||||
}
|
||||
};
|
||||
|
||||
run();
|
||||
@@ -0,0 +1,30 @@
|
||||
import axios from "axios";
|
||||
import fs from "fs";
|
||||
import jsYaml from "js-yaml";
|
||||
|
||||
const LANGS_FILEPATH = "./src/common/languageColors.json";
|
||||
|
||||
//Retrieve languages from github linguist repository yaml file
|
||||
//@ts-ignore
|
||||
axios
|
||||
.get(
|
||||
"https://raw.githubusercontent.com/github/linguist/master/lib/linguist/languages.yml",
|
||||
)
|
||||
.then((response) => {
|
||||
//and convert them to a JS Object
|
||||
const languages = jsYaml.load(response.data);
|
||||
|
||||
const languageColors = {};
|
||||
|
||||
//Filter only language colors from the whole file
|
||||
Object.keys(languages).forEach((lang) => {
|
||||
languageColors[lang] = languages[lang].color;
|
||||
});
|
||||
|
||||
//Debug Print
|
||||
//console.dir(languageColors);
|
||||
fs.writeFileSync(
|
||||
LANGS_FILEPATH,
|
||||
JSON.stringify(languageColors, null, " "),
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,105 @@
|
||||
import fs from "fs";
|
||||
import { themes } from "../themes/index.js";
|
||||
|
||||
const TARGET_FILE = "./themes/README.md";
|
||||
const REPO_CARD_LINKS_FLAG = "<!-- REPO_CARD_LINKS -->";
|
||||
const STAT_CARD_LINKS_FLAG = "<!-- STATS_CARD_LINKS -->";
|
||||
|
||||
const STAT_CARD_TABLE_FLAG = "<!-- STATS_CARD_TABLE -->";
|
||||
const REPO_CARD_TABLE_FLAG = "<!-- REPO_CARD_TABLE -->";
|
||||
|
||||
const THEME_TEMPLATE = `## Available Themes
|
||||
|
||||
<!-- DO NOT EDIT THIS FILE DIRECTLY -->
|
||||
|
||||
With inbuilt themes, you can customize the look of the card without doing any manual customization.
|
||||
|
||||
Use \`?theme=THEME_NAME\` parameter like so:
|
||||
|
||||
\`\`\`md
|
||||

|
||||
\`\`\`
|
||||
|
||||
## Stats
|
||||
|
||||
> These themes work with all five of our cards: Stats Card, Repo Card, Gist Card, Top Languages Card, and WakaTime Card.
|
||||
|
||||
| | | |
|
||||
| :--: | :--: | :--: |
|
||||
${STAT_CARD_TABLE_FLAG}
|
||||
|
||||
## Repo Card
|
||||
|
||||
> These themes work with all five of our cards: Stats Card, Repo Card, Gist Card, Top Languages Card, and WakaTime Card.
|
||||
|
||||
| | | |
|
||||
| :--: | :--: | :--: |
|
||||
${REPO_CARD_TABLE_FLAG}
|
||||
|
||||
${STAT_CARD_LINKS_FLAG}
|
||||
|
||||
${REPO_CARD_LINKS_FLAG}
|
||||
`;
|
||||
|
||||
const createRepoMdLink = (theme) => {
|
||||
return `\n[${theme}_repo]: https://github-stats-extended.vercel.app/api/pin/?username=anuraghazra&repo=github-readme-stats&cache_seconds=86400&theme=${theme}`;
|
||||
};
|
||||
const createStatMdLink = (theme) => {
|
||||
return `\n[${theme}]: https://github-stats-extended.vercel.app/api?username=anuraghazra&show_icons=true&hide=contribs,prs&cache_seconds=86400&theme=${theme}`;
|
||||
};
|
||||
|
||||
const generateLinks = (fn) => {
|
||||
return Object.keys(themes)
|
||||
.map((name) => fn(name))
|
||||
.join("");
|
||||
};
|
||||
|
||||
const createTableItem = ({ link, label, isRepoCard }) => {
|
||||
if (!link || !label) {
|
||||
return "";
|
||||
}
|
||||
return `\`${label}\` ![${link}][${link}${isRepoCard ? "_repo" : ""}]`;
|
||||
};
|
||||
|
||||
const generateTable = ({ isRepoCard }) => {
|
||||
const rows = [];
|
||||
const themesFiltered = Object.keys(themes).filter(
|
||||
(name) => name !== (isRepoCard ? "default" : "default_repocard"),
|
||||
);
|
||||
|
||||
for (let i = 0; i < themesFiltered.length; i += 3) {
|
||||
const one = themesFiltered[i];
|
||||
const two = themesFiltered[i + 1];
|
||||
const three = themesFiltered[i + 2];
|
||||
|
||||
let tableItem1 = createTableItem({ link: one, label: one, isRepoCard });
|
||||
let tableItem2 = createTableItem({ link: two, label: two, isRepoCard });
|
||||
let tableItem3 = createTableItem({ link: three, label: three, isRepoCard });
|
||||
|
||||
rows.push(`| ${tableItem1} | ${tableItem2} | ${tableItem3} |`);
|
||||
}
|
||||
|
||||
return rows.join("\n");
|
||||
};
|
||||
|
||||
const buildReadme = () => {
|
||||
return THEME_TEMPLATE.split("\n")
|
||||
.map((line) => {
|
||||
if (line.includes(REPO_CARD_LINKS_FLAG)) {
|
||||
return generateLinks(createRepoMdLink);
|
||||
}
|
||||
if (line.includes(STAT_CARD_LINKS_FLAG)) {
|
||||
return generateLinks(createStatMdLink);
|
||||
}
|
||||
if (line.includes(REPO_CARD_TABLE_FLAG)) {
|
||||
return generateTable({ isRepoCard: true });
|
||||
}
|
||||
if (line.includes(STAT_CARD_TABLE_FLAG)) {
|
||||
return generateTable({ isRepoCard: false });
|
||||
}
|
||||
return line;
|
||||
})
|
||||
.join("\n");
|
||||
};
|
||||
|
||||
fs.writeFileSync(TARGET_FILE, buildReadme());
|
||||
@@ -0,0 +1,44 @@
|
||||
/**
|
||||
* @file Contains helper functions used in the scripts.
|
||||
*/
|
||||
|
||||
import { getInput } from "@actions/core";
|
||||
|
||||
const OWNER = "anuraghazra";
|
||||
const REPO = "github-readme-stats";
|
||||
|
||||
/**
|
||||
* Retrieve information about the repository that ran the action.
|
||||
*
|
||||
* @param {Object} ctx Action context.
|
||||
* @returns {Object} Repository information.
|
||||
*/
|
||||
export const getRepoInfo = (ctx) => {
|
||||
try {
|
||||
return {
|
||||
owner: ctx.repo.owner,
|
||||
repo: ctx.repo.repo,
|
||||
};
|
||||
} catch (error) {
|
||||
// Resolve eslint no-unused-vars
|
||||
error;
|
||||
|
||||
return {
|
||||
owner: OWNER,
|
||||
repo: REPO,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Retrieve github token and throw error if it is not found.
|
||||
*
|
||||
* @returns {string} GitHub token.
|
||||
*/
|
||||
export const getGithubToken = () => {
|
||||
const token = getInput("github_token") || process.env.GITHUB_TOKEN;
|
||||
if (!token) {
|
||||
throw Error("Could not find github token");
|
||||
}
|
||||
return token;
|
||||
};
|
||||
@@ -0,0 +1,687 @@
|
||||
/**
|
||||
* @file This script is used to preview the theme on theme PRs.
|
||||
*/
|
||||
import * as dotenv from "dotenv";
|
||||
dotenv.config();
|
||||
|
||||
import { debug, setFailed } from "@actions/core";
|
||||
import github from "@actions/github";
|
||||
import ColorContrastChecker from "color-contrast-checker";
|
||||
import { info } from "console";
|
||||
import Hjson from "hjson";
|
||||
import snakeCase from "lodash.snakecase";
|
||||
import parse from "parse-diff";
|
||||
import { inspect } from "util";
|
||||
import { isValidHexColor, isValidGradient } from "../src/common/color.js";
|
||||
import { themes } from "../themes/index.js";
|
||||
import { getGithubToken, getRepoInfo } from "./helpers.js";
|
||||
|
||||
const COMMENTER = "github-actions[bot]";
|
||||
|
||||
const COMMENT_TITLE = "Automated Theme Preview";
|
||||
const THEME_PR_FAIL_TEXT = ":x: Theme PR does not adhere to our guidelines.";
|
||||
const THEME_PR_SUCCESS_TEXT =
|
||||
":heavy_check_mark: Theme PR does adhere to our guidelines.";
|
||||
const FAIL_TEXT = `
|
||||
\rUnfortunately, your theme PR contains an error or does not adhere to our [theme guidelines](https://github.com/anuraghazra/github-readme-stats/blob/master/CONTRIBUTING.md#themes-contribution). Please fix the issues below, and we will review your\
|
||||
\r PR again. This pull request will **automatically close in 20 days** if no changes are made. After this time, you must re-open the PR for it to be reviewed.
|
||||
`;
|
||||
const THEME_CONTRIB_GUIDELINES = `
|
||||
\rHi, thanks for the theme contribution. Please read our theme [contribution guidelines](https://github.com/anuraghazra/github-readme-stats/blob/master/CONTRIBUTING.md#themes-contribution).
|
||||
|
||||
\r> [!WARNING]\
|
||||
\r> Keep in mind that we already have a vast collection of different themes. To keep their number manageable, we began to add only themes supported by the community. Your pull request with theme addition will be merged once we get enough positive feedback from the community in the form of thumbs up :+1: emojis (see [#1935](https://github.com/anuraghazra/github-readme-stats/issues/1935#top-themes-prs)). We expect to see at least 10-15 thumbs up before making a decision to merge your pull request into the master branch. Remember that you can also support themes of other contributors that you liked to speed up their merge.
|
||||
|
||||
\r> [!WARNING]\
|
||||
\r> Please do not submit a pull request with a batch of themes, since it will be hard to judge how the community will react to each of them. We will only merge one theme per pull request. If you have several themes, please submit a separate pull request for each of them. Situations when you have several versions of the same theme (e.g. light and dark) are an exception to this rule.
|
||||
|
||||
\r> [!NOTE]\
|
||||
\r> Also, note that if this theme is exclusively for your personal use, then instead of adding it to our theme collection, you can use card [customization options](https://github.com/anuraghazra/github-readme-stats#customization).
|
||||
`;
|
||||
const COLOR_PROPS = {
|
||||
title_color: 6,
|
||||
icon_color: 6,
|
||||
text_color: 6,
|
||||
bg_color: 23,
|
||||
border_color: 6,
|
||||
};
|
||||
const ACCEPTED_COLOR_PROPS = Object.keys(COLOR_PROPS);
|
||||
const REQUIRED_COLOR_PROPS = ACCEPTED_COLOR_PROPS.slice(0, 4);
|
||||
const INVALID_REVIEW_COMMENT = (commentUrl) =>
|
||||
`Some themes are invalid. See the [Automated Theme Preview](${commentUrl}) comment above for more information.`;
|
||||
var OCTOKIT;
|
||||
var OWNER;
|
||||
var REPO;
|
||||
var PULL_REQUEST_ID;
|
||||
|
||||
/**
|
||||
* Incorrect JSON format error.
|
||||
* @extends Error
|
||||
* @param {string} message Error message.
|
||||
* @returns {Error} IncorrectJsonFormatError.
|
||||
*/
|
||||
class IncorrectJsonFormatError extends Error {
|
||||
/**
|
||||
* Constructor.
|
||||
*
|
||||
* @param {string} message Error message.
|
||||
*/
|
||||
constructor(message) {
|
||||
super(message);
|
||||
this.name = "IncorrectJsonFormatError";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve PR number from the event payload.
|
||||
*
|
||||
* @returns {number} PR number.
|
||||
*/
|
||||
const getPrNumber = () => {
|
||||
if (process.env.MOCK_PR_NUMBER) {
|
||||
return process.env.MOCK_PR_NUMBER; // For testing purposes.
|
||||
}
|
||||
|
||||
const pullRequest = github.context.payload.pull_request;
|
||||
if (!pullRequest) {
|
||||
throw Error("Could not get pull request number from context");
|
||||
}
|
||||
return pullRequest.number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Retrieve the commenting user.
|
||||
* @returns {string} Commenting user.
|
||||
*/
|
||||
const getCommenter = () => {
|
||||
return process.env.COMMENTER ? process.env.COMMENTER : COMMENTER;
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns whether the comment is a preview comment.
|
||||
*
|
||||
* @param {Object} inputs Action inputs.
|
||||
* @param {Object} comment Comment object.
|
||||
* @returns {boolean} Whether the comment is a preview comment.
|
||||
*/
|
||||
const isPreviewComment = (inputs, comment) => {
|
||||
return (
|
||||
(inputs.commentAuthor && comment.user
|
||||
? comment.user.login === inputs.commentAuthor
|
||||
: true) &&
|
||||
(inputs.bodyIncludes && comment.body
|
||||
? comment.body.includes(inputs.bodyIncludes)
|
||||
: true)
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Find the preview theme comment.
|
||||
*
|
||||
* @param {Object} octokit Octokit instance.
|
||||
* @param {number} issueNumber Issue number.
|
||||
* @param {string} owner Owner of the repository.
|
||||
* @param {string} repo Repository name.
|
||||
* @param {string} commenter Comment author.
|
||||
* @returns {Object | undefined} The GitHub comment object.
|
||||
*/
|
||||
const findComment = async (octokit, issueNumber, owner, repo, commenter) => {
|
||||
const parameters = {
|
||||
owner,
|
||||
repo,
|
||||
issue_number: issueNumber,
|
||||
};
|
||||
const inputs = {
|
||||
commentAuthor: commenter,
|
||||
bodyIncludes: COMMENT_TITLE,
|
||||
};
|
||||
|
||||
// Search each page for the comment
|
||||
for await (const { data: comments } of octokit.paginate.iterator(
|
||||
octokit.rest.issues.listComments,
|
||||
parameters,
|
||||
)) {
|
||||
const comment = comments.find((comment) =>
|
||||
isPreviewComment(inputs, comment),
|
||||
);
|
||||
if (comment) {
|
||||
debug(`Found theme preview comment: ${inspect(comment)}`);
|
||||
return comment;
|
||||
} else {
|
||||
debug(`No theme preview comment found.`);
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
};
|
||||
|
||||
/**
|
||||
* Create or update the preview comment.
|
||||
*
|
||||
* @param {Object} octokit Octokit instance.
|
||||
* @param {number} issueNumber Issue number.
|
||||
* @param {Object} repo Repository name.
|
||||
* @param {Object} owner Owner of the repository.
|
||||
* @param {number} commentId Comment ID.
|
||||
* @param {string} body Comment body.
|
||||
* @returns {string} The comment URL.
|
||||
*/
|
||||
const upsertComment = async (
|
||||
octokit,
|
||||
issueNumber,
|
||||
repo,
|
||||
owner,
|
||||
commentId,
|
||||
body,
|
||||
) => {
|
||||
let resp;
|
||||
if (commentId === undefined) {
|
||||
resp = await octokit.rest.issues.createComment({
|
||||
owner,
|
||||
repo,
|
||||
issue_number: issueNumber,
|
||||
body,
|
||||
});
|
||||
} else {
|
||||
resp = await octokit.rest.issues.updateComment({
|
||||
owner,
|
||||
repo,
|
||||
comment_id: commentId,
|
||||
body,
|
||||
});
|
||||
}
|
||||
return resp.data.html_url;
|
||||
};
|
||||
|
||||
/**
|
||||
* Adds a review to the pull request.
|
||||
*
|
||||
* @param {Object} octokit Octokit instance.
|
||||
* @param {number} prNumber Pull request number.
|
||||
* @param {string} owner Owner of the repository.
|
||||
* @param {string} repo Repository name.
|
||||
* @param {string} reviewState The review state. Options are (APPROVE, REQUEST_CHANGES, COMMENT, PENDING).
|
||||
* @param {string} reason The reason for the review.
|
||||
* @returns {Promise<void>} Promise.
|
||||
*/
|
||||
const addReview = async (
|
||||
octokit,
|
||||
prNumber,
|
||||
owner,
|
||||
repo,
|
||||
reviewState,
|
||||
reason,
|
||||
) => {
|
||||
await octokit.rest.pulls.createReview({
|
||||
owner,
|
||||
repo,
|
||||
pull_number: prNumber,
|
||||
event: reviewState,
|
||||
body: reason,
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Add label to pull request.
|
||||
*
|
||||
* @param {Object} octokit Octokit instance.
|
||||
* @param {number} prNumber Pull request number.
|
||||
* @param {string} owner Repository owner.
|
||||
* @param {string} repo Repository name.
|
||||
* @param {string[]} labels Labels to add.
|
||||
* @returns {Promise<void>} Promise.
|
||||
*/
|
||||
const addLabel = async (octokit, prNumber, owner, repo, labels) => {
|
||||
await octokit.rest.issues.addLabels({
|
||||
owner,
|
||||
repo,
|
||||
issue_number: prNumber,
|
||||
labels,
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Remove label from the pull request.
|
||||
*
|
||||
* @param {Object} octokit Octokit instance.
|
||||
* @param {number} prNumber Pull request number.
|
||||
* @param {string} owner Repository owner.
|
||||
* @param {string} repo Repository name.
|
||||
* @param {string} label Label to add or remove.
|
||||
* @returns {Promise<void>} Promise.
|
||||
*/
|
||||
const removeLabel = async (octokit, prNumber, owner, repo, label) => {
|
||||
await octokit.rest.issues.removeLabel({
|
||||
owner,
|
||||
repo,
|
||||
issue_number: prNumber,
|
||||
name: label,
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Adds or removes a label from the pull request.
|
||||
*
|
||||
* @param {Object} octokit Octokit instance.
|
||||
* @param {number} prNumber Pull request number.
|
||||
* @param {string} owner Repository owner.
|
||||
* @param {string} repo Repository name.
|
||||
* @param {string} label Label to add or remove.
|
||||
* @param {boolean} add Whether to add or remove the label.
|
||||
* @returns {Promise<void>} Promise.
|
||||
*/
|
||||
const addRemoveLabel = async (octokit, prNumber, owner, repo, label, add) => {
|
||||
const res = await octokit.rest.pulls.get({
|
||||
owner,
|
||||
repo,
|
||||
pull_number: prNumber,
|
||||
});
|
||||
if (add) {
|
||||
if (!res.data.labels.find((l) => l.name === label)) {
|
||||
await addLabel(octokit, prNumber, owner, repo, [label]);
|
||||
}
|
||||
} else {
|
||||
if (res.data.labels.find((l) => l.name === label)) {
|
||||
await removeLabel(octokit, prNumber, owner, repo, label);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Retrieve webAim contrast color check link.
|
||||
*
|
||||
* @param {string} color1 First color.
|
||||
* @param {string} color2 Second color.
|
||||
* @returns {string} WebAim contrast color check link.
|
||||
*/
|
||||
const getWebAimLink = (color1, color2) => {
|
||||
return `https://webaim.org/resources/contrastchecker/?fcolor=${color1}&bcolor=${color2}`;
|
||||
};
|
||||
|
||||
/**
|
||||
* Retrieves the theme GRS url.
|
||||
*
|
||||
* @param {Object} colors The theme colors.
|
||||
* @returns {string} GRS theme url.
|
||||
*/
|
||||
const getGRSLink = (colors) => {
|
||||
const url = `https://github-stats-extended.vercel.app/api?username=anuraghazra`;
|
||||
const colorString = Object.keys(colors)
|
||||
.map((colorKey) => `${colorKey}=${colors[colorKey]}`)
|
||||
.join("&");
|
||||
|
||||
return `${url}&${colorString}&show_icons=true`;
|
||||
};
|
||||
|
||||
/**
|
||||
* Retrieve javascript object from json string.
|
||||
*
|
||||
* @description Wraps the Hjson parse function to fix several known json syntax errors.
|
||||
*
|
||||
* @param {string} json The json to parse.
|
||||
* @returns {Object} Object parsed from the json.
|
||||
*/
|
||||
const parseJSON = (json) => {
|
||||
try {
|
||||
const parsedJson = Hjson.parse(json);
|
||||
if (typeof parsedJson === "object") {
|
||||
return parsedJson;
|
||||
} else {
|
||||
throw new IncorrectJsonFormatError(
|
||||
"PR diff is not a valid theme JSON object.",
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
// Resolve eslint no-unused-vars
|
||||
error;
|
||||
|
||||
// Remove trailing commas (if any).
|
||||
let parsedJson = json.replace(/(,\s*})/g, "}");
|
||||
|
||||
// Remove JS comments (if any).
|
||||
parsedJson = parsedJson.replace(/\/\/[A-z\s]*\s/g, "");
|
||||
|
||||
// Fix incorrect open bracket (if any).
|
||||
const splitJson = parsedJson
|
||||
.split(/([\s\r\s]*}[\s\r\s]*,[\s\r\s]*)(?=[\w"-]+:)/)
|
||||
.filter((x) => typeof x !== "string" || !!x.trim()); // Split json into array of strings and objects.
|
||||
if (splitJson[0].replace(/\s+/g, "") === "},") {
|
||||
splitJson[0] = "},";
|
||||
if (/\s*}\s*,?\s*$/.test(splitJson[1])) {
|
||||
splitJson.shift();
|
||||
} else {
|
||||
splitJson.push(splitJson.shift());
|
||||
}
|
||||
parsedJson = splitJson.join("");
|
||||
}
|
||||
|
||||
// Try to parse the fixed json.
|
||||
try {
|
||||
return Hjson.parse(parsedJson);
|
||||
} catch (error) {
|
||||
throw new IncorrectJsonFormatError(
|
||||
`Theme JSON file could not be parsed: ${error.message}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Check whether the theme name is still available.
|
||||
* @param {string} name Theme name.
|
||||
* @returns {boolean} Whether the theme name is available.
|
||||
*/
|
||||
const themeNameAlreadyExists = (name) => {
|
||||
return themes[name] !== undefined;
|
||||
};
|
||||
|
||||
const DRY_RUN = process.env.DRY_RUN === "true" || false;
|
||||
|
||||
/**
|
||||
* Main function.
|
||||
*
|
||||
* @returns {Promise<void>} Promise.
|
||||
*/
|
||||
export const run = async () => {
|
||||
try {
|
||||
debug("Retrieve action information from context...");
|
||||
debug(`Context: ${inspect(github.context)}`);
|
||||
let commentBody = `
|
||||
\r# ${COMMENT_TITLE}
|
||||
\r${THEME_CONTRIB_GUIDELINES}
|
||||
`;
|
||||
const ccc = new ColorContrastChecker();
|
||||
OCTOKIT = github.getOctokit(getGithubToken());
|
||||
PULL_REQUEST_ID = getPrNumber();
|
||||
const { owner, repo } = getRepoInfo(github.context);
|
||||
OWNER = owner;
|
||||
REPO = repo;
|
||||
const commenter = getCommenter();
|
||||
PULL_REQUEST_ID = getPrNumber();
|
||||
debug(`Owner: ${OWNER}`);
|
||||
debug(`Repo: ${REPO}`);
|
||||
debug(`Commenter: ${commenter}`);
|
||||
|
||||
// Retrieve the PR diff and preview-theme comment.
|
||||
debug("Retrieve PR diff...");
|
||||
const res = await OCTOKIT.rest.pulls.get({
|
||||
owner: OWNER,
|
||||
repo: REPO,
|
||||
pull_number: PULL_REQUEST_ID,
|
||||
mediaType: {
|
||||
format: "diff",
|
||||
},
|
||||
});
|
||||
debug("Retrieve preview-theme comment...");
|
||||
const comment = await findComment(
|
||||
OCTOKIT,
|
||||
PULL_REQUEST_ID,
|
||||
OWNER,
|
||||
REPO,
|
||||
commenter,
|
||||
);
|
||||
|
||||
// Retrieve theme changes from the PR diff.
|
||||
debug("Retrieve themes...");
|
||||
const diff = parse(res.data);
|
||||
|
||||
// Retrieve all theme changes from the PR diff and convert to JSON.
|
||||
debug("Retrieve theme changes...");
|
||||
const content = diff
|
||||
.find((file) => file.to === "themes/index.js")
|
||||
.chunks.map((chunk) =>
|
||||
chunk.changes
|
||||
.filter((c) => c.type === "add")
|
||||
.map((c) => c.content.replace("+", ""))
|
||||
.join(""),
|
||||
)
|
||||
.join("");
|
||||
const themeObject = parseJSON(content);
|
||||
if (
|
||||
Object.keys(themeObject).every(
|
||||
(key) => typeof themeObject[key] !== "object",
|
||||
)
|
||||
) {
|
||||
throw new Error("PR diff is not a valid theme JSON object.");
|
||||
}
|
||||
|
||||
// Loop through themes and create theme preview body.
|
||||
debug("Create theme preview body...");
|
||||
const themeValid = Object.fromEntries(
|
||||
Object.keys(themeObject).map((name) => [name, true]),
|
||||
);
|
||||
let previewBody = "";
|
||||
for (const theme in themeObject) {
|
||||
debug(`Create theme preview for ${theme}...`);
|
||||
const themeName = theme;
|
||||
const colors = themeObject[theme];
|
||||
const warnings = [];
|
||||
const errors = [];
|
||||
|
||||
// Check if the theme name is valid.
|
||||
debug("Theme preview body: Check if the theme name is valid...");
|
||||
if (themeNameAlreadyExists(themeName)) {
|
||||
warnings.push("Theme name already taken");
|
||||
themeValid[theme] = false;
|
||||
}
|
||||
if (themeName !== snakeCase(themeName)) {
|
||||
warnings.push("Theme name isn't in snake_case");
|
||||
themeValid[theme] = false;
|
||||
}
|
||||
|
||||
// Check if the theme colors are valid.
|
||||
debug("Theme preview body: Check if the theme colors are valid...");
|
||||
let invalidColors = false;
|
||||
if (colors) {
|
||||
const missingKeys = REQUIRED_COLOR_PROPS.filter(
|
||||
(x) => !Object.keys(colors).includes(x),
|
||||
);
|
||||
const extraKeys = Object.keys(colors).filter(
|
||||
(x) => !ACCEPTED_COLOR_PROPS.includes(x),
|
||||
);
|
||||
if (missingKeys.length > 0 || extraKeys.length > 0) {
|
||||
for (const missingKey of missingKeys) {
|
||||
errors.push(`Theme color properties \`${missingKey}\` are missing`);
|
||||
}
|
||||
|
||||
for (const extraKey of extraKeys) {
|
||||
warnings.push(
|
||||
`Theme color properties \`${extraKey}\` is not supported`,
|
||||
);
|
||||
}
|
||||
invalidColors = true;
|
||||
} else {
|
||||
for (const [colorKey, colorValue] of Object.entries(colors)) {
|
||||
if (colorValue[0] === "#") {
|
||||
errors.push(
|
||||
`Theme color property \`${colorKey}\` should not start with '#'`,
|
||||
);
|
||||
invalidColors = true;
|
||||
} else if (colorValue.length > COLOR_PROPS[colorKey]) {
|
||||
errors.push(
|
||||
`Theme color property \`${colorKey}\` can not be longer than \`${COLOR_PROPS[colorKey]}\` characters`,
|
||||
);
|
||||
invalidColors = true;
|
||||
} else if (
|
||||
!(colorKey === "bg_color" && colorValue.split(",").length > 1
|
||||
? isValidGradient(colorValue.split(","))
|
||||
: isValidHexColor(colorValue))
|
||||
) {
|
||||
errors.push(
|
||||
`Theme color property \`${colorKey}\` is not a valid hex color: <code>${colorValue}</code>`,
|
||||
);
|
||||
invalidColors = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
warnings.push("Theme colors are missing");
|
||||
invalidColors = true;
|
||||
}
|
||||
if (invalidColors) {
|
||||
themeValid[theme] = false;
|
||||
previewBody += `
|
||||
\r### ${
|
||||
themeName.charAt(0).toUpperCase() + themeName.slice(1)
|
||||
} theme preview
|
||||
|
||||
\r${warnings.map((warning) => `- :warning: ${warning}.\n`).join("")}
|
||||
\r${errors.map((error) => `- :x: ${error}.\n`).join("")}
|
||||
|
||||
\r>:x: Cannot create theme preview.
|
||||
`;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check color contrast.
|
||||
debug("Theme preview body: Check color contrast...");
|
||||
const titleColor = colors.title_color;
|
||||
const iconColor = colors.icon_color;
|
||||
const textColor = colors.text_color;
|
||||
const bgColor = colors.bg_color;
|
||||
const borderColor = colors.border_color;
|
||||
const url = getGRSLink(colors);
|
||||
const colorPairs = {
|
||||
title_color: [titleColor, bgColor],
|
||||
icon_color: [iconColor, bgColor],
|
||||
text_color: [textColor, bgColor],
|
||||
};
|
||||
Object.keys(colorPairs).forEach((item) => {
|
||||
let color1 = colorPairs[item][0];
|
||||
let color2 = colorPairs[item][1];
|
||||
const isGradientColor = color2.split(",").length > 1;
|
||||
if (isGradientColor) {
|
||||
return;
|
||||
}
|
||||
color1 = color1.length === 4 ? color1.slice(0, 3) : color1.slice(0, 6);
|
||||
color2 = color2.length === 4 ? color2.slice(0, 3) : color2.slice(0, 6);
|
||||
if (!ccc.isLevelAA(`#${color1}`, `#${color2}`)) {
|
||||
const permalink = getWebAimLink(color1, color2);
|
||||
warnings.push(
|
||||
`\`${item}\` does not pass [AA contrast ratio](${permalink})`,
|
||||
);
|
||||
themeValid[theme] = false;
|
||||
}
|
||||
});
|
||||
|
||||
// Create theme preview body.
|
||||
debug("Theme preview body: Create theme preview body...");
|
||||
previewBody += `
|
||||
\r### ${
|
||||
themeName.charAt(0).toUpperCase() + themeName.slice(1)
|
||||
} theme preview
|
||||
|
||||
\r${warnings.map((warning) => `- :warning: ${warning}.\n`).join("")}
|
||||
|
||||
\ntitle_color: <code>#${titleColor}</code> | icon_color: <code>#${iconColor}</code> | text_color: <code>#${textColor}</code> | bg_color: <code>#${bgColor}</code>${
|
||||
borderColor ? ` | border_color: <code>#${borderColor}</code>` : ""
|
||||
}
|
||||
|
||||
\r[Preview Link](${url})
|
||||
|
||||
\r[](${url})
|
||||
`;
|
||||
}
|
||||
|
||||
// Create comment body.
|
||||
debug("Create comment body...");
|
||||
commentBody += `
|
||||
\r${
|
||||
Object.values(themeValid).every((value) => value)
|
||||
? THEME_PR_SUCCESS_TEXT
|
||||
: THEME_PR_FAIL_TEXT
|
||||
}
|
||||
\r## Test results
|
||||
\r${Object.entries(themeValid)
|
||||
.map(
|
||||
([key, value]) => `- ${value ? ":heavy_check_mark:" : ":x:"} ${key}`,
|
||||
)
|
||||
.join("\r")}
|
||||
|
||||
\r${
|
||||
Object.values(themeValid).every((value) => value)
|
||||
? "**Result:** :heavy_check_mark: All themes are valid."
|
||||
: "**Result:** :x: Some themes are invalid.\n\n" + FAIL_TEXT
|
||||
}
|
||||
|
||||
\r## Details
|
||||
\r${previewBody}
|
||||
`;
|
||||
|
||||
// Create or update theme-preview comment.
|
||||
debug("Create or update theme-preview comment...");
|
||||
let comment_url;
|
||||
if (DRY_RUN) {
|
||||
info(`DRY_RUN: Comment body: ${commentBody}`);
|
||||
comment_url = "";
|
||||
} else {
|
||||
comment_url = await upsertComment(
|
||||
OCTOKIT,
|
||||
PULL_REQUEST_ID,
|
||||
REPO,
|
||||
OWNER,
|
||||
comment?.id,
|
||||
commentBody,
|
||||
);
|
||||
}
|
||||
|
||||
// Change review state and add/remove `invalid` label based on theme PR validity.
|
||||
debug(
|
||||
"Change review state and add/remove `invalid` label based on whether all themes passed...",
|
||||
);
|
||||
const themesValid = Object.values(themeValid).every((value) => value);
|
||||
const reviewState = themesValid ? "APPROVE" : "REQUEST_CHANGES";
|
||||
const reviewReason = themesValid
|
||||
? undefined
|
||||
: INVALID_REVIEW_COMMENT(comment_url);
|
||||
if (DRY_RUN) {
|
||||
info(`DRY_RUN: Review state: ${reviewState}`);
|
||||
info(`DRY_RUN: Review reason: ${reviewReason}`);
|
||||
} else {
|
||||
await addReview(
|
||||
OCTOKIT,
|
||||
PULL_REQUEST_ID,
|
||||
OWNER,
|
||||
REPO,
|
||||
reviewState,
|
||||
reviewReason,
|
||||
);
|
||||
await addRemoveLabel(
|
||||
OCTOKIT,
|
||||
PULL_REQUEST_ID,
|
||||
OWNER,
|
||||
REPO,
|
||||
"invalid",
|
||||
!themesValid,
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
debug("Set review state to `REQUEST_CHANGES` and add `invalid` label...");
|
||||
if (DRY_RUN) {
|
||||
info(`DRY_RUN: Review state: REQUEST_CHANGES`);
|
||||
info(`DRY_RUN: Review reason: ${error.message}`);
|
||||
} else {
|
||||
await addReview(
|
||||
OCTOKIT,
|
||||
PULL_REQUEST_ID,
|
||||
OWNER,
|
||||
REPO,
|
||||
"REQUEST_CHANGES",
|
||||
"**Something went wrong in the theme preview action:** `" +
|
||||
error.message +
|
||||
"`",
|
||||
);
|
||||
await addRemoveLabel(
|
||||
OCTOKIT,
|
||||
PULL_REQUEST_ID,
|
||||
OWNER,
|
||||
REPO,
|
||||
"invalid",
|
||||
true,
|
||||
);
|
||||
}
|
||||
setFailed(error.message);
|
||||
}
|
||||
};
|
||||
|
||||
run();
|
||||
@@ -0,0 +1,15 @@
|
||||
#!/bin/bash
|
||||
set -x
|
||||
set -e
|
||||
|
||||
export BRANCH_NAME=updated-theme-readme
|
||||
git --version
|
||||
git config --global user.email "no-reply@githubreadmestats.com"
|
||||
git config --global user.name "GitHub Readme Stats Bot"
|
||||
git config --global --add safe.directory ${GITHUB_WORKSPACE}
|
||||
git branch -d $BRANCH_NAME || true
|
||||
git checkout -b $BRANCH_NAME
|
||||
git add --all
|
||||
git commit --no-verify --message "docs(theme): auto update theme readme"
|
||||
git remote add origin-$BRANCH_NAME https://${PERSONAL_TOKEN}@github.com/${GH_REPO}.git
|
||||
git push --force --quiet --set-upstream origin-$BRANCH_NAME $BRANCH_NAME
|
||||
@@ -0,0 +1,87 @@
|
||||
/**
|
||||
* Calculates the exponential cdf.
|
||||
*
|
||||
* @param {number} x The value.
|
||||
* @returns {number} The exponential cdf.
|
||||
*/
|
||||
function exponential_cdf(x) {
|
||||
return 1 - 2 ** -x;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculates the log normal cdf.
|
||||
*
|
||||
* @param {number} x The value.
|
||||
* @returns {number} The log normal cdf.
|
||||
*/
|
||||
function log_normal_cdf(x) {
|
||||
// approximation
|
||||
return x / (1 + x);
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculates the users rank.
|
||||
*
|
||||
* @param {object} params Parameters on which the user's rank depends.
|
||||
* @param {boolean} params.all_commits Whether `include_all_commits` was used.
|
||||
* @param {number} params.commits Number of commits.
|
||||
* @param {number} params.prs The number of pull requests.
|
||||
* @param {number} params.issues The number of issues.
|
||||
* @param {number} params.reviews The number of reviews.
|
||||
* @param {number} params.repos Total number of repos.
|
||||
* @param {number} params.stars The number of stars.
|
||||
* @param {number} params.followers The number of followers.
|
||||
* @returns {{ level: string, percentile: number }} The users rank.
|
||||
*/
|
||||
function calculateRank({
|
||||
all_commits,
|
||||
commits,
|
||||
prs,
|
||||
issues,
|
||||
reviews,
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
repos, // unused
|
||||
stars,
|
||||
followers,
|
||||
}) {
|
||||
const COMMITS_MEDIAN = all_commits ? 1000 : 250,
|
||||
COMMITS_WEIGHT = 2;
|
||||
const PRS_MEDIAN = 50,
|
||||
PRS_WEIGHT = 3;
|
||||
const ISSUES_MEDIAN = 25,
|
||||
ISSUES_WEIGHT = 1;
|
||||
const REVIEWS_MEDIAN = 2,
|
||||
REVIEWS_WEIGHT = 1;
|
||||
const STARS_MEDIAN = 50,
|
||||
STARS_WEIGHT = 4;
|
||||
const FOLLOWERS_MEDIAN = 10,
|
||||
FOLLOWERS_WEIGHT = 1;
|
||||
|
||||
const TOTAL_WEIGHT =
|
||||
COMMITS_WEIGHT +
|
||||
PRS_WEIGHT +
|
||||
ISSUES_WEIGHT +
|
||||
REVIEWS_WEIGHT +
|
||||
STARS_WEIGHT +
|
||||
FOLLOWERS_WEIGHT;
|
||||
|
||||
const THRESHOLDS = [1, 12.5, 25, 37.5, 50, 62.5, 75, 87.5, 100];
|
||||
const LEVELS = ["S", "A+", "A", "A-", "B+", "B", "B-", "C+", "C"];
|
||||
|
||||
const rank =
|
||||
1 -
|
||||
(COMMITS_WEIGHT * exponential_cdf(commits / COMMITS_MEDIAN) +
|
||||
PRS_WEIGHT * exponential_cdf(prs / PRS_MEDIAN) +
|
||||
ISSUES_WEIGHT * exponential_cdf(issues / ISSUES_MEDIAN) +
|
||||
REVIEWS_WEIGHT * exponential_cdf(reviews / REVIEWS_MEDIAN) +
|
||||
STARS_WEIGHT * log_normal_cdf(stars / STARS_MEDIAN) +
|
||||
FOLLOWERS_WEIGHT * log_normal_cdf(followers / FOLLOWERS_MEDIAN)) /
|
||||
TOTAL_WEIGHT;
|
||||
|
||||
const level = LEVELS[THRESHOLDS.findIndex((t) => rank * 100 <= t)];
|
||||
|
||||
return { level, percentile: rank * 100 };
|
||||
}
|
||||
|
||||
export { calculateRank };
|
||||
export default calculateRank;
|
||||
@@ -0,0 +1,143 @@
|
||||
// @ts-check
|
||||
|
||||
import {
|
||||
measureText,
|
||||
flexLayout,
|
||||
iconWithLabel,
|
||||
createLanguageNode,
|
||||
} from "../common/render.js";
|
||||
import Card from "../common/Card.js";
|
||||
import { getCardColors } from "../common/color.js";
|
||||
import { kFormatter, wrapTextMultiline } from "../common/fmt.js";
|
||||
import { encodeHTML } from "../common/html.js";
|
||||
import { icons } from "../common/icons.js";
|
||||
import languageColors from "../common/languageColors.json" with { type: "json" };
|
||||
import { parseEmojis } from "../common/ops.js";
|
||||
|
||||
|
||||
const ICON_SIZE = 16;
|
||||
const CARD_DEFAULT_WIDTH = 400;
|
||||
const HEADER_MAX_LENGTH = 35;
|
||||
|
||||
/**
|
||||
* @typedef {import('./types').GistCardOptions} GistCardOptions Gist card options.
|
||||
* @typedef {import('../fetchers/types').GistData} GistData Gist data.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Render gist card.
|
||||
*
|
||||
* @param {GistData} gistData Gist data.
|
||||
* @param {Partial<GistCardOptions>} options Gist card options.
|
||||
* @returns {string} Gist card.
|
||||
*/
|
||||
const renderGistCard = (gistData, options = {}) => {
|
||||
const { name, nameWithOwner, description, language, starsCount, forksCount } =
|
||||
gistData;
|
||||
const {
|
||||
title_color,
|
||||
icon_color,
|
||||
text_color,
|
||||
bg_color,
|
||||
theme = "default_repocard",
|
||||
border_radius,
|
||||
border_color,
|
||||
show_owner = false,
|
||||
hide_border = false,
|
||||
} = options;
|
||||
|
||||
// returns theme based colors with proper overrides and defaults
|
||||
const { titleColor, textColor, iconColor, bgColor, borderColor } =
|
||||
getCardColors({
|
||||
title_color,
|
||||
icon_color,
|
||||
text_color,
|
||||
bg_color,
|
||||
border_color,
|
||||
theme,
|
||||
});
|
||||
|
||||
const lineWidth = 59;
|
||||
const linesLimit = 10;
|
||||
const desc = parseEmojis(description || "No description provided");
|
||||
const multiLineDescription = wrapTextMultiline(desc, lineWidth, linesLimit);
|
||||
const descriptionLines = multiLineDescription.length;
|
||||
const descriptionSvg = multiLineDescription
|
||||
.map((line) => `<tspan dy="1.2em" x="25">${encodeHTML(line)}</tspan>`)
|
||||
.join("");
|
||||
|
||||
const lineHeight = descriptionLines > 3 ? 12 : 10;
|
||||
const height =
|
||||
(descriptionLines > 1 ? 120 : 110) + descriptionLines * lineHeight;
|
||||
|
||||
const totalStars = kFormatter(starsCount);
|
||||
const totalForks = kFormatter(forksCount);
|
||||
const svgStars = iconWithLabel(
|
||||
icons.star,
|
||||
totalStars,
|
||||
"starsCount",
|
||||
ICON_SIZE,
|
||||
);
|
||||
const svgForks = iconWithLabel(
|
||||
icons.fork,
|
||||
totalForks,
|
||||
"forksCount",
|
||||
ICON_SIZE,
|
||||
);
|
||||
|
||||
const languageName = language || "Unspecified";
|
||||
// @ts-ignore
|
||||
const languageColor = languageColors[languageName] || "#858585";
|
||||
|
||||
const svgLanguage = createLanguageNode(languageName, languageColor);
|
||||
|
||||
const starAndForkCount = flexLayout({
|
||||
items: [svgLanguage, svgStars, svgForks],
|
||||
sizes: [
|
||||
measureText(languageName, 12),
|
||||
ICON_SIZE + measureText(`${totalStars}`, 12),
|
||||
ICON_SIZE + measureText(`${totalForks}`, 12),
|
||||
],
|
||||
gap: 25,
|
||||
}).join("");
|
||||
|
||||
const header = show_owner ? nameWithOwner : name;
|
||||
|
||||
const card = new Card({
|
||||
defaultTitle:
|
||||
header.length > HEADER_MAX_LENGTH
|
||||
? `${header.slice(0, HEADER_MAX_LENGTH)}...`
|
||||
: header,
|
||||
titlePrefixIcon: icons.gist,
|
||||
width: CARD_DEFAULT_WIDTH,
|
||||
height,
|
||||
border_radius,
|
||||
colors: {
|
||||
titleColor,
|
||||
textColor,
|
||||
iconColor,
|
||||
bgColor,
|
||||
borderColor,
|
||||
},
|
||||
});
|
||||
|
||||
card.setCSS(`
|
||||
.description { font: 400 13px 'Segoe UI', Ubuntu, Sans-Serif; fill: ${textColor} }
|
||||
.gray { font: 400 12px 'Segoe UI', Ubuntu, Sans-Serif; fill: ${textColor} }
|
||||
.icon { fill: ${iconColor} }
|
||||
`);
|
||||
card.setHideBorder(hide_border);
|
||||
|
||||
return card.render(`
|
||||
<text class="description" x="25" y="-5">
|
||||
${descriptionSvg}
|
||||
</text>
|
||||
|
||||
<g transform="translate(30, ${height - 75})">
|
||||
${starAndForkCount}
|
||||
</g>
|
||||
`);
|
||||
};
|
||||
|
||||
export { renderGistCard, HEADER_MAX_LENGTH };
|
||||
export default renderGistCard;
|
||||
@@ -0,0 +1,4 @@
|
||||
export { renderRepoCard } from "./repo.js";
|
||||
export { renderStatsCard } from "./stats.js";
|
||||
export { renderTopLanguages } from "./top-languages.js";
|
||||
export { renderWakatimeCard } from "./wakatime.js";
|
||||
@@ -0,0 +1,325 @@
|
||||
// @ts-check
|
||||
|
||||
import { Card } from "../common/Card.js";
|
||||
import { getCardColors } from "../common/color.js";
|
||||
import { kFormatter, wrapTextMultiline } from "../common/fmt.js";
|
||||
import { encodeHTML } from "../common/html.js";
|
||||
import { I18n } from "../common/I18n.js";
|
||||
import { icons } from "../common/icons.js";
|
||||
import { clampValue, parseEmojis } from "../common/ops.js";
|
||||
import { buildSearchFilter } from "../common/ops.js";
|
||||
import {
|
||||
flexLayout,
|
||||
measureText,
|
||||
iconWithLabel,
|
||||
createLanguageNode,
|
||||
} from "../common/render.js";
|
||||
import { repoCardLocales } from "../translations.js";
|
||||
import { createTextNode } from "./stats.js";
|
||||
|
||||
const ICON_SIZE = 16;
|
||||
const DESCRIPTION_LINE_WIDTH = 59;
|
||||
const DESCRIPTION_MAX_LINES = 3;
|
||||
|
||||
/**
|
||||
* Retrieves the repository description and wraps it to fit the card width.
|
||||
*
|
||||
* @param {string} label The repository description.
|
||||
* @param {string} textColor The color of the text.
|
||||
* @returns {string} Wrapped repo description SVG object.
|
||||
*/
|
||||
const getBadgeSVG = (label, textColor, xOffset = 0) => `
|
||||
<g data-testid="badge" class="badge" transform="translate(${320 + xOffset}, -18)">
|
||||
<rect stroke="${textColor}" stroke-width="1" width="70" height="20" x="-12" y="-14" ry="10" rx="10"></rect>
|
||||
<text
|
||||
x="23" y="-5"
|
||||
alignment-baseline="central"
|
||||
dominant-baseline="central"
|
||||
text-anchor="middle"
|
||||
fill="${textColor}"
|
||||
>
|
||||
${label}
|
||||
</text>
|
||||
</g>
|
||||
`;
|
||||
|
||||
/**
|
||||
* @typedef {import("../fetchers/types").RepositoryData} RepositoryData Repository data.
|
||||
* @typedef {import("./types").RepoCardOptions} RepoCardOptions Repo card options.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Renders repository card details.
|
||||
*
|
||||
* @param {RepositoryData} repo Repository data.
|
||||
* @param {Partial<RepoCardOptions>} options Card options.
|
||||
* @returns {string} Repository card SVG object.
|
||||
*/
|
||||
const renderRepoCard = (repo, options = {}) => {
|
||||
const {
|
||||
name,
|
||||
nameWithOwner,
|
||||
description,
|
||||
primaryLanguage,
|
||||
isArchived,
|
||||
isTemplate,
|
||||
starCount,
|
||||
forkCount,
|
||||
totalPRsAuthored,
|
||||
totalPRsCommented,
|
||||
totalPRsReviewed,
|
||||
totalIssuesAuthored,
|
||||
totalIssuesCommented,
|
||||
} = repo;
|
||||
const {
|
||||
hide_border = false,
|
||||
title_color,
|
||||
icon_color,
|
||||
text_color,
|
||||
bg_color,
|
||||
card_width_input,
|
||||
show_owner = false,
|
||||
show = [],
|
||||
show_icons = true,
|
||||
number_format = "short",
|
||||
text_bold = false,
|
||||
line_height = 22,
|
||||
username,
|
||||
theme = "default_repocard",
|
||||
border_radius,
|
||||
border_color,
|
||||
locale,
|
||||
description_lines_count,
|
||||
} = options;
|
||||
|
||||
const card_width =
|
||||
card_width_input && !isNaN(card_width_input)
|
||||
? card_width_input
|
||||
: show.length >= 2
|
||||
? 430
|
||||
: 400;
|
||||
|
||||
const i18n = new I18n({
|
||||
locale,
|
||||
translations: repoCardLocales,
|
||||
});
|
||||
|
||||
let repoFilter = encodeURIComponent(buildSearchFilter([nameWithOwner], []));
|
||||
const STATS = {};
|
||||
if (show.includes("prs_authored")) {
|
||||
STATS.prs_authored = {
|
||||
icon: icons.prs,
|
||||
label: i18n.t("repocard.prs-authored"),
|
||||
value: totalPRsAuthored,
|
||||
id: "prs_authored",
|
||||
link: `https://github.com/search?q=${repoFilter}author%3A${username}&type=pullrequests`,
|
||||
};
|
||||
}
|
||||
if (show.includes("prs_commented")) {
|
||||
STATS.prs_commented = {
|
||||
icon: icons.comments,
|
||||
label: i18n.t("repocard.prs-commented"),
|
||||
value: totalPRsCommented,
|
||||
id: "prs_commented",
|
||||
link: `https://github.com/search?q=${repoFilter}commenter%3A${username}+-author%3A${username}&type=pullrequests`,
|
||||
};
|
||||
}
|
||||
if (show.includes("prs_reviewed")) {
|
||||
STATS.prs_reviewed = {
|
||||
icon: icons.reviews,
|
||||
label: i18n.t("repocard.prs-reviewed"),
|
||||
value: totalPRsReviewed,
|
||||
id: "prs_reviewed",
|
||||
link: `https://github.com/search?q=${repoFilter}reviewed-by%3A${username}+-author%3A${username}&type=pullrequests`,
|
||||
};
|
||||
}
|
||||
if (show.includes("issues_authored")) {
|
||||
STATS.issues_authored = {
|
||||
icon: icons.issues,
|
||||
label: i18n.t("repocard.issues-authored"),
|
||||
value: totalIssuesAuthored,
|
||||
id: "issues_authored",
|
||||
link: `https://github.com/search?q=${repoFilter}author%3A${username}&type=issues`,
|
||||
};
|
||||
}
|
||||
if (show.includes("issues_commented")) {
|
||||
STATS.issues_commented = {
|
||||
icon: icons.discussions_started,
|
||||
label: i18n.t("repocard.issues-commented"),
|
||||
value: totalIssuesCommented,
|
||||
id: "issues_commented",
|
||||
link: `https://github.com/search?q=${repoFilter}commenter%3A${username}+-author%3A${username}&type=issues`,
|
||||
};
|
||||
}
|
||||
|
||||
const statItems = Object.keys(STATS).map((key, index) =>
|
||||
// create the text nodes, and pass index so that we can calculate the line spacing
|
||||
createTextNode({
|
||||
icon: STATS[key].icon,
|
||||
label: STATS[key].label,
|
||||
value: STATS[key].value,
|
||||
id: STATS[key].id,
|
||||
unitSymbol: STATS[key].unitSymbol,
|
||||
index,
|
||||
showIcons: show_icons,
|
||||
shiftValuePos: 14.01,
|
||||
bold: text_bold,
|
||||
numberFormat: number_format,
|
||||
link: STATS[key].link,
|
||||
labelXOffset: 23,
|
||||
}),
|
||||
);
|
||||
|
||||
const extraLHeight = parseInt(String(line_height), 10);
|
||||
const lineHeight = 10;
|
||||
const header = show_owner ? nameWithOwner : name;
|
||||
const langName = (primaryLanguage && primaryLanguage.name) || "Unspecified";
|
||||
const langColor = (primaryLanguage && primaryLanguage.color) || "#333";
|
||||
const descriptionMaxLines = description_lines_count
|
||||
? clampValue(description_lines_count, 1, DESCRIPTION_MAX_LINES)
|
||||
: DESCRIPTION_MAX_LINES;
|
||||
|
||||
const desc = parseEmojis(description || "No description provided");
|
||||
const multiLineDescription = wrapTextMultiline(
|
||||
desc,
|
||||
Math.round((card_width - 400) / 5.93 + DESCRIPTION_LINE_WIDTH),
|
||||
descriptionMaxLines,
|
||||
);
|
||||
const descriptionLinesCount = description_lines_count
|
||||
? clampValue(description_lines_count, 1, DESCRIPTION_MAX_LINES)
|
||||
: multiLineDescription.length;
|
||||
|
||||
const descriptionSvg = multiLineDescription
|
||||
.map((line) => `<tspan dy="1.2em" x="25">${encodeHTML(line)}</tspan>`)
|
||||
.join("");
|
||||
|
||||
const extraHeight = Object.keys(STATS).length
|
||||
? -7 + (Math.ceil(statItems.length / 2) + 1) * extraLHeight
|
||||
: 0;
|
||||
const height =
|
||||
(descriptionLinesCount > 1 ? 120 : 110) +
|
||||
descriptionLinesCount * lineHeight +
|
||||
extraHeight;
|
||||
|
||||
// returns theme based colors with proper overrides and defaults
|
||||
const colors = getCardColors({
|
||||
title_color,
|
||||
icon_color,
|
||||
text_color,
|
||||
bg_color,
|
||||
border_color,
|
||||
theme,
|
||||
});
|
||||
|
||||
const svgLanguage = primaryLanguage
|
||||
? createLanguageNode(langName, langColor)
|
||||
: "";
|
||||
|
||||
const totalStars = kFormatter(starCount);
|
||||
const totalForks = kFormatter(forkCount);
|
||||
const svgStars = iconWithLabel(
|
||||
icons.star,
|
||||
totalStars,
|
||||
"stargazers",
|
||||
ICON_SIZE,
|
||||
);
|
||||
const svgForks = iconWithLabel(
|
||||
icons.fork,
|
||||
totalForks,
|
||||
"forkcount",
|
||||
ICON_SIZE,
|
||||
);
|
||||
|
||||
const starAndForkCount = flexLayout({
|
||||
items: [svgLanguage, svgStars, svgForks],
|
||||
sizes: [
|
||||
measureText(langName, 12),
|
||||
ICON_SIZE + measureText(`${totalStars}`, 12),
|
||||
ICON_SIZE + measureText(`${totalForks}`, 12),
|
||||
],
|
||||
gap: 25,
|
||||
}).join("");
|
||||
|
||||
let extraRows = [];
|
||||
for (let i = 0; i < statItems.length; i += 2) {
|
||||
extraRows.push(
|
||||
flexLayout({
|
||||
items: statItems.slice(i, i + 2),
|
||||
gap: 210,
|
||||
direction: "row",
|
||||
}).join(""),
|
||||
);
|
||||
}
|
||||
const extraItems = `
|
||||
<svg x="0" y="0"><g transform="translate(-3, ${height - 52 - extraHeight})">
|
||||
${flexLayout({
|
||||
items: extraRows,
|
||||
gap: extraLHeight,
|
||||
direction: "column",
|
||||
}).join("")}
|
||||
</g></svg>
|
||||
`;
|
||||
|
||||
const card = new Card({
|
||||
defaultTitle: header.length > 35 ? `${header.slice(0, 35)}...` : header,
|
||||
titlePrefixIcon: icons.contribs,
|
||||
width: card_width,
|
||||
height,
|
||||
border_radius,
|
||||
colors,
|
||||
});
|
||||
|
||||
card.disableAnimations();
|
||||
card.setHideBorder(hide_border);
|
||||
card.setHideTitle(false);
|
||||
card.setCSS(`
|
||||
.description { font: 400 13px 'Segoe UI', Ubuntu, Sans-Serif; fill: ${colors.textColor} }
|
||||
.gray { font: 400 12px 'Segoe UI', Ubuntu, Sans-Serif; fill: ${colors.textColor} }
|
||||
.badge { font: 600 11px 'Segoe UI', Ubuntu, Sans-Serif; }
|
||||
.badge rect { opacity: 0.2 }
|
||||
|
||||
.stat { font: 400 12px 'Segoe UI', Ubuntu, Sans-Serif; fill: ${colors.textColor} }
|
||||
.stagger {
|
||||
opacity: 0;
|
||||
animation: fadeInAnimation 0.3s ease-in-out forwards;
|
||||
}
|
||||
.not_bold { font-weight: 400 }
|
||||
.bold { font-weight: 700 }
|
||||
.icon {
|
||||
fill: ${colors.iconColor};
|
||||
display: block;
|
||||
}
|
||||
`);
|
||||
|
||||
return card.render(`
|
||||
${
|
||||
isTemplate
|
||||
? // @ts-ignore
|
||||
getBadgeSVG(
|
||||
i18n.t("repocard.template"),
|
||||
colors.textColor,
|
||||
card_width - 400,
|
||||
)
|
||||
: isArchived
|
||||
? // @ts-ignore
|
||||
getBadgeSVG(
|
||||
i18n.t("repocard.archived"),
|
||||
colors.textColor,
|
||||
card_width - 400,
|
||||
)
|
||||
: ""
|
||||
}
|
||||
|
||||
<text class="description" x="25" y="-5">
|
||||
${descriptionSvg}
|
||||
</text>
|
||||
|
||||
<g transform="translate(30, ${height - 75 - extraHeight})">
|
||||
${starAndForkCount}
|
||||
</g>
|
||||
${extraItems}
|
||||
`);
|
||||
};
|
||||
|
||||
export { renderRepoCard };
|
||||
export default renderRepoCard;
|
||||
@@ -0,0 +1,675 @@
|
||||
// @ts-check
|
||||
|
||||
import { Card } from "../common/Card.js";
|
||||
import { getCardColors } from "../common/color.js";
|
||||
import { CustomError } from "../common/error.js";
|
||||
import { kFormatter } from "../common/fmt.js";
|
||||
import { I18n } from "../common/I18n.js";
|
||||
import { icons, rankIcon } from "../common/icons.js";
|
||||
import { clampValue } from "../common/ops.js";
|
||||
import { buildSearchFilter } from "../common/ops.js";
|
||||
import { flexLayout, measureText } from "../common/render.js";
|
||||
import { statCardLocales, wakatimeCardLocales } from "../translations.js";
|
||||
|
||||
const CARD_MIN_WIDTH = 287;
|
||||
const CARD_DEFAULT_WIDTH = 287;
|
||||
const RANK_CARD_MIN_WIDTH = 420;
|
||||
const RANK_CARD_DEFAULT_WIDTH = 450;
|
||||
const RANK_ONLY_CARD_MIN_WIDTH = 290;
|
||||
const RANK_ONLY_CARD_DEFAULT_WIDTH = 290;
|
||||
|
||||
/**
|
||||
* Long locales that need more space for text. Keep sorted alphabetically.
|
||||
*
|
||||
* @type {(keyof typeof wakatimeCardLocales["wakatimecard.title"])[]}
|
||||
*/
|
||||
const LONG_LOCALES = [
|
||||
"az",
|
||||
"bg",
|
||||
"cs",
|
||||
"de",
|
||||
"el",
|
||||
"es",
|
||||
"fil",
|
||||
"fi",
|
||||
"fr",
|
||||
"hu",
|
||||
"id",
|
||||
"ja",
|
||||
"ml",
|
||||
"my",
|
||||
"nl",
|
||||
"pl",
|
||||
"pt-br",
|
||||
"pt-pt",
|
||||
"ru",
|
||||
"sr",
|
||||
"sr-latn",
|
||||
"sw",
|
||||
"ta",
|
||||
"uk-ua",
|
||||
"uz",
|
||||
"zh-tw",
|
||||
];
|
||||
|
||||
/**
|
||||
* Create a stats card text item.
|
||||
*
|
||||
* @param {object} params Object that contains the createTextNode parameters.
|
||||
* @param {string} params.icon The icon to display.
|
||||
* @param {string} params.label The label to display.
|
||||
* @param {number} params.value The value to display.
|
||||
* @param {string} params.id The id of the stat.
|
||||
* @param {string=} params.unitSymbol The unit symbol of the stat.
|
||||
* @param {number} params.index The index of the stat.
|
||||
* @param {boolean} params.showIcons Whether to show icons.
|
||||
* @param {number} params.shiftValuePos Number of pixels the value has to be shifted to the right.
|
||||
* @param {boolean} params.bold Whether to bold the label.
|
||||
* @param {string} params.numberFormat The format of numbers on card.
|
||||
* @param {number=} params.numberPrecision The precision of numbers on card.
|
||||
* @param {string} params.link Url to link to.
|
||||
* @param {number} params.labelXOffset horizontal offset for label.
|
||||
* @returns {string} The stats card text item SVG object.
|
||||
*/
|
||||
const createTextNode = ({
|
||||
icon,
|
||||
label,
|
||||
value,
|
||||
id,
|
||||
unitSymbol,
|
||||
index,
|
||||
showIcons,
|
||||
shiftValuePos,
|
||||
bold,
|
||||
numberFormat,
|
||||
numberPrecision,
|
||||
link,
|
||||
labelXOffset = 25,
|
||||
}) => {
|
||||
const precision =
|
||||
typeof numberPrecision === "number" && !isNaN(numberPrecision)
|
||||
? clampValue(numberPrecision, 0, 2)
|
||||
: undefined;
|
||||
const kValue =
|
||||
numberFormat.toLowerCase() === "long" || id === "prs_merged_percentage"
|
||||
? value
|
||||
: kFormatter(value, precision);
|
||||
const staggerDelay = (index + 3) * 150;
|
||||
|
||||
const labelOffset = showIcons ? `x="${labelXOffset}"` : "";
|
||||
const iconSvg = showIcons
|
||||
? `
|
||||
<svg data-testid="icon" class="icon" viewBox="0 0 16 16" version="1.1" width="16" height="16">
|
||||
${icon}
|
||||
</svg>
|
||||
`
|
||||
: "";
|
||||
return (
|
||||
`
|
||||
<g class="stagger" style="animation-delay: ${staggerDelay}ms" transform="translate(25, 0)">` +
|
||||
(link ? `<a href="${link}">` : "") +
|
||||
`
|
||||
${iconSvg}
|
||||
<text class="stat ${
|
||||
bold ? " bold" : "not_bold"
|
||||
}" ${labelOffset} y="12.5">${label}:</text>
|
||||
<text
|
||||
class="stat ${bold ? " bold" : "not_bold"}"
|
||||
x="${(showIcons ? 140 : 120) + (bold ? 5 : 0) + shiftValuePos}"
|
||||
y="12.5"
|
||||
data-testid="${id}"
|
||||
>${kValue}${unitSymbol ? ` ${unitSymbol}` : ""}</text>` +
|
||||
(link ? "</a>" : "") +
|
||||
`
|
||||
</g>
|
||||
`
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Calculates progress along the boundary of the circle, i.e. its circumference.
|
||||
*
|
||||
* @param {number} value The rank value to calculate progress for.
|
||||
* @returns {number} Progress value.
|
||||
*/
|
||||
const calculateCircleProgress = (value) => {
|
||||
const radius = 40;
|
||||
const c = Math.PI * (radius * 2);
|
||||
|
||||
if (value < 0) {
|
||||
value = 0;
|
||||
}
|
||||
if (value > 100) {
|
||||
value = 100;
|
||||
}
|
||||
|
||||
return ((100 - value) / 100) * c;
|
||||
};
|
||||
|
||||
/**
|
||||
* Retrieves the animation to display progress along the circumference of circle
|
||||
* from the beginning to the given value in a clockwise direction.
|
||||
*
|
||||
* @param {{progress: number}} progress The progress value to animate to.
|
||||
* @returns {string} Progress animation css.
|
||||
*/
|
||||
const getProgressAnimation = ({ progress }) => {
|
||||
return `
|
||||
@keyframes rankAnimation {
|
||||
from {
|
||||
stroke-dashoffset: ${calculateCircleProgress(0)};
|
||||
}
|
||||
to {
|
||||
stroke-dashoffset: ${calculateCircleProgress(progress)};
|
||||
}
|
||||
}
|
||||
`;
|
||||
};
|
||||
|
||||
/**
|
||||
* Retrieves CSS styles for a card.
|
||||
*
|
||||
* @param {Object} colors The colors to use for the card.
|
||||
* @param {string} colors.titleColor The title color.
|
||||
* @param {string} colors.textColor The text color.
|
||||
* @param {string} colors.iconColor The icon color.
|
||||
* @param {string} colors.ringColor The ring color.
|
||||
* @param {boolean} colors.show_icons Whether to show icons.
|
||||
* @param {number} colors.progress The progress value to animate to.
|
||||
* @returns {string} Card CSS styles.
|
||||
*/
|
||||
const getStyles = ({
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
titleColor,
|
||||
textColor,
|
||||
iconColor,
|
||||
ringColor,
|
||||
show_icons,
|
||||
progress,
|
||||
}) => {
|
||||
return `
|
||||
.stat {
|
||||
font: 600 14px 'Segoe UI', Ubuntu, "Helvetica Neue", Sans-Serif; fill: ${textColor};
|
||||
}
|
||||
@supports(-moz-appearance: auto) {
|
||||
/* Selector detects Firefox */
|
||||
.stat { font-size:12px; }
|
||||
}
|
||||
.stagger {
|
||||
opacity: 0;
|
||||
animation: fadeInAnimation 0.3s ease-in-out forwards;
|
||||
}
|
||||
.rank-text {
|
||||
font: 800 24px 'Segoe UI', Ubuntu, Sans-Serif; fill: ${textColor};
|
||||
animation: scaleInAnimation 0.3s ease-in-out forwards;
|
||||
}
|
||||
.rank-percentile-header {
|
||||
font-size: 14px;
|
||||
}
|
||||
.rank-percentile-text {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.not_bold { font-weight: 400 }
|
||||
.bold { font-weight: 700 }
|
||||
.icon {
|
||||
fill: ${iconColor};
|
||||
display: ${show_icons ? "block" : "none"};
|
||||
}
|
||||
|
||||
.rank-circle-rim {
|
||||
stroke: ${ringColor};
|
||||
fill: none;
|
||||
stroke-width: 6;
|
||||
opacity: 0.2;
|
||||
}
|
||||
.rank-circle {
|
||||
stroke: ${ringColor};
|
||||
stroke-dasharray: 250;
|
||||
fill: none;
|
||||
stroke-width: 6;
|
||||
stroke-linecap: round;
|
||||
opacity: 0.8;
|
||||
transform-origin: -10px 8px;
|
||||
transform: rotate(-90deg);
|
||||
animation: rankAnimation 1s forwards ease-in-out;
|
||||
}
|
||||
${process.env.NODE_ENV === "test" ? "" : getProgressAnimation({ progress })}
|
||||
`;
|
||||
};
|
||||
|
||||
/**
|
||||
* Return the label for commits according to the selected options
|
||||
*
|
||||
* @param {boolean} include_all_commits Option to include all years
|
||||
* @param {number|undefined} commits_year Option to include only selected year
|
||||
* @param {I18n} i18n The I18n instance.
|
||||
* @returns {string} The label corresponding to the options.
|
||||
*/
|
||||
const getTotalCommitsYearLabel = (include_all_commits, commits_year, i18n) =>
|
||||
include_all_commits
|
||||
? ""
|
||||
: commits_year
|
||||
? ` (${commits_year})`
|
||||
: ` (${i18n.t("wakatimecard.lastyear")})`;
|
||||
|
||||
/**
|
||||
* @typedef {import('../fetchers/types').StatsData} StatsData
|
||||
* @typedef {import('./types').StatCardOptions} StatCardOptions
|
||||
*/
|
||||
|
||||
/**
|
||||
* Renders the stats card.
|
||||
*
|
||||
* @param {StatsData} stats The stats data.
|
||||
* @param {Partial<StatCardOptions>} options The card options.
|
||||
* @returns {string} The stats card SVG object.
|
||||
*/
|
||||
const renderStatsCard = (
|
||||
stats,
|
||||
options = {},
|
||||
username,
|
||||
repo = [],
|
||||
owner = [],
|
||||
) => {
|
||||
const {
|
||||
name,
|
||||
totalStars,
|
||||
totalCommits,
|
||||
totalIssues,
|
||||
totalPRs,
|
||||
totalPRsMerged,
|
||||
mergedPRsPercentage,
|
||||
totalReviews,
|
||||
totalDiscussionsStarted,
|
||||
totalDiscussionsAnswered,
|
||||
contributedTo,
|
||||
totalPRsAuthored,
|
||||
totalPRsCommented,
|
||||
totalPRsReviewed,
|
||||
totalIssuesAuthored,
|
||||
totalIssuesCommented,
|
||||
rank,
|
||||
} = stats;
|
||||
const {
|
||||
hide = [],
|
||||
show_icons = false,
|
||||
hide_title = false,
|
||||
hide_border = false,
|
||||
card_width,
|
||||
hide_rank = false,
|
||||
include_all_commits = false,
|
||||
commits_year,
|
||||
line_height = 25,
|
||||
title_color,
|
||||
ring_color,
|
||||
icon_color,
|
||||
text_color,
|
||||
text_bold = true,
|
||||
bg_color,
|
||||
theme = "default",
|
||||
custom_title,
|
||||
border_radius,
|
||||
border_color,
|
||||
number_format = "short",
|
||||
number_precision,
|
||||
locale,
|
||||
disable_animations = false,
|
||||
rank_icon = "default",
|
||||
show = [],
|
||||
} = options;
|
||||
|
||||
const lheight = parseInt(String(line_height), 10);
|
||||
|
||||
// returns theme based colors with proper overrides and defaults
|
||||
const { titleColor, iconColor, textColor, bgColor, borderColor, ringColor } =
|
||||
getCardColors({
|
||||
title_color,
|
||||
text_color,
|
||||
icon_color,
|
||||
bg_color,
|
||||
border_color,
|
||||
ring_color,
|
||||
theme,
|
||||
});
|
||||
|
||||
const apostrophe = /s$/i.test(name.trim()) ? "" : "s";
|
||||
const i18n = new I18n({
|
||||
locale,
|
||||
translations: {
|
||||
...statCardLocales({ name, apostrophe }),
|
||||
...wakatimeCardLocales,
|
||||
},
|
||||
});
|
||||
|
||||
// Meta data for creating text nodes with createTextNode function
|
||||
const STATS = {};
|
||||
|
||||
STATS.stars = {
|
||||
icon: icons.star,
|
||||
label: i18n.t("statcard.totalstars"),
|
||||
value: totalStars,
|
||||
id: "stars",
|
||||
};
|
||||
STATS.commits = {
|
||||
icon: icons.commits,
|
||||
label: `${i18n.t("statcard.commits")}${getTotalCommitsYearLabel(
|
||||
include_all_commits,
|
||||
commits_year,
|
||||
i18n,
|
||||
)}`,
|
||||
value: totalCommits,
|
||||
id: "commits",
|
||||
};
|
||||
STATS.prs = {
|
||||
icon: icons.prs,
|
||||
label: i18n.t("statcard.prs"),
|
||||
value: totalPRs,
|
||||
id: "prs",
|
||||
};
|
||||
|
||||
if (show.includes("prs_merged")) {
|
||||
STATS.prs_merged = {
|
||||
icon: icons.prs_merged,
|
||||
label: i18n.t("statcard.prs-merged"),
|
||||
value: totalPRsMerged,
|
||||
id: "prs_merged",
|
||||
};
|
||||
}
|
||||
|
||||
if (show.includes("prs_merged_percentage")) {
|
||||
STATS.prs_merged_percentage = {
|
||||
icon: icons.prs_merged_percentage,
|
||||
label: i18n.t("statcard.prs-merged-percentage"),
|
||||
value: mergedPRsPercentage.toFixed(
|
||||
typeof number_precision === "number" && !isNaN(number_precision)
|
||||
? clampValue(number_precision, 0, 2)
|
||||
: 2,
|
||||
),
|
||||
id: "prs_merged_percentage",
|
||||
unitSymbol: "%",
|
||||
};
|
||||
}
|
||||
|
||||
if (show.includes("reviews")) {
|
||||
STATS.reviews = {
|
||||
icon: icons.reviews,
|
||||
label: i18n.t("statcard.reviews"),
|
||||
value: totalReviews,
|
||||
id: "reviews",
|
||||
};
|
||||
}
|
||||
|
||||
STATS.issues = {
|
||||
icon: icons.issues,
|
||||
label: i18n.t("statcard.issues"),
|
||||
value: totalIssues,
|
||||
id: "issues",
|
||||
};
|
||||
|
||||
if (show.includes("discussions_started")) {
|
||||
STATS.discussions_started = {
|
||||
icon: icons.discussions_started,
|
||||
label: i18n.t("statcard.discussions-started"),
|
||||
value: totalDiscussionsStarted,
|
||||
id: "discussions_started",
|
||||
};
|
||||
}
|
||||
if (show.includes("discussions_answered")) {
|
||||
STATS.discussions_answered = {
|
||||
icon: icons.discussions_answered,
|
||||
label: i18n.t("statcard.discussions-answered"),
|
||||
value: totalDiscussionsAnswered,
|
||||
id: "discussions_answered",
|
||||
};
|
||||
}
|
||||
|
||||
let repoFilter = encodeURIComponent(buildSearchFilter(repo, owner));
|
||||
if (show.includes("prs_authored")) {
|
||||
STATS.prs_authored = {
|
||||
icon: icons.prs,
|
||||
label: i18n.t("statcard.prs-authored"),
|
||||
value: totalPRsAuthored,
|
||||
id: "prs_authored",
|
||||
link: `https://github.com/search?q=${repoFilter}author%3A${username}&type=pullrequests`,
|
||||
};
|
||||
}
|
||||
if (show.includes("prs_commented")) {
|
||||
STATS.prs_commented = {
|
||||
icon: icons.comments,
|
||||
label: i18n.t("statcard.prs-commented"),
|
||||
value: totalPRsCommented,
|
||||
id: "prs_commented",
|
||||
link: `https://github.com/search?q=${repoFilter}commenter%3A${username}+-author%3A${username}&type=pullrequests`,
|
||||
};
|
||||
}
|
||||
if (show.includes("prs_reviewed")) {
|
||||
STATS.prs_reviewed = {
|
||||
icon: icons.reviews,
|
||||
label: i18n.t("statcard.prs-reviewed"),
|
||||
value: totalPRsReviewed,
|
||||
id: "prs_reviewed",
|
||||
link: `https://github.com/search?q=${repoFilter}reviewed-by%3A${username}+-author%3A${username}&type=pullrequests`,
|
||||
};
|
||||
}
|
||||
if (show.includes("issues_authored")) {
|
||||
STATS.issues_authored = {
|
||||
icon: icons.issues,
|
||||
label: i18n.t("statcard.issues-authored"),
|
||||
value: totalIssuesAuthored,
|
||||
id: "issues_authored",
|
||||
link: `https://github.com/search?q=${repoFilter}author%3A${username}&type=issues`,
|
||||
};
|
||||
}
|
||||
if (show.includes("issues_commented")) {
|
||||
STATS.issues_commented = {
|
||||
icon: icons.discussions_started,
|
||||
label: i18n.t("statcard.issues-commented"),
|
||||
value: totalIssuesCommented,
|
||||
id: "issues_commented",
|
||||
link: `https://github.com/search?q=${repoFilter}commenter%3A${username}+-author%3A${username}&type=issues`,
|
||||
};
|
||||
}
|
||||
|
||||
STATS.contribs = {
|
||||
icon: icons.contribs,
|
||||
label: i18n.t("statcard.contribs"),
|
||||
value: contributedTo,
|
||||
id: "contribs",
|
||||
};
|
||||
|
||||
// @ts-ignore
|
||||
const isLongLocale = locale ? LONG_LOCALES.includes(locale) : false;
|
||||
|
||||
// check if all used labels are short
|
||||
const longLabels =
|
||||
Object.keys(STATS)
|
||||
.filter((key) => !hide.includes(key))
|
||||
.filter((key) => STATS[key].label.length > 18).length > 0;
|
||||
|
||||
// filter out hidden stats defined by user & create the text nodes
|
||||
const statItems = Object.keys(STATS)
|
||||
.filter((key) => !hide.includes(key))
|
||||
.map((key, index) => {
|
||||
// @ts-ignore
|
||||
const stats = STATS[key];
|
||||
|
||||
// create the text nodes, and pass index so that we can calculate the line spacing
|
||||
return createTextNode({
|
||||
icon: stats.icon,
|
||||
label: stats.label,
|
||||
value: stats.value,
|
||||
id: stats.id,
|
||||
unitSymbol: stats.unitSymbol,
|
||||
index,
|
||||
showIcons: show_icons,
|
||||
shiftValuePos: 29.01 + (longLabels ? 50 : 0) + (isLongLocale ? 50 : 0),
|
||||
bold: text_bold,
|
||||
numberFormat: number_format,
|
||||
numberPrecision: number_precision,
|
||||
link: STATS[key].link,
|
||||
});
|
||||
});
|
||||
|
||||
if (statItems.length === 0 && hide_rank) {
|
||||
throw new CustomError(
|
||||
"Could not render stats card.",
|
||||
"Either stats or rank are required.",
|
||||
);
|
||||
}
|
||||
|
||||
// Calculate the card height depending on how many items there are
|
||||
// but if rank circle is visible clamp the minimum height to `150`
|
||||
let height = Math.max(
|
||||
45 + (statItems.length + 1) * lheight,
|
||||
hide_rank ? 0 : statItems.length ? 150 : 180,
|
||||
);
|
||||
|
||||
// the lower the user's percentile the better
|
||||
const progress = 100 - rank.percentile;
|
||||
const cssStyles = getStyles({
|
||||
titleColor,
|
||||
ringColor,
|
||||
textColor,
|
||||
iconColor,
|
||||
show_icons,
|
||||
progress,
|
||||
});
|
||||
|
||||
const calculateTextWidth = () => {
|
||||
return measureText(
|
||||
custom_title
|
||||
? custom_title
|
||||
: statItems.length
|
||||
? i18n.t("statcard.title")
|
||||
: i18n.t("statcard.ranktitle"),
|
||||
);
|
||||
};
|
||||
|
||||
/*
|
||||
When hide_rank=true, the minimum card width is 270 px + the title length and padding.
|
||||
When hide_rank=false, the minimum card_width is 340 px + the icon width (if show_icons=true).
|
||||
Numbers are picked by looking at existing dimensions on production.
|
||||
*/
|
||||
const iconWidth = show_icons && statItems.length ? 16 + /* padding */ 1 : 0;
|
||||
const minCardWidth =
|
||||
(hide_rank
|
||||
? clampValue(
|
||||
50 /* padding */ + calculateTextWidth() * 2,
|
||||
CARD_MIN_WIDTH,
|
||||
Infinity,
|
||||
)
|
||||
: statItems.length
|
||||
? RANK_CARD_MIN_WIDTH
|
||||
: RANK_ONLY_CARD_MIN_WIDTH) + iconWidth;
|
||||
const defaultCardWidth =
|
||||
(hide_rank
|
||||
? CARD_DEFAULT_WIDTH
|
||||
: statItems.length
|
||||
? RANK_CARD_DEFAULT_WIDTH
|
||||
: RANK_ONLY_CARD_DEFAULT_WIDTH) + iconWidth;
|
||||
let width = card_width
|
||||
? isNaN(card_width)
|
||||
? Math.max(defaultCardWidth, minCardWidth)
|
||||
: card_width
|
||||
: Math.max(defaultCardWidth, minCardWidth);
|
||||
|
||||
const card = new Card({
|
||||
customTitle: custom_title,
|
||||
defaultTitle: statItems.length
|
||||
? i18n.t("statcard.title")
|
||||
: i18n.t("statcard.ranktitle"),
|
||||
width,
|
||||
height,
|
||||
border_radius,
|
||||
colors: {
|
||||
titleColor,
|
||||
textColor,
|
||||
iconColor,
|
||||
bgColor,
|
||||
borderColor,
|
||||
},
|
||||
});
|
||||
|
||||
card.setHideBorder(hide_border);
|
||||
card.setHideTitle(hide_title);
|
||||
card.setCSS(cssStyles);
|
||||
|
||||
if (disable_animations) {
|
||||
card.disableAnimations();
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculates the right rank circle translation values such that the rank circle
|
||||
* keeps respecting the following padding:
|
||||
*
|
||||
* width > RANK_CARD_DEFAULT_WIDTH: The default right padding of 70 px will be used.
|
||||
* width < RANK_CARD_DEFAULT_WIDTH: The left and right padding will be enlarged
|
||||
* equally from a certain minimum at RANK_CARD_MIN_WIDTH.
|
||||
*
|
||||
* @returns {number} - Rank circle translation value.
|
||||
*/
|
||||
const calculateRankXTranslation = () => {
|
||||
if (statItems.length) {
|
||||
const minXTranslation = RANK_CARD_MIN_WIDTH + iconWidth - 70;
|
||||
if (width > RANK_CARD_DEFAULT_WIDTH) {
|
||||
const xMaxExpansion = minXTranslation + (450 - minCardWidth) / 2;
|
||||
return xMaxExpansion + width - RANK_CARD_DEFAULT_WIDTH;
|
||||
} else {
|
||||
return minXTranslation + (width - minCardWidth) / 2;
|
||||
}
|
||||
} else {
|
||||
return width / 2 + 20 - 10;
|
||||
}
|
||||
};
|
||||
|
||||
// Conditionally rendered elements
|
||||
const rankCircle = hide_rank
|
||||
? ""
|
||||
: `<g data-testid="rank-circle"
|
||||
transform="translate(${calculateRankXTranslation()}, ${
|
||||
height / 2 - 50
|
||||
})">
|
||||
<circle class="rank-circle-rim" cx="-10" cy="8" r="40" />
|
||||
<circle class="rank-circle" cx="-10" cy="8" r="40" />
|
||||
<g class="rank-text">
|
||||
${rankIcon(rank_icon, rank?.level, rank?.percentile)}
|
||||
</g>
|
||||
</g>`;
|
||||
|
||||
// Accessibility Labels
|
||||
const labels = Object.keys(STATS)
|
||||
.filter((key) => !hide.includes(key))
|
||||
.map((key) => {
|
||||
// @ts-ignore
|
||||
const stats = STATS[key];
|
||||
if (key === "commits") {
|
||||
return `${i18n.t("statcard.commits")} ${getTotalCommitsYearLabel(
|
||||
include_all_commits,
|
||||
commits_year,
|
||||
i18n,
|
||||
)} : ${stats.value}`;
|
||||
}
|
||||
return `${stats.label}: ${stats.value}`;
|
||||
})
|
||||
.join(", ");
|
||||
|
||||
card.setAccessibilityLabel({
|
||||
title: `${card.title}, Rank: ${rank.level}`,
|
||||
desc: labels,
|
||||
});
|
||||
|
||||
return card.render(`
|
||||
${rankCircle}
|
||||
<svg x="0" y="0">
|
||||
${flexLayout({
|
||||
items: statItems,
|
||||
gap: lheight,
|
||||
direction: "column",
|
||||
}).join("")}
|
||||
</svg>
|
||||
`);
|
||||
};
|
||||
|
||||
export { renderStatsCard, createTextNode };
|
||||
export default renderStatsCard;
|
||||
@@ -0,0 +1,965 @@
|
||||
// @ts-check
|
||||
|
||||
import { Card } from "../common/Card.js";
|
||||
import { getCardColors } from "../common/color.js";
|
||||
import { formatBytes } from "../common/fmt.js";
|
||||
import { I18n } from "../common/I18n.js";
|
||||
import { chunkArray, clampValue, lowercaseTrim } from "../common/ops.js";
|
||||
import {
|
||||
createProgressNode,
|
||||
flexLayout,
|
||||
measureText,
|
||||
} from "../common/render.js";
|
||||
import { langCardLocales } from "../translations.js";
|
||||
|
||||
const DEFAULT_CARD_WIDTH = 300;
|
||||
const MIN_CARD_WIDTH = 280;
|
||||
const DEFAULT_LANG_COLOR = "#858585";
|
||||
const CARD_PADDING = 25;
|
||||
const COMPACT_LAYOUT_BASE_HEIGHT = 90;
|
||||
const MAXIMUM_LANGS_COUNT = 20;
|
||||
|
||||
const NORMAL_LAYOUT_DEFAULT_LANGS_COUNT = 5;
|
||||
const COMPACT_LAYOUT_DEFAULT_LANGS_COUNT = 6;
|
||||
const DONUT_LAYOUT_DEFAULT_LANGS_COUNT = 5;
|
||||
const PIE_LAYOUT_DEFAULT_LANGS_COUNT = 6;
|
||||
const DONUT_VERTICAL_LAYOUT_DEFAULT_LANGS_COUNT = 6;
|
||||
|
||||
/**
|
||||
* @typedef {import("../fetchers/types").Lang} Lang
|
||||
*/
|
||||
|
||||
/**
|
||||
* Retrieves the programming language whose name is the longest.
|
||||
*
|
||||
* @param {Lang[]} arr Array of programming languages.
|
||||
* @returns {{ name: string, size: number, color: string }} Longest programming language object.
|
||||
*/
|
||||
const getLongestLang = (arr) =>
|
||||
arr.reduce(
|
||||
(savedLang, lang) =>
|
||||
lang.name.length > savedLang.name.length ? lang : savedLang,
|
||||
{ name: "", size: 0, color: "" },
|
||||
);
|
||||
|
||||
/**
|
||||
* Convert degrees to radians.
|
||||
*
|
||||
* @param {number} angleInDegrees Angle in degrees.
|
||||
* @returns {number} Angle in radians.
|
||||
*/
|
||||
const degreesToRadians = (angleInDegrees) => angleInDegrees * (Math.PI / 180.0);
|
||||
|
||||
/**
|
||||
* Convert radians to degrees.
|
||||
*
|
||||
* @param {number} angleInRadians Angle in radians.
|
||||
* @returns {number} Angle in degrees.
|
||||
*/
|
||||
const radiansToDegrees = (angleInRadians) => angleInRadians / (Math.PI / 180.0);
|
||||
|
||||
/**
|
||||
* Convert polar coordinates to cartesian coordinates.
|
||||
*
|
||||
* @param {number} centerX Center x coordinate.
|
||||
* @param {number} centerY Center y coordinate.
|
||||
* @param {number} radius Radius of the circle.
|
||||
* @param {number} angleInDegrees Angle in degrees.
|
||||
* @returns {{x: number, y: number}} Cartesian coordinates.
|
||||
*/
|
||||
const polarToCartesian = (centerX, centerY, radius, angleInDegrees) => {
|
||||
const rads = degreesToRadians(angleInDegrees);
|
||||
return {
|
||||
x: centerX + radius * Math.cos(rads),
|
||||
y: centerY + radius * Math.sin(rads),
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Convert cartesian coordinates to polar coordinates.
|
||||
*
|
||||
* @param {number} centerX Center x coordinate.
|
||||
* @param {number} centerY Center y coordinate.
|
||||
* @param {number} x Point x coordinate.
|
||||
* @param {number} y Point y coordinate.
|
||||
* @returns {{radius: number, angleInDegrees: number}} Polar coordinates.
|
||||
*/
|
||||
const cartesianToPolar = (centerX, centerY, x, y) => {
|
||||
const radius = Math.sqrt(Math.pow(x - centerX, 2) + Math.pow(y - centerY, 2));
|
||||
let angleInDegrees = radiansToDegrees(Math.atan2(y - centerY, x - centerX));
|
||||
if (angleInDegrees < 0) {
|
||||
angleInDegrees += 360;
|
||||
}
|
||||
return { radius, angleInDegrees };
|
||||
};
|
||||
|
||||
/**
|
||||
* Calculates length of circle.
|
||||
*
|
||||
* @param {number} radius Radius of the circle.
|
||||
* @returns {number} The length of the circle.
|
||||
*/
|
||||
const getCircleLength = (radius) => {
|
||||
return 2 * Math.PI * radius;
|
||||
};
|
||||
|
||||
/**
|
||||
* Calculates height for the compact layout.
|
||||
*
|
||||
* @param {number} totalLangs Total number of languages.
|
||||
* @returns {number} Card height.
|
||||
*/
|
||||
const calculateCompactLayoutHeight = (totalLangs) => {
|
||||
return COMPACT_LAYOUT_BASE_HEIGHT + Math.round(totalLangs / 2) * 25;
|
||||
};
|
||||
|
||||
/**
|
||||
* Calculates height for the normal layout.
|
||||
*
|
||||
* @param {number} totalLangs Total number of languages.
|
||||
* @returns {number} Card height.
|
||||
*/
|
||||
const calculateNormalLayoutHeight = (totalLangs) => {
|
||||
return 45 + (totalLangs + 1) * 40;
|
||||
};
|
||||
|
||||
/**
|
||||
* Calculates height for the donut layout.
|
||||
*
|
||||
* @param {number} totalLangs Total number of languages.
|
||||
* @returns {number} Card height.
|
||||
*/
|
||||
const calculateDonutLayoutHeight = (totalLangs) => {
|
||||
return 215 + Math.max(totalLangs - 5, 0) * 32;
|
||||
};
|
||||
|
||||
/**
|
||||
* Calculates height for the donut vertical layout.
|
||||
*
|
||||
* @param {number} totalLangs Total number of languages.
|
||||
* @returns {number} Card height.
|
||||
*/
|
||||
const calculateDonutVerticalLayoutHeight = (totalLangs) => {
|
||||
return 300 + Math.round(totalLangs / 2) * 25;
|
||||
};
|
||||
|
||||
/**
|
||||
* Calculates height for the pie layout.
|
||||
*
|
||||
* @param {number} totalLangs Total number of languages.
|
||||
* @returns {number} Card height.
|
||||
*/
|
||||
const calculatePieLayoutHeight = (totalLangs) => {
|
||||
return 300 + Math.round(totalLangs / 2) * 25;
|
||||
};
|
||||
|
||||
/**
|
||||
* Calculates the center translation needed to keep the donut chart centred.
|
||||
* @param {number} totalLangs Total number of languages.
|
||||
* @returns {number} Donut center translation.
|
||||
*/
|
||||
const donutCenterTranslation = (totalLangs) => {
|
||||
return -45 + Math.max(totalLangs - 5, 0) * 16;
|
||||
};
|
||||
|
||||
/**
|
||||
* Trim top languages to lang_count while also hiding certain languages.
|
||||
*
|
||||
* @param {Record<string, Lang>} topLangs Top languages.
|
||||
* @param {number} langs_count Number of languages to show.
|
||||
* @param {string[]=} hide Languages to hide.
|
||||
* @returns {{ langs: Lang[], totalLanguageSize: number }} Trimmed top languages and total size.
|
||||
*/
|
||||
const trimTopLanguages = (topLangs, langs_count, hide) => {
|
||||
let langs = Object.values(topLangs);
|
||||
let langsToHide = {};
|
||||
let langsCount = clampValue(langs_count, 1, MAXIMUM_LANGS_COUNT);
|
||||
|
||||
// populate langsToHide map for quick lookup
|
||||
// while filtering out
|
||||
if (hide) {
|
||||
hide.forEach((langName) => {
|
||||
// @ts-ignore
|
||||
langsToHide[lowercaseTrim(langName)] = true;
|
||||
});
|
||||
}
|
||||
|
||||
// filter out languages to be hidden
|
||||
langs = langs
|
||||
.sort((a, b) => b.size - a.size)
|
||||
.filter((lang) => {
|
||||
// @ts-ignore
|
||||
return !langsToHide[lowercaseTrim(lang.name)];
|
||||
})
|
||||
.slice(0, langsCount);
|
||||
|
||||
const totalLanguageSize = langs.reduce((acc, curr) => acc + curr.size, 0);
|
||||
|
||||
return { langs, totalLanguageSize };
|
||||
};
|
||||
|
||||
/**
|
||||
* Get display value corresponding to the format.
|
||||
*
|
||||
* @param {number} size Bytes size.
|
||||
* @param {number} percentages Percentage value.
|
||||
* @param {string} format Format of the stats.
|
||||
* @returns {string} Display value.
|
||||
*/
|
||||
const getDisplayValue = (size, percentages, format) => {
|
||||
return format === "bytes" ? formatBytes(size) : `${percentages.toFixed(2)}%`;
|
||||
};
|
||||
|
||||
/**
|
||||
* Create progress bar text item for a programming language.
|
||||
*
|
||||
* @param {object} props Function properties.
|
||||
* @param {number} props.width The card width
|
||||
* @param {string} props.color Color of the programming language.
|
||||
* @param {string} props.name Name of the programming language.
|
||||
* @param {number} props.size Size of the programming language.
|
||||
* @param {number} props.totalSize Total size of all languages.
|
||||
* @param {string} props.statsFormat Stats format.
|
||||
* @param {number} props.index Index of the programming language.
|
||||
* @returns {string} Programming language SVG node.
|
||||
*/
|
||||
const createProgressTextNode = ({
|
||||
width,
|
||||
color,
|
||||
name,
|
||||
size,
|
||||
totalSize,
|
||||
statsFormat,
|
||||
index,
|
||||
}) => {
|
||||
const staggerDelay = (index + 3) * 150;
|
||||
const paddingRight = 95;
|
||||
const progressTextX = width - paddingRight + 10;
|
||||
const progressWidth = width - paddingRight;
|
||||
|
||||
const progress = (size / totalSize) * 100;
|
||||
const displayValue = getDisplayValue(size, progress, statsFormat);
|
||||
|
||||
return `
|
||||
<g class="stagger" style="animation-delay: ${staggerDelay}ms">
|
||||
<text data-testid="lang-name" x="2" y="15" class="lang-name">${name}</text>
|
||||
<text x="${progressTextX}" y="34" class="lang-name">${displayValue}</text>
|
||||
${createProgressNode({
|
||||
x: 0,
|
||||
y: 25,
|
||||
color,
|
||||
width: progressWidth,
|
||||
progress,
|
||||
progressBarBackgroundColor: "#ddd",
|
||||
delay: staggerDelay + 300,
|
||||
})}
|
||||
</g>
|
||||
`;
|
||||
};
|
||||
|
||||
/**
|
||||
* Creates compact text item for a programming language.
|
||||
*
|
||||
* @param {object} props Function properties.
|
||||
* @param {Lang} props.lang Programming language object.
|
||||
* @param {number} props.totalSize Total size of all languages.
|
||||
* @param {boolean=} props.hideProgress Whether to hide percentage.
|
||||
* @param {string=} props.statsFormat Stats format
|
||||
* @param {number} props.index Index of the programming language.
|
||||
* @returns {string} Compact layout programming language SVG node.
|
||||
*/
|
||||
const createCompactLangNode = ({
|
||||
lang,
|
||||
totalSize,
|
||||
hideProgress,
|
||||
statsFormat = "percentages",
|
||||
index,
|
||||
}) => {
|
||||
const percentages = (lang.size / totalSize) * 100;
|
||||
const displayValue = getDisplayValue(lang.size, percentages, statsFormat);
|
||||
|
||||
const staggerDelay = (index + 3) * 150;
|
||||
const color = lang.color || "#858585";
|
||||
|
||||
return `
|
||||
<g class="stagger" style="animation-delay: ${staggerDelay}ms">
|
||||
<circle cx="5" cy="6" r="5" fill="${color}" />
|
||||
<text data-testid="lang-name" x="15" y="10" class='lang-name'>
|
||||
${lang.name} ${hideProgress ? "" : displayValue}
|
||||
</text>
|
||||
</g>
|
||||
`;
|
||||
};
|
||||
|
||||
/**
|
||||
* Create compact languages text items for all programming languages.
|
||||
*
|
||||
* @param {object} props Function properties.
|
||||
* @param {Lang[]} props.langs Array of programming languages.
|
||||
* @param {number} props.totalSize Total size of all languages.
|
||||
* @param {boolean=} props.hideProgress Whether to hide percentage.
|
||||
* @param {string=} props.statsFormat Stats format
|
||||
* @returns {string} Programming languages SVG node.
|
||||
*/
|
||||
const createLanguageTextNode = ({
|
||||
langs,
|
||||
totalSize,
|
||||
hideProgress,
|
||||
statsFormat,
|
||||
}) => {
|
||||
const longestLang = getLongestLang(langs);
|
||||
const chunked = chunkArray(langs, langs.length / 2);
|
||||
const layouts = chunked.map((array) => {
|
||||
// @ts-ignore
|
||||
const items = array.map((lang, index) =>
|
||||
createCompactLangNode({
|
||||
lang,
|
||||
totalSize,
|
||||
hideProgress,
|
||||
statsFormat,
|
||||
index,
|
||||
}),
|
||||
);
|
||||
return flexLayout({
|
||||
items,
|
||||
gap: 25,
|
||||
direction: "column",
|
||||
}).join("");
|
||||
});
|
||||
|
||||
const percent = ((longestLang.size / totalSize) * 100).toFixed(2);
|
||||
const minGap = 150;
|
||||
const maxGap = 20 + measureText(`${longestLang.name} ${percent}%`, 11);
|
||||
return flexLayout({
|
||||
items: layouts,
|
||||
gap: maxGap < minGap ? minGap : maxGap,
|
||||
}).join("");
|
||||
};
|
||||
|
||||
/**
|
||||
* Create donut languages text items for all programming languages.
|
||||
*
|
||||
* @param {object} props Function properties.
|
||||
* @param {Lang[]} props.langs Array of programming languages.
|
||||
* @param {number} props.totalSize Total size of all languages.
|
||||
* @param {string} props.statsFormat Stats format
|
||||
* @returns {string} Donut layout programming language SVG node.
|
||||
*/
|
||||
const createDonutLanguagesNode = ({ langs, totalSize, statsFormat }) => {
|
||||
return flexLayout({
|
||||
items: langs.map((lang, index) => {
|
||||
return createCompactLangNode({
|
||||
lang,
|
||||
totalSize,
|
||||
hideProgress: false,
|
||||
statsFormat,
|
||||
index,
|
||||
});
|
||||
}),
|
||||
gap: 32,
|
||||
direction: "column",
|
||||
}).join("");
|
||||
};
|
||||
|
||||
/**
|
||||
* Renders the default language card layout.
|
||||
*
|
||||
* @param {Lang[]} langs Array of programming languages.
|
||||
* @param {number} width Card width.
|
||||
* @param {number} totalLanguageSize Total size of all languages.
|
||||
* @param {string} statsFormat Stats format.
|
||||
* @returns {string} Normal layout card SVG object.
|
||||
*/
|
||||
const renderNormalLayout = (langs, width, totalLanguageSize, statsFormat) => {
|
||||
return flexLayout({
|
||||
items: langs.map((lang, index) => {
|
||||
return createProgressTextNode({
|
||||
width,
|
||||
name: lang.name,
|
||||
color: lang.color || DEFAULT_LANG_COLOR,
|
||||
size: lang.size,
|
||||
totalSize: totalLanguageSize,
|
||||
statsFormat,
|
||||
index,
|
||||
});
|
||||
}),
|
||||
gap: 40,
|
||||
direction: "column",
|
||||
}).join("");
|
||||
};
|
||||
|
||||
/**
|
||||
* Renders the compact language card layout.
|
||||
*
|
||||
* @param {Lang[]} langs Array of programming languages.
|
||||
* @param {number} width Card width.
|
||||
* @param {number} totalLanguageSize Total size of all languages.
|
||||
* @param {boolean=} hideProgress Whether to hide progress bar.
|
||||
* @param {string} statsFormat Stats format.
|
||||
* @returns {string} Compact layout card SVG object.
|
||||
*/
|
||||
const renderCompactLayout = (
|
||||
langs,
|
||||
width,
|
||||
totalLanguageSize,
|
||||
hideProgress,
|
||||
statsFormat = "percentages",
|
||||
) => {
|
||||
const paddingRight = 50;
|
||||
const offsetWidth = width - paddingRight;
|
||||
// progressOffset holds the previous language's width and used to offset the next language
|
||||
// so that we can stack them one after another, like this: [--][----][---]
|
||||
let progressOffset = 0;
|
||||
const compactProgressBar = langs
|
||||
.map((lang) => {
|
||||
const percentage = parseFloat(
|
||||
((lang.size / totalLanguageSize) * offsetWidth).toFixed(2),
|
||||
);
|
||||
|
||||
const progress = percentage < 10 ? percentage + 10 : percentage;
|
||||
|
||||
const output = `
|
||||
<rect
|
||||
mask="url(#rect-mask)"
|
||||
data-testid="lang-progress"
|
||||
x="${progressOffset}"
|
||||
y="0"
|
||||
width="${progress}"
|
||||
height="8"
|
||||
fill="${lang.color || "#858585"}"
|
||||
/>
|
||||
`;
|
||||
progressOffset += percentage;
|
||||
return output;
|
||||
})
|
||||
.join("");
|
||||
|
||||
return `
|
||||
${
|
||||
hideProgress
|
||||
? ""
|
||||
: `
|
||||
<mask id="rect-mask">
|
||||
<rect x="0" y="0" width="${offsetWidth}" height="8" fill="white" rx="5"/>
|
||||
</mask>
|
||||
${compactProgressBar}
|
||||
`
|
||||
}
|
||||
<g transform="translate(0, ${hideProgress ? "0" : "25"})">
|
||||
${createLanguageTextNode({
|
||||
langs,
|
||||
totalSize: totalLanguageSize,
|
||||
hideProgress,
|
||||
statsFormat,
|
||||
})}
|
||||
</g>
|
||||
`;
|
||||
};
|
||||
|
||||
/**
|
||||
* Renders donut vertical layout to display user's most frequently used programming languages.
|
||||
*
|
||||
* @param {Lang[]} langs Array of programming languages.
|
||||
* @param {number} totalLanguageSize Total size of all languages.
|
||||
* @param {string} statsFormat Stats format.
|
||||
* @returns {string} Compact layout card SVG object.
|
||||
*/
|
||||
const renderDonutVerticalLayout = (langs, totalLanguageSize, statsFormat) => {
|
||||
// Donut vertical chart radius and total length
|
||||
const radius = 80;
|
||||
const totalCircleLength = getCircleLength(radius);
|
||||
|
||||
// SVG circles
|
||||
let circles = [];
|
||||
|
||||
// Start indent for donut vertical chart parts
|
||||
let indent = 0;
|
||||
|
||||
// Start delay coefficient for donut vertical chart parts
|
||||
let startDelayCoefficient = 1;
|
||||
|
||||
// Generate each donut vertical chart part
|
||||
for (const lang of langs) {
|
||||
const percentage = (lang.size / totalLanguageSize) * 100;
|
||||
const circleLength = totalCircleLength * (percentage / 100);
|
||||
const delay = startDelayCoefficient * 100;
|
||||
|
||||
circles.push(`
|
||||
<g class="stagger" style="animation-delay: ${delay}ms">
|
||||
<circle
|
||||
cx="150"
|
||||
cy="100"
|
||||
r="${radius}"
|
||||
fill="transparent"
|
||||
stroke="${lang.color}"
|
||||
stroke-width="25"
|
||||
stroke-dasharray="${totalCircleLength}"
|
||||
stroke-dashoffset="${indent}"
|
||||
size="${percentage}"
|
||||
data-testid="lang-donut"
|
||||
/>
|
||||
</g>
|
||||
`);
|
||||
|
||||
// Update the indent for the next part
|
||||
indent += circleLength;
|
||||
// Update the start delay coefficient for the next part
|
||||
startDelayCoefficient += 1;
|
||||
}
|
||||
|
||||
return `
|
||||
<svg data-testid="lang-items">
|
||||
<g transform="translate(0, 0)">
|
||||
<svg data-testid="donut">
|
||||
${circles.join("")}
|
||||
</svg>
|
||||
</g>
|
||||
<g transform="translate(0, 220)">
|
||||
<svg data-testid="lang-names" x="${CARD_PADDING}">
|
||||
${createLanguageTextNode({
|
||||
langs,
|
||||
totalSize: totalLanguageSize,
|
||||
hideProgress: false,
|
||||
statsFormat,
|
||||
})}
|
||||
</svg>
|
||||
</g>
|
||||
</svg>
|
||||
`;
|
||||
};
|
||||
|
||||
/**
|
||||
* Renders pie layout to display user's most frequently used programming languages.
|
||||
*
|
||||
* @param {Lang[]} langs Array of programming languages.
|
||||
* @param {number} totalLanguageSize Total size of all languages.
|
||||
* @param {string} statsFormat Stats format.
|
||||
* @returns {string} Compact layout card SVG object.
|
||||
*/
|
||||
const renderPieLayout = (langs, totalLanguageSize, statsFormat) => {
|
||||
// Pie chart radius and center coordinates
|
||||
const radius = 90;
|
||||
const centerX = 150;
|
||||
const centerY = 100;
|
||||
|
||||
// Start angle for the pie chart parts
|
||||
let startAngle = 0;
|
||||
|
||||
// Start delay coefficient for the pie chart parts
|
||||
let startDelayCoefficient = 1;
|
||||
|
||||
// SVG paths
|
||||
const paths = [];
|
||||
|
||||
// Generate each pie chart part
|
||||
for (const lang of langs) {
|
||||
if (langs.length === 1) {
|
||||
paths.push(`
|
||||
<circle
|
||||
cx="${centerX}"
|
||||
cy="${centerY}"
|
||||
r="${radius}"
|
||||
stroke="none"
|
||||
fill="${lang.color}"
|
||||
data-testid="lang-pie"
|
||||
size="100"
|
||||
/>
|
||||
`);
|
||||
break;
|
||||
}
|
||||
|
||||
const langSizePart = lang.size / totalLanguageSize;
|
||||
const percentage = langSizePart * 100;
|
||||
// Calculate the angle for the current part
|
||||
const angle = langSizePart * 360;
|
||||
|
||||
// Calculate the end angle
|
||||
const endAngle = startAngle + angle;
|
||||
|
||||
// Calculate the coordinates of the start and end points of the arc
|
||||
const startPoint = polarToCartesian(centerX, centerY, radius, startAngle);
|
||||
const endPoint = polarToCartesian(centerX, centerY, radius, endAngle);
|
||||
|
||||
// Determine the large arc flag based on the angle
|
||||
const largeArcFlag = angle > 180 ? 1 : 0;
|
||||
|
||||
// Calculate delay
|
||||
const delay = startDelayCoefficient * 100;
|
||||
|
||||
// SVG arc markup
|
||||
paths.push(`
|
||||
<g class="stagger" style="animation-delay: ${delay}ms">
|
||||
<path
|
||||
data-testid="lang-pie"
|
||||
size="${percentage}"
|
||||
d="M ${centerX} ${centerY} L ${startPoint.x} ${startPoint.y} A ${radius} ${radius} 0 ${largeArcFlag} 1 ${endPoint.x} ${endPoint.y} Z"
|
||||
fill="${lang.color}"
|
||||
/>
|
||||
</g>
|
||||
`);
|
||||
|
||||
// Update the start angle for the next part
|
||||
startAngle = endAngle;
|
||||
// Update the start delay coefficient for the next part
|
||||
startDelayCoefficient += 1;
|
||||
}
|
||||
|
||||
return `
|
||||
<svg data-testid="lang-items">
|
||||
<g transform="translate(0, 0)">
|
||||
<svg data-testid="pie">
|
||||
${paths.join("")}
|
||||
</svg>
|
||||
</g>
|
||||
<g transform="translate(0, 220)">
|
||||
<svg data-testid="lang-names" x="${CARD_PADDING}">
|
||||
${createLanguageTextNode({
|
||||
langs,
|
||||
totalSize: totalLanguageSize,
|
||||
hideProgress: false,
|
||||
statsFormat,
|
||||
})}
|
||||
</svg>
|
||||
</g>
|
||||
</svg>
|
||||
`;
|
||||
};
|
||||
|
||||
/**
|
||||
* Creates the SVG paths for the language donut chart.
|
||||
*
|
||||
* @param {number} cx Donut center x-position.
|
||||
* @param {number} cy Donut center y-position.
|
||||
* @param {number} radius Donut arc Radius.
|
||||
* @param {number[]} percentages Array with donut section percentages.
|
||||
* @returns {{d: string, percent: number}[]} Array of svg path elements
|
||||
*/
|
||||
const createDonutPaths = (cx, cy, radius, percentages) => {
|
||||
const paths = [];
|
||||
let startAngle = 0;
|
||||
let endAngle = 0;
|
||||
|
||||
const totalPercent = percentages.reduce((acc, curr) => acc + curr, 0);
|
||||
for (let i = 0; i < percentages.length; i++) {
|
||||
const tmpPath = {};
|
||||
|
||||
let percent = parseFloat(
|
||||
((percentages[i] / totalPercent) * 100).toFixed(2),
|
||||
);
|
||||
|
||||
endAngle = 3.6 * percent + startAngle;
|
||||
const startPoint = polarToCartesian(cx, cy, radius, endAngle - 90); // rotate donut 90 degrees counter-clockwise.
|
||||
const endPoint = polarToCartesian(cx, cy, radius, startAngle - 90); // rotate donut 90 degrees counter-clockwise.
|
||||
const largeArc = endAngle - startAngle <= 180 ? 0 : 1;
|
||||
|
||||
tmpPath.percent = percent;
|
||||
tmpPath.d = `M ${startPoint.x} ${startPoint.y} A ${radius} ${radius} 0 ${largeArc} 0 ${endPoint.x} ${endPoint.y}`;
|
||||
|
||||
paths.push(tmpPath);
|
||||
startAngle = endAngle;
|
||||
}
|
||||
|
||||
return paths;
|
||||
};
|
||||
|
||||
/**
|
||||
* Renders the donut language card layout.
|
||||
*
|
||||
* @param {Lang[]} langs Array of programming languages.
|
||||
* @param {number} width Card width.
|
||||
* @param {number} totalLanguageSize Total size of all languages.
|
||||
* @param {string} statsFormat Stats format.
|
||||
* @returns {string} Donut layout card SVG object.
|
||||
*/
|
||||
const renderDonutLayout = (langs, width, totalLanguageSize, statsFormat) => {
|
||||
const centerX = width / 3;
|
||||
const centerY = width / 3;
|
||||
const radius = centerX - 60;
|
||||
const strokeWidth = 12;
|
||||
|
||||
const colors = langs.map((lang) => lang.color);
|
||||
const langsPercents = langs.map((lang) =>
|
||||
parseFloat(((lang.size / totalLanguageSize) * 100).toFixed(2)),
|
||||
);
|
||||
|
||||
const langPaths = createDonutPaths(centerX, centerY, radius, langsPercents);
|
||||
|
||||
const donutPaths =
|
||||
langs.length === 1
|
||||
? `<circle cx="${centerX}" cy="${centerY}" r="${radius}" stroke="${colors[0]}" fill="none" stroke-width="${strokeWidth}" data-testid="lang-donut" size="100"/>`
|
||||
: langPaths
|
||||
.map((section, index) => {
|
||||
const staggerDelay = (index + 3) * 100;
|
||||
const delay = staggerDelay + 300;
|
||||
|
||||
const output = `
|
||||
<g class="stagger" style="animation-delay: ${delay}ms">
|
||||
<path
|
||||
data-testid="lang-donut"
|
||||
size="${section.percent}"
|
||||
d="${section.d}"
|
||||
stroke="${colors[index]}"
|
||||
fill="none"
|
||||
stroke-width="${strokeWidth}">
|
||||
</path>
|
||||
</g>
|
||||
`;
|
||||
|
||||
return output;
|
||||
})
|
||||
.join("");
|
||||
|
||||
const donut = `<svg width="${width}" height="${width}">${donutPaths}</svg>`;
|
||||
|
||||
return `
|
||||
<g transform="translate(0, 0)">
|
||||
<g transform="translate(0, 0)">
|
||||
${createDonutLanguagesNode({ langs, totalSize: totalLanguageSize, statsFormat })}
|
||||
</g>
|
||||
|
||||
<g transform="translate(125, ${donutCenterTranslation(langs.length)})">
|
||||
${donut}
|
||||
</g>
|
||||
</g>
|
||||
`;
|
||||
};
|
||||
|
||||
/**
|
||||
* @typedef {import("./types").TopLangOptions} TopLangOptions
|
||||
* @typedef {TopLangOptions["layout"]} Layout
|
||||
*/
|
||||
|
||||
/**
|
||||
* Creates the no languages data SVG node.
|
||||
*
|
||||
* @param {object} props Object with function properties.
|
||||
* @param {string} props.color No languages data text color.
|
||||
* @param {string} props.text No languages data translated text.
|
||||
* @param {Layout | undefined} props.layout Card layout.
|
||||
* @returns {string} No languages data SVG node string.
|
||||
*/
|
||||
const noLanguagesDataNode = ({ color, text, layout }) => {
|
||||
return `
|
||||
<text x="${
|
||||
layout === "pie" || layout === "donut-vertical" ? CARD_PADDING : 0
|
||||
}" y="11" class="stat bold" fill="${color}">${text}</text>
|
||||
`;
|
||||
};
|
||||
|
||||
/**
|
||||
* Get default languages count for provided card layout.
|
||||
*
|
||||
* @param {object} props Function properties.
|
||||
* @param {Layout=} props.layout Input layout string.
|
||||
* @param {boolean=} props.hide_progress Input hide_progress parameter value.
|
||||
* @returns {number} Default languages count for input layout.
|
||||
*/
|
||||
const getDefaultLanguagesCountByLayout = ({ layout, hide_progress }) => {
|
||||
if (layout === "compact" || hide_progress === true) {
|
||||
return COMPACT_LAYOUT_DEFAULT_LANGS_COUNT;
|
||||
} else if (layout === "donut") {
|
||||
return DONUT_LAYOUT_DEFAULT_LANGS_COUNT;
|
||||
} else if (layout === "donut-vertical") {
|
||||
return DONUT_VERTICAL_LAYOUT_DEFAULT_LANGS_COUNT;
|
||||
} else if (layout === "pie") {
|
||||
return PIE_LAYOUT_DEFAULT_LANGS_COUNT;
|
||||
} else {
|
||||
return NORMAL_LAYOUT_DEFAULT_LANGS_COUNT;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* @typedef {import('../fetchers/types').TopLangData} TopLangData
|
||||
*/
|
||||
|
||||
/**
|
||||
* Renders card that display user's most frequently used programming languages.
|
||||
*
|
||||
* @param {TopLangData} topLangs User's most frequently used programming languages.
|
||||
* @param {Partial<TopLangOptions>} options Card options.
|
||||
* @returns {string} Language card SVG object.
|
||||
*/
|
||||
const renderTopLanguages = (topLangs, options = {}) => {
|
||||
const {
|
||||
hide_title = false,
|
||||
hide_border = false,
|
||||
card_width,
|
||||
title_color,
|
||||
text_color,
|
||||
bg_color,
|
||||
hide,
|
||||
hide_progress,
|
||||
theme,
|
||||
layout,
|
||||
custom_title,
|
||||
locale,
|
||||
langs_count = getDefaultLanguagesCountByLayout({ layout, hide_progress }),
|
||||
border_radius,
|
||||
border_color,
|
||||
disable_animations,
|
||||
stats_format = "percentages",
|
||||
} = options;
|
||||
|
||||
const i18n = new I18n({
|
||||
locale,
|
||||
translations: langCardLocales,
|
||||
});
|
||||
|
||||
const { langs, totalLanguageSize } = trimTopLanguages(
|
||||
topLangs,
|
||||
langs_count,
|
||||
hide,
|
||||
);
|
||||
|
||||
let width = card_width
|
||||
? isNaN(card_width)
|
||||
? DEFAULT_CARD_WIDTH
|
||||
: card_width < MIN_CARD_WIDTH
|
||||
? MIN_CARD_WIDTH
|
||||
: card_width
|
||||
: DEFAULT_CARD_WIDTH;
|
||||
let height = calculateNormalLayoutHeight(langs.length);
|
||||
|
||||
// returns theme based colors with proper overrides and defaults
|
||||
const colors = getCardColors({
|
||||
title_color,
|
||||
text_color,
|
||||
bg_color,
|
||||
border_color,
|
||||
theme,
|
||||
});
|
||||
|
||||
let finalLayout = "";
|
||||
if (langs.length === 0) {
|
||||
height = COMPACT_LAYOUT_BASE_HEIGHT;
|
||||
finalLayout = noLanguagesDataNode({
|
||||
color: colors.textColor,
|
||||
text: i18n.t("langcard.nodata"),
|
||||
layout,
|
||||
});
|
||||
} else if (layout === "pie") {
|
||||
height = calculatePieLayoutHeight(langs.length);
|
||||
finalLayout = renderPieLayout(langs, totalLanguageSize, stats_format);
|
||||
} else if (layout === "donut-vertical") {
|
||||
height = calculateDonutVerticalLayoutHeight(langs.length);
|
||||
finalLayout = renderDonutVerticalLayout(
|
||||
langs,
|
||||
totalLanguageSize,
|
||||
stats_format,
|
||||
);
|
||||
} else if (layout === "compact" || hide_progress == true) {
|
||||
height =
|
||||
calculateCompactLayoutHeight(langs.length) + (hide_progress ? -25 : 0);
|
||||
|
||||
finalLayout = renderCompactLayout(
|
||||
langs,
|
||||
width,
|
||||
totalLanguageSize,
|
||||
hide_progress,
|
||||
stats_format,
|
||||
);
|
||||
} else if (layout === "donut") {
|
||||
height = calculateDonutLayoutHeight(langs.length);
|
||||
width = width + 50; // padding
|
||||
finalLayout = renderDonutLayout(
|
||||
langs,
|
||||
width,
|
||||
totalLanguageSize,
|
||||
stats_format,
|
||||
);
|
||||
} else {
|
||||
finalLayout = renderNormalLayout(
|
||||
langs,
|
||||
width,
|
||||
totalLanguageSize,
|
||||
stats_format,
|
||||
);
|
||||
}
|
||||
|
||||
const card = new Card({
|
||||
customTitle: custom_title,
|
||||
defaultTitle: i18n.t("langcard.title"),
|
||||
width,
|
||||
height,
|
||||
border_radius,
|
||||
colors,
|
||||
});
|
||||
|
||||
if (disable_animations) {
|
||||
card.disableAnimations();
|
||||
}
|
||||
|
||||
card.setHideBorder(hide_border);
|
||||
card.setHideTitle(hide_title);
|
||||
card.setCSS(
|
||||
`
|
||||
@keyframes slideInAnimation {
|
||||
from {
|
||||
width: 0;
|
||||
}
|
||||
to {
|
||||
width: calc(100%-100px);
|
||||
}
|
||||
}
|
||||
@keyframes growWidthAnimation {
|
||||
from {
|
||||
width: 0;
|
||||
}
|
||||
to {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
.stat {
|
||||
font: 600 14px 'Segoe UI', Ubuntu, "Helvetica Neue", Sans-Serif; fill: ${colors.textColor};
|
||||
}
|
||||
@supports(-moz-appearance: auto) {
|
||||
/* Selector detects Firefox */
|
||||
.stat { font-size:12px; }
|
||||
}
|
||||
.bold { font-weight: 700 }
|
||||
.lang-name {
|
||||
font: 400 11px "Segoe UI", Ubuntu, Sans-Serif;
|
||||
fill: ${colors.textColor};
|
||||
}
|
||||
.stagger {
|
||||
opacity: 0;
|
||||
animation: fadeInAnimation 0.3s ease-in-out forwards;
|
||||
}
|
||||
#rect-mask rect{
|
||||
animation: slideInAnimation 1s ease-in-out forwards;
|
||||
}
|
||||
.lang-progress{
|
||||
animation: growWidthAnimation 0.6s ease-in-out forwards;
|
||||
}
|
||||
`,
|
||||
);
|
||||
|
||||
if (layout === "pie" || layout === "donut-vertical") {
|
||||
return card.render(finalLayout);
|
||||
}
|
||||
|
||||
return card.render(`
|
||||
<svg data-testid="lang-items" x="${CARD_PADDING}">
|
||||
${finalLayout}
|
||||
</svg>
|
||||
`);
|
||||
};
|
||||
|
||||
export {
|
||||
getLongestLang,
|
||||
degreesToRadians,
|
||||
radiansToDegrees,
|
||||
polarToCartesian,
|
||||
cartesianToPolar,
|
||||
getCircleLength,
|
||||
calculateCompactLayoutHeight,
|
||||
calculateNormalLayoutHeight,
|
||||
calculateDonutLayoutHeight,
|
||||
calculateDonutVerticalLayoutHeight,
|
||||
calculatePieLayoutHeight,
|
||||
donutCenterTranslation,
|
||||
trimTopLanguages,
|
||||
renderTopLanguages,
|
||||
MIN_CARD_WIDTH,
|
||||
getDefaultLanguagesCountByLayout,
|
||||
};
|
||||
Vendored
+74
@@ -0,0 +1,74 @@
|
||||
type ThemeNames = keyof typeof import("../../themes/index.js");
|
||||
type RankIcon = "default" | "github" | "percentile";
|
||||
|
||||
export type CommonOptions = {
|
||||
title_color: string;
|
||||
icon_color: string;
|
||||
text_color: string;
|
||||
bg_color: string;
|
||||
theme: ThemeNames;
|
||||
border_radius: number;
|
||||
border_color: string;
|
||||
locale: string;
|
||||
hide_border: boolean;
|
||||
};
|
||||
|
||||
export type StatCardOptions = CommonOptions & {
|
||||
hide: string[];
|
||||
show_icons: boolean;
|
||||
hide_title: boolean;
|
||||
card_width: number;
|
||||
hide_rank: boolean;
|
||||
include_all_commits: boolean;
|
||||
commits_year: number;
|
||||
line_height: number | string;
|
||||
custom_title: string;
|
||||
disable_animations: boolean;
|
||||
number_format: string;
|
||||
number_precision: number;
|
||||
ring_color: string;
|
||||
text_bold: boolean;
|
||||
rank_icon: RankIcon;
|
||||
show: string[];
|
||||
};
|
||||
|
||||
export type RepoCardOptions = CommonOptions & {
|
||||
show_owner: boolean;
|
||||
description_lines_count: number;
|
||||
card_width_input;
|
||||
show: string[];
|
||||
show_icons: boolean;
|
||||
number_format: string;
|
||||
text_bold: boolean;
|
||||
line_height: number | string;
|
||||
username;
|
||||
};
|
||||
|
||||
export type TopLangOptions = CommonOptions & {
|
||||
hide_title: boolean;
|
||||
card_width: number;
|
||||
hide: string[];
|
||||
layout: "compact" | "normal" | "donut" | "donut-vertical" | "pie";
|
||||
custom_title: string;
|
||||
langs_count: number;
|
||||
disable_animations: boolean;
|
||||
hide_progress: boolean;
|
||||
stats_format: "percentages" | "bytes";
|
||||
};
|
||||
|
||||
export type WakaTimeOptions = CommonOptions & {
|
||||
hide_title: boolean;
|
||||
hide: string[];
|
||||
card_width: number;
|
||||
line_height: string;
|
||||
hide_progress: boolean;
|
||||
custom_title: string;
|
||||
layout: "compact" | "normal";
|
||||
langs_count: number;
|
||||
display_format: "time" | "percent";
|
||||
disable_animations: boolean;
|
||||
};
|
||||
|
||||
export type GistCardOptions = CommonOptions & {
|
||||
show_owner: boolean;
|
||||
};
|
||||
@@ -0,0 +1,471 @@
|
||||
// @ts-check
|
||||
|
||||
import { Card } from "../common/Card.js";
|
||||
import { getCardColors } from "../common/color.js";
|
||||
import { I18n } from "../common/I18n.js";
|
||||
import { clampValue, lowercaseTrim } from "../common/ops.js";
|
||||
import { createProgressNode, flexLayout } from "../common/render.js";
|
||||
import { wakatimeCardLocales } from "../translations.js";
|
||||
import languageColors from "../common/languageColors.json" with { type: "json" };
|
||||
|
||||
const DEFAULT_CARD_WIDTH = 495;
|
||||
const MIN_CARD_WIDTH = 250;
|
||||
const COMPACT_LAYOUT_MIN_WIDTH = 400;
|
||||
const DEFAULT_LINE_HEIGHT = 25;
|
||||
const PROGRESSBAR_PADDING = 130;
|
||||
const HIDDEN_PROGRESSBAR_PADDING = 170;
|
||||
const COMPACT_LAYOUT_PROGRESSBAR_PADDING = 25;
|
||||
const TOTAL_TEXT_WIDTH = 275;
|
||||
|
||||
/**
|
||||
* Creates the no coding activity SVG node.
|
||||
*
|
||||
* @param {object} props The function properties.
|
||||
* @param {string} props.color No coding activity text color.
|
||||
* @param {string} props.text No coding activity translated text.
|
||||
* @returns {string} No coding activity SVG node string.
|
||||
*/
|
||||
const noCodingActivityNode = ({ color, text }) => {
|
||||
return `
|
||||
<text x="25" y="11" class="stat bold" fill="${color}">${text}</text>
|
||||
`;
|
||||
};
|
||||
|
||||
/**
|
||||
* @typedef {import('../fetchers/types').WakaTimeLang} WakaTimeLang
|
||||
*/
|
||||
|
||||
/**
|
||||
* Format language value.
|
||||
*
|
||||
* @param {Object} args The function arguments.
|
||||
* @param {WakaTimeLang} args.lang The language object.
|
||||
* @param {"time" | "percent"} args.display_format The display format of the language node.
|
||||
* @returns {string} The formatted language value.
|
||||
*/
|
||||
const formatLanguageValue = ({ display_format, lang }) => {
|
||||
return display_format === "percent"
|
||||
? `${lang.percent.toFixed(2).toString()} %`
|
||||
: lang.text;
|
||||
};
|
||||
|
||||
/**
|
||||
* Create compact WakaTime layout.
|
||||
*
|
||||
* @param {Object} args The function arguments.
|
||||
* @param {WakaTimeLang} args.lang The languages array.
|
||||
* @param {number} args.x The x position of the language node.
|
||||
* @param {number} args.y The y position of the language node.
|
||||
* @param {"time" | "percent"} args.display_format The display format of the language node.
|
||||
* @returns {string} The compact layout language SVG node.
|
||||
*/
|
||||
const createCompactLangNode = ({ lang, x, y, display_format }) => {
|
||||
// @ts-ignore
|
||||
const color = languageColors[lang.name] || "#858585";
|
||||
const value = formatLanguageValue({ display_format, lang });
|
||||
|
||||
return `
|
||||
<g transform="translate(${x}, ${y})">
|
||||
<circle cx="5" cy="6" r="5" fill="${color}" />
|
||||
<text data-testid="lang-name" x="15" y="10" class='lang-name'>
|
||||
${lang.name} - ${value}
|
||||
</text>
|
||||
</g>
|
||||
`;
|
||||
};
|
||||
|
||||
/**
|
||||
* Create WakaTime language text node item.
|
||||
*
|
||||
* @param {Object} args The function arguments.
|
||||
* @param {WakaTimeLang[]} args.langs The language objects.
|
||||
* @param {number} args.y The y position of the language node.
|
||||
* @param {"time" | "percent"} args.display_format The display format of the language node.
|
||||
* @param {number} args.card_width Width in px of the card.
|
||||
* @returns {string[]} The language text node items.
|
||||
*/
|
||||
const createLanguageTextNode = ({ langs, y, display_format, card_width }) => {
|
||||
const LEFT_X = 25;
|
||||
const RIGHT_X_BASE = 230;
|
||||
const rightOffset = (card_width - DEFAULT_CARD_WIDTH) / 2;
|
||||
const RIGHT_X = RIGHT_X_BASE + rightOffset;
|
||||
|
||||
return langs.map((lang, index) => {
|
||||
const isLeft = index % 2 === 0;
|
||||
return createCompactLangNode({
|
||||
lang,
|
||||
x: isLeft ? LEFT_X : RIGHT_X,
|
||||
y: y + DEFAULT_LINE_HEIGHT * Math.floor(index / 2),
|
||||
display_format,
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Create WakaTime text item.
|
||||
*
|
||||
* @param {Object} args The function arguments.
|
||||
* @param {string} args.id The id of the text node item.
|
||||
* @param {string} args.label The label of the text node item.
|
||||
* @param {string} args.value The value of the text node item.
|
||||
* @param {number} args.index The index of the text node item.
|
||||
* @param {number} args.percent Percentage of the text node item.
|
||||
* @param {boolean=} args.hideProgress Whether to hide the progress bar.
|
||||
* @param {string} args.progressBarColor The color of the progress bar.
|
||||
* @param {string} args.progressBarBackgroundColor The color of the progress bar background.
|
||||
* @param {number} args.progressBarWidth The width of the progress bar.
|
||||
* @returns {string} The text SVG node.
|
||||
*/
|
||||
const createTextNode = ({
|
||||
id,
|
||||
label,
|
||||
value,
|
||||
index,
|
||||
percent,
|
||||
hideProgress,
|
||||
progressBarColor,
|
||||
progressBarBackgroundColor,
|
||||
progressBarWidth,
|
||||
}) => {
|
||||
const staggerDelay = (index + 3) * 150;
|
||||
const cardProgress = hideProgress
|
||||
? null
|
||||
: createProgressNode({
|
||||
x: 110,
|
||||
y: 4,
|
||||
progress: percent,
|
||||
color: progressBarColor,
|
||||
width: progressBarWidth,
|
||||
// @ts-ignore
|
||||
name: label,
|
||||
progressBarBackgroundColor,
|
||||
delay: staggerDelay + 300,
|
||||
});
|
||||
|
||||
return `
|
||||
<g class="stagger" style="animation-delay: ${staggerDelay}ms" transform="translate(25, 0)">
|
||||
<text class="stat bold" y="12.5" data-testid="${id}">${label}:</text>
|
||||
<text
|
||||
class="stat"
|
||||
x="${hideProgress ? HIDDEN_PROGRESSBAR_PADDING : PROGRESSBAR_PADDING + progressBarWidth}"
|
||||
y="12.5"
|
||||
>${value}</text>
|
||||
${cardProgress}
|
||||
</g>
|
||||
`;
|
||||
};
|
||||
|
||||
/**
|
||||
* Recalculating percentages so that, compact layout's progress bar does not break when
|
||||
* hiding languages.
|
||||
*
|
||||
* @param {WakaTimeLang[]} languages The languages array.
|
||||
* @returns {void} The recalculated languages array.
|
||||
*/
|
||||
const recalculatePercentages = (languages) => {
|
||||
const totalSum = languages.reduce(
|
||||
(totalSum, language) => totalSum + language.percent,
|
||||
0,
|
||||
);
|
||||
const weight = +(100 / totalSum).toFixed(2);
|
||||
languages.forEach((language) => {
|
||||
language.percent = +(language.percent * weight).toFixed(2);
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Retrieves CSS styles for a card.
|
||||
*
|
||||
* @param {Object} colors The colors to use for the card.
|
||||
* @param {string} colors.titleColor The title color.
|
||||
* @param {string} colors.textColor The text color.
|
||||
* @returns {string} Card CSS styles.
|
||||
*/
|
||||
const getStyles = ({
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
titleColor,
|
||||
textColor,
|
||||
}) => {
|
||||
return `
|
||||
.stat {
|
||||
font: 600 14px 'Segoe UI', Ubuntu, "Helvetica Neue", Sans-Serif; fill: ${textColor};
|
||||
}
|
||||
@supports(-moz-appearance: auto) {
|
||||
/* Selector detects Firefox */
|
||||
.stat { font-size:12px; }
|
||||
}
|
||||
.stagger {
|
||||
opacity: 0;
|
||||
animation: fadeInAnimation 0.3s ease-in-out forwards;
|
||||
}
|
||||
.not_bold { font-weight: 400 }
|
||||
.bold { font-weight: 700 }
|
||||
`;
|
||||
};
|
||||
|
||||
/**
|
||||
* Normalize incoming width (string or number) and clamp to minimum.
|
||||
*
|
||||
* @param {Object} args The function arguments.
|
||||
* @param {WakaTimeOptions["layout"] | undefined} args.layout The incoming layout value.
|
||||
* @param {number|undefined} args.value The incoming width value.
|
||||
* @returns {number} The normalized width value.
|
||||
*/
|
||||
const normalizeCardWidth = ({ value, layout }) => {
|
||||
if (value === undefined || value === null || isNaN(value)) {
|
||||
return DEFAULT_CARD_WIDTH;
|
||||
}
|
||||
return Math.max(
|
||||
layout === "compact" ? COMPACT_LAYOUT_MIN_WIDTH : MIN_CARD_WIDTH,
|
||||
value,
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* @typedef {import('../fetchers/types').WakaTimeData} WakaTimeData
|
||||
* @typedef {import('./types').WakaTimeOptions} WakaTimeOptions
|
||||
*/
|
||||
|
||||
/**
|
||||
* Renders WakaTime card.
|
||||
*
|
||||
* @param {Partial<WakaTimeData>} stats WakaTime stats.
|
||||
* @param {Partial<WakaTimeOptions>} options Card options.
|
||||
* @returns {string} WakaTime card SVG.
|
||||
*/
|
||||
const renderWakatimeCard = (stats = {}, options = { hide: [] }) => {
|
||||
let { languages = [] } = stats;
|
||||
const {
|
||||
hide_title = false,
|
||||
hide_border = false,
|
||||
card_width,
|
||||
hide,
|
||||
line_height = DEFAULT_LINE_HEIGHT,
|
||||
title_color,
|
||||
icon_color,
|
||||
text_color,
|
||||
bg_color,
|
||||
theme = "default",
|
||||
hide_progress,
|
||||
custom_title,
|
||||
locale,
|
||||
layout,
|
||||
langs_count = languages.length,
|
||||
border_radius,
|
||||
border_color,
|
||||
display_format = "time",
|
||||
disable_animations,
|
||||
} = options;
|
||||
|
||||
const normalizedWidth = normalizeCardWidth({ value: card_width, layout });
|
||||
|
||||
const shouldHideLangs = Array.isArray(hide) && hide.length > 0;
|
||||
if (shouldHideLangs) {
|
||||
const languagesToHide = new Set(hide.map((lang) => lowercaseTrim(lang)));
|
||||
languages = languages.filter(
|
||||
(lang) => !languagesToHide.has(lowercaseTrim(lang.name)),
|
||||
);
|
||||
}
|
||||
|
||||
// Since the percentages are sorted in descending order, we can just
|
||||
// slice from the beginning without sorting.
|
||||
languages = languages.slice(0, langs_count);
|
||||
recalculatePercentages(languages);
|
||||
|
||||
const i18n = new I18n({
|
||||
locale,
|
||||
translations: wakatimeCardLocales,
|
||||
});
|
||||
|
||||
const lheight = parseInt(String(line_height), 10);
|
||||
|
||||
const langsCount = clampValue(langs_count, 1, langs_count);
|
||||
|
||||
// returns theme based colors with proper overrides and defaults
|
||||
const { titleColor, textColor, iconColor, bgColor, borderColor } =
|
||||
getCardColors({
|
||||
title_color,
|
||||
icon_color,
|
||||
text_color,
|
||||
bg_color,
|
||||
border_color,
|
||||
theme,
|
||||
});
|
||||
|
||||
const filteredLanguages = languages
|
||||
.filter((language) => language.hours || language.minutes)
|
||||
.slice(0, langsCount);
|
||||
|
||||
// Calculate the card height depending on how many items there are
|
||||
// but if rank circle is visible clamp the minimum height to `150`
|
||||
let height = Math.max(45 + (filteredLanguages.length + 1) * lheight, 150);
|
||||
|
||||
const cssStyles = getStyles({
|
||||
titleColor,
|
||||
textColor,
|
||||
});
|
||||
|
||||
let finalLayout = "";
|
||||
|
||||
// RENDER COMPACT LAYOUT
|
||||
if (layout === "compact") {
|
||||
const width = normalizedWidth - 5;
|
||||
height =
|
||||
90 + Math.round(filteredLanguages.length / 2) * DEFAULT_LINE_HEIGHT;
|
||||
|
||||
// progressOffset holds the previous language's width and used to offset the next language
|
||||
// so that we can stack them one after another, like this: [--][----][---]
|
||||
let progressOffset = 0;
|
||||
const compactProgressBar = filteredLanguages
|
||||
.map((language) => {
|
||||
const progress =
|
||||
((width - COMPACT_LAYOUT_PROGRESSBAR_PADDING) * language.percent) /
|
||||
100;
|
||||
|
||||
// @ts-ignore
|
||||
const languageColor = languageColors[language.name] || "#858585";
|
||||
|
||||
const output = `
|
||||
<rect
|
||||
mask="url(#rect-mask)"
|
||||
data-testid="lang-progress"
|
||||
x="${progressOffset}"
|
||||
y="0"
|
||||
width="${progress}"
|
||||
height="8"
|
||||
fill="${languageColor}"
|
||||
/>
|
||||
`;
|
||||
progressOffset += progress;
|
||||
return output;
|
||||
})
|
||||
.join("");
|
||||
|
||||
finalLayout = `
|
||||
<mask id="rect-mask">
|
||||
<rect x="${COMPACT_LAYOUT_PROGRESSBAR_PADDING}" y="0" width="${width - 2 * COMPACT_LAYOUT_PROGRESSBAR_PADDING}" height="8" fill="white" rx="5" />
|
||||
</mask>
|
||||
${compactProgressBar}
|
||||
${
|
||||
filteredLanguages.length
|
||||
? createLanguageTextNode({
|
||||
y: 25,
|
||||
langs: filteredLanguages,
|
||||
display_format,
|
||||
card_width: normalizedWidth,
|
||||
}).join("")
|
||||
: noCodingActivityNode({
|
||||
// @ts-ignore
|
||||
color: textColor,
|
||||
text: stats.is_coding_activity_visible
|
||||
? stats.is_other_usage_visible
|
||||
? i18n.t("wakatimecard.nocodingactivity")
|
||||
: i18n.t("wakatimecard.nocodedetails")
|
||||
: i18n.t("wakatimecard.notpublic"),
|
||||
})
|
||||
}
|
||||
`;
|
||||
} else {
|
||||
finalLayout = flexLayout({
|
||||
items: filteredLanguages.length
|
||||
? filteredLanguages.map((language, index) => {
|
||||
return createTextNode({
|
||||
id: language.name,
|
||||
label: language.name,
|
||||
value: formatLanguageValue({ display_format, lang: language }),
|
||||
index,
|
||||
percent: language.percent,
|
||||
// @ts-ignore
|
||||
progressBarColor: titleColor,
|
||||
// @ts-ignore
|
||||
progressBarBackgroundColor: textColor === titleColor ? bgColor : textColor,
|
||||
hideProgress: hide_progress,
|
||||
progressBarWidth: normalizedWidth - TOTAL_TEXT_WIDTH,
|
||||
});
|
||||
})
|
||||
: [
|
||||
noCodingActivityNode({
|
||||
// @ts-ignore
|
||||
color: textColor,
|
||||
text: stats.is_coding_activity_visible
|
||||
? stats.is_other_usage_visible
|
||||
? i18n.t("wakatimecard.nocodingactivity")
|
||||
: i18n.t("wakatimecard.nocodedetails")
|
||||
: i18n.t("wakatimecard.notpublic"),
|
||||
}),
|
||||
],
|
||||
gap: lheight,
|
||||
direction: "column",
|
||||
}).join("");
|
||||
}
|
||||
|
||||
// Get title range text
|
||||
let titleText = i18n.t("wakatimecard.title");
|
||||
switch (stats.range) {
|
||||
case "last_7_days":
|
||||
titleText += ` (${i18n.t("wakatimecard.last7days")})`;
|
||||
break;
|
||||
case "last_year":
|
||||
titleText += ` (${i18n.t("wakatimecard.lastyear")})`;
|
||||
break;
|
||||
}
|
||||
|
||||
const card = new Card({
|
||||
customTitle: custom_title,
|
||||
defaultTitle: titleText,
|
||||
width: normalizedWidth,
|
||||
height,
|
||||
border_radius,
|
||||
colors: {
|
||||
titleColor,
|
||||
textColor,
|
||||
iconColor,
|
||||
bgColor,
|
||||
borderColor,
|
||||
},
|
||||
});
|
||||
|
||||
if (disable_animations) {
|
||||
card.disableAnimations();
|
||||
}
|
||||
|
||||
card.setHideBorder(hide_border);
|
||||
card.setHideTitle(hide_title);
|
||||
card.setCSS(
|
||||
`
|
||||
${cssStyles}
|
||||
@keyframes slideInAnimation {
|
||||
from {
|
||||
width: 0;
|
||||
}
|
||||
to {
|
||||
width: calc(100%-100px);
|
||||
}
|
||||
}
|
||||
@keyframes growWidthAnimation {
|
||||
from {
|
||||
width: 0;
|
||||
}
|
||||
to {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
.lang-name { font: 400 11px 'Segoe UI', Ubuntu, Sans-Serif; fill: ${textColor} }
|
||||
#rect-mask rect{
|
||||
animation: slideInAnimation 1s ease-in-out forwards;
|
||||
}
|
||||
.lang-progress{
|
||||
animation: growWidthAnimation 0.6s ease-in-out forwards;
|
||||
}
|
||||
`,
|
||||
);
|
||||
|
||||
return card.render(`
|
||||
<svg x="0" y="0" width="100%">
|
||||
${finalLayout}
|
||||
</svg>
|
||||
`);
|
||||
};
|
||||
|
||||
export { renderWakatimeCard };
|
||||
export default renderWakatimeCard;
|
||||
@@ -0,0 +1,275 @@
|
||||
// @ts-check
|
||||
|
||||
import { encodeHTML } from "./html.js";
|
||||
import { flexLayout } from "./render.js";
|
||||
|
||||
class Card {
|
||||
/**
|
||||
* Creates a new card instance.
|
||||
*
|
||||
* @param {object} args Card arguments.
|
||||
* @param {number=} args.width Card width.
|
||||
* @param {number=} args.height Card height.
|
||||
* @param {number=} args.border_radius Card border radius.
|
||||
* @param {string=} args.customTitle Card custom title.
|
||||
* @param {string=} args.defaultTitle Card default title.
|
||||
* @param {string=} args.titlePrefixIcon Card title prefix icon.
|
||||
* @param {object} [args.colors={}] Card colors arguments.
|
||||
* @param {string=} args.colors.titleColor Card title color.
|
||||
* @param {string=} args.colors.textColor Card text color.
|
||||
* @param {string=} args.colors.iconColor Card icon color.
|
||||
* @param {string|string[]=} args.colors.bgColor Card background color.
|
||||
* @param {string=} args.colors.borderColor Card border color.
|
||||
*/
|
||||
constructor({
|
||||
width = 100,
|
||||
height = 100,
|
||||
border_radius = 4.5,
|
||||
colors = {},
|
||||
customTitle,
|
||||
defaultTitle = "",
|
||||
titlePrefixIcon,
|
||||
}) {
|
||||
this.width = width;
|
||||
this.height = height;
|
||||
|
||||
this.hideBorder = false;
|
||||
this.hideTitle = false;
|
||||
|
||||
this.border_radius = border_radius;
|
||||
|
||||
// returns theme based colors with proper overrides and defaults
|
||||
this.colors = colors;
|
||||
this.title =
|
||||
customTitle === undefined
|
||||
? encodeHTML(defaultTitle)
|
||||
: encodeHTML(customTitle);
|
||||
|
||||
this.css = "";
|
||||
|
||||
this.paddingX = 25;
|
||||
this.paddingY = 35;
|
||||
this.titlePrefixIcon = titlePrefixIcon;
|
||||
this.animations = true;
|
||||
this.a11yTitle = "";
|
||||
this.a11yDesc = "";
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {void}
|
||||
*/
|
||||
disableAnimations() {
|
||||
this.animations = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Object} props The props object.
|
||||
* @param {string} props.title Accessibility title.
|
||||
* @param {string} props.desc Accessibility description.
|
||||
* @returns {void}
|
||||
*/
|
||||
setAccessibilityLabel({ title, desc }) {
|
||||
this.a11yTitle = title;
|
||||
this.a11yDesc = desc;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} value The CSS to add to the card.
|
||||
* @returns {void}
|
||||
*/
|
||||
setCSS(value) {
|
||||
this.css = value;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {boolean} value Whether to hide the border or not.
|
||||
* @returns {void}
|
||||
*/
|
||||
setHideBorder(value) {
|
||||
this.hideBorder = value;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {boolean} value Whether to hide the title or not.
|
||||
* @returns {void}
|
||||
*/
|
||||
setHideTitle(value) {
|
||||
this.hideTitle = value;
|
||||
if (value) {
|
||||
this.height -= 30;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} text The title to set.
|
||||
* @returns {void}
|
||||
*/
|
||||
setTitle(text) {
|
||||
this.title = text;
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {string} The rendered card title.
|
||||
*/
|
||||
renderTitle() {
|
||||
const titleText = `
|
||||
<text
|
||||
x="0"
|
||||
y="0"
|
||||
class="header"
|
||||
data-testid="header"
|
||||
>${this.title}</text>
|
||||
`;
|
||||
|
||||
const prefixIcon = `
|
||||
<svg
|
||||
class="icon"
|
||||
x="0"
|
||||
y="-13"
|
||||
viewBox="0 0 16 16"
|
||||
version="1.1"
|
||||
width="16"
|
||||
height="16"
|
||||
>
|
||||
${this.titlePrefixIcon}
|
||||
</svg>
|
||||
`;
|
||||
return `
|
||||
<g
|
||||
data-testid="card-title"
|
||||
transform="translate(${this.paddingX}, ${this.paddingY})"
|
||||
>
|
||||
${flexLayout({
|
||||
items: [this.titlePrefixIcon ? prefixIcon : "", titleText],
|
||||
gap: 25,
|
||||
}).join("")}
|
||||
</g>
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {string} The rendered card gradient.
|
||||
*/
|
||||
renderGradient() {
|
||||
if (typeof this.colors.bgColor !== "object") {
|
||||
return "";
|
||||
}
|
||||
|
||||
const gradients = this.colors.bgColor.slice(1);
|
||||
return typeof this.colors.bgColor === "object"
|
||||
? `
|
||||
<defs>
|
||||
<linearGradient
|
||||
id="gradient"
|
||||
gradientTransform="rotate(${this.colors.bgColor[0]})"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
${gradients.map((grad, index) => {
|
||||
let offset = (index * 100) / (gradients.length - 1);
|
||||
return `<stop offset="${offset}%" stop-color="#${grad}" />`;
|
||||
})}
|
||||
</linearGradient>
|
||||
</defs>
|
||||
`
|
||||
: "";
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves css animations for a card.
|
||||
*
|
||||
* @returns {string} Animation css.
|
||||
*/
|
||||
getAnimations = () => {
|
||||
return `
|
||||
/* Animations */
|
||||
@keyframes scaleInAnimation {
|
||||
from {
|
||||
transform: translate(-5px, 5px) scale(0);
|
||||
}
|
||||
to {
|
||||
transform: translate(-5px, 5px) scale(1);
|
||||
}
|
||||
}
|
||||
@keyframes fadeInAnimation {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
`;
|
||||
};
|
||||
|
||||
/**
|
||||
* @param {string} body The inner body of the card.
|
||||
* @returns {string} The rendered card.
|
||||
*/
|
||||
render(body) {
|
||||
return `
|
||||
<svg
|
||||
width="${this.width}"
|
||||
height="${this.height}"
|
||||
viewBox="0 0 ${this.width} ${this.height}"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
role="img"
|
||||
aria-labelledby="descId"
|
||||
>
|
||||
<title id="titleId">${this.a11yTitle}</title>
|
||||
<desc id="descId">${this.a11yDesc}</desc>
|
||||
<style>
|
||||
.header {
|
||||
font: 600 18px 'Segoe UI', Ubuntu, Sans-Serif;
|
||||
fill: ${this.colors.titleColor};
|
||||
animation: fadeInAnimation 0.8s ease-in-out forwards;
|
||||
}
|
||||
@supports(-moz-appearance: auto) {
|
||||
/* Selector detects Firefox */
|
||||
.header { font-size: 15.5px; }
|
||||
}
|
||||
${this.css}
|
||||
|
||||
${process.env.NODE_ENV === "test" ? "" : this.getAnimations()}
|
||||
${
|
||||
this.animations === false
|
||||
? `* { animation-duration: 0s !important; animation-delay: 0s !important; }`
|
||||
: ""
|
||||
}
|
||||
</style>
|
||||
|
||||
${this.renderGradient()}
|
||||
|
||||
<rect
|
||||
data-testid="card-bg"
|
||||
x="0.5"
|
||||
y="0.5"
|
||||
rx="${this.border_radius}"
|
||||
height="99%"
|
||||
stroke="${this.colors.borderColor}"
|
||||
width="${this.width - 1}"
|
||||
fill="${
|
||||
typeof this.colors.bgColor === "object"
|
||||
? "url(#gradient)"
|
||||
: this.colors.bgColor
|
||||
}"
|
||||
stroke-opacity="${this.hideBorder ? 0 : 1}"
|
||||
/>
|
||||
|
||||
${this.hideTitle ? "" : this.renderTitle()}
|
||||
|
||||
<g
|
||||
data-testid="main-card-body"
|
||||
transform="translate(0, ${
|
||||
this.hideTitle ? this.paddingX : this.paddingY + 20
|
||||
})"
|
||||
>
|
||||
${body}
|
||||
</g>
|
||||
</svg>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
export { Card };
|
||||
export default Card;
|
||||
@@ -0,0 +1,43 @@
|
||||
// @ts-check
|
||||
|
||||
const FALLBACK_LOCALE = "en";
|
||||
|
||||
/**
|
||||
* I18n translation class.
|
||||
*/
|
||||
class I18n {
|
||||
/**
|
||||
* Constructor.
|
||||
*
|
||||
* @param {Object} options Options.
|
||||
* @param {string=} options.locale Locale.
|
||||
* @param {any} options.translations Translations.
|
||||
*/
|
||||
constructor({ locale, translations }) {
|
||||
this.locale = locale || FALLBACK_LOCALE;
|
||||
this.translations = translations;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get translation.
|
||||
*
|
||||
* @param {string} str String to translate.
|
||||
* @returns {string} Translated string.
|
||||
*/
|
||||
t(str) {
|
||||
if (!this.translations[str]) {
|
||||
throw new Error(`${str} Translation string not found`);
|
||||
}
|
||||
|
||||
if (!this.translations[str][this.locale]) {
|
||||
throw new Error(
|
||||
`'${str}' translation not found for locale '${this.locale}'`,
|
||||
);
|
||||
}
|
||||
|
||||
return this.translations[str][this.locale];
|
||||
}
|
||||
}
|
||||
|
||||
export { I18n };
|
||||
export default I18n;
|
||||
@@ -0,0 +1,69 @@
|
||||
// @ts-check
|
||||
|
||||
import { renderError } from "./render.js";
|
||||
import { blacklist } from "./blacklist.js";
|
||||
import { whitelist, gistWhitelist } from "./envs.js";
|
||||
|
||||
const NOT_WHITELISTED_USERNAME_MESSAGE = "This username is not whitelisted";
|
||||
const NOT_WHITELISTED_GIST_MESSAGE = "This gist ID is not whitelisted";
|
||||
const BLACKLISTED_MESSAGE = "This username is blacklisted";
|
||||
|
||||
/**
|
||||
* Guards access using whitelist/blacklist.
|
||||
*
|
||||
* @param {Object} args The parameters object.
|
||||
* @param {any} args.res The response object.
|
||||
* @param {string} args.id Resource identifier (username or gist id).
|
||||
* @param {"username"|"gist"|"wakatime"} args.type The type of identifier.
|
||||
* @param {{ title_color?: string, text_color?: string, bg_color?: string, border_color?: string, theme?: string }} args.colors Color options for the error card.
|
||||
* @returns {{ isPassed: boolean, result?: any }} The result object indicating success or failure.
|
||||
*/
|
||||
const guardAccess = ({ res, id, type, colors }) => {
|
||||
if (!["username", "gist", "wakatime"].includes(type)) {
|
||||
throw new Error(
|
||||
'Invalid type. Expected "username", "gist", or "wakatime".',
|
||||
);
|
||||
}
|
||||
|
||||
const currentWhitelist = type === "gist" ? gistWhitelist : whitelist;
|
||||
const notWhitelistedMsg =
|
||||
type === "gist"
|
||||
? NOT_WHITELISTED_GIST_MESSAGE
|
||||
: NOT_WHITELISTED_USERNAME_MESSAGE;
|
||||
|
||||
if (Array.isArray(currentWhitelist) && !currentWhitelist.includes(id)) {
|
||||
const result = res.send(
|
||||
renderError({
|
||||
message: notWhitelistedMsg,
|
||||
secondaryMessage: "Please deploy your own instance",
|
||||
renderOptions: {
|
||||
...colors,
|
||||
show_repo_link: false,
|
||||
},
|
||||
}),
|
||||
);
|
||||
return { isPassed: false, result };
|
||||
}
|
||||
|
||||
if (
|
||||
type === "username" &&
|
||||
currentWhitelist === undefined &&
|
||||
blacklist.includes(id)
|
||||
) {
|
||||
const result = res.send(
|
||||
renderError({
|
||||
message: BLACKLISTED_MESSAGE,
|
||||
secondaryMessage: "Please deploy your own instance",
|
||||
renderOptions: {
|
||||
...colors,
|
||||
show_repo_link: false,
|
||||
},
|
||||
}),
|
||||
);
|
||||
return { isPassed: false, result };
|
||||
}
|
||||
|
||||
return { isPassed: true };
|
||||
};
|
||||
|
||||
export { guardAccess };
|
||||
@@ -0,0 +1,10 @@
|
||||
const blacklist = [
|
||||
"renovate-bot",
|
||||
"technote-space",
|
||||
"sw-yx",
|
||||
"YourUsername",
|
||||
"[YourUsername]",
|
||||
];
|
||||
|
||||
export { blacklist };
|
||||
export default blacklist;
|
||||
@@ -0,0 +1,154 @@
|
||||
// @ts-check
|
||||
|
||||
import { clampValue } from "./ops.js";
|
||||
|
||||
const MIN = 60;
|
||||
const HOUR = 60 * MIN;
|
||||
const DAY = 24 * HOUR;
|
||||
|
||||
/**
|
||||
* Common durations in seconds.
|
||||
*/
|
||||
const DURATIONS = {
|
||||
ONE_MINUTE: MIN,
|
||||
FIVE_MINUTES: 5 * MIN,
|
||||
TEN_MINUTES: 10 * MIN,
|
||||
FIFTEEN_MINUTES: 15 * MIN,
|
||||
THIRTY_MINUTES: 30 * MIN,
|
||||
|
||||
TWO_HOURS: 2 * HOUR,
|
||||
FOUR_HOURS: 4 * HOUR,
|
||||
SIX_HOURS: 6 * HOUR,
|
||||
EIGHT_HOURS: 8 * HOUR,
|
||||
TEN_HOURS: 10 * HOUR,
|
||||
TWELVE_HOURS: 12 * HOUR,
|
||||
|
||||
ONE_DAY: DAY,
|
||||
TWO_DAY: 2 * DAY,
|
||||
SIX_DAY: 6 * DAY,
|
||||
TEN_DAY: 10 * DAY,
|
||||
};
|
||||
|
||||
/**
|
||||
* Common cache TTL values in seconds.
|
||||
*/
|
||||
const CACHE_TTL = {
|
||||
STATS_CARD: {
|
||||
DEFAULT: DURATIONS.TEN_HOURS,
|
||||
MIN: DURATIONS.FOUR_HOURS,
|
||||
MAX: DURATIONS.TWO_DAY,
|
||||
},
|
||||
TOP_LANGS_CARD: {
|
||||
DEFAULT: DURATIONS.TEN_HOURS,
|
||||
MIN: DURATIONS.FOUR_HOURS,
|
||||
MAX: DURATIONS.TEN_DAY,
|
||||
},
|
||||
PIN_CARD: {
|
||||
DEFAULT: DURATIONS.TEN_HOURS,
|
||||
MIN: DURATIONS.FOUR_HOURS,
|
||||
MAX: DURATIONS.TEN_DAY,
|
||||
},
|
||||
GIST_CARD: {
|
||||
DEFAULT: DURATIONS.TEN_HOURS,
|
||||
MIN: DURATIONS.FOUR_HOURS,
|
||||
MAX: DURATIONS.TEN_DAY,
|
||||
},
|
||||
WAKATIME_CARD: {
|
||||
DEFAULT: DURATIONS.TEN_HOURS,
|
||||
MIN: DURATIONS.FOUR_HOURS,
|
||||
MAX: DURATIONS.TWO_DAY,
|
||||
},
|
||||
ERROR: DURATIONS.TEN_MINUTES,
|
||||
};
|
||||
|
||||
/**
|
||||
* Resolves the cache seconds based on the requested, default, min, and max values.
|
||||
*
|
||||
* @param {Object} args The parameters object.
|
||||
* @param {number} args.requested The requested cache seconds.
|
||||
* @param {number} args.def The default cache seconds.
|
||||
* @param {number} args.min The minimum cache seconds.
|
||||
* @param {number} args.max The maximum cache seconds.
|
||||
* @returns {number} The resolved cache seconds.
|
||||
*/
|
||||
const resolveCacheSeconds = ({ requested, def, min, max }) => {
|
||||
let cacheSeconds = clampValue(isNaN(requested) ? def : requested, min, max);
|
||||
|
||||
if (process.env.CACHE_SECONDS) {
|
||||
const envCacheSeconds = parseInt(process.env.CACHE_SECONDS, 10);
|
||||
if (!isNaN(envCacheSeconds)) {
|
||||
cacheSeconds = envCacheSeconds;
|
||||
}
|
||||
}
|
||||
|
||||
return cacheSeconds;
|
||||
};
|
||||
|
||||
/**
|
||||
* Disables caching by setting appropriate headers on the response object.
|
||||
*
|
||||
* @param {any} res The response object.
|
||||
*/
|
||||
const disableCaching = (res) => {
|
||||
// Disable caching for browsers, shared caches/CDNs, and GitHub Camo.
|
||||
res.setHeader(
|
||||
"Cache-Control",
|
||||
"no-cache, no-store, must-revalidate, max-age=0, s-maxage=0",
|
||||
);
|
||||
res.setHeader("Pragma", "no-cache");
|
||||
res.setHeader("Expires", "0");
|
||||
};
|
||||
|
||||
/**
|
||||
* Sets the Cache-Control headers on the response object.
|
||||
*
|
||||
* @param {any} res The response object.
|
||||
* @param {number} cacheSeconds The cache seconds to set in the headers.
|
||||
*/
|
||||
const setCacheHeaders = (res, cacheSeconds) => {
|
||||
if (cacheSeconds < 1 || process.env.NODE_ENV === "development") {
|
||||
disableCaching(res);
|
||||
return;
|
||||
}
|
||||
|
||||
res.setHeader(
|
||||
"Cache-Control",
|
||||
`max-age=${cacheSeconds}, ` +
|
||||
`s-maxage=${cacheSeconds}, ` +
|
||||
`stale-while-revalidate=${DURATIONS.ONE_DAY}`,
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Sets the Cache-Control headers for error responses on the response object.
|
||||
*
|
||||
* @param {any} res The response object.
|
||||
*/
|
||||
const setErrorCacheHeaders = (res) => {
|
||||
const envCacheSeconds = process.env.CACHE_SECONDS
|
||||
? parseInt(process.env.CACHE_SECONDS, 10)
|
||||
: NaN;
|
||||
if (
|
||||
(!isNaN(envCacheSeconds) && envCacheSeconds < 1) ||
|
||||
process.env.NODE_ENV === "development"
|
||||
) {
|
||||
disableCaching(res);
|
||||
return;
|
||||
}
|
||||
|
||||
// Use lower cache period for errors.
|
||||
res.setHeader(
|
||||
"Cache-Control",
|
||||
`max-age=${CACHE_TTL.ERROR}, ` +
|
||||
`s-maxage=${CACHE_TTL.ERROR}, ` +
|
||||
`stale-while-revalidate=${DURATIONS.ONE_DAY}`,
|
||||
);
|
||||
};
|
||||
|
||||
export {
|
||||
resolveCacheSeconds,
|
||||
setCacheHeaders,
|
||||
setErrorCacheHeaders,
|
||||
DURATIONS,
|
||||
CACHE_TTL,
|
||||
};
|
||||
@@ -0,0 +1,144 @@
|
||||
// @ts-check
|
||||
|
||||
import { themes } from "../../themes/index.js";
|
||||
|
||||
/**
|
||||
* Checks if a string is a valid hex color.
|
||||
*
|
||||
* @param {string} hexColor String to check.
|
||||
* @returns {boolean} True if the given string is a valid hex color.
|
||||
*/
|
||||
const isValidHexColor = (hexColor) => {
|
||||
return new RegExp(
|
||||
/^([A-Fa-f0-9]{8}|[A-Fa-f0-9]{6}|[A-Fa-f0-9]{3}|[A-Fa-f0-9]{4})$/,
|
||||
).test(hexColor);
|
||||
};
|
||||
|
||||
/**
|
||||
* Check if the given string is a valid gradient.
|
||||
*
|
||||
* @param {string[]} colors Array of colors.
|
||||
* @returns {boolean} True if the given string is a valid gradient.
|
||||
*/
|
||||
const isValidGradient = (colors) => {
|
||||
return (
|
||||
colors.length > 2 &&
|
||||
colors.slice(1).every((color) => isValidHexColor(color))
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Retrieves a gradient if color has more than one valid hex codes else a single color.
|
||||
*
|
||||
* @param {string} color The color to parse.
|
||||
* @param {string | string[]} fallbackColor The fallback color.
|
||||
* @returns {string | string[]} The gradient or color.
|
||||
*/
|
||||
const fallbackColor = (color, fallbackColor) => {
|
||||
let gradient = null;
|
||||
|
||||
let colors = color ? color.split(",") : [];
|
||||
if (colors.length > 1 && isValidGradient(colors)) {
|
||||
gradient = colors;
|
||||
}
|
||||
|
||||
return (
|
||||
(gradient ? gradient : isValidHexColor(color) && `#${color}`) ||
|
||||
fallbackColor
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Object containing card colors.
|
||||
* @typedef {{
|
||||
* titleColor: string;
|
||||
* iconColor: string;
|
||||
* textColor: string;
|
||||
* bgColor: string | string[];
|
||||
* borderColor: string;
|
||||
* ringColor: string;
|
||||
* }} CardColors
|
||||
*/
|
||||
|
||||
/**
|
||||
* Returns theme based colors with proper overrides and defaults.
|
||||
*
|
||||
* @param {Object} args Function arguments.
|
||||
* @param {string=} args.title_color Card title color.
|
||||
* @param {string=} args.text_color Card text color.
|
||||
* @param {string=} args.icon_color Card icon color.
|
||||
* @param {string=} args.bg_color Card background color.
|
||||
* @param {string=} args.border_color Card border color.
|
||||
* @param {string=} args.ring_color Card ring color.
|
||||
* @param {string=} args.theme Card theme.
|
||||
* @returns {CardColors} Card colors.
|
||||
*/
|
||||
const getCardColors = ({
|
||||
title_color,
|
||||
text_color,
|
||||
icon_color,
|
||||
bg_color,
|
||||
border_color,
|
||||
ring_color,
|
||||
theme,
|
||||
}) => {
|
||||
const defaultTheme = themes["default"];
|
||||
const isThemeProvided = theme !== null && theme !== undefined && theme in themes;
|
||||
|
||||
// @ts-ignore
|
||||
const selectedTheme = isThemeProvided ? themes[theme] : defaultTheme;
|
||||
|
||||
const defaultBorderColor =
|
||||
"border_color" in selectedTheme
|
||||
? selectedTheme.border_color
|
||||
: // @ts-ignore
|
||||
defaultTheme.border_color;
|
||||
|
||||
// get the color provided by the user else the theme color
|
||||
// finally if both colors are invalid fallback to default theme
|
||||
const titleColor = fallbackColor(
|
||||
title_color || selectedTheme.title_color,
|
||||
"#" + defaultTheme.title_color,
|
||||
);
|
||||
|
||||
// get the color provided by the user else the theme color
|
||||
// finally if both colors are invalid we use the titleColor
|
||||
const ringColor = fallbackColor(
|
||||
// @ts-ignore
|
||||
ring_color || selectedTheme.ring_color,
|
||||
titleColor,
|
||||
);
|
||||
const iconColor = fallbackColor(
|
||||
icon_color || selectedTheme.icon_color,
|
||||
"#" + defaultTheme.icon_color,
|
||||
);
|
||||
const textColor = fallbackColor(
|
||||
text_color || selectedTheme.text_color,
|
||||
"#" + defaultTheme.text_color,
|
||||
);
|
||||
const bgColor = fallbackColor(
|
||||
bg_color || selectedTheme.bg_color,
|
||||
"#" + defaultTheme.bg_color,
|
||||
);
|
||||
|
||||
const borderColor = fallbackColor(
|
||||
border_color || defaultBorderColor,
|
||||
"#" + defaultBorderColor,
|
||||
);
|
||||
|
||||
if (
|
||||
typeof titleColor !== "string" ||
|
||||
typeof textColor !== "string" ||
|
||||
typeof ringColor !== "string" ||
|
||||
typeof iconColor !== "string" ||
|
||||
typeof borderColor !== "string"
|
||||
) {
|
||||
throw new Error(
|
||||
"Unexpected behavior, all colors except background should be string.",
|
||||
);
|
||||
}
|
||||
|
||||
return { titleColor, iconColor, textColor, bgColor, borderColor, ringColor };
|
||||
};
|
||||
|
||||
export { isValidHexColor, isValidGradient, getCardColors };
|
||||
@@ -0,0 +1,262 @@
|
||||
import pkg from "pg";
|
||||
const { Pool } = pkg;
|
||||
|
||||
export const pool = process.env.POSTGRES_URL
|
||||
? new Pool({
|
||||
connectionString: process.env.POSTGRES_URL,
|
||||
})
|
||||
: null;
|
||||
|
||||
/**
|
||||
* Creates all required tables if they do not exist.
|
||||
*/
|
||||
async function createAllTables() {
|
||||
if (!pool) {
|
||||
return;
|
||||
}
|
||||
|
||||
await pool.query(`
|
||||
CREATE TABLE IF NOT EXISTS requests (
|
||||
request TEXT PRIMARY KEY,
|
||||
requested_at TIMESTAMP NOT NULL DEFAULT now(),
|
||||
user_requested_at TIMESTAMP NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS authenticated_users (
|
||||
user_id TEXT PRIMARY KEY,
|
||||
access_token TEXT NOT NULL,
|
||||
user_key TEXT,
|
||||
private_access BOOLEAN NOT NULL DEFAULT false
|
||||
);
|
||||
`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stores or updates a request in the database.
|
||||
*/
|
||||
export async function storeRequest(req) {
|
||||
if (!pool) {
|
||||
return;
|
||||
}
|
||||
|
||||
const isBypass = req.headers && req.headers["x-bypass-store"];
|
||||
const insertQuery = isBypass
|
||||
? `
|
||||
INSERT INTO requests (request, requested_at)
|
||||
VALUES ($1, NOW())
|
||||
ON CONFLICT (request)
|
||||
DO UPDATE SET requested_at = EXCLUDED.requested_at
|
||||
`
|
||||
: `
|
||||
INSERT INTO requests (request, requested_at, user_requested_at)
|
||||
VALUES ($1, NOW(), NOW())
|
||||
ON CONFLICT (request)
|
||||
DO UPDATE SET requested_at = EXCLUDED.requested_at, user_requested_at = EXCLUDED.user_requested_at
|
||||
`;
|
||||
|
||||
try {
|
||||
await pool.query(insertQuery, [req.url]);
|
||||
} catch (err) {
|
||||
// Check for undefined_table error (SQLSTATE 42P01)
|
||||
if (err.code === "42P01") {
|
||||
await createAllTables();
|
||||
// Retry the insert after creating the table
|
||||
await pool.query(insertQuery, [req.url]);
|
||||
} else {
|
||||
throw err; // Re-throw if it's some other error
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes all requests older than 8 days from the database.
|
||||
*/
|
||||
export async function deleteOldRequests() {
|
||||
if (!pool) {
|
||||
return;
|
||||
}
|
||||
|
||||
const deleteQuery = `
|
||||
DELETE FROM requests
|
||||
WHERE user_requested_at < NOW() - INTERVAL '8 days'
|
||||
`;
|
||||
try {
|
||||
let result = await pool.query(deleteQuery);
|
||||
console.log(`Deleted ${result.rowCount} old requests.`);
|
||||
} catch (err) {
|
||||
if (err.code === "42P01") {
|
||||
console.log("Error deleting requests, table doesn't exist");
|
||||
} else {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches all requests which are between 11 hours and 8 days old.
|
||||
*
|
||||
* @returns {Promise<string[]>} Array of all requests between 11 hours and 8 days old.
|
||||
*/
|
||||
export async function getRecentRequests() {
|
||||
if (!pool) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const query = `
|
||||
SELECT request
|
||||
FROM requests
|
||||
WHERE requested_at >= NOW() - INTERVAL '8 days'
|
||||
AND requested_at < NOW() - INTERVAL '11 hours'
|
||||
ORDER BY requested_at ASC
|
||||
`;
|
||||
let rows;
|
||||
try {
|
||||
({ rows } = await pool.query(query));
|
||||
} catch (err) {
|
||||
if (err.code === "42P01") {
|
||||
console.log("Error fetching requests, table doesn't exist");
|
||||
} else {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
return rows.map((row) => row.request);
|
||||
}
|
||||
|
||||
/**
|
||||
* Inserts or updates a user in the database.
|
||||
*
|
||||
* @param {string} userId GitHub userId (login name)
|
||||
* @param {string} accessToken GitHub access token
|
||||
* @param {string|null} userKey Optional user key
|
||||
* @param {boolean} privateAccess Whether private access was requested
|
||||
*/
|
||||
export async function storeUser(userId, accessToken, userKey, privateAccess) {
|
||||
if (!pool) {
|
||||
return;
|
||||
}
|
||||
|
||||
const insertQuery = `
|
||||
INSERT INTO authenticated_users (user_id, access_token, user_key, private_access)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
ON CONFLICT (user_id)
|
||||
DO UPDATE SET
|
||||
access_token = EXCLUDED.access_token,
|
||||
user_key = EXCLUDED.user_key,
|
||||
private_access = EXCLUDED.private_access
|
||||
`;
|
||||
|
||||
try {
|
||||
await pool.query(insertQuery, [
|
||||
userId,
|
||||
accessToken,
|
||||
userKey,
|
||||
privateAccess,
|
||||
]);
|
||||
} catch (err) {
|
||||
if (err.code === "42P01") {
|
||||
await createAllTables();
|
||||
await pool.query(insertQuery, [
|
||||
userId,
|
||||
accessToken,
|
||||
userKey,
|
||||
privateAccess,
|
||||
]);
|
||||
} else {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a user from the database.
|
||||
*
|
||||
* @param userKey user key of the user which is to be deleted.
|
||||
*/
|
||||
export async function deleteUser(userKey) {
|
||||
if (!pool) {
|
||||
return;
|
||||
}
|
||||
|
||||
const deleteQuery = `
|
||||
DELETE FROM authenticated_users
|
||||
WHERE user_key = $1
|
||||
`;
|
||||
try {
|
||||
await pool.query(deleteQuery, [userKey]);
|
||||
} catch (err) {
|
||||
if (err.code === "42P01") {
|
||||
console.log("Error deleting user, table doesn't exist");
|
||||
} else {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches token and private access status for a given user_key.
|
||||
*
|
||||
* @param {string} userKey user key of the user to fetch information for
|
||||
* @returns {Promise<{token: string, privateAccess: boolean} | null>} token and private access status, or null if user not found
|
||||
*/
|
||||
export async function getUserAccessByKey(userKey) {
|
||||
if (!pool) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const query = `
|
||||
SELECT access_token, private_access
|
||||
FROM authenticated_users
|
||||
WHERE user_key = $1
|
||||
LIMIT 1
|
||||
`;
|
||||
try {
|
||||
const { rows } = await pool.query(query, [userKey]);
|
||||
if (rows.length === 0) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
token: rows[0].access_token,
|
||||
privateAccess: rows[0].private_access
|
||||
};
|
||||
} catch (err) {
|
||||
if (err.code === "42P01") {
|
||||
return null;
|
||||
} else {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches token and private access status for a given username.
|
||||
*
|
||||
* @param {string} userName GitHub username of the user to fetch information for
|
||||
* @returns {Promise<{token: string, privateAccess: boolean} | null>} token and private access status, or null if user not found
|
||||
*/
|
||||
export async function getUserAccessByName(userName) {
|
||||
if (!pool) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const query = `
|
||||
SELECT access_token, private_access
|
||||
FROM authenticated_users
|
||||
WHERE user_id = $1
|
||||
LIMIT 1
|
||||
`;
|
||||
try {
|
||||
const { rows } = await pool.query(query, [userName]);
|
||||
if (rows.length === 0) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
token: rows[0].access_token,
|
||||
privateAccess: rows[0].private_access
|
||||
};
|
||||
} catch (err) {
|
||||
if (err.code === "42P01") {
|
||||
return null;
|
||||
} else {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
// @ts-check
|
||||
|
||||
const whitelist = process.env.WHITELIST
|
||||
? process.env.WHITELIST.split(",")
|
||||
: undefined;
|
||||
|
||||
const gistWhitelist = process.env.GIST_WHITELIST
|
||||
? process.env.GIST_WHITELIST.split(",")
|
||||
: undefined;
|
||||
|
||||
const excludeRepositories = process.env.EXCLUDE_REPO
|
||||
? process.env.EXCLUDE_REPO.split(",")
|
||||
: [];
|
||||
|
||||
export { whitelist, gistWhitelist, excludeRepositories };
|
||||
@@ -0,0 +1,90 @@
|
||||
// @ts-check
|
||||
|
||||
import { OWNER_AFFILIATIONS } from "./ops.js";
|
||||
|
||||
/**
|
||||
* @type {string} A general message to ask user to try again later.
|
||||
*/
|
||||
const TRY_AGAIN_LATER = "Please try again later";
|
||||
|
||||
/**
|
||||
* @type {Object<string, string>} A map of error types to secondary error messages.
|
||||
*/
|
||||
const SECONDARY_ERROR_MESSAGES = {
|
||||
MAX_RETRY:
|
||||
"You can deploy own instance or wait until public will be no longer limited",
|
||||
NO_TOKENS:
|
||||
"Please add an env variable called PAT_1 with your GitHub API token in vercel",
|
||||
USER_NOT_FOUND: "Make sure the provided username is not an organization",
|
||||
GRAPHQL_ERROR: TRY_AGAIN_LATER,
|
||||
GITHUB_REST_API_ERROR: TRY_AGAIN_LATER,
|
||||
WAKATIME_USER_NOT_FOUND: "Make sure you have a public WakaTime profile",
|
||||
INVALID_AFFILIATION: `Invalid owner affiliations. Valid values are: ${OWNER_AFFILIATIONS.join(
|
||||
", ",
|
||||
)}`,
|
||||
};
|
||||
|
||||
/**
|
||||
* Custom error class to handle custom GRS errors.
|
||||
*/
|
||||
class CustomError extends Error {
|
||||
/**
|
||||
* Custom error constructor.
|
||||
*
|
||||
* @param {string} message Error message.
|
||||
* @param {string} type Error type.
|
||||
*/
|
||||
constructor(message, type) {
|
||||
super(message);
|
||||
this.type = type;
|
||||
this.secondaryMessage = SECONDARY_ERROR_MESSAGES[type] || type;
|
||||
}
|
||||
|
||||
static MAX_RETRY = "MAX_RETRY";
|
||||
static NO_TOKENS = "NO_TOKENS";
|
||||
static USER_NOT_FOUND = "USER_NOT_FOUND";
|
||||
static GRAPHQL_ERROR = "GRAPHQL_ERROR";
|
||||
static GITHUB_REST_API_ERROR = "GITHUB_REST_API_ERROR";
|
||||
static WAKATIME_ERROR = "WAKATIME_ERROR";
|
||||
static INVALID_AFFILIATION = "INVALID_AFFILIATION";
|
||||
}
|
||||
|
||||
/**
|
||||
* Missing query parameter class.
|
||||
*/
|
||||
class MissingParamError extends Error {
|
||||
/**
|
||||
* Missing query parameter error constructor.
|
||||
*
|
||||
* @param {string[]} missedParams An array of missing parameters names.
|
||||
* @param {string=} secondaryMessage Optional secondary message to display.
|
||||
*/
|
||||
constructor(missedParams, secondaryMessage) {
|
||||
const msg = `Missing params ${missedParams
|
||||
.map((p) => `"${p}"`)
|
||||
.join(", ")} make sure you pass the parameters in URL`;
|
||||
super(msg);
|
||||
this.missedParams = missedParams;
|
||||
this.secondaryMessage = secondaryMessage;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve secondary message from an error object.
|
||||
*
|
||||
* @param {Error} err The error object.
|
||||
* @returns {string|undefined} The secondary message if available, otherwise undefined.
|
||||
*/
|
||||
const retrieveSecondaryMessage = (err) => {
|
||||
return "secondaryMessage" in err && typeof err.secondaryMessage === "string"
|
||||
? err.secondaryMessage
|
||||
: undefined;
|
||||
};
|
||||
|
||||
export {
|
||||
CustomError,
|
||||
MissingParamError,
|
||||
SECONDARY_ERROR_MESSAGES,
|
||||
TRY_AGAIN_LATER,
|
||||
retrieveSecondaryMessage,
|
||||
};
|
||||
@@ -0,0 +1,90 @@
|
||||
// @ts-check
|
||||
|
||||
import wrap from "word-wrap";
|
||||
import { encodeHTML } from "./html.js";
|
||||
|
||||
/**
|
||||
* Retrieves num with suffix k(thousands) precise to given decimal places.
|
||||
*
|
||||
* @param {number} num The number to format.
|
||||
* @param {number=} precision The number of decimal places to include.
|
||||
* @returns {string|number} The formatted number.
|
||||
*/
|
||||
const kFormatter = (num, precision) => {
|
||||
const abs = Math.abs(num);
|
||||
const sign = Math.sign(num);
|
||||
|
||||
if (typeof precision === "number" && !isNaN(precision)) {
|
||||
return (sign * (abs / 1000)).toFixed(precision) + "k";
|
||||
}
|
||||
|
||||
if (abs < 1000) {
|
||||
return sign * abs;
|
||||
}
|
||||
|
||||
return sign * parseFloat((abs / 1000).toFixed(1)) + "k";
|
||||
};
|
||||
|
||||
/**
|
||||
* Convert bytes to a human-readable string representation.
|
||||
*
|
||||
* @param {number} bytes The number of bytes to convert.
|
||||
* @returns {string} The human-readable representation of bytes.
|
||||
* @throws {Error} If bytes is negative or too large.
|
||||
*/
|
||||
const formatBytes = (bytes) => {
|
||||
if (bytes < 0) {
|
||||
throw new Error("Bytes must be a non-negative number");
|
||||
}
|
||||
|
||||
if (bytes === 0) {
|
||||
return "0 B";
|
||||
}
|
||||
|
||||
const sizes = ["B", "KB", "MB", "GB", "TB", "PB", "EB"];
|
||||
const base = 1024;
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(base));
|
||||
|
||||
if (i >= sizes.length) {
|
||||
throw new Error("Bytes is too large to convert to a human-readable string");
|
||||
}
|
||||
|
||||
return `${(bytes / Math.pow(base, i)).toFixed(1)} ${sizes[i]}`;
|
||||
};
|
||||
|
||||
/**
|
||||
* Split text over multiple lines based on the card width.
|
||||
*
|
||||
* @param {string} text Text to split.
|
||||
* @param {number} width Line width in number of characters.
|
||||
* @param {number} maxLines Maximum number of lines.
|
||||
* @returns {string[]} Array of lines.
|
||||
*/
|
||||
const wrapTextMultiline = (text, width = 59, maxLines = 3) => {
|
||||
const fullWidthComma = ",";
|
||||
const encoded = encodeHTML(text);
|
||||
const isChinese = encoded.includes(fullWidthComma);
|
||||
|
||||
let wrapped = [];
|
||||
|
||||
if (isChinese) {
|
||||
wrapped = encoded.split(fullWidthComma); // Chinese full punctuation
|
||||
} else {
|
||||
wrapped = wrap(encoded, {
|
||||
width,
|
||||
}).split("\n"); // Split wrapped lines to get an array of lines
|
||||
}
|
||||
|
||||
const lines = wrapped.map((line) => line.trim()).slice(0, maxLines); // Only consider maxLines lines
|
||||
|
||||
// Add "..." to the last line if the text exceeds maxLines
|
||||
if (wrapped.length > maxLines) {
|
||||
lines[maxLines - 1] += "...";
|
||||
}
|
||||
|
||||
// Remove empty lines if text fits in less than maxLines lines
|
||||
const multiLineText = lines.filter(Boolean);
|
||||
return multiLineText;
|
||||
};
|
||||
|
||||
export { kFormatter, formatBytes, wrapTextMultiline };
|
||||
@@ -0,0 +1,19 @@
|
||||
// @ts-check
|
||||
|
||||
/**
|
||||
* Encode string as HTML.
|
||||
*
|
||||
* @see https://stackoverflow.com/a/48073476/10629172
|
||||
*
|
||||
* @param {string} str String to encode.
|
||||
* @returns {string} Encoded string.
|
||||
*/
|
||||
const encodeHTML = (str) => {
|
||||
return str
|
||||
.replace(/[\u00A0-\u9999<>&](?!#)/gim, (i) => {
|
||||
return "&#" + i.charCodeAt(0) + ";";
|
||||
})
|
||||
.replace(/\u0008/gim, "");
|
||||
};
|
||||
|
||||
export { encodeHTML };
|
||||
@@ -0,0 +1,21 @@
|
||||
// @ts-check
|
||||
|
||||
import axios from "axios";
|
||||
|
||||
/**
|
||||
* Send GraphQL request to GitHub API.
|
||||
*
|
||||
* @param {import('axios').AxiosRequestConfig['data']} data Request data.
|
||||
* @param {import('axios').AxiosRequestConfig['headers']} headers Request headers.
|
||||
* @returns {Promise<any>} Request response.
|
||||
*/
|
||||
const request = (data, headers) => {
|
||||
return axios({
|
||||
url: "https://api.github.com/graphql",
|
||||
method: "post",
|
||||
headers,
|
||||
data,
|
||||
});
|
||||
};
|
||||
|
||||
export { request };
|
||||
@@ -0,0 +1,56 @@
|
||||
// @ts-check
|
||||
|
||||
const icons = {
|
||||
star: `<path fill-rule="evenodd" d="M8 .25a.75.75 0 01.673.418l1.882 3.815 4.21.612a.75.75 0 01.416 1.279l-3.046 2.97.719 4.192a.75.75 0 01-1.088.791L8 12.347l-3.766 1.98a.75.75 0 01-1.088-.79l.72-4.194L.818 6.374a.75.75 0 01.416-1.28l4.21-.611L7.327.668A.75.75 0 018 .25zm0 2.445L6.615 5.5a.75.75 0 01-.564.41l-3.097.45 2.24 2.184a.75.75 0 01.216.664l-.528 3.084 2.769-1.456a.75.75 0 01.698 0l2.77 1.456-.53-3.084a.75.75 0 01.216-.664l2.24-2.183-3.096-.45a.75.75 0 01-.564-.41L8 2.694v.001z"/>`,
|
||||
commits: `<path fill-rule="evenodd" d="M1.643 3.143L.427 1.927A.25.25 0 000 2.104V5.75c0 .138.112.25.25.25h3.646a.25.25 0 00.177-.427L2.715 4.215a6.5 6.5 0 11-1.18 4.458.75.75 0 10-1.493.154 8.001 8.001 0 101.6-5.684zM7.75 4a.75.75 0 01.75.75v2.992l2.028.812a.75.75 0 01-.557 1.392l-2.5-1A.75.75 0 017 8.25v-3.5A.75.75 0 017.75 4z"/>`,
|
||||
prs: `<path fill-rule="evenodd" d="M7.177 3.073L9.573.677A.25.25 0 0110 .854v4.792a.25.25 0 01-.427.177L7.177 3.427a.25.25 0 010-.354zM3.75 2.5a.75.75 0 100 1.5.75.75 0 000-1.5zm-2.25.75a2.25 2.25 0 113 2.122v5.256a2.251 2.251 0 11-1.5 0V5.372A2.25 2.25 0 011.5 3.25zM11 2.5h-1V4h1a1 1 0 011 1v5.628a2.251 2.251 0 101.5 0V5A2.5 2.5 0 0011 2.5zm1 10.25a.75.75 0 111.5 0 .75.75 0 01-1.5 0zM3.75 12a.75.75 0 100 1.5.75.75 0 000-1.5z"/>`,
|
||||
prs_merged: `<path fill-rule="evenodd" d="M5.45 5.154A4.25 4.25 0 0 0 9.25 7.5h1.378a2.251 2.251 0 1 1 0 1.5H9.25A5.734 5.734 0 0 1 5 7.123v3.505a2.25 2.25 0 1 1-1.5 0V5.372a2.25 2.25 0 1 1 1.95-.218ZM4.25 13.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5Zm8.5-4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5ZM5 3.25a.75.75 0 1 0 0 .005V3.25Z" />`,
|
||||
prs_merged_percentage: `<path fill-rule="evenodd" d="M13.442 2.558a.625.625 0 0 1 0 .884l-10 10a.625.625 0 1 1-.884-.884l10-10a.625.625 0 0 1 .884 0zM4.5 6a1.5 1.5 0 1 1 0-3 1.5 1.5 0 0 1 0 3zm0 1a2.5 2.5 0 1 0 0-5 2.5 2.5 0 0 0 0 5zm7 6a1.5 1.5 0 1 1 0-3 1.5 1.5 0 0 1 0 3zm0 1a2.5 2.5 0 1 0 0-5 2.5 2.5 0 0 0 0 5z" />`,
|
||||
issues: `<path fill-rule="evenodd" d="M8 1.5a6.5 6.5 0 100 13 6.5 6.5 0 000-13zM0 8a8 8 0 1116 0A8 8 0 010 8zm9 3a1 1 0 11-2 0 1 1 0 012 0zm-.25-6.25a.75.75 0 00-1.5 0v3.5a.75.75 0 001.5 0v-3.5z"/>`,
|
||||
icon: `<path fill-rule="evenodd" d="M2 2.5A2.5 2.5 0 014.5 0h8.75a.75.75 0 01.75.75v12.5a.75.75 0 01-.75.75h-2.5a.75.75 0 110-1.5h1.75v-2h-8a1 1 0 00-.714 1.7.75.75 0 01-1.072 1.05A2.495 2.495 0 012 11.5v-9zm10.5-1V9h-8c-.356 0-.694.074-1 .208V2.5a1 1 0 011-1h8zM5 12.25v3.25a.25.25 0 00.4.2l1.45-1.087a.25.25 0 01.3 0L8.6 15.7a.25.25 0 00.4-.2v-3.25a.25.25 0 00-.25-.25h-3.5a.25.25 0 00-.25.25z"/>`,
|
||||
contribs: `<path fill-rule="evenodd" d="M2 2.5A2.5 2.5 0 014.5 0h8.75a.75.75 0 01.75.75v12.5a.75.75 0 01-.75.75h-2.5a.75.75 0 110-1.5h1.75v-2h-8a1 1 0 00-.714 1.7.75.75 0 01-1.072 1.05A2.495 2.495 0 012 11.5v-9zm10.5-1V9h-8c-.356 0-.694.074-1 .208V2.5a1 1 0 011-1h8zM5 12.25v3.25a.25.25 0 00.4.2l1.45-1.087a.25.25 0 01.3 0L8.6 15.7a.25.25 0 00.4-.2v-3.25a.25.25 0 00-.25-.25h-3.5a.25.25 0 00-.25.25z"/>`,
|
||||
fork: `<path fill-rule="evenodd" d="M5 3.25a.75.75 0 11-1.5 0 .75.75 0 011.5 0zm0 2.122a2.25 2.25 0 10-1.5 0v.878A2.25 2.25 0 005.75 8.5h1.5v2.128a2.251 2.251 0 101.5 0V8.5h1.5a2.25 2.25 0 002.25-2.25v-.878a2.25 2.25 0 10-1.5 0v.878a.75.75 0 01-.75.75h-4.5A.75.75 0 015 6.25v-.878zm3.75 7.378a.75.75 0 11-1.5 0 .75.75 0 011.5 0zm3-8.75a.75.75 0 100-1.5.75.75 0 000 1.5z"></path>`,
|
||||
reviews: `<path fill-rule="evenodd" d="M8 2c1.981 0 3.671.992 4.933 2.078 1.27 1.091 2.187 2.345 2.637 3.023a1.62 1.62 0 0 1 0 1.798c-.45.678-1.367 1.932-2.637 3.023C11.67 13.008 9.981 14 8 14c-1.981 0-3.671-.992-4.933-2.078C1.797 10.83.88 9.576.43 8.898a1.62 1.62 0 0 1 0-1.798c.45-.677 1.367-1.931 2.637-3.022C4.33 2.992 6.019 2 8 2ZM1.679 7.932a.12.12 0 0 0 0 .136c.411.622 1.241 1.75 2.366 2.717C5.176 11.758 6.527 12.5 8 12.5c1.473 0 2.825-.742 3.955-1.715 1.124-.967 1.954-2.096 2.366-2.717a.12.12 0 0 0 0-.136c-.412-.621-1.242-1.75-2.366-2.717C10.824 4.242 9.473 3.5 8 3.5c-1.473 0-2.825.742-3.955 1.715-1.124.967-1.954 2.096-2.366 2.717ZM8 10a2 2 0 1 1-.001-3.999A2 2 0 0 1 8 10Z"/>`,
|
||||
discussions_started: `<path fill-rule="evenodd" d="M1.75 1h8.5c.966 0 1.75.784 1.75 1.75v5.5A1.75 1.75 0 0 1 10.25 10H7.061l-2.574 2.573A1.458 1.458 0 0 1 2 11.543V10h-.25A1.75 1.75 0 0 1 0 8.25v-5.5C0 1.784.784 1 1.75 1ZM1.5 2.75v5.5c0 .138.112.25.25.25h1a.75.75 0 0 1 .75.75v2.19l2.72-2.72a.749.749 0 0 1 .53-.22h3.5a.25.25 0 0 0 .25-.25v-5.5a.25.25 0 0 0-.25-.25h-8.5a.25.25 0 0 0-.25.25Zm13 2a.25.25 0 0 0-.25-.25h-.5a.75.75 0 0 1 0-1.5h.5c.966 0 1.75.784 1.75 1.75v5.5A1.75 1.75 0 0 1 14.25 12H14v1.543a1.458 1.458 0 0 1-2.487 1.03L9.22 12.28a.749.749 0 0 1 .326-1.275.749.749 0 0 1 .734.215l2.22 2.22v-2.19a.75.75 0 0 1 .75-.75h1a.25.25 0 0 0 .25-.25Z" />`,
|
||||
discussions_answered: `<path fill-rule="evenodd" d="M13.78 4.22a.75.75 0 0 1 0 1.06l-7.25 7.25a.75.75 0 0 1-1.06 0L2.22 9.28a.751.751 0 0 1 .018-1.042.751.751 0 0 1 1.042-.018L6 10.94l6.72-6.72a.75.75 0 0 1 1.06 0Z" />`,
|
||||
comments: `<path d="M1 2.75C1 1.784 1.784 1 2.75 1h10.5c.966 0 1.75.784 1.75 1.75v7.5A1.75 1.75 0 0 1 13.25 12H9.06l-2.573 2.573A1.458 1.458 0 0 1 4 13.543V12H2.75A1.75 1.75 0 0 1 1 10.25Zm1.75-.25a.25.25 0 0 0-.25.25v7.5c0 .138.112.25.25.25h2a.75.75 0 0 1 .75.75v2.19l2.72-2.72a.749.749 0 0 1 .53-.22h4.5a.25.25 0 0 0 .25-.25v-7.5a.25.25 0 0 0-.25-.25Z" />`,
|
||||
gist: `<path fill-rule="evenodd" d="M0 1.75C0 .784.784 0 1.75 0h12.5C15.216 0 16 .784 16 1.75v12.5A1.75 1.75 0 0 1 14.25 16H1.75A1.75 1.75 0 0 1 0 14.25Zm1.75-.25a.25.25 0 0 0-.25.25v12.5c0 .138.112.25.25.25h12.5a.25.25 0 0 0 .25-.25V1.75a.25.25 0 0 0-.25-.25Zm7.47 3.97a.75.75 0 0 1 1.06 0l2 2a.75.75 0 0 1 0 1.06l-2 2a.749.749 0 0 1-1.275-.326.749.749 0 0 1 .215-.734L10.69 8 9.22 6.53a.75.75 0 0 1 0-1.06ZM6.78 6.53 5.31 8l1.47 1.47a.749.749 0 0 1-.326 1.275.749.749 0 0 1-.734-.215l-2-2a.75.75 0 0 1 0-1.06l2-2a.751.751 0 0 1 1.042.018.751.751 0 0 1 .018 1.042Z" />`,
|
||||
};
|
||||
|
||||
/**
|
||||
* Get rank icon
|
||||
*
|
||||
* @param {string} rankIcon - The rank icon type.
|
||||
* @param {string} rankLevel - The rank level.
|
||||
* @param {number} percentile - The rank percentile.
|
||||
* @returns {string} - The SVG code of the rank icon
|
||||
*/
|
||||
const rankIcon = (rankIcon, rankLevel, percentile) => {
|
||||
switch (rankIcon) {
|
||||
case "github":
|
||||
return `
|
||||
<svg x="-38" y="-30" height="66" width="66" aria-hidden="true" viewBox="0 0 16 16" version="1.1" data-view-component="true" data-testid="github-rank-icon">
|
||||
<path d="M8 0c4.42 0 8 3.58 8 8a8.013 8.013 0 0 1-5.45 7.59c-.4.08-.55-.17-.55-.38 0-.27.01-1.13.01-2.2 0-.75-.25-1.23-.54-1.48 1.78-.2 3.65-.88 3.65-3.95 0-.88-.31-1.59-.82-2.15.08-.2.36-1.02-.08-2.12 0 0-.67-.22-2.2.82-.64-.18-1.32-.27-2-.27-.68 0-1.36.09-2 .27-1.53-1.03-2.2-.82-2.2-.82-.44 1.1-.16 1.92-.08 2.12-.51.56-.82 1.28-.82 2.15 0 3.06 1.86 3.75 3.64 3.95-.23.2-.44.55-.51 1.07-.46.21-1.61.55-2.33-.66-.15-.24-.6-.83-1.23-.82-.67.01-.27.38.01.53.34.19.73.9.82 1.13.16.45.68 1.31 2.69.94 0 .67.01 1.3.01 1.49 0 .21-.15.45-.55.38A7.995 7.995 0 0 1 0 8c0-4.42 3.58-8 8-8Z"></path>
|
||||
</svg>
|
||||
`;
|
||||
case "percentile":
|
||||
return `
|
||||
<text x="-5" y="-12" alignment-baseline="central" dominant-baseline="central" text-anchor="middle" data-testid="percentile-top-header" class="rank-percentile-header">
|
||||
Top
|
||||
</text>
|
||||
<text x="-5" y="12" alignment-baseline="central" dominant-baseline="central" text-anchor="middle" data-testid="percentile-rank-value" class="rank-percentile-text">
|
||||
${percentile.toFixed(1)}%
|
||||
</text>
|
||||
`;
|
||||
case "default":
|
||||
default:
|
||||
return `
|
||||
<text x="-5" y="3" alignment-baseline="central" dominant-baseline="central" text-anchor="middle" data-testid="level-rank-icon">
|
||||
${rankLevel}
|
||||
</text>
|
||||
`;
|
||||
}
|
||||
};
|
||||
|
||||
export { icons, rankIcon };
|
||||
export default icons;
|
||||
@@ -0,0 +1,13 @@
|
||||
// @ts-check
|
||||
|
||||
export { blacklist } from "./blacklist.js";
|
||||
export { Card } from "./Card.js";
|
||||
export { I18n } from "./I18n.js";
|
||||
export { icons } from "./icons.js";
|
||||
export { retryer } from "./retryer.js";
|
||||
export {
|
||||
ERROR_CARD_LENGTH,
|
||||
renderError,
|
||||
flexLayout,
|
||||
measureText,
|
||||
} from "./render.js";
|
||||
@@ -0,0 +1,651 @@
|
||||
{
|
||||
"1C Enterprise": "#814CCC",
|
||||
"2-Dimensional Array": "#38761D",
|
||||
"4D": "#004289",
|
||||
"ABAP": "#E8274B",
|
||||
"ABAP CDS": "#555e25",
|
||||
"AGS Script": "#B9D9FF",
|
||||
"AIDL": "#34EB6B",
|
||||
"AL": "#3AA2B5",
|
||||
"AMPL": "#E6EFBB",
|
||||
"ANTLR": "#9DC3FF",
|
||||
"API Blueprint": "#2ACCA8",
|
||||
"APL": "#5A8164",
|
||||
"ASP.NET": "#9400ff",
|
||||
"ATS": "#1ac620",
|
||||
"ActionScript": "#882B0F",
|
||||
"Ada": "#02f88c",
|
||||
"Adblock Filter List": "#800000",
|
||||
"Adobe Font Metrics": "#fa0f00",
|
||||
"Agda": "#315665",
|
||||
"Aiken": "#640ff8",
|
||||
"Alloy": "#64C800",
|
||||
"Alpine Abuild": "#0D597F",
|
||||
"Altium Designer": "#A89663",
|
||||
"AngelScript": "#C7D7DC",
|
||||
"Answer Set Programming": "#A9CC29",
|
||||
"Ant Build System": "#A9157E",
|
||||
"Antlers": "#ff269e",
|
||||
"ApacheConf": "#d12127",
|
||||
"Apex": "#1797c0",
|
||||
"Apollo Guidance Computer": "#0B3D91",
|
||||
"AppleScript": "#101F1F",
|
||||
"Arc": "#aa2afe",
|
||||
"ArkTS": "#0080ff",
|
||||
"AsciiDoc": "#73a0c5",
|
||||
"AspectJ": "#a957b0",
|
||||
"Assembly": "#6E4C13",
|
||||
"Astro": "#ff5a03",
|
||||
"Asymptote": "#ff0000",
|
||||
"Augeas": "#9CC134",
|
||||
"AutoHotkey": "#6594b9",
|
||||
"AutoIt": "#1C3552",
|
||||
"Avro IDL": "#0040FF",
|
||||
"Awk": "#c30e9b",
|
||||
"B4X": "#00e4ff",
|
||||
"BASIC": "#ff0000",
|
||||
"BQN": "#2b7067",
|
||||
"Ballerina": "#FF5000",
|
||||
"Batchfile": "#C1F12E",
|
||||
"Beef": "#a52f4e",
|
||||
"Berry": "#15A13C",
|
||||
"BibTeX": "#778899",
|
||||
"Bicep": "#519aba",
|
||||
"Bikeshed": "#5562ac",
|
||||
"Bison": "#6A463F",
|
||||
"BitBake": "#00bce4",
|
||||
"Blade": "#f7523f",
|
||||
"BlitzBasic": "#00FFAE",
|
||||
"BlitzMax": "#cd6400",
|
||||
"Bluespec": "#12223c",
|
||||
"Bluespec BH": "#12223c",
|
||||
"Boo": "#d4bec1",
|
||||
"Boogie": "#c80fa0",
|
||||
"Brainfuck": "#2F2530",
|
||||
"BrighterScript": "#66AABB",
|
||||
"Brightscript": "#662D91",
|
||||
"Browserslist": "#ffd539",
|
||||
"Bru": "#F4AA41",
|
||||
"BuildStream": "#006bff",
|
||||
"C": "#555555",
|
||||
"C#": "#178600",
|
||||
"C++": "#f34b7d",
|
||||
"C3": "#2563eb",
|
||||
"CAP CDS": "#0092d1",
|
||||
"CLIPS": "#00A300",
|
||||
"CMake": "#DA3434",
|
||||
"COLLADA": "#F1A42B",
|
||||
"CSON": "#244776",
|
||||
"CSS": "#663399",
|
||||
"CSV": "#237346",
|
||||
"CUE": "#5886E1",
|
||||
"CWeb": "#00007a",
|
||||
"Cabal Config": "#483465",
|
||||
"Caddyfile": "#22b638",
|
||||
"Cadence": "#00ef8b",
|
||||
"Cairo": "#ff4a48",
|
||||
"Cairo Zero": "#ff4a48",
|
||||
"CameLIGO": "#3be133",
|
||||
"Cangjie": "#00868B",
|
||||
"Cap'n Proto": "#c42727",
|
||||
"Carbon": "#222222",
|
||||
"Ceylon": "#dfa535",
|
||||
"Chapel": "#8dc63f",
|
||||
"ChucK": "#3f8000",
|
||||
"Circom": "#707575",
|
||||
"Cirru": "#ccccff",
|
||||
"Clarion": "#db901e",
|
||||
"Clarity": "#5546ff",
|
||||
"Classic ASP": "#6a40fd",
|
||||
"Clean": "#3F85AF",
|
||||
"Click": "#E4E6F3",
|
||||
"Clojure": "#db5855",
|
||||
"Closure Templates": "#0d948f",
|
||||
"Cloud Firestore Security Rules": "#FFA000",
|
||||
"Clue": "#0009b5",
|
||||
"CodeQL": "#140f46",
|
||||
"CoffeeScript": "#244776",
|
||||
"ColdFusion": "#ed2cd6",
|
||||
"ColdFusion CFC": "#ed2cd6",
|
||||
"Common Lisp": "#3fb68b",
|
||||
"Common Workflow Language": "#B5314C",
|
||||
"Component Pascal": "#B0CE4E",
|
||||
"Cooklang": "#E15A29",
|
||||
"Crystal": "#000100",
|
||||
"Csound": "#1a1a1a",
|
||||
"Csound Document": "#1a1a1a",
|
||||
"Csound Score": "#1a1a1a",
|
||||
"Cuda": "#3A4E3A",
|
||||
"Curry": "#531242",
|
||||
"Cylc": "#00b3fd",
|
||||
"Cypher": "#34c0eb",
|
||||
"Cython": "#fedf5b",
|
||||
"D": "#ba595e",
|
||||
"D2": "#526ee8",
|
||||
"DM": "#447265",
|
||||
"Dafny": "#FFEC25",
|
||||
"Darcs Patch": "#8eff23",
|
||||
"Dart": "#00B4AB",
|
||||
"Daslang": "#d3d3d3",
|
||||
"DataWeave": "#003a52",
|
||||
"Debian Package Control File": "#D70751",
|
||||
"DenizenScript": "#FBEE96",
|
||||
"Dhall": "#dfafff",
|
||||
"DirectX 3D File": "#aace60",
|
||||
"Dockerfile": "#384d54",
|
||||
"Dogescript": "#cca760",
|
||||
"Dotenv": "#e5d559",
|
||||
"Dune": "#89421e",
|
||||
"Dylan": "#6c616e",
|
||||
"E": "#ccce35",
|
||||
"ECL": "#8a1267",
|
||||
"ECLiPSe": "#001d9d",
|
||||
"EJS": "#a91e50",
|
||||
"EQ": "#a78649",
|
||||
"Earthly": "#2af0ff",
|
||||
"Easybuild": "#069406",
|
||||
"Ecere Projects": "#913960",
|
||||
"Ecmarkup": "#eb8131",
|
||||
"Edge": "#0dffe0",
|
||||
"EdgeQL": "#31A7FF",
|
||||
"EditorConfig": "#fff1f2",
|
||||
"Eiffel": "#4d6977",
|
||||
"Elixir": "#6e4a7e",
|
||||
"Elm": "#60B5CC",
|
||||
"Elvish": "#55BB55",
|
||||
"Elvish Transcript": "#55BB55",
|
||||
"Emacs Lisp": "#c065db",
|
||||
"EmberScript": "#FFF4F3",
|
||||
"Erlang": "#B83998",
|
||||
"Euphoria": "#FF790B",
|
||||
"F#": "#b845fc",
|
||||
"F*": "#572e30",
|
||||
"FIGlet Font": "#FFDDBB",
|
||||
"FIRRTL": "#2f632f",
|
||||
"FLUX": "#88ccff",
|
||||
"Factor": "#636746",
|
||||
"Fancy": "#7b9db4",
|
||||
"Fantom": "#14253c",
|
||||
"Faust": "#c37240",
|
||||
"Fennel": "#fff3d7",
|
||||
"Filebench WML": "#F6B900",
|
||||
"Flix": "#d44a45",
|
||||
"Fluent": "#ffcc33",
|
||||
"Forth": "#341708",
|
||||
"Fortran": "#4d41b1",
|
||||
"Fortran Free Form": "#4d41b1",
|
||||
"FreeBASIC": "#141AC9",
|
||||
"FreeMarker": "#0050b2",
|
||||
"Frege": "#00cafe",
|
||||
"Futhark": "#5f021f",
|
||||
"G-code": "#D08CF2",
|
||||
"GAML": "#FFC766",
|
||||
"GAMS": "#f49a22",
|
||||
"GAP": "#0000cc",
|
||||
"GCC Machine Description": "#FFCFAB",
|
||||
"GDScript": "#355570",
|
||||
"GDShader": "#478CBF",
|
||||
"GEDCOM": "#003058",
|
||||
"GLSL": "#5686a5",
|
||||
"GSC": "#FF6800",
|
||||
"Game Maker Language": "#71b417",
|
||||
"Gemfile.lock": "#701516",
|
||||
"Gemini": "#ff6900",
|
||||
"Genero 4gl": "#63408e",
|
||||
"Genero per": "#d8df39",
|
||||
"Genie": "#fb855d",
|
||||
"Genshi": "#951531",
|
||||
"Gentoo Ebuild": "#9400ff",
|
||||
"Gentoo Eclass": "#9400ff",
|
||||
"Gerber Image": "#d20b00",
|
||||
"Gherkin": "#5B2063",
|
||||
"Git Attributes": "#F44D27",
|
||||
"Git Commit": "#F44D27",
|
||||
"Git Config": "#F44D27",
|
||||
"Git Revision List": "#F44D27",
|
||||
"Gleam": "#ffaff3",
|
||||
"Glimmer JS": "#F5835F",
|
||||
"Glimmer TS": "#3178c6",
|
||||
"Glyph": "#c1ac7f",
|
||||
"Gnuplot": "#f0a9f0",
|
||||
"Go": "#00ADD8",
|
||||
"Go Checksums": "#00ADD8",
|
||||
"Go Module": "#00ADD8",
|
||||
"Go Workspace": "#00ADD8",
|
||||
"Godot Resource": "#355570",
|
||||
"Golo": "#88562A",
|
||||
"Gosu": "#82937f",
|
||||
"Grace": "#615f8b",
|
||||
"Gradle": "#02303a",
|
||||
"Gradle Kotlin DSL": "#02303a",
|
||||
"Grammatical Framework": "#ff0000",
|
||||
"GraphQL": "#e10098",
|
||||
"Graphviz (DOT)": "#2596be",
|
||||
"Groovy": "#4298b8",
|
||||
"Groovy Server Pages": "#4298b8",
|
||||
"HAProxy": "#106da9",
|
||||
"HCL": "#844FBA",
|
||||
"HIP": "#4F3A4F",
|
||||
"HLSL": "#aace60",
|
||||
"HOCON": "#9ff8ee",
|
||||
"HTML": "#e34c26",
|
||||
"HTML+ECR": "#2e1052",
|
||||
"HTML+EEX": "#6e4a7e",
|
||||
"HTML+ERB": "#701516",
|
||||
"HTML+PHP": "#4f5d95",
|
||||
"HTML+Razor": "#512be4",
|
||||
"HTTP": "#005C9C",
|
||||
"HXML": "#f68712",
|
||||
"Hack": "#878787",
|
||||
"Haml": "#ece2a9",
|
||||
"Handlebars": "#f7931e",
|
||||
"Harbour": "#0e60e3",
|
||||
"Hare": "#9d7424",
|
||||
"Haskell": "#5e5086",
|
||||
"Haxe": "#df7900",
|
||||
"HiveQL": "#dce200",
|
||||
"HolyC": "#ffefaf",
|
||||
"Hosts File": "#308888",
|
||||
"Hurl": "#FF0288",
|
||||
"Hy": "#7790B2",
|
||||
"IDL": "#a3522f",
|
||||
"IGOR Pro": "#0000cc",
|
||||
"INI": "#d1dbe0",
|
||||
"ISPC": "#2D68B1",
|
||||
"Idris": "#b30000",
|
||||
"Ignore List": "#000000",
|
||||
"ImageJ Macro": "#99AAFF",
|
||||
"Imba": "#16cec6",
|
||||
"Inno Setup": "#264b99",
|
||||
"Io": "#a9188d",
|
||||
"Ioke": "#078193",
|
||||
"Isabelle": "#FEFE00",
|
||||
"Isabelle ROOT": "#FEFE00",
|
||||
"J": "#9EEDFF",
|
||||
"JAR Manifest": "#b07219",
|
||||
"JCL": "#d90e09",
|
||||
"JFlex": "#DBCA00",
|
||||
"JSON": "#292929",
|
||||
"JSON with Comments": "#292929",
|
||||
"JSON5": "#267CB9",
|
||||
"JSONLD": "#0c479c",
|
||||
"JSONiq": "#40d47e",
|
||||
"Jai": "#ab8b4b",
|
||||
"Janet": "#0886a5",
|
||||
"Jasmin": "#d03600",
|
||||
"Java": "#b07219",
|
||||
"Java Properties": "#2A6277",
|
||||
"Java Server Pages": "#2A6277",
|
||||
"Java Template Engine": "#2A6277",
|
||||
"JavaScript": "#f1e05a",
|
||||
"JavaScript+ERB": "#f1e05a",
|
||||
"Jest Snapshot": "#15c213",
|
||||
"JetBrains MPS": "#21D789",
|
||||
"Jinja": "#a52a22",
|
||||
"Jison": "#56b3cb",
|
||||
"Jison Lex": "#56b3cb",
|
||||
"Jolie": "#843179",
|
||||
"Jsonnet": "#0064bd",
|
||||
"Julia": "#a270ba",
|
||||
"Julia REPL": "#a270ba",
|
||||
"Jupyter Notebook": "#DA5B0B",
|
||||
"Just": "#384d54",
|
||||
"KDL": "#ffb3b3",
|
||||
"KRL": "#28430A",
|
||||
"Kaitai Struct": "#773b37",
|
||||
"KakouneScript": "#6f8042",
|
||||
"KerboScript": "#41adf0",
|
||||
"KiCad Layout": "#2f4aab",
|
||||
"KiCad Legacy Layout": "#2f4aab",
|
||||
"KiCad Schematic": "#2f4aab",
|
||||
"KoLmafia ASH": "#B9D9B9",
|
||||
"Koka": "#215166",
|
||||
"Kotlin": "#A97BFF",
|
||||
"LFE": "#4C3023",
|
||||
"LLVM": "#185619",
|
||||
"LOLCODE": "#cc9900",
|
||||
"LSL": "#3d9970",
|
||||
"LabVIEW": "#fede06",
|
||||
"Lark": "#2980B9",
|
||||
"Lasso": "#999999",
|
||||
"Latte": "#f2a542",
|
||||
"Leo": "#C4FFC2",
|
||||
"Less": "#1d365d",
|
||||
"Lex": "#DBCA00",
|
||||
"LigoLANG": "#0e74ff",
|
||||
"LilyPond": "#9ccc7c",
|
||||
"Liquid": "#67b8de",
|
||||
"Literate Agda": "#315665",
|
||||
"Literate CoffeeScript": "#244776",
|
||||
"Literate Haskell": "#5e5086",
|
||||
"LiveCode Script": "#0c5ba5",
|
||||
"LiveScript": "#499886",
|
||||
"Logtalk": "#295b9a",
|
||||
"LookML": "#652B81",
|
||||
"Lua": "#000080",
|
||||
"Luau": "#00A2FF",
|
||||
"M3U": "#179C7D",
|
||||
"MATLAB": "#e16737",
|
||||
"MAXScript": "#00a6a6",
|
||||
"MDX": "#fcb32c",
|
||||
"MLIR": "#5EC8DB",
|
||||
"MQL4": "#62A8D6",
|
||||
"MQL5": "#4A76B8",
|
||||
"MTML": "#b7e1f4",
|
||||
"Macaulay2": "#d8ffff",
|
||||
"Makefile": "#427819",
|
||||
"Mako": "#7e858d",
|
||||
"Markdown": "#083fa1",
|
||||
"Marko": "#42bff2",
|
||||
"Mask": "#f97732",
|
||||
"Max": "#c4a79c",
|
||||
"Mercury": "#ff2b2b",
|
||||
"Mermaid": "#ff3670",
|
||||
"Meson": "#007800",
|
||||
"Metal": "#8f14e9",
|
||||
"MiniYAML": "#ff1111",
|
||||
"MiniZinc": "#06a9e6",
|
||||
"Mint": "#02b046",
|
||||
"Mirah": "#c7a938",
|
||||
"Modelica": "#de1d31",
|
||||
"Modula-2": "#10253f",
|
||||
"Modula-3": "#223388",
|
||||
"Mojo": "#ff4c1f",
|
||||
"Monkey C": "#8D6747",
|
||||
"MoonBit": "#b92381",
|
||||
"MoonScript": "#ff4585",
|
||||
"Motoko": "#fbb03b",
|
||||
"Motorola 68K Assembly": "#005daa",
|
||||
"Move": "#4a137a",
|
||||
"Mustache": "#724b3b",
|
||||
"NCL": "#28431f",
|
||||
"NMODL": "#00356B",
|
||||
"NPM Config": "#cb3837",
|
||||
"NWScript": "#111522",
|
||||
"Nasal": "#1d2c4e",
|
||||
"Nearley": "#990000",
|
||||
"Nemerle": "#3d3c6e",
|
||||
"NetLinx": "#0aa0ff",
|
||||
"NetLinx+ERB": "#747faa",
|
||||
"NetLogo": "#ff6375",
|
||||
"NewLisp": "#87AED7",
|
||||
"Nextflow": "#3ac486",
|
||||
"Nginx": "#009639",
|
||||
"Nickel": "#E0C3FC",
|
||||
"Nim": "#ffc200",
|
||||
"Nit": "#009917",
|
||||
"Nix": "#7e7eff",
|
||||
"Noir": "#2f1f49",
|
||||
"Nu": "#c9df40",
|
||||
"NumPy": "#9C8AF9",
|
||||
"Nunjucks": "#3d8137",
|
||||
"Nushell": "#4E9906",
|
||||
"OASv2-json": "#85ea2d",
|
||||
"OASv2-yaml": "#85ea2d",
|
||||
"OASv3-json": "#85ea2d",
|
||||
"OASv3-yaml": "#85ea2d",
|
||||
"OCaml": "#ef7a08",
|
||||
"OMNeT++ MSG": "#a0e0a0",
|
||||
"OMNeT++ NED": "#08607c",
|
||||
"ObjectScript": "#424893",
|
||||
"Objective-C": "#438eff",
|
||||
"Objective-C++": "#6866fb",
|
||||
"Objective-J": "#ff0c5a",
|
||||
"Odin": "#60AFFE",
|
||||
"Omgrofl": "#cabbff",
|
||||
"Opal": "#f7ede0",
|
||||
"Open Policy Agent": "#7d9199",
|
||||
"OpenAPI Specification v2": "#85ea2d",
|
||||
"OpenAPI Specification v3": "#85ea2d",
|
||||
"OpenCL": "#ed2e2d",
|
||||
"OpenEdge ABL": "#5ce600",
|
||||
"OpenQASM": "#AA70FF",
|
||||
"OpenSCAD": "#e5cd45",
|
||||
"Option List": "#476732",
|
||||
"Org": "#77aa99",
|
||||
"OverpassQL": "#cce2aa",
|
||||
"Oxygene": "#cdd0e3",
|
||||
"Oz": "#fab738",
|
||||
"P4": "#7055b5",
|
||||
"PDDL": "#0d00ff",
|
||||
"PEG.js": "#234d6b",
|
||||
"PHP": "#4F5D95",
|
||||
"PLSQL": "#dad8d8",
|
||||
"PLpgSQL": "#336790",
|
||||
"POV-Ray SDL": "#6bac65",
|
||||
"Pact": "#F7A8B8",
|
||||
"Pan": "#cc0000",
|
||||
"Papyrus": "#6600cc",
|
||||
"Parrot": "#f3ca0a",
|
||||
"Pascal": "#E3F171",
|
||||
"Pawn": "#dbb284",
|
||||
"Pep8": "#C76F5B",
|
||||
"Perl": "#0298c3",
|
||||
"PicoLisp": "#6067af",
|
||||
"PigLatin": "#fcd7de",
|
||||
"Pike": "#005390",
|
||||
"Pip Requirements": "#FFD343",
|
||||
"Pkl": "#6b9543",
|
||||
"PlantUML": "#fbbd16",
|
||||
"PogoScript": "#d80074",
|
||||
"Polar": "#ae81ff",
|
||||
"Portugol": "#f8bd00",
|
||||
"PostCSS": "#dc3a0c",
|
||||
"PostScript": "#da291c",
|
||||
"PowerBuilder": "#8f0f8d",
|
||||
"PowerShell": "#012456",
|
||||
"Praat": "#c8506d",
|
||||
"Prisma": "#0c344b",
|
||||
"Processing": "#0096D8",
|
||||
"Procfile": "#3B2F63",
|
||||
"Prolog": "#74283c",
|
||||
"Promela": "#de0000",
|
||||
"Propeller Spin": "#7fa2a7",
|
||||
"Pug": "#a86454",
|
||||
"Puppet": "#302B6D",
|
||||
"PureBasic": "#5a6986",
|
||||
"PureScript": "#1D222D",
|
||||
"Pyret": "#ee1e10",
|
||||
"Python": "#3572A5",
|
||||
"Python console": "#3572A5",
|
||||
"Python traceback": "#3572A5",
|
||||
"Q#": "#fed659",
|
||||
"QML": "#44a51c",
|
||||
"Qt Script": "#00b841",
|
||||
"Quake": "#882233",
|
||||
"QuakeC": "#975777",
|
||||
"QuickBASIC": "#008080",
|
||||
"R": "#198CE7",
|
||||
"RAML": "#77d9fb",
|
||||
"RBS": "#701516",
|
||||
"RDoc": "#701516",
|
||||
"REXX": "#d90e09",
|
||||
"RMarkdown": "#198ce7",
|
||||
"RON": "#a62c00",
|
||||
"ROS Interface": "#22314e",
|
||||
"RPGLE": "#2BDE21",
|
||||
"RUNOFF": "#665a4e",
|
||||
"Racket": "#3c5caa",
|
||||
"Ragel": "#9d5200",
|
||||
"Raku": "#0000fb",
|
||||
"Rascal": "#fffaa0",
|
||||
"ReScript": "#ed5051",
|
||||
"Reason": "#ff5847",
|
||||
"ReasonLIGO": "#ff5847",
|
||||
"Rebol": "#358a5b",
|
||||
"Record Jar": "#0673ba",
|
||||
"Red": "#f50000",
|
||||
"Regular Expression": "#009a00",
|
||||
"Ren'Py": "#ff7f7f",
|
||||
"Rez": "#FFDAB3",
|
||||
"Ring": "#2D54CB",
|
||||
"Riot": "#A71E49",
|
||||
"RobotFramework": "#00c0b5",
|
||||
"Roc": "#7c38f5",
|
||||
"Rocq Prover": "#d0b68c",
|
||||
"Roff": "#ecdebe",
|
||||
"Roff Manpage": "#ecdebe",
|
||||
"Rouge": "#cc0088",
|
||||
"RouterOS Script": "#DE3941",
|
||||
"Ruby": "#701516",
|
||||
"Rust": "#dea584",
|
||||
"SAS": "#B34936",
|
||||
"SCSS": "#c6538c",
|
||||
"SPARQL": "#0C4597",
|
||||
"SQF": "#3F3F3F",
|
||||
"SQL": "#e38c00",
|
||||
"SQLPL": "#e38c00",
|
||||
"SRecode Template": "#348a34",
|
||||
"STL": "#373b5e",
|
||||
"SVG": "#ff9900",
|
||||
"Sail": "#259dd5",
|
||||
"SaltStack": "#646464",
|
||||
"Sass": "#a53b70",
|
||||
"Scala": "#c22d40",
|
||||
"Scaml": "#bd181a",
|
||||
"Scenic": "#fdc700",
|
||||
"Scheme": "#1e4aec",
|
||||
"Scilab": "#ca0f21",
|
||||
"Self": "#0579aa",
|
||||
"ShaderLab": "#222c37",
|
||||
"Shell": "#89e051",
|
||||
"ShellCheck Config": "#cecfcb",
|
||||
"Shen": "#120F14",
|
||||
"Simple File Verification": "#C9BFED",
|
||||
"Singularity": "#64E6AD",
|
||||
"Slang": "#1fbec9",
|
||||
"Slash": "#007eff",
|
||||
"Slice": "#003fa2",
|
||||
"Slim": "#2b2b2b",
|
||||
"Slint": "#2379F4",
|
||||
"SmPL": "#c94949",
|
||||
"Smalltalk": "#596706",
|
||||
"Smarty": "#f0c040",
|
||||
"Smithy": "#c44536",
|
||||
"Snakemake": "#419179",
|
||||
"Solidity": "#AA6746",
|
||||
"SourcePawn": "#f69e1d",
|
||||
"Squirrel": "#800000",
|
||||
"Stan": "#b2011d",
|
||||
"Standard ML": "#dc566d",
|
||||
"Starlark": "#76d275",
|
||||
"Stata": "#1a5f91",
|
||||
"StringTemplate": "#3fb34f",
|
||||
"Stylus": "#ff6347",
|
||||
"SubRip Text": "#9e0101",
|
||||
"SugarSS": "#2fcc9f",
|
||||
"SuperCollider": "#46390b",
|
||||
"Survex data": "#ffcc99",
|
||||
"Svelte": "#ff3e00",
|
||||
"Sway": "#00F58C",
|
||||
"Sweave": "#198ce7",
|
||||
"Swift": "#F05138",
|
||||
"SystemVerilog": "#DAE1C2",
|
||||
"TI Program": "#A0AA87",
|
||||
"TL-Verilog": "#C40023",
|
||||
"TLA": "#4b0079",
|
||||
"TOML": "#9c4221",
|
||||
"TSQL": "#e38c00",
|
||||
"TSV": "#237346",
|
||||
"TSX": "#3178c6",
|
||||
"TXL": "#0178b8",
|
||||
"Tact": "#48b5ff",
|
||||
"Talon": "#333333",
|
||||
"Tcl": "#e4cc98",
|
||||
"TeX": "#3D6117",
|
||||
"Teal": "#00B1BC",
|
||||
"Terra": "#00004c",
|
||||
"Terraform Template": "#7b42bb",
|
||||
"TextGrid": "#c8506d",
|
||||
"TextMate Properties": "#df66e4",
|
||||
"Textile": "#ffe7ac",
|
||||
"Thrift": "#D12127",
|
||||
"Toit": "#c2c9fb",
|
||||
"Tor Config": "#59316b",
|
||||
"Tree-sitter Query": "#8ea64c",
|
||||
"Turing": "#cf142b",
|
||||
"Twig": "#c1d026",
|
||||
"TypeScript": "#3178c6",
|
||||
"TypeSpec": "#4A3665",
|
||||
"Typst": "#239dad",
|
||||
"Unified Parallel C": "#4e3617",
|
||||
"Unity3D Asset": "#222c37",
|
||||
"Uno": "#9933cc",
|
||||
"UnrealScript": "#a54c4d",
|
||||
"Untyped Plutus Core": "#36adbd",
|
||||
"UrWeb": "#ccccee",
|
||||
"V": "#4f87c4",
|
||||
"VBA": "#867db1",
|
||||
"VBScript": "#15dcdc",
|
||||
"VCL": "#148AA8",
|
||||
"VHDL": "#adb2cb",
|
||||
"Vala": "#a56de2",
|
||||
"Valve Data Format": "#f26025",
|
||||
"Velocity Template Language": "#507cff",
|
||||
"Vento": "#ff0080",
|
||||
"Verilog": "#b2b7f8",
|
||||
"Vim Help File": "#199f4b",
|
||||
"Vim Script": "#199f4b",
|
||||
"Vim Snippet": "#199f4b",
|
||||
"Visual Basic .NET": "#945db7",
|
||||
"Visual Basic 6.0": "#2c6353",
|
||||
"Volt": "#1F1F1F",
|
||||
"Vue": "#41b883",
|
||||
"Vyper": "#9F4CF2",
|
||||
"WDL": "#42f1f4",
|
||||
"WGSL": "#1a5e9a",
|
||||
"Web Ontology Language": "#5b70bd",
|
||||
"WebAssembly": "#04133b",
|
||||
"WebAssembly Interface Type": "#6250e7",
|
||||
"Whiley": "#d5c397",
|
||||
"Wikitext": "#fc5757",
|
||||
"Windows Registry Entries": "#52d5ff",
|
||||
"Witcher Script": "#ff0000",
|
||||
"Wolfram Language": "#dd1100",
|
||||
"Wollok": "#a23738",
|
||||
"World of Warcraft Addon Data": "#f7e43f",
|
||||
"Wren": "#383838",
|
||||
"X10": "#4B6BEF",
|
||||
"XC": "#99DA07",
|
||||
"XML": "#0060ac",
|
||||
"XML Property List": "#0060ac",
|
||||
"XQuery": "#5232e7",
|
||||
"XSLT": "#EB8CEB",
|
||||
"Xmake": "#22a079",
|
||||
"Xojo": "#81bd41",
|
||||
"Xonsh": "#285EEF",
|
||||
"Xtend": "#24255d",
|
||||
"YAML": "#cb171e",
|
||||
"YARA": "#220000",
|
||||
"YASnippet": "#32AB90",
|
||||
"Yacc": "#4B6C4B",
|
||||
"Yul": "#794932",
|
||||
"ZAP": "#0d665e",
|
||||
"ZIL": "#dc75e5",
|
||||
"ZenScript": "#00BCD1",
|
||||
"Zephir": "#118f9e",
|
||||
"Zig": "#ec915c",
|
||||
"Zimpl": "#d67711",
|
||||
"Zmodel": "#ff7100",
|
||||
"crontab": "#ead7ac",
|
||||
"eC": "#913960",
|
||||
"fish": "#4aae47",
|
||||
"hoon": "#00b171",
|
||||
"iCalendar": "#ec564c",
|
||||
"jq": "#c7254e",
|
||||
"kvlang": "#1da6e0",
|
||||
"mIRC Script": "#3d57c3",
|
||||
"mcfunction": "#E22837",
|
||||
"mdsvex": "#5f9ea0",
|
||||
"mupad": "#244963",
|
||||
"nanorc": "#2d004d",
|
||||
"nesC": "#94B0C7",
|
||||
"ooc": "#b0b77e",
|
||||
"q": "#0040cd",
|
||||
"reStructuredText": "#141414",
|
||||
"sed": "#64b970",
|
||||
"templ": "#66D0DD",
|
||||
"vCard": "#ee2647",
|
||||
"wisp": "#7582D1",
|
||||
"xBase": "#403a40"
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
// @ts-check
|
||||
|
||||
const noop = () => {};
|
||||
|
||||
/**
|
||||
* Return console instance based on the environment.
|
||||
*
|
||||
* @type {Console | {log: () => void, error: () => void}}
|
||||
*/
|
||||
const logger =
|
||||
process.env.NODE_ENV === "test" ? { log: noop, error: noop } : console;
|
||||
|
||||
export { logger };
|
||||
export default logger;
|
||||
@@ -0,0 +1,172 @@
|
||||
// @ts-check
|
||||
|
||||
import toEmoji from "emoji-name-map";
|
||||
|
||||
const OWNER_AFFILIATIONS = ["OWNER", "COLLABORATOR", "ORGANIZATION_MEMBER"];
|
||||
|
||||
/**
|
||||
* Returns boolean if value is either "true" or "false" else the value as it is.
|
||||
*
|
||||
* @param {string | boolean} value The value to parse.
|
||||
* @returns {boolean | undefined } The parsed value.
|
||||
*/
|
||||
const parseBoolean = (value) => {
|
||||
if (typeof value === "boolean") {
|
||||
return value;
|
||||
}
|
||||
|
||||
if (typeof value === "string") {
|
||||
if (value.toLowerCase() === "true") {
|
||||
return true;
|
||||
} else if (value.toLowerCase() === "false") {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
/**
|
||||
* Parse string to array of strings.
|
||||
*
|
||||
* @param {string} str The string to parse.
|
||||
* @returns {string[]} The array of strings.
|
||||
*/
|
||||
const parseArray = (str) => {
|
||||
if (!str) {
|
||||
return [];
|
||||
}
|
||||
return str.split(",");
|
||||
};
|
||||
|
||||
/**
|
||||
* Clamp the given number between the given range.
|
||||
*
|
||||
* @param {number} number The number to clamp.
|
||||
* @param {number} min The minimum value.
|
||||
* @param {number} max The maximum value.
|
||||
* @returns {number} The clamped number.
|
||||
*/
|
||||
const clampValue = (number, min, max) => {
|
||||
// @ts-ignore
|
||||
if (Number.isNaN(parseInt(number, 10))) {
|
||||
return min;
|
||||
}
|
||||
return Math.max(min, Math.min(number, max));
|
||||
};
|
||||
|
||||
/**
|
||||
* Lowercase and trim string.
|
||||
*
|
||||
* @param {string} name String to lowercase and trim.
|
||||
* @returns {string} Lowercased and trimmed string.
|
||||
*/
|
||||
const lowercaseTrim = (name) => name.toLowerCase().trim();
|
||||
|
||||
/**
|
||||
* Split array of languages in two columns.
|
||||
*
|
||||
* @template T Language object.
|
||||
* @param {Array<T>} arr Array of languages.
|
||||
* @param {number} perChunk Number of languages per column.
|
||||
* @returns {Array<T>} Array of languages split in two columns.
|
||||
*/
|
||||
const chunkArray = (arr, perChunk) => {
|
||||
return arr.reduce((resultArray, item, index) => {
|
||||
const chunkIndex = Math.floor(index / perChunk);
|
||||
|
||||
if (!resultArray[chunkIndex]) {
|
||||
// @ts-ignore
|
||||
resultArray[chunkIndex] = []; // start a new chunk
|
||||
}
|
||||
|
||||
// @ts-ignore
|
||||
resultArray[chunkIndex].push(item);
|
||||
|
||||
return resultArray;
|
||||
}, []);
|
||||
};
|
||||
|
||||
/**
|
||||
* Parse emoji from string.
|
||||
*
|
||||
* @param {string} str String to parse emoji from.
|
||||
* @returns {string} String with emoji parsed.
|
||||
*/
|
||||
const parseEmojis = (str) => {
|
||||
if (!str) {
|
||||
throw new Error("[parseEmoji]: str argument not provided");
|
||||
}
|
||||
return str.replace(/:\w+:/gm, (emoji) => {
|
||||
return toEmoji.get(emoji) || "";
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Get diff in minutes between two dates.
|
||||
*
|
||||
* @param {Date} d1 First date.
|
||||
* @param {Date} d2 Second date.
|
||||
* @returns {number} Number of minutes between the two dates.
|
||||
*/
|
||||
const dateDiff = (d1, d2) => {
|
||||
const date1 = new Date(d1);
|
||||
const date2 = new Date(d2);
|
||||
const diff = date1.getTime() - date2.getTime();
|
||||
return Math.round(diff / (1000 * 60));
|
||||
};
|
||||
|
||||
/**
|
||||
* Parse owner affiliations.
|
||||
*
|
||||
* @param {string[]} affiliations input affiliations to be parsed.
|
||||
* @returns {string[]} Parsed affiliations.
|
||||
*
|
||||
* @throws {CustomError} If affiliations contains invalid values.
|
||||
*/
|
||||
const parseOwnerAffiliations = (affiliations) => {
|
||||
// Set default value for ownerAffiliations.
|
||||
// NOTE: Done here since parseArray() will always return an empty array even nothing
|
||||
//was specified.
|
||||
affiliations =
|
||||
affiliations && affiliations.length > 0
|
||||
? affiliations.map((affiliation) => affiliation.toUpperCase())
|
||||
: ["OWNER"];
|
||||
|
||||
// Check if ownerAffiliations contains valid values.
|
||||
if (
|
||||
affiliations.some(
|
||||
(affiliation) => !OWNER_AFFILIATIONS.includes(affiliation),
|
||||
)
|
||||
) {
|
||||
throw new CustomError(
|
||||
"Invalid query parameter",
|
||||
CustomError.INVALID_AFFILIATION,
|
||||
);
|
||||
}
|
||||
return affiliations;
|
||||
};
|
||||
|
||||
const buildSearchFilter = (repos = [], owners = []) => {
|
||||
let repoFilter =
|
||||
Array.isArray(repos) && repos.length > 0
|
||||
? repos.map((r) => `repo:${r} `).join("")
|
||||
: "";
|
||||
let orgFilter =
|
||||
Array.isArray(owners) && owners.length > 0
|
||||
? owners.map((o) => `owner:${o} `).join("")
|
||||
: "";
|
||||
return repoFilter + orgFilter;
|
||||
};
|
||||
|
||||
export {
|
||||
OWNER_AFFILIATIONS,
|
||||
parseBoolean,
|
||||
parseArray,
|
||||
clampValue,
|
||||
lowercaseTrim,
|
||||
chunkArray,
|
||||
parseEmojis,
|
||||
dateDiff,
|
||||
parseOwnerAffiliations,
|
||||
buildSearchFilter,
|
||||
};
|
||||
@@ -0,0 +1,239 @@
|
||||
// @ts-check
|
||||
|
||||
import { SECONDARY_ERROR_MESSAGES, TRY_AGAIN_LATER } from "./error.js";
|
||||
import { getCardColors } from "./color.js";
|
||||
import { encodeHTML } from "./html.js";
|
||||
import { clampValue } from "./ops.js";
|
||||
|
||||
/**
|
||||
* Auto layout utility, allows us to layout things vertically or horizontally with
|
||||
* proper gaping.
|
||||
*
|
||||
* @param {object} props Function properties.
|
||||
* @param {string[]} props.items Array of items to layout.
|
||||
* @param {number} props.gap Gap between items.
|
||||
* @param {"column" | "row"=} props.direction Direction to layout items.
|
||||
* @param {number[]=} props.sizes Array of sizes for each item.
|
||||
* @returns {string[]} Array of items with proper layout.
|
||||
*/
|
||||
const flexLayout = ({ items, gap, direction, sizes = [] }) => {
|
||||
let lastSize = 0;
|
||||
// filter() for filtering out empty strings
|
||||
return items.filter(Boolean).map((item, i) => {
|
||||
const size = sizes[i] || 0;
|
||||
let transform = `translate(${lastSize}, 0)`;
|
||||
if (direction === "column") {
|
||||
transform = `translate(0, ${lastSize})`;
|
||||
}
|
||||
lastSize += size + gap;
|
||||
return `<g transform="${transform}">${item}</g>`;
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Creates a node to display the primary programming language of the repository/gist.
|
||||
*
|
||||
* @param {string} langName Language name.
|
||||
* @param {string} langColor Language color.
|
||||
* @returns {string} Language display SVG object.
|
||||
*/
|
||||
const createLanguageNode = (langName, langColor) => {
|
||||
return `
|
||||
<g data-testid="primary-lang">
|
||||
<circle data-testid="lang-color" cx="0" cy="-5" r="6" fill="${langColor}" />
|
||||
<text data-testid="lang-name" class="gray" x="15">${langName}</text>
|
||||
</g>
|
||||
`;
|
||||
};
|
||||
|
||||
/**
|
||||
* Create a node to indicate progress in percentage along a horizontal line.
|
||||
*
|
||||
* @param {Object} params Object that contains the createProgressNode parameters.
|
||||
* @param {number} params.x X-axis position.
|
||||
* @param {number} params.y Y-axis position.
|
||||
* @param {number} params.width Width of progress bar.
|
||||
* @param {string} params.color Progress color.
|
||||
* @param {number} params.progress Progress value.
|
||||
* @param {string} params.progressBarBackgroundColor Progress bar bg color.
|
||||
* @param {number} params.delay Delay before animation starts.
|
||||
* @returns {string} Progress node.
|
||||
*/
|
||||
const createProgressNode = ({
|
||||
x,
|
||||
y,
|
||||
width,
|
||||
color,
|
||||
progress,
|
||||
progressBarBackgroundColor,
|
||||
delay,
|
||||
}) => {
|
||||
const progressPercentage = clampValue(progress, 2, 100);
|
||||
|
||||
return `
|
||||
<svg width="${width}" x="${x}" y="${y}">
|
||||
<rect rx="5" ry="5" x="0" y="0" width="${width}" height="8" fill="${progressBarBackgroundColor}"></rect>
|
||||
<svg data-testid="lang-progress" width="${progressPercentage}%">
|
||||
<rect
|
||||
height="8"
|
||||
fill="${color}"
|
||||
rx="5" ry="5" x="0" y="0"
|
||||
class="lang-progress"
|
||||
style="animation-delay: ${delay}ms;"
|
||||
/>
|
||||
</svg>
|
||||
</svg>
|
||||
`;
|
||||
};
|
||||
|
||||
/**
|
||||
* Creates an icon with label to display repository/gist stats like forks, stars, etc.
|
||||
*
|
||||
* @param {string} icon The icon to display.
|
||||
* @param {number|string} label The label to display.
|
||||
* @param {string} testid The testid to assign to the label.
|
||||
* @param {number} iconSize The size of the icon.
|
||||
* @returns {string} Icon with label SVG object.
|
||||
*/
|
||||
const iconWithLabel = (icon, label, testid, iconSize) => {
|
||||
if (typeof label === "number" && label <= 0) {
|
||||
return "";
|
||||
}
|
||||
const iconSvg = `
|
||||
<svg
|
||||
class="icon"
|
||||
y="-12"
|
||||
viewBox="0 0 16 16"
|
||||
version="1.1"
|
||||
width="${iconSize}"
|
||||
height="${iconSize}"
|
||||
>
|
||||
${icon}
|
||||
</svg>
|
||||
`;
|
||||
const text = `<text data-testid="${testid}" class="gray">${label}</text>`;
|
||||
return flexLayout({ items: [iconSvg, text], gap: 20 }).join("");
|
||||
};
|
||||
|
||||
// Script parameters.
|
||||
const ERROR_CARD_LENGTH = 576.5;
|
||||
|
||||
const UPSTREAM_API_ERRORS = [
|
||||
TRY_AGAIN_LATER,
|
||||
SECONDARY_ERROR_MESSAGES.MAX_RETRY,
|
||||
];
|
||||
|
||||
/**
|
||||
* Renders error message on the card.
|
||||
*
|
||||
* @param {object} args Function arguments.
|
||||
* @param {string} args.message Main error message.
|
||||
* @param {string} [args.secondaryMessage=""] The secondary error message.
|
||||
* @param {object} [args.renderOptions={}] Render options.
|
||||
* @param {string=} args.renderOptions.title_color Card title color.
|
||||
* @param {string=} args.renderOptions.text_color Card text color.
|
||||
* @param {string=} args.renderOptions.bg_color Card background color.
|
||||
* @param {string=} args.renderOptions.border_color Card border color.
|
||||
* @param {Parameters<typeof getCardColors>[0]["theme"]=} args.renderOptions.theme Card theme.
|
||||
* @param {boolean=} args.renderOptions.show_repo_link Whether to show repo link or not.
|
||||
* @returns {string} The SVG markup.
|
||||
*/
|
||||
const renderError = ({
|
||||
message,
|
||||
secondaryMessage = "",
|
||||
renderOptions = {},
|
||||
}) => {
|
||||
const {
|
||||
title_color,
|
||||
text_color,
|
||||
bg_color,
|
||||
border_color,
|
||||
theme = "default",
|
||||
show_repo_link = true,
|
||||
} = renderOptions;
|
||||
|
||||
// returns theme based colors with proper overrides and defaults
|
||||
const { titleColor, textColor, bgColor, borderColor } = getCardColors({
|
||||
title_color,
|
||||
text_color,
|
||||
icon_color: "",
|
||||
bg_color,
|
||||
border_color,
|
||||
ring_color: "",
|
||||
theme,
|
||||
});
|
||||
|
||||
return `
|
||||
<svg width="${ERROR_CARD_LENGTH}" height="120" viewBox="0 0 ${ERROR_CARD_LENGTH} 120" fill="${bgColor}" xmlns="http://www.w3.org/2000/svg">
|
||||
<style>
|
||||
.text { font: 600 16px 'Segoe UI', Ubuntu, Sans-Serif; fill: ${titleColor} }
|
||||
.small { font: 600 12px 'Segoe UI', Ubuntu, Sans-Serif; fill: ${textColor} }
|
||||
.gray { fill: #858585 }
|
||||
</style>
|
||||
<rect x="0.5" y="0.5" width="${
|
||||
ERROR_CARD_LENGTH - 1
|
||||
}" height="99%" rx="4.5" fill="${bgColor}" stroke="${borderColor}"/>
|
||||
<text x="25" y="45" class="text">Something went wrong!${
|
||||
UPSTREAM_API_ERRORS.includes(secondaryMessage) || !show_repo_link
|
||||
? ""
|
||||
: " file an issue at https://tinyurl.com/github-stats"
|
||||
}</text>
|
||||
<text data-testid="message" x="25" y="55" class="text small">
|
||||
<tspan x="25" dy="18">${encodeHTML(message)}</tspan>
|
||||
<tspan x="25" dy="18" class="gray">${secondaryMessage}</tspan>
|
||||
</text>
|
||||
</svg>
|
||||
`;
|
||||
};
|
||||
|
||||
/**
|
||||
* Retrieve text length.
|
||||
*
|
||||
* @see https://stackoverflow.com/a/48172630/10629172
|
||||
* @param {string} str String to measure.
|
||||
* @param {number} fontSize Font size.
|
||||
* @returns {number} Text length.
|
||||
*/
|
||||
const measureText = (str, fontSize = 10) => {
|
||||
// prettier-ignore
|
||||
const widths = [
|
||||
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
|
||||
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
|
||||
0, 0, 0, 0, 0.2796875, 0.2765625,
|
||||
0.3546875, 0.5546875, 0.5546875, 0.8890625, 0.665625, 0.190625,
|
||||
0.3328125, 0.3328125, 0.3890625, 0.5828125, 0.2765625, 0.3328125,
|
||||
0.2765625, 0.3015625, 0.5546875, 0.5546875, 0.5546875, 0.5546875,
|
||||
0.5546875, 0.5546875, 0.5546875, 0.5546875, 0.5546875, 0.5546875,
|
||||
0.2765625, 0.2765625, 0.584375, 0.5828125, 0.584375, 0.5546875,
|
||||
1.0140625, 0.665625, 0.665625, 0.721875, 0.721875, 0.665625,
|
||||
0.609375, 0.7765625, 0.721875, 0.2765625, 0.5, 0.665625,
|
||||
0.5546875, 0.8328125, 0.721875, 0.7765625, 0.665625, 0.7765625,
|
||||
0.721875, 0.665625, 0.609375, 0.721875, 0.665625, 0.94375,
|
||||
0.665625, 0.665625, 0.609375, 0.2765625, 0.3546875, 0.2765625,
|
||||
0.4765625, 0.5546875, 0.3328125, 0.5546875, 0.5546875, 0.5,
|
||||
0.5546875, 0.5546875, 0.2765625, 0.5546875, 0.5546875, 0.221875,
|
||||
0.240625, 0.5, 0.221875, 0.8328125, 0.5546875, 0.5546875,
|
||||
0.5546875, 0.5546875, 0.3328125, 0.5, 0.2765625, 0.5546875,
|
||||
0.5, 0.721875, 0.5, 0.5, 0.5, 0.3546875, 0.259375, 0.353125, 0.5890625,
|
||||
];
|
||||
|
||||
const avg = 0.5279276315789471;
|
||||
return (
|
||||
str
|
||||
.split("")
|
||||
.map((c) =>
|
||||
c.charCodeAt(0) < widths.length ? widths[c.charCodeAt(0)] : avg,
|
||||
)
|
||||
.reduce((cur, acc) => acc + cur) * fontSize
|
||||
);
|
||||
};
|
||||
|
||||
export {
|
||||
ERROR_CARD_LENGTH,
|
||||
renderError,
|
||||
createLanguageNode,
|
||||
createProgressNode,
|
||||
iconWithLabel,
|
||||
flexLayout,
|
||||
measureText,
|
||||
};
|
||||
@@ -0,0 +1,104 @@
|
||||
// @ts-check
|
||||
|
||||
import { CustomError } from "./error.js";
|
||||
import { logger } from "./log.js";
|
||||
import { getUserAccessByKey, getUserAccessByName } from "./database.js";
|
||||
|
||||
function getRandomInt(max) {
|
||||
return Math.floor(Math.random() * max);
|
||||
}
|
||||
|
||||
/**
|
||||
* @typedef {import("axios").AxiosResponse} AxiosResponse Axios response.
|
||||
* @typedef {(variables: any, token: string, retriesForTests?: number) => Promise<AxiosResponse>} FetcherFunction Fetcher function.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Try to execute the fetcher function until it succeeds or the max number of retries is reached.
|
||||
*
|
||||
* @param {FetcherFunction} fetcher The fetcher function.
|
||||
* @param username GitHub username of the user whose PAT to use, if available
|
||||
* @param {any} variables Object with arguments to pass to the fetcher function.
|
||||
* @returns {Promise<any>} The response from the fetcher function.
|
||||
*/
|
||||
const retryer = async (fetcher, username, variables) => {
|
||||
let userPAT;
|
||||
if (username) {
|
||||
userPAT = await getUserAccessByName(username);
|
||||
}
|
||||
|
||||
let PATs;
|
||||
if (userPAT?.token) {
|
||||
PATs = [{ name: `USER_${username}`, value: userPAT.token }];
|
||||
} else {
|
||||
const patNames = Object.keys(process.env).filter((key) =>
|
||||
/PAT_\d*$/.exec(key),
|
||||
);
|
||||
PATs = patNames.map((name) => ({ name, value: process.env[name] }));
|
||||
}
|
||||
const RETRIES = process.env.NODE_ENV === "test" ? 7 : PATs.length;
|
||||
|
||||
if (!RETRIES) {
|
||||
throw new CustomError("No GitHub API tokens found", CustomError.NO_TOKENS);
|
||||
}
|
||||
const startPAT = getRandomInt(PATs.length);
|
||||
|
||||
for (let retries = 0; retries < RETRIES; retries++) {
|
||||
const currentPAT = PATs[(startPAT + retries) % PATs.length];
|
||||
|
||||
try {
|
||||
let response = await fetcher(
|
||||
variables,
|
||||
// @ts-ignore
|
||||
currentPAT.value,
|
||||
// used in tests for faking rate limit
|
||||
retries,
|
||||
);
|
||||
|
||||
// react on both type and message-based rate-limit signals.
|
||||
// https://github.com/anuraghazra/github-readme-stats/issues/4425
|
||||
const errors = response?.data?.errors;
|
||||
const errorType = errors?.[0]?.type;
|
||||
const errorMsg = errors?.[0]?.message || "";
|
||||
const isRateLimited =
|
||||
(errors && errorType === "RATE_LIMITED") ||
|
||||
/rate limit/i.test(errorMsg);
|
||||
|
||||
if (isRateLimited) {
|
||||
logger.log(`${currentPAT.name} Failed due to rate limiting`);
|
||||
} else {
|
||||
return response;
|
||||
}
|
||||
} catch (err) {
|
||||
/** @type {any} */
|
||||
const e = err;
|
||||
|
||||
// network/unexpected error → let caller treat as failure
|
||||
if (!e?.response) {
|
||||
throw e;
|
||||
}
|
||||
|
||||
// prettier-ignore
|
||||
// also checking for bad credentials if any tokens gets invalidated
|
||||
const isBadCredential =
|
||||
e?.response?.data?.message === "Bad credentials";
|
||||
const isAccountSuspended =
|
||||
e?.response?.data?.message === "Sorry. Your account was suspended.";
|
||||
|
||||
if (isBadCredential || isAccountSuspended) {
|
||||
logger.log(`${currentPAT.name} Failed due to bad credentials`);
|
||||
} else {
|
||||
// HTTP error with a response → return it for caller-side handling
|
||||
return e.response;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
throw new CustomError(
|
||||
"Downtime due to GitHub API rate limiting",
|
||||
CustomError.MAX_RETRY,
|
||||
);
|
||||
};
|
||||
|
||||
export { retryer };
|
||||
export default retryer;
|
||||
@@ -0,0 +1,114 @@
|
||||
// @ts-check
|
||||
|
||||
import { retryer } from "../common/retryer.js";
|
||||
import { MissingParamError } from "../common/error.js";
|
||||
import { request } from "../common/http.js";
|
||||
|
||||
const QUERY = `
|
||||
query gistInfo($gistName: String!) {
|
||||
viewer {
|
||||
gist(name: $gistName) {
|
||||
description
|
||||
owner {
|
||||
login
|
||||
}
|
||||
stargazerCount
|
||||
forks {
|
||||
totalCount
|
||||
}
|
||||
files {
|
||||
name
|
||||
language {
|
||||
name
|
||||
}
|
||||
size
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
/**
|
||||
* Gist data fetcher.
|
||||
*
|
||||
* @param {object} variables Fetcher variables.
|
||||
* @param {string} token GitHub token.
|
||||
* @returns {Promise<import('axios').AxiosResponse>} The response.
|
||||
*/
|
||||
const fetcher = async (variables, token) => {
|
||||
return await request(
|
||||
{ query: QUERY, variables },
|
||||
{ Authorization: `token ${token}` },
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* @typedef {{ name: string; language: { name: string; }, size: number }} GistFile Gist file.
|
||||
*/
|
||||
|
||||
/**
|
||||
* This function calculates the primary language of a gist by files size.
|
||||
*
|
||||
* @param {GistFile[]} files Files.
|
||||
* @returns {string} Primary language.
|
||||
*/
|
||||
const calculatePrimaryLanguage = (files) => {
|
||||
/** @type {Record<string, number>} */
|
||||
const languages = {};
|
||||
|
||||
for (const file of files) {
|
||||
if (file.language) {
|
||||
if (languages[file.language.name]) {
|
||||
languages[file.language.name] += file.size;
|
||||
} else {
|
||||
languages[file.language.name] = file.size;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let primaryLanguage = Object.keys(languages)[0];
|
||||
for (const language in languages) {
|
||||
if (languages[language] > languages[primaryLanguage]) {
|
||||
primaryLanguage = language;
|
||||
}
|
||||
}
|
||||
|
||||
return primaryLanguage;
|
||||
};
|
||||
|
||||
/**
|
||||
* @typedef {import('./types').GistData} GistData Gist data.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Fetch GitHub gist information by given username and ID.
|
||||
*
|
||||
* @param {string} id GitHub gist ID.
|
||||
* @returns {Promise<GistData>} Gist data.
|
||||
*/
|
||||
const fetchGist = async (id) => {
|
||||
if (!id) {
|
||||
throw new MissingParamError(["id"], "/api/gist?id=GIST_ID");
|
||||
}
|
||||
const res = await retryer(fetcher, null, { gistName: id });
|
||||
if (res.data.errors) {
|
||||
throw new Error(res.data.errors[0].message);
|
||||
}
|
||||
if (!res.data.data.viewer.gist) {
|
||||
throw new Error("Gist not found");
|
||||
}
|
||||
const data = res.data.data.viewer.gist;
|
||||
return {
|
||||
name: data.files[Object.keys(data.files)[0]].name,
|
||||
nameWithOwner: `${data.owner.login}/${
|
||||
data.files[Object.keys(data.files)[0]].name
|
||||
}`,
|
||||
description: data.description,
|
||||
language: calculatePrimaryLanguage(data.files),
|
||||
starsCount: data.stargazerCount,
|
||||
forksCount: data.forks.totalCount,
|
||||
};
|
||||
};
|
||||
|
||||
export { fetchGist };
|
||||
export default fetchGist;
|
||||
@@ -0,0 +1,162 @@
|
||||
// @ts-check
|
||||
|
||||
import { MissingParamError } from "../common/error.js";
|
||||
import { request } from "../common/http.js";
|
||||
import { retryer } from "../common/retryer.js";
|
||||
import { fetchRepoUserStats } from "./stats.js";
|
||||
|
||||
/**
|
||||
* Repo data fetcher.
|
||||
*
|
||||
* @param {object} variables Fetcher variables.
|
||||
* @param {string} token GitHub token.
|
||||
* @returns {Promise<import('axios').AxiosResponse>} The response.
|
||||
*/
|
||||
const fetcher = (variables, token) => {
|
||||
return request(
|
||||
{
|
||||
query: `
|
||||
fragment RepoInfo on Repository {
|
||||
name
|
||||
nameWithOwner
|
||||
isPrivate
|
||||
isArchived
|
||||
isTemplate
|
||||
stargazers {
|
||||
totalCount
|
||||
}
|
||||
description
|
||||
primaryLanguage {
|
||||
color
|
||||
id
|
||||
name
|
||||
}
|
||||
forkCount
|
||||
}
|
||||
query getRepo($login: String!, $repo: String!) {
|
||||
user(login: $login) {
|
||||
repository(name: $repo) {
|
||||
...RepoInfo
|
||||
}
|
||||
}
|
||||
organization(login: $login) {
|
||||
repository(name: $repo) {
|
||||
...RepoInfo
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
variables,
|
||||
},
|
||||
{
|
||||
Authorization: `token ${token}`,
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
const urlExample = "/api/pin?username=USERNAME&repo=REPO_NAME";
|
||||
|
||||
/**
|
||||
* @typedef {import("./types").RepositoryData} RepositoryData Repository data.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Fetch repository data.
|
||||
*
|
||||
* @param {string} username GitHub username.
|
||||
* @param {string} reponame GitHub repository name.
|
||||
* @returns {Promise<RepositoryData>} Repository data.
|
||||
*/
|
||||
const fetchRepo = async (
|
||||
username,
|
||||
reponame,
|
||||
include_prs_authored = false,
|
||||
include_prs_commented = false,
|
||||
include_prs_reviewed = false,
|
||||
include_issues_authored = false,
|
||||
include_issues_commented = false,
|
||||
) => {
|
||||
let owner = username;
|
||||
if (reponame && reponame.includes("/")) {
|
||||
const [parsed_owner, parsed_repo] = reponame.split("/");
|
||||
owner = parsed_owner;
|
||||
reponame = parsed_repo;
|
||||
}
|
||||
|
||||
if (owner && !username) {
|
||||
username = owner;
|
||||
}
|
||||
if (username && !owner) {
|
||||
owner = username;
|
||||
}
|
||||
if (!username && !reponame) {
|
||||
throw new MissingParamError(["username", "repo"], urlExample);
|
||||
}
|
||||
if (!username) {
|
||||
throw new MissingParamError(["username"], urlExample);
|
||||
}
|
||||
if (!reponame) {
|
||||
throw new MissingParamError(["repo"], urlExample);
|
||||
}
|
||||
|
||||
let res = await retryer(fetcher, username, { login: owner, repo: reponame });
|
||||
|
||||
const data = res.data.data;
|
||||
|
||||
if (!data.user && !data.organization) {
|
||||
throw new Error("Not found");
|
||||
}
|
||||
|
||||
const isUser = data.organization === null && data.user;
|
||||
const isOrg = data.user === null && data.organization;
|
||||
|
||||
if (isUser) {
|
||||
if (!data.user.repository || data.user.repository.isPrivate) {
|
||||
throw new Error("User Repository Not found");
|
||||
}
|
||||
let repoUserStats = await fetchRepoUserStats(
|
||||
username,
|
||||
[owner + "/" + reponame],
|
||||
[],
|
||||
include_prs_authored,
|
||||
include_prs_commented,
|
||||
include_prs_reviewed,
|
||||
include_issues_authored,
|
||||
include_issues_commented,
|
||||
);
|
||||
return {
|
||||
...repoUserStats,
|
||||
...data.user.repository,
|
||||
starCount: data.user.repository.stargazers.totalCount,
|
||||
};
|
||||
}
|
||||
|
||||
if (isOrg) {
|
||||
if (
|
||||
!data.organization.repository ||
|
||||
data.organization.repository.isPrivate
|
||||
) {
|
||||
throw new Error("Organization Repository Not found");
|
||||
}
|
||||
let repoUserStats = await fetchRepoUserStats(
|
||||
username,
|
||||
[owner + "/" + reponame],
|
||||
[],
|
||||
include_prs_authored,
|
||||
include_prs_commented,
|
||||
include_prs_reviewed,
|
||||
include_issues_authored,
|
||||
include_issues_commented,
|
||||
);
|
||||
return {
|
||||
...repoUserStats,
|
||||
...data.organization.repository,
|
||||
starCount: data.organization.repository.stargazers.totalCount,
|
||||
};
|
||||
}
|
||||
|
||||
throw new Error("Unexpected behavior");
|
||||
};
|
||||
|
||||
export { fetchRepo };
|
||||
export default fetchRepo;
|
||||
@@ -0,0 +1,464 @@
|
||||
// @ts-check
|
||||
|
||||
import axios from "axios";
|
||||
import * as dotenv from "dotenv";
|
||||
import githubUsernameRegex from "github-username-regex";
|
||||
import { calculateRank } from "../calculateRank.js";
|
||||
import { retryer } from "../common/retryer.js";
|
||||
import {
|
||||
buildSearchFilter,
|
||||
parseOwnerAffiliations,
|
||||
} from "../common/ops.js";
|
||||
import { logger } from "../common/log.js";
|
||||
import { excludeRepositories } from "../common/envs.js";
|
||||
import { CustomError, MissingParamError } from "../common/error.js";
|
||||
import { wrapTextMultiline } from "../common/fmt.js";
|
||||
import { request } from "../common/http.js";
|
||||
|
||||
dotenv.config();
|
||||
|
||||
// GraphQL queries.
|
||||
const GRAPHQL_REPOS_FIELD = `
|
||||
repositories(first: 100, after: $after, ownerAffiliations: $ownerAffiliations, orderBy: {direction: DESC, field: STARGAZERS}) {
|
||||
totalCount
|
||||
nodes {
|
||||
name
|
||||
stargazers {
|
||||
totalCount
|
||||
}
|
||||
}
|
||||
pageInfo {
|
||||
hasNextPage
|
||||
endCursor
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const GRAPHQL_REPOS_QUERY = `
|
||||
query userInfo($login: String!, $after: String, $ownerAffiliations: [RepositoryAffiliation]) {
|
||||
user(login: $login) {
|
||||
${GRAPHQL_REPOS_FIELD}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const GRAPHQL_STATS_QUERY = `
|
||||
query userInfo($login: String!, $after: String, $includeMergedPullRequests: Boolean!, $includeDiscussions: Boolean!, $includeDiscussionsAnswers: Boolean!, $startTime: DateTime = null, $ownerAffiliations: [RepositoryAffiliation]) {
|
||||
user(login: $login) {
|
||||
name
|
||||
login
|
||||
commits: contributionsCollection (from: $startTime) {
|
||||
totalCommitContributions,
|
||||
}
|
||||
reviews: contributionsCollection {
|
||||
totalPullRequestReviewContributions
|
||||
}
|
||||
repositoriesContributedTo(first: 1, contributionTypes: [COMMIT, ISSUE, PULL_REQUEST, REPOSITORY]) {
|
||||
totalCount
|
||||
}
|
||||
pullRequests(first: 1) {
|
||||
totalCount
|
||||
}
|
||||
mergedPullRequests: pullRequests(states: MERGED) @include(if: $includeMergedPullRequests) {
|
||||
totalCount
|
||||
}
|
||||
openIssues: issues(states: OPEN) {
|
||||
totalCount
|
||||
}
|
||||
closedIssues: issues(states: CLOSED) {
|
||||
totalCount
|
||||
}
|
||||
followers {
|
||||
totalCount
|
||||
}
|
||||
repositoryDiscussions @include(if: $includeDiscussions) {
|
||||
totalCount
|
||||
}
|
||||
repositoryDiscussionComments(onlyAnswers: true) @include(if: $includeDiscussionsAnswers) {
|
||||
totalCount
|
||||
}
|
||||
${GRAPHQL_REPOS_FIELD}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
/**
|
||||
* Stats fetcher object.
|
||||
*
|
||||
* @param {object & { after: string | null }} variables Fetcher variables.
|
||||
* @param {string} token GitHub token.
|
||||
* @returns {Promise<import('axios').AxiosResponse>} Axios response.
|
||||
*/
|
||||
const fetcher = (variables, token) => {
|
||||
const query = variables.after ? GRAPHQL_REPOS_QUERY : GRAPHQL_STATS_QUERY;
|
||||
return request(
|
||||
{
|
||||
query,
|
||||
variables,
|
||||
},
|
||||
{
|
||||
Authorization: `bearer ${token}`,
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Fetch stats information for a given username.
|
||||
*
|
||||
* @param {object} variables Fetcher variables.
|
||||
* @param {string} variables.username GitHub username.
|
||||
* @param {boolean} variables.includeMergedPullRequests Include merged pull requests.
|
||||
* @param {boolean} variables.includeDiscussions Include discussions.
|
||||
* @param {boolean} variables.includeDiscussionsAnswers Include discussions answers.
|
||||
* @param {string|undefined} variables.startTime Time to start the count of total commits.
|
||||
* @param {string[]} ownerAffiliations The owner affiliations to filter by. Default: OWNER.
|
||||
* @returns {Promise<import('axios').AxiosResponse>} Axios response.
|
||||
*
|
||||
* @description This function supports multi-page fetching if the 'FETCH_MULTI_PAGE_STARS' environment variable is set to true or a limit of fetches.
|
||||
*/
|
||||
const statsFetcher = async ({
|
||||
username,
|
||||
includeMergedPullRequests,
|
||||
includeDiscussions,
|
||||
includeDiscussionsAnswers,
|
||||
startTime,
|
||||
ownerAffiliations,
|
||||
}) => {
|
||||
let stats;
|
||||
let hasNextPage = true;
|
||||
let endCursor = null;
|
||||
let fetchedPages = 0;
|
||||
while (hasNextPage) {
|
||||
const variables = {
|
||||
login: username,
|
||||
first: 100,
|
||||
after: endCursor,
|
||||
includeMergedPullRequests,
|
||||
includeDiscussions,
|
||||
includeDiscussionsAnswers,
|
||||
startTime,
|
||||
ownerAffiliations,
|
||||
};
|
||||
let res = await retryer(fetcher, username, variables);
|
||||
if (res.data.errors) {
|
||||
return res;
|
||||
}
|
||||
|
||||
// Store stats data.
|
||||
const repoNodes = res.data.data.user.repositories.nodes;
|
||||
if (stats) {
|
||||
if (fetchedPages === 1) {
|
||||
// make deep copy of relevant stats fields to avoid altering the cached response object in frontend
|
||||
stats = structuredClone({
|
||||
data: stats.data,
|
||||
statusText: stats.statusText,
|
||||
});
|
||||
}
|
||||
stats.data.data.user.repositories.nodes.push(...repoNodes);
|
||||
} else {
|
||||
stats = res;
|
||||
}
|
||||
|
||||
// Disable multi page fetching on public Vercel instance due to rate limits.
|
||||
fetchedPages++;
|
||||
const repoNodesWithStars = repoNodes.filter(
|
||||
(node) => node.stargazers.totalCount !== 0,
|
||||
);
|
||||
hasNextPage =
|
||||
(process.env.FETCH_MULTI_PAGE_STARS === "true" ||
|
||||
process.env.FETCH_MULTI_PAGE_STARS > fetchedPages) &&
|
||||
repoNodes.length === repoNodesWithStars.length &&
|
||||
res.data.data.user.repositories.pageInfo.hasNextPage;
|
||||
endCursor = res.data.data.user.repositories.pageInfo.endCursor;
|
||||
}
|
||||
|
||||
return stats;
|
||||
};
|
||||
|
||||
/**
|
||||
* Fetch total commits using the REST API.
|
||||
*
|
||||
* @param {object} variables Fetcher variables.
|
||||
* @param {string} token GitHub token.
|
||||
* @returns {Promise<import('axios').AxiosResponse>} Axios response.
|
||||
*
|
||||
* @see https://developer.github.com/v3/search/#search-commits
|
||||
*/
|
||||
const fetchTotalItems = (variables, token) => {
|
||||
return axios({
|
||||
method: "get",
|
||||
url:
|
||||
`https://api.github.com/search/` +
|
||||
variables.type +
|
||||
`?per_page=1&q=` +
|
||||
buildSearchFilter(variables.repo, variables.owner).replaceAll(
|
||||
" ",
|
||||
"+",
|
||||
) +
|
||||
variables.filter,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Accept: "application/vnd.github.cloak-preview",
|
||||
Authorization: `token ${token}`,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Fetch all the commits for all the repositories of a given username.
|
||||
*
|
||||
* @param {string} username GitHub username.
|
||||
* @returns {Promise<number>} Total commits.
|
||||
*
|
||||
* @description Done like this because the GitHub API does not provide a way to fetch all the commits. See
|
||||
* #92#issuecomment-661026467 and #211 for more information.
|
||||
*/
|
||||
const totalItemsFetcher = async (username, repo, owner, type, filter) => {
|
||||
if (!githubUsernameRegex.test(username)) {
|
||||
logger.log("Invalid username provided.");
|
||||
throw new Error("Invalid username provided.");
|
||||
}
|
||||
|
||||
let res;
|
||||
try {
|
||||
res = await retryer(fetchTotalItems, username, {
|
||||
login: username,
|
||||
repo,
|
||||
owner,
|
||||
type,
|
||||
filter,
|
||||
});
|
||||
} catch (err) {
|
||||
logger.log(err);
|
||||
throw new Error(err);
|
||||
}
|
||||
|
||||
const totalCount = res.data.total_count;
|
||||
if (isNaN(totalCount)) {
|
||||
logger.error("GitHub error: " + JSON.stringify(res.data));
|
||||
throw new CustomError(
|
||||
"Could not fetch data from GitHub REST API.",
|
||||
CustomError.GITHUB_REST_API_ERROR,
|
||||
);
|
||||
}
|
||||
return totalCount;
|
||||
};
|
||||
|
||||
const fetchRepoUserStats = async (
|
||||
username,
|
||||
repo,
|
||||
owner,
|
||||
include_prs_authored,
|
||||
include_prs_commented,
|
||||
include_prs_reviewed,
|
||||
include_issues_authored,
|
||||
include_issues_commented,
|
||||
) => {
|
||||
let stats = {};
|
||||
if (include_prs_authored) {
|
||||
stats.totalPRsAuthored = await totalItemsFetcher(
|
||||
username,
|
||||
repo,
|
||||
owner,
|
||||
"issues",
|
||||
`author:${username}+type:pr`,
|
||||
);
|
||||
}
|
||||
if (include_prs_commented) {
|
||||
stats.totalPRsCommented = await totalItemsFetcher(
|
||||
username,
|
||||
repo,
|
||||
owner,
|
||||
"issues",
|
||||
`commenter:${username}+-author:${username}+type:pr`,
|
||||
);
|
||||
}
|
||||
if (include_prs_reviewed) {
|
||||
stats.totalPRsReviewed = await totalItemsFetcher(
|
||||
username,
|
||||
repo,
|
||||
owner,
|
||||
"issues",
|
||||
`reviewed-by:${username}+-author:${username}+type:pr`,
|
||||
);
|
||||
}
|
||||
if (include_issues_authored) {
|
||||
stats.totalIssuesAuthored = await totalItemsFetcher(
|
||||
username,
|
||||
repo,
|
||||
owner,
|
||||
"issues",
|
||||
`author:${username}+type:issue`,
|
||||
);
|
||||
}
|
||||
if (include_issues_commented) {
|
||||
stats.totalIssuesCommented = await totalItemsFetcher(
|
||||
username,
|
||||
repo,
|
||||
owner,
|
||||
"issues",
|
||||
`commenter:${username}+-author:${username}+type:issue`,
|
||||
);
|
||||
}
|
||||
return stats;
|
||||
};
|
||||
|
||||
/**
|
||||
* Fetch stats for a given username.
|
||||
*
|
||||
* @param {string} username GitHub username.
|
||||
* @param {boolean} include_all_commits Include all commits.
|
||||
* @param {string[]} exclude_repo Repositories to exclude.
|
||||
* @param {boolean} include_merged_pull_requests Include merged pull requests.
|
||||
* @param {boolean} include_discussions Include discussions.
|
||||
* @param {boolean} include_discussions_answers Include discussions answers.
|
||||
* @param {number|undefined} commits_year Year to count total commits
|
||||
* @param {string[]} ownerAffiliations Owner affiliations. Default: OWNER.
|
||||
* @returns {Promise<import("./types").StatsData>} Stats data.
|
||||
*/
|
||||
const fetchStats = async (
|
||||
username,
|
||||
include_all_commits = false,
|
||||
exclude_repo = [],
|
||||
include_merged_pull_requests = false,
|
||||
include_discussions = false,
|
||||
include_discussions_answers = false,
|
||||
commits_year,
|
||||
repo = [],
|
||||
owner = [],
|
||||
include_prs_authored = false,
|
||||
include_prs_commented = false,
|
||||
include_prs_reviewed = false,
|
||||
include_issues_authored = false,
|
||||
include_issues_commented = false,
|
||||
ownerAffiliations = [],
|
||||
) => {
|
||||
if (!username) {
|
||||
throw new MissingParamError(["username"]);
|
||||
}
|
||||
|
||||
const stats = {
|
||||
name: "",
|
||||
totalPRs: 0,
|
||||
totalPRsMerged: 0,
|
||||
mergedPRsPercentage: 0,
|
||||
totalReviews: 0,
|
||||
totalCommits: 0,
|
||||
totalIssues: 0,
|
||||
totalStars: 0,
|
||||
totalDiscussionsStarted: 0,
|
||||
totalDiscussionsAnswered: 0,
|
||||
contributedTo: 0,
|
||||
totalPRsAuthored: 0,
|
||||
totalPRsCommented: 0,
|
||||
totalPRsReviewed: 0,
|
||||
totalIssuesAuthored: 0,
|
||||
totalIssuesCommented: 0,
|
||||
rank: { level: "C", percentile: 100 },
|
||||
};
|
||||
ownerAffiliations = parseOwnerAffiliations(ownerAffiliations);
|
||||
|
||||
let res = await statsFetcher({
|
||||
username,
|
||||
includeMergedPullRequests: include_merged_pull_requests,
|
||||
includeDiscussions: include_discussions,
|
||||
includeDiscussionsAnswers: include_discussions_answers,
|
||||
startTime: commits_year ? `${commits_year}-01-01T00:00:00Z` : undefined,
|
||||
ownerAffiliations,
|
||||
});
|
||||
|
||||
// Catch GraphQL errors.
|
||||
if (res.data.errors) {
|
||||
logger.error(res.data.errors);
|
||||
if (res.data.errors[0].type === "NOT_FOUND") {
|
||||
throw new CustomError(
|
||||
res.data.errors[0].message || "Could not fetch user.",
|
||||
CustomError.USER_NOT_FOUND,
|
||||
);
|
||||
}
|
||||
if (res.data.errors[0].message) {
|
||||
throw new CustomError(
|
||||
wrapTextMultiline(res.data.errors[0].message, 90, 1)[0],
|
||||
res.statusText,
|
||||
);
|
||||
}
|
||||
throw new CustomError(
|
||||
"Something went wrong while trying to retrieve the stats data using the GraphQL API.",
|
||||
CustomError.GRAPHQL_ERROR,
|
||||
);
|
||||
}
|
||||
|
||||
const user = res.data.data.user;
|
||||
|
||||
stats.name = user.name || user.login;
|
||||
|
||||
// if include_all_commits, fetch all commits using the REST API.
|
||||
if (include_all_commits) {
|
||||
stats.totalCommits = await totalItemsFetcher(
|
||||
username,
|
||||
repo,
|
||||
owner,
|
||||
"commits",
|
||||
`author:${username}`,
|
||||
);
|
||||
} else {
|
||||
stats.totalCommits = user.commits.totalCommitContributions;
|
||||
}
|
||||
let repoUserStats = await fetchRepoUserStats(
|
||||
username,
|
||||
repo,
|
||||
owner,
|
||||
include_prs_authored,
|
||||
include_prs_commented,
|
||||
include_prs_reviewed,
|
||||
include_issues_authored,
|
||||
include_issues_commented,
|
||||
);
|
||||
Object.assign(stats, repoUserStats);
|
||||
|
||||
stats.totalPRs = user.pullRequests.totalCount;
|
||||
if (include_merged_pull_requests) {
|
||||
stats.totalPRsMerged = user.mergedPullRequests.totalCount;
|
||||
stats.mergedPRsPercentage =
|
||||
(user.mergedPullRequests.totalCount / user.pullRequests.totalCount) *
|
||||
100 || 0;
|
||||
}
|
||||
stats.totalReviews = user.reviews.totalPullRequestReviewContributions;
|
||||
stats.totalIssues = user.openIssues.totalCount + user.closedIssues.totalCount;
|
||||
if (include_discussions) {
|
||||
stats.totalDiscussionsStarted = user.repositoryDiscussions.totalCount;
|
||||
}
|
||||
if (include_discussions_answers) {
|
||||
stats.totalDiscussionsAnswered =
|
||||
user.repositoryDiscussionComments.totalCount;
|
||||
}
|
||||
stats.contributedTo = user.repositoriesContributedTo.totalCount;
|
||||
|
||||
// Retrieve stars while filtering out repositories to be hidden.
|
||||
const allExcludedRepos = [...exclude_repo, ...excludeRepositories];
|
||||
let repoToHide = new Set(allExcludedRepos);
|
||||
|
||||
stats.totalStars = user.repositories.nodes
|
||||
.filter((data) => {
|
||||
return !repoToHide.has(data.name);
|
||||
})
|
||||
.reduce((prev, curr) => {
|
||||
return prev + curr.stargazers.totalCount;
|
||||
}, 0);
|
||||
|
||||
stats.rank = calculateRank({
|
||||
all_commits: include_all_commits,
|
||||
commits: stats.totalCommits,
|
||||
prs: stats.totalPRs,
|
||||
reviews: stats.totalReviews,
|
||||
issues: stats.totalIssues,
|
||||
repos: user.repositories.totalCount,
|
||||
stars: stats.totalStars,
|
||||
followers: user.followers.totalCount,
|
||||
});
|
||||
|
||||
return stats;
|
||||
};
|
||||
|
||||
export { fetchStats, fetchRepoUserStats };
|
||||
export default fetchStats;
|
||||
@@ -0,0 +1,166 @@
|
||||
// @ts-check
|
||||
|
||||
import { retryer } from "../common/retryer.js";
|
||||
import { parseOwnerAffiliations } from "../common/ops.js";
|
||||
import { logger } from "../common/log.js";
|
||||
import { excludeRepositories } from "../common/envs.js";
|
||||
import { CustomError, MissingParamError } from "../common/error.js";
|
||||
import { wrapTextMultiline } from "../common/fmt.js";
|
||||
import { request } from "../common/http.js";
|
||||
|
||||
/**
|
||||
* Top languages fetcher object.
|
||||
*
|
||||
* @param {any} variables Fetcher variables.
|
||||
* @param {string} token GitHub token.
|
||||
* @returns {Promise<import("axios").AxiosResponse>} Languages fetcher response.
|
||||
*/
|
||||
const fetcher = (variables, token) => {
|
||||
return request(
|
||||
{
|
||||
query: `
|
||||
query userInfo($login: String!, $ownerAffiliations: [RepositoryAffiliation]) {
|
||||
user(login: $login) {
|
||||
# do not fetch forks
|
||||
repositories(ownerAffiliations: $ownerAffiliations, isFork: false, first: 100) {
|
||||
nodes {
|
||||
name
|
||||
languages(first: 10, orderBy: {field: SIZE, direction: DESC}) {
|
||||
edges {
|
||||
size
|
||||
node {
|
||||
color
|
||||
name
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
variables,
|
||||
},
|
||||
{
|
||||
Authorization: `token ${token}`,
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* @typedef {import("./types").TopLangData} TopLangData Top languages data.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Fetch top languages for a given username.
|
||||
*
|
||||
* @param {string} username GitHub username.
|
||||
* @param {string[]} exclude_repo List of repositories to exclude. Default: [].
|
||||
* @param {number} size_weight Weightage to be given to size.
|
||||
* @param {number} count_weight Weightage to be given to count.
|
||||
* @param {string[]} ownerAffiliations The owner affiliations to filter by. Default: OWNER.
|
||||
* @returns {Promise<TopLangData>} Top languages data.
|
||||
*/
|
||||
const fetchTopLanguages = async (
|
||||
username,
|
||||
exclude_repo = [],
|
||||
size_weight = 1,
|
||||
count_weight = 0,
|
||||
ownerAffiliations = [],
|
||||
) => {
|
||||
if (!username) {
|
||||
throw new MissingParamError(["username"]);
|
||||
}
|
||||
ownerAffiliations = parseOwnerAffiliations(ownerAffiliations);
|
||||
|
||||
const res = await retryer(fetcher, username, { login: username, ownerAffiliations });
|
||||
|
||||
if (res.data.errors) {
|
||||
logger.error(res.data.errors);
|
||||
if (res.data.errors[0].type === "NOT_FOUND") {
|
||||
throw new CustomError(
|
||||
res.data.errors[0].message || "Could not fetch user.",
|
||||
CustomError.USER_NOT_FOUND,
|
||||
);
|
||||
}
|
||||
if (res.data.errors[0].message) {
|
||||
throw new CustomError(
|
||||
wrapTextMultiline(res.data.errors[0].message, 90, 1)[0],
|
||||
res.statusText,
|
||||
);
|
||||
}
|
||||
throw new CustomError(
|
||||
"Something went wrong while trying to retrieve the language data using the GraphQL API.",
|
||||
CustomError.GRAPHQL_ERROR,
|
||||
);
|
||||
}
|
||||
|
||||
let repoNodes = res.data.data.user.repositories.nodes;
|
||||
/** @type {Record<string, boolean>} */
|
||||
let repoToHide = {};
|
||||
const allExcludedRepos = [...exclude_repo, ...excludeRepositories];
|
||||
|
||||
// populate repoToHide map for quick lookup
|
||||
// while filtering out
|
||||
if (allExcludedRepos) {
|
||||
allExcludedRepos.forEach((repoName) => {
|
||||
repoToHide[repoName] = true;
|
||||
});
|
||||
}
|
||||
|
||||
// filter out repositories to be hidden
|
||||
repoNodes = repoNodes
|
||||
.sort((a, b) => b.size - a.size)
|
||||
.filter((name) => !repoToHide[name.name]);
|
||||
|
||||
let repoCount = 0;
|
||||
|
||||
repoNodes = repoNodes
|
||||
.filter((node) => node.languages.edges.length > 0)
|
||||
// flatten the list of language nodes
|
||||
.reduce((acc, curr) => curr.languages.edges.concat(acc), [])
|
||||
.reduce((acc, prev) => {
|
||||
// get the size of the language (bytes)
|
||||
let langSize = prev.size;
|
||||
|
||||
// if we already have the language in the accumulator
|
||||
// & the current language name is same as previous name
|
||||
// add the size to the language size and increase repoCount.
|
||||
if (acc[prev.node.name] && prev.node.name === acc[prev.node.name].name) {
|
||||
langSize = prev.size + acc[prev.node.name].size;
|
||||
repoCount += 1;
|
||||
} else {
|
||||
// reset repoCount to 1
|
||||
// language must exist in at least one repo to be detected
|
||||
repoCount = 1;
|
||||
}
|
||||
return {
|
||||
...acc,
|
||||
[prev.node.name]: {
|
||||
name: prev.node.name,
|
||||
color: prev.node.color,
|
||||
size: langSize,
|
||||
count: repoCount,
|
||||
},
|
||||
};
|
||||
}, {});
|
||||
|
||||
Object.keys(repoNodes).forEach((name) => {
|
||||
// comparison index calculation
|
||||
repoNodes[name].size =
|
||||
Math.pow(repoNodes[name].size, size_weight) *
|
||||
Math.pow(repoNodes[name].count, count_weight);
|
||||
});
|
||||
|
||||
const topLangs = Object.keys(repoNodes)
|
||||
.sort((a, b) => repoNodes[b].size - repoNodes[a].size)
|
||||
.reduce((result, key) => {
|
||||
result[key] = repoNodes[key];
|
||||
return result;
|
||||
}, {});
|
||||
|
||||
return topLangs;
|
||||
};
|
||||
|
||||
export { fetchTopLanguages };
|
||||
export default fetchTopLanguages;
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user