├── .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 | [](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 | [](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 | 
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 | [](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 | 
69 | ```
70 |
71 | [](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 | 
81 | 
82 | ```
83 |
84 | [](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 | 
94 | ```
95 |
96 | [](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 | 
106 | ```
107 |
108 | [](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 | 
116 | ```
117 |
118 | [](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 | 
126 | ```
127 |
128 | [](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 | 
136 | ```
137 |
138 | [](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 | 
152 | ```
153 |
154 | [](https://leetcard.jacoblin.cool/jacoblincool?ext=activity)
155 |
156 | ```md
157 | 
158 | ```
159 |
160 | [](https://leetcard.jacoblin.cool/lapor?ext=contest)
161 |
162 | ```md
163 | 
164 | ```
165 |
166 | [](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 | 
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 | 
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 | 
210 | ```
211 |
212 | [](https://leetcard.jacoblin.cool/JacobLinCool?theme=light)
213 |
214 | #### Dark
215 |
216 | ```md
217 | 
218 | ```
219 |
220 | [](https://leetcard.jacoblin.cool/JacobLinCool?theme=dark)
221 |
222 | #### Nord
223 |
224 | ```md
225 | 
226 | ```
227 |
228 | [](https://leetcard.jacoblin.cool/JacobLinCool?theme=nord)
229 |
230 | #### Forest
231 |
232 | ```md
233 | 
234 | ```
235 |
236 | [](https://leetcard.jacoblin.cool/JacobLinCool?theme=forest)
237 |
238 | #### WTF
239 |
240 | ```md
241 | 
242 | ```
243 |
244 | [](https://leetcard.jacoblin.cool/JacobLinCool?theme=wtf)
245 |
246 | #### Unicorn
247 |
248 | ```md
249 | 
250 | ```
251 |
252 | [](https://leetcard.jacoblin.cool/JacobLinCool?theme=unicorn)
253 |
254 | #### Transparent
255 |
256 | ```md
257 | 
258 | ```
259 |
260 | [](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 | 
272 | ```
273 |
274 | [](https://leetcard.jacoblin.cool/JacobLinCool?font=milonga)
275 |
276 | #### Patrick Hand
277 |
278 | ```md
279 | 
280 | ```
281 |
282 | [](https://leetcard.jacoblin.cool/JacobLinCool?font=patrick_hand)
283 |
284 | #### Ruthie
285 |
286 | ```md
287 | 
288 | ```
289 |
290 | [](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 | 
308 | ```
309 |
310 | [](https://leetcard.jacoblin.cool/JacobLinCool?ext=activity)
311 |
312 | #### `contest`
313 |
314 | Show your contest rating history.
315 |
316 | ```md
317 | 
318 | ```
319 |
320 | [](https://leetcard.jacoblin.cool/lapor?ext=contest)
321 |
322 | #### `heatmap`
323 |
324 | Show heatmap in the past 52 weeks.
325 |
326 | ```md
327 | 
328 | ```
329 |
330 | [](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 |
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}${this.type}>`;
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 |
--------------------------------------------------------------------------------