Repository: ryo-ma/github-profile-trophy
Branch: master
Commit: bff648c9991b
Files: 50
Total size: 149.1 KB
Directory structure:
gitextract_uvsbr8fm/
├── .editorconfig
├── .github/
│ ├── FUNDING.yml
│ └── workflows/
│ ├── close-old-issues.yml
│ ├── test-repository-action.yml
│ └── testing.yml
├── .gitignore
├── .vercelignore
├── CONTRIBUTING.md
├── Dockerfile
├── LICENSE
├── README.md
├── action.yml
├── api/
│ └── index.ts
├── deno.json
├── deps.ts
├── docker-compose.yml
├── env-example
├── main.ts
├── render_svg.ts
├── src/
│ ├── Helpers/
│ │ ├── Logger.ts
│ │ ├── Retry.ts
│ │ └── __tests__/
│ │ └── Retry.test.ts
│ ├── Repository/
│ │ └── GithubRepository.ts
│ ├── Schemas/
│ │ └── index.ts
│ ├── Services/
│ │ ├── GithubApiService.ts
│ │ ├── __mocks__/
│ │ │ ├── notFoundUserMock.json
│ │ │ ├── rateLimitMock.json
│ │ │ └── successGithubResponse.json
│ │ ├── __tests__/
│ │ │ └── githubApiService.test.ts
│ │ └── request.ts
│ ├── StaticRenderRegeneration/
│ │ ├── cache_manager.ts
│ │ ├── index.ts
│ │ ├── types.ts
│ │ └── utils.ts
│ ├── Types/
│ │ ├── EServiceKindError.ts
│ │ ├── Request.ts
│ │ ├── ServiceError.ts
│ │ └── index.ts
│ ├── card.ts
│ ├── config/
│ │ └── cache.ts
│ ├── error_page.ts
│ ├── icons.ts
│ ├── pages/
│ │ └── Error.ts
│ ├── theme.ts
│ ├── trophy.ts
│ ├── trophy_list.ts
│ ├── user_info.ts
│ └── utils.ts
├── test/
│ └── test.ts
└── vercel.json
================================================
FILE CONTENTS
================================================
================================================
FILE: .editorconfig
================================================
root = true
[*]
indent_style = space
indent_size = 2
end_of_line = LF
charset = utf-8
trim_trailing_whitespace = true
insert_final_newline = true
[vercel.json]
indent_size = 4
[*.md]
trim_trailing_whitespace = false
================================================
FILE: .github/FUNDING.yml
================================================
# These are supported funding model platforms
github: [ryo-ma]
================================================
FILE: .github/workflows/close-old-issues.yml
================================================
name: Close inactive issues
on:
schedule:
- cron: "30 1 * * *"
jobs:
close-issues:
runs-on: ubuntu-latest
permissions:
issues: write
pull-requests: write
steps:
- uses: actions/stale@v5
with:
days-before-issue-stale: 30
days-before-issue-close: 14
stale-issue-label: "stale"
stale-issue-message: "This issue is stale because it has been open for 30 days with no activity."
close-issue-message: "This issue was closed because it has been inactive for 14 days since being marked as stale."
days-before-pr-stale: -1
days-before-pr-close: -1
repo-token: ${{ secrets.GITHUB_TOKEN }}
================================================
FILE: .github/workflows/test-repository-action.yml
================================================
name: Test GitHub Profile Trophy Action
on: [push, pull_request, workflow_dispatch]
jobs:
test-trophy:
runs-on: ubuntu-latest
steps:
- name: Checkout this repo
uses: actions/checkout@v4
- name: Test Action
id: trophy
uses: ./.
with:
username: ${{ github.repository_owner }}
output_path: ./trophy.svg
token: ${{ secrets.GITHUB_TOKEN }}
- name: Verify SVG generated
run: |
if [ -f trophy.svg ]; then
echo "SVG exsists ($(wc -c < trophy.svg) Bytes)"
file trophy.svg
else
echo "SVG failed to generate"
exit 1
fi
================================================
FILE: .github/workflows/testing.yml
================================================
name: Check PR Test
on:
push:
branches:
- master
pull_request:
branches:
- master
workflow_dispatch:
jobs:
install-dependencies:
runs-on: ubuntu-latest
strategy:
matrix:
deno-version: ["1.44.4"]
steps:
# Step 1: Checkout repository
- name: Git Checkout Deno Module
uses: actions/checkout@v4
# Step 2: Setup Deno
- name: Setup Deno
uses: denoland/setup-deno@v1
with:
deno-version: ${{ matrix.deno-version }}
# Step 3: Cache / Install dependencies
# Change src/mod.ts to main.ts if your project uses main.ts
- name: Install dependencies
run: deno cache --reload deps.ts
# Step 4: Format check
- name: Deno format check
run: deno fmt --check
# Step 5: Lint check (won't fail workflow)
- name: Deno lint check
run: deno lint || true
# Step 6: Run tests
- name: Test Deno Module
run: deno task test
================================================
FILE: .gitignore
================================================
.vscode
.env
.idea
deno.lock
*.sh
**/.DS_Store
================================================
FILE: .vercelignore
================================================
.gitignore
.github
README.md
LICENSE
debug.ts
================================================
FILE: CONTRIBUTING.md
================================================
# Contribution Guide
## Environment
- Deno >= v1.36.1
- [Vercel](https://vercel.com/)
- GitHub API v4
- Docker and Docker compose (optional)
## Local Run
Create `.env` file to project root directory, and write your GitHub token to the
`.env` file. Please select the authority of `repo` when creating token.
```properties
GITHUB_TOKEN1=XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
GITHUB_TOKEN2=XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
# if using GitHub Enterprise:
# (this env var defaults to https://api.github.com/graphql)
GITHUB_API=https://github.example.com/api/graphql
```
Run local server.
```sh
deno task start
```
You can enable the Redis if you want, but it's not mandatory.
```sh
docker compose up -d
```
Rename `env-example` to `.env`, and change ENABLE_REDIS to true
Open localhost from your browser.
http://localhost:8080/?username=ryo-ma
## Editor config
Read the [.editorconfig](./.editorconfig)
## Pull Requests
Pull requests are always welcome! In general, they should a single concern in
the least number of changed lines as possible. For changes that address core
functionality, it is best to open an issue to discuss your proposal first. I
look forward to seeing what you come up with!
## Run deno lint
## What to do before contributing
### 1. Run deno lint
```sh
deno task lint
```
### 2. Run deno format
```sh
deno task format
```
### 3. Run deno test
```sh
deno task test
```
================================================
FILE: Dockerfile
================================================
FROM denoland/deno:latest
# Create working directory
WORKDIR /app
# Copy source
COPY . .
# Compile the main app
RUN deno cache main.ts
# Run the app
CMD ["deno", "run", "-A", "main.ts"]
================================================
FILE: LICENSE
================================================
Copyright (c) 2020 ryo-ma
Permission is hereby granted, free of charge, to any person obtaining
a copy of this software and associated documentation files (the
"Software"), to deal in the Software without restriction, including
without limitation the rights to use, copy, modify, merge, publish,
distribute, sublicense, and/or sell copies of the Software, and to
permit persons to whom the Software is furnished to do so, subject to
the following conditions:
The above copyright notice and this permission notice shall be
included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
================================================
FILE: README.md
================================================
GitHub Profile Trophy
🏆 Add dynamically generated GitHub Stat Trophies on your README
[](https://github.com/ryo-ma/github-profile-trophy/stargazers)
[](https://github.com/ryo-ma/github-profile-trophy/network/members)
[](https://github.com/ryo-ma/github-profile-trophy/issues)
[](https://github.com/ryo-ma/github-profile-trophy/blob/master/LICENSE)
[](https://twitter.com/intent/tweet?text=Add%20dynamically%20generated%20GitHub%20Trophy%20on%20your%20readme%0D%0A&url=https%3A%2F%2Fgithub.com%2Fryo-ma%2Fgithub-profile-trophy)
You can use this service for free. I'm looking for sponsors to help us keep up with this service❤️
> ⚠️ **Notice from the Project Owner**
>
> Hello everyone,\
> I am the owner of this project.
>
> Currently, due to the increase in users and traffic, the cost of maintaining
> this service has become quite high, and it is becoming financially difficult
> to sustain.\
> As costs continue to rise, there is a possibility that the service may have to
> be discontinued.\
> We are now at a stage where monetization and financial support are essential.
>
> Your support in the following ways would be greatly appreciated:
>
> 1. Financial support: [GitHub Sponsors](https://github.com/sponsors/ryo-ma)
> 2. Reducing server load through self-hosting (by forking on GitHub and
> deploying to Vercel)
> 3. Share your github-profile-trophy URL deployed to Vercel for load balancing
> (Send email: saka_ro@yahoo.co.jp or Pull Request)
# Load balancing endpoints
These are endpoints provided by volunteers. Please use these in moderation.
- [https://github-profile-trophy-liard-delta.vercel.app](https://github-profile-trophy-liard-delta.vercel.app/)
by [Adwitya](https://github.com/Adwitya)
- [https://github-profile-trophy-fork-two.vercel.app](https://github-profile-trophy-fork-two.vercel.app)
by [hesreallyhim](https://github.com/hesreallyhim)
- [https://github-profile-trophy-winning.vercel.app](https://github-profile-trophy-winning.vercel.app)
by [hongbo-wei](https://github.com/hongbo-wei)
- [https://github-profile-trophy-kannan.vercel.app](https://github-profile-trophy-kannan.vercel.app)
by [kann4n](https://github.com/kann4n)
- [https://trophy.ryglcloud.net](https://trophy.ryglcloud.net) by
[PracticalRyan](https://github.com/PracticalRyan)
- [https://github-profile-trophy-tawny.vercel.app](https://github-profile-trophy-tawny.vercel.app)
by [vijaypurohit322](https://github.com/vijaypurohit322)
- [https://github-profile-repo.vercel.app](https://github-profile-repo.vercel.app/))
by [HackyCoder0951](https://github.com/hackycoder0951)
- [https://gh-trophy.cdnsoft.net](https://gh-trophy.cdnsoft.net) by
[cromatikap](https://github.com/cromatikap)
- [https://trophygh.kolioaris.xyz](https://trophygh.kolioaris.xyz) by
[kolioaris](https://github.com/kolioaris)
- [https://github-profile-trophy-orcin-eta.vercel.app](https://github-profile-trophy-orcin-eta.vercel.app/)
by [manupawick](https://github.com/manupawickramasinghe)
- [https://github-profile-trophy-reiyua-mirror.vercel.app](https://github-profile-trophy-reiyua-mirror.vercel.app)
by [reiyua](https://github.com/reiyua)
# Quick Start
Add the following code to your readme. When pasting the code into your profile's
readme, change the `?username=` value to your GitHub's username.
```
[](https://github.com/ryo-ma/github-profile-trophy)
```
## Use theme
Add optional parameter of the theme.
```
[](https://github.com/ryo-ma/github-profile-trophy)
```
**[More detail](#apply-theme)**
# About Rank
Ranks are `SSS` `SS` `S` `AAA` `AA` `A` `B` `C` `UNKNOWN` `SECRET`.
| Rank | Description |
| ---------- | ------------------------------------------------------------------------------------------ |
| SSS, SS, S | You are at a hard to reach rank. You can brag. |
| AAA, AA, A | You will reach this rank if you do your best. Let's aim here first. |
| B, C | You are currently making good progress. Let's aim a bit higher. |
| UNKNOWN | You have not taken action yet. Let's act first. |
| SECRET | This rank is very rare. The trophy will not be displayed until certain conditions are met. |
**NOTE: The `UNKNOWN` rank is denoted by `?`**
## Secret Rank
The acquisition condition is secret, but you can see this.
There are only a few secret trophies. Therefore, if you come up with interesting
conditions, I will consider adding a trophy. I am waiting for contributions.
# About Display details
1. Title name of aggregation target.
2. Current rank.
3. Title according to rank.
4. Target aggregation result.
5. Rank progress bar.
# Optional Request Parameters
- [title](#filter-by-titles)
- [rank](#filter-by-ranks)
- [column](#specify-the-maximum-row--column-size)
- [row](#specify-the-maximum-row--column-size)
- [theme](#apply-theme)
- [margin-w](#margin-width)
- [margin-h](#margin-height)
- [no-bg](#transparent-background)
- [no-frame](#hide-frames)
## Filter by titles
You can filter the display by specifying the titles of trophy.
```
https://github-profile-trophy.vercel.app/?username=ryo-ma&title=Followers
```
If you want to specify multiple titles.
```
https://github-profile-trophy.vercel.app/?username=ryo-ma&title=Stars,Followers
```
You can also exclude the trophies you don't want to display.
```
https://github-profile-trophy.vercel.app/?username=ryo-ma&title=-Stars,-Followers
```
## Filter by ranks
You can filter the display by specifying the ranks.\
`Available values: SECRET SSS SS S AAA AA A B C`
```
https://github-profile-trophy.vercel.app/?username=ryo-ma&rank=S
```
If you want to specify multiple ranks.
```
https://github-profile-trophy.vercel.app/?username=ryo-ma&rank=S,AAA
```
You can also exclude ranks.
```
https://github-profile-trophy.vercel.app/?username=ryo-ma&rank=-C,-B
```
**NOTE: Since `UNKNOWN` is denoted by `?`, in order to include or exclude it you
will have to use `rank=?` and `rank=-?` respectively**
## Specify the maximum row & column size
You can specify the maximum row and column size.\
Trophy will be hidden if it exceeds the range of both row and column.
`Available value: number type`\
`Default: column=6 row=3`
Restrict only row
```
https://github-profile-trophy.vercel.app/?username=ryo-ma&row=2
```
Restrict only column
```
https://github-profile-trophy.vercel.app/?username=ryo-ma&column=2
```
Restrict row & column
```
https://github-profile-trophy.vercel.app/?username=ryo-ma&row=2&column=3
```
Adaptive column
```
https://github-profile-trophy.vercel.app/?username=ryo-ma&column=-1
```
You can set `column` to `-1` to adapt the width to the number of trophies, the
parameter `row` will be ignored.
## Apply theme
Available themes.
| theme |
| --------------------------- |
| [flat](#flat) |
| [onedark](#onedark) |
| [gruvbox](#gruvbox) |
| [dracula](#dracula) |
| [monokai](#monokai) |
| [chalk](#chalk) |
| [nord](#nord) |
| [alduin](#alduin) |
| [darkhub](#darkhub) |
| [juicyfresh](#juicyfresh) |
| [buddhism](#buddhism) |
| [oldie](#oldie) |
| [radical](#radical) |
| [onestar](#onestar) |
| [discord](#discord) |
| [algolia](#algolia) |
| [gitdimmed](#gitdimmed) |
| [tokyonight](#tokyonight) |
| [matrix](#matrix) |
| [apprentice](#apprentice) |
| [dark_dimmed](#dark_dimmed) |
| [dark_lover](#dark_lover) |
| [kimbie_dark](#kimbie_dark) |
| [aura](#aura) |
### flat
```
https://github-profile-trophy.vercel.app/?username=ryo-ma&theme=flat
```
### onedark
```
https://github-profile-trophy.vercel.app/?username=ryo-ma&theme=onedark
```
### gruvbox
```
https://github-profile-trophy.vercel.app/?username=ryo-ma&theme=gruvbox
```
### dracula
```
https://github-profile-trophy.vercel.app/?username=ryo-ma&theme=dracula
```
### monokai
```
https://github-profile-trophy.vercel.app/?username=ryo-ma&theme=monokai
```
### chalk
```
https://github-profile-trophy.vercel.app/?username=ryo-ma&theme=chalk
```
### nord
```
https://github-profile-trophy.vercel.app/?username=ryo-ma&theme=nord
```
### alduin
```
https://github-profile-trophy.vercel.app/?username=ryo-ma&theme=alduin
```
### darkhub
```
https://github-profile-trophy.vercel.app/?username=ryo-ma&theme=darkhub
```
### juicyfresh
```
https://github-profile-trophy.vercel.app/?username=ryo-ma&theme=juicyfresh
```
### buddhism
```
https://github-profile-trophy.vercel.app/?username=ryo-ma&theme=buddhism
```
### oldie
```
https://github-profile-trophy.vercel.app/?username=ryo-ma&theme=oldie
```
### radical
```
https://github-profile-trophy.vercel.app/?username=ryo-ma&theme=radical
```
### onestar
```
https://github-profile-trophy.vercel.app/?username=ryo-ma&theme=onestar
```
### discord
```
https://github-profile-trophy.vercel.app/?username=ryo-ma&theme=discord
```
### algolia
```
https://github-profile-trophy.vercel.app/?username=ryo-ma&theme=algolia
```
### gitdimmed
```
https://github-profile-trophy.vercel.app/?username=ryo-ma&theme=gitdimmed
```
### tokyonight
```
https://github-profile-trophy.vercel.app/?username=ryo-ma&theme=tokyonight
```
### matrix
```
https://github-profile-trophy.vercel.app/?username=ryo-ma&theme=matrix
```
### apprentice
```
https://github-profile-trophy.vercel.app/?username=ryo-ma&theme=apprentice
```
### dark_dimmed
```
https://github-profile-trophy.vercel.app/?username=ryo-ma&theme=dark_dimmed
```
### dark_lover
```
https://github-profile-trophy.vercel.app/?username=ryo-ma&theme=dark_lover
```
### kimbie_dark
```
https://github-profile-trophy.vercel.app/?username=ryo-ma&theme=kimbie_dark
```
### aura
```
https://github-profile-trophy.vercel.app/?username=ryo-ma&theme=aura
```
## Margin Width
You can put a margin in the width between trophies.\
`Available value: number type`\
`Default: margin-w=0`
```
https://github-profile-trophy.vercel.app/?username=ryo-ma&margin-w=15
```
## Margin Height
You can put a margin in the height between trophies.\
`Available value: number type`\
`Default: margin-h=0`
```
https://github-profile-trophy.vercel.app/?username=ryo-ma&margin-h=15
```
## Example layout
```
https://github-profile-trophy.vercel.app/?username=ryo-ma&column=3&margin-w=15&margin-h=15
```
## Transparent background
You can turn the background transparent.\
`Available value: boolean type (true or false)`\
`Default: no-bg=false`
```
https://github-profile-trophy.vercel.app/?username=ryo-ma&no-bg=true
```
## Hide frames
You can hide the frames around the trophies.\
`Available value: boolean type (true or false)`\
`Default: no-frame=false`
```
https://github-profile-trophy.vercel.app/?username=ryo-ma&no-frame=true
```
## Generate an svg file localy
Using the render_svg.ts script you can generate your trophys as an svg file
given your username, (Enviroment Vars: See [env-example](env-example)).
Usage:
```bash
deno run --allow-net --allow-env --allow-read --allow-write ./render_svg.ts USERNAME OUTPUT_DIR THEME
```
## Generate an svg inside Github CI (Workflow)
Using the provided github action you can easly generate the trophy inside an
github workflow. This eliminates the needs of an online service running but you
have to manualy update rerun the action to update the file.
Usage:
```yaml
- name: Generate trophy
uses: Erik-Donath/github-profile-trophy@feature/generate-svg
with:
username: your-username
output_path: trophy.svg
token: ${{ secrets.GITHUB_TOKEN }}
```
# Contribution Guide
Check [CONTRIBUTING.md](./CONTRIBUTING.md) for more details.
# License
This product is licensed under the
[MIT License](https://github.com/ryo-ma/github-profile-trophy/blob/master/LICENSE).
# 🙏 Sponsors
Thank you so much to all the amazing sponsors who support this project! Your
contributions help keep development going and make this work possible.
## 💖 Monthly Sponsors
- [@Leay15](https://github.com/Leay15) - $10 / month
- [@hesreallyhim](https://github.com/hesreallyhim) - $10 / month
- [@pmsosa](https://github.com/pmsosa) — $10 / month
- [@chenfeng-huang](https://github.com/chenfeng-huang) — $10 / month
- [@holly-hacker](https://github.com/holly-hacker) — $2 / month
- [@skillerious](https://github.com/skillerious) — $2 / month (since Aug
17, 2024)
- [@LudovicGardy](https://github.com/LudovicGardy) — $10 / month (since Aug
15, 2024)
- [@alexcastrodev](https://github.com/alexcastrodev) — $10 / month (since Oct
13, 2023, previously $50 / month)
- [@great-work-told-is](https://github.com/great-work-told-is) — $10 / month
(since Apr 12, 2023)
- [@Ilithy](https://github.com/Ilithy) — $5 / month (since Jul 21, 2022)
- [@weakish](https://github.com/weakish) — $2 / month (since Jan 22, 2022)
- [@Kazuhito00](https://github.com/Kazuhito00) — $2 / month (since Jan 19, 2022)
- [@KATO-Hiro](https://github.com/KATO-Hiro) — $2 / month (since Jan 5, 2022)
- [@batazor](https://github.com/batazor) — $2 / month (since Oct 28, 2021)
- [@port19x](https://github.com/port19x) — $2 / month (since Jan 27, 2022)
## 🎁 One-Time Sponsors
- [@pronoym99](https://github.com/pronoym99) - $20 (Feb 8, 2026)
- [@hesreallyhim](https://github.com/hesreallyhim) - $100 (Jan 11, 2026)
- [@Seo-4d696b75](https://github.com/Seo-4d696b75) — $10 (Jan 4, 2026)
- [@massif-01](https://github.com/massif-01) — $5 (December 17, 2025)
- [@tapegram](https://github.com/tapegram) — $20 (December 6, 2025)
- [@WilliamCorotan](https://github.com/WilliamCorotan) — $5 (November 5, 2025)
- [@arnabnandy7](https://github.com/arnabnandy7) — $10 (Oct 3, 2025)
- [@JoqarSabon](https://github.com/JoqarSabon) — $5 (May 24, 2024)
- [@syaghoubi00](https://github.com/syaghoubi00) — $5 (Jan 28, 2024)
- [@pylapp](https://github.com/pylapp) — $20 (Jan 15, 2024)
- [@Dobefu](https://github.com/Dobefu) — $10 (Dec 22, 2024)
- [@michele-lorenzoni](https://github.com/michele-lorenzoni) — $10 (Nov
26, 2024)
- [@skillerious](https://github.com/skillerious) — $10 (Aug 17, 2024)
================================================
FILE: action.yml
================================================
name: Generate GitHub Profile Trophy SVG
description: Run the local generator script to produce an SVG.
inputs:
username:
description: "GitHub username to generate the trophy for"
required: true
output_path:
description: "Output path to write the SVG"
required: true
default: "trophy.svg"
token:
description: "PAT or token to use for GitHub API"
required: true
theme:
description: "Theme for the card"
required: false
default: "default"
runs:
using: "composite"
steps:
- name: Setup Deno
uses: denoland/setup-deno@v1
with:
deno-version: v1.x
- name: Generate trophy
shell: bash
env:
GITHUB_TOKEN1: ${{ inputs.token }}
run: |
deno run --allow-net --allow-env --allow-read --allow-write $GITHUB_ACTION_PATH/render_svg.ts "${{ inputs.username }}" "${{ inputs.output_path }}" "${{ inputs.theme }}"
================================================
FILE: api/index.ts
================================================
import { Card } from "../src/card.ts";
import { CONSTANTS, parseParams } from "../src/utils.ts";
import { COLORS, Theme } from "../src/theme.ts";
import { Error400 } from "../src/error_page.ts";
import "https://deno.land/x/dotenv@v0.5.0/load.ts";
import { staticRenderRegeneration } from "../src/StaticRenderRegeneration/index.ts";
import { GithubRepositoryService } from "../src/Repository/GithubRepository.ts";
import { GithubApiService } from "../src/Services/GithubApiService.ts";
import { ServiceError } from "../src/Types/index.ts";
import { ErrorPage } from "../src/pages/Error.ts";
import { cacheProvider } from "../src/config/cache.ts";
const serviceProvider = new GithubApiService();
const client = new GithubRepositoryService(serviceProvider).repository;
// Build cache control header with optimized caching strategy
const cacheControlHeader = [
"public",
`max-age=${CONSTANTS.CACHE_MAX_AGE}`,
`s-maxage=${CONSTANTS.CDN_CACHE_MAX_AGE}`,
`stale-while-revalidate=${CONSTANTS.STALE_WHILE_REVALIDATE}`,
].join(", ");
const defaultHeaders = new Headers(
{
"Content-Type": "image/svg+xml",
"Cache-Control": cacheControlHeader,
},
);
export default (request: Request) =>
staticRenderRegeneration(request, {
revalidate: CONSTANTS.REVALIDATE_TIME,
headers: defaultHeaders,
}, function (req: Request) {
return app(req);
});
async function app(req: Request): Promise {
const params = parseParams(req);
const username = params.get("username");
const row = params.getNumberValue("row", CONSTANTS.DEFAULT_MAX_ROW);
const column = params.getNumberValue("column", CONSTANTS.DEFAULT_MAX_COLUMN);
const themeParam: string = params.getStringValue("theme", "default");
if (username === null) {
const [base] = req.url.split("?");
const error = new Error400(
`
"username" is a required query parameter
The URL should look like
${base}?username=USERNAME
Copy Base Url
where
USERNAME is
your GitHub username.
You can use this form:
Enter your username and click "Get Trophies"
`,
);
return new Response(
error.render(),
{
status: error.status,
headers: new Headers({
"Content-Type": "text/html",
"Cache-Control": cacheControlHeader,
}),
},
);
}
let theme: Theme = COLORS.default;
if (Object.keys(COLORS).includes(themeParam)) {
theme = COLORS[themeParam];
}
const marginWidth = params.getNumberValue(
"margin-w",
CONSTANTS.DEFAULT_MARGIN_W,
);
const paddingHeight = params.getNumberValue(
"margin-h",
CONSTANTS.DEFAULT_MARGIN_H,
);
const noBackground = params.getBooleanValue(
"no-bg",
CONSTANTS.DEFAULT_NO_BACKGROUND,
);
const noFrame = params.getBooleanValue(
"no-frame",
CONSTANTS.DEFAULT_NO_FRAME,
);
const titles: Array = params.getAll("title").flatMap((r) =>
r.split(",")
).map((r) => r.trim());
const ranks: Array = params.getAll("rank").flatMap((r) =>
r.split(",")
).map((r) => r.trim());
const userKeyCache = ["v1", username].join("-");
const userInfoCached = await cacheProvider.get(userKeyCache) || "{}";
let userInfo = JSON.parse(userInfoCached);
const hasCache = !!Object.keys(userInfo).length;
if (!hasCache) {
const userResponseInfo = await client.requestUserInfo(username);
if (userResponseInfo instanceof ServiceError) {
return new Response(
ErrorPage({ error: userResponseInfo }).render(),
{
status: userResponseInfo.code,
headers: new Headers({
"Content-Type": "text/html",
"Cache-Control": cacheControlHeader,
}),
},
);
}
userInfo = userResponseInfo;
await cacheProvider.set(userKeyCache, JSON.stringify(userInfo));
}
// Success Response
return new Response(
new Card(
titles,
ranks,
column,
row,
CONSTANTS.DEFAULT_PANEL_SIZE,
marginWidth,
paddingHeight,
noBackground,
noFrame,
).render(userInfo, theme),
{
headers: defaultHeaders,
},
);
}
================================================
FILE: deno.json
================================================
{
"tasks": {
"start": "deno run -A main.ts",
"debug": "deno run --inspect-brk -A main.ts",
"format": "deno fmt",
"lint": "deno lint",
"test": "ENV_TYPE=test deno test --allow-env"
}
}
================================================
FILE: deps.ts
================================================
import { Soxa as ServiceProvider } from "https://deno.land/x/soxa@1.4/src/core/Soxa.ts";
import { defaults } from "https://deno.land/x/soxa@1.4/src/defaults.ts";
import {
assertEquals,
assertRejects,
} from "https://deno.land/std@0.203.0/assert/mod.ts";
import {
assertSpyCalls,
returnsNext,
spy,
stub,
} from "https://deno.land/std@0.203.0/testing/mock.ts";
export {
type Bulk,
connect,
type Redis,
} from "https://deno.land/x/redis@v0.31.0/mod.ts";
import { CONSTANTS } from "./src/utils.ts";
const baseURL = Deno.env.get("GITHUB_API") || CONSTANTS.DEFAULT_GITHUB_API;
const soxa = new ServiceProvider({
...defaults,
baseURL,
});
export {
assertEquals,
assertRejects,
assertSpyCalls,
returnsNext,
soxa,
spy,
stub,
};
================================================
FILE: docker-compose.yml
================================================
version: "3"
services:
redis:
container_name: trophy-redis
image: redis:latest
ports:
- "6379:6379"
deno-app:
build: .
volumes:
- .:/app
ports:
- "80:8080"
environment:
- DENO_ENV=development
command: ["deno", "run", "--watch", "-A", "main.ts"]
================================================
FILE: env-example
================================================
PORT=8080
GITHUB_TOKEN1=
GITHUB_TOKEN2=
GITHUB_API=https://api.github.com/graphql
ENABLE_REDIS=
REDIS_PORT=6379
REDIS_HOST=
REDIS_USERNAME=
REDIS_PASSWORD=
================================================
FILE: main.ts
================================================
import { serve } from "https://deno.land/std@0.125.0/http/server.ts";
import requestHandler from "./api/index.ts";
serve(requestHandler, { port: Number(Deno.env.get("PORT")) || 8080 });
================================================
FILE: render_svg.ts
================================================
import "https://deno.land/x/dotenv@v0.5.0/load.ts";
const username = Deno.args[0];
const outputPath = Deno.args[1] ?? "./assets/trophy.svg";
const themeName = Deno.args[2] ?? "default";
if (!username) {
console.error(
"Usage: deno run --allow-net --allow-env --allow-read --allow-write ./render_svg.ts USERNAME [OUTPUT_PATH] [THEME]",
);
Deno.exit(1);
}
import { GithubApiService } from "./src/Services/GithubApiService.ts";
import { Card } from "./src/card.ts";
import { COLORS } from "./src/theme.ts";
async function main() {
console.log("Starting trophy render...");
console.log("Username:", username);
console.log("Output path:", outputPath);
console.log("Theme:", themeName);
const svc = new GithubApiService();
const userInfoOrError = await svc.requestUserInfo(username);
if (
!(userInfoOrError && (userInfoOrError as any).totalCommits !== undefined)
) {
console.error(
"Failed to fetch user info. Check token, username and rate limits.",
);
Deno.exit(2);
}
const userInfo = userInfoOrError as any;
const panelSize = 115;
const maxRow = 10;
const maxColumn = -1; // auto
const marginWidth = 10;
const marginHeight = 10;
const noBackground = false;
const noFrame = false;
const card = new Card(
[],
[],
maxColumn,
maxRow,
panelSize,
marginWidth,
marginHeight,
noBackground,
noFrame,
);
const theme = (COLORS as any)[themeName] ?? (COLORS as any).default;
const svg = card.render(userInfo, theme);
try {
const dir = outputPath.replace(/\/[^/]+$/, "");
if (dir) await Deno.mkdir(dir, { recursive: true });
} catch {
console.error("Failed to create directory. No permission?");
Deno.exit(3);
}
await Deno.writeTextFile(outputPath, svg);
console.log(`Wrote ${outputPath}`);
}
await main();
================================================
FILE: src/Helpers/Logger.ts
================================================
const enableLogging = Deno.env.get("ENV_TYPE") !== "test";
export class Logger {
public static log(message: unknown): void {
if (!enableLogging) return;
console.log(message);
}
public static error(message: unknown): void {
if (!enableLogging) return;
console.error(message);
}
public static warn(message: unknown): void {
if (!enableLogging) return;
console.warn(message);
}
}
================================================
FILE: src/Helpers/Retry.ts
================================================
import { ServiceError } from "../Types/index.ts";
import { Logger } from "./Logger.ts";
export type RetryCallbackProps = {
attempt: number;
};
type callbackType = (data: RetryCallbackProps) => Promise | T;
async function* createAsyncIterable(
callback: callbackType,
retries: number,
delay: number,
) {
for (let i = 0; i < retries; i++) {
const isLastAttempt = i === retries - 1;
try {
const data = await callback({ attempt: i });
yield data;
return;
} catch (e) {
if (e instanceof ServiceError && isLastAttempt) {
yield e;
return;
}
yield null;
Logger.error(e);
await new Promise((resolve) => setTimeout(resolve, delay));
}
}
}
export class Retry {
constructor(private maxRetries = 2, private retryDelay = 1000) {}
async fetch(
callback: callbackType,
) {
let lastError = null;
for await (
const callbackResult of createAsyncIterable(
callback,
this.maxRetries,
this.retryDelay,
)
) {
const isError = callbackResult instanceof Error;
if (callbackResult && !isError) {
return callbackResult as T;
}
if (isError) {
lastError = callbackResult;
}
}
throw new Error(`Max retries (${this.maxRetries}) exceeded.`, {
cause: lastError,
});
}
}
================================================
FILE: src/Helpers/__tests__/Retry.test.ts
================================================
import { Retry } from "../Retry.ts";
import {
assertEquals,
assertRejects,
assertSpyCalls,
spy,
} from "../../../deps.ts";
type MockResponse = {
value: number;
};
Deno.test("Retry.fetch", () => {
const retryInstance = new Retry();
const callback = spy(retryInstance, "fetch");
retryInstance.fetch(() => {
return { value: 1 };
});
assertSpyCalls(callback, 1);
});
Deno.test("Should retry", async () => {
let countErrors = 0;
const callbackError = () => {
countErrors++;
throw new Error("Panic! Threw Error");
};
const retries = 3;
const retryInstance = new Retry(retries);
await assertRejects(
() => {
return retryInstance.fetch(callbackError);
},
Error,
`Max retries (${retries}) exceeded.`,
);
assertEquals(countErrors, 3);
});
Deno.test("Should retry the asyncronous callback", async () => {
let countErrors = 0;
const callbackError = async () => {
countErrors++;
// Mock request in callback
await new Promise((_, reject) => setTimeout(reject, 100));
};
const retries = 3;
const retryInstance = new Retry(retries);
await assertRejects(
() => {
return retryInstance.fetch(callbackError);
},
Error,
`Max retries (${retries}) exceeded.`,
);
assertEquals(countErrors, 3);
});
================================================
FILE: src/Repository/GithubRepository.ts
================================================
import { ServiceError } from "../Types/index.ts";
import {
GitHubUserActivity,
GitHubUserAll,
GitHubUserIssue,
GitHubUserPullRequest,
GitHubUserRepository,
UserInfo,
} from "../user_info.ts";
export abstract class GithubRepository {
abstract requestUserInfo(username: string): Promise;
abstract requestUserAll(
username: string,
): Promise;
abstract requestUserActivity(
username: string,
): Promise;
abstract requestUserIssue(
username: string,
): Promise;
abstract requestUserPullRequest(
username: string,
): Promise;
abstract requestUserRepository(
username: string,
): Promise;
}
export class GithubRepositoryService {
constructor(public repository: GithubRepository) {}
}
================================================
FILE: src/Schemas/index.ts
================================================
export const queryUserActivity = `
query userInfo($username: String!) {
user(login: $username) {
createdAt
contributionsCollection {
totalCommitContributions
restrictedContributionsCount
totalPullRequestReviewContributions
}
organizations(first: 1) {
totalCount
}
followers(first: 1) {
totalCount
}
}
}
`;
export const queryUserIssue = `
query userInfo($username: String!) {
user(login: $username) {
openIssues: issues(states: OPEN) {
totalCount
}
closedIssues: issues(states: CLOSED) {
totalCount
}
}
}
`;
export const queryUserPullRequest = `
query userInfo($username: String!) {
user(login: $username) {
pullRequests(first: 1) {
totalCount
}
}
}
`;
export const queryUserRepository = `
query userInfo($username: String!) {
user(login: $username) {
repositories(first: 50, ownerAffiliations: OWNER, orderBy: {direction: DESC, field: STARGAZERS}) {
totalCount
nodes {
languages(first: 2, orderBy: {direction:DESC, field: SIZE}) {
nodes {
name
}
}
stargazers {
totalCount
}
createdAt
}
}
}
}
`;
export const queryUserAll = `
query userInfo($username: String!) {
user(login: $username) {
createdAt
contributionsCollection {
totalCommitContributions
restrictedContributionsCount
totalPullRequestReviewContributions
}
organizations(first: 1) {
totalCount
}
followers(first: 1) {
totalCount
}
openIssues: issues(states: OPEN) {
totalCount
}
closedIssues: issues(states: CLOSED) {
totalCount
}
pullRequests(first: 1) {
totalCount
}
repositories(first: 50, ownerAffiliations: OWNER, orderBy: {direction: DESC, field: STARGAZERS}) {
totalCount
nodes {
languages(first: 2, orderBy: {direction:DESC, field: SIZE}) {
nodes {
name
}
}
stargazers {
totalCount
}
createdAt
}
}
}
}
`;
================================================
FILE: src/Services/GithubApiService.ts
================================================
import { GithubRepository } from "../Repository/GithubRepository.ts";
import {
GitHubUserActivity,
GitHubUserAll,
GitHubUserIssue,
GitHubUserPullRequest,
GitHubUserRepository,
UserInfo,
} from "../user_info.ts";
import {
queryUserActivity,
queryUserAll,
queryUserIssue,
queryUserPullRequest,
queryUserRepository,
} from "../Schemas/index.ts";
import { Retry } from "../Helpers/Retry.ts";
import { CONSTANTS } from "../utils.ts";
import { EServiceKindError, ServiceError } from "../Types/index.ts";
import { Logger } from "../Helpers/Logger.ts";
import { requestGithubData } from "./request.ts";
// Need to be here - Exporting from another file makes array of null
export const TOKENS = [
Deno.env.get("GITHUB_TOKEN1"),
Deno.env.get("GITHUB_TOKEN2"),
];
export class GithubApiService extends GithubRepository {
async requestUserAll(
username: string,
): Promise {
return await this.executeQuery(queryUserAll, {
username,
});
}
async requestUserRepository(
username: string,
): Promise {
return await this.executeQuery(queryUserRepository, {
username,
});
}
async requestUserActivity(
username: string,
): Promise {
return await this.executeQuery(queryUserActivity, {
username,
});
}
async requestUserIssue(
username: string,
): Promise {
return await this.executeQuery(queryUserIssue, {
username,
});
}
async requestUserPullRequest(
username: string,
): Promise {
return await this.executeQuery(
queryUserPullRequest,
{ username },
);
}
async requestUserInfo(username: string): Promise {
// Use single combined query instead of 4 separate queries to reduce Function Duration
try {
const result = await this.requestUserAll(username);
if (result instanceof ServiceError) {
return result;
}
return UserInfo.fromCombined(result);
} catch {
Logger.error(`Error fetching user info for username: ${username}`);
return new ServiceError("Not found", EServiceKindError.NOT_FOUND);
}
}
async executeQuery(
query: string,
variables: { [key: string]: string },
) {
try {
const retry = new Retry(
TOKENS.length,
CONSTANTS.DEFAULT_GITHUB_RETRY_DELAY,
);
return await retry.fetch>(async ({ attempt }) => {
return await requestGithubData(
query,
variables,
TOKENS[attempt],
);
});
} catch (error) {
if (error.cause instanceof ServiceError) {
Logger.error(error.cause.message);
return error.cause;
}
if (error instanceof Error && error.cause) {
Logger.error(JSON.stringify(error.cause, null, 2));
} else {
Logger.error(error);
}
return new ServiceError("not found", EServiceKindError.NOT_FOUND);
}
}
}
================================================
FILE: src/Services/__mocks__/notFoundUserMock.json
================================================
{
"data": {
"data": {
"user": null
},
"errors": [
{
"type": "NOT_FOUND",
"path": [
"user"
],
"locations": [
{
"line": 2,
"column": 5
}
],
"message": "Could not resolve to a User with the login of 'alekinho'."
}
]
}
}
================================================
FILE: src/Services/__mocks__/rateLimitMock.json
================================================
{
"exceeded": {
"data": {
"documentation_url": "https://docs.github.com/en/free-pro-team@latest/rest/overview/resources-in-the-rest-api#secondary-rate-limits",
"message": "You have exceeded a secondary rate limit. Please wait a few minutes before you try again. If you reach out to GitHub Support for help, please include the request ID DBD8:FB98:31801A8:3222432:65195FDB."
}
},
"rate_limit": {
"data": {
"data": {
"user": null
},
"errors": [
{
"type": "RATE_LIMITED",
"message": "API rate limit exceeded for user ID 10711649."
}
]
}
}
}
================================================
FILE: src/Services/__mocks__/successGithubResponse.json
================================================
{
"data": {
"data": {
"user": {
"repositories": {
"totalCount": 128,
"nodes": [
{
"languages": {
"nodes": [
{
"name": "JavaScript"
}
]
},
"stargazers": {
"totalCount": 23
}
},
{
"languages": {
"nodes": [
{
"name": "JavaScript"
}
]
},
"stargazers": {
"totalCount": 11
}
},
{
"languages": {
"nodes": [
{
"name": "TypeScript"
}
]
},
"stargazers": {
"totalCount": 9
}
},
{
"languages": {
"nodes": [
{
"name": "JavaScript"
}
]
},
"stargazers": {
"totalCount": 6
}
},
{
"languages": {
"nodes": [
{
"name": "JavaScript"
}
]
},
"stargazers": {
"totalCount": 6
}
},
{
"languages": {
"nodes": [
{
"name": "Java"
}
]
},
"stargazers": {
"totalCount": 5
}
},
{
"languages": {
"nodes": [
{
"name": "TypeScript"
}
]
},
"stargazers": {
"totalCount": 5
}
},
{
"languages": {
"nodes": [
{
"name": "Jupyter Notebook"
}
]
},
"stargazers": {
"totalCount": 5
}
},
{
"languages": {
"nodes": [
{
"name": "TypeScript"
}
]
},
"stargazers": {
"totalCount": 4
}
},
{
"languages": {
"nodes": [
{
"name": "JavaScript"
}
]
},
"stargazers": {
"totalCount": 3
}
},
{
"languages": {
"nodes": [
{
"name": "TypeScript"
}
]
},
"stargazers": {
"totalCount": 2
}
},
{
"languages": {
"nodes": [
{
"name": "JavaScript"
}
]
},
"stargazers": {
"totalCount": 2
}
},
{
"languages": {
"nodes": [
{
"name": "JavaScript"
}
]
},
"stargazers": {
"totalCount": 2
}
},
{
"languages": {
"nodes": []
},
"stargazers": {
"totalCount": 1
}
},
{
"languages": {
"nodes": [
{
"name": "JavaScript"
}
]
},
"stargazers": {
"totalCount": 1
}
},
{
"languages": {
"nodes": [
{
"name": "PHP"
},
{
"name": "Go"
},
{
"name": "HTML"
}
]
},
"stargazers": {
"totalCount": 1
}
},
{
"languages": {
"nodes": [
{
"name": "TypeScript"
},
{
"name": "JavaScript"
},
{
"name": "CSS"
}
]
},
"stargazers": {
"totalCount": 1
}
},
{
"languages": {
"nodes": [
{
"name": "TypeScript"
}
]
},
"stargazers": {
"totalCount": 1
}
},
{
"languages": {
"nodes": [
{
"name": "TypeScript"
},
{
"name": "JavaScript"
},
{
"name": "Dockerfile"
}
]
},
"stargazers": {
"totalCount": 1
}
},
{
"languages": {
"nodes": [
{
"name": "TypeScript"
},
{
"name": "JavaScript"
},
{
"name": "Dockerfile"
}
]
},
"stargazers": {
"totalCount": 1
}
},
{
"languages": {
"nodes": [
{
"name": "Dart"
},
{
"name": "Swift"
},
{
"name": "Kotlin"
}
]
},
"stargazers": {
"totalCount": 1
}
},
{
"languages": {
"nodes": [
{
"name": "HTML"
},
{
"name": "CSS"
}
]
},
"stargazers": {
"totalCount": 1
}
},
{
"languages": {
"nodes": [
{
"name": "JavaScript"
},
{
"name": "Vue"
},
{
"name": "HTML"
}
]
},
"stargazers": {
"totalCount": 1
}
},
{
"languages": {
"nodes": [
{
"name": "Jupyter Notebook"
},
{
"name": "Python"
}
]
},
"stargazers": {
"totalCount": 1
}
},
{
"languages": {
"nodes": [
{
"name": "Dart"
},
{
"name": "HTML"
},
{
"name": "Swift"
}
]
},
"stargazers": {
"totalCount": 1
}
},
{
"languages": {
"nodes": [
{
"name": "JavaScript"
},
{
"name": "Vue"
},
{
"name": "HTML"
}
]
},
"stargazers": {
"totalCount": 1
}
},
{
"languages": {
"nodes": [
{
"name": "PHP"
}
]
},
"stargazers": {
"totalCount": 1
}
},
{
"languages": {
"nodes": [
{
"name": "TypeScript"
},
{
"name": "CSS"
},
{
"name": "JavaScript"
}
]
},
"stargazers": {
"totalCount": 0
}
},
{
"languages": {
"nodes": [
{
"name": "TypeScript"
},
{
"name": "HTML"
},
{
"name": "JavaScript"
}
]
},
"stargazers": {
"totalCount": 0
}
},
{
"languages": {
"nodes": [
{
"name": "TypeScript"
},
{
"name": "CSS"
},
{
"name": "JavaScript"
}
]
},
"stargazers": {
"totalCount": 0
}
},
{
"languages": {
"nodes": []
},
"stargazers": {
"totalCount": 0
}
},
{
"languages": {
"nodes": []
},
"stargazers": {
"totalCount": 0
}
},
{
"languages": {
"nodes": [
{
"name": "JavaScript"
},
{
"name": "Shell"
},
{
"name": "Dockerfile"
}
]
},
"stargazers": {
"totalCount": 0
}
},
{
"languages": {
"nodes": [
{
"name": "JavaScript"
}
]
},
"stargazers": {
"totalCount": 0
}
},
{
"languages": {
"nodes": [
{
"name": "TypeScript"
}
]
},
"stargazers": {
"totalCount": 0
}
},
{
"languages": {
"nodes": [
{
"name": "CSS"
},
{
"name": "TypeScript"
},
{
"name": "JavaScript"
}
]
},
"stargazers": {
"totalCount": 0
}
},
{
"languages": {
"nodes": [
{
"name": "TypeScript"
},
{
"name": "JavaScript"
},
{
"name": "Dockerfile"
}
]
},
"stargazers": {
"totalCount": 0
}
},
{
"languages": {
"nodes": []
},
"stargazers": {
"totalCount": 0
}
},
{
"languages": {
"nodes": [
{
"name": "JavaScript"
},
{
"name": "TypeScript"
}
]
},
"stargazers": {
"totalCount": 0
}
},
{
"languages": {
"nodes": [
{
"name": "TypeScript"
},
{
"name": "CSS"
},
{
"name": "JavaScript"
}
]
},
"stargazers": {
"totalCount": 0
}
},
{
"languages": {
"nodes": [
{
"name": "JavaScript"
},
{
"name": "HTML"
},
{
"name": "C#"
}
]
},
"stargazers": {
"totalCount": 0
}
},
{
"languages": {
"nodes": [
{
"name": "JavaScript"
},
{
"name": "HTML"
}
]
},
"stargazers": {
"totalCount": 0
}
},
{
"languages": {
"nodes": [
{
"name": "TypeScript"
},
{
"name": "JavaScript"
},
{
"name": "CSS"
}
]
},
"stargazers": {
"totalCount": 0
}
},
{
"languages": {
"nodes": [
{
"name": "PHP"
},
{
"name": "Vue"
},
{
"name": "Blade"
}
]
},
"stargazers": {
"totalCount": 0
}
},
{
"languages": {
"nodes": [
{
"name": "CSS"
},
{
"name": "TypeScript"
},
{
"name": "JavaScript"
}
]
},
"stargazers": {
"totalCount": 0
}
},
{
"languages": {
"nodes": [
{
"name": "C"
},
{
"name": "C++"
},
{
"name": "HTML"
}
]
},
"stargazers": {
"totalCount": 0
}
},
{
"languages": {
"nodes": [
{
"name": "C++"
},
{
"name": "Makefile"
},
{
"name": "CMake"
}
]
},
"stargazers": {
"totalCount": 0
}
},
{
"languages": {
"nodes": [
{
"name": "JavaScript"
}
]
},
"stargazers": {
"totalCount": 0
}
},
{
"languages": {
"nodes": [
{
"name": "TypeScript"
},
{
"name": "JavaScript"
}
]
},
"stargazers": {
"totalCount": 0
}
},
{
"languages": {
"nodes": [
{
"name": "C++"
},
{
"name": "Makefile"
},
{
"name": "CMake"
}
]
},
"stargazers": {
"totalCount": 0
}
},
{
"languages": {
"nodes": [
{
"name": "JavaScript"
}
]
},
"stargazers": {
"totalCount": 0
}
},
{
"languages": {
"nodes": [
{
"name": "JavaScript"
},
{
"name": "TypeScript"
}
]
},
"stargazers": {
"totalCount": 0
}
},
{
"languages": {
"nodes": [
{
"name": "JavaScript"
}
]
},
"stargazers": {
"totalCount": 0
}
},
{
"languages": {
"nodes": [
{
"name": "JavaScript"
}
]
},
"stargazers": {
"totalCount": 0
}
},
{
"languages": {
"nodes": [
{
"name": "TypeScript"
},
{
"name": "CSS"
},
{
"name": "HTML"
}
]
},
"stargazers": {
"totalCount": 0
}
},
{
"languages": {
"nodes": [
{
"name": "JavaScript"
},
{
"name": "TypeScript"
},
{
"name": "CSS"
}
]
},
"stargazers": {
"totalCount": 0
}
},
{
"languages": {
"nodes": [
{
"name": "JavaScript"
}
]
},
"stargazers": {
"totalCount": 0
}
},
{
"languages": {
"nodes": [
{
"name": "TypeScript"
},
{
"name": "JavaScript"
}
]
},
"stargazers": {
"totalCount": 0
}
},
{
"languages": {
"nodes": [
{
"name": "Vue"
},
{
"name": "TypeScript"
},
{
"name": "CSS"
}
]
},
"stargazers": {
"totalCount": 0
}
},
{
"languages": {
"nodes": [
{
"name": "TypeScript"
},
{
"name": "CSS"
},
{
"name": "HTML"
}
]
},
"stargazers": {
"totalCount": 0
}
},
{
"languages": {
"nodes": [
{
"name": "TypeScript"
}
]
},
"stargazers": {
"totalCount": 0
}
},
{
"languages": {
"nodes": []
},
"stargazers": {
"totalCount": 0
}
},
{
"languages": {
"nodes": [
{
"name": "JavaScript"
},
{
"name": "HTML"
}
]
},
"stargazers": {
"totalCount": 0
}
},
{
"languages": {
"nodes": [
{
"name": "PHP"
},
{
"name": "JavaScript"
},
{
"name": "Blade"
}
]
},
"stargazers": {
"totalCount": 0
}
},
{
"languages": {
"nodes": [
{
"name": "JavaScript"
},
{
"name": "Rust"
},
{
"name": "HTML"
}
]
},
"stargazers": {
"totalCount": 0
}
},
{
"languages": {
"nodes": [
{
"name": "JavaScript"
},
{
"name": "Svelte"
},
{
"name": "HTML"
}
]
},
"stargazers": {
"totalCount": 0
}
},
{
"languages": {
"nodes": [
{
"name": "CSS"
},
{
"name": "HTML"
},
{
"name": "JavaScript"
}
]
},
"stargazers": {
"totalCount": 0
}
},
{
"languages": {
"nodes": [
{
"name": "JavaScript"
}
]
},
"stargazers": {
"totalCount": 0
}
},
{
"languages": {
"nodes": [
{
"name": "TypeScript"
},
{
"name": "Dockerfile"
},
{
"name": "JavaScript"
}
]
},
"stargazers": {
"totalCount": 0
}
},
{
"languages": {
"nodes": [
{
"name": "Rust"
}
]
},
"stargazers": {
"totalCount": 0
}
},
{
"languages": {
"nodes": [
{
"name": "JavaScript"
},
{
"name": "Dockerfile"
},
{
"name": "Shell"
}
]
},
"stargazers": {
"totalCount": 0
}
},
{
"languages": {
"nodes": [
{
"name": "TypeScript"
},
{
"name": "Shell"
}
]
},
"stargazers": {
"totalCount": 0
}
},
{
"languages": {
"nodes": [
{
"name": "TypeScript"
},
{
"name": "JavaScript"
}
]
},
"stargazers": {
"totalCount": 0
}
},
{
"languages": {
"nodes": [
{
"name": "HTML"
},
{
"name": "JavaScript"
},
{
"name": "CSS"
}
]
},
"stargazers": {
"totalCount": 0
}
},
{
"languages": {
"nodes": [
{
"name": "TypeScript"
},
{
"name": "CSS"
},
{
"name": "HTML"
}
]
},
"stargazers": {
"totalCount": 0
}
},
{
"languages": {
"nodes": [
{
"name": "Dart"
},
{
"name": "HTML"
},
{
"name": "Swift"
}
]
},
"stargazers": {
"totalCount": 0
}
},
{
"languages": {
"nodes": [
{
"name": "Dart"
}
]
},
"stargazers": {
"totalCount": 0
}
},
{
"languages": {
"nodes": [
{
"name": "Rust"
},
{
"name": "JavaScript"
},
{
"name": "HTML"
}
]
},
"stargazers": {
"totalCount": 0
}
},
{
"languages": {
"nodes": [
{
"name": "JavaScript"
}
]
},
"stargazers": {
"totalCount": 0
}
},
{
"languages": {
"nodes": [
{
"name": "JavaScript"
},
{
"name": "CSS"
},
{
"name": "Swift"
}
]
},
"stargazers": {
"totalCount": 0
}
},
{
"languages": {
"nodes": [
{
"name": "TypeScript"
},
{
"name": "JavaScript"
},
{
"name": "SCSS"
}
]
},
"stargazers": {
"totalCount": 0
}
},
{
"languages": {
"nodes": [
{
"name": "TypeScript"
}
]
},
"stargazers": {
"totalCount": 0
}
},
{
"languages": {
"nodes": [
{
"name": "JavaScript"
},
{
"name": "Shell"
}
]
},
"stargazers": {
"totalCount": 0
}
},
{
"languages": {
"nodes": [
{
"name": "JavaScript"
}
]
},
"stargazers": {
"totalCount": 0
}
},
{
"languages": {
"nodes": [
{
"name": "TypeScript"
},
{
"name": "JavaScript"
},
{
"name": "SCSS"
}
]
},
"stargazers": {
"totalCount": 0
}
},
{
"languages": {
"nodes": [
{
"name": "TypeScript"
},
{
"name": "HTML"
}
]
},
"stargazers": {
"totalCount": 0
}
},
{
"languages": {
"nodes": [
{
"name": "TypeScript"
},
{
"name": "JavaScript"
},
{
"name": "HTML"
}
]
},
"stargazers": {
"totalCount": 0
}
},
{
"languages": {
"nodes": [
{
"name": "JavaScript"
},
{
"name": "CSS"
},
{
"name": "HTML"
}
]
},
"stargazers": {
"totalCount": 0
}
},
{
"languages": {
"nodes": [
{
"name": "TypeScript"
},
{
"name": "JavaScript"
},
{
"name": "CSS"
}
]
},
"stargazers": {
"totalCount": 0
}
},
{
"languages": {
"nodes": [
{
"name": "TypeScript"
},
{
"name": "JavaScript"
},
{
"name": "HTML"
}
]
},
"stargazers": {
"totalCount": 0
}
},
{
"languages": {
"nodes": [
{
"name": "TypeScript"
},
{
"name": "JavaScript"
},
{
"name": "HTML"
}
]
},
"stargazers": {
"totalCount": 0
}
},
{
"languages": {
"nodes": [
{
"name": "TypeScript"
},
{
"name": "JavaScript"
},
{
"name": "HTML"
}
]
},
"stargazers": {
"totalCount": 0
}
},
{
"languages": {
"nodes": [
{
"name": "TypeScript"
},
{
"name": "SCSS"
},
{
"name": "JavaScript"
}
]
},
"stargazers": {
"totalCount": 0
}
},
{
"languages": {
"nodes": [
{
"name": "TypeScript"
},
{
"name": "JavaScript"
},
{
"name": "SCSS"
}
]
},
"stargazers": {
"totalCount": 0
}
},
{
"languages": {
"nodes": [
{
"name": "JavaScript"
},
{
"name": "HTML"
},
{
"name": "CSS"
}
]
},
"stargazers": {
"totalCount": 0
}
},
{
"languages": {
"nodes": [
{
"name": "JavaScript"
},
{
"name": "HTML"
},
{
"name": "CSS"
}
]
},
"stargazers": {
"totalCount": 0
}
},
{
"languages": {
"nodes": [
{
"name": "JavaScript"
},
{
"name": "HTML"
},
{
"name": "SCSS"
}
]
},
"stargazers": {
"totalCount": 0
}
},
{
"languages": {
"nodes": [
{
"name": "Swift"
}
]
},
"stargazers": {
"totalCount": 0
}
},
{
"languages": {
"nodes": [
{
"name": "TypeScript"
},
{
"name": "HTML"
},
{
"name": "CSS"
}
]
},
"stargazers": {
"totalCount": 0
}
},
{
"languages": {
"nodes": [
{
"name": "TypeScript"
},
{
"name": "HTML"
},
{
"name": "CSS"
}
]
},
"stargazers": {
"totalCount": 0
}
}
]
}
}
}
}
}
================================================
FILE: src/Services/__tests__/githubApiService.test.ts
================================================
import { GithubApiService } from "../GithubApiService.ts";
import { assertEquals, returnsNext, soxa, stub } from "../../../deps.ts";
import { GitHubUserRepository } from "../../user_info.ts";
const rateLimitMock = await import("../__mocks__/rateLimitMock.json", {
with: { type: "json" },
});
const successGithubResponseMock = await import(
"../__mocks__/successGithubResponse.json",
{ with: { type: "json" } }
);
const notFoundGithubResponseMock = await import(
"../__mocks__/notFoundUserMock.json",
{ with: { type: "json" } }
);
import { ServiceError } from "../../Types/index.ts";
// Unfortunatelly, The spy is a global instance
// We can't reset mock as Jest does.
stub(
soxa,
"post",
returnsNext([
// Should get data in first try
new Promise((resolve) => {
resolve(successGithubResponseMock.default);
}),
// Should throw NOT FOUND (requestUserInfo makes 1 combined API call)
// Each call makes 2 attempts (one per token), so 2 promises total
new Promise((resolve) => {
resolve(notFoundGithubResponseMock.default);
}),
new Promise((resolve) => {
resolve(notFoundGithubResponseMock.default);
}),
// Should throw NOT FOUND even if request the user only
new Promise((resolve) => {
resolve(notFoundGithubResponseMock.default);
}),
new Promise((resolve) => {
resolve(notFoundGithubResponseMock.default);
}),
// Should throw RATE LIMIT
new Promise((resolve) => {
resolve(rateLimitMock.default.rate_limit);
}),
new Promise((resolve) => {
resolve(rateLimitMock.default.rate_limit);
}),
// Should throw RATE LIMIT Exceed
new Promise((resolve) => {
resolve(rateLimitMock.default.rate_limit);
}),
new Promise((resolve) => {
resolve(rateLimitMock.default.exceeded);
}),
]),
);
Deno.test("Should get data in first try", async () => {
const provider = new GithubApiService();
const data = await provider.requestUserRepository(
"test",
) as GitHubUserRepository;
assertEquals(data.repositories.totalCount, 128);
});
//Deno.test("Should get data in second Retry", async () => {
// const provider = new GithubApiService();
//
// const data = await provider.requestUserRepository(
// "test",
// ) as GitHubUserRepository;
//
// assertEquals(data.repositories.totalCount, 128);
//});
Deno.test("Should throw NOT FOUND", async () => {
const provider = new GithubApiService();
let error = null;
try {
error = await provider.requestUserInfo("test");
} catch (e) {
error = e;
}
assertEquals(error.code, 404);
assertEquals(error instanceof ServiceError, true);
});
Deno.test("Should throw NOT FOUND even if request the user only", async () => {
const provider = new GithubApiService();
let error = null;
try {
error = await provider.requestUserRepository("test");
} catch (e) {
error = e;
}
assertEquals(error.code, 404);
assertEquals(error instanceof ServiceError, true);
});
// The assertRejects() assertion is a little more complicated
// mainly because it deals with Promises.
// https://docs.deno.com/runtime/manual/basics/testing/assertions#throws
Deno.test("Should throw RATE LIMIT", async () => {
const provider = new GithubApiService();
let error = null;
try {
error = await provider.requestUserRepository("test");
} catch (e) {
error = e;
}
assertEquals(error.code, 419);
assertEquals(error instanceof ServiceError, true);
});
Deno.test("Should throw RATE LIMIT Exceed", async () => {
const provider = new GithubApiService();
let error = null;
try {
error = await provider.requestUserRepository("test");
} catch (e) {
error = e;
}
assertEquals(error.code, 419);
assertEquals(error instanceof ServiceError, true);
});
================================================
FILE: src/Services/request.ts
================================================
import { soxa } from "../../deps.ts";
import {
EServiceKindError,
GithubError,
GithubErrorResponse,
GithubExceedError,
QueryDefaultResponse,
ServiceError,
} from "../Types/index.ts";
export async function requestGithubData(
query: string,
variables: { [key: string]: string },
token = "",
) {
const response = await soxa.post("", {}, {
data: { query, variables },
headers: {
Authorization: `bearer ${token}`,
},
}) as QueryDefaultResponse<{ user: T }>;
const responseData = response.data;
if (responseData?.data?.user) {
return responseData.data.user;
}
throw handleError(responseData);
}
function handleError(
responseData: {
data?: unknown;
errors?: GithubError[];
message?: string;
documentation_url?: string;
},
): ServiceError {
let isRateLimitExceeded = false;
const arrayErrors = responseData?.errors || [];
if (Array.isArray(arrayErrors) && arrayErrors.length > 0) {
isRateLimitExceeded = arrayErrors.some((error) =>
error.type.includes(EServiceKindError.RATE_LIMIT)
);
}
if (responseData?.message) {
isRateLimitExceeded = responseData.message.toLowerCase().includes(
"rate limit",
);
}
if (isRateLimitExceeded) {
throw new ServiceError(
"Rate limit exceeded",
EServiceKindError.RATE_LIMIT,
);
}
throw new ServiceError(
"unknown error",
EServiceKindError.NOT_FOUND,
);
}
================================================
FILE: src/StaticRenderRegeneration/cache_manager.ts
================================================
import { Logger } from "../Helpers/Logger.ts";
import { existsSync } from "./utils.ts";
export class CacheManager {
constructor(private revalidateTime: number, private cacheFile: string) {}
// Reason to use /tmp/:
// https://github.com/orgs/vercel/discussions/314
get cacheFilePath(): string {
return `/tmp/${this.cacheFile}`;
}
get cacheFileExists(): boolean {
return existsSync(this.cacheFilePath);
}
get cacheFileLastModified(): Date | null {
if (!this.cacheFileExists) {
return null;
}
const fileInfo = Deno.statSync(this.cacheFilePath);
return fileInfo.mtime ?? null;
}
get cacheFileLastModifiedGetTime(): number | null {
const lastModified = this.cacheFileLastModified;
if (lastModified === null) {
return null;
}
return lastModified.getTime();
}
get isCacheValid(): boolean {
if (this.cacheFileLastModifiedGetTime === null) {
return false;
}
const currentTime = new Date().getTime();
return currentTime - this.cacheFileLastModifiedGetTime <
this.revalidateTime;
}
async save(response: Response): Promise {
if (response === null) return;
// Prevent TypeError: ReadableStream is locked
const text = await response.clone().text();
const data = new TextEncoder().encode(text);
Deno.writeFile(this.cacheFilePath, data, { create: true }).catch(() => {
Logger.warn("Failed to save cache file");
});
}
}
================================================
FILE: src/StaticRenderRegeneration/index.ts
================================================
import { CacheManager } from "./cache_manager.ts";
import { StaticRegenerationOptions } from "./types.ts";
import { getUrl, hashString, readCache } from "./utils.ts";
export async function staticRenderRegeneration(
request: Request,
options: StaticRegenerationOptions,
render: (request: Request) => Promise,
) {
// avoid TypeError: Invalid URL at deno:core
const url = getUrl(request);
// if more conditions are added, make sure to create a variable to skipCache
if (url.pathname === "/favicon.ico") {
return await render(request);
}
const cacheFile = await hashString(url.pathname + (url.search ?? ""));
const cacheManager = new CacheManager(options.revalidate ?? 0, cacheFile);
if (cacheManager.isCacheValid) {
const cache = readCache(cacheManager.cacheFilePath);
if (cache !== null) {
return new Response(cache, {
headers: options.headers ?? new Headers({}),
});
}
}
const response = await render(request);
if (response.status >= 200 && response.status < 300) {
void cacheManager.save(response);
}
return response;
}
================================================
FILE: src/StaticRenderRegeneration/types.ts
================================================
export interface StaticRegenerationOptions {
// The number of milliseconds before the page should be revalidated
revalidate?: number;
// The headers to be sent with the response
headers?: Headers;
}
================================================
FILE: src/StaticRenderRegeneration/utils.ts
================================================
export function getUrl(request: Request) {
try {
return new URL(request.url);
} catch {
return {
pathname: request.url,
search: request.url,
};
}
}
export function readCache(cacheFilePath: string): Uint8Array | null {
try {
return Deno.readFileSync(cacheFilePath);
} catch {
return null;
}
}
// https://developer.mozilla.org/en-US/docs/Web/API/SubtleCrypto/digest
export async function hashString(message: string): Promise {
const encoder = new TextEncoder();
const data = encoder.encode(message);
const hashBuffer = await crypto.subtle.digest("SHA-256", data);
const hashArray = Array.from(new Uint8Array(hashBuffer));
const hashHex = hashArray.map((b) => b.toString(16).padStart(2, "0")).join(
"",
);
return hashHex;
}
export const existsSync = (filename: string): boolean => {
try {
Deno.statSync(filename);
// successful, file or directory must exist
return true;
} catch {
return false;
}
};
================================================
FILE: src/Types/EServiceKindError.ts
================================================
export const enum EServiceKindError {
RATE_LIMIT = "RATE_LIMITED",
NOT_FOUND = "NOT_FOUND",
}
================================================
FILE: src/Types/Request.ts
================================================
export type GithubError = {
message: string;
type: string;
};
export type GithubErrorResponse = {
errors: GithubError[];
};
export type GithubExceedError = {
documentation_url: string;
message: string;
};
export type QueryDefaultResponse = {
data: {
data: T;
errors?: GithubError[];
message?: string;
documentation_url?: string;
};
};
================================================
FILE: src/Types/ServiceError.ts
================================================
import { EServiceKindError } from "./EServiceKindError.ts";
export class ServiceError extends Error {
constructor(message: string, kind: EServiceKindError) {
super(message);
this.message = message;
this.name = "ServiceError";
this.cause = kind;
}
get code(): number {
switch (this.cause) {
case EServiceKindError.RATE_LIMIT:
return 419;
case EServiceKindError.NOT_FOUND:
return 404;
default:
return 400;
}
}
}
================================================
FILE: src/Types/index.ts
================================================
export * from "./Request.ts";
export * from "./ServiceError.ts";
export * from "./EServiceKindError.ts";
================================================
FILE: src/card.ts
================================================
import { UserInfo } from "./user_info.ts";
import { TrophyList } from "./trophy_list.ts";
import { Trophy } from "./trophy.ts";
import { Theme } from "./theme.ts";
export class Card {
private width = 0;
private height = 0;
constructor(
private titles: Array,
private ranks: Array,
private maxColumn: number,
private maxRow: number,
private panelSize: number,
private marginWidth: number,
private marginHeight: number,
private noBackground: boolean,
private noFrame: boolean,
) {
this.width = panelSize * this.maxColumn +
this.marginWidth * (this.maxColumn - 1);
}
render(
userInfo: UserInfo,
theme: Theme,
): string {
const trophyList = new TrophyList(userInfo);
trophyList.filterByHidden();
if (this.titles.length != 0) {
const includeTitles = this.titles.filter((title) =>
!title.startsWith("-")
);
if (includeTitles.length > 0) {
trophyList.filterByTitles(includeTitles);
}
trophyList.filterByExclusionTitles(this.titles);
}
if (this.ranks.length != 0) {
trophyList.filterByRanks(this.ranks);
}
trophyList.sortByRank();
if (this.maxColumn == -1) {
this.maxColumn = trophyList.length;
this.width = this.panelSize * this.maxColumn +
this.marginWidth * (this.maxColumn - 1);
}
const row = this.getRow(trophyList);
this.height = this.getHeight(row);
return `
${this.renderTrophy(trophyList, theme)}
`;
}
private getRow(trophyList: TrophyList) {
let row = Math.floor((trophyList.length - 1) / this.maxColumn) + 1;
if (row > this.maxRow) {
row = this.maxRow;
}
return row;
}
private getHeight(row: number) {
// Calculate the height of the card from turns
return this.panelSize * row + this.marginHeight * (row - 1);
}
private renderTrophy(trophyList: TrophyList, theme: Theme) {
return trophyList.getArray.reduce(
(sum: string, trophy: Trophy, i: number) => {
const currentColumn = i % this.maxColumn;
const currentRow = Math.floor(i / this.maxColumn);
const x = this.panelSize * currentColumn +
this.marginWidth * currentColumn;
const y = this.panelSize * currentRow + this.marginHeight * currentRow;
return sum +
trophy.render(
theme,
x,
y,
this.panelSize,
this.noBackground,
this.noFrame,
);
},
"",
);
}
}
================================================
FILE: src/config/cache.ts
================================================
import { Bulk, connect, Redis } from "../../deps.ts";
import { Logger } from "../Helpers/Logger.ts";
import { CONSTANTS } from "../utils.ts";
const enableCache: boolean = Deno.env.get("ENABLE_REDIS") === "true";
// https://developer.redis.com/develop/deno/
class CacheProvider {
private static instance: CacheProvider;
public client: Redis | null = null;
private constructor() {}
static getInstance(): CacheProvider {
if (!CacheProvider.instance) {
CacheProvider.instance = new CacheProvider();
}
return CacheProvider.instance;
}
async connect(): Promise {
if (!enableCache) return;
this.client = await connect({
hostname: Deno.env.get("REDIS_HOST") || "",
port: Number(Deno.env.get("REDIS_PORT")) || 6379,
username: Deno.env.get("REDIS_USERNAME") || undefined,
password: Deno.env.get("REDIS_PASSWORD") || undefined,
});
}
async get(key: string): Promise {
if (!enableCache) return undefined;
try {
if (!this.client) {
await this.connect();
}
return await this.client?.get(key);
} catch {
return undefined;
}
}
async set(key: string, value: string): Promise {
if (!enableCache) return;
try {
if (!this.client) {
await this.connect();
}
await this.client?.set(key, value, {
px: CONSTANTS.REDIS_TTL,
});
} catch (e) {
Logger.error(`Failed to set cache: ${e.message}`);
}
}
async del(key: string): Promise {
if (!enableCache) return;
try {
if (!this.client) {
await this.connect();
}
await this.client?.del(key);
} catch (e) {
Logger.error(`Failed to delete cache: ${e.message}`);
}
}
}
export const cacheProvider = CacheProvider.getInstance();
================================================
FILE: src/error_page.ts
================================================
abstract class BaseError {
readonly status!: number;
readonly message!: string;
constructor(readonly content?: string) {}
render() {
return this.renderPage();
}
private renderPage() {
return `
GitHub Profile Trophy
${this.status} - ${this.message}
${this.content ?? ""}
${
this.content &&
'Go back '
}
`;
}
}
export class Error400 extends BaseError {
readonly status = 400;
readonly message = "Bad Request";
}
export class Error419 extends BaseError {
readonly status = 419;
readonly message = "Rate Limit Exceeded";
}
export class Error404 extends BaseError {
readonly status = 404;
readonly message = "Not Found";
}
================================================
FILE: src/icons.ts
================================================
import { RANK } from "./utils.ts";
import { Theme } from "./theme.ts";
const leafIcon = (laurel: string): string => {
return `
Created by potrace 1.15, written by Peter Selinger 2001-2017
`;
};
export const getNextRankBar = (
title: string,
percentage: number,
color: string,
): string => {
const maxWidth = 80;
return `
`;
};
const getSmallTrophyIcon = (
icon: string,
color: string,
count: number,
): string => {
const leftXPosition = 7;
const rightXPosition = 68;
const getIcon = (x: number) => {
return `
${icon}
`;
};
if (count == 1) {
// Double Rank
return getIcon(rightXPosition);
} else if (count == 2) {
// Triple Rank
return `${getIcon(leftXPosition)}${getIcon(rightXPosition)}`;
}
// Single Rank
return "";
};
export const getTrophyIcon = (theme: Theme, rank = RANK.UNKNOWN) => {
let color = theme.DEFAULT_RANK_BASE;
let rankColor = theme.DEFAULT_RANK_TEXT;
let backgroundIcon = "";
let gradationColor = `
`;
const { ICON_CIRCLE } = theme;
if (rank === RANK.SECRET) {
rankColor = theme.SECRET_RANK_TEXT;
gradationColor = `
`;
} else if (rank.slice(0, 1) === RANK.S) {
color = theme.S_RANK_BASE;
rankColor = theme.S_RANK_TEXT;
backgroundIcon = leafIcon(theme.LAUREL);
gradationColor = `
`;
} else if (rank.slice(0, 1) === RANK.A) {
color = theme.A_RANK_BASE;
rankColor = theme.A_RANK_TEXT;
backgroundIcon = leafIcon(theme.LAUREL);
gradationColor = `
`;
} else if (rank === RANK.B) {
color = theme.B_RANK_BASE;
rankColor = theme.B_RANK_TEXT;
gradationColor = `
`;
}
const icon = `
${
rank.slice(0, 1)
}
`;
const optionRankIcon = getSmallTrophyIcon(icon, color, rank.length - 1);
return `
${backgroundIcon}
${optionRankIcon}
${gradationColor}
${icon}
`;
};
================================================
FILE: src/pages/Error.ts
================================================
import { EServiceKindError, ServiceError } from "../Types/index.ts";
import { Error400, Error404, Error419 } from "../error_page.ts";
interface ErrorPageProps {
error: ServiceError;
}
export function ErrorPage({ error }: ErrorPageProps) {
let cause: Error400 | Error404 | Error419 = new Error400();
if (error.cause === EServiceKindError.RATE_LIMIT) {
cause = new Error419();
}
if (error.cause === EServiceKindError.NOT_FOUND) {
cause = new Error404(
"Sorry, the user you are looking for was not found.",
);
}
return cause;
}
================================================
FILE: src/theme.ts
================================================
export const COLORS: { [name: string]: Theme } = {
default: {
BACKGROUND: "#FFF",
TITLE: "#000",
ICON_CIRCLE: "#FFF",
TEXT: "#666",
LAUREL: "#009366",
SECRET_RANK_1: "red",
SECRET_RANK_2: "fuchsia",
SECRET_RANK_3: "blue",
SECRET_RANK_TEXT: "fuchsia",
NEXT_RANK_BAR: "#0366d6",
S_RANK_BASE: "#FAD200",
S_RANK_SHADOW: "#C8A090",
S_RANK_TEXT: "#886000",
A_RANK_BASE: "#B0B0B0",
A_RANK_SHADOW: "#9090C0",
A_RANK_TEXT: "#505050",
B_RANK_BASE: "#A18D66",
B_RANK_SHADOW: "#816D96",
B_RANK_TEXT: "#412D06",
DEFAULT_RANK_BASE: "#777",
DEFAULT_RANK_SHADOW: "#333",
DEFAULT_RANK_TEXT: "#333",
},
dracula: {
BACKGROUND: "#282a36",
TITLE: "#ff79c6",
ICON_CIRCLE: "#f8f8f2",
TEXT: "#f8f8f2",
LAUREL: "#50fa7b",
SECRET_RANK_1: "#ff5555",
SECRET_RANK_2: "#ff79c6",
SECRET_RANK_3: "#bd93f9",
SECRET_RANK_TEXT: "#bd93f9",
NEXT_RANK_BAR: "#ff79c6",
S_RANK_BASE: "#ffb86c",
S_RANK_SHADOW: "#ffb86c",
S_RANK_TEXT: "#6272a4",
A_RANK_BASE: "#8be9fd",
A_RANK_SHADOW: "#8be9fd",
A_RANK_TEXT: "#6272a4",
B_RANK_BASE: "#ff5555",
B_RANK_SHADOW: "#ff5555",
B_RANK_TEXT: "#6272a4",
DEFAULT_RANK_BASE: "#6272a4",
DEFAULT_RANK_SHADOW: "#6272a4",
DEFAULT_RANK_TEXT: "#6272a4",
},
flat: {
BACKGROUND: "#FFF",
TITLE: "#000",
ICON_CIRCLE: "#FFF",
TEXT: "#666",
LAUREL: "#009366",
SECRET_RANK_1: "red",
SECRET_RANK_2: "fuchsia",
SECRET_RANK_3: "blue",
SECRET_RANK_TEXT: "fuchsia",
NEXT_RANK_BAR: "#0366d6",
S_RANK_BASE: "#eac200",
S_RANK_SHADOW: "#eac200",
S_RANK_TEXT: "#886000",
A_RANK_BASE: "#B0B0B0",
A_RANK_SHADOW: "#B0B0B0",
A_RANK_TEXT: "#505050",
B_RANK_BASE: "#A18D66",
B_RANK_SHADOW: "#A18D66",
B_RANK_TEXT: "#412D06",
DEFAULT_RANK_BASE: "#777",
DEFAULT_RANK_SHADOW: "#777",
DEFAULT_RANK_TEXT: "#333",
},
onedark: {
BACKGROUND: "#282c34",
TITLE: "#e5c07b",
ICON_CIRCLE: "#FFF",
TEXT: "#e06c75",
LAUREL: "#98c379",
SECRET_RANK_1: "#e06c75",
SECRET_RANK_2: "#c678dd",
SECRET_RANK_3: "#61afef",
SECRET_RANK_TEXT: "#c678dd",
NEXT_RANK_BAR: "#e5c07b",
S_RANK_BASE: "#e5c07b",
S_RANK_SHADOW: "#e5c07b",
S_RANK_TEXT: "#282c34",
A_RANK_BASE: "#56b6c2",
A_RANK_SHADOW: "#56b6c2",
A_RANK_TEXT: "#282c34",
B_RANK_BASE: "#c678dd",
B_RANK_SHADOW: "#c678dd",
B_RANK_TEXT: "#282c34",
DEFAULT_RANK_BASE: "#abb2bf",
DEFAULT_RANK_SHADOW: "#abb2bf",
DEFAULT_RANK_TEXT: "#282c34",
},
gruvbox: {
BACKGROUND: "#282828",
TITLE: "#ebdbb2",
ICON_CIRCLE: "#ebdbb2",
TEXT: "#98971a",
LAUREL: "#689d6a",
SECRET_RANK_1: "#fb4934",
SECRET_RANK_2: "#d3869b",
SECRET_RANK_3: "#458588",
SECRET_RANK_TEXT: "#b16286",
NEXT_RANK_BAR: "#fabd26",
S_RANK_BASE: "#fabd2f",
S_RANK_SHADOW: "#fabd2f",
S_RANK_TEXT: "#322301",
A_RANK_BASE: "#83a598",
A_RANK_SHADOW: "#83a598",
A_RANK_TEXT: "#151e1a",
B_RANK_BASE: "#d65d0e",
B_RANK_SHADOW: "#d65d0e",
B_RANK_TEXT: "#301503",
DEFAULT_RANK_BASE: "#928374",
DEFAULT_RANK_SHADOW: "#928374",
DEFAULT_RANK_TEXT: "#282828",
},
monokai: {
BACKGROUND: "#272822",
TITLE: "#f92672",
ICON_CIRCLE: "#fff",
TEXT: "#fff",
LAUREL: "#a6e22e",
SECRET_RANK_1: "#f92672",
SECRET_RANK_2: "#ae81ff",
SECRET_RANK_3: "#66d9ef",
SECRET_RANK_TEXT: "#b16286",
NEXT_RANK_BAR: "#f92672",
S_RANK_BASE: "#e6db74",
S_RANK_SHADOW: "#e6db74",
S_RANK_TEXT: "#272822",
A_RANK_BASE: "#66d9ef",
A_RANK_SHADOW: "#66d9ef",
A_RANK_TEXT: "#272822",
B_RANK_BASE: "#fd971f",
B_RANK_SHADOW: "#fd971f",
B_RANK_TEXT: "#272822",
DEFAULT_RANK_BASE: "#75715e",
DEFAULT_RANK_SHADOW: "#75715e",
DEFAULT_RANK_TEXT: "#282828",
},
nord: {
BACKGROUND: "#2E3440",
TITLE: "#81A1C1",
ICON_CIRCLE: "#D8DEE9",
TEXT: "#ECEFF4",
LAUREL: "#A3BE8C",
SECRET_RANK_1: "#BF616A",
SECRET_RANK_2: "#B48EAD",
SECRET_RANK_3: "#81A1C1",
SECRET_RANK_TEXT: "#B48EAD",
NEXT_RANK_BAR: "#81A1C1",
S_RANK_BASE: "#EBCB8B",
S_RANK_SHADOW: "#EBCB8B",
S_RANK_TEXT: "#3B4252",
A_RANK_BASE: "#8FBCBB",
A_RANK_SHADOW: "#8FBCBB",
A_RANK_TEXT: "#3B4252",
B_RANK_BASE: "#D08770",
B_RANK_SHADOW: "#D08770",
B_RANK_TEXT: "#3B4252",
DEFAULT_RANK_BASE: "#5E81AC",
DEFAULT_RANK_SHADOW: "#5E81AC",
DEFAULT_RANK_TEXT: "#3B4252",
},
discord: {
BACKGROUND: "#23272A",
TITLE: "#7289DA",
ICON_CIRCLE: "#FFFFFF",
TEXT: "#FFFFFF",
LAUREL: "#57F287",
SECRET_RANK_1: "#ED4245",
SECRET_RANK_2: "#57F287",
SECRET_RANK_3: "#5865F2",
SECRET_RANK_TEXT: "#000000",
NEXT_RANK_BAR: "#5865F2",
S_RANK_BASE: "#FEE75C",
S_RANK_SHADOW: "#FEE75C",
S_RANK_TEXT: "#000000",
A_RANK_BASE: "#EB459E",
A_RANK_SHADOW: "#ED4245",
A_RANK_TEXT: "#000000",
B_RANK_BASE: "#ED4245",
B_RANK_SHADOW: "#ED4245",
B_RANK_TEXT: "#000000",
DEFAULT_RANK_BASE: "#5865F2",
DEFAULT_RANK_SHADOW: "#5865F2",
DEFAULT_RANK_TEXT: "#000000",
},
chalk: {
BACKGROUND: "#2d2d2d",
TITLE: "#fed37e",
ICON_CIRCLE: "#e4e4e4",
TEXT: "#d4d4d4",
LAUREL: "#a9d3ab",
SECRET_RANK_1: "#f58e8e",
SECRET_RANK_2: "#d6add5",
SECRET_RANK_3: "#66d9ef",
SECRET_RANK_TEXT: "#f58e8e",
NEXT_RANK_BAR: "#7aabd4",
S_RANK_BASE: "#fed37e",
S_RANK_SHADOW: "#fed37e",
S_RANK_TEXT: "#2d2d2d",
A_RANK_BASE: "#79D4D5",
A_RANK_SHADOW: "#79D4D5",
A_RANK_TEXT: "#2d2d2d",
B_RANK_BASE: "#f58e8e",
B_RANK_SHADOW: "#f58e8e",
B_RANK_TEXT: "#2d2d2d",
DEFAULT_RANK_BASE: "#75715e",
DEFAULT_RANK_SHADOW: "#75715e",
DEFAULT_RANK_TEXT: "#2d2d2d",
},
alduin: {
BACKGROUND: "#1c1c1c",
TITLE: "#dfd7af",
ICON_CIRCLE: "#e3e3e3",
TEXT: "#dfd7af",
LAUREL: "#a9d3ab",
SECRET_RANK_1: "#f58e8e",
SECRET_RANK_2: "#d6add5",
SECRET_RANK_3: "#66d9ef",
SECRET_RANK_TEXT: "#f58e8e",
NEXT_RANK_BAR: "#dfd7af",
S_RANK_BASE: "#fed37e",
S_RANK_SHADOW: "#fed37e",
S_RANK_TEXT: "#2d2d2d",
A_RANK_BASE: "#79D4D5",
A_RANK_SHADOW: "#79D4D5",
A_RANK_TEXT: "#2d2d2d",
B_RANK_BASE: "#f58e8e",
B_RANK_SHADOW: "#f58e8e",
B_RANK_TEXT: "#2d2d2d",
DEFAULT_RANK_BASE: "#75715e",
DEFAULT_RANK_SHADOW: "#75715e",
DEFAULT_RANK_TEXT: "#2d2d2d",
},
darkhub: {
BACKGROUND: "#0d1117",
TITLE: "#c9d1d9",
ICON_CIRCLE: "#f0f6fb",
TEXT: "#8b949e",
LAUREL: "#178600",
SECRET_RANK_1: "#ff5555",
SECRET_RANK_2: "#ff79c6",
SECRET_RANK_3: "#388bfd",
SECRET_RANK_TEXT: "#ff79c6",
NEXT_RANK_BAR: "#ff79c6",
S_RANK_BASE: "#ffb86c",
S_RANK_SHADOW: "#ffb86c",
S_RANK_TEXT: "#0d1117",
A_RANK_BASE: "#8be9fd",
A_RANK_SHADOW: "#8be9fd",
A_RANK_TEXT: "#0d1117",
B_RANK_BASE: "#ff5555",
B_RANK_SHADOW: "#ff5555",
B_RANK_TEXT: "#0d1117",
DEFAULT_RANK_BASE: "#6272a4",
DEFAULT_RANK_SHADOW: "#6272a4",
DEFAULT_RANK_TEXT: "#0d1117",
},
juicyfresh: {
BACKGROUND: "#0d0c15",
TITLE: "#f7d745",
ICON_CIRCLE: "#FFF",
TEXT: "#b2d76c",
LAUREL: "#8bb071",
SECRET_RANK_1: "#a8d937",
SECRET_RANK_2: "#f7e662",
SECRET_RANK_3: "#4d9b1c",
SECRET_RANK_TEXT: "#ff5700",
NEXT_RANK_BAR: "#6562af",
S_RANK_BASE: "#f7d644",
S_RANK_SHADOW: "#f69e44",
S_RANK_TEXT: "#ff5700",
A_RANK_BASE: "#f69e44",
A_RANK_SHADOW: "#f46d5a",
A_RANK_TEXT: "#ff5700",
B_RANK_BASE: "#f46d5a",
B_RANK_SHADOW: "#f73155",
B_RANK_TEXT: "#ff5700",
DEFAULT_RANK_BASE: "#f0d7d6",
DEFAULT_RANK_SHADOW: "#f58867",
DEFAULT_RANK_TEXT: "#ff5700",
},
oldie: {
BACKGROUND: "#F0F0F0",
TITLE: "#111",
ICON_CIRCLE: "#FFF",
TEXT: "#666",
LAUREL: "#535353",
SECRET_RANK_1: "#738986",
SECRET_RANK_2: "#B36154",
SECRET_RANK_3: "#91A16A",
SECRET_RANK_TEXT: "#4D4D4D",
NEXT_RANK_BAR: "#8E8680",
S_RANK_BASE: "#8E8E8E",
S_RANK_SHADOW: "#8E8E8E",
S_RANK_TEXT: "#4D4D4D",
A_RANK_BASE: "#AFAFAF",
A_RANK_SHADOW: "#AFAFAF",
A_RANK_TEXT: "#4D4D4D",
B_RANK_BASE: "#858585",
B_RANK_SHADOW: "#858585",
B_RANK_TEXT: "#4D4D4D",
DEFAULT_RANK_BASE: "#535353",
DEFAULT_RANK_SHADOW: "#535353",
DEFAULT_RANK_TEXT: "#4D4D4D",
},
buddhism: {
BACKGROUND: "#ffc20e",
TITLE: "#FFF",
ICON_CIRCLE: "#FFF",
TEXT: "#FFF",
LAUREL: "#27c5ff",
SECRET_RANK_1: "#FFF",
SECRET_RANK_2: "#f73155",
SECRET_RANK_3: "#fff",
SECRET_RANK_TEXT: "#f73155",
NEXT_RANK_BAR: "#f73155",
S_RANK_BASE: "#ff8400",
S_RANK_SHADOW: "#ff8400",
S_RANK_TEXT: "#ffc20e",
A_RANK_BASE: "#fff",
A_RANK_SHADOW: "#fff",
A_RANK_TEXT: "#ffc20e",
B_RANK_BASE: "#f73155",
B_RANK_SHADOW: "#f73155",
B_RANK_TEXT: "#ffc20e",
DEFAULT_RANK_BASE: "#27c5ff",
DEFAULT_RANK_SHADOW: "#27c5ff",
DEFAULT_RANK_TEXT: "#ffc20e",
},
radical: {
BACKGROUND: "#141321",
ICON_CIRCLE: "#EEEEEE",
TITLE: "#fe428e",
TEXT: "#a9fef7",
LAUREL: "#50fa7b",
SECRET_RANK_1: "#ff5555",
SECRET_RANK_2: "#ff15d9",
SECRET_RANK_3: "#1E65F5",
SECRET_RANK_TEXT: "#ff61c6",
NEXT_RANK_BAR: "#fe428e",
S_RANK_BASE: "#ffce32",
S_RANK_SHADOW: "#ffce32",
S_RANK_TEXT: "#CB8A30",
A_RANK_BASE: "#8DF7B5",
A_RANK_SHADOW: "#8DF7B5",
A_RANK_TEXT: "#3A3A3A",
B_RANK_BASE: "#EA3F25",
B_RANK_SHADOW: "#EA3F25",
B_RANK_TEXT: "#3A3A3A",
DEFAULT_RANK_BASE: "#1E65F5",
DEFAULT_RANK_SHADOW: "#1E65F5",
DEFAULT_RANK_TEXT: "#3A3A3A",
},
onestar: {
BACKGROUND: "#0d1117",
ICON_CIRCLE: "#EEEEEE",
TITLE: "#EEEEEE",
TEXT: "#c7c7c7",
LAUREL: "#0dbc79",
SECRET_RANK_1: "#ff5555",
SECRET_RANK_2: "#d861d8",
SECRET_RANK_3: "#3b8eea",
SECRET_RANK_TEXT: "#ff61c6",
NEXT_RANK_BAR: "#9e9e9e",
S_RANK_BASE: "#FFD54F",
S_RANK_SHADOW: "#FFE082",
S_RANK_TEXT: "#CB8A30",
A_RANK_BASE: "#23d18b",
A_RANK_SHADOW: "#8DF7B5",
A_RANK_TEXT: "#3A3A3A",
B_RANK_BASE: "#d13b3b",
B_RANK_SHADOW: "#fa4b4b",
B_RANK_TEXT: "#3A3A3A",
DEFAULT_RANK_BASE: "#2472c8",
DEFAULT_RANK_SHADOW: "#3b8eea",
DEFAULT_RANK_TEXT: "#3A3A3A",
},
algolia: {
BACKGROUND: "#050f2c",
TITLE: "#00aeff",
ICON_CIRCLE: "#f0f6fb",
TEXT: "#7eace9",
LAUREL: "#178600",
SECRET_RANK_1: "#ff5555",
SECRET_RANK_2: "#ff79c6",
SECRET_RANK_3: "#388bfd",
SECRET_RANK_TEXT: "#ff79c6",
NEXT_RANK_BAR: "#00aeff",
S_RANK_BASE: "#ffb86c",
S_RANK_SHADOW: "#ffb86c",
S_RANK_TEXT: "#0d1117",
A_RANK_BASE: "#2dde98",
A_RANK_TEXT: "#0d1117",
A_RANK_SHADOW: "#2dde98",
B_RANK_BASE: "#8be9fd",
B_RANK_SHADOW: "#8be9fd",
B_RANK_TEXT: "#0d1117",
DEFAULT_RANK_BASE: "#5c75c3",
DEFAULT_RANK_SHADOW: "#6272a4",
DEFAULT_RANK_TEXT: "#0d1117",
},
gitdimmed: {
BACKGROUND: "#333",
TITLE: "#f0f6fb",
ICON_CIRCLE: "#f0f6fb",
TEXT: "#FFF",
LAUREL: "#178600",
SECRET_RANK_1: "#ff5555",
SECRET_RANK_2: "#ff79c6",
SECRET_RANK_3: "#388bfd",
SECRET_RANK_TEXT: "#ff79c6",
NEXT_RANK_BAR: "#00aeff",
S_RANK_BASE: "#ffb86c",
S_RANK_SHADOW: "#ffb86c",
S_RANK_TEXT: "#0d1117",
A_RANK_BASE: "#2dde98",
A_RANK_TEXT: "#0d1117",
A_RANK_SHADOW: "#2dde98",
B_RANK_BASE: "#8be9fd",
B_RANK_SHADOW: "#8be9fd",
B_RANK_TEXT: "#0d1117",
DEFAULT_RANK_BASE: "#5c75c3",
DEFAULT_RANK_SHADOW: "#6272a4",
DEFAULT_RANK_TEXT: "#0d1117",
},
tokyonight: {
BACKGROUND: "#1a1b27",
TITLE: "#70a5fd",
ICON_CIRCLE: "#bf91f3",
TEXT: "#38bdae",
LAUREL: "#178600",
SECRET_RANK_1: "#ff5555",
SECRET_RANK_2: "#ff79c6",
SECRET_RANK_3: "#388bfd",
SECRET_RANK_TEXT: "#ff79c6",
NEXT_RANK_BAR: "#00aeff",
S_RANK_BASE: "#ffb86c",
S_RANK_SHADOW: "#ffb86c",
S_RANK_TEXT: "#0d1117",
A_RANK_BASE: "#2dde98",
A_RANK_TEXT: "#0d1117",
A_RANK_SHADOW: "#2dde98",
B_RANK_BASE: "#8be9fd",
B_RANK_SHADOW: "#8be9fd",
B_RANK_TEXT: "#0d1117",
DEFAULT_RANK_BASE: "#5c75c3",
DEFAULT_RANK_SHADOW: "#6272a4",
DEFAULT_RANK_TEXT: "#0d1117",
},
matrix: {
BACKGROUND: "#000000",
TITLE: "#00cc00",
ICON_CIRCLE: "#002200",
TEXT: "#00cc00",
LAUREL: "#178600",
SECRET_RANK_1: "#ffd700",
SECRET_RANK_2: "#ffffff",
SECRET_RANK_3: "#ffd700",
SECRET_RANK_TEXT: "#00ff00",
NEXT_RANK_BAR: "#00ff00",
S_RANK_BASE: "#ffd700",
S_RANK_SHADOW: "#ffd700",
S_RANK_TEXT: "#00ff00",
A_RANK_BASE: "#c0c0c0",
A_RANK_TEXT: "#00ff00",
A_RANK_SHADOW: "#c0c0c0",
B_RANK_BASE: "#b08d57",
B_RANK_SHADOW: "#b08d57",
B_RANK_TEXT: "#00ff00",
DEFAULT_RANK_BASE: "#b08d57",
DEFAULT_RANK_SHADOW: "#b08d57",
DEFAULT_RANK_TEXT: "#00ff00",
},
apprentice: {
BACKGROUND: "#262626",
TITLE: "#BCBCBC",
ICON_CIRCLE: "#BCBCBC",
TEXT: "#5F875F",
LAUREL: "#5F8787",
SECRET_RANK_1: "#FF8700",
SECRET_RANK_2: "#8787AF",
SECRET_RANK_3: "#5F87AF",
SECRET_RANK_TEXT: "#5F5F87",
NEXT_RANK_BAR: "#FFFFA9",
S_RANK_BASE: "#FFFFAF",
S_RANK_SHADOW: "#FFFFAF",
S_RANK_TEXT: "#87875F",
A_RANK_BASE: "#8FAFD7",
A_RANK_SHADOW: "#8FAFD7",
A_RANK_TEXT: "#5F875F",
B_RANK_BASE: "#AF5F5F",
B_RANK_SHADOW: "#AF5F5F",
B_RANK_TEXT: "#AF5F5F",
DEFAULT_RANK_BASE: "#6C6C6C",
DEFAULT_RANK_SHADOW: "#6C6C6C",
DEFAULT_RANK_TEXT: "#1C1C1C",
},
dark_dimmed: {
BACKGROUND: "#22272e",
TITLE: "#adbac7",
ICON_CIRCLE: "#002200",
TEXT: "#adbac7",
LAUREL: "#178600",
SECRET_RANK_1: "red",
SECRET_RANK_2: "fuchsia",
SECRET_RANK_3: "blue",
SECRET_RANK_TEXT: "fuchsia",
NEXT_RANK_BAR: "#0366d6",
S_RANK_BASE: "#FAD200",
S_RANK_SHADOW: "#C8A090",
S_RANK_TEXT: "#886000",
A_RANK_BASE: "#B0B0B0",
A_RANK_SHADOW: "#9090C0",
A_RANK_TEXT: "#505050",
B_RANK_BASE: "#A18D66",
B_RANK_SHADOW: "#816D96",
B_RANK_TEXT: "#412D06",
DEFAULT_RANK_BASE: "#777",
DEFAULT_RANK_SHADOW: "#333",
DEFAULT_RANK_TEXT: "#333",
},
dark_lover: {
BACKGROUND: "#0d0d0d",
TITLE: "#e8aa64",
ICON_CIRCLE: "white",
TEXT: "#e8aa64",
LAUREL: "#e86464",
SECRET_RANK_1: "#e05555",
SECRET_RANK_2: "#e05555",
SECRET_RANK_3: "#e05555",
SECRET_RANK_TEXT: "#e05555",
NEXT_RANK_BAR: "#e05555",
S_RANK_BASE: "#f2c635",
S_RANK_SHADOW: "#e0d7b8",
S_RANK_TEXT: "#b35707",
A_RANK_BASE: "#f25755",
A_RANK_SHADOW: "#e69493",
A_RANK_TEXT: "#f5352f",
B_RANK_BASE: "#63db93",
B_RANK_SHADOW: "#8cd1a8",
B_RANK_TEXT: "#07b84e",
DEFAULT_RANK_BASE: "#7f6ceb",
DEFAULT_RANK_SHADOW: "#a598ed",
DEFAULT_RANK_TEXT: "#7f6ceb",
},
kimbie_dark: {
BACKGROUND: "#221a0f",
TITLE: "#d3af86",
ICON_CIRCLE: "#7e602c",
TEXT: "#d3af86",
LAUREL: "#889b4a",
SECRET_RANK_1: "#f14a68",
SECRET_RANK_2: "#f14a68",
SECRET_RANK_3: "#dc3958",
SECRET_RANK_TEXT: "#dc3958",
NEXT_RANK_BAR: "#dc3958",
S_RANK_BASE: "#fcac51",
S_RANK_SHADOW: "#f79a32",
S_RANK_TEXT: "#d3af86",
A_RANK_BASE: "#a3B95a",
A_RANK_SHADOW: "#889b4a",
A_RANK_TEXT: "#d3af86",
B_RANK_BASE: "#4c96a8",
B_RANK_SHADOW: "#418292",
B_RANK_TEXT: "#d3af86",
DEFAULT_RANK_BASE: "#8ab1b0",
DEFAULT_RANK_SHADOW: "#719190",
DEFAULT_RANK_TEXT: "#d3af86",
},
aura: {
BACKGROUND: "#1E1D26",
TITLE: "#FFFFFF",
ICON_CIRCLE: "#FFFFFF",
TEXT: "#dbffe6",
LAUREL: "#a9fcca",
SECRET_RANK_1: "#c273ff",
SECRET_RANK_2: "#c273ff",
SECRET_RANK_3: "#c273ff",
SECRET_RANK_TEXT: "#bd93f9",
NEXT_RANK_BAR: "#715df5",
S_RANK_BASE: "#8e57ff",
S_RANK_SHADOW: "#2361ad",
S_RANK_TEXT: "#6272a4",
A_RANK_BASE: "#7c71f5",
A_RANK_SHADOW: "#3ae056",
A_RANK_TEXT: "#6272a4",
B_RANK_BASE: "#226a80",
B_RANK_SHADOW: "#226a80",
B_RANK_TEXT: "#6272a4",
DEFAULT_RANK_BASE: "#5e8c2a",
DEFAULT_RANK_SHADOW: "#5e8c2a",
DEFAULT_RANK_TEXT: "#5e8c2a",
},
};
export interface Theme {
BACKGROUND: string;
TITLE: string;
ICON_CIRCLE: string;
TEXT: string;
LAUREL: string;
SECRET_RANK_1: string;
SECRET_RANK_2: string;
SECRET_RANK_3: string;
SECRET_RANK_TEXT: string;
NEXT_RANK_BAR: string;
S_RANK_BASE: string;
S_RANK_SHADOW: string;
S_RANK_TEXT: string;
A_RANK_BASE: string;
A_RANK_SHADOW: string;
A_RANK_TEXT: string;
B_RANK_BASE: string;
B_RANK_SHADOW: string;
B_RANK_TEXT: string;
DEFAULT_RANK_BASE: string;
DEFAULT_RANK_SHADOW: string;
DEFAULT_RANK_TEXT: string;
}
================================================
FILE: src/trophy.ts
================================================
import { getNextRankBar, getTrophyIcon } from "./icons.ts";
import { abridgeScore, CONSTANTS, RANK, RANK_ORDER } from "./utils.ts";
import { Theme } from "./theme.ts";
class RankCondition {
constructor(
readonly rank: RANK,
readonly message: string,
readonly requiredScore: number,
) {}
}
export class Trophy {
rankCondition: RankCondition | null = null;
rank: RANK = RANK.UNKNOWN;
topMessage = "Unknown";
bottomMessage = "0";
title = "";
filterTitles: Array = [];
hidden = false;
constructor(
private score: number,
private rankConditions: Array,
) {
this.bottomMessage = abridgeScore(score);
this.setRank();
}
setRank() {
const sortedRankConditions = this.rankConditions.toSorted((a, b) =>
RANK_ORDER.indexOf(a.rank) - RANK_ORDER.indexOf(b.rank)
);
// Set the rank that hit the first condition
const rankCondition = sortedRankConditions.find((r) =>
this.score >= r.requiredScore
);
if (rankCondition != null) {
this.rank = rankCondition.rank;
this.rankCondition = rankCondition;
this.topMessage = rankCondition.message;
}
}
private calculateNextRankPercentage() {
if (this.rank === RANK.UNKNOWN) {
return 0;
}
const nextRankIndex = RANK_ORDER.indexOf(this.rank) - 1;
// When got the max rank
if (nextRankIndex < 0 || this.rank === RANK.SSS) {
return 1;
}
const nextRank = RANK_ORDER[nextRankIndex];
const nextRankCondition = this.rankConditions.find((r) =>
r.rank == nextRank
);
const distance = nextRankCondition!.requiredScore -
this.rankCondition!.requiredScore;
const progress = this.score - this.rankCondition!.requiredScore;
const result = progress / distance;
return result;
}
render(
theme: Theme,
x = 0,
y = 0,
panelSize = CONSTANTS.DEFAULT_PANEL_SIZE,
noBackground = CONSTANTS.DEFAULT_NO_BACKGROUND,
noFrame = CONSTANTS.DEFAULT_NO_FRAME,
): string {
const { BACKGROUND: PRIMARY, TITLE: SECONDARY, TEXT, NEXT_RANK_BAR } =
theme;
const nextRankBar = getNextRankBar(
this.title,
this.calculateNextRankPercentage(),
NEXT_RANK_BAR,
);
return `
${getTrophyIcon(theme, this.rank)}
${this.title}
${this.topMessage}
${this.bottomMessage}
${nextRankBar}
`;
}
}
export class MultipleLangTrophy extends Trophy {
constructor(score: number) {
const rankConditions = [
new RankCondition(
RANK.SECRET,
"Rainbow Lang User",
10,
),
];
super(score, rankConditions);
this.title = "MultiLanguage";
this.filterTitles = ["MultipleLang", "MultiLanguage"];
this.hidden = true;
}
}
export class AllSuperRankTrophy extends Trophy {
constructor(score: number) {
const rankConditions = [
new RankCondition(
RANK.SECRET,
"S Rank Hacker",
1,
),
];
super(score, rankConditions);
this.title = "AllSuperRank";
this.filterTitles = ["AllSuperRank"];
this.bottomMessage = "All S Rank";
this.hidden = true;
}
}
export class Joined2020Trophy extends Trophy {
constructor(score: number) {
const rankConditions = [
new RankCondition(
RANK.SECRET,
"Everything started...",
1,
),
];
super(score, rankConditions);
this.title = "Joined2020";
this.filterTitles = ["Joined2020"];
this.bottomMessage = "Joined 2020";
this.hidden = true;
}
}
export class AncientAccountTrophy extends Trophy {
constructor(score: number) {
const rankConditions = [
new RankCondition(
RANK.SECRET,
"Ancient User",
1,
),
];
super(score, rankConditions);
this.title = "AncientUser";
this.filterTitles = ["AncientUser"];
this.bottomMessage = "Before 2010";
this.hidden = true;
}
}
export class LongTimeAccountTrophy extends Trophy {
constructor(score: number) {
const rankConditions = [
new RankCondition(
RANK.SECRET,
"Village Elder",
10,
),
];
super(score, rankConditions);
this.title = "LongTimeUser";
this.filterTitles = ["LongTimeUser"];
this.hidden = true;
}
}
export class MultipleOrganizationsTrophy extends Trophy {
constructor(score: number) {
const rankConditions = [
new RankCondition(
RANK.SECRET,
// or if this doesn't render well: "Factorum"
"Jack of all Trades",
3,
),
];
super(score, rankConditions);
this.title = "Organizations";
this.filterTitles = ["Organizations", "Orgs", "Teams"];
this.hidden = true;
}
}
export class OGAccountTrophy extends Trophy {
constructor(score: number) {
const rankConditions = [
new RankCondition(
RANK.SECRET,
"OG User",
1,
),
];
super(score, rankConditions);
this.title = "OGUser";
this.filterTitles = ["OGUser"];
this.bottomMessage = "Joined 2008";
this.hidden = true;
}
}
export class TotalReviewsTrophy extends Trophy {
constructor(score: number) {
const rankConditions = [
new RankCondition(
RANK.SSS,
"God Reviewer",
70,
),
new RankCondition(
RANK.SS,
"Deep Reviewer",
57,
),
new RankCondition(
RANK.S,
"Super Reviewer",
45,
),
new RankCondition(
RANK.AAA,
"Ultra Reviewer",
30,
),
new RankCondition(
RANK.AA,
"Hyper Reviewer",
20,
),
new RankCondition(
RANK.A,
"Active Reviewer",
8,
),
new RankCondition(
RANK.B,
"Intermediate Reviewer",
3,
),
new RankCondition(
RANK.C,
"New Reviewer",
1,
),
];
super(score, rankConditions);
this.title = "Reviews";
this.filterTitles = ["Review", "Reviews"];
}
}
export class AccountDurationTrophy extends Trophy {
constructor(score: number) {
const rankConditions = [
new RankCondition(
RANK.SSS,
"Seasoned Veteran",
70, // 20 years
),
new RankCondition(
RANK.SS,
"Grandmaster",
55, // 15 years
),
new RankCondition(
RANK.S,
"Master Dev",
40, // 10 years
),
new RankCondition(
RANK.AAA,
"Expert Dev",
28, // 7.5 years
),
new RankCondition(
RANK.AA,
"Experienced Dev",
18, // 5 years
),
new RankCondition(
RANK.A,
"Intermediate Dev",
11, // 3 years
),
new RankCondition(
RANK.B,
"Junior Dev",
6, // 1.5 years
),
new RankCondition(
RANK.C,
"Newbie",
2, // 0.5 year
),
];
super(score, rankConditions);
this.title = "Experience";
this.filterTitles = ["Experience", "Duration", "Since"];
}
}
export class TotalStarTrophy extends Trophy {
constructor(score: number) {
const rankConditions = [
new RankCondition(
RANK.SSS,
"Super Stargazer",
2000,
),
new RankCondition(
RANK.SS,
"High Stargazer",
700,
),
new RankCondition(
RANK.S,
"Stargazer",
200,
),
new RankCondition(
RANK.AAA,
"Super Star",
100,
),
new RankCondition(
RANK.AA,
"High Star",
50,
),
new RankCondition(
RANK.A,
"You are a Star",
30,
),
new RankCondition(
RANK.B,
"Middle Star",
10,
),
new RankCondition(
RANK.C,
"First Star",
1,
),
];
super(score, rankConditions);
this.title = "Stars";
this.filterTitles = ["Star", "Stars"];
}
}
export class TotalCommitTrophy extends Trophy {
constructor(score: number) {
const rankConditions = [
new RankCondition(
RANK.SSS,
"God Committer",
4000,
),
new RankCondition(
RANK.SS,
"Deep Committer",
2000,
),
new RankCondition(
RANK.S,
"Super Committer",
1000,
),
new RankCondition(
RANK.AAA,
"Ultra Committer",
500,
),
new RankCondition(
RANK.AA,
"Hyper Committer",
200,
),
new RankCondition(
RANK.A,
"High Committer",
100,
),
new RankCondition(
RANK.B,
"Middle Committer",
10,
),
new RankCondition(
RANK.C,
"First Commit",
1,
),
];
super(score, rankConditions);
this.title = "Commits";
this.filterTitles = ["Commit", "Commits"];
}
}
export class TotalFollowerTrophy extends Trophy {
constructor(score: number) {
const rankConditions = [
new RankCondition(
RANK.SSS,
"Super Celebrity",
1000,
),
new RankCondition(
RANK.SS,
"Ultra Celebrity",
400,
),
new RankCondition(
RANK.S,
"Hyper Celebrity",
200,
),
new RankCondition(
RANK.AAA,
"Famous User",
100,
),
new RankCondition(
RANK.AA,
"Active User",
50,
),
new RankCondition(
RANK.A,
"Dynamic User",
20,
),
new RankCondition(
RANK.B,
"Many Friends",
10,
),
new RankCondition(
RANK.C,
"First Friend",
1,
),
];
super(score, rankConditions);
this.title = "Followers";
this.filterTitles = ["Follower", "Followers"];
}
}
export class TotalIssueTrophy extends Trophy {
constructor(score: number) {
const rankConditions = [
new RankCondition(
RANK.SSS,
"God Issuer",
1000,
),
new RankCondition(
RANK.SS,
"Deep Issuer",
500,
),
new RankCondition(
RANK.S,
"Super Issuer",
200,
),
new RankCondition(
RANK.AAA,
"Ultra Issuer",
100,
),
new RankCondition(
RANK.AA,
"Hyper Issuer",
50,
),
new RankCondition(
RANK.A,
"High Issuer",
20,
),
new RankCondition(
RANK.B,
"Middle Issuer",
10,
),
new RankCondition(
RANK.C,
"First Issue",
1,
),
];
super(score, rankConditions);
this.title = "Issues";
this.filterTitles = ["Issue", "Issues"];
}
}
export class TotalPullRequestTrophy extends Trophy {
constructor(score: number) {
const rankConditions = [
new RankCondition(
RANK.SSS,
"God Puller",
1000,
),
new RankCondition(
RANK.SS,
"Deep Puller",
500,
),
new RankCondition(
RANK.S,
"Super Puller",
200,
),
new RankCondition(
RANK.AAA,
"Ultra Puller",
100,
),
new RankCondition(
RANK.AA,
"Hyper Puller",
50,
),
new RankCondition(
RANK.A,
"High Puller",
20,
),
new RankCondition(
RANK.B,
"Middle Puller",
10,
),
new RankCondition(
RANK.C,
"First Pull",
1,
),
];
super(score, rankConditions);
this.title = "PullRequest";
this.filterTitles = ["PR", "PullRequest", "Pulls", "Puller"];
}
}
export class TotalRepositoryTrophy extends Trophy {
constructor(score: number) {
const rankConditions = [
new RankCondition(
RANK.SSS,
"God Repo Creator",
50,
),
new RankCondition(
RANK.SS,
"Deep Repo Creator",
45,
),
new RankCondition(
RANK.S,
"Super Repo Creator",
40,
),
new RankCondition(
RANK.AAA,
"Ultra Repo Creator",
35,
),
new RankCondition(
RANK.AA,
"Hyper Repo Creator",
30,
),
new RankCondition(
RANK.A,
"High Repo Creator",
20,
),
new RankCondition(
RANK.B,
"Middle Repo Creator",
10,
),
new RankCondition(
RANK.C,
"First Repository",
1,
),
];
super(score, rankConditions);
this.title = "Repositories";
this.filterTitles = ["Repo", "Repository", "Repositories"];
}
}
================================================
FILE: src/trophy_list.ts
================================================
import {
AccountDurationTrophy,
AllSuperRankTrophy,
AncientAccountTrophy,
Joined2020Trophy,
LongTimeAccountTrophy,
MultipleLangTrophy,
MultipleOrganizationsTrophy,
OGAccountTrophy,
TotalCommitTrophy,
TotalFollowerTrophy,
TotalIssueTrophy,
TotalPullRequestTrophy,
TotalRepositoryTrophy,
TotalReviewsTrophy,
TotalStarTrophy,
Trophy,
} from "./trophy.ts";
import { UserInfo } from "./user_info.ts";
import { RANK, RANK_ORDER } from "./utils.ts";
export class TrophyList {
private trophies = new Array();
constructor(userInfo: UserInfo) {
// Base trophies
this.trophies.push(
new TotalStarTrophy(userInfo.totalStargazers),
new TotalCommitTrophy(userInfo.totalCommits),
new TotalFollowerTrophy(userInfo.totalFollowers),
new TotalIssueTrophy(userInfo.totalIssues),
new TotalPullRequestTrophy(userInfo.totalPullRequests),
new TotalRepositoryTrophy(userInfo.totalRepositories),
new TotalReviewsTrophy(userInfo.totalReviews),
);
// Secret trophies
this.trophies.push(
new AllSuperRankTrophy(this.isAllSRank),
new MultipleLangTrophy(userInfo.languageCount),
new LongTimeAccountTrophy(userInfo.durationYear),
new AncientAccountTrophy(userInfo.ancientAccount),
new OGAccountTrophy(userInfo.ogAccount),
new Joined2020Trophy(userInfo.joined2020),
new MultipleOrganizationsTrophy(userInfo.totalOrganizations),
new AccountDurationTrophy(userInfo.durationDays),
);
}
get length() {
return this.trophies.length;
}
get getArray() {
return this.trophies;
}
private get isAllSRank() {
return this.trophies.every((trophy) => trophy.rank.slice(0, 1) == RANK.S)
? 1
: 0;
}
filterByHidden() {
this.trophies = this.trophies.filter((trophy) =>
!trophy.hidden || trophy.rank !== RANK.UNKNOWN
);
}
filterByTitles(titles: Array) {
this.trophies = this.trophies.filter((trophy) => {
return trophy.filterTitles.some((title) => titles.includes(title));
});
}
filterByRanks(ranks: Array) {
if (ranks.filter((rank) => rank.includes("-")).length !== 0) {
this.trophies = this.trophies.filter((trophy) =>
!ranks.map((rank) => rank.substring(1)).includes(trophy.rank)
);
return;
}
this.trophies = this.trophies.filter((trophy) =>
ranks.includes(trophy.rank)
);
}
filterByExclusionTitles(titles: Array) {
const excludeTitles = titles.filter((title) => title.startsWith("-")).map(
(title) => title.substring(1),
);
if (excludeTitles.length > 0) {
this.trophies = this.trophies.filter((trophy) =>
!excludeTitles.includes(trophy.title)
);
}
}
sortByRank() {
this.trophies = this.trophies.toSorted((a: Trophy, b: Trophy) =>
RANK_ORDER.indexOf(a.rank) - RANK_ORDER.indexOf(b.rank)
);
}
}
================================================
FILE: src/user_info.ts
================================================
type Language = { name: string };
type Stargazers = { totalCount: number };
type Repository = {
languages: { nodes: Language[] };
stargazers: Stargazers;
createdAt: string;
};
export type GitHubUserRepository = {
repositories: {
totalCount: number;
nodes: Repository[];
};
};
export type GitHubUserIssue = {
openIssues: {
totalCount: number;
};
closedIssues: {
totalCount: number;
};
};
export type GitHubUserPullRequest = {
pullRequests: {
totalCount: number;
};
};
export type GitHubUserActivity = {
createdAt: string;
contributionsCollection: {
totalCommitContributions: number;
restrictedContributionsCount: number;
totalPullRequestReviewContributions: number;
};
organizations: {
totalCount: number;
};
followers: {
totalCount: number;
};
};
export type GitHubUserAll =
& GitHubUserActivity
& GitHubUserIssue
& GitHubUserPullRequest
& GitHubUserRepository;
export class UserInfo {
public readonly totalCommits: number;
public readonly totalFollowers: number;
public readonly totalIssues: number;
public readonly totalOrganizations: number;
public readonly totalPullRequests: number;
public readonly totalReviews: number;
public readonly totalStargazers: number;
public readonly totalRepositories: number;
public readonly languageCount: number;
public readonly durationYear: number;
public readonly durationDays: number;
public readonly ancientAccount: number;
public readonly joined2020: number;
public readonly ogAccount: number;
static fromCombined(data: GitHubUserAll): UserInfo {
return new UserInfo(data, data, data, data);
}
constructor(
userActivity: GitHubUserActivity,
userIssue: GitHubUserIssue,
userPullRequest: GitHubUserPullRequest,
userRepository: GitHubUserRepository,
) {
const totalCommits =
userActivity.contributionsCollection.restrictedContributionsCount +
userActivity.contributionsCollection.totalCommitContributions;
const totalStargazers = userRepository.repositories.nodes.reduce(
(prev: number, node: Repository) => {
return prev + node.stargazers.totalCount;
},
0,
);
const languages = new Set();
userRepository.repositories.nodes.forEach((node: Repository) => {
if (node.languages.nodes != undefined) {
node.languages.nodes.forEach((node: Language) => {
if (node != undefined) {
languages.add(node.name);
}
});
}
});
// Find the earliest repository creation date
let earliestRepoDate = userActivity.createdAt; // start with the oldest possible
earliestRepoDate = userRepository.repositories.nodes.reduce(
(earliest, node) => {
return new Date(node.createdAt).getTime() < new Date(earliest).getTime()
? node.createdAt
: earliest;
},
earliestRepoDate,
);
const durationTime = new Date().getTime() -
new Date(earliestRepoDate).getTime();
const durationYear = new Date(durationTime).getUTCFullYear() - 1970;
const durationDays = Math.floor(
durationTime / (1000 * 60 * 60 * 24) / 100,
);
const ancientAccount = new Date(earliestRepoDate).getFullYear() <= 2010
? 1
: 0;
const joined2020 = new Date(earliestRepoDate).getFullYear() == 2020 ? 1 : 0;
const ogAccount = new Date(earliestRepoDate).getFullYear() <= 2008 ? 1 : 0;
this.totalCommits = totalCommits;
this.totalFollowers = userActivity.followers.totalCount;
this.totalIssues = userIssue.openIssues.totalCount +
userIssue.closedIssues.totalCount;
this.totalOrganizations = userActivity.organizations.totalCount;
this.totalPullRequests = userPullRequest.pullRequests.totalCount;
this.totalReviews =
userActivity.contributionsCollection.totalPullRequestReviewContributions;
this.totalStargazers = totalStargazers;
this.totalRepositories = userRepository.repositories.totalCount;
this.languageCount = languages.size;
this.durationYear = durationYear;
this.durationDays = durationDays;
this.ancientAccount = ancientAccount;
this.joined2020 = joined2020;
this.ogAccount = ogAccount;
}
}
================================================
FILE: src/utils.ts
================================================
export class CustomURLSearchParams extends URLSearchParams {
getStringValue(key: string, defaultValue: string): string {
if (super.has(key)) {
const param = super.get(key);
if (param !== null) {
return param.toString();
}
}
return defaultValue.toString();
}
getNumberValue(key: string, defaultValue: number): number {
if (super.has(key)) {
const param = super.get(key);
if (param !== null) {
const parsedValue = parseInt(param);
if (isNaN(parsedValue)) {
return defaultValue;
}
return parsedValue;
}
}
return defaultValue;
}
getBooleanValue(key: string, defaultValue: boolean): boolean {
if (super.has(key)) {
const param = super.get(key);
return param !== null && param.toString() === "true";
}
return defaultValue;
}
}
export function parseParams(req: Request): CustomURLSearchParams {
const splittedURL = req.url.split("?");
if (splittedURL.length < 2) {
return new CustomURLSearchParams();
}
return new CustomURLSearchParams(splittedURL[1]);
}
export function abridgeScore(score: number): string {
if (Math.abs(score) < 1) {
return "0pt";
}
if (Math.abs(score) > 999) {
return (Math.sign(score) * (Math.abs(score) / 1000)).toFixed(1) + "kpt";
}
return (Math.sign(score) * Math.abs(score)).toString() + "pt";
}
const HOUR_IN_MILLISECONDS = 60 * 60 * 1000;
export const CONSTANTS = {
CACHE_MAX_AGE: 18800,
CDN_CACHE_MAX_AGE: 28800, // 8 hours for CDN edge cache
STALE_WHILE_REVALIDATE: 86400, // 24 hours - serve stale while revalidating
DEFAULT_PANEL_SIZE: 110,
DEFAULT_MAX_COLUMN: 8,
DEFAULT_MAX_ROW: 3,
DEFAULT_MARGIN_W: 0,
DEFAULT_MARGIN_H: 0,
DEFAULT_NO_BACKGROUND: false,
DEFAULT_NO_FRAME: false,
DEFAULT_GITHUB_API: "https://api.github.com/graphql",
DEFAULT_GITHUB_RETRY_DELAY: 500,
REVALIDATE_TIME: HOUR_IN_MILLISECONDS * 6,
REDIS_TTL: HOUR_IN_MILLISECONDS * 4,
};
export enum RANK {
SECRET = "SECRET",
SSS = "SSS",
SS = "SS",
S = "S",
AAA = "AAA",
AA = "AA",
A = "A",
B = "B",
C = "C",
UNKNOWN = "?",
}
export const RANK_ORDER = Object.values(RANK);
================================================
FILE: test/test.ts
================================================
================================================
FILE: vercel.json
================================================
{
"public": true,
"functions": {
"api/**/*.[jt]s": {
"runtime": "vercel-deno@3.1.1",
"maxDuration": 5
}
},
"rewrites": [
{
"source": "/(.*)",
"destination": "/api/$1"
}
]
}