├── .gitattributes ├── .github ├── renovate.json └── workflows │ ├── pr_check.yml │ └── publish.yml ├── .gitignore ├── .hadolint.yaml ├── .husky └── pre-commit ├── .prettierrc.yml ├── Dockerfile ├── LICENSE ├── README.md ├── docker-compose.yml ├── eslint.config.js ├── favicon └── leetcode.ico ├── package.json ├── packages ├── cloudflare-worker │ ├── .gitignore │ ├── env.d.ts │ ├── package.json │ ├── src │ │ ├── demo │ │ │ ├── demo.html │ │ │ ├── google-fonts.ts │ │ │ └── index.ts │ │ ├── handler.ts │ │ ├── headers.ts │ │ ├── index.ts │ │ ├── sanitize.ts │ │ └── utils.ts │ ├── tsconfig.json │ ├── vitest.config.mts │ ├── worker-configuration.d.ts │ └── wrangler.toml └── core │ ├── package.json │ ├── src │ ├── _test │ │ ├── elements.test.ts │ │ ├── index.test.ts │ │ └── query.test.ts │ ├── card.ts │ ├── constants.ts │ ├── elements.ts │ ├── exts │ │ ├── activity.ts │ │ ├── animation.ts │ │ ├── contest.ts │ │ ├── font.ts │ │ ├── heatmap.ts │ │ ├── remote-style.ts │ │ └── theme.ts │ ├── index.ts │ ├── item.ts │ ├── query.ts │ ├── theme │ │ ├── _theme.ts │ │ ├── catppuccin-mocha.ts │ │ ├── chartreuse.ts │ │ ├── dark.ts │ │ ├── forest.ts │ │ ├── light.ts │ │ ├── nord.ts │ │ ├── radical.ts │ │ ├── transparent.ts │ │ ├── unicorn.ts │ │ └── wtf.ts │ └── types.ts │ ├── tsconfig.json │ └── tsup.config.ts ├── pnpm-lock.yaml ├── pnpm-workspace.yaml ├── tsconfig.json └── wrangler.toml /.gitattributes: -------------------------------------------------------------------------------- 1 | # Auto detect text files and perform LF normalization 2 | * text=auto 3 | -------------------------------------------------------------------------------- /.github/renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["config:base"] 3 | } 4 | -------------------------------------------------------------------------------- /.github/workflows/pr_check.yml: -------------------------------------------------------------------------------- 1 | name: PR Check 2 | 3 | on: 4 | pull_request: 5 | branches: 6 | - main 7 | 8 | jobs: 9 | test_action_build: 10 | name: Check Worker Build 11 | runs-on: ubuntu-latest 12 | steps: 13 | - name: Checkout 14 | uses: actions/checkout@v4 15 | 16 | - name: Setup PNPM 17 | uses: pnpm/action-setup@v3 18 | with: 19 | run_install: true 20 | 21 | - name: Build Worker 22 | run: pnpm build 23 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: Publish Worker 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | paths-ignore: 8 | - "**.md" 9 | workflow_dispatch: 10 | 11 | jobs: 12 | publish_worker: 13 | runs-on: ubuntu-latest 14 | name: Deploy 15 | steps: 16 | - name: Checkout Repository 17 | uses: actions/checkout@v4 18 | 19 | - name: Setup PNPM 20 | uses: pnpm/action-setup@v3 21 | with: 22 | run_install: true 23 | 24 | - name: Build Worker 25 | run: pnpm build 26 | 27 | - name: Publish to Cloudflare 28 | uses: cloudflare/wrangler-action@v3 29 | with: 30 | apiToken: ${{ secrets.CF_API_TOKEN }} 31 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | 8 | # Runtime data 9 | pids 10 | *.pid 11 | *.seed 12 | *.pid.lock 13 | 14 | # Directory for instrumented libs generated by jscoverage/JSCover 15 | lib-cov 16 | 17 | # Coverage directory used by tools like istanbul 18 | coverage 19 | 20 | # nyc test coverage 21 | .nyc_output 22 | 23 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 24 | .grunt 25 | 26 | # Bower dependency directory (https://bower.io/) 27 | bower_components 28 | 29 | # node-waf configuration 30 | .lock-wscript 31 | 32 | # Compiled binary addons (https://nodejs.org/api/addons.html) 33 | build/Release 34 | 35 | # Dependency directories 36 | node_modules/ 37 | jspm_packages/ 38 | 39 | # TypeScript v1 declaration files 40 | typings/ 41 | 42 | # Optional npm cache directory 43 | .npm 44 | 45 | # Optional eslint cache 46 | .eslintcache 47 | 48 | # Optional REPL history 49 | .node_repl_history 50 | 51 | # Output of 'npm pack' 52 | *.tgz 53 | 54 | # Yarn Integrity file 55 | .yarn-integrity 56 | 57 | # dotenv environment variables file 58 | .env 59 | 60 | # parcel-bundler cache (https://parceljs.org/) 61 | .cache 62 | 63 | # next.js build output 64 | .next 65 | 66 | # nuxt.js build output 67 | .nuxt 68 | 69 | # vuepress build output 70 | .vuepress/dist 71 | 72 | # Serverless directories 73 | .serverless 74 | 75 | # FuseBox cache 76 | .fusebox/ 77 | 78 | dist/ 79 | lib/ 80 | **/.DS_Store 81 | .wrangler/ 82 | 83 | worker.capnp 84 | .storage/ 85 | -------------------------------------------------------------------------------- /.hadolint.yaml: -------------------------------------------------------------------------------- 1 | ignored: 2 | - DL3007 3 | - DL3045 4 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | pnpm i --frozen-lockfile 2 | pnpm build 3 | pnpm lint-staged 4 | -------------------------------------------------------------------------------- /.prettierrc.yml: -------------------------------------------------------------------------------- 1 | --- 2 | printWidth: 100 3 | tabWidth: 4 4 | useTabs: false 5 | trailingComma: all 6 | semi: true 7 | singleQuote: false 8 | overrides: 9 | - files: "**/*.md" 10 | options: 11 | tabWidth: 2 12 | plugins: 13 | - prettier-plugin-organize-imports 14 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM jacoblincool/workerd:latest 2 | 3 | COPY ./worker.capnp ./worker.capnp 4 | 5 | CMD ["serve", "--experimental", "--binary", "worker.capnp"] -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 JacobLinCool 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # LeetCode Stats Card 2 | 3 | [![CodeFactor](https://www.codefactor.io/repository/github/jacoblincool/leetcode-stats-card/badge)](https://www.codefactor.io/repository/github/jacoblincool/leetcode-stats-card) 4 | 5 | Show your dynamically generated LeetCode stats on your GitHub profile or your website! 6 | 7 | LeetCode and LeetCode CN are both supported. 8 | 9 | [Playground: Try It Now](https://leetcard.jacoblin.cool/) 10 | 11 | [![LeetCode Stats](https://leetcard.jacoblin.cool/JacobLinCool?theme=unicorn&extension=activity)](https://leetcard.jacoblin.cool/JacobLinCool?theme=unicorn&extension=activity) 12 | 13 | ## Features 14 | 15 | - 📈 Clean and simple LeetCode stats, for both `us` and `cn` sites 16 | - 🎨 Multiple themes and 1,300+ fonts - [Theme](#theme-default-lightdark), [Font](#font-default-baloo_2) 17 | - 🪄 Fully customizable using CSS - [Custom Stylesheets](#sheets-default-) 18 | - ⚡️ Fast and global edge network - [Cloudflare Workers](https://workers.cloudflare.com/) 19 | - 🚫 No tracking, controllable cache - [Cache](#cache-default-60) 20 | - 🍀 Open source - [MIT License](./LICENSE) 21 | - ⚙️ Extended-cards: `activity`, `contest`, `heatmap` 22 | 23 | It also has a [NPM package](https://www.npmjs.com/package/leetcode-card) and a [highly extensible system](./packages/core/src/index.ts), so you can easily customize it to your needs. 24 | 25 | Want to contribute? Feel free to open a pull request! 26 | 27 | ## Self-hosting 28 | 29 | You can also self-host this service using the [`jacoblincool/leetcode-stats-card`](https://hub.docker.com/r/jacoblincool/leetcode-stats-card) Docker image. 30 | 31 | To build the image by yourself, use `pnpm build:image` script. 32 | 33 | See [docker-compose.yml](./docker-compose.yml) for an example. 34 | 35 | ## Usage 36 | 37 | Simply copy the code below, paste it into your `README.md`, and change the path to your leetcode username (case-insensitive). 38 | 39 | ```md 40 | ![Leetcode Stats](https://leetcard.jacoblin.cool/JacobLinCool) 41 | ``` 42 | 43 | Congratulation! You are now showing your LeetCode stats on your profile! 44 | 45 | Want a hyperlink? Try this: 46 | 47 | ```md 48 | [![Leetcode Stats](https://leetcard.jacoblin.cool/JacobLinCool)](https://leetcode.com/JacobLinCool) 49 | ``` 50 | 51 | ### Endpoint 52 | 53 | The endpoint of this tool is: 54 | 55 | 56 | 57 | > The legacy one: 58 | 59 | ### Options 60 | 61 | There are many options, you can configure them by passing a query string to the endpoint. 62 | 63 | #### `site` (default: `us`) 64 | 65 | Data source, can be `us` or `cn`. 66 | 67 | ```md 68 | ![](https://leetcard.jacoblin.cool/leetcode?site=cn) 69 | ``` 70 | 71 | [![](https://leetcard.jacoblin.cool/leetcode?site=cn)](https://leetcard.jacoblin.cool/leetcode?site=cn) 72 | 73 | #### `theme` (default: `light,dark`) 74 | 75 | Card theme, see [Theme](#themes) for more information. 76 | 77 | Use a comma to separate the light and dark theme. 78 | 79 | ```md 80 | ![](https://leetcard.jacoblin.cool/jacoblincool?theme=unicorn) 81 | ![](https://leetcard.jacoblin.cool/jacoblincool?theme=light,unicorn) 82 | ``` 83 | 84 | [![](https://leetcard.jacoblin.cool/jacoblincool?theme=unicorn)](https://leetcode.com/jacoblincool) 85 | 86 | #### `font` (default: `Baloo_2`) 87 | 88 | Card font, you can use almost all fonts on [Google Fonts](https://fonts.google.com/). 89 | 90 | It is case-insensitive, and you can use `font=dancing_script` or `font=Dancing%20Script` to get the same result. 91 | 92 | ```md 93 | ![](https://leetcard.jacoblin.cool/jacoblincool?font=Dancing_Script) 94 | ``` 95 | 96 | [![](https://leetcard.jacoblin.cool/jacoblincool?font=Dancing_Script)](https://leetcard.jacoblin.cool/jacoblincool?font=Dancing_Script) 97 | 98 | #### `width` and `height` (default: `500` and `200`) 99 | 100 | Change the card size, it will not resize the content. 101 | 102 | But it will be helpful if you want to use custom css. 103 | 104 | ```md 105 | ![](https://leetcard.jacoblin.cool/jacoblincool?width=500&height=500) 106 | ``` 107 | 108 | [![](https://leetcard.jacoblin.cool/jacoblincool?width=500&height=500)](https://leetcard.jacoblin.cool/jacoblincool?width=500&height=500) 109 | 110 | #### `border` and `radius` (default: `1` and `4`) 111 | 112 | Change the card border and radius. 113 | 114 | ```md 115 | ![](https://leetcard.jacoblin.cool/jacoblincool?border=0&radius=20) 116 | ``` 117 | 118 | [![](https://leetcard.jacoblin.cool/jacoblincool?border=0&radius=20)](https://leetcard.jacoblin.cool/jacoblincool?border=0&radius=20) 119 | 120 | #### `animation` (default: `true`) 121 | 122 | Enable or disable the animation. 123 | 124 | ```md 125 | ![](https://leetcard.jacoblin.cool/jacoblincool?animation=false) 126 | ``` 127 | 128 | [![](https://leetcard.jacoblin.cool/jacoblincool?animation=false)](https://leetcard.jacoblin.cool/jacoblincool?animation=false) 129 | 130 | #### `hide` (default: `""`) 131 | 132 | Hide elements on the card, it is a comma-separated list of element ids. 133 | 134 | ```md 135 | ![](https://leetcard.jacoblin.cool/jacoblincool?hide=ranking,total-solved-text,easy-solved-count,medium-solved-count,hard-solved-count) 136 | ``` 137 | 138 | [![](https://leetcard.jacoblin.cool/jacoblincool?hide=ranking,total-solved-text,easy-solved-count,medium-solved-count,hard-solved-count)](https://leetcard.jacoblin.cool/jacoblincool?hide=ranking,total-solved-text,easy-solved-count,medium-solved-count,hard-solved-count) 139 | 140 | #### `ext` (default: `""`) 141 | 142 | Extension, it is a comma-separated list of extension names. 143 | 144 | NOTICE: You can only use one of extended-card extensions (`activity`, `contest`, `heatmap`) at a time now, maybe they can be used together in the future. 145 | 146 | > Animation, font, theme, and external stylesheet are all implemented by extensions and enabled by default. 147 | 148 | Want to contribute a `nyan-cat` extension? PR is welcome! 149 | 150 | ```md 151 | ![](https://leetcard.jacoblin.cool/jacoblincool?ext=activity) 152 | ``` 153 | 154 | [![](https://leetcard.jacoblin.cool/jacoblincool?ext=activity)](https://leetcard.jacoblin.cool/jacoblincool?ext=activity) 155 | 156 | ```md 157 | ![](https://leetcard.jacoblin.cool/lapor?ext=contest) 158 | ``` 159 | 160 | [![](https://leetcard.jacoblin.cool/lapor?ext=contest)](https://leetcard.jacoblin.cool/lapor?ext=contest) 161 | 162 | ```md 163 | ![](https://leetcard.jacoblin.cool/lapor?ext=heatmap) 164 | ``` 165 | 166 | [![](https://leetcard.jacoblin.cool/lapor?ext=heatmap)](https://leetcard.jacoblin.cool/lapor?ext=heatmap) 167 | 168 | #### `cache` (default: `60`) 169 | 170 | Cache time in seconds. 171 | 172 | Note: it will not be a good idea to set it to a long time because GitHub will fetch and cache the card. 173 | 174 | ```md 175 | ![](https://leetcard.jacoblin.cool/jacoblincool?cache=0) 176 | ``` 177 | 178 | > You can make `DELETE` request to `/:site/:username` to delete the cache. 179 | 180 | #### `sheets` (default: `""`) 181 | 182 | External stylesheet, it is a comma-separated list of urls. 183 | 184 | You can upload your custom CSS to gist and use the url. 185 | 186 | ```md 187 | ![](https://leetcard.jacoblin.cool/jacoblincool?sheets=url1,url2) 188 | ``` 189 | 190 | They will be injected in the order you specified. 191 | 192 | #### Legacy Options 193 | 194 | Still work, but deprecated. 195 | 196 | | Key | Description | Default Value | 197 | | --------------- | ---------------------------- | ------------- | 198 | | `border_radius` | Same as `radius` | `4` | 199 | | `show_rank` | Display/Hide Rank: `Boolean` | `true` | 200 | | `extension` | Same as `ext` | `""` | 201 | 202 | ### Themes 203 | 204 | Now we have 6 themes. If you have any great idea, please feel free to open a PR! 205 | 206 | #### Light 207 | 208 | ```md 209 | ![Leetcode Stats](https://leetcard.jacoblin.cool/JacobLinCool?theme=light) 210 | ``` 211 | 212 | [![Leetcode Stats](https://leetcard.jacoblin.cool/JacobLinCool?theme=light)](https://leetcard.jacoblin.cool/JacobLinCool?theme=light) 213 | 214 | #### Dark 215 | 216 | ```md 217 | ![Leetcode Stats](https://leetcard.jacoblin.cool/JacobLinCool?theme=dark) 218 | ``` 219 | 220 | [![Leetcode Stats](https://leetcard.jacoblin.cool/JacobLinCool?theme=dark)](https://leetcard.jacoblin.cool/JacobLinCool?theme=dark) 221 | 222 | #### Nord 223 | 224 | ```md 225 | ![Leetcode Stats](https://leetcard.jacoblin.cool/JacobLinCool?theme=nord) 226 | ``` 227 | 228 | [![Leetcode Stats](https://leetcard.jacoblin.cool/JacobLinCool?theme=nord)](https://leetcard.jacoblin.cool/JacobLinCool?theme=nord) 229 | 230 | #### Forest 231 | 232 | ```md 233 | ![Leetcode Stats](https://leetcard.jacoblin.cool/JacobLinCool?theme=forest) 234 | ``` 235 | 236 | [![Leetcode Stats](https://leetcard.jacoblin.cool/JacobLinCool?theme=forest)](https://leetcard.jacoblin.cool/JacobLinCool?theme=forest) 237 | 238 | #### WTF 239 | 240 | ```md 241 | ![Leetcode Stats](https://leetcard.jacoblin.cool/JacobLinCool?theme=wtf) 242 | ``` 243 | 244 | [![Leetcode Stats](https://leetcard.jacoblin.cool/JacobLinCool?theme=wtf)](https://leetcard.jacoblin.cool/JacobLinCool?theme=wtf) 245 | 246 | #### Unicorn 247 | 248 | ```md 249 | ![Leetcode Stats](https://leetcard.jacoblin.cool/JacobLinCool?theme=unicorn) 250 | ``` 251 | 252 | [![Leetcode Stats](https://leetcard.jacoblin.cool/JacobLinCool?theme=unicorn)](https://leetcard.jacoblin.cool/JacobLinCool?theme=unicorn) 253 | 254 | #### Transparent 255 | 256 | ```md 257 | ![Leetcode Stats](https://leetcard.jacoblin.cool/JacobLinCool?theme=transparent) 258 | ``` 259 | 260 | [![Leetcode Stats](https://leetcard.jacoblin.cool/JacobLinCool?theme=transparent)](https://leetcard.jacoblin.cool/JacobLinCool?theme=transparent) 261 | 262 | ### Fonts 263 | 264 | You can now use almost all fonts on [Google Fonts](https://fonts.google.com/). 265 | 266 | Some examples: 267 | 268 | #### Milonga 269 | 270 | ```md 271 | ![Leetcode Stats](https://leetcard.jacoblin.cool/JacobLinCool?font=milonga) 272 | ``` 273 | 274 | [![Leetcode Stats](https://leetcard.jacoblin.cool/JacobLinCool?font=milonga)](https://leetcard.jacoblin.cool/JacobLinCool?font=milonga) 275 | 276 | #### Patrick Hand 277 | 278 | ```md 279 | ![Leetcode Stats](https://leetcard.jacoblin.cool/JacobLinCool?font=patrick_hand) 280 | ``` 281 | 282 | [![Leetcode Stats](https://leetcard.jacoblin.cool/JacobLinCool?font=patrick_hand)](https://leetcard.jacoblin.cool/JacobLinCool?font=patrick_hand) 283 | 284 | #### Ruthie 285 | 286 | ```md 287 | ![Leetcode Stats](https://leetcard.jacoblin.cool/JacobLinCool?font=ruthie) 288 | ``` 289 | 290 | [![Leetcode Stats](https://leetcard.jacoblin.cool/JacobLinCool?font=ruthie)](https://leetcard.jacoblin.cool/JacobLinCool?font=ruthie) 291 | 292 | ### Extensions 293 | 294 | Extension, it is a comma-separated list of extension names. 295 | 296 | NOTICE: You can only use one of extended-card extensions (`activity`, `contest`, `heatmap`) at a time now, maybe they can be used together in the future. 297 | 298 | > Animation, font, theme, and external stylesheet are all implemented by extensions and enabled by default. 299 | 300 | Want to contribute a `nyan-cat` extension? PR is welcome! 301 | 302 | #### `activity` 303 | 304 | Show your recent submissions. 305 | 306 | ```md 307 | ![Leetcode Stats](https://leetcard.jacoblin.cool/JacobLinCool?ext=activity) 308 | ``` 309 | 310 | [![Leetcode Stats](https://leetcard.jacoblin.cool/JacobLinCool?ext=activity)](https://leetcard.jacoblin.cool/JacobLinCool?ext=activity) 311 | 312 | #### `contest` 313 | 314 | Show your contest rating history. 315 | 316 | ```md 317 | ![Leetcode Stats](https://leetcard.jacoblin.cool/lapor?ext=contest) 318 | ``` 319 | 320 | [![Leetcode Stats](https://leetcard.jacoblin.cool/lapor?ext=contest)](https://leetcard.jacoblin.cool/lapor?ext=contest) 321 | 322 | #### `heatmap` 323 | 324 | Show heatmap in the past 52 weeks. 325 | 326 | ```md 327 | ![Leetcode Stats](https://leetcard.jacoblin.cool/lapor?ext=heatmap) 328 | ``` 329 | 330 | [![Leetcode Stats](https://leetcard.jacoblin.cool/lapor?ext=heatmap)](https://leetcard.jacoblin.cool/lapor?ext=heatmap) 331 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "3.8" 2 | 3 | services: 4 | worker: 5 | build: . 6 | image: jacoblincool/leetcode-stats-card 7 | volumes: 8 | - ./.storage/cache:/worker/cache 9 | ports: 10 | - "8080:8080" 11 | -------------------------------------------------------------------------------- /eslint.config.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | import eslint from "@eslint/js"; 4 | import prettier from "eslint-config-prettier"; 5 | import tseslint from "typescript-eslint"; 6 | 7 | export default tseslint.config( 8 | eslint.configs.recommended, 9 | ...tseslint.configs.recommended, 10 | prettier, 11 | { 12 | ignores: ["packages/*/dist", "packages/cloudflare-worker/worker-configuration.d.ts"], 13 | }, 14 | ); 15 | -------------------------------------------------------------------------------- /favicon/leetcode.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JacobLinCool/LeetCode-Stats-Card/43cf7fbb901ae648a25aea5863f36ad67da6a328/favicon/leetcode.ico -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "private": true, 3 | "name": "leetcode-card-monorepo", 4 | "version": "1.0.1", 5 | "description": "Show your dynamically generated LeetCode stats on your GitHub profile or your website!", 6 | "license": "MIT", 7 | "type": "module", 8 | "author": { 9 | "name": "JacobLinCool", 10 | "email": "jacoblincool@gmail.com" 11 | }, 12 | "main": "lib/index.js", 13 | "files": [ 14 | "lib" 15 | ], 16 | "scripts": { 17 | "prepare": "husky", 18 | "test": "vitest --coverage --coverage.include packages/*/src", 19 | "build:worker": "pnpm run --filter cloudflare-worker build", 20 | "build:package": "pnpm run --filter leetcode-card build", 21 | "build:image": "selflare compile --script packages/cloudflare-worker/dist/worker.js && docker compose build", 22 | "build": "pnpm -r build", 23 | "format": "prettier --write . --ignore-path .gitignore", 24 | "lint": "eslint ." 25 | }, 26 | "keywords": [ 27 | "leetcode", 28 | "stats", 29 | "card" 30 | ], 31 | "devDependencies": { 32 | "@eslint/js": "^9.17.0", 33 | "@types/node": "^22.10.2", 34 | "@typescript-eslint/eslint-plugin": "^8.18.0", 35 | "@typescript-eslint/parser": "^8.18.0", 36 | "@vitest/coverage-v8": "^2.1.8", 37 | "eslint": "^9.17.0", 38 | "eslint-config-prettier": "^9.1.0", 39 | "husky": "^9.1.7", 40 | "lint-staged": "^15.2.11", 41 | "prettier": "^3.4.2", 42 | "prettier-plugin-organize-imports": "^4.1.0", 43 | "selflare": "^1.1.2", 44 | "tsup": "8.0.2", 45 | "typescript": "^5.7.2", 46 | "typescript-eslint": "^8.18.0", 47 | "vitest": "^2.1.8" 48 | }, 49 | "repository": { 50 | "type": "git", 51 | "url": "https://github.com/JacobLinCool/LeetCode-Stats-Card.git" 52 | }, 53 | "bugs": { 54 | "url": "https://github.com/JacobLinCool/LeetCode-Stats-Card/issues" 55 | }, 56 | "homepage": "https://github.com/JacobLinCool/LeetCode-Stats-Card#readme", 57 | "packageManager": "pnpm@9.15.0", 58 | "lint-staged": { 59 | "*.{js,ts}": [ 60 | "eslint --fix", 61 | "prettier --write" 62 | ] 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /packages/cloudflare-worker/.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | 3 | logs 4 | _.log 5 | npm-debug.log_ 6 | yarn-debug.log* 7 | yarn-error.log* 8 | lerna-debug.log* 9 | .pnpm-debug.log* 10 | 11 | # Diagnostic reports (https://nodejs.org/api/report.html) 12 | 13 | report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json 14 | 15 | # Runtime data 16 | 17 | pids 18 | _.pid 19 | _.seed 20 | \*.pid.lock 21 | 22 | # Directory for instrumented libs generated by jscoverage/JSCover 23 | 24 | lib-cov 25 | 26 | # Coverage directory used by tools like istanbul 27 | 28 | coverage 29 | \*.lcov 30 | 31 | # nyc test coverage 32 | 33 | .nyc_output 34 | 35 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 36 | 37 | .grunt 38 | 39 | # Bower dependency directory (https://bower.io/) 40 | 41 | bower_components 42 | 43 | # node-waf configuration 44 | 45 | .lock-wscript 46 | 47 | # Compiled binary addons (https://nodejs.org/api/addons.html) 48 | 49 | build/Release 50 | 51 | # Dependency directories 52 | 53 | node_modules/ 54 | jspm_packages/ 55 | 56 | # Snowpack dependency directory (https://snowpack.dev/) 57 | 58 | web_modules/ 59 | 60 | # TypeScript cache 61 | 62 | \*.tsbuildinfo 63 | 64 | # Optional npm cache directory 65 | 66 | .npm 67 | 68 | # Optional eslint cache 69 | 70 | .eslintcache 71 | 72 | # Optional stylelint cache 73 | 74 | .stylelintcache 75 | 76 | # Microbundle cache 77 | 78 | .rpt2_cache/ 79 | .rts2_cache_cjs/ 80 | .rts2_cache_es/ 81 | .rts2_cache_umd/ 82 | 83 | # Optional REPL history 84 | 85 | .node_repl_history 86 | 87 | # Output of 'npm pack' 88 | 89 | \*.tgz 90 | 91 | # Yarn Integrity file 92 | 93 | .yarn-integrity 94 | 95 | # dotenv environment variable files 96 | 97 | .env 98 | .env.development.local 99 | .env.test.local 100 | .env.production.local 101 | .env.local 102 | 103 | # parcel-bundler cache (https://parceljs.org/) 104 | 105 | .cache 106 | .parcel-cache 107 | 108 | # Next.js build output 109 | 110 | .next 111 | out 112 | 113 | # Nuxt.js build / generate output 114 | 115 | .nuxt 116 | dist 117 | 118 | # Gatsby files 119 | 120 | .cache/ 121 | 122 | # Comment in the public line in if your project uses Gatsby and not Next.js 123 | 124 | # https://nextjs.org/blog/next-9-1#public-directory-support 125 | 126 | # public 127 | 128 | # vuepress build output 129 | 130 | .vuepress/dist 131 | 132 | # vuepress v2.x temp and cache directory 133 | 134 | .temp 135 | .cache 136 | 137 | # Docusaurus cache and generated files 138 | 139 | .docusaurus 140 | 141 | # Serverless directories 142 | 143 | .serverless/ 144 | 145 | # FuseBox cache 146 | 147 | .fusebox/ 148 | 149 | # DynamoDB Local files 150 | 151 | .dynamodb/ 152 | 153 | # TernJS port file 154 | 155 | .tern-port 156 | 157 | # Stores VSCode versions used for testing VSCode extensions 158 | 159 | .vscode-test 160 | 161 | # yarn v2 162 | 163 | .yarn/cache 164 | .yarn/unplugged 165 | .yarn/build-state.yml 166 | .yarn/install-state.gz 167 | .pnp.\* 168 | 169 | # wrangler project 170 | 171 | .dev.vars 172 | .wrangler/ 173 | -------------------------------------------------------------------------------- /packages/cloudflare-worker/env.d.ts: -------------------------------------------------------------------------------- 1 | declare module "*.html" { 2 | const content: string; 3 | export default content; 4 | } 5 | -------------------------------------------------------------------------------- /packages/cloudflare-worker/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "private": true, 3 | "name": "cloudflare-worker", 4 | "version": "0.0.0", 5 | "scripts": { 6 | "deploy": "wrangler deploy", 7 | "dev": "wrangler dev", 8 | "start": "wrangler dev", 9 | "build": "esbuild src/index.ts --outfile=dist/worker.js --bundle --format=esm --loader:.html=text --keep-names", 10 | "test": "vitest", 11 | "cf-typegen": "wrangler types" 12 | }, 13 | "devDependencies": { 14 | "@cloudflare/vitest-pool-workers": "^0.5.36", 15 | "@cloudflare/workers-types": "^4.20241205.0", 16 | "esbuild": "^0.24.0", 17 | "typescript": "^5.7.2", 18 | "vitest": "2.1.8", 19 | "wrangler": "^3.95.0" 20 | }, 21 | "dependencies": { 22 | "hono": "^4.6.14", 23 | "leetcode-card": "workspace:*" 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /packages/cloudflare-worker/src/demo/demo.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 9 | 10 | LeetCode Stats Card 11 | 12 | 16 | 17 | 18 | 19 |
20 |

LeetCode Stats Card

21 |
22 |
23 | 24 | 25 |
26 |
27 | 28 | 31 |
32 |
33 | 34 | 37 |
38 |
39 | 40 | 46 |
47 |
48 | 49 | 53 |
54 |
55 | 56 | 57 | 58 |
59 |
60 |
61 |
62 | 63 |
64 | 69 |
70 |
71 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 |
93 | 363 | 454 | 455 | 456 | -------------------------------------------------------------------------------- /packages/cloudflare-worker/src/demo/google-fonts.ts: -------------------------------------------------------------------------------- 1 | export const fonts = [ 2 | "ABeeZee", 3 | "Abel", 4 | "Abhaya Libre", 5 | "Abril Fatface", 6 | "Aclonica", 7 | "Acme", 8 | "Actor", 9 | "Adamina", 10 | "Advent Pro", 11 | "Aguafina Script", 12 | "Akaya Kanadaka", 13 | "Akaya Telivigala", 14 | "Akronim", 15 | "Akshar", 16 | "Aladin", 17 | "Alata", 18 | "Alatsi", 19 | "Aldrich", 20 | "Alef", 21 | "Alegreya", 22 | "Alegreya SC", 23 | "Alegreya Sans", 24 | "Alegreya Sans SC", 25 | "Aleo", 26 | "Alex Brush", 27 | "Alfa Slab One", 28 | "Alice", 29 | "Alike", 30 | "Alike Angular", 31 | "Allan", 32 | "Allerta", 33 | "Allerta Stencil", 34 | "Allison", 35 | "Allura", 36 | "Almarai", 37 | "Almendra", 38 | "Almendra Display", 39 | "Almendra SC", 40 | "Alumni Sans", 41 | "Alumni Sans Inline One", 42 | "Amarante", 43 | "Amaranth", 44 | "Amatic SC", 45 | "Amethysta", 46 | "Amiko", 47 | "Amiri", 48 | "Amita", 49 | "Anaheim", 50 | "Andada Pro", 51 | "Andika", 52 | "Andika New Basic", 53 | "Anek Bangla", 54 | "Anek Devanagari", 55 | "Anek Gujarati", 56 | "Anek Gurmukhi", 57 | "Anek Kannada", 58 | "Anek Latin", 59 | "Anek Malayalam", 60 | "Anek Odia", 61 | "Anek Tamil", 62 | "Anek Telugu", 63 | "Angkor", 64 | "Annie Use Your Telescope", 65 | "Anonymous Pro", 66 | "Antic", 67 | "Antic Didone", 68 | "Antic Slab", 69 | "Anton", 70 | "Antonio", 71 | "Anybody", 72 | "Arapey", 73 | "Arbutus", 74 | "Arbutus Slab", 75 | "Architects Daughter", 76 | "Archivo", 77 | "Archivo Black", 78 | "Archivo Narrow", 79 | "Are You Serious", 80 | "Aref Ruqaa", 81 | "Arima Madurai", 82 | "Arimo", 83 | "Arizonia", 84 | "Armata", 85 | "Arsenal", 86 | "Artifika", 87 | "Arvo", 88 | "Arya", 89 | "Asap", 90 | "Asap Condensed", 91 | "Asar", 92 | "Asset", 93 | "Assistant", 94 | "Astloch", 95 | "Asul", 96 | "Athiti", 97 | "Atkinson Hyperlegible", 98 | "Atma", 99 | "Atomic Age", 100 | "Aubrey", 101 | "Audiowide", 102 | "Autour One", 103 | "Average", 104 | "Average Sans", 105 | "Averia Gruesa Libre", 106 | "Averia Libre", 107 | "Averia Sans Libre", 108 | "Averia Serif Libre", 109 | "Azeret Mono", 110 | "B612", 111 | "B612 Mono", 112 | "BIZ UDGothic", 113 | "BIZ UDMincho", 114 | "BIZ UDPGothic", 115 | "BIZ UDPMincho", 116 | "Babylonica", 117 | "Bad Script", 118 | "Bahiana", 119 | "Bahianita", 120 | "Bai Jamjuree", 121 | "Bakbak One", 122 | "Ballet", 123 | "Baloo 2", 124 | "Baloo Bhai 2", 125 | "Baloo Bhaijaan 2", 126 | "Baloo Bhaina 2", 127 | "Baloo Chettan 2", 128 | "Baloo Da 2", 129 | "Baloo Paaji 2", 130 | "Baloo Tamma 2", 131 | "Baloo Tammudu 2", 132 | "Baloo Thambi 2", 133 | "Balsamiq Sans", 134 | "Balthazar", 135 | "Bangers", 136 | "Barlow", 137 | "Barlow Condensed", 138 | "Barlow Semi Condensed", 139 | "Barriecito", 140 | "Barrio", 141 | "Basic", 142 | "Baskervville", 143 | "Battambang", 144 | "Baumans", 145 | "Bayon", 146 | "Be Vietnam Pro", 147 | "Beau Rivage", 148 | "Bebas Neue", 149 | "Belgrano", 150 | "Bellefair", 151 | "Belleza", 152 | "Bellota", 153 | "Bellota Text", 154 | "BenchNine", 155 | "Benne", 156 | "Bentham", 157 | "Berkshire Swash", 158 | "Besley", 159 | "Beth Ellen", 160 | "Bevan", 161 | "BhuTuka Expanded One", 162 | "Big Shoulders Display", 163 | "Big Shoulders Inline Display", 164 | "Big Shoulders Inline Text", 165 | "Big Shoulders Stencil Display", 166 | "Big Shoulders Stencil Text", 167 | "Big Shoulders Text", 168 | "Bigelow Rules", 169 | "Bigshot One", 170 | "Bilbo", 171 | "Bilbo Swash Caps", 172 | "BioRhyme", 173 | "BioRhyme Expanded", 174 | "Birthstone", 175 | "Birthstone Bounce", 176 | "Biryani", 177 | "Bitter", 178 | "Black And White Picture", 179 | "Black Han Sans", 180 | "Black Ops One", 181 | "Blaka", 182 | "Blaka Hollow", 183 | "Blinker", 184 | "Bodoni Moda", 185 | "Bokor", 186 | "Bona Nova", 187 | "Bonbon", 188 | "Bonheur Royale", 189 | "Boogaloo", 190 | "Bowlby One", 191 | "Bowlby One SC", 192 | "Brawler", 193 | "Bree Serif", 194 | "Brygada 1918", 195 | "Bubblegum Sans", 196 | "Bubbler One", 197 | "Buda", 198 | "Buenard", 199 | "Bungee", 200 | "Bungee Hairline", 201 | "Bungee Inline", 202 | "Bungee Outline", 203 | "Bungee Shade", 204 | "Butcherman", 205 | "Butterfly Kids", 206 | "Cabin", 207 | "Cabin Condensed", 208 | "Cabin Sketch", 209 | "Caesar Dressing", 210 | "Cagliostro", 211 | "Cairo", 212 | "Caladea", 213 | "Calistoga", 214 | "Calligraffitti", 215 | "Cambay", 216 | "Cambo", 217 | "Candal", 218 | "Cantarell", 219 | "Cantata One", 220 | "Cantora One", 221 | "Capriola", 222 | "Caramel", 223 | "Carattere", 224 | "Cardo", 225 | "Carme", 226 | "Carrois Gothic", 227 | "Carrois Gothic SC", 228 | "Carter One", 229 | "Castoro", 230 | "Catamaran", 231 | "Caudex", 232 | "Caveat", 233 | "Caveat Brush", 234 | "Cedarville Cursive", 235 | "Ceviche One", 236 | "Chakra Petch", 237 | "Changa", 238 | "Changa One", 239 | "Chango", 240 | "Charm", 241 | "Charmonman", 242 | "Chathura", 243 | "Chau Philomene One", 244 | "Chela One", 245 | "Chelsea Market", 246 | "Chenla", 247 | "Cherish", 248 | "Cherry Cream Soda", 249 | "Cherry Swash", 250 | "Chewy", 251 | "Chicle", 252 | "Chilanka", 253 | "Chivo", 254 | "Chonburi", 255 | "Cinzel", 256 | "Cinzel Decorative", 257 | "Clicker Script", 258 | "Coda", 259 | "Coda Caption", 260 | "Codystar", 261 | "Coiny", 262 | "Combo", 263 | "Comfortaa", 264 | "Comforter", 265 | "Comforter Brush", 266 | "Comic Neue", 267 | "Coming Soon", 268 | "Commissioner", 269 | "Concert One", 270 | "Condiment", 271 | "Content", 272 | "Contrail One", 273 | "Convergence", 274 | "Cookie", 275 | "Copse", 276 | "Corben", 277 | "Corinthia", 278 | "Cormorant", 279 | "Cormorant Garamond", 280 | "Cormorant Infant", 281 | "Cormorant SC", 282 | "Cormorant Unicase", 283 | "Cormorant Upright", 284 | "Courgette", 285 | "Courier Prime", 286 | "Cousine", 287 | "Coustard", 288 | "Covered By Your Grace", 289 | "Crafty Girls", 290 | "Creepster", 291 | "Crete Round", 292 | "Crimson Pro", 293 | "Crimson Text", 294 | "Croissant One", 295 | "Crushed", 296 | "Cuprum", 297 | "Cute Font", 298 | "Cutive", 299 | "Cutive Mono", 300 | "DM Mono", 301 | "DM Sans", 302 | "DM Serif Display", 303 | "DM Serif Text", 304 | "Damion", 305 | "Dancing Script", 306 | "Dangrek", 307 | "Darker Grotesque", 308 | "David Libre", 309 | "Dawning of a New Day", 310 | "Days One", 311 | "Dekko", 312 | "Dela Gothic One", 313 | "Delius", 314 | "Delius Swash Caps", 315 | "Delius Unicase", 316 | "Della Respira", 317 | "Denk One", 318 | "Devonshire", 319 | "Dhurjati", 320 | "Didact Gothic", 321 | "Diplomata", 322 | "Diplomata SC", 323 | "Do Hyeon", 324 | "Dokdo", 325 | "Domine", 326 | "Donegal One", 327 | "Dongle", 328 | "Doppio One", 329 | "Dorsa", 330 | "Dosis", 331 | "DotGothic16", 332 | "Dr Sugiyama", 333 | "Duru Sans", 334 | "Dynalight", 335 | "EB Garamond", 336 | "Eagle Lake", 337 | "East Sea Dokdo", 338 | "Eater", 339 | "Economica", 340 | "Eczar", 341 | "El Messiri", 342 | "Electrolize", 343 | "Elsie", 344 | "Elsie Swash Caps", 345 | "Emblema One", 346 | "Emilys Candy", 347 | "Encode Sans", 348 | "Encode Sans Condensed", 349 | "Encode Sans Expanded", 350 | "Encode Sans SC", 351 | "Encode Sans Semi Condensed", 352 | "Encode Sans Semi Expanded", 353 | "Engagement", 354 | "Englebert", 355 | "Enriqueta", 356 | "Ephesis", 357 | "Epilogue", 358 | "Erica One", 359 | "Esteban", 360 | "Estonia", 361 | "Euphoria Script", 362 | "Ewert", 363 | "Exo", 364 | "Exo 2", 365 | "Expletus Sans", 366 | "Explora", 367 | "Fahkwang", 368 | "Familjen Grotesk", 369 | "Fanwood Text", 370 | "Farro", 371 | "Farsan", 372 | "Fascinate", 373 | "Fascinate Inline", 374 | "Faster One", 375 | "Fasthand", 376 | "Fauna One", 377 | "Faustina", 378 | "Federant", 379 | "Federo", 380 | "Felipa", 381 | "Fenix", 382 | "Festive", 383 | "Finger Paint", 384 | "Fira Code", 385 | "Fira Mono", 386 | "Fira Sans", 387 | "Fira Sans Condensed", 388 | "Fira Sans Extra Condensed", 389 | "Fjalla One", 390 | "Fjord One", 391 | "Flamenco", 392 | "Flavors", 393 | "Fleur De Leah", 394 | "Flow Block", 395 | "Flow Circular", 396 | "Flow Rounded", 397 | "Fondamento", 398 | "Fontdiner Swanky", 399 | "Forum", 400 | "Francois One", 401 | "Frank Ruhl Libre", 402 | "Fraunces", 403 | "Freckle Face", 404 | "Fredericka the Great", 405 | "Fredoka", 406 | "Fredoka One", 407 | "Freehand", 408 | "Fresca", 409 | "Frijole", 410 | "Fruktur", 411 | "Fugaz One", 412 | "Fuggles", 413 | "Fuzzy Bubbles", 414 | "GFS Didot", 415 | "GFS Neohellenic", 416 | "Gabriela", 417 | "Gaegu", 418 | "Gafata", 419 | "Galada", 420 | "Galdeano", 421 | "Galindo", 422 | "Gamja Flower", 423 | "Gayathri", 424 | "Gelasio", 425 | "Gemunu Libre", 426 | "Genos", 427 | "Gentium Basic", 428 | "Gentium Book Basic", 429 | "Geo", 430 | "Georama", 431 | "Geostar", 432 | "Geostar Fill", 433 | "Germania One", 434 | "Gideon Roman", 435 | "Gidugu", 436 | "Gilda Display", 437 | "Girassol", 438 | "Give You Glory", 439 | "Glass Antiqua", 440 | "Glegoo", 441 | "Gloria Hallelujah", 442 | "Glory", 443 | "Gluten", 444 | "Goblin One", 445 | "Gochi Hand", 446 | "Goldman", 447 | "Gorditas", 448 | "Gothic A1", 449 | "Gotu", 450 | "Goudy Bookletter 1911", 451 | "Gowun Batang", 452 | "Gowun Dodum", 453 | "Graduate", 454 | "Grand Hotel", 455 | "Grandstander", 456 | "Grape Nuts", 457 | "Gravitas One", 458 | "Great Vibes", 459 | "Grechen Fuemen", 460 | "Grenze", 461 | "Grenze Gotisch", 462 | "Grey Qo", 463 | "Griffy", 464 | "Gruppo", 465 | "Gudea", 466 | "Gugi", 467 | "Gupter", 468 | "Gurajada", 469 | "Gwendolyn", 470 | "Habibi", 471 | "Hachi Maru Pop", 472 | "Hahmlet", 473 | "Halant", 474 | "Hammersmith One", 475 | "Hanalei", 476 | "Hanalei Fill", 477 | "Handlee", 478 | "Hanuman", 479 | "Happy Monkey", 480 | "Harmattan", 481 | "Headland One", 482 | "Heebo", 483 | "Henny Penny", 484 | "Hepta Slab", 485 | "Herr Von Muellerhoff", 486 | "Hi Melody", 487 | "Hina Mincho", 488 | "Hind", 489 | "Hind Guntur", 490 | "Hind Madurai", 491 | "Hind Siliguri", 492 | "Hind Vadodara", 493 | "Holtwood One SC", 494 | "Homemade Apple", 495 | "Homenaje", 496 | "Hubballi", 497 | "Hurricane", 498 | "IBM Plex Mono", 499 | "IBM Plex Sans", 500 | "IBM Plex Sans Arabic", 501 | "IBM Plex Sans Condensed", 502 | "IBM Plex Sans Devanagari", 503 | "IBM Plex Sans Hebrew", 504 | "IBM Plex Sans KR", 505 | "IBM Plex Sans Thai", 506 | "IBM Plex Sans Thai Looped", 507 | "IBM Plex Serif", 508 | "IM Fell DW Pica", 509 | "IM Fell DW Pica SC", 510 | "IM Fell Double Pica", 511 | "IM Fell Double Pica SC", 512 | "IM Fell English", 513 | "IM Fell English SC", 514 | "IM Fell French Canon", 515 | "IM Fell French Canon SC", 516 | "IM Fell Great Primer", 517 | "IM Fell Great Primer SC", 518 | "Ibarra Real Nova", 519 | "Iceberg", 520 | "Iceland", 521 | "Imbue", 522 | "Imperial Script", 523 | "Imprima", 524 | "Inconsolata", 525 | "Inder", 526 | "Indie Flower", 527 | "Ingrid Darling", 528 | "Inika", 529 | "Inknut Antiqua", 530 | "Inria Sans", 531 | "Inria Serif", 532 | "Inspiration", 533 | "Inter", 534 | "Irish Grover", 535 | "Island Moments", 536 | "Istok Web", 537 | "Italiana", 538 | "Italianno", 539 | "Itim", 540 | "Jacques Francois", 541 | "Jacques Francois Shadow", 542 | "Jaldi", 543 | "JetBrains Mono", 544 | "Jim Nightshade", 545 | "Jockey One", 546 | "Jolly Lodger", 547 | "Jomhuria", 548 | "Jomolhari", 549 | "Josefin Sans", 550 | "Josefin Slab", 551 | "Jost", 552 | "Joti One", 553 | "Jua", 554 | "Judson", 555 | "Julee", 556 | "Julius Sans One", 557 | "Junge", 558 | "Jura", 559 | "Just Another Hand", 560 | "Just Me Again Down Here", 561 | "K2D", 562 | "Kadwa", 563 | "Kaisei Decol", 564 | "Kaisei HarunoUmi", 565 | "Kaisei Opti", 566 | "Kaisei Tokumin", 567 | "Kalam", 568 | "Kameron", 569 | "Kanit", 570 | "Kantumruy", 571 | "Karantina", 572 | "Karla", 573 | "Karma", 574 | "Katibeh", 575 | "Kaushan Script", 576 | "Kavivanar", 577 | "Kavoon", 578 | "Kdam Thmor", 579 | "Keania One", 580 | "Kelly Slab", 581 | "Kenia", 582 | "Khand", 583 | "Khmer", 584 | "Khula", 585 | "Kings", 586 | "Kirang Haerang", 587 | "Kite One", 588 | "Kiwi Maru", 589 | "Klee One", 590 | "Knewave", 591 | "KoHo", 592 | "Kodchasan", 593 | "Koh Santepheap", 594 | "Kolker Brush", 595 | "Kosugi", 596 | "Kosugi Maru", 597 | "Kotta One", 598 | "Koulen", 599 | "Kranky", 600 | "Kreon", 601 | "Kristi", 602 | "Krona One", 603 | "Krub", 604 | "Kufam", 605 | "Kulim Park", 606 | "Kumar One", 607 | "Kumar One Outline", 608 | "Kumbh Sans", 609 | "Kurale", 610 | "La Belle Aurore", 611 | "Lacquer", 612 | "Laila", 613 | "Lakki Reddy", 614 | "Lalezar", 615 | "Lancelot", 616 | "Langar", 617 | "Lateef", 618 | "Lato", 619 | "Lavishly Yours", 620 | "League Gothic", 621 | "League Script", 622 | "League Spartan", 623 | "Leckerli One", 624 | "Ledger", 625 | "Lekton", 626 | "Lemon", 627 | "Lemonada", 628 | "Lexend", 629 | "Lexend Deca", 630 | "Lexend Exa", 631 | "Lexend Giga", 632 | "Lexend Mega", 633 | "Lexend Peta", 634 | "Lexend Tera", 635 | "Lexend Zetta", 636 | "Libre Barcode 128", 637 | "Libre Barcode 128 Text", 638 | "Libre Barcode 39", 639 | "Libre Barcode 39 Extended", 640 | "Libre Barcode 39 Extended Text", 641 | "Libre Barcode 39 Text", 642 | "Libre Barcode EAN13 Text", 643 | "Libre Baskerville", 644 | "Libre Bodoni", 645 | "Libre Caslon Display", 646 | "Libre Caslon Text", 647 | "Libre Franklin", 648 | "Licorice", 649 | "Life Savers", 650 | "Lilita One", 651 | "Lily Script One", 652 | "Limelight", 653 | "Linden Hill", 654 | "Literata", 655 | "Liu Jian Mao Cao", 656 | "Livvic", 657 | "Lobster", 658 | "Lobster Two", 659 | "Londrina Outline", 660 | "Londrina Shadow", 661 | "Londrina Sketch", 662 | "Londrina Solid", 663 | "Long Cang", 664 | "Lora", 665 | "Love Light", 666 | "Love Ya Like A Sister", 667 | "Loved by the King", 668 | "Lovers Quarrel", 669 | "Luckiest Guy", 670 | "Lusitana", 671 | "Lustria", 672 | "Luxurious Roman", 673 | "Luxurious Script", 674 | "M PLUS 1", 675 | "M PLUS 1 Code", 676 | "M PLUS 1p", 677 | "M PLUS 2", 678 | "M PLUS Code Latin", 679 | "M PLUS Rounded 1c", 680 | "Ma Shan Zheng", 681 | "Macondo", 682 | "Macondo Swash Caps", 683 | "Mada", 684 | "Magra", 685 | "Maiden Orange", 686 | "Maitree", 687 | "Major Mono Display", 688 | "Mako", 689 | "Mali", 690 | "Mallanna", 691 | "Mandali", 692 | "Manjari", 693 | "Manrope", 694 | "Mansalva", 695 | "Manuale", 696 | "Marcellus", 697 | "Marcellus SC", 698 | "Marck Script", 699 | "Margarine", 700 | "Markazi Text", 701 | "Marko One", 702 | "Marmelad", 703 | "Martel", 704 | "Martel Sans", 705 | "Marvel", 706 | "Mate", 707 | "Mate SC", 708 | "Maven Pro", 709 | "McLaren", 710 | "Mea Culpa", 711 | "Meddon", 712 | "MedievalSharp", 713 | "Medula One", 714 | "Meera Inimai", 715 | "Megrim", 716 | "Meie Script", 717 | "Meow Script", 718 | "Merienda", 719 | "Merienda One", 720 | "Merriweather", 721 | "Merriweather Sans", 722 | "Metal", 723 | "Metal Mania", 724 | "Metamorphous", 725 | "Metrophobic", 726 | "Michroma", 727 | "Milonga", 728 | "Miltonian", 729 | "Miltonian Tattoo", 730 | "Mina", 731 | "Miniver", 732 | "Miriam Libre", 733 | "Mirza", 734 | "Miss Fajardose", 735 | "Mitr", 736 | "Mochiy Pop One", 737 | "Mochiy Pop P One", 738 | "Modak", 739 | "Modern Antiqua", 740 | "Mogra", 741 | "Mohave", 742 | "Molengo", 743 | "Molle", 744 | "Monda", 745 | "Monofett", 746 | "Monoton", 747 | "Monsieur La Doulaise", 748 | "Montaga", 749 | "Montagu Slab", 750 | "MonteCarlo", 751 | "Montez", 752 | "Montserrat", 753 | "Montserrat Alternates", 754 | "Montserrat Subrayada", 755 | "Moo Lah Lah", 756 | "Moon Dance", 757 | "Moul", 758 | "Moulpali", 759 | "Mountains of Christmas", 760 | "Mouse Memoirs", 761 | "Mr Bedfort", 762 | "Mr Dafoe", 763 | "Mr De Haviland", 764 | "Mrs Saint Delafield", 765 | "Mrs Sheppards", 766 | "Ms Madi", 767 | "Mukta", 768 | "Mukta Mahee", 769 | "Mukta Malar", 770 | "Mukta Vaani", 771 | "Mulish", 772 | "Murecho", 773 | "MuseoModerno", 774 | "My Soul", 775 | "Mystery Quest", 776 | "NTR", 777 | "Nanum Brush Script", 778 | "Nanum Gothic", 779 | "Nanum Gothic Coding", 780 | "Nanum Myeongjo", 781 | "Nanum Pen Script", 782 | "Neonderthaw", 783 | "Nerko One", 784 | "Neucha", 785 | "Neuton", 786 | "New Rocker", 787 | "New Tegomin", 788 | "News Cycle", 789 | "Newsreader", 790 | "Niconne", 791 | "Niramit", 792 | "Nixie One", 793 | "Nobile", 794 | "Nokora", 795 | "Norican", 796 | "Nosifer", 797 | "Notable", 798 | "Nothing You Could Do", 799 | "Noticia Text", 800 | "Noto Emoji", 801 | "Noto Kufi Arabic", 802 | "Noto Music", 803 | "Noto Naskh Arabic", 804 | "Noto Nastaliq Urdu", 805 | "Noto Rashi Hebrew", 806 | "Noto Sans", 807 | "Noto Sans Adlam", 808 | "Noto Sans Adlam Unjoined", 809 | "Noto Sans Anatolian Hieroglyphs", 810 | "Noto Sans Arabic", 811 | "Noto Sans Armenian", 812 | "Noto Sans Avestan", 813 | "Noto Sans Balinese", 814 | "Noto Sans Bamum", 815 | "Noto Sans Bassa Vah", 816 | "Noto Sans Batak", 817 | "Noto Sans Bengali", 818 | "Noto Sans Bhaiksuki", 819 | "Noto Sans Brahmi", 820 | "Noto Sans Buginese", 821 | "Noto Sans Buhid", 822 | "Noto Sans Canadian Aboriginal", 823 | "Noto Sans Carian", 824 | "Noto Sans Caucasian Albanian", 825 | "Noto Sans Chakma", 826 | "Noto Sans Cham", 827 | "Noto Sans Cherokee", 828 | "Noto Sans Coptic", 829 | "Noto Sans Cuneiform", 830 | "Noto Sans Cypriot", 831 | "Noto Sans Deseret", 832 | "Noto Sans Devanagari", 833 | "Noto Sans Display", 834 | "Noto Sans Duployan", 835 | "Noto Sans Egyptian Hieroglyphs", 836 | "Noto Sans Elbasan", 837 | "Noto Sans Elymaic", 838 | "Noto Sans Georgian", 839 | "Noto Sans Glagolitic", 840 | "Noto Sans Gothic", 841 | "Noto Sans Grantha", 842 | "Noto Sans Gujarati", 843 | "Noto Sans Gunjala Gondi", 844 | "Noto Sans Gurmukhi", 845 | "Noto Sans HK", 846 | "Noto Sans Hanifi Rohingya", 847 | "Noto Sans Hanunoo", 848 | "Noto Sans Hatran", 849 | "Noto Sans Hebrew", 850 | "Noto Sans Imperial Aramaic", 851 | "Noto Sans Indic Siyaq Numbers", 852 | "Noto Sans Inscriptional Pahlavi", 853 | "Noto Sans Inscriptional Parthian", 854 | "Noto Sans JP", 855 | "Noto Sans Javanese", 856 | "Noto Sans KR", 857 | "Noto Sans Kaithi", 858 | "Noto Sans Kannada", 859 | "Noto Sans Kayah Li", 860 | "Noto Sans Kharoshthi", 861 | "Noto Sans Khmer", 862 | "Noto Sans Khojki", 863 | "Noto Sans Khudawadi", 864 | "Noto Sans Lao", 865 | "Noto Sans Lepcha", 866 | "Noto Sans Limbu", 867 | "Noto Sans Linear A", 868 | "Noto Sans Linear B", 869 | "Noto Sans Lisu", 870 | "Noto Sans Lycian", 871 | "Noto Sans Lydian", 872 | "Noto Sans Mahajani", 873 | "Noto Sans Malayalam", 874 | "Noto Sans Mandaic", 875 | "Noto Sans Manichaean", 876 | "Noto Sans Marchen", 877 | "Noto Sans Masaram Gondi", 878 | "Noto Sans Math", 879 | "Noto Sans Mayan Numerals", 880 | "Noto Sans Medefaidrin", 881 | "Noto Sans Meetei Mayek", 882 | "Noto Sans Meroitic", 883 | "Noto Sans Miao", 884 | "Noto Sans Modi", 885 | "Noto Sans Mongolian", 886 | "Noto Sans Mono", 887 | "Noto Sans Mro", 888 | "Noto Sans Multani", 889 | "Noto Sans Myanmar", 890 | "Noto Sans N Ko", 891 | "Noto Sans Nabataean", 892 | "Noto Sans New Tai Lue", 893 | "Noto Sans Newa", 894 | "Noto Sans Nushu", 895 | "Noto Sans Ogham", 896 | "Noto Sans Ol Chiki", 897 | "Noto Sans Old Hungarian", 898 | "Noto Sans Old Italic", 899 | "Noto Sans Old North Arabian", 900 | "Noto Sans Old Permic", 901 | "Noto Sans Old Persian", 902 | "Noto Sans Old Sogdian", 903 | "Noto Sans Old South Arabian", 904 | "Noto Sans Old Turkic", 905 | "Noto Sans Oriya", 906 | "Noto Sans Osage", 907 | "Noto Sans Osmanya", 908 | "Noto Sans Pahawh Hmong", 909 | "Noto Sans Palmyrene", 910 | "Noto Sans Pau Cin Hau", 911 | "Noto Sans Phags Pa", 912 | "Noto Sans Phoenician", 913 | "Noto Sans Psalter Pahlavi", 914 | "Noto Sans Rejang", 915 | "Noto Sans Runic", 916 | "Noto Sans SC", 917 | "Noto Sans Samaritan", 918 | "Noto Sans Saurashtra", 919 | "Noto Sans Sharada", 920 | "Noto Sans Shavian", 921 | "Noto Sans Siddham", 922 | "Noto Sans Sinhala", 923 | "Noto Sans Sogdian", 924 | "Noto Sans Sora Sompeng", 925 | "Noto Sans Soyombo", 926 | "Noto Sans Sundanese", 927 | "Noto Sans Syloti Nagri", 928 | "Noto Sans Symbols", 929 | "Noto Sans Symbols 2", 930 | "Noto Sans Syriac", 931 | "Noto Sans TC", 932 | "Noto Sans Tagalog", 933 | "Noto Sans Tagbanwa", 934 | "Noto Sans Tai Le", 935 | "Noto Sans Tai Tham", 936 | "Noto Sans Tai Viet", 937 | "Noto Sans Takri", 938 | "Noto Sans Tamil", 939 | "Noto Sans Tamil Supplement", 940 | "Noto Sans Telugu", 941 | "Noto Sans Thaana", 942 | "Noto Sans Thai", 943 | "Noto Sans Thai Looped", 944 | "Noto Sans Tifinagh", 945 | "Noto Sans Tirhuta", 946 | "Noto Sans Ugaritic", 947 | "Noto Sans Vai", 948 | "Noto Sans Wancho", 949 | "Noto Sans Warang Citi", 950 | "Noto Sans Yi", 951 | "Noto Sans Zanabazar Square", 952 | "Noto Serif", 953 | "Noto Serif Ahom", 954 | "Noto Serif Armenian", 955 | "Noto Serif Balinese", 956 | "Noto Serif Bengali", 957 | "Noto Serif Devanagari", 958 | "Noto Serif Display", 959 | "Noto Serif Dogra", 960 | "Noto Serif Ethiopic", 961 | "Noto Serif Georgian", 962 | "Noto Serif Grantha", 963 | "Noto Serif Gujarati", 964 | "Noto Serif Gurmukhi", 965 | "Noto Serif Hebrew", 966 | "Noto Serif JP", 967 | "Noto Serif KR", 968 | "Noto Serif Kannada", 969 | "Noto Serif Khmer", 970 | "Noto Serif Lao", 971 | "Noto Serif Malayalam", 972 | "Noto Serif Myanmar", 973 | "Noto Serif Nyiakeng Puachue Hmong", 974 | "Noto Serif SC", 975 | "Noto Serif Sinhala", 976 | "Noto Serif TC", 977 | "Noto Serif Tamil", 978 | "Noto Serif Tangut", 979 | "Noto Serif Telugu", 980 | "Noto Serif Thai", 981 | "Noto Serif Tibetan", 982 | "Noto Serif Yezidi", 983 | "Noto Traditional Nushu", 984 | "Nova Cut", 985 | "Nova Flat", 986 | "Nova Mono", 987 | "Nova Oval", 988 | "Nova Round", 989 | "Nova Script", 990 | "Nova Slim", 991 | "Nova Square", 992 | "Numans", 993 | "Nunito", 994 | "Nunito Sans", 995 | "Odibee Sans", 996 | "Odor Mean Chey", 997 | "Offside", 998 | "Oi", 999 | "Old Standard TT", 1000 | "Oldenburg", 1001 | "Ole", 1002 | "Oleo Script", 1003 | "Oleo Script Swash Caps", 1004 | "Oooh Baby", 1005 | "Open Sans", 1006 | "Oranienbaum", 1007 | "Orbitron", 1008 | "Oregano", 1009 | "Orelega One", 1010 | "Orienta", 1011 | "Original Surfer", 1012 | "Oswald", 1013 | "Otomanopee One", 1014 | "Outfit", 1015 | "Over the Rainbow", 1016 | "Overlock", 1017 | "Overlock SC", 1018 | "Overpass", 1019 | "Overpass Mono", 1020 | "Ovo", 1021 | "Oxanium", 1022 | "Oxygen", 1023 | "Oxygen Mono", 1024 | "PT Mono", 1025 | "PT Sans", 1026 | "PT Sans Caption", 1027 | "PT Sans Narrow", 1028 | "PT Serif", 1029 | "PT Serif Caption", 1030 | "Pacifico", 1031 | "Padauk", 1032 | "Palanquin", 1033 | "Palanquin Dark", 1034 | "Palette Mosaic", 1035 | "Pangolin", 1036 | "Paprika", 1037 | "Parisienne", 1038 | "Passero One", 1039 | "Passion One", 1040 | "Passions Conflict", 1041 | "Pathway Gothic One", 1042 | "Patrick Hand", 1043 | "Patrick Hand SC", 1044 | "Pattaya", 1045 | "Patua One", 1046 | "Pavanam", 1047 | "Paytone One", 1048 | "Peddana", 1049 | "Peralta", 1050 | "Permanent Marker", 1051 | "Petemoss", 1052 | "Petit Formal Script", 1053 | "Petrona", 1054 | "Philosopher", 1055 | "Piazzolla", 1056 | "Piedra", 1057 | "Pinyon Script", 1058 | "Pirata One", 1059 | "Plaster", 1060 | "Play", 1061 | "Playball", 1062 | "Playfair Display", 1063 | "Playfair Display SC", 1064 | "Plus Jakarta Sans", 1065 | "Podkova", 1066 | "Poiret One", 1067 | "Poller One", 1068 | "Poly", 1069 | "Pompiere", 1070 | "Pontano Sans", 1071 | "Poor Story", 1072 | "Poppins", 1073 | "Port Lligat Sans", 1074 | "Port Lligat Slab", 1075 | "Potta One", 1076 | "Pragati Narrow", 1077 | "Praise", 1078 | "Prata", 1079 | "Preahvihear", 1080 | "Press Start 2P", 1081 | "Pridi", 1082 | "Princess Sofia", 1083 | "Prociono", 1084 | "Prompt", 1085 | "Prosto One", 1086 | "Proza Libre", 1087 | "Public Sans", 1088 | "Puppies Play", 1089 | "Puritan", 1090 | "Purple Purse", 1091 | "Qahiri", 1092 | "Quando", 1093 | "Quantico", 1094 | "Quattrocento", 1095 | "Quattrocento Sans", 1096 | "Questrial", 1097 | "Quicksand", 1098 | "Quintessential", 1099 | "Qwigley", 1100 | "Qwitcher Grypen", 1101 | "Racing Sans One", 1102 | "Radio Canada", 1103 | "Radley", 1104 | "Rajdhani", 1105 | "Rakkas", 1106 | "Raleway", 1107 | "Raleway Dots", 1108 | "Ramabhadra", 1109 | "Ramaraja", 1110 | "Rambla", 1111 | "Rammetto One", 1112 | "Rampart One", 1113 | "Ranchers", 1114 | "Rancho", 1115 | "Ranga", 1116 | "Rasa", 1117 | "Rationale", 1118 | "Ravi Prakash", 1119 | "Readex Pro", 1120 | "Recursive", 1121 | "Red Hat Display", 1122 | "Red Hat Mono", 1123 | "Red Hat Text", 1124 | "Red Rose", 1125 | "Redacted", 1126 | "Redacted Script", 1127 | "Redressed", 1128 | "Reem Kufi", 1129 | "Reenie Beanie", 1130 | "Reggae One", 1131 | "Revalia", 1132 | "Rhodium Libre", 1133 | "Ribeye", 1134 | "Ribeye Marrow", 1135 | "Righteous", 1136 | "Risque", 1137 | "Road Rage", 1138 | "Roboto", 1139 | "Roboto Condensed", 1140 | "Roboto Flex", 1141 | "Roboto Mono", 1142 | "Roboto Serif", 1143 | "Roboto Slab", 1144 | "Rochester", 1145 | "Rock 3D", 1146 | "Rock Salt", 1147 | "RocknRoll One", 1148 | "Rokkitt", 1149 | "Romanesco", 1150 | "Ropa Sans", 1151 | "Rosario", 1152 | "Rosarivo", 1153 | "Rouge Script", 1154 | "Rowdies", 1155 | "Rozha One", 1156 | "Rubik", 1157 | "Rubik Beastly", 1158 | "Rubik Bubbles", 1159 | "Rubik Glitch", 1160 | "Rubik Microbe", 1161 | "Rubik Mono One", 1162 | "Rubik Moonrocks", 1163 | "Rubik Puddles", 1164 | "Rubik Wet Paint", 1165 | "Ruda", 1166 | "Rufina", 1167 | "Ruge Boogie", 1168 | "Ruluko", 1169 | "Rum Raisin", 1170 | "Ruslan Display", 1171 | "Russo One", 1172 | "Ruthie", 1173 | "Rye", 1174 | "STIX Two Text", 1175 | "Sacramento", 1176 | "Sahitya", 1177 | "Sail", 1178 | "Saira", 1179 | "Saira Condensed", 1180 | "Saira Extra Condensed", 1181 | "Saira Semi Condensed", 1182 | "Saira Stencil One", 1183 | "Salsa", 1184 | "Sanchez", 1185 | "Sancreek", 1186 | "Sansita", 1187 | "Sansita Swashed", 1188 | "Sarabun", 1189 | "Sarala", 1190 | "Sarina", 1191 | "Sarpanch", 1192 | "Sassy Frass", 1193 | "Satisfy", 1194 | "Sawarabi Gothic", 1195 | "Sawarabi Mincho", 1196 | "Scada", 1197 | "Scheherazade New", 1198 | "Schoolbell", 1199 | "Scope One", 1200 | "Seaweed Script", 1201 | "Secular One", 1202 | "Sedgwick Ave", 1203 | "Sedgwick Ave Display", 1204 | "Sen", 1205 | "Send Flowers", 1206 | "Sevillana", 1207 | "Seymour One", 1208 | "Shadows Into Light", 1209 | "Shadows Into Light Two", 1210 | "Shalimar", 1211 | "Shanti", 1212 | "Share", 1213 | "Share Tech", 1214 | "Share Tech Mono", 1215 | "Shippori Antique", 1216 | "Shippori Antique B1", 1217 | "Shippori Mincho", 1218 | "Shippori Mincho B1", 1219 | "Shizuru", 1220 | "Shojumaru", 1221 | "Short Stack", 1222 | "Shrikhand", 1223 | "Siemreap", 1224 | "Sigmar One", 1225 | "Signika", 1226 | "Signika Negative", 1227 | "Simonetta", 1228 | "Single Day", 1229 | "Sintony", 1230 | "Sirin Stencil", 1231 | "Six Caps", 1232 | "Skranji", 1233 | "Slabo 13px", 1234 | "Slabo 27px", 1235 | "Slackey", 1236 | "Smokum", 1237 | "Smooch", 1238 | "Smooch Sans", 1239 | "Smythe", 1240 | "Sniglet", 1241 | "Snippet", 1242 | "Snowburst One", 1243 | "Sofadi One", 1244 | "Sofia", 1245 | "Solway", 1246 | "Song Myung", 1247 | "Sonsie One", 1248 | "Sora", 1249 | "Sorts Mill Goudy", 1250 | "Source Code Pro", 1251 | "Source Sans 3", 1252 | "Source Sans Pro", 1253 | "Source Serif 4", 1254 | "Source Serif Pro", 1255 | "Space Grotesk", 1256 | "Space Mono", 1257 | "Special Elite", 1258 | "Spectral", 1259 | "Spectral SC", 1260 | "Spicy Rice", 1261 | "Spinnaker", 1262 | "Spirax", 1263 | "Spline Sans", 1264 | "Spline Sans Mono", 1265 | "Squada One", 1266 | "Square Peg", 1267 | "Sree Krushnadevaraya", 1268 | "Sriracha", 1269 | "Srisakdi", 1270 | "Staatliches", 1271 | "Stalemate", 1272 | "Stalinist One", 1273 | "Stardos Stencil", 1274 | "Stick", 1275 | "Stick No Bills", 1276 | "Stint Ultra Condensed", 1277 | "Stint Ultra Expanded", 1278 | "Stoke", 1279 | "Strait", 1280 | "Style Script", 1281 | "Stylish", 1282 | "Sue Ellen Francisco", 1283 | "Suez One", 1284 | "Sulphur Point", 1285 | "Sumana", 1286 | "Sunflower", 1287 | "Sunshiney", 1288 | "Supermercado One", 1289 | "Sura", 1290 | "Suranna", 1291 | "Suravaram", 1292 | "Suwannaphum", 1293 | "Swanky and Moo Moo", 1294 | "Syncopate", 1295 | "Syne", 1296 | "Syne Mono", 1297 | "Syne Tactile", 1298 | "Tajawal", 1299 | "Tangerine", 1300 | "Tapestry", 1301 | "Taprom", 1302 | "Tauri", 1303 | "Taviraj", 1304 | "Teko", 1305 | "Telex", 1306 | "Tenali Ramakrishna", 1307 | "Tenor Sans", 1308 | "Text Me One", 1309 | "Texturina", 1310 | "Thasadith", 1311 | "The Girl Next Door", 1312 | "The Nautigal", 1313 | "Tienne", 1314 | "Tillana", 1315 | "Timmana", 1316 | "Tinos", 1317 | "Tiro Bangla", 1318 | "Tiro Devanagari Hindi", 1319 | "Tiro Devanagari Marathi", 1320 | "Tiro Devanagari Sanskrit", 1321 | "Tiro Gurmukhi", 1322 | "Tiro Kannada", 1323 | "Tiro Tamil", 1324 | "Tiro Telugu", 1325 | "Titan One", 1326 | "Titillium Web", 1327 | "Tomorrow", 1328 | "Tourney", 1329 | "Trade Winds", 1330 | "Train One", 1331 | "Trirong", 1332 | "Trispace", 1333 | "Trocchi", 1334 | "Trochut", 1335 | "Truculenta", 1336 | "Trykker", 1337 | "Tulpen One", 1338 | "Turret Road", 1339 | "Twinkle Star", 1340 | "Ubuntu", 1341 | "Ubuntu Condensed", 1342 | "Ubuntu Mono", 1343 | "Uchen", 1344 | "Ultra", 1345 | "Uncial Antiqua", 1346 | "Underdog", 1347 | "Unica One", 1348 | "UnifrakturCook", 1349 | "UnifrakturMaguntia", 1350 | "Unkempt", 1351 | "Unlock", 1352 | "Unna", 1353 | "Updock", 1354 | "Urbanist", 1355 | "VT323", 1356 | "Vampiro One", 1357 | "Varela", 1358 | "Varela Round", 1359 | "Varta", 1360 | "Vast Shadow", 1361 | "Vazirmatn", 1362 | "Vesper Libre", 1363 | "Viaoda Libre", 1364 | "Vibes", 1365 | "Vibur", 1366 | "Vidaloka", 1367 | "Viga", 1368 | "Voces", 1369 | "Volkhov", 1370 | "Vollkorn", 1371 | "Vollkorn SC", 1372 | "Voltaire", 1373 | "Vujahday Script", 1374 | "Waiting for the Sunrise", 1375 | "Wallpoet", 1376 | "Walter Turncoat", 1377 | "Warnes", 1378 | "Water Brush", 1379 | "Waterfall", 1380 | "Wellfleet", 1381 | "Wendy One", 1382 | "Whisper", 1383 | "WindSong", 1384 | "Wire One", 1385 | "Work Sans", 1386 | "Xanh Mono", 1387 | "Yaldevi", 1388 | "Yanone Kaffeesatz", 1389 | "Yantramanav", 1390 | "Yatra One", 1391 | "Yellowtail", 1392 | "Yeon Sung", 1393 | "Yeseva One", 1394 | "Yesteryear", 1395 | "Yomogi", 1396 | "Yrsa", 1397 | "Yuji Boku", 1398 | "Yuji Hentaigana Akari", 1399 | "Yuji Hentaigana Akebono", 1400 | "Yuji Mai", 1401 | "Yuji Syuku", 1402 | "Yusei Magic", 1403 | "ZCOOL KuaiLe", 1404 | "ZCOOL QingKe HuangYou", 1405 | "ZCOOL XiaoWei", 1406 | "Zen Antique", 1407 | "Zen Antique Soft", 1408 | "Zen Dots", 1409 | "Zen Kaku Gothic Antique", 1410 | "Zen Kaku Gothic New", 1411 | "Zen Kurenaido", 1412 | "Zen Loop", 1413 | "Zen Maru Gothic", 1414 | "Zen Old Mincho", 1415 | "Zen Tokyo Zoo", 1416 | "Zeyada", 1417 | "Zhi Mang Xing", 1418 | "Zilla Slab", 1419 | "Zilla Slab Highlight", 1420 | ]; 1421 | -------------------------------------------------------------------------------- /packages/cloudflare-worker/src/demo/index.ts: -------------------------------------------------------------------------------- 1 | import { supported as themes } from "leetcode-card"; 2 | import html from "./demo.html"; 3 | import { fonts } from "./google-fonts"; 4 | 5 | const selected_font = Math.floor(Math.random() * fonts.length); 6 | 7 | export default html 8 | .replace( 9 | "${theme_options}", 10 | Object.keys(themes) 11 | .map( 12 | (theme) => 13 | ``, 16 | ) 17 | .join(""), 18 | ) 19 | .replace( 20 | "${font_options}", 21 | fonts 22 | .map( 23 | (font, i) => 24 | ``, 27 | ) 28 | .join(""), 29 | ); 30 | -------------------------------------------------------------------------------- /packages/cloudflare-worker/src/handler.ts: -------------------------------------------------------------------------------- 1 | import { Hono } from "hono"; 2 | import { cors } from "hono/cors"; 3 | import { Config, Generator } from "leetcode-card"; 4 | import demo from "./demo"; 5 | import Header from "./headers"; 6 | import { sanitize } from "./sanitize"; 7 | 8 | const app = new Hono().use("*", cors()); 9 | 10 | app.get("/favicon.ico", (c) => { 11 | return c.redirect( 12 | "https://raw.githubusercontent.com/JacobLinCool/leetcode-stats-card/main/favicon/leetcode.ico", 13 | 301, 14 | ); 15 | }); 16 | 17 | async function generate( 18 | config: Record, 19 | header: Record, 20 | ): Promise { 21 | let sanitized: Config; 22 | try { 23 | sanitized = sanitize(config); 24 | } catch (err) { 25 | return new Response((err as Error).message, { 26 | status: 400, 27 | }); 28 | } 29 | console.log("sanitized config", JSON.stringify(sanitized, null, 4)); 30 | 31 | const cache_time = parseInt(config.cache || "300") ?? 300; 32 | const cache = await caches.open("leetcode"); 33 | 34 | const generator = new Generator(cache, header); 35 | generator.verbose = true; 36 | 37 | const headers = new Header().add("cors", "svg"); 38 | headers.set("cache-control", `public, max-age=${cache_time}`); 39 | 40 | return new Response(await generator.generate(sanitized), { headers }); 41 | } 42 | 43 | // handle path variable 44 | app.get("/:username", async (c) => { 45 | const username = c.req.param("username"); 46 | const query = Object.fromEntries(new URL(c.req.url).searchParams); 47 | query.username = username; 48 | return await generate(query, { 49 | "user-agent": c.req.header("user-agent") || "Unknown", 50 | }); 51 | }); 52 | 53 | // handle query string 54 | app.get("*", async (c) => { 55 | const query = Object.fromEntries(new URL(c.req.url).searchParams); 56 | 57 | if (!query.username) { 58 | return new Response(demo, { 59 | headers: new Header().add("cors", "html"), 60 | }); 61 | } 62 | 63 | return await generate(query, { 64 | "user-agent": c.req.header("user-agent") || "Unknown", 65 | }); 66 | }); 67 | 68 | // 404 for all other routes 69 | app.all("*", () => new Response("Not Found.", { status: 404 })); 70 | 71 | export async function handle(request: Request): Promise { 72 | console.log(`${request.method} ${request.url}`); 73 | return app.fetch(request); 74 | } 75 | -------------------------------------------------------------------------------- /packages/cloudflare-worker/src/headers.ts: -------------------------------------------------------------------------------- 1 | const source = { 2 | cors: { 3 | "Access-Control-Allow-Origin": "*", 4 | "Access-Control-Allow-Methods": "*", 5 | "Access-Control-Allow-Headers": "*", 6 | "Access-Control-Allow-Credentials": "true", 7 | }, 8 | json: { 9 | "Content-Type": "application/json", 10 | }, 11 | html: { 12 | "Content-Type": "text/html", 13 | }, 14 | text: { 15 | "Content-Type": "text/plain", 16 | }, 17 | svg: { 18 | "Content-Type": "image/svg+xml", 19 | }, 20 | }; 21 | 22 | export default class Header extends Headers { 23 | constructor(headers?: Headers) { 24 | super(headers); 25 | } 26 | 27 | add(...types: (keyof typeof source)[]): Headers { 28 | for (const type of types) { 29 | for (const [key, value] of Object.entries(source[type])) { 30 | this.set(key, value); 31 | } 32 | } 33 | 34 | return this; 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /packages/cloudflare-worker/src/index.ts: -------------------------------------------------------------------------------- 1 | import { handle } from "./handler"; 2 | import Header from "./headers"; 3 | 4 | export default { 5 | async fetch(request: Request): Promise { 6 | try { 7 | return await handle(request); 8 | } catch (err) { 9 | console.error(err); 10 | return new Response((err as Error).message, { 11 | status: 500, 12 | headers: new Header().add("cors", "text"), 13 | }); 14 | } 15 | }, 16 | }; 17 | -------------------------------------------------------------------------------- /packages/cloudflare-worker/src/sanitize.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ActivityExtension, 3 | AnimationExtension, 4 | Config, 5 | ContestExtension, 6 | FontExtension, 7 | HeatmapExtension, 8 | RemoteStyleExtension, 9 | ThemeExtension, 10 | } from "leetcode-card"; 11 | import { booleanize, normalize } from "./utils"; 12 | 13 | // Helper functions to reduce complexity 14 | function handleExtension(config: Record): Config["extensions"] { 15 | const extensions = [FontExtension, AnimationExtension, ThemeExtension]; 16 | 17 | const extName = config.ext || config.extension; 18 | if (extName === "activity") { 19 | extensions.push(ActivityExtension); 20 | } else if (extName === "contest") { 21 | extensions.push(ContestExtension); 22 | } else if (extName === "heatmap") { 23 | extensions.push(HeatmapExtension); 24 | } 25 | 26 | if (config.sheets) { 27 | extensions.push(RemoteStyleExtension); 28 | } 29 | 30 | return extensions; 31 | } 32 | 33 | function handleCssRules(config: Record): string[] { 34 | const css: string[] = []; 35 | 36 | // Handle border radius (backward compatibility) 37 | if (config.border_radius) { 38 | css.push(`#background{rx:${parseFloat(config.border_radius) ?? 1}px}`); 39 | } 40 | 41 | // Handle show_rank (backward compatibility) 42 | if (config.show_rank && booleanize(config.show_rank) === false) { 43 | css.push(`#ranking{display:none}`); 44 | } 45 | 46 | // Handle radius 47 | if (config.radius) { 48 | css.push(`#background{rx:${parseFloat(config.radius) ?? 4}px}`); 49 | } 50 | 51 | // Handle hide elements 52 | if (config.hide) { 53 | const targets = config.hide.split(",").map((x) => x.trim()); 54 | css.push(...targets.map((x) => `#${x}{display:none}`)); 55 | } 56 | 57 | return css; 58 | } 59 | 60 | export function sanitize(config: Record): Config { 61 | if (!config.username?.trim()) { 62 | throw new Error("Missing username"); 63 | } 64 | 65 | const sanitized: Config = { 66 | username: config.username.trim(), 67 | site: config.site?.trim().toLowerCase() === "cn" ? "cn" : "us", 68 | width: parseInt(config.width?.trim()) || 500, 69 | height: parseInt(config.height?.trim()) || 200, 70 | css: [], 71 | extensions: handleExtension(config), 72 | font: normalize(config.font?.trim() || "baloo_2"), 73 | animation: config.animation ? booleanize(config.animation.trim()) : true, 74 | theme: { light: "light", dark: "dark" }, 75 | cache: 60, 76 | }; 77 | 78 | // Handle theme 79 | if (config.theme?.trim()) { 80 | const themes = config.theme.trim().split(","); 81 | sanitized.theme = 82 | themes.length === 1 || themes[1] === "" 83 | ? themes[0].trim() 84 | : { light: themes[0].trim(), dark: themes[1].trim() }; 85 | } 86 | 87 | // Handle border 88 | if (config.border) { 89 | const size = parseFloat(config.border) ?? 1; 90 | sanitized.extensions.push(() => (generator, data, body, styles) => { 91 | styles.push( 92 | `#background{stroke-width:${size};width:${generator.config.width - size}px;height:${ 93 | generator.config.height - size 94 | }px;transform:translate(${size / 2}px,${size / 2}px)}`, 95 | ); 96 | }); 97 | } 98 | 99 | // Handle CSS rules 100 | sanitized.css = handleCssRules(config); 101 | 102 | // Handle remote style sheets 103 | if (config.sheets) { 104 | sanitized.sheets = config.sheets.split(",").map((x) => x.trim()); 105 | } 106 | 107 | // Handle cache 108 | if (config.cache) { 109 | const cacheValue = parseInt(config.cache); 110 | if (cacheValue >= 0 && cacheValue <= 60 * 60 * 24 * 7) { 111 | sanitized.cache = cacheValue; 112 | } 113 | } 114 | 115 | return sanitized; 116 | } 117 | -------------------------------------------------------------------------------- /packages/cloudflare-worker/src/utils.ts: -------------------------------------------------------------------------------- 1 | export function normalize(str: string): string { 2 | return str.toLowerCase().split(/[ _]+/g).join("_"); 3 | } 4 | 5 | export function booleanize(value: string | boolean): boolean { 6 | if (typeof value === "boolean") return value; 7 | 8 | const F = [ 9 | "false", 10 | "null", 11 | "0", 12 | "undefined", 13 | "no", 14 | "none", 15 | "off", 16 | "disable", 17 | "disabled", 18 | "nan", 19 | "", 20 | ]; 21 | return !F.includes(value.toLowerCase()); 22 | } 23 | -------------------------------------------------------------------------------- /packages/cloudflare-worker/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | /* Visit https://aka.ms/tsconfig.json to read more about this file */ 4 | 5 | /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */ 6 | "target": "es2021", 7 | /* Specify a set of bundled library declaration files that describe the target runtime environment. */ 8 | "lib": ["es2021"], 9 | /* Specify what JSX code is generated. */ 10 | "jsx": "react-jsx", 11 | 12 | /* Specify what module code is generated. */ 13 | "module": "es2022", 14 | /* Specify how TypeScript looks up a file from a given module specifier. */ 15 | "moduleResolution": "Bundler", 16 | /* Specify type package names to be included without being referenced in a source file. */ 17 | "types": ["@cloudflare/workers-types/2023-07-01"], 18 | /* Enable importing .json files */ 19 | "resolveJsonModule": true, 20 | 21 | /* Allow JavaScript files to be a part of your program. Use the `checkJS` option to get errors from these files. */ 22 | "allowJs": true, 23 | /* Enable error reporting in type-checked JavaScript files. */ 24 | "checkJs": false, 25 | 26 | /* Disable emitting files from a compilation. */ 27 | "noEmit": true, 28 | 29 | /* Ensure that each file can be safely transpiled without relying on other imports. */ 30 | "isolatedModules": true, 31 | /* Allow 'import x from y' when a module doesn't have a default export. */ 32 | "allowSyntheticDefaultImports": true, 33 | /* Ensure that casing is correct in imports. */ 34 | "forceConsistentCasingInFileNames": true, 35 | 36 | /* Enable all strict type-checking options. */ 37 | "strict": true, 38 | 39 | /* Skip type checking all .d.ts files. */ 40 | "skipLibCheck": true 41 | }, 42 | "exclude": ["test"], 43 | "include": ["worker-configuration.d.ts", "src/**/*.ts"] 44 | } 45 | -------------------------------------------------------------------------------- /packages/cloudflare-worker/vitest.config.mts: -------------------------------------------------------------------------------- 1 | import { defineWorkersConfig } from "@cloudflare/vitest-pool-workers/config"; 2 | 3 | export default defineWorkersConfig({ 4 | test: { 5 | poolOptions: { 6 | workers: { 7 | wrangler: { configPath: "./wrangler.toml" }, 8 | }, 9 | }, 10 | }, 11 | }); 12 | -------------------------------------------------------------------------------- /packages/cloudflare-worker/worker-configuration.d.ts: -------------------------------------------------------------------------------- 1 | // Generated by Wrangler 2 | // After adding bindings to `wrangler.toml`, regenerate this interface via `npm run cf-typegen` 3 | interface Env {} 4 | -------------------------------------------------------------------------------- /packages/cloudflare-worker/wrangler.toml: -------------------------------------------------------------------------------- 1 | #:schema node_modules/wrangler/config-schema.json 2 | name = "leetcode" 3 | main = "src/index.ts" 4 | compatibility_date = "2024-12-05" 5 | compatibility_flags = ["nodejs_compat"] 6 | 7 | workers_dev = true 8 | 9 | [limits] 10 | cpu_ms = 50 11 | 12 | # Workers Logs 13 | # Docs: https://developers.cloudflare.com/workers/observability/logs/workers-logs/ 14 | # Configuration: https://developers.cloudflare.com/workers/observability/logs/workers-logs/#enable-workers-logs 15 | [observability] 16 | enabled = true 17 | 18 | # Automatically place your workloads in an optimal location to minimize latency. 19 | # If you are running back-end logic in a Worker, running it closer to your back-end infrastructure 20 | # rather than the end user may result in better performance. 21 | # Docs: https://developers.cloudflare.com/workers/configuration/smart-placement/#smart-placement 22 | # [placement] 23 | # mode = "smart" 24 | 25 | # Variable bindings. These are arbitrary, plaintext strings (similar to environment variables) 26 | # Docs: 27 | # - https://developers.cloudflare.com/workers/wrangler/configuration/#environment-variables 28 | # Note: Use secrets to store sensitive data. 29 | # - https://developers.cloudflare.com/workers/configuration/secrets/ 30 | # [vars] 31 | # MY_VARIABLE = "production_value" 32 | 33 | # Bind the Workers AI model catalog. Run machine learning models, powered by serverless GPUs, on Cloudflare’s global network 34 | # Docs: https://developers.cloudflare.com/workers/wrangler/configuration/#workers-ai 35 | # [ai] 36 | # binding = "AI" 37 | 38 | # Bind an Analytics Engine dataset. Use Analytics Engine to write analytics within your Pages Function. 39 | # Docs: https://developers.cloudflare.com/workers/wrangler/configuration/#analytics-engine-datasets 40 | # [[analytics_engine_datasets]] 41 | # binding = "MY_DATASET" 42 | 43 | # Bind a headless browser instance running on Cloudflare's global network. 44 | # Docs: https://developers.cloudflare.com/workers/wrangler/configuration/#browser-rendering 45 | # [browser] 46 | # binding = "MY_BROWSER" 47 | 48 | # Bind a D1 database. D1 is Cloudflare’s native serverless SQL database. 49 | # Docs: https://developers.cloudflare.com/workers/wrangler/configuration/#d1-databases 50 | # [[d1_databases]] 51 | # binding = "MY_DB" 52 | # database_name = "my-database" 53 | # database_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" 54 | 55 | # Bind a dispatch namespace. Use Workers for Platforms to deploy serverless functions programmatically on behalf of your customers. 56 | # Docs: https://developers.cloudflare.com/workers/wrangler/configuration/#dispatch-namespace-bindings-workers-for-platforms 57 | # [[dispatch_namespaces]] 58 | # binding = "MY_DISPATCHER" 59 | # namespace = "my-namespace" 60 | 61 | # Bind a Durable Object. Durable objects are a scale-to-zero compute primitive based on the actor model. 62 | # Durable Objects can live for as long as needed. Use these when you need a long-running "server", such as in realtime apps. 63 | # Docs: https://developers.cloudflare.com/workers/wrangler/configuration/#durable-objects 64 | # [[durable_objects.bindings]] 65 | # name = "MY_DURABLE_OBJECT" 66 | # class_name = "MyDurableObject" 67 | 68 | # Durable Object migrations. 69 | # Docs: https://developers.cloudflare.com/workers/wrangler/configuration/#migrations 70 | # [[migrations]] 71 | # tag = "v1" 72 | # new_classes = ["MyDurableObject"] 73 | 74 | # Bind a Hyperdrive configuration. Use to accelerate access to your existing databases from Cloudflare Workers. 75 | # Docs: https://developers.cloudflare.com/workers/wrangler/configuration/#hyperdrive 76 | # [[hyperdrive]] 77 | # binding = "MY_HYPERDRIVE" 78 | # id = "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" 79 | 80 | # Bind a KV Namespace. Use KV as persistent storage for small key-value pairs. 81 | # Docs: https://developers.cloudflare.com/workers/wrangler/configuration/#kv-namespaces 82 | # [[kv_namespaces]] 83 | # binding = "MY_KV_NAMESPACE" 84 | # id = "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" 85 | 86 | # Bind an mTLS certificate. Use to present a client certificate when communicating with another service. 87 | # Docs: https://developers.cloudflare.com/workers/wrangler/configuration/#mtls-certificates 88 | # [[mtls_certificates]] 89 | # binding = "MY_CERTIFICATE" 90 | # certificate_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" 91 | 92 | # Bind a Queue producer. Use this binding to schedule an arbitrary task that may be processed later by a Queue consumer. 93 | # Docs: https://developers.cloudflare.com/workers/wrangler/configuration/#queues 94 | # [[queues.producers]] 95 | # binding = "MY_QUEUE" 96 | # queue = "my-queue" 97 | 98 | # Bind a Queue consumer. Queue Consumers can retrieve tasks scheduled by Producers to act on them. 99 | # Docs: https://developers.cloudflare.com/workers/wrangler/configuration/#queues 100 | # [[queues.consumers]] 101 | # queue = "my-queue" 102 | 103 | # Bind an R2 Bucket. Use R2 to store arbitrarily large blobs of data, such as files. 104 | # Docs: https://developers.cloudflare.com/workers/wrangler/configuration/#r2-buckets 105 | # [[r2_buckets]] 106 | # binding = "MY_BUCKET" 107 | # bucket_name = "my-bucket" 108 | 109 | # Bind another Worker service. Use this binding to call another Worker without network overhead. 110 | # Docs: https://developers.cloudflare.com/workers/wrangler/configuration/#service-bindings 111 | # [[services]] 112 | # binding = "MY_SERVICE" 113 | # service = "my-service" 114 | 115 | # Bind a Vectorize index. Use to store and query vector embeddings for semantic search, classification and other vector search use-cases. 116 | # Docs: https://developers.cloudflare.com/workers/wrangler/configuration/#vectorize-indexes 117 | # [[vectorize]] 118 | # binding = "MY_INDEX" 119 | # index_name = "my-index" 120 | -------------------------------------------------------------------------------- /packages/core/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "leetcode-card", 3 | "version": "1.0.1", 4 | "description": "Show your dynamically generated LeetCode stats on your GitHub profile or your website!", 5 | "license": "MIT", 6 | "type": "module", 7 | "author": { 8 | "name": "JacobLinCool", 9 | "email": "jacoblincool@gmail.com" 10 | }, 11 | "keywords": [ 12 | "leetcode", 13 | "stats", 14 | "card" 15 | ], 16 | "main": "dist/index.js", 17 | "types": "dist/index.d.ts", 18 | "exports": { 19 | ".": { 20 | "types": "./dist/index.d.ts", 21 | "import": "./dist/index.js" 22 | } 23 | }, 24 | "files": [ 25 | "dist" 26 | ], 27 | "dependencies": { 28 | "leetcode-query": "1.2.3", 29 | "nano-font": "0.3.1" 30 | }, 31 | "devDependencies": { 32 | "@cloudflare/workers-types": "^4.20241205.0", 33 | "tsup": "8.0.2", 34 | "typescript": "^5.7.2" 35 | }, 36 | "scripts": { 37 | "build": "tsup" 38 | }, 39 | "repository": { 40 | "type": "git", 41 | "url": "https://github.com/JacobLinCool/LeetCode-Stats-Card.git" 42 | }, 43 | "bugs": { 44 | "url": "https://github.com/JacobLinCool/LeetCode-Stats-Card/issues" 45 | }, 46 | "homepage": "https://github.com/JacobLinCool/LeetCode-Stats-Card#readme" 47 | } 48 | -------------------------------------------------------------------------------- /packages/core/src/_test/elements.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, test } from "vitest"; 2 | import { Icon, Ranking, Solved, TotalSolved, Username } from "../elements"; 3 | 4 | describe("elements", () => { 5 | test("Icon", () => { 6 | const icon = Icon(); 7 | expect(icon.stringify().length).toBeGreaterThan(100); 8 | }); 9 | 10 | test("Username", () => { 11 | const username = Username("username", "us"); 12 | expect(username.stringify().length).toBeGreaterThan(100); 13 | }); 14 | 15 | test("Ranking", () => { 16 | const ranking = Ranking(1234); 17 | expect(ranking.stringify().length).toBeGreaterThan(10); 18 | }); 19 | 20 | test("TotalSolved", () => { 21 | const total_solved = TotalSolved(1234, 123); 22 | expect(total_solved.stringify().length).toBeGreaterThan(100); 23 | }); 24 | 25 | test("Solved", () => { 26 | const solved = Solved({ 27 | easy: { solved: 100, total: 200 }, 28 | medium: { solved: 100, total: 200 }, 29 | hard: { solved: 100, total: 200 }, 30 | ranking: 0, 31 | }); 32 | expect(solved.stringify().length).toBeGreaterThan(100); 33 | }); 34 | }); 35 | -------------------------------------------------------------------------------- /packages/core/src/_test/index.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, test } from "vitest"; 2 | import generate from "../"; 3 | import { ActivityExtension } from "../exts/activity"; 4 | import { AnimationExtension } from "../exts/animation"; 5 | import { FontExtension } from "../exts/font"; 6 | import { ThemeExtension } from "../exts/theme"; 7 | 8 | describe("generate", () => { 9 | test("should work (us)", async () => { 10 | const svg = await generate({ 11 | username: "jacoblincool", 12 | site: "us", 13 | width: 500, 14 | height: 200, 15 | css: [], 16 | extensions: [], 17 | }); 18 | expect(svg.length).toBeGreaterThan(1000); 19 | }); 20 | 21 | test("should work (cn)", async () => { 22 | const svg = await generate({ 23 | username: "leetcode", 24 | site: "cn", 25 | width: 500, 26 | height: 200, 27 | css: [], 28 | extensions: [], 29 | }); 30 | expect(svg.length).toBeGreaterThan(1000); 31 | }); 32 | 33 | test("should work (not found)", async () => { 34 | const svg = await generate({ 35 | username: "random-" + Math.random().toString(36).substring(2, 7), 36 | }); 37 | expect(svg.length).toBeGreaterThan(1000); 38 | expect(svg.includes("User Not Found")).toBeTruthy(); 39 | }); 40 | 41 | test("should work with extensions (us)", async () => { 42 | const svg = await generate({ 43 | username: "jacoblincool", 44 | theme: "nord", 45 | font: "Source_Code_Pro", 46 | extensions: [FontExtension, AnimationExtension, ThemeExtension, ActivityExtension], 47 | }); 48 | expect(svg.length).toBeGreaterThan(1000); 49 | expect(svg.includes("#2e3440")).toBeTruthy(); 50 | expect(svg.includes("Source Code Pro")).toBeTruthy(); 51 | }); 52 | }); 53 | -------------------------------------------------------------------------------- /packages/core/src/_test/query.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, test } from "vitest"; 2 | import query from "../query"; 3 | import { FetchedData } from "../types"; 4 | 5 | describe("query", () => { 6 | test("should match types (us)", async () => { 7 | const data = await query.us("jacoblincool"); 8 | test_types(data); 9 | }); 10 | 11 | test("should match types (cn)", async () => { 12 | const data = await query.cn("leetcode"); 13 | test_types(data); 14 | }); 15 | }); 16 | 17 | function test_types(data: FetchedData) { 18 | expect(typeof data.profile.about).toBe("string"); 19 | expect(typeof data.profile.avatar).toBe("string"); 20 | expect(typeof data.profile.country).toBe("string"); 21 | expect(typeof data.profile.realname).toBe("string"); 22 | for (const skill of data.profile.skills) { 23 | expect(typeof skill).toBe("string"); 24 | } 25 | 26 | (["easy", "medium", "hard"] as const).forEach((difficulty) => { 27 | expect(typeof data.problem[difficulty].solved).toBe("number"); 28 | expect(typeof data.problem[difficulty].total).toBe("number"); 29 | }); 30 | expect(typeof data.problem.ranking).toBe("number"); 31 | 32 | for (const submission of data.submissions) { 33 | expect(typeof submission.id).toBe("string"); 34 | expect(typeof submission.slug).toBe("string"); 35 | expect(typeof submission.status).toBe("string"); 36 | expect(typeof submission.title).toBe("string"); 37 | expect(typeof submission.lang).toBe("string"); 38 | expect(submission.time).toBeGreaterThan(0); 39 | } 40 | 41 | if (data.contest) { 42 | expect(typeof data.contest.rating).toBe("number"); 43 | expect(typeof data.contest.ranking).toBe("number"); 44 | expect(typeof data.contest.badge).toBe("string"); 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /packages/core/src/card.ts: -------------------------------------------------------------------------------- 1 | import { Icon, Ranking, Root, Solved, TotalSolved, Username } from "./elements"; 2 | import { Item } from "./item"; 3 | import query from "./query"; 4 | import { Config, Extension, FetchedData } from "./types"; 5 | 6 | export class Generator { 7 | public verbose = false; 8 | public config: Config = { 9 | username: "jacoblincool", 10 | site: "us", 11 | width: 500, 12 | height: 200, 13 | css: [], 14 | extensions: [], 15 | }; 16 | public cache?: Cache; 17 | public headers: Record; 18 | public fetches: Record> = {}; 19 | 20 | constructor(cache?: Cache, headers?: Record) { 21 | this.cache = cache; 22 | this.headers = headers ?? {}; 23 | } 24 | 25 | async generate(config: Config): Promise { 26 | const start_time = Date.now(); 27 | this.log("generating card for", config.username, config.site); 28 | 29 | this.config = config; 30 | 31 | const extensions = 32 | this.config.extensions.map(async (init) => { 33 | const start = Date.now(); 34 | const ext = await init(this); 35 | this.log(`extension "${ext.name}" initialized in ${Date.now() - start} ms`); 36 | return ext; 37 | }) ?? []; 38 | const data = (async () => { 39 | const start = Date.now(); 40 | const data = await this.fetch(config.username, config.site, this.headers); 41 | this.log(`user data fetched in ${Date.now() - start} ms`, data.profile); 42 | return data; 43 | })(); 44 | const body = this.body(); 45 | 46 | const result = await this.hydrate(await data, body, await Promise.all(extensions)); 47 | this.log(`card generated in ${Date.now() - start_time} ms`); 48 | return result; 49 | } 50 | 51 | protected async fetch( 52 | username: string, 53 | site: "us" | "cn", 54 | headers: Record, 55 | ): Promise { 56 | this.log("fetching", username, site); 57 | const cache_key = `https://leetcode-stats-card.local/data-${username.toLowerCase()}-${site}`; 58 | console.log("cache_key", cache_key); 59 | 60 | if (cache_key in this.fetches) { 61 | return this.fetches[cache_key]; 62 | } 63 | this.fetches[cache_key] = this._fetch(username, site, headers, cache_key); 64 | this.fetches[cache_key].finally(() => { 65 | delete this.fetches[cache_key]; 66 | }); 67 | return this.fetches[cache_key]; 68 | } 69 | 70 | protected async _fetch( 71 | username: string, 72 | site: "us" | "cn", 73 | headers: Record, 74 | cache_key: string, 75 | ): Promise { 76 | this.log("fetching", username, site); 77 | const cached = await this.cache?.match(cache_key); 78 | if (cached) { 79 | this.log("fetch cache hit"); 80 | return cached.json(); 81 | } else { 82 | this.log("fetch cache miss"); 83 | } 84 | 85 | try { 86 | if (site === "us") { 87 | const data = await query.us(username, headers); 88 | await this.cache 89 | ?.put( 90 | cache_key, 91 | new Response(JSON.stringify(data), { 92 | headers: { "cache-control": "max-age=300" }, 93 | }), 94 | ) 95 | .catch(console.error); 96 | return data; 97 | } else { 98 | const data = await query.cn(username, headers); 99 | await this.cache 100 | ?.put( 101 | cache_key, 102 | new Response(JSON.stringify(data), { 103 | headers: { "cache-control": "max-age=300" }, 104 | }), 105 | ) 106 | .catch(console.error); 107 | return data; 108 | } 109 | } catch (err) { 110 | console.error(err); 111 | const message = (err as Error).message; 112 | return { 113 | profile: { 114 | username: message.slice(0, 32), 115 | realname: "", 116 | about: "", 117 | avatar: "", 118 | skills: [], 119 | country: "", 120 | }, 121 | problem: { 122 | easy: { 123 | solved: Math.round(Math.random() * 500), 124 | total: 500 + Math.round(Math.random() * 500), 125 | }, 126 | medium: { 127 | solved: Math.round(Math.random() * 500), 128 | total: 500 + Math.round(Math.random() * 500), 129 | }, 130 | hard: { 131 | solved: Math.round(Math.random() * 500), 132 | total: 500 + Math.round(Math.random() * 500), 133 | }, 134 | ranking: 0, 135 | }, 136 | submissions: [ 137 | { 138 | title: "", 139 | time: 0, 140 | status: "System Error", 141 | lang: "JavaScript", 142 | slug: "", 143 | id: "", 144 | }, 145 | ], 146 | }; 147 | } 148 | } 149 | 150 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 151 | protected body(): Record Item> { 152 | const icon = Icon; 153 | const username = Username; 154 | const ranking = Ranking; 155 | const total_solved = TotalSolved; 156 | const solved = Solved; 157 | 158 | return { icon, username, ranking, total_solved, solved }; 159 | } 160 | 161 | protected async hydrate( 162 | data: FetchedData, 163 | body: Record Item>, 164 | extensions: Extension[], 165 | ): Promise { 166 | this.log("hydrating"); 167 | const ext_styles: string[] = []; 168 | 169 | for (const extension of extensions) { 170 | try { 171 | const start = Date.now(); 172 | await extension(this, data, body, ext_styles); 173 | this.log(`extension "${extension.name}" hydrated in ${Date.now() - start} ms`); 174 | } catch (err) { 175 | this.log(`extension "${extension.name}" failed`, err); 176 | } 177 | } 178 | 179 | const root = Root(this.config, data); 180 | if (!root.children) { 181 | root.children = []; 182 | } 183 | root.children.push(body.icon()); 184 | delete body.icon; 185 | root.children.push(body.username(data.profile.username, this.config.site)); 186 | delete body.username; 187 | root.children.push(body.ranking(data.problem.ranking)); 188 | delete body.ranking; 189 | const [total, solved] = (["easy", "medium", "hard"] as const).reduce( 190 | (acc, level) => [ 191 | acc[0] + data.problem[level].total, 192 | acc[1] + data.problem[level].solved, 193 | ], 194 | [0, 0], 195 | ); 196 | root.children.push(body.total_solved(total, solved)); 197 | delete body.total_solved; 198 | root.children.push(body.solved(data.problem)); 199 | delete body.solved; 200 | 201 | Object.values(body).forEach((item) => { 202 | root.children?.push(item()); 203 | }); 204 | 205 | const styles = [`@namespace svg url(http://www.w3.org/2000/svg);`, root.css()]; 206 | styles.push(...ext_styles); 207 | styles.push(`svg{opacity:1}`); 208 | if (this.config?.css) { 209 | styles.push(...this.config.css); 210 | } 211 | 212 | root.children.push(new Item("style", { content: styles.join("\n") })); 213 | 214 | return root.stringify(); 215 | } 216 | 217 | public log(...args: unknown[]): void { 218 | if (this.verbose) { 219 | console.log(...args); 220 | } 221 | } 222 | } 223 | -------------------------------------------------------------------------------- /packages/core/src/constants.ts: -------------------------------------------------------------------------------- 1 | export const CN_LANGS_MAP: Record = { 2 | A_0: "C++", 3 | A_1: "Java", 4 | A_2: "Python", 5 | A_3: "MySQL", 6 | A_4: "C", 7 | A_5: "C#", 8 | A_6: "JavaScript", 9 | A_7: "Ruby", 10 | A_8: "Bash", 11 | A_9: "Swift", 12 | A_10: "Go", 13 | A_11: "Python3", 14 | A_12: "Scala", 15 | A_13: "Kotlin", 16 | A_14: "MS SQL Server", 17 | A_15: "Oracle", 18 | A_16: "HTML", 19 | A_17: "Python ML", 20 | A_18: "Rust", 21 | A_19: "PHP", 22 | A_20: "TypeScript", 23 | A_21: "Racket", 24 | A_22: "Erlang", 25 | A_23: "Elixir", 26 | }; 27 | 28 | export const CN_RESULTS_MAP: Record = { 29 | A_10: "Accepted", 30 | A_11: "Wrong Answer", 31 | A_12: "Memory Limit Exceeded", 32 | A_13: "Output Limit Exceeded", 33 | A_14: "Time Limit Exceeded", 34 | A_15: "Runtime Error", 35 | A_16: "System Error", 36 | A_20: "Compile Error", 37 | A_30: "Timeout", 38 | }; 39 | -------------------------------------------------------------------------------- /packages/core/src/elements.ts: -------------------------------------------------------------------------------- 1 | import { Item, svg_attrs } from "./item"; 2 | import { Config, FetchedData } from "./types"; 3 | 4 | export function Root(config: Config, data: FetchedData) { 5 | return new Item("svg", { 6 | id: "root", 7 | attr: { 8 | width: config.width, 9 | height: config.height, 10 | viewBox: `0 0 ${config.width} ${config.height}`, 11 | ...svg_attrs, 12 | }, 13 | style: { fill: "none" }, 14 | children: [ 15 | new Item("title", { 16 | content: `${data?.profile.username || config.username} | LeetCode Stats Card`, 17 | }), 18 | new Item("style", { 19 | id: "default-colors", 20 | content: `svg{opacity:0}:root{--bg-0:#fff;--bg-1:#e5e5e5;--bg-2:#d3d3d3;--bg-3:#d3d3d3;--text-0:#000;--text-1:#808080;--text-2:#808080;--text-3:#808080;--color-0:#ffa116;--color-1:#5cb85c;--color-2:#f0ad4e;--color-3:#d9534f}`, 21 | }), 22 | new Item("rect", { 23 | id: "background", 24 | style: { 25 | transform: "translate(0.5px, 0.5px)", 26 | stroke: "var(--bg-2)", 27 | fill: "var(--bg-0)", 28 | "stroke-width": 1, 29 | width: config.width - 1 + "px", 30 | height: config.height - 1 + "px", 31 | rx: "4px", 32 | }, 33 | }), 34 | ], 35 | }); 36 | } 37 | 38 | export function Icon() { 39 | const item = new Item("g", { 40 | id: "icon", 41 | style: { 42 | transform: "translate(20px, 15px) scale(0.27)", 43 | }, 44 | }); 45 | 46 | item.children = [ 47 | new Item("g", { 48 | style: { 49 | stroke: "none", 50 | fill: "var(--text-0)", 51 | "fill-rule": "evenodd", 52 | }, 53 | children: [ 54 | new Item("path", { 55 | id: "C", 56 | attr: { 57 | d: "M67.506,83.066 C70.000,80.576 74.037,80.582 76.522,83.080 C79.008,85.578 79.002,89.622 76.508,92.112 L65.435,103.169 C55.219,113.370 38.560,113.518 28.172,103.513 C28.112,103.455 23.486,98.920 8.227,83.957 C-1.924,74.002 -2.936,58.074 6.616,47.846 L24.428,28.774 C33.910,18.621 51.387,17.512 62.227,26.278 L78.405,39.362 C81.144,41.577 81.572,45.598 79.361,48.342 C77.149,51.087 73.135,51.515 70.395,49.300 L54.218,36.217 C48.549,31.632 38.631,32.262 33.739,37.500 L15.927,56.572 C11.277,61.552 11.786,69.574 17.146,74.829 C28.351,85.816 36.987,94.284 36.997,94.294 C42.398,99.495 51.130,99.418 56.433,94.123 L67.506,83.066 Z", 58 | }, 59 | style: { 60 | fill: "#FFA116", 61 | "fill-rule": "nonzero", 62 | }, 63 | }), 64 | new Item("path", { 65 | id: "L", 66 | attr: { 67 | d: "M49.412,2.023 C51.817,-0.552 55.852,-0.686 58.423,1.722 C60.994,4.132 61.128,8.173 58.723,10.749 L15.928,56.572 C11.277,61.551 11.786,69.573 17.145,74.829 L36.909,94.209 C39.425,96.676 39.468,100.719 37.005,103.240 C34.542,105.760 30.506,105.804 27.990,103.336 L8.226,83.956 C-1.924,74.002 -2.936,58.074 6.617,47.846 L49.412,2.023 Z", 68 | }, 69 | style: { 70 | fill: "#000000", 71 | }, 72 | }), 73 | new Item("path", { 74 | id: "dash", 75 | attr: { 76 | d: "M40.606,72.001 C37.086,72.001 34.231,69.142 34.231,65.614 C34.231,62.087 37.086,59.228 40.606,59.228 L87.624,59.228 C91.145,59.228 94,62.087 94,65.614 C94,69.142 91.145,72.001 87.624,72.001 L40.606,72.001 Z", 77 | }, 78 | style: { 79 | fill: "#B3B3B3", 80 | }, 81 | }), 82 | ], 83 | }), 84 | ]; 85 | 86 | return item; 87 | } 88 | 89 | export function Username(username: string, site: string) { 90 | const item = new Item("a", { 91 | id: "username", 92 | attr: { 93 | href: 94 | username === "User Not Found" 95 | ? "https://github.com/JacobLinCool/LeetCode-Stats-Card" 96 | : `https://leetcode.${site === "us" ? "com" : "cn"}/${username}/`, 97 | target: "_blank", 98 | }, 99 | style: { 100 | transform: "translate(65px, 40px)", 101 | }, 102 | children: [ 103 | new Item("text", { 104 | id: "username-text", 105 | content: username, 106 | style: { 107 | fill: "var(--text-0)", 108 | "font-size": "24px", 109 | "font-weight": "bold", 110 | }, 111 | }), 112 | ], 113 | }); 114 | 115 | return item; 116 | } 117 | 118 | export function Ranking(ranking: number) { 119 | const item = new Item("text", { 120 | id: "ranking", 121 | content: "#" + ranking.toString(), 122 | style: { 123 | transform: "translate(480px, 40px)", 124 | fill: "var(--text-1)", 125 | "font-size": "18px", 126 | "font-weight": "bold", 127 | "text-anchor": "end", 128 | }, 129 | }); 130 | 131 | return item; 132 | } 133 | 134 | export function TotalSolved(total: number, solved: number) { 135 | return new Item("g", { 136 | id: "total-solved", 137 | style: { 138 | transform: "translate(30px, 85px)", 139 | }, 140 | children: [ 141 | new Item("circle", { 142 | id: "total-solved-bg", 143 | style: { 144 | cx: "40px", 145 | cy: "40px", 146 | r: "40px", 147 | stroke: "var(--bg-1)", 148 | "stroke-width": "6px", 149 | }, 150 | }), 151 | new Item("circle", { 152 | id: "total-solved-ring", 153 | style: { 154 | cx: "40px", 155 | cy: "40px", 156 | r: "40px", 157 | transform: "rotate(-90deg)", 158 | "transform-origin": "40px 40px", 159 | "stroke-dasharray": `${(80 * Math.PI * solved) / total} 10000`, 160 | stroke: "var(--color-0)", 161 | "stroke-width": "6px", 162 | "stroke-linecap": "round", 163 | }, 164 | }), 165 | new Item("text", { 166 | content: solved.toString(), 167 | id: "total-solved-text", 168 | style: { 169 | transform: "translate(40px, 40px)", 170 | "font-size": "28px", 171 | "alignment-baseline": "central", 172 | "dominant-baseline": "central", 173 | "text-anchor": "middle", 174 | fill: "var(--text-0)", 175 | "font-weight": "bold", 176 | }, 177 | }), 178 | ], 179 | }); 180 | } 181 | 182 | export function Solved(problem: FetchedData["problem"]) { 183 | const group = new Item("g", { 184 | id: "solved", 185 | style: { 186 | transform: "translate(160px, 80px)", 187 | }, 188 | }); 189 | 190 | const difficulties = ["easy", "medium", "hard"] as const; 191 | const colors = ["var(--color-1)", "var(--color-2)", "var(--color-3)"] as const; 192 | for (let i = 0; i < difficulties.length; i++) { 193 | group.children?.push( 194 | new Item("g", { 195 | id: `${difficulties[i]}-solved`, 196 | style: { 197 | transform: `translate(0, ${i * 40}px)`, 198 | }, 199 | children: [ 200 | new Item("text", { 201 | id: `${difficulties[i]}-solved-type`, 202 | style: { 203 | fill: "var(--text-0)", 204 | "font-size": "18px", 205 | "font-weight": "bold", 206 | }, 207 | content: difficulties[i][0].toUpperCase() + difficulties[i].slice(1), 208 | }), 209 | new Item("text", { 210 | id: `${difficulties[i]}-solved-count`, 211 | style: { 212 | transform: "translate(300px, 0px)", 213 | fill: "var(--text-1)", 214 | "font-size": "16px", 215 | "font-weight": "bold", 216 | "text-anchor": "end", 217 | }, 218 | content: `${problem[difficulties[i]].solved} / ${ 219 | problem[difficulties[i]].total 220 | }`, 221 | }), 222 | new Item("line", { 223 | id: `${difficulties[i]}-solved-bg`, 224 | attr: { x1: 0, y1: 10, x2: 300, y2: 10 }, 225 | style: { 226 | stroke: "var(--bg-1)", 227 | "stroke-width": "4px", 228 | "stroke-linecap": "round", 229 | }, 230 | }), 231 | new Item("line", { 232 | id: `${difficulties[i]}-solved-progress`, 233 | attr: { x1: 0, y1: 10, x2: 300, y2: 10 }, 234 | style: { 235 | stroke: colors[i], 236 | "stroke-width": "4px", 237 | "stroke-dasharray": `${ 238 | 300 * 239 | (problem[difficulties[i]].solved / problem[difficulties[i]].total) 240 | } 10000`, 241 | "stroke-linecap": "round", 242 | }, 243 | }), 244 | ], 245 | }), 246 | ); 247 | } 248 | 249 | return group; 250 | } 251 | 252 | export const selectors = [ 253 | "#root", 254 | "#background", 255 | "#icon", 256 | "#L", 257 | "#C", 258 | "#dash", 259 | "#username", 260 | "#username-text", 261 | "#ranking", 262 | "#total-solved", 263 | "#total-solved-bg", 264 | "#total-solved-ring", 265 | "#total-solved-text", 266 | "#solved", 267 | "#easy-solved", 268 | "#easy-solved-type", 269 | "#easy-solved-count", 270 | "#easy-solved-bg", 271 | "#easy-solved-progress", 272 | "#medium-solved", 273 | "#medium-solved-type", 274 | "#medium-solved-count", 275 | "#medium-solved-bg", 276 | "#medium-solved-progress", 277 | "#hard-solved", 278 | "#hard-solved-type", 279 | "#hard-solved-count", 280 | "#hard-solved-bg", 281 | "#hard-solved-progress", 282 | ] as const; 283 | 284 | export function Gradient(id: string, colors: Record, ratio = 0) { 285 | return new Item("linearGradient", { 286 | id, 287 | attr: { 288 | x1: 0, 289 | y1: 0, 290 | x2: Math.round(Math.cos(ratio) * 100) / 100, 291 | y2: Math.round(Math.sin(ratio) * 100) / 100, 292 | }, 293 | children: Object.entries(colors) 294 | .sort((a, b) => a[0].localeCompare(b[0])) 295 | .map(([offset, color]) => { 296 | return new Item("stop", { attr: { offset, "stop-color": color } }); 297 | }), 298 | }); 299 | } 300 | -------------------------------------------------------------------------------- /packages/core/src/exts/activity.ts: -------------------------------------------------------------------------------- 1 | import { Gradient } from "../elements"; 2 | import { Item } from "../item"; 3 | import { Extension } from "../types"; 4 | 5 | const statuses: Record = { 6 | Accepted: "AC", 7 | "Wrong Answer": "WA", 8 | "Time Limit Exceeded": "TLE", 9 | "Memory Limit Exceeded": "MLE", 10 | "Output Limit Exceeded": "OLE", 11 | "Runtime Error": "RE", 12 | "Compile Error": "CE", 13 | "System Error": "SE", 14 | }; 15 | 16 | const langs: Record = { 17 | cpp: "C++", 18 | java: "Java", 19 | python: "Python", 20 | python3: "Python", 21 | mysql: "MySQL", 22 | c: "C", 23 | csharp: "C#", 24 | javascript: "JavaScript", 25 | ruby: "Ruby", 26 | bash: "Bash", 27 | swift: "Swift", 28 | golang: "Go", 29 | scala: "Scala", 30 | kotlin: "Kotlin", 31 | rust: "Rust", 32 | php: "PHP", 33 | typescript: "TypeScript", 34 | racket: "Racket", 35 | erlang: "Erlang", 36 | elixir: "Elixir", 37 | }; 38 | 39 | export function ActivityExtension(): Extension { 40 | return async function Activity(generator, data, body) { 41 | if (generator.config.height < 400) { 42 | generator.config.height = 400; 43 | } 44 | 45 | const submissions = data.submissions.slice(0, 5); 46 | 47 | const extension = new Item("g", { 48 | id: "ext-activity", 49 | style: { transform: `translate(0px, 200px)` }, 50 | children: [ 51 | new Item("line", { 52 | attr: { x1: 10, y1: 0, x2: generator.config.width - 10, y2: 0 }, 53 | style: { stroke: "var(--bg-1)", "stroke-width": 1 }, 54 | }), 55 | new Item("text", { 56 | content: "Recent Activities", 57 | id: "ext-activity-title", 58 | style: { 59 | transform: `translate(20px, 20px)`, 60 | fill: "var(--text-0)", 61 | opacity: generator.config.animation !== false ? 0 : 1, 62 | animation: 63 | generator.config.animation !== false 64 | ? "fade_in 1 0.3s 1.7s forwards" 65 | : "", 66 | }, 67 | }), 68 | new Item("defs", { 69 | children: [ 70 | Gradient("ext-activity-mask-gradient", { 71 | 0: "#fff", 72 | 0.85: "#fff", 73 | 1: "#000", 74 | }), 75 | new Item("mask", { 76 | id: "ext-activity-mask", 77 | children: [ 78 | new Item("rect", { 79 | style: { 80 | fill: "url(#ext-activity-mask-gradient)", 81 | width: `${generator.config.width - 225 - 20}px`, 82 | height: "24px", 83 | transform: "translate(0, -14px)", 84 | }, 85 | }), 86 | ], 87 | }), 88 | new Item("clipPath", { 89 | id: "ext-activity-clip", 90 | children: [ 91 | new Item("rect", { 92 | style: { 93 | width: `${generator.config.width - 225 - 20}px`, 94 | height: "24px", 95 | transform: "translate(0, -14px)", 96 | }, 97 | }), 98 | ], 99 | }), 100 | ], 101 | }), 102 | ], 103 | }); 104 | 105 | for (let i = 0; i < submissions.length; i++) { 106 | const status = statuses[submissions[i].status] || "Unknown"; 107 | const time = new Date(submissions[i].time); 108 | 109 | extension.children?.push( 110 | new Item("a", { 111 | id: `ext-activity-item-${i}`, 112 | attr: { 113 | href: `https://leetcode.${ 114 | generator.config.site === "us" ? "com" : "cn" 115 | }/submissions/detail/${submissions[i].id}/`, 116 | target: "_blank", 117 | }, 118 | style: { 119 | transform: `translate(0px, ${i * 32 + 45}px)`, 120 | animation: 121 | generator.config.animation !== false 122 | ? `fade_in 0.3s ease ${(1.8 + 0.1 * i).toFixed(2)}s 1 backwards` 123 | : "", 124 | }, 125 | children: [ 126 | new Item("text", { 127 | content: `${time.getFullYear() % 100}.${ 128 | time.getMonth() + 1 129 | }.${time.getDate()}`, 130 | attr: { 131 | textLength: 56, 132 | }, 133 | style: { 134 | transform: `translate(20px, 0)`, 135 | fill: "var(--text-0)", 136 | "alignment-baseline": "middle", 137 | }, 138 | }), 139 | new Item("rect", { 140 | style: { 141 | transform: `translate(85px, -14px)`, 142 | fill: `var(--color-${status === "AC" ? "1" : "3"})`, 143 | width: "30px", 144 | height: "24px", 145 | rx: 4, 146 | }, 147 | }), 148 | new Item("text", { 149 | content: status, 150 | style: { 151 | transform: `translate(100px, 0)`, 152 | fill: "#fff", 153 | "text-anchor": "middle", 154 | "alignment-baseline": "middle", 155 | }, 156 | }), 157 | new Item("text", { 158 | content: (langs[submissions[i].lang] || submissions[i].lang).slice( 159 | 0, 160 | 12, 161 | ), 162 | style: { 163 | transform: `translate(125px, 0)`, 164 | fill: "var(--text-0)", 165 | "font-weight": "bold", 166 | "alignment-baseline": "middle", 167 | }, 168 | }), 169 | new Item("text", { 170 | content: submissions[i].title, 171 | style: { 172 | "clip-path": "url(#ext-activity-clip)", 173 | transform: `translate(225px, 0)`, 174 | fill: "var(--text-1)", 175 | "alignment-baseline": "middle", 176 | mask: "url(#ext-activity-mask)", 177 | }, 178 | }), 179 | ], 180 | }), 181 | ); 182 | } 183 | 184 | body["ext-activity"] = () => extension; 185 | }; 186 | } 187 | -------------------------------------------------------------------------------- /packages/core/src/exts/animation.ts: -------------------------------------------------------------------------------- 1 | import { selectors } from "../elements"; 2 | import { Extension } from "../types"; 3 | 4 | const keyframe = `@keyframes fade_in{from{opacity:0}to{opacity:1}}`; 5 | 6 | const order: (typeof selectors)[number][] = [ 7 | "#icon", 8 | "#username", 9 | "#ranking", 10 | "#total-solved-bg", 11 | "#total-solved-ring", 12 | "#total-solved-text", 13 | "#easy-solved-type", 14 | "#easy-solved-count", 15 | "#easy-solved-bg", 16 | "#easy-solved-progress", 17 | "#medium-solved-type", 18 | "#medium-solved-count", 19 | "#medium-solved-bg", 20 | "#medium-solved-progress", 21 | "#hard-solved-type", 22 | "#hard-solved-count", 23 | "#hard-solved-bg", 24 | "#hard-solved-progress", 25 | ]; 26 | 27 | export function AnimationExtension(): Extension { 28 | return async function Animation(generator, data, body, styles) { 29 | if (generator.config.animation === false) { 30 | return; 31 | } 32 | 33 | const speed = 1; 34 | 35 | let css = keyframe; 36 | for (let i = 0; i < order.length; i++) { 37 | css += `${order[i]}{opacity:0;animation:fade_in ${0.3 / speed}s ease ${( 38 | 0.1 * i 39 | ).toFixed(2)}s 1 forwards}`; 40 | } 41 | 42 | const [total, solved] = (["easy", "medium", "hard"] as const).reduce( 43 | (acc, level) => [ 44 | acc[0] + data.problem[level].total, 45 | acc[1] + data.problem[level].solved, 46 | ], 47 | [0, 0], 48 | ); 49 | 50 | css += circle("#total-solved-ring", 80 * Math.PI * (solved / total), 0.7); 51 | 52 | styles.push(css); 53 | }; 54 | } 55 | 56 | function circle(selector: string, len = 0, delay = 0) { 57 | const R = Math.floor(Math.random() * 1000); 58 | const animation = `@keyframes circle_${R}{0%{opacity:0;stroke-dasharray:0 1000}50%{opacity:1}100%{opacity:1;stroke-dasharray:${len} 10000}}`; 59 | const style = `${selector}{animation:circle_${R} 1.2s ease ${delay}s 1 forwards}`; 60 | return animation + style; 61 | } 62 | -------------------------------------------------------------------------------- /packages/core/src/exts/contest.ts: -------------------------------------------------------------------------------- 1 | import { ContestInfo, ContestRanking, LeetCode } from "leetcode-query"; 2 | import { Generator } from "../card"; 3 | import { Item } from "../item"; 4 | import { Extension } from "../types"; 5 | 6 | export async function ContestExtension(generator: Generator): Promise { 7 | const pre_result = new Promise( 8 | (resolve) => { 9 | const lc = new LeetCode(); 10 | lc.user_contest_info(generator.config.username) 11 | .then((data) => { 12 | try { 13 | const history = Array.isArray(data.userContestRankingHistory) 14 | ? data.userContestRankingHistory.filter((x) => x.attended) 15 | : []; 16 | 17 | if (history.length === 0) { 18 | resolve(null); 19 | return; 20 | } 21 | 22 | resolve({ ranking: data.userContestRanking, history }); 23 | } catch (e) { 24 | console.error(e); 25 | resolve(null); 26 | } 27 | }) 28 | .catch(() => resolve(null)); 29 | }, 30 | ); 31 | 32 | return async function Contest(generator, data, body) { 33 | const result = await pre_result; 34 | 35 | if (result) { 36 | if (generator.config.height < 400) { 37 | generator.config.height = 400; 38 | } 39 | 40 | const start_time = result.history[0].contest.startTime; 41 | const end_time = result.history[result.history.length - 1].contest.startTime; 42 | const [min_rating, max_rating] = result.history.reduce( 43 | ([min, max], { rating }) => [Math.min(min, rating), Math.max(max, rating)], 44 | [Infinity, -Infinity], 45 | ); 46 | 47 | const width = generator.config.width - 90; 48 | const height = 100; 49 | const x_scale = width / (end_time - start_time); 50 | const y_scale = height / (max_rating - min_rating); 51 | 52 | const points = result.history.map((d) => { 53 | const { rating } = d; 54 | const time = d.contest.startTime; 55 | const x = (time - start_time) * x_scale; 56 | const y = (max_rating - rating) * y_scale; 57 | return [x, y]; 58 | }); 59 | 60 | const extension = new Item("g", { 61 | id: "ext-contest", 62 | style: { transform: `translate(0px, 200px)` }, 63 | children: [ 64 | new Item("line", { 65 | attr: { x1: 10, y1: 0, x2: generator.config.width - 10, y2: 0 }, 66 | style: { stroke: "var(--bg-1)", "stroke-width": 1 }, 67 | }), 68 | new Item("text", { 69 | content: "Contest Rating", 70 | id: "ext-contest-rating-title", 71 | style: { 72 | transform: `translate(20px, 20px)`, 73 | fill: "var(--text-1)", 74 | "font-size": "0.8rem", 75 | opacity: generator.config.animation !== false ? 0 : 1, 76 | animation: 77 | generator.config.animation !== false 78 | ? "fade_in 1 0.3s 1.7s forwards" 79 | : "", 80 | }, 81 | }), 82 | new Item("text", { 83 | content: result.ranking.rating.toFixed(0), 84 | id: "ext-contest-rating", 85 | style: { 86 | transform: `translate(20px, 50px)`, 87 | fill: "var(--text-0)", 88 | "font-size": "2rem", 89 | opacity: generator.config.animation !== false ? 0 : 1, 90 | animation: 91 | generator.config.animation !== false 92 | ? "fade_in 1 0.3s 1.7s forwards" 93 | : "", 94 | }, 95 | }), 96 | new Item("text", { 97 | content: "Highest Rating", 98 | id: "ext-contest-highest-rating-title", 99 | style: { 100 | transform: `translate(160px, 20px)`, 101 | fill: "var(--text-1)", 102 | "font-size": "0.8rem", 103 | opacity: generator.config.animation !== false ? 0 : 1, 104 | animation: 105 | generator.config.animation !== false 106 | ? "fade_in 1 0.3s 1.7s forwards" 107 | : "", 108 | }, 109 | }), 110 | new Item("text", { 111 | content: max_rating.toFixed(0), 112 | id: "ext-contest-highest-rating", 113 | style: { 114 | transform: `translate(160px, 50px)`, 115 | fill: "var(--text-0)", 116 | "font-size": "2rem", 117 | opacity: generator.config.animation !== false ? 0 : 1, 118 | animation: 119 | generator.config.animation !== false 120 | ? "fade_in 1 0.3s 1.7s forwards" 121 | : "", 122 | }, 123 | }), 124 | new Item("text", { 125 | content: 126 | result.ranking.globalRanking + " / " + result.ranking.totalParticipants, 127 | id: "ext-contest-ranking", 128 | style: { 129 | transform: `translate(${generator.config.width - 20}px, 20px)`, 130 | "text-anchor": "end", 131 | fill: "var(--text-1)", 132 | "font-size": "0.8rem", 133 | opacity: generator.config.animation !== false ? 0 : 1, 134 | animation: 135 | generator.config.animation !== false 136 | ? "fade_in 1 0.3s 1.7s forwards" 137 | : "", 138 | }, 139 | }), 140 | new Item("text", { 141 | content: result.ranking.topPercentage.toFixed(2) + "%", 142 | id: "ext-contest-percentage", 143 | style: { 144 | transform: `translate(${generator.config.width - 20}px, 50px)`, 145 | "text-anchor": "end", 146 | fill: "var(--text-0)", 147 | "font-size": "2rem", 148 | opacity: generator.config.animation !== false ? 0 : 1, 149 | animation: 150 | generator.config.animation !== false 151 | ? "fade_in 1 0.3s 1.7s forwards" 152 | : "", 153 | }, 154 | }), 155 | ], 156 | }); 157 | 158 | if (result.ranking.badge) { 159 | const image = 160 | result.ranking.badge.name === "Guardian" 161 | ? guardian_icon() 162 | : result.ranking.badge.name === "Knight" 163 | ? knight_icon() 164 | : ""; 165 | 166 | extension.children?.push( 167 | new Item("image", { 168 | id: "ext-contest-badge", 169 | attr: { 170 | href: image, 171 | }, 172 | style: { 173 | transform: "translate(300px, 10px)", 174 | width: "48px", 175 | height: "48px", 176 | opacity: generator.config.animation !== false ? 0 : 1, 177 | animation: 178 | generator.config.animation !== false 179 | ? "fade_in 1 0.3s 1.7s forwards" 180 | : "", 181 | }, 182 | }), 183 | ); 184 | } 185 | 186 | for ( 187 | let i = Math.ceil(min_rating / 100) * 100; 188 | i < max_rating; 189 | i += max_rating - min_rating < 1000 ? 100 : 200 190 | ) { 191 | const y = (max_rating - i) * y_scale; 192 | const text = new Item("text", { 193 | content: i.toFixed(0), 194 | id: "ext-contest-rating-label-" + i, 195 | style: { 196 | transform: `translate(45px, ${y + 73.5}px)`, 197 | "text-anchor": "end", 198 | fill: "var(--text-2)", 199 | "font-size": "0.7rem", 200 | opacity: generator.config.animation !== false ? 0 : 1, 201 | animation: 202 | generator.config.animation !== false 203 | ? "fade_in 1 0.3s 1.7s forwards" 204 | : "", 205 | }, 206 | }); 207 | const line = new Item("line", { 208 | attr: { x1: 0, y1: y, x2: width + 20, y2: y }, 209 | style: { 210 | stroke: "var(--bg-1)", 211 | "stroke-width": 1, 212 | transform: `translate(50px, 70px)`, 213 | opacity: generator.config.animation !== false ? 0 : 1, 214 | animation: 215 | generator.config.animation !== false 216 | ? "fade_in 1 0.3s 1.7s forwards" 217 | : "", 218 | }, 219 | }); 220 | extension.children?.push(text, line); 221 | } 222 | 223 | const start_date = new Date(start_time * 1000); 224 | const end_date = new Date(end_time * 1000); 225 | 226 | extension.children?.push( 227 | new Item("text", { 228 | content: `${start_date.getFullYear()}/${start_date.getMonth() + 1}`, 229 | id: "ext-contest-start-date", 230 | style: { 231 | transform: `translate(50px, 185px)`, 232 | "text-anchor": "start", 233 | fill: "var(--text-1)", 234 | "font-size": "0.8rem", 235 | opacity: generator.config.animation !== false ? 0 : 1, 236 | animation: 237 | generator.config.animation !== false 238 | ? "fade_in 1 0.3s 1.7s forwards" 239 | : "", 240 | }, 241 | }), 242 | new Item("text", { 243 | content: `${end_date.getFullYear()}/${end_date.getMonth() + 1}`, 244 | id: "ext-contest-end-date", 245 | style: { 246 | transform: `translate(${generator.config.width - 20}px, 185px)`, 247 | "text-anchor": "end", 248 | fill: "var(--text-1)", 249 | "font-size": "0.8rem", 250 | opacity: generator.config.animation !== false ? 0 : 1, 251 | animation: 252 | generator.config.animation !== false 253 | ? "fade_in 1 0.3s 1.7s forwards" 254 | : "", 255 | }, 256 | }), 257 | ); 258 | 259 | extension.children?.push( 260 | new Item("polyline", { 261 | id: "ext-contest-polyline", 262 | attr: { 263 | points: points.map(([x, y]) => `${x},${y}`).join(" "), 264 | }, 265 | style: { 266 | transform: `translate(60px, 70px)`, 267 | stroke: "var(--color-0)", 268 | "stroke-width": 2, 269 | "stroke-linecap": "round", 270 | "stroke-linejoin": "round", 271 | fill: "none", 272 | opacity: generator.config.animation !== false ? 0 : 1, 273 | animation: 274 | generator.config.animation !== false 275 | ? "fade_in 1 0.3s 1.7s forwards" 276 | : "", 277 | }, 278 | }), 279 | ); 280 | 281 | body["ext-contest"] = () => extension; 282 | } 283 | }; 284 | } 285 | 286 | function knight_icon() { 287 | return "data:image/png;base64,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"; 288 | } 289 | 290 | function guardian_icon() { 291 | return "data:image/png;base64,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"; 292 | } 293 | -------------------------------------------------------------------------------- /packages/core/src/exts/font.ts: -------------------------------------------------------------------------------- 1 | import Baloo_2 from "nano-font/fonts/Baloo_2"; 2 | import Milonga from "nano-font/fonts/Milonga"; 3 | import Patrick_Hand from "nano-font/fonts/Patrick_Hand"; 4 | import Ruthie from "nano-font/fonts/Ruthie"; 5 | import Source_Code_Pro from "nano-font/fonts/Source_Code_Pro"; 6 | import { Generator } from "../card"; 7 | import { Extension } from "../types"; 8 | 9 | export const supported: Record = { 10 | baloo_2: Baloo_2, 11 | milonga: Milonga, 12 | patrick_hand: Patrick_Hand, 13 | ruthie: Ruthie, 14 | source_code_pro: Source_Code_Pro, 15 | }; 16 | 17 | const remote_base = "https://cdn.jsdelivr.net/gh/JacobLinCool/nano-font@json/"; 18 | 19 | export async function FontExtension(generator: Generator): Promise { 20 | const config = generator.config; 21 | let names: string[] = []; 22 | if (Array.isArray(config.fonts)) { 23 | names = config.fonts.filter((font) => !supported[font.toLowerCase()]); 24 | } else if (typeof config.font === "string" && !supported[config.font.toLowerCase()]) { 25 | names = [config.font]; 26 | } 27 | 28 | await Promise.all( 29 | names.map(async (name) => { 30 | try { 31 | const url = `${remote_base}${name.replace(/\s+/g, "_")}.json`; 32 | const cached = await generator.cache?.match(url); 33 | if (cached) { 34 | supported[name.toLowerCase()] = await cached.json(); 35 | generator.log(`Loaded cached font ${name}`); 36 | } else { 37 | const res = await fetch(url); 38 | if (res.ok) { 39 | const data = (await res.clone().json()) as { name: string; base64: string }; 40 | supported[name.toLowerCase()] = { name, base64: data.base64 }; 41 | generator.log(`loaded remote font "${name}"`); 42 | generator.cache?.put(url, res); 43 | } else { 44 | return; 45 | } 46 | } 47 | } catch { 48 | // do nothing 49 | } 50 | }), 51 | ); 52 | 53 | return async function Font(generator, data, body, styles) { 54 | if (Array.isArray(config.fonts)) { 55 | const list = config.fonts.map((font: string) => { 56 | if (supported[font.toLowerCase()]) { 57 | return supported[font.toLowerCase()]; 58 | } else { 59 | return { name: font }; 60 | } 61 | }); 62 | styles.push(css(list)); 63 | } else if (typeof config.font === "string") { 64 | const font = supported[config.font.toLowerCase()]; 65 | 66 | if (font) { 67 | styles.push(css([font])); 68 | } else { 69 | styles.push(css([{ name: config.font }])); 70 | } 71 | } 72 | }; 73 | } 74 | 75 | function css(fonts: { name: string; base64?: string | null }[]): string { 76 | let css = ""; 77 | for (const font of fonts) { 78 | if (!font.base64) { 79 | continue; 80 | } 81 | css += `@font-face {font-family:"${font.name}";src:url("${font.base64}") format("woff2")}`; 82 | } 83 | css += `*{font-family:${fonts 84 | .map((font) => 85 | ["monospace", "sans-serif", "sans"].includes(font.name) ? font.name : `"${font.name}"`, 86 | ) 87 | .join(",")}}`; 88 | return css; 89 | } 90 | -------------------------------------------------------------------------------- /packages/core/src/exts/heatmap.ts: -------------------------------------------------------------------------------- 1 | import { LeetCode, LeetCodeCN } from "leetcode-query"; 2 | import { Generator } from "../card"; 3 | import { Item } from "../item"; 4 | import { Extension } from "../types"; 5 | 6 | export async function HeatmapExtension(generator: Generator): Promise { 7 | const pre_counts = new Promise>((resolve) => { 8 | if (generator.config.site === "us") { 9 | const lc = new LeetCode(); 10 | lc.once("receive-graphql", async (res) => { 11 | try { 12 | const { data } = (await res.json()) as { 13 | data: { user: { calendar: { calendar: string } } }; 14 | }; 15 | const calendar = data?.user?.calendar?.calendar; 16 | resolve(calendar ? JSON.parse(calendar) : {}); 17 | } catch (e) { 18 | console.warn("Failed to parse calendar", e); 19 | resolve({}); 20 | } 21 | }); 22 | lc.graphql({ 23 | operationName: "calendar", 24 | query: `query calendar($username: String!, $year: Int) { user: matchedUser(username: $username) { calendar: userCalendar(year: $year) { calendar: submissionCalendar } } }`, 25 | variables: { username: generator.config.username }, 26 | }); 27 | } else { 28 | const lc = new LeetCodeCN(); 29 | lc.once("receive-graphql", async (res) => { 30 | try { 31 | const { data } = (await res.json()) as { 32 | data: { calendar: { calendar: string } }; 33 | }; 34 | const calendar = data?.calendar?.calendar; 35 | resolve(calendar ? JSON.parse(calendar) : {}); 36 | } catch (e) { 37 | console.warn("Failed to parse calendar", e); 38 | resolve({}); 39 | } 40 | }); 41 | lc.graphql( 42 | { 43 | operationName: "calendar", 44 | query: `query calendar($username: String!, $year: Int) { calendar: userCalendar(userSlug: $username, year: $year) { calendar: submissionCalendar } }`, 45 | variables: { username: generator.config.username }, 46 | }, 47 | "/graphql/noj-go/", 48 | ); 49 | } 50 | }); 51 | 52 | return async function Heatmap(generator, data, body) { 53 | if (generator.config.height < 320) { 54 | generator.config.height = 320; 55 | } 56 | 57 | const counts = await pre_counts; 58 | const today = Math.floor(Date.now() / 86400_000) * 86400; 59 | const width = generator.config.width - 40; 60 | const wrap = +(width / 52).toFixed(2); 61 | const block = wrap * 0.9; 62 | 63 | const extension = new Item("g", { 64 | id: "ext-heatmap", 65 | style: { transform: `translate(0px, 200px)` }, 66 | children: [ 67 | new Item("line", { 68 | attr: { x1: 10, y1: 0, x2: generator.config.width - 10, y2: 0 }, 69 | style: { stroke: "var(--bg-1)", "stroke-width": 1 }, 70 | }), 71 | new Item("text", { 72 | content: "Heatmap (Last 52 Weeks)", 73 | id: "ext-heatmap-title", 74 | style: { 75 | transform: `translate(20px, 20px)`, 76 | fill: "var(--text-0)", 77 | opacity: generator.config.animation !== false ? 0 : 1, 78 | animation: 79 | generator.config.animation !== false 80 | ? "fade_in 1 0.3s 1.7s forwards" 81 | : "", 82 | }, 83 | }), 84 | ], 85 | }); 86 | 87 | const blocks = new Item("g", { 88 | id: "ext-heatmap-blocks", 89 | children: [], 90 | style: { 91 | transform: `translate(20px, 35px)`, 92 | opacity: generator.config.animation !== false ? 0 : 1, 93 | animation: 94 | generator.config.animation !== false ? "fade_in 1 0.3s 1.9s forwards" : "", 95 | }, 96 | }); 97 | extension.children?.push(blocks); 98 | 99 | for (let i = 0; i < 364; i++) { 100 | const count = counts[today - i * 86400] || 0; 101 | const opacity = calc_opacity(count); 102 | 103 | blocks.children?.push( 104 | new Item("rect", { 105 | attr: { 106 | class: `ext-heatmap-${count}`, 107 | }, 108 | style: { 109 | transform: `translate(${width - wrap * (Math.floor(i / 7) + 1)}px, ${ 110 | wrap * (6 - (i % 7)) 111 | }px)`, 112 | fill: `var(--color-1)`, 113 | opacity: opacity, 114 | width: `${block}px`, 115 | height: `${block}px`, 116 | stroke: "var(--color-0)", 117 | "stroke-width": +(i === 0), 118 | rx: 2, 119 | }, 120 | }), 121 | ); 122 | } 123 | 124 | const from = new Date((today - 86400 * 364) * 1000); 125 | const to = new Date(today * 1000); 126 | extension.children?.push( 127 | new Item("text", { 128 | content: `${from.getFullYear()}.${from.getMonth() + 1}.${from.getDate()}`, 129 | id: "ext-heatmap-from", 130 | style: { 131 | transform: `translate(20px, 110px)`, 132 | fill: "var(--text-0)", 133 | opacity: generator.config.animation !== false ? 0 : 1, 134 | animation: 135 | generator.config.animation !== false ? "fade_in 1 0.3s 2.1s forwards" : "", 136 | "font-size": "10px", 137 | }, 138 | }), 139 | new Item("text", { 140 | content: `${to.getFullYear()}.${to.getMonth() + 1}.${to.getDate()}`, 141 | id: "ext-heatmap-to", 142 | style: { 143 | transform: `translate(${generator.config.width - 20}px, 110px)`, 144 | fill: "var(--text-0)", 145 | opacity: generator.config.animation !== false ? 0 : 1, 146 | animation: 147 | generator.config.animation !== false ? "fade_in 1 0.3s 2.1s forwards" : "", 148 | "font-size": "10px", 149 | "text-anchor": "end", 150 | }, 151 | }), 152 | ); 153 | 154 | body["ext-heatmap"] = () => extension; 155 | }; 156 | } 157 | 158 | function calc_opacity(count: number, max = 8): number { 159 | return Math.sin(Math.min(1, (count + 0.5) / max) * Math.PI * 0.5); 160 | } 161 | -------------------------------------------------------------------------------- /packages/core/src/exts/remote-style.ts: -------------------------------------------------------------------------------- 1 | import { Generator } from "../card"; 2 | import { Extension } from "../types"; 3 | 4 | export async function RemoteStyleExtension(generator: Generator): Promise { 5 | const urls = generator.config.sheets; 6 | 7 | const externals: Promise[] = []; 8 | if (Array.isArray(urls)) { 9 | externals.push( 10 | ...urls.map(async (url) => { 11 | const cahced = await generator.cache?.match(url); 12 | if (cahced) { 13 | return cahced.text(); 14 | } 15 | 16 | const data = await fetch(url) 17 | .then(async (res) => 18 | res.ok 19 | ? `/* ${url} */ ${await res.text()}` 20 | : `/* ${url} ${await res.text()} */`, 21 | ) 22 | .catch((err) => `/* ${url} ${err} */`); 23 | 24 | generator.cache?.put(url, new Response(data)); 25 | return data; 26 | }), 27 | ); 28 | } 29 | 30 | return async function RemoteStyle(generator, data, body, styles) { 31 | for (const css of externals) { 32 | styles.push(await css); 33 | } 34 | }; 35 | } 36 | -------------------------------------------------------------------------------- /packages/core/src/exts/theme.ts: -------------------------------------------------------------------------------- 1 | import { Theme } from "../theme/_theme"; 2 | import catppuccinMocha from "../theme/catppuccin-mocha"; 3 | import chartreuse from "../theme/chartreuse"; 4 | import dark from "../theme/dark"; 5 | import forest from "../theme/forest"; 6 | import light from "../theme/light"; 7 | import nord from "../theme/nord"; 8 | import radical from "../theme/radical"; 9 | import transparent from "../theme/transparent"; 10 | import unicorn from "../theme/unicorn"; 11 | import wtf from "../theme/wtf"; 12 | import { Extension, Item } from "../types"; 13 | 14 | export const supported: Record = { 15 | dark, 16 | forest, 17 | light, 18 | nord, 19 | unicorn, 20 | wtf, 21 | transparent, 22 | radical, 23 | chartreuse, 24 | catppuccinMocha, 25 | }; 26 | 27 | export function ThemeExtension(): Extension { 28 | return async function Theme(generator, data, body, styles) { 29 | if (!generator.config?.theme) { 30 | return; 31 | } 32 | 33 | if (typeof generator.config.theme === "string" && supported[generator.config.theme]) { 34 | const theme = supported[generator.config.theme]; 35 | styles.push(css(theme)); 36 | if (theme.extends) { 37 | body["theme-ext"] = () => theme.extends as Item; 38 | } 39 | } 40 | 41 | if ( 42 | typeof generator.config.theme?.light === "string" && 43 | supported[generator.config.theme.light] 44 | ) { 45 | const theme = supported[generator.config.theme.light]; 46 | styles.push(`@media (prefers-color-scheme: light) {${css(theme)}}`); 47 | if (theme.extends) { 48 | body["theme-ext-light"] = () => theme.extends as Item; 49 | } 50 | } 51 | 52 | if ( 53 | typeof generator.config.theme?.dark === "string" && 54 | supported[generator.config.theme.dark] 55 | ) { 56 | const theme = supported[generator.config.theme.dark]; 57 | styles.push(`@media (prefers-color-scheme: dark) {${css(theme)}}`); 58 | if (theme.extends) { 59 | body["theme-ext-dark"] = () => theme.extends as Item; 60 | } 61 | } 62 | }; 63 | } 64 | 65 | function css(theme: Theme): string { 66 | let css = ":root{"; 67 | if (theme.palette.bg) { 68 | for (let i = 0; i < theme.palette.bg.length; i++) { 69 | css += `--bg-${i}:${theme.palette.bg[i]};`; 70 | } 71 | } 72 | if (theme.palette.text) { 73 | for (let i = 0; i < theme.palette.text.length; i++) { 74 | css += `--text-${i}:${theme.palette.text[i]};`; 75 | } 76 | } 77 | if (theme.palette.color) { 78 | for (let i = 0; i < theme.palette.color.length; i++) { 79 | css += `--color-${i}:${theme.palette.color[i]};`; 80 | } 81 | } 82 | css += "}"; 83 | 84 | if (theme.palette.bg) { 85 | css += `#background{fill:var(--bg-0)}`; 86 | css += `#total-solved-bg{stroke:var(--bg-1)}`; 87 | css += `#easy-solved-bg{stroke:var(--bg-1)}`; 88 | css += `#medium-solved-bg{stroke:var(--bg-1)}`; 89 | css += `#hard-solved-bg{stroke:var(--bg-1)}`; 90 | } 91 | if (theme.palette.text) { 92 | css += `#username{fill:var(--text-0)}`; 93 | css += `#username-text{fill:var(--text-0)}`; 94 | css += `#total-solved-text{fill:var(--text-0)}`; 95 | css += `#easy-solved-type{fill:var(--text-0)}`; 96 | css += `#medium-solved-type{fill:var(--text-0)}`; 97 | css += `#hard-solved-type{fill:var(--text-0)}`; 98 | css += `#ranking{fill:var(--text-1)}`; 99 | css += `#easy-solved-count{fill:var(--text-1)}`; 100 | css += `#medium-solved-count{fill:var(--text-1)}`; 101 | css += `#hard-solved-count{fill:var(--text-1)}`; 102 | } 103 | if (theme.palette.color) { 104 | if (theme.palette.color.length > 0) { 105 | css += `#total-solved-ring{stroke:var(--color-0)}`; 106 | } 107 | if (theme.palette.color.length > 1) { 108 | css += `#easy-solved-progress{stroke:var(--color-1)}`; 109 | } 110 | if (theme.palette.color.length > 2) { 111 | css += `#medium-solved-progress{stroke:var(--color-2)}`; 112 | } 113 | if (theme.palette.color.length > 3) { 114 | css += `#hard-solved-progress{stroke:var(--color-3)}`; 115 | } 116 | } 117 | 118 | css += theme.css || ""; 119 | 120 | return css; 121 | } 122 | -------------------------------------------------------------------------------- /packages/core/src/index.ts: -------------------------------------------------------------------------------- 1 | import { Generator } from "./card"; 2 | import { ActivityExtension } from "./exts/activity"; 3 | import { AnimationExtension } from "./exts/animation"; 4 | import { ContestExtension } from "./exts/contest"; 5 | import { FontExtension } from "./exts/font"; 6 | import { HeatmapExtension } from "./exts/heatmap"; 7 | import { RemoteStyleExtension } from "./exts/remote-style"; 8 | import { ThemeExtension, supported } from "./exts/theme"; 9 | import { Config } from "./types"; 10 | 11 | /** 12 | * Generate a card. 13 | * @param config The configuration of the card 14 | * @returns The card (svg) 15 | */ 16 | export async function generate(config: Partial): Promise { 17 | const generator = new Generator(); 18 | return await generator.generate({ 19 | username: "jacoblincool", 20 | site: "us", 21 | width: 500, 22 | height: 200, 23 | css: [], 24 | extensions: [FontExtension, AnimationExtension, ThemeExtension], 25 | animation: true, 26 | font: "baloo_2", 27 | theme: "light", 28 | ...config, 29 | }); 30 | } 31 | 32 | export default generate; 33 | export { 34 | ActivityExtension, 35 | AnimationExtension, 36 | Config, 37 | ContestExtension, 38 | FontExtension, 39 | Generator, 40 | HeatmapExtension, 41 | RemoteStyleExtension, 42 | ThemeExtension, 43 | supported, 44 | }; 45 | -------------------------------------------------------------------------------- /packages/core/src/item.ts: -------------------------------------------------------------------------------- 1 | import { Item as Base } from "./types"; 2 | 3 | let counter = 0; 4 | 5 | export class Item implements Base { 6 | public type: string; 7 | public attr: Record; 8 | public style: Record; 9 | public single?: boolean; 10 | public children?: Item[]; 11 | public content?: string; 12 | 13 | constructor( 14 | type = "g", 15 | { 16 | id, 17 | attr = {}, 18 | style = {}, 19 | single = false, 20 | children = [], 21 | content = undefined, 22 | }: { 23 | id?: string; 24 | attr?: Record; 25 | style?: Record; 26 | single?: boolean; 27 | children?: Item[]; 28 | content?: string; 29 | } = {}, 30 | ) { 31 | this.type = type; 32 | this.attr = attr; 33 | this.attr.id = id || this.attr.id; 34 | this.style = style; 35 | this.single = single; 36 | this.children = children; 37 | this.content = content; 38 | } 39 | 40 | public stringify(): string { 41 | if (!this.attr.id) { 42 | this.attr.id = `_${(++counter).toString(36)}`; 43 | } 44 | const attr = Object.entries(this.attr) 45 | .map( 46 | ([key, value]) => 47 | `${key}="${escape(Array.isArray(value) ? value.join(" ") : value.toString())}"`, 48 | ) 49 | .join(" "); 50 | const children = this.children?.map((child) => child.stringify()).join("") || ""; 51 | return this.single 52 | ? `<${this.type} ${attr} />` 53 | : `<${this.type} ${attr}>${this.content ? escape(this.content) : ""}${children}`; 54 | } 55 | 56 | public css(): string { 57 | if (!this.attr.id) { 58 | this.attr.id = `_${(++counter).toString(36)}`; 59 | } 60 | 61 | if (Object.keys(this.style).length === 0) { 62 | return this.children?.map((child) => child.css()).join("") || ""; 63 | } 64 | 65 | return `#${this.attr.id}{${Object.entries(this.style) 66 | .map(([key, value]) => `${key}:${value}`) 67 | .join(";")}} ${this.children?.map((child) => child.css()).join("") || ""}`; 68 | } 69 | } 70 | 71 | export const svg_attrs = { 72 | version: "1.1", 73 | xmlns: "http://www.w3.org/2000/svg", 74 | "xmlns:xlink": "http://www.w3.org/1999/xlink", 75 | }; 76 | 77 | function escape(str: string): string { 78 | return str.replace(/&/g, "&").replace(//g, ">"); 79 | } 80 | -------------------------------------------------------------------------------- /packages/core/src/query.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-explicit-any */ 2 | import { LeetCode, LeetCodeCN } from "leetcode-query"; 3 | import { CN_LANGS_MAP, CN_RESULTS_MAP } from "./constants"; 4 | import { FetchedData } from "./types"; 5 | 6 | interface ProblemCount { 7 | difficulty: string; 8 | count: number; 9 | } 10 | 11 | interface DifficultyStats { 12 | solved: number; 13 | total: number; 14 | } 15 | 16 | function getProblemStats( 17 | difficulty: string, 18 | acCounts: ProblemCount[], 19 | totalCounts: ProblemCount[], 20 | ): DifficultyStats { 21 | return { 22 | solved: acCounts.find((x) => x.difficulty === difficulty)?.count || 0, 23 | total: totalCounts.find((x) => x.difficulty === difficulty)?.count || 0, 24 | }; 25 | } 26 | 27 | function getCNProblemStats( 28 | difficulty: string, 29 | progress: Record, 30 | ): DifficultyStats { 31 | return { 32 | solved: progress.ac.find((x) => x.difficulty === difficulty.toUpperCase())?.count || 0, 33 | total: (Object.values(progress) as ProblemCount[][]).reduce( 34 | (acc, arr) => 35 | acc + (arr.find((x) => x.difficulty === difficulty.toUpperCase())?.count || 0), 36 | 0, 37 | ), 38 | }; 39 | } 40 | 41 | export class Query { 42 | async us(username: string, headers?: Record): Promise { 43 | const lc = new LeetCode(); 44 | const { data } = await lc.graphql({ 45 | operationName: "data", 46 | variables: { username }, 47 | query: ` 48 | query data($username: String!) { 49 | problems: allQuestionsCount { 50 | difficulty 51 | count 52 | } 53 | user: matchedUser(username: $username) { 54 | username 55 | profile { 56 | realname: realName 57 | about: aboutMe 58 | avatar: userAvatar 59 | skills: skillTags 60 | country: countryName 61 | ranking 62 | } 63 | submits: submitStatsGlobal { 64 | ac: acSubmissionNum { difficulty count } 65 | } 66 | } 67 | submissions: recentSubmissionList(username: $username, limit: 10) { 68 | id 69 | title 70 | slug: titleSlug 71 | time: timestamp 72 | status: statusDisplay 73 | lang 74 | } 75 | contest: userContestRanking(username: $username) { 76 | rating 77 | ranking: globalRanking 78 | badge { 79 | name 80 | } 81 | } 82 | }`, 83 | headers, 84 | }); 85 | 86 | if (!data?.user) { 87 | throw new Error("User Not Found"); 88 | } 89 | 90 | const result: FetchedData = { 91 | profile: { 92 | username: data.user.username, 93 | realname: data.user.profile.realname, 94 | about: data.user.profile.about, 95 | avatar: data.user.profile.avatar, 96 | skills: data.user.profile.skills, 97 | country: data.user.profile.country, 98 | }, 99 | problem: { 100 | easy: getProblemStats("Easy", data.user.submits.ac, data.problems), 101 | medium: getProblemStats("Medium", data.user.submits.ac, data.problems), 102 | hard: getProblemStats("Hard", data.user.submits.ac, data.problems), 103 | ranking: data.user.profile.ranking, 104 | }, 105 | submissions: data.submissions.map((x: { time: string }) => ({ 106 | ...x, 107 | time: parseInt(x.time) * 1000, 108 | })), 109 | contest: data.contest && { 110 | rating: data.contest.rating, 111 | ranking: data.contest.ranking, 112 | badge: data.contest.badge?.name || "", 113 | }, 114 | }; 115 | 116 | return result; 117 | } 118 | 119 | async cn(username: string, headers?: Record): Promise { 120 | const lc = new LeetCodeCN(); 121 | const { data } = await lc.graphql({ 122 | operationName: "data", 123 | variables: { username }, 124 | query: ` 125 | query data($username: String!) { 126 | progress: userProfileUserQuestionProgress(userSlug: $username) { 127 | ac: numAcceptedQuestions { difficulty count } 128 | wa: numFailedQuestions { difficulty count } 129 | un: numUntouchedQuestions { difficulty count } 130 | } 131 | user: userProfilePublicProfile(userSlug: $username) { 132 | username 133 | ranking: siteRanking 134 | profile { 135 | realname: realName 136 | about: aboutMe 137 | avatar: userAvatar 138 | skills: skillTags 139 | country: countryName 140 | } 141 | } 142 | submissions: recentSubmitted(userSlug: $username) { 143 | id: submissionId 144 | status 145 | lang 146 | time: submitTime 147 | question { 148 | title: translatedTitle 149 | slug: titleSlug 150 | } 151 | } 152 | }`, 153 | headers, 154 | }); 155 | 156 | if (!data?.user) { 157 | throw new Error("User Not Found"); 158 | } 159 | 160 | const result: FetchedData = { 161 | profile: { 162 | username: data.user.username, 163 | realname: data.user.profile.realname, 164 | about: data.user.profile.about, 165 | avatar: data.user.profile.avatar, 166 | skills: data.user.profile.skills, 167 | country: data.user.profile.country, 168 | }, 169 | problem: { 170 | easy: getCNProblemStats("EASY", data.progress), 171 | medium: getCNProblemStats("MEDIUM", data.progress), 172 | hard: getCNProblemStats("HARD", data.progress), 173 | ranking: data.user.ranking, 174 | }, 175 | submissions: data.submissions.map( 176 | (x: { 177 | question: { title: any; slug: any }; 178 | time: number; 179 | status: string | number; 180 | lang: string | number; 181 | id: any; 182 | }) => ({ 183 | title: x.question.title, 184 | time: x.time * 1000, 185 | status: CN_RESULTS_MAP[x.status] || "", 186 | lang: CN_LANGS_MAP[x.lang] || "", 187 | slug: x.question.slug, 188 | id: x.id, 189 | }), 190 | ), 191 | }; 192 | 193 | return result; 194 | } 195 | } 196 | 197 | export default new Query(); 198 | -------------------------------------------------------------------------------- /packages/core/src/theme/_theme.ts: -------------------------------------------------------------------------------- 1 | import { Item } from "../item"; 2 | 3 | export interface Theme { 4 | palette: { 5 | bg?: string[]; 6 | text?: string[]; 7 | color?: string[]; 8 | }; 9 | css: string; 10 | extends?: Item; 11 | } 12 | 13 | export function Theme(theme: Partial): Theme { 14 | const completed: Theme = { 15 | palette: { 16 | bg: theme.palette?.bg ?? ["#fff", "#e5e5e5"], 17 | text: theme.palette?.text ?? ["#000", "#808080"], 18 | color: theme.palette?.color ?? [], 19 | }, 20 | css: theme.css ?? "", 21 | extends: theme.extends ?? undefined, 22 | }; 23 | 24 | while (completed.palette.bg && completed.palette.bg.length < 4) { 25 | completed.palette.bg.push(completed.palette.bg[completed.palette.bg.length - 1]); 26 | } 27 | while (completed.palette.text && completed.palette.text.length < 4) { 28 | completed.palette.text.push(completed.palette.text[completed.palette.text.length - 1]); 29 | } 30 | 31 | return completed; 32 | } 33 | -------------------------------------------------------------------------------- /packages/core/src/theme/catppuccin-mocha.ts: -------------------------------------------------------------------------------- 1 | import { Theme } from "./_theme"; 2 | 3 | export default Theme({ 4 | palette: { 5 | bg: ["#1e1e2e", "#45475a", "#45475a"], 6 | text: ["#cdd6f4", "#bac2de"], 7 | color: ["#fab387", "#a6e3a1", "#f9e2af", "#f38ba8"], 8 | }, 9 | }); 10 | -------------------------------------------------------------------------------- /packages/core/src/theme/chartreuse.ts: -------------------------------------------------------------------------------- 1 | import { Theme } from "./_theme"; 2 | 3 | export default Theme({ 4 | palette: { 5 | bg: ["#000", "#fff"], 6 | text: ["#00AEFF", "#7fff00"], 7 | color: ["#ffa116", "#5cb85c", "#f0ad4e", "#d9534f"], 8 | }, 9 | css: `#L{fill:#fff}`, 10 | }); 11 | -------------------------------------------------------------------------------- /packages/core/src/theme/dark.ts: -------------------------------------------------------------------------------- 1 | import { Theme } from "./_theme"; 2 | 3 | export default Theme({ 4 | palette: { 5 | bg: ["#101010", "#404040"], 6 | text: ["#f0f0f0", "#dcdcdc"], 7 | color: ["#ffa116", "#5cb85c", "#f0ad4e", "#d9534f"], 8 | }, 9 | css: `#L{fill:#fff}`, 10 | }); 11 | -------------------------------------------------------------------------------- /packages/core/src/theme/forest.ts: -------------------------------------------------------------------------------- 1 | import { Theme } from "./_theme"; 2 | 3 | export default Theme({ 4 | palette: { 5 | bg: ["#fff9dd", "#ffec96"], 6 | color: ["#80c600", "#1abc97", "#8ec941", "#a36d00"], 7 | }, 8 | }); 9 | -------------------------------------------------------------------------------- /packages/core/src/theme/light.ts: -------------------------------------------------------------------------------- 1 | import { Theme } from "./_theme"; 2 | 3 | export default Theme({}); 4 | -------------------------------------------------------------------------------- /packages/core/src/theme/nord.ts: -------------------------------------------------------------------------------- 1 | import { Theme } from "./_theme"; 2 | 3 | const nord = [ 4 | "#2e3440", 5 | "#3b4252", 6 | "#434c5e", 7 | "#4c566a", 8 | "#d8dee9", 9 | "#e5e9f0", 10 | "#eceff4", 11 | "#8fbcbb", 12 | "#88c0d0", 13 | "#81a1c1", 14 | "#5e81ac", 15 | "#bf616a", 16 | "#d08770", 17 | "#ebcb8b", 18 | "#a3be8c", 19 | "#b48ead", 20 | ] as const; 21 | 22 | export default Theme({ 23 | palette: { 24 | bg: nord.slice(0, 4), 25 | text: nord.slice(4, 7).reverse(), 26 | color: [nord[12], nord[14], nord[13], nord[11]], 27 | }, 28 | css: `#L{fill:${nord[6]}}`, 29 | }); 30 | -------------------------------------------------------------------------------- /packages/core/src/theme/radical.ts: -------------------------------------------------------------------------------- 1 | import { Theme } from "./_theme"; 2 | 3 | export default Theme({ 4 | palette: { 5 | bg: ["#101010", "#ffff"], 6 | text: ["#fe428e", "#a9fef7"], 7 | color: ["#ffa116", "#5cb85c", "#f0ad4e", "#d9534f"], 8 | }, 9 | css: `#L{fill:#fff}`, 10 | }); 11 | -------------------------------------------------------------------------------- /packages/core/src/theme/transparent.ts: -------------------------------------------------------------------------------- 1 | import { Theme } from "./_theme"; 2 | 3 | export default Theme({ 4 | palette: { 5 | bg: ["rgba(16, 16, 16, 0.5)", "rgba(0, 0, 0, 0.5)"], 6 | text: ["#417E87", "#417E87"], 7 | color: ["#ffa116", "#5cb85c", "#f0ad4e", "#d9534f"], 8 | }, 9 | css: `#L{fill:#fff}`, 10 | }); 11 | -------------------------------------------------------------------------------- /packages/core/src/theme/unicorn.ts: -------------------------------------------------------------------------------- 1 | import { Gradient } from "../elements"; 2 | import { Item } from "../item"; 3 | import { Theme } from "./_theme"; 4 | 5 | export default Theme({ 6 | palette: { 7 | bg: ["url(#g-bg)", "#ffffffaa"], 8 | text: ["url(#g-text)"], 9 | color: ["url(#g-text)", "#6ee7b7", "#fcd34d", "#fca5a5"], 10 | }, 11 | css: `#background{stroke:url(#g-text)}`, 12 | extends: new Item("defs", { 13 | children: [ 14 | Gradient("g-bg", { 0: "#dbeafe", 0.5: "#e0e7ff", 1: "#fae8ff" }), 15 | Gradient("g-text", { 0: "#2563eb", 0.5: "#4f46e5", 1: "#d946ef" }), 16 | ], 17 | }), 18 | }); 19 | -------------------------------------------------------------------------------- /packages/core/src/theme/wtf.ts: -------------------------------------------------------------------------------- 1 | import { Theme } from "./_theme"; 2 | 3 | export default Theme({ 4 | css: `#root { animation: wtf_animation 1s linear 0s infinite forwards } @keyframes wtf_animation {from { filter: hue-rotate(0deg) } to { filter: hue-rotate(360deg) }}`, 5 | }); 6 | -------------------------------------------------------------------------------- /packages/core/src/types.ts: -------------------------------------------------------------------------------- 1 | import { Generator } from "./card"; 2 | 3 | export interface Config { 4 | username: string; 5 | site: "us" | "cn"; 6 | 7 | width: number; 8 | height: number; 9 | 10 | css: string[]; 11 | 12 | extensions: ExtensionInit[]; 13 | 14 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 15 | [key: string]: any; 16 | } 17 | 18 | export type Extension = ( 19 | generator: Generator, 20 | data: FetchedData, 21 | body: Record Item>, 22 | styles: string[], 23 | ) => Promise | void; 24 | 25 | export type ExtensionInit = (generator: Generator) => Promise | Extension; 26 | 27 | export interface FetchedData { 28 | profile: { 29 | username: string; 30 | realname: string; 31 | about: string; 32 | avatar: string; 33 | skills: string[]; 34 | country: string; 35 | }; 36 | 37 | problem: { 38 | easy: { 39 | solved: number; 40 | total: number; 41 | }; 42 | medium: { 43 | solved: number; 44 | total: number; 45 | }; 46 | hard: { 47 | solved: number; 48 | total: number; 49 | }; 50 | ranking: number; 51 | }; 52 | 53 | submissions: { 54 | title: string; 55 | lang: string; 56 | time: number; 57 | status: string; 58 | id: string; 59 | slug: string; 60 | }[]; 61 | 62 | contest?: { 63 | rating: number; 64 | ranking: number; 65 | badge: string; 66 | }; 67 | 68 | [key: string]: unknown; 69 | } 70 | 71 | export interface Item { 72 | type: string; 73 | attr: Record; 74 | style: Record; 75 | single?: boolean; 76 | children?: Item[]; 77 | content?: string; 78 | } 79 | -------------------------------------------------------------------------------- /packages/core/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "outDir": "./dist", 4 | "module": "esnext", 5 | "target": "esnext", 6 | "lib": ["esnext"], 7 | "alwaysStrict": true, 8 | "strict": true, 9 | "preserveConstEnums": true, 10 | "moduleResolution": "node", 11 | "sourceMap": true, 12 | "esModuleInterop": true, 13 | "types": ["@cloudflare/workers-types"] 14 | }, 15 | "include": ["src"], 16 | "exclude": ["node_modules", "dist", "tsup.config.ts"] 17 | } 18 | -------------------------------------------------------------------------------- /packages/core/tsup.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "tsup"; 2 | 3 | export default defineConfig({ 4 | entry: ["./src/index.ts"], 5 | format: ["esm"], 6 | dts: true, 7 | clean: true, 8 | }); 9 | -------------------------------------------------------------------------------- /pnpm-workspace.yaml: -------------------------------------------------------------------------------- 1 | packages: 2 | - "packages/*" 3 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "outDir": "./dist", 4 | "module": "esnext", 5 | "target": "esnext", 6 | "lib": ["esnext"], 7 | "alwaysStrict": true, 8 | "strict": true, 9 | "preserveConstEnums": true, 10 | "moduleResolution": "node", 11 | "sourceMap": true, 12 | "esModuleInterop": true 13 | }, 14 | "include": ["src"], 15 | "exclude": ["node_modules", "dist", "tsup.config.ts"] 16 | } 17 | -------------------------------------------------------------------------------- /wrangler.toml: -------------------------------------------------------------------------------- 1 | name = "leetcode" 2 | main = "dist/worker.js" 3 | workers_dev = true 4 | compatibility_date = "2022-05-26" 5 | node_compat = true 6 | 7 | [limits] 8 | cpu_ms = 50 9 | 10 | [build] 11 | command = "npm run -s build:worker" 12 | 13 | [[rules]] 14 | type = "ESModule" 15 | globs = ["**/*.js"] 16 | --------------------------------------------------------------------------------