@shiinasaku/github-card 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +354 -0
- package/fonts/Roboto-subset.woff2 +0 -0
- package/package.json +72 -0
- package/src/card.ts +137 -0
- package/src/github.ts +572 -0
- package/src/index.ts +355 -0
- package/src/lib.ts +21 -0
- package/src/types.ts +40 -0
- package/src/utils/font.ts +19 -0
- package/src/utils/format.ts +42 -0
- package/src/utils/icons.ts +17 -0
- package/src/utils/index.ts +5 -0
- package/src/utils/languages.ts +656 -0
- package/src/utils/themes.ts +47 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 サクシイナ
|
|
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.
|
package/README.md
ADDED
|
@@ -0,0 +1,354 @@
|
|
|
1
|
+
# GitHub Profile Card
|
|
2
|
+
|
|
3
|
+
<div align="center">
|
|
4
|
+
|
|
5
|
+
<br/>
|
|
6
|
+
|
|
7
|
+
<picture>
|
|
8
|
+
<source media="(prefers-color-scheme: dark)" srcset="https://card.shiina.xyz/card/ShiinaSaku?theme=github_dark" />
|
|
9
|
+
<img src="https://card.shiina.xyz/card/ShiinaSaku" alt="GitHub Profile Card" width="500" />
|
|
10
|
+
</picture>
|
|
11
|
+
|
|
12
|
+
<br/><br/>
|
|
13
|
+
|
|
14
|
+
# github-card
|
|
15
|
+
|
|
16
|
+
**Beautiful GitHub stats cards for your README, site, or npm project.**
|
|
17
|
+
|
|
18
|
+
[](https://www.npmjs.com/package/github-card)
|
|
19
|
+
[](https://bun.sh)
|
|
20
|
+
[](LICENSE)
|
|
21
|
+
[](#testing)
|
|
22
|
+
|
|
23
|
+
[Live Demo](https://card.shiina.xyz/card/ShiinaSaku) ·
|
|
24
|
+
[All Themes](https://card.shiina.xyz/ShiinaSaku/themes) ·
|
|
25
|
+
[API Docs](https://card.shiina.xyz/openapi) ·
|
|
26
|
+
[npm Package](#programmatic-usage-npm)
|
|
27
|
+
|
|
28
|
+
</div>
|
|
29
|
+
|
|
30
|
+
---
|
|
31
|
+
|
|
32
|
+
## Embed in 10 seconds
|
|
33
|
+
|
|
34
|
+
```markdown
|
|
35
|
+

|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
Replace `YOUR_USERNAME`. Done.
|
|
39
|
+
|
|
40
|
+
### Dark mode aware
|
|
41
|
+
|
|
42
|
+
```html
|
|
43
|
+
<picture>
|
|
44
|
+
<source
|
|
45
|
+
media="(prefers-color-scheme: dark)"
|
|
46
|
+
srcset="https://card.shiina.xyz/card/YOUR_USERNAME?theme=github_dark"
|
|
47
|
+
/>
|
|
48
|
+
<img src="https://card.shiina.xyz/card/YOUR_USERNAME" alt="GitHub Stats" />
|
|
49
|
+
</picture>
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
---
|
|
53
|
+
|
|
54
|
+
## Themes
|
|
55
|
+
|
|
56
|
+
20 built-in themes. Pass `?theme=name` to use one.
|
|
57
|
+
|
|
58
|
+
<table>
|
|
59
|
+
<tr>
|
|
60
|
+
<td align="center"><img src="https://card.shiina.xyz/card/ShiinaSaku?theme=default" width="400" alt="default" /><br/><code>default</code></td>
|
|
61
|
+
<td align="center"><img src="https://card.shiina.xyz/card/ShiinaSaku?theme=github_dark" width="400" alt="github_dark" /><br/><code>github_dark</code></td>
|
|
62
|
+
</tr>
|
|
63
|
+
<tr>
|
|
64
|
+
<td align="center"><img src="https://card.shiina.xyz/card/ShiinaSaku?theme=tokyonight" width="400" alt="tokyonight" /><br/><code>tokyonight</code></td>
|
|
65
|
+
<td align="center"><img src="https://card.shiina.xyz/card/ShiinaSaku?theme=dracula" width="400" alt="dracula" /><br/><code>dracula</code></td>
|
|
66
|
+
</tr>
|
|
67
|
+
<tr>
|
|
68
|
+
<td align="center"><img src="https://card.shiina.xyz/card/ShiinaSaku?theme=nord" width="400" alt="nord" /><br/><code>nord</code></td>
|
|
69
|
+
<td align="center"><img src="https://card.shiina.xyz/card/ShiinaSaku?theme=radical" width="400" alt="radical" /><br/><code>radical</code></td>
|
|
70
|
+
</tr>
|
|
71
|
+
<tr>
|
|
72
|
+
<td align="center"><img src="https://card.shiina.xyz/card/ShiinaSaku?theme=synthwave" width="400" alt="synthwave" /><br/><code>synthwave</code></td>
|
|
73
|
+
<td align="center"><img src="https://card.shiina.xyz/card/ShiinaSaku?theme=rose" width="400" alt="rose" /><br/><code>rose</code></td>
|
|
74
|
+
</tr>
|
|
75
|
+
</table>
|
|
76
|
+
|
|
77
|
+
<details>
|
|
78
|
+
<summary><strong>All 20 themes</strong></summary>
|
|
79
|
+
<br/>
|
|
80
|
+
|
|
81
|
+
`default` `dark` `radical` `merko` `gruvbox` `tokyonight` `onedark` `cobalt` `synthwave` `highcontrast` `dracula` `monokai` `nord` `github_dark` `pearl` `slate` `forest` `rose` `sand`
|
|
82
|
+
|
|
83
|
+
Preview every theme with your own data:
|
|
84
|
+
|
|
85
|
+
```
|
|
86
|
+
https://card.shiina.xyz/YOUR_USERNAME/themes
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
</details>
|
|
90
|
+
|
|
91
|
+
---
|
|
92
|
+
|
|
93
|
+
## Parameters
|
|
94
|
+
|
|
95
|
+
All parameters are optional query strings on `/card/:username`.
|
|
96
|
+
|
|
97
|
+
### Appearance
|
|
98
|
+
|
|
99
|
+
| Parameter | Default | Description |
|
|
100
|
+
| :------------ | :-------- | :----------------------------------- |
|
|
101
|
+
| `theme` | `default` | One of the 20 built-in themes |
|
|
102
|
+
| `compact` | `false` | Hides bio, pronouns, language labels |
|
|
103
|
+
| `hide_border` | `false` | Removes the card border |
|
|
104
|
+
|
|
105
|
+
### Custom Colors
|
|
106
|
+
|
|
107
|
+
Pass hex values **without** `#`.
|
|
108
|
+
|
|
109
|
+
| Parameter | Description |
|
|
110
|
+
| :------------- | :----------------- |
|
|
111
|
+
| `bg_color` | Background |
|
|
112
|
+
| `title_color` | Name text |
|
|
113
|
+
| `text_color` | Body text & labels |
|
|
114
|
+
| `icon_color` | Stat icons |
|
|
115
|
+
| `border_color` | Card border |
|
|
116
|
+
|
|
117
|
+
### Data Filtering
|
|
118
|
+
|
|
119
|
+
| Parameter | Default | Description |
|
|
120
|
+
| :----------- | :--------- | :---------------------------------------------------------------------- |
|
|
121
|
+
| `lang_count` | `5` | Top languages shown (1--10) |
|
|
122
|
+
| `hide` | -- | Comma-separated: `stars,commits,issues,repos,prs` |
|
|
123
|
+
| `scope` | `personal` | `personal` / `org` / `all` -- see [scope details](#what-the-card-shows) |
|
|
124
|
+
| `orgs` | -- | Comma-separated org logins to include (filters contributed-to repos) |
|
|
125
|
+
|
|
126
|
+
### Examples
|
|
127
|
+
|
|
128
|
+
```bash
|
|
129
|
+
# Compact with 3 languages
|
|
130
|
+
https://card.shiina.xyz/card/ShiinaSaku?compact=true&lang_count=3
|
|
131
|
+
|
|
132
|
+
# GitHub dark colors
|
|
133
|
+
https://card.shiina.xyz/card/ShiinaSaku?bg_color=0d1117&text_color=c9d1d9&title_color=58a6ff
|
|
134
|
+
|
|
135
|
+
# Organization stats
|
|
136
|
+
https://card.shiina.xyz/card/ShiinaSaku?scope=all&orgs=oven-sh,elysiajs
|
|
137
|
+
|
|
138
|
+
# Hide specific stats
|
|
139
|
+
https://card.shiina.xyz/card/ShiinaSaku?hide=issues,prs,stars
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
---
|
|
143
|
+
|
|
144
|
+
## Programmatic Usage (npm)
|
|
145
|
+
|
|
146
|
+
Install the package to render cards in your own server, CLI tool, or script:
|
|
147
|
+
|
|
148
|
+
```bash
|
|
149
|
+
bun add github-card # or npm install github-card
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
### Render SVG from data
|
|
153
|
+
|
|
154
|
+
```typescript
|
|
155
|
+
import { renderCard, themes } from "github-card";
|
|
156
|
+
|
|
157
|
+
const svg = renderCard(
|
|
158
|
+
{
|
|
159
|
+
login: "octocat",
|
|
160
|
+
name: "The Octocat",
|
|
161
|
+
avatarUrl: "https://github.com/octocat.png",
|
|
162
|
+
bio: "Open source enthusiast",
|
|
163
|
+
pronouns: "they/them",
|
|
164
|
+
twitter: "github",
|
|
165
|
+
},
|
|
166
|
+
{ stars: 3200, commits: 1450, issues: 89, repos: 42, prs: 210 },
|
|
167
|
+
[
|
|
168
|
+
{ name: "TypeScript", size: 120000, color: "#3178c6" },
|
|
169
|
+
{ name: "Go", size: 80000, color: "#00ADD8" },
|
|
170
|
+
{ name: "Rust", size: 40000, color: "#dea584" },
|
|
171
|
+
],
|
|
172
|
+
{ theme: "tokyonight", compact: false },
|
|
173
|
+
);
|
|
174
|
+
|
|
175
|
+
// svg is a self-contained SVG string with embedded font
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
### Available exports
|
|
179
|
+
|
|
180
|
+
```typescript
|
|
181
|
+
import {
|
|
182
|
+
// Core renderer
|
|
183
|
+
renderCard,
|
|
184
|
+
type CardOpts,
|
|
185
|
+
|
|
186
|
+
// Types
|
|
187
|
+
type UserProfile,
|
|
188
|
+
type UserStats,
|
|
189
|
+
type LanguageStat,
|
|
190
|
+
type ProfileData,
|
|
191
|
+
type CardOptions,
|
|
192
|
+
|
|
193
|
+
// Theming
|
|
194
|
+
themes,
|
|
195
|
+
resolveColors,
|
|
196
|
+
type Theme,
|
|
197
|
+
|
|
198
|
+
// Utilities
|
|
199
|
+
kFormat, // 1500 -> "1.5k"
|
|
200
|
+
escapeXml, // "<>" -> "<>"
|
|
201
|
+
wrapText, // text wrapping with ellipsis
|
|
202
|
+
icon, // SVG stat icons (star, commit, pr, issue, repo, x)
|
|
203
|
+
icons, // raw SVG path data
|
|
204
|
+
getLangColor, // language -> hex color (650+ languages)
|
|
205
|
+
FONT_FACE, // base64 @font-face CSS rule
|
|
206
|
+
FONT_FAMILY, // font-family declaration
|
|
207
|
+
} from "github-card";
|
|
208
|
+
```
|
|
209
|
+
|
|
210
|
+
### Use as Elysia plugin
|
|
211
|
+
|
|
212
|
+
The default export is an Elysia app. Mount it into your own server:
|
|
213
|
+
|
|
214
|
+
```typescript
|
|
215
|
+
import { Elysia } from "elysia";
|
|
216
|
+
import githubCard from "github-card/server";
|
|
217
|
+
|
|
218
|
+
new Elysia()
|
|
219
|
+
.use(githubCard) // adds /card/:username, /:username/themes, etc.
|
|
220
|
+
.get("/my-route", () => "hello")
|
|
221
|
+
.listen(3000);
|
|
222
|
+
```
|
|
223
|
+
|
|
224
|
+
---
|
|
225
|
+
|
|
226
|
+
## Self-Hosting
|
|
227
|
+
|
|
228
|
+
### Vercel (recommended)
|
|
229
|
+
|
|
230
|
+
```bash
|
|
231
|
+
git clone https://github.com/ShiinaSaku/Github-Card.git
|
|
232
|
+
cd Github-Card
|
|
233
|
+
vercel deploy
|
|
234
|
+
```
|
|
235
|
+
|
|
236
|
+
Set environment variables:
|
|
237
|
+
|
|
238
|
+
```
|
|
239
|
+
GITHUB_TOKEN=ghp_xxxxxxxxxxxxxxxxxxxx # required
|
|
240
|
+
UPSTASH_REDIS_REST_URL=https://... # optional, L2 cache
|
|
241
|
+
UPSTASH_REDIS_REST_TOKEN=... # optional
|
|
242
|
+
```
|
|
243
|
+
|
|
244
|
+
### Local Development
|
|
245
|
+
|
|
246
|
+
```bash
|
|
247
|
+
bun install
|
|
248
|
+
echo "GITHUB_TOKEN=ghp_xxx" > .env
|
|
249
|
+
bun dev
|
|
250
|
+
# http://localhost:3000/card/ShiinaSaku
|
|
251
|
+
```
|
|
252
|
+
|
|
253
|
+
---
|
|
254
|
+
|
|
255
|
+
## API Reference
|
|
256
|
+
|
|
257
|
+
| Endpoint | Returns |
|
|
258
|
+
| :---------------------- | :------------------------------------------ |
|
|
259
|
+
| `GET /card/:username` | SVG stats card |
|
|
260
|
+
| `GET /:username/themes` | SVG grid of all themes |
|
|
261
|
+
| `GET /health` | JSON -- cache metrics, Redis status, uptime |
|
|
262
|
+
| `GET /openapi` | Interactive API documentation |
|
|
263
|
+
|
|
264
|
+
### Response Headers
|
|
265
|
+
|
|
266
|
+
| Header | Value |
|
|
267
|
+
| :---------------------------- | :-------------------------------------------------------------- |
|
|
268
|
+
| `Cache-Control` | `public, max-age=0, s-maxage=1800, stale-while-revalidate=1800` |
|
|
269
|
+
| `ETag` | Enables 304 Not Modified |
|
|
270
|
+
| `Server-Timing` | Per-request timing telemetry |
|
|
271
|
+
| `Access-Control-Allow-Origin` | `*` |
|
|
272
|
+
|
|
273
|
+
### Status Codes
|
|
274
|
+
|
|
275
|
+
| Code | Meaning |
|
|
276
|
+
| :--- | :------------------ |
|
|
277
|
+
| 200 | SVG card |
|
|
278
|
+
| 304 | Cached (ETag match) |
|
|
279
|
+
| 401 | Bad GitHub token |
|
|
280
|
+
| 404 | User not found |
|
|
281
|
+
| 422 | Invalid parameters |
|
|
282
|
+
| 429 | GitHub rate limited |
|
|
283
|
+
| 502 | GitHub API down |
|
|
284
|
+
|
|
285
|
+
---
|
|
286
|
+
|
|
287
|
+
## Architecture
|
|
288
|
+
|
|
289
|
+
```
|
|
290
|
+
Request -> Elysia (CORS, OpenAPI, Server-Timing)
|
|
291
|
+
-> GitHub GraphQL API (parallel personal + org fetches)
|
|
292
|
+
-> L1 In-Memory Cache (30m fresh / 30m stale SWR)
|
|
293
|
+
-> L2 Upstash Redis (optional, 60m TTL)
|
|
294
|
+
-> SVG Renderer (embedded Roboto WOFF2 font)
|
|
295
|
+
-> ETag / 304 / Cache-Control
|
|
296
|
+
```
|
|
297
|
+
|
|
298
|
+
| Layer | Tech |
|
|
299
|
+
| :-------- | :---------------------------------------- |
|
|
300
|
+
| Runtime | Bun |
|
|
301
|
+
| Framework | Elysia |
|
|
302
|
+
| Data | GitHub GraphQL API |
|
|
303
|
+
| Cache L1 | In-memory Map with SWR |
|
|
304
|
+
| Cache L2 | Upstash Redis (serverless) |
|
|
305
|
+
| Font | Roboto (subsetted WOFF2, base64 embedded) |
|
|
306
|
+
| Deploy | Vercel Functions |
|
|
307
|
+
| Tests | bun:test -- 21 tests |
|
|
308
|
+
| Lint | oxlint + oxfmt |
|
|
309
|
+
|
|
310
|
+
---
|
|
311
|
+
|
|
312
|
+
## Testing
|
|
313
|
+
|
|
314
|
+
```bash
|
|
315
|
+
bun test # 21 tests
|
|
316
|
+
bun test --coverage # with coverage
|
|
317
|
+
bun run typecheck # tsc
|
|
318
|
+
bun run lint # oxlint
|
|
319
|
+
```
|
|
320
|
+
|
|
321
|
+
---
|
|
322
|
+
|
|
323
|
+
## What the card shows
|
|
324
|
+
|
|
325
|
+
| Stat | Source |
|
|
326
|
+
| :-------- | :------------------------------------- |
|
|
327
|
+
| Stars | Total across owned repos (excl. forks) |
|
|
328
|
+
| Commits | Current calendar year |
|
|
329
|
+
| Issues | Open + closed |
|
|
330
|
+
| Repos | Owned, non-fork |
|
|
331
|
+
| PRs | Open + closed + merged |
|
|
332
|
+
| Languages | Top N by code size |
|
|
333
|
+
|
|
334
|
+
### Scope
|
|
335
|
+
|
|
336
|
+
| Value | Stars / Repos / Languages from | Speed |
|
|
337
|
+
| :--------- | :--------------------------------------------------- | :--------------------------------- |
|
|
338
|
+
| `personal` | Your own repos only | Single query |
|
|
339
|
+
| `org` | Org repos you actually contributed to (commits, PRs) | Parallel: metadata + contributions |
|
|
340
|
+
| `all` | Your repos + org repos you contributed to | Parallel: personal + contributions |
|
|
341
|
+
|
|
342
|
+
Org stats use GitHub's `repositoriesContributedTo` API -- only repos you actually touched are counted, not every repo in the organization. Filter to specific orgs with `orgs=org1,org2`.
|
|
343
|
+
|
|
344
|
+
---
|
|
345
|
+
|
|
346
|
+
## License
|
|
347
|
+
|
|
348
|
+
[MIT](LICENSE)
|
|
349
|
+
|
|
350
|
+
---
|
|
351
|
+
|
|
352
|
+
<div align="center">
|
|
353
|
+
<sub>Built with <a href="https://bun.sh">Bun</a> and <a href="https://elysiajs.com">Elysia</a>. Hosted on <a href="https://vercel.com">Vercel</a>.</sub>
|
|
354
|
+
</div>
|
|
Binary file
|
package/package.json
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@shiinasaku/github-card",
|
|
3
|
+
"version": "2.0.0",
|
|
4
|
+
"description": "Beautiful GitHub stats SVG cards. Embed in README, use as API, or render programmatically.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"badge",
|
|
7
|
+
"bun",
|
|
8
|
+
"elysia",
|
|
9
|
+
"github",
|
|
10
|
+
"github-card",
|
|
11
|
+
"github-stats",
|
|
12
|
+
"profile-card",
|
|
13
|
+
"readme",
|
|
14
|
+
"stats",
|
|
15
|
+
"svg"
|
|
16
|
+
],
|
|
17
|
+
"homepage": "https://card.shiina.xyz",
|
|
18
|
+
"license": "MIT",
|
|
19
|
+
"author": {
|
|
20
|
+
"name": "saku shiina",
|
|
21
|
+
"email": "saku@shiina.xyz",
|
|
22
|
+
"url": "https://www.shiina.xyz"
|
|
23
|
+
},
|
|
24
|
+
"repository": {
|
|
25
|
+
"type": "git",
|
|
26
|
+
"url": "https://github.com/ShiinaSaku/Github-Card.git"
|
|
27
|
+
},
|
|
28
|
+
"files": [
|
|
29
|
+
"src",
|
|
30
|
+
"fonts",
|
|
31
|
+
"README.md",
|
|
32
|
+
"LICENSE"
|
|
33
|
+
],
|
|
34
|
+
"type": "module",
|
|
35
|
+
"main": "./src/lib.ts",
|
|
36
|
+
"module": "./src/lib.ts",
|
|
37
|
+
"types": "./src/lib.ts",
|
|
38
|
+
"exports": {
|
|
39
|
+
".": {
|
|
40
|
+
"types": "./src/lib.ts",
|
|
41
|
+
"import": "./src/lib.ts"
|
|
42
|
+
},
|
|
43
|
+
"./server": {
|
|
44
|
+
"types": "./src/index.ts",
|
|
45
|
+
"import": "./src/index.ts"
|
|
46
|
+
}
|
|
47
|
+
},
|
|
48
|
+
"scripts": {
|
|
49
|
+
"dev": "bun run --watch src/index.ts",
|
|
50
|
+
"build": "bun build src/index.ts --target bun --outdir dist",
|
|
51
|
+
"fmt": "oxfmt",
|
|
52
|
+
"fmt:check": "oxfmt --check",
|
|
53
|
+
"lint": "oxlint",
|
|
54
|
+
"lint:fix": "oxlint --fix",
|
|
55
|
+
"typecheck": "tsc --noEmit --skipLibCheck",
|
|
56
|
+
"test": "bun test",
|
|
57
|
+
"test:coverage": "bun test --coverage"
|
|
58
|
+
},
|
|
59
|
+
"dependencies": {
|
|
60
|
+
"@elysiajs/cors": "^1.4.1",
|
|
61
|
+
"@elysiajs/openapi": "^1.4.14",
|
|
62
|
+
"@elysiajs/server-timing": "^1.4.0",
|
|
63
|
+
"@upstash/redis": "^1.36.2",
|
|
64
|
+
"elysia": "latest"
|
|
65
|
+
},
|
|
66
|
+
"devDependencies": {
|
|
67
|
+
"@types/bun": "latest",
|
|
68
|
+
"oxfmt": "0.33.0",
|
|
69
|
+
"oxlint": "1.48.0",
|
|
70
|
+
"typescript": "5.9.3"
|
|
71
|
+
}
|
|
72
|
+
}
|
package/src/card.ts
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import { resolveColors, kFormat, escapeXml, icon, wrapText, FONT_FACE, FONT_FAMILY } from "./utils";
|
|
2
|
+
import type { UserProfile, UserStats, LanguageStat } from "./types";
|
|
3
|
+
|
|
4
|
+
export type CardOpts = {
|
|
5
|
+
theme?: string;
|
|
6
|
+
title_color?: string;
|
|
7
|
+
text_color?: string;
|
|
8
|
+
icon_color?: string;
|
|
9
|
+
bg_color?: string;
|
|
10
|
+
border_color?: string;
|
|
11
|
+
hide_border?: boolean;
|
|
12
|
+
compact?: boolean;
|
|
13
|
+
hide?: string[];
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export function renderCard(
|
|
17
|
+
user: UserProfile,
|
|
18
|
+
stats: UserStats,
|
|
19
|
+
langs: LanguageStat[],
|
|
20
|
+
opts: CardOpts = {},
|
|
21
|
+
): string {
|
|
22
|
+
const c = resolveColors(opts);
|
|
23
|
+
const hideBorder = opts.hide_border ?? false;
|
|
24
|
+
const compact = opts.compact ?? false;
|
|
25
|
+
|
|
26
|
+
const name = escapeXml(user.name || user.login);
|
|
27
|
+
const uname = escapeXml(user.login);
|
|
28
|
+
const bioRaw = !compact && user.bio ? escapeXml(user.bio) : "";
|
|
29
|
+
const bioLines = bioRaw ? wrapText(bioRaw, 42, 2) : [];
|
|
30
|
+
const pronouns = !compact && user.pronouns ? escapeXml(user.pronouns) : "";
|
|
31
|
+
const avatar = user.avatarUrl.replace(/&/g, "&");
|
|
32
|
+
const twitter = !compact && user.twitter ? escapeXml(user.twitter) : "";
|
|
33
|
+
const hiddenStats = new Set((opts.hide || []).map((key) => key.toLowerCase()));
|
|
34
|
+
const svgTitle = `${name}'s GitHub Stats`;
|
|
35
|
+
|
|
36
|
+
const W = 500;
|
|
37
|
+
const H = 200;
|
|
38
|
+
const P = 22;
|
|
39
|
+
const avatarSize = 72;
|
|
40
|
+
const barWidth = W - P * 2;
|
|
41
|
+
const barY = H - 40;
|
|
42
|
+
const labelY = H - 16;
|
|
43
|
+
const infoX = P + avatarSize + 16;
|
|
44
|
+
|
|
45
|
+
const totalSize = langs.reduce((sum, l) => sum + l.size, 0) || 1;
|
|
46
|
+
let offset = 0;
|
|
47
|
+
const langRects = langs
|
|
48
|
+
.map((lang) => {
|
|
49
|
+
const w = (lang.size / totalSize) * barWidth;
|
|
50
|
+
const r = `<rect x="${P + offset}" y="${barY}" width="${w}" height="8" fill="${lang.color}"/>`;
|
|
51
|
+
offset += w;
|
|
52
|
+
return r;
|
|
53
|
+
})
|
|
54
|
+
.join("");
|
|
55
|
+
|
|
56
|
+
const labelSpacing = compact ? 0 : Math.floor(barWidth / Math.max(langs.length, 1));
|
|
57
|
+
const langLabels = compact
|
|
58
|
+
? ""
|
|
59
|
+
: langs
|
|
60
|
+
.map((lang, i) => {
|
|
61
|
+
const pct = ((lang.size / totalSize) * 100).toFixed(0);
|
|
62
|
+
return `<circle cx="${P + i * labelSpacing + 5}" cy="${labelY}" r="4" fill="${lang.color}"/><text x="${P + i * labelSpacing + 13}" y="${labelY + 4}" class="lang">${lang.name} ${pct}%</text>`;
|
|
63
|
+
})
|
|
64
|
+
.join("");
|
|
65
|
+
|
|
66
|
+
const nameY = P + 22;
|
|
67
|
+
const usernameY = nameY + 18;
|
|
68
|
+
const bioY = usernameY + 14;
|
|
69
|
+
const twitterY = bioLines.length ? bioY + 28 : usernameY + 16;
|
|
70
|
+
|
|
71
|
+
const headerY = P + avatarSize + 12;
|
|
72
|
+
const statLabelY = 28;
|
|
73
|
+
|
|
74
|
+
const visibleStats = [
|
|
75
|
+
{ key: "stars", iconName: "star" as const, label: "Stars", value: stats.stars },
|
|
76
|
+
{ key: "commits", iconName: "commit" as const, label: "Commits", value: stats.commits },
|
|
77
|
+
{ key: "issues", iconName: "issue" as const, label: "Issues", value: stats.issues },
|
|
78
|
+
{ key: "repos", iconName: "repo" as const, label: "Repos", value: stats.repos },
|
|
79
|
+
{ key: "prs", iconName: "pr" as const, label: "PRs", value: stats.prs },
|
|
80
|
+
].filter((stat) => !hiddenStats.has(stat.key));
|
|
81
|
+
|
|
82
|
+
const statsMarkup = visibleStats
|
|
83
|
+
.map(
|
|
84
|
+
(stat, index) => `<g transform="translate(${index * 100},0)">
|
|
85
|
+
${icon(stat.iconName, c.icon, 16)}
|
|
86
|
+
<text x="20" y="12" class="stat">${kFormat(stat.value)}</text>
|
|
87
|
+
<text x="0" y="${statLabelY}" class="stat-label">${stat.label}</text>
|
|
88
|
+
</g>`,
|
|
89
|
+
)
|
|
90
|
+
.join("");
|
|
91
|
+
|
|
92
|
+
return `<svg width="${W}" height="${H}" viewBox="0 0 ${W} ${H}" preserveAspectRatio="xMinYMin meet" xmlns="http://www.w3.org/2000/svg" role="img" aria-labelledby="title desc" shape-rendering="auto" text-rendering="optimizeLegibility" image-rendering="optimizeQuality">
|
|
93
|
+
<title id="title">${svgTitle}</title>
|
|
94
|
+
<desc id="desc">GitHub stats card for ${name} including stars, commits, issues, repositories, pull requests, and top languages.</desc>
|
|
95
|
+
<defs>
|
|
96
|
+
<clipPath id="a"><circle cx="${P + avatarSize / 2}" cy="${P + avatarSize / 2}" r="${avatarSize / 2}"/></clipPath>
|
|
97
|
+
<clipPath id="b"><rect x="${P}" y="${barY}" width="${barWidth}" height="8" rx="4"/></clipPath>
|
|
98
|
+
<filter id="shadow" x="-10%" y="-10%" width="130%" height="140%">
|
|
99
|
+
<feGaussianBlur in="SourceAlpha" stdDeviation="2"/>
|
|
100
|
+
<feOffset dx="0" dy="1" result="offsetblur"/>
|
|
101
|
+
<feComponentTransfer>
|
|
102
|
+
<feFuncA type="linear" slope="0.15"/>
|
|
103
|
+
</feComponentTransfer>
|
|
104
|
+
<feMerge>
|
|
105
|
+
<feMergeNode/>
|
|
106
|
+
<feMergeNode in="SourceGraphic"/>
|
|
107
|
+
</feMerge>
|
|
108
|
+
</filter>
|
|
109
|
+
</defs>
|
|
110
|
+
<style>
|
|
111
|
+
${FONT_FACE}
|
|
112
|
+
*{font-family:${FONT_FAMILY}}
|
|
113
|
+
.bg{fill:#${c.bg}}
|
|
114
|
+
.title{font-size:18px;font-weight:700;fill:#${c.title}}
|
|
115
|
+
.user{font-size:12px;fill:#${c.text};opacity:.7}
|
|
116
|
+
${compact ? "" : `.bio{font-size:11px;fill:#${c.text};opacity:.65}.tw{font-size:11px;fill:#${c.text};opacity:.7}.lang{font-size:10px;fill:#${c.text}}`}
|
|
117
|
+
.stat{font-size:14px;font-weight:700;fill:#${c.text};text-anchor:start}
|
|
118
|
+
.stat-label{font-size:9px;font-weight:600;fill:#${c.text};opacity:.55;text-transform:uppercase;letter-spacing:.6px;text-anchor:start}
|
|
119
|
+
.sec{font-size:9px;font-weight:600;fill:#${c.text};opacity:.5;text-transform:uppercase;letter-spacing:.6px}
|
|
120
|
+
</style>
|
|
121
|
+
<rect class="bg" width="${W}" height="${H}" rx="10" stroke="${hideBorder ? "none" : `#${c.border}`}" stroke-width="1.5" filter="url(#shadow)"/>
|
|
122
|
+
<circle cx="${P + avatarSize / 2}" cy="${P + avatarSize / 2}" r="${avatarSize / 2 + 2}" fill="none" stroke="#${c.border}" stroke-width="1" opacity=".6"/>
|
|
123
|
+
<image href="${avatar}" x="${P}" y="${P}" width="${avatarSize}" height="${avatarSize}" clip-path="url(#a)" filter="url(#shadow)" preserveAspectRatio="xMidYMid slice"/>
|
|
124
|
+
<text x="${infoX}" y="${nameY}" class="title">${name}</text>
|
|
125
|
+
<text x="${infoX}" y="${usernameY}" class="user">@${uname}${pronouns ? ` · ${pronouns}` : ""}</text>
|
|
126
|
+
${!compact && bioLines[0] ? `<text x="${infoX}" y="${bioY}" class="bio">${bioLines[0]}</text>` : ""}
|
|
127
|
+
${!compact && bioLines[1] ? `<text x="${infoX}" y="${bioY + 12}" class="bio">${bioLines[1]}</text>` : ""}
|
|
128
|
+
${!compact && twitter ? `<g transform="translate(${infoX},${twitterY - 9})">${icon("x", c.icon, 11)}<text x="14" y="9" class="tw">@${twitter}</text></g>` : ""}
|
|
129
|
+
<g transform="translate(${P},${headerY})">
|
|
130
|
+
${statsMarkup}
|
|
131
|
+
</g>
|
|
132
|
+
<text x="${P}" y="${barY - 8}" class="sec">Top Languages</text>
|
|
133
|
+
<rect x="${P}" y="${barY}" width="${barWidth}" height="8" rx="4" fill="#${c.text}" opacity=".1"/>
|
|
134
|
+
<g clip-path="url(#b)">${langRects}</g>
|
|
135
|
+
${langLabels}
|
|
136
|
+
</svg>`;
|
|
137
|
+
}
|