@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/src/index.ts
ADDED
|
@@ -0,0 +1,355 @@
|
|
|
1
|
+
import { Elysia, t } from "elysia";
|
|
2
|
+
import { cors } from "@elysiajs/cors";
|
|
3
|
+
import { openapi } from "@elysiajs/openapi";
|
|
4
|
+
import { serverTiming } from "@elysiajs/server-timing";
|
|
5
|
+
import {
|
|
6
|
+
AuthError,
|
|
7
|
+
getCacheMetrics,
|
|
8
|
+
getProfileData,
|
|
9
|
+
isRedisReachable,
|
|
10
|
+
NotFoundError,
|
|
11
|
+
RateLimitError,
|
|
12
|
+
UpstreamError,
|
|
13
|
+
} from "./github";
|
|
14
|
+
import { renderCard } from "./card";
|
|
15
|
+
import { themes } from "./utils";
|
|
16
|
+
|
|
17
|
+
const USERNAME_PATTERN = "^[a-zA-Z0-9-]{1,39}$";
|
|
18
|
+
const appStartedAt = Date.now();
|
|
19
|
+
|
|
20
|
+
function parseLangCount(value?: string): number {
|
|
21
|
+
if (!value) return 5;
|
|
22
|
+
const parsed = Number.parseInt(value, 10);
|
|
23
|
+
if (Number.isNaN(parsed)) return 5;
|
|
24
|
+
return Math.min(10, Math.max(1, parsed));
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function parseHiddenStats(value?: string): string[] {
|
|
28
|
+
if (!value) return [];
|
|
29
|
+
const allowed = new Set(["stars", "commits", "issues", "repos", "prs"]);
|
|
30
|
+
return Array.from(
|
|
31
|
+
new Set(
|
|
32
|
+
value
|
|
33
|
+
.split(",")
|
|
34
|
+
.map((v) => v.trim().toLowerCase())
|
|
35
|
+
.filter((v) => allowed.has(v)),
|
|
36
|
+
),
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function parseScope(value?: string): "personal" | "org" | "all" {
|
|
41
|
+
const normalized = value?.trim().toLowerCase();
|
|
42
|
+
if (normalized === "org" || normalized === "all") return normalized;
|
|
43
|
+
return "personal";
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function parseOrgs(value?: string): string[] {
|
|
47
|
+
if (!value) return [];
|
|
48
|
+
return Array.from(
|
|
49
|
+
new Set(
|
|
50
|
+
value
|
|
51
|
+
.split(",")
|
|
52
|
+
.map((v) => v.trim())
|
|
53
|
+
.filter(Boolean),
|
|
54
|
+
),
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function renderUserThemesSvg(
|
|
59
|
+
user: Parameters<typeof renderCard>[0],
|
|
60
|
+
stats: Parameters<typeof renderCard>[1],
|
|
61
|
+
langs: Parameters<typeof renderCard>[2],
|
|
62
|
+
cardOpts: Parameters<typeof renderCard>[3],
|
|
63
|
+
) {
|
|
64
|
+
const entries = Object.keys(themes);
|
|
65
|
+
const cols = 2;
|
|
66
|
+
const gap = 20;
|
|
67
|
+
const cardW = 500;
|
|
68
|
+
const cardH = 200;
|
|
69
|
+
const labelH = 22;
|
|
70
|
+
const cellH = cardH + labelH;
|
|
71
|
+
const rows = Math.ceil(entries.length / cols);
|
|
72
|
+
const width = cols * cardW + (cols + 1) * gap;
|
|
73
|
+
const height = rows * cellH + (rows + 1) * gap;
|
|
74
|
+
|
|
75
|
+
const blocks = entries
|
|
76
|
+
.map((name, index) => {
|
|
77
|
+
const col = index % cols;
|
|
78
|
+
const row = Math.floor(index / cols);
|
|
79
|
+
const x = gap + col * (cardW + gap);
|
|
80
|
+
const y = gap + row * (cellH + gap);
|
|
81
|
+
const cardSvg = renderCard(user, stats, langs, { ...cardOpts, theme: name });
|
|
82
|
+
const encoded = encodeURIComponent(cardSvg).replace(/'/g, "%27").replace(/"/g, "%22");
|
|
83
|
+
|
|
84
|
+
return `<g transform="translate(${x},${y})">
|
|
85
|
+
<text x="4" y="14" fill="#e2e8f0" font-size="13" font-family="Inter,ui-sans-serif,system-ui,-apple-system,Segoe UI,Roboto,Arial" font-weight="700">${name}</text>
|
|
86
|
+
<image href="data:image/svg+xml;utf8,${encoded}" x="0" y="${labelH}" width="${cardW}" height="${cardH}"/>
|
|
87
|
+
</g>`;
|
|
88
|
+
})
|
|
89
|
+
.join("");
|
|
90
|
+
|
|
91
|
+
return `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}" role="img" aria-label="GitHub Card themes for user">
|
|
92
|
+
<rect width="100%" height="100%" fill="#0b1220"/>
|
|
93
|
+
${blocks}
|
|
94
|
+
</svg>`;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const app = new Elysia({ name: "github-card" })
|
|
98
|
+
.error({
|
|
99
|
+
NOT_FOUND_ERROR: NotFoundError,
|
|
100
|
+
RATE_LIMIT_ERROR: RateLimitError,
|
|
101
|
+
AUTH_ERROR: AuthError,
|
|
102
|
+
UPSTREAM_ERROR: UpstreamError,
|
|
103
|
+
})
|
|
104
|
+
.onError(({ code, error, set }) => {
|
|
105
|
+
switch (code) {
|
|
106
|
+
case "NOT_FOUND_ERROR":
|
|
107
|
+
set.status = 404;
|
|
108
|
+
return { error: error.message };
|
|
109
|
+
case "RATE_LIMIT_ERROR":
|
|
110
|
+
set.status = 429;
|
|
111
|
+
return { error: error.message };
|
|
112
|
+
case "AUTH_ERROR":
|
|
113
|
+
set.status = 401;
|
|
114
|
+
return { error: error.message };
|
|
115
|
+
case "UPSTREAM_ERROR":
|
|
116
|
+
set.status = 502;
|
|
117
|
+
return { error: error.message };
|
|
118
|
+
}
|
|
119
|
+
})
|
|
120
|
+
.use(
|
|
121
|
+
cors({
|
|
122
|
+
origin: true,
|
|
123
|
+
methods: ["GET", "OPTIONS"],
|
|
124
|
+
allowedHeaders: ["Content-Type", "If-None-Match"],
|
|
125
|
+
exposeHeaders: ["ETag", "Cache-Control", "Content-Type"],
|
|
126
|
+
maxAge: 86400,
|
|
127
|
+
credentials: false,
|
|
128
|
+
}),
|
|
129
|
+
)
|
|
130
|
+
.use(
|
|
131
|
+
openapi({
|
|
132
|
+
documentation: {
|
|
133
|
+
info: {
|
|
134
|
+
title: "GitHub Profile Card API",
|
|
135
|
+
version: "1.3.0",
|
|
136
|
+
description: "Generate GitHub profile SVG cards for README and web embeds.",
|
|
137
|
+
},
|
|
138
|
+
tags: [
|
|
139
|
+
{ name: "Card", description: "SVG card endpoints" },
|
|
140
|
+
{ name: "Meta", description: "Service metadata" },
|
|
141
|
+
{ name: "Ops", description: "Operational endpoints" },
|
|
142
|
+
],
|
|
143
|
+
},
|
|
144
|
+
}),
|
|
145
|
+
)
|
|
146
|
+
.use(serverTiming())
|
|
147
|
+
.get(
|
|
148
|
+
"/",
|
|
149
|
+
() => ({
|
|
150
|
+
message: "GitHub Profile Card API",
|
|
151
|
+
usage: "GET /card/:username",
|
|
152
|
+
themes: "GET /:username/themes",
|
|
153
|
+
docs: "/openapi",
|
|
154
|
+
}),
|
|
155
|
+
{
|
|
156
|
+
detail: { tags: ["Meta"], summary: "API metadata" },
|
|
157
|
+
response: {
|
|
158
|
+
200: t.Object({
|
|
159
|
+
message: t.String(),
|
|
160
|
+
usage: t.String(),
|
|
161
|
+
themes: t.String(),
|
|
162
|
+
docs: t.String(),
|
|
163
|
+
}),
|
|
164
|
+
},
|
|
165
|
+
},
|
|
166
|
+
)
|
|
167
|
+
.get(
|
|
168
|
+
"/meta",
|
|
169
|
+
() => ({
|
|
170
|
+
message: "GitHub Profile Card API",
|
|
171
|
+
usage: "GET /card/:username",
|
|
172
|
+
themes:
|
|
173
|
+
"default, dark, radical, merko, gruvbox, tokyonight, onedark, cobalt, synthwave, highcontrast, dracula, monokai, nord, github_dark, pearl, slate, forest, rose, sand",
|
|
174
|
+
}),
|
|
175
|
+
{
|
|
176
|
+
detail: { tags: ["Meta"], summary: "API metadata" },
|
|
177
|
+
},
|
|
178
|
+
)
|
|
179
|
+
.get(
|
|
180
|
+
"/:username/themes",
|
|
181
|
+
async ({ params: { username }, query, headers, set }) => {
|
|
182
|
+
const langCount = parseLangCount(query.lang_count);
|
|
183
|
+
const hiddenStats = parseHiddenStats(query.hide);
|
|
184
|
+
const scope = parseScope(query.scope);
|
|
185
|
+
const orgs = parseOrgs(query.orgs);
|
|
186
|
+
const fields = query.fields
|
|
187
|
+
? new Set(
|
|
188
|
+
query.fields
|
|
189
|
+
.split(",")
|
|
190
|
+
.map((v) => v.trim().toLowerCase())
|
|
191
|
+
.filter(Boolean),
|
|
192
|
+
)
|
|
193
|
+
: null;
|
|
194
|
+
const includeLanguages =
|
|
195
|
+
!fields || fields.has("all") || fields.has("languages") || fields.has("langs");
|
|
196
|
+
const data = await getProfileData(username, { includeLanguages, langCount, scope, orgs });
|
|
197
|
+
const svg = renderUserThemesSvg(data.user, data.stats, data.languages, {
|
|
198
|
+
compact: query.compact === "true",
|
|
199
|
+
hide_border: query.hide_border === "true",
|
|
200
|
+
hide: hiddenStats,
|
|
201
|
+
});
|
|
202
|
+
const etag = `W/"${Bun.hash(svg).toString(36)}"`;
|
|
203
|
+
if (headers["if-none-match"] === etag) {
|
|
204
|
+
set.status = 304;
|
|
205
|
+
set.headers["ETag"] = etag;
|
|
206
|
+
set.headers["Cache-Control"] =
|
|
207
|
+
"public, max-age=0, s-maxage=1800, stale-while-revalidate=1800";
|
|
208
|
+
return "";
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
set.headers["Content-Type"] = "image/svg+xml";
|
|
212
|
+
set.headers["ETag"] = etag;
|
|
213
|
+
set.headers["Cache-Control"] =
|
|
214
|
+
"public, max-age=0, s-maxage=1800, stale-while-revalidate=1800";
|
|
215
|
+
return svg;
|
|
216
|
+
},
|
|
217
|
+
{
|
|
218
|
+
detail: { tags: ["Card"], summary: "Render all built-in themes for a username" },
|
|
219
|
+
params: t.Object({ username: t.String({ pattern: USERNAME_PATTERN }) }),
|
|
220
|
+
query: t.Object({
|
|
221
|
+
compact: t.Optional(t.String()),
|
|
222
|
+
hide_border: t.Optional(t.String()),
|
|
223
|
+
fields: t.Optional(t.String()),
|
|
224
|
+
hide: t.Optional(t.String()),
|
|
225
|
+
lang_count: t.Optional(t.String()),
|
|
226
|
+
scope: t.Optional(t.Union([t.Literal("personal"), t.Literal("org"), t.Literal("all")])),
|
|
227
|
+
orgs: t.Optional(t.String()),
|
|
228
|
+
}),
|
|
229
|
+
response: {
|
|
230
|
+
200: t.String({ description: "SVG markup" }),
|
|
231
|
+
304: t.String(),
|
|
232
|
+
401: t.Object({ error: t.String() }),
|
|
233
|
+
404: t.Object({ error: t.String() }),
|
|
234
|
+
429: t.Object({ error: t.String() }),
|
|
235
|
+
500: t.Object({ error: t.String() }),
|
|
236
|
+
502: t.Object({ error: t.String() }),
|
|
237
|
+
},
|
|
238
|
+
},
|
|
239
|
+
)
|
|
240
|
+
.get(
|
|
241
|
+
"/health",
|
|
242
|
+
async () => {
|
|
243
|
+
const cache = getCacheMetrics();
|
|
244
|
+
const redisReachable = await isRedisReachable();
|
|
245
|
+
return {
|
|
246
|
+
status: "ok",
|
|
247
|
+
uptimeSeconds: Math.floor((Date.now() - appStartedAt) / 1000),
|
|
248
|
+
runtime: "vercel-function",
|
|
249
|
+
region: Bun.env.VERCEL_REGION || null,
|
|
250
|
+
cache,
|
|
251
|
+
redisReachable,
|
|
252
|
+
};
|
|
253
|
+
},
|
|
254
|
+
{
|
|
255
|
+
detail: { tags: ["Ops"], summary: "Service health and cache telemetry" },
|
|
256
|
+
response: {
|
|
257
|
+
200: t.Object({
|
|
258
|
+
status: t.String(),
|
|
259
|
+
uptimeSeconds: t.Number(),
|
|
260
|
+
runtime: t.String(),
|
|
261
|
+
region: t.Union([t.String(), t.Null()]),
|
|
262
|
+
cache: t.Object({
|
|
263
|
+
total: t.Number(),
|
|
264
|
+
fresh: t.Number(),
|
|
265
|
+
stale: t.Number(),
|
|
266
|
+
expired: t.Number(),
|
|
267
|
+
ttlFreshSeconds: t.Number(),
|
|
268
|
+
ttlStaleSeconds: t.Number(),
|
|
269
|
+
}),
|
|
270
|
+
redisReachable: t.Boolean(),
|
|
271
|
+
}),
|
|
272
|
+
},
|
|
273
|
+
},
|
|
274
|
+
)
|
|
275
|
+
.get(
|
|
276
|
+
"/card/:username",
|
|
277
|
+
async ({ params: { username }, query, headers, set }) => {
|
|
278
|
+
const langCount = parseLangCount(query.lang_count);
|
|
279
|
+
const hiddenStats = parseHiddenStats(query.hide);
|
|
280
|
+
const scope = parseScope(query.scope);
|
|
281
|
+
const orgs = parseOrgs(query.orgs);
|
|
282
|
+
const fields = query.fields
|
|
283
|
+
? new Set(
|
|
284
|
+
query.fields
|
|
285
|
+
.split(",")
|
|
286
|
+
.map((v) => v.trim().toLowerCase())
|
|
287
|
+
.filter(Boolean),
|
|
288
|
+
)
|
|
289
|
+
: null;
|
|
290
|
+
const includeLanguages =
|
|
291
|
+
!fields || fields.has("all") || fields.has("languages") || fields.has("langs");
|
|
292
|
+
const data = await getProfileData(username, { includeLanguages, langCount, scope, orgs });
|
|
293
|
+
const svg = renderCard(data.user, data.stats, data.languages, {
|
|
294
|
+
theme: query.theme,
|
|
295
|
+
title_color: query.title_color,
|
|
296
|
+
text_color: query.text_color,
|
|
297
|
+
icon_color: query.icon_color,
|
|
298
|
+
bg_color: query.bg_color,
|
|
299
|
+
border_color: query.border_color,
|
|
300
|
+
hide_border: query.hide_border === "true",
|
|
301
|
+
compact: query.compact === "true",
|
|
302
|
+
hide: hiddenStats,
|
|
303
|
+
});
|
|
304
|
+
const etag = `W/"${Bun.hash(svg).toString(36)}"`;
|
|
305
|
+
if (headers["if-none-match"] === etag) {
|
|
306
|
+
set.status = 304;
|
|
307
|
+
set.headers["ETag"] = etag;
|
|
308
|
+
set.headers["Cache-Control"] =
|
|
309
|
+
"public, max-age=0, s-maxage=1800, stale-while-revalidate=1800";
|
|
310
|
+
return "";
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
set.headers["Content-Type"] = "image/svg+xml";
|
|
314
|
+
set.headers["ETag"] = etag;
|
|
315
|
+
set.headers["Cache-Control"] =
|
|
316
|
+
"public, max-age=0, s-maxage=1800, stale-while-revalidate=1800";
|
|
317
|
+
return svg;
|
|
318
|
+
},
|
|
319
|
+
{
|
|
320
|
+
detail: { tags: ["Card"], summary: "Render GitHub profile card SVG" },
|
|
321
|
+
params: t.Object({ username: t.String({ pattern: USERNAME_PATTERN }) }),
|
|
322
|
+
query: t.Object({
|
|
323
|
+
theme: t.Optional(t.String()),
|
|
324
|
+
title_color: t.Optional(t.String()),
|
|
325
|
+
text_color: t.Optional(t.String()),
|
|
326
|
+
icon_color: t.Optional(t.String()),
|
|
327
|
+
bg_color: t.Optional(t.String()),
|
|
328
|
+
border_color: t.Optional(t.String()),
|
|
329
|
+
hide_border: t.Optional(t.String()),
|
|
330
|
+
compact: t.Optional(t.String()),
|
|
331
|
+
fields: t.Optional(t.String()),
|
|
332
|
+
hide: t.Optional(t.String()),
|
|
333
|
+
lang_count: t.Optional(t.String()),
|
|
334
|
+
scope: t.Optional(t.Union([t.Literal("personal"), t.Literal("org"), t.Literal("all")])),
|
|
335
|
+
orgs: t.Optional(t.String()),
|
|
336
|
+
}),
|
|
337
|
+
response: {
|
|
338
|
+
200: t.String({ description: "SVG markup" }),
|
|
339
|
+
304: t.String(),
|
|
340
|
+
401: t.Object({ error: t.String() }),
|
|
341
|
+
404: t.Object({ error: t.String() }),
|
|
342
|
+
429: t.Object({ error: t.String() }),
|
|
343
|
+
500: t.Object({ error: t.String() }),
|
|
344
|
+
502: t.Object({ error: t.String() }),
|
|
345
|
+
},
|
|
346
|
+
},
|
|
347
|
+
);
|
|
348
|
+
|
|
349
|
+
if (import.meta.main) {
|
|
350
|
+
const port = Number(Bun.env.PORT || 3000);
|
|
351
|
+
app.listen(port);
|
|
352
|
+
console.log(`Dev server running on http://localhost:${port}`);
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
export default app;
|
package/src/lib.ts
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* github-card — npm entry point
|
|
3
|
+
*
|
|
4
|
+
* Re-exports the pure SVG renderer, all themes, types, and utility functions.
|
|
5
|
+
* No server dependency — works in any JS/TS runtime.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
// Core renderer
|
|
9
|
+
export { renderCard, type CardOpts } from "./card";
|
|
10
|
+
|
|
11
|
+
// Types
|
|
12
|
+
export type { UserProfile, UserStats, LanguageStat, ProfileData, CardOptions } from "./types";
|
|
13
|
+
|
|
14
|
+
// Theming
|
|
15
|
+
export { themes, resolveColors, type Theme } from "./utils/themes";
|
|
16
|
+
|
|
17
|
+
// Utilities
|
|
18
|
+
export { kFormat, escapeXml, wrapText } from "./utils/format";
|
|
19
|
+
export { icons, icon } from "./utils/icons";
|
|
20
|
+
export { getLangColor } from "./utils/languages";
|
|
21
|
+
export { FONT_FACE, FONT_FAMILY } from "./utils/font";
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
export interface UserProfile {
|
|
2
|
+
login: string;
|
|
3
|
+
name: string | null;
|
|
4
|
+
avatarUrl: string;
|
|
5
|
+
bio: string | null;
|
|
6
|
+
pronouns: string | null;
|
|
7
|
+
twitter: string | null;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface UserStats {
|
|
11
|
+
stars: number;
|
|
12
|
+
repos: number;
|
|
13
|
+
prs: number;
|
|
14
|
+
issues: number;
|
|
15
|
+
commits: number;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface LanguageStat {
|
|
19
|
+
name: string;
|
|
20
|
+
size: number;
|
|
21
|
+
color: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface ProfileData {
|
|
25
|
+
user: UserProfile;
|
|
26
|
+
stats: UserStats;
|
|
27
|
+
languages: LanguageStat[];
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface CardOptions {
|
|
31
|
+
theme?: string;
|
|
32
|
+
title_color?: string;
|
|
33
|
+
text_color?: string;
|
|
34
|
+
icon_color?: string;
|
|
35
|
+
bg_color?: string;
|
|
36
|
+
border_color?: string;
|
|
37
|
+
hide_border?: boolean;
|
|
38
|
+
compact?: boolean;
|
|
39
|
+
hide?: string[];
|
|
40
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Roboto variable font embedded as base64 WOFF2 for SVG @font-face.
|
|
3
|
+
*
|
|
4
|
+
* The subset (~47KB / ~63KB base64) covers:
|
|
5
|
+
* - ASCII printable (U+0020-007E)
|
|
6
|
+
* - Latin-1 Supplement (U+00A0-00FF)
|
|
7
|
+
* - Common typographic chars (dashes, quotes, ellipsis, bullets)
|
|
8
|
+
* - Variable weight axis (400-700)
|
|
9
|
+
*
|
|
10
|
+
* Loaded once at startup via Bun.file — zero per-request cost.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
const fontPath = new URL("../../fonts/Roboto-subset.woff2", import.meta.url).pathname;
|
|
14
|
+
const fontBytes = await Bun.file(fontPath).arrayBuffer();
|
|
15
|
+
const fontBase64 = Buffer.from(fontBytes).toString("base64");
|
|
16
|
+
|
|
17
|
+
export const FONT_FAMILY = "'Roboto', sans-serif";
|
|
18
|
+
|
|
19
|
+
export const FONT_FACE = `@font-face{font-family:'Roboto';font-style:normal;font-weight:100 900;font-display:swap;src:url(data:font/woff2;base64,${fontBase64}) format('woff2')}`;
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
export function kFormat(num: number): string {
|
|
2
|
+
if (num >= 1000000) return (num / 1000000).toFixed(1).replace(/\.0$/, "") + "M";
|
|
3
|
+
if (num >= 1000) return (num / 1000).toFixed(1).replace(/\.0$/, "") + "k";
|
|
4
|
+
return String(num);
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export function escapeXml(str: string): string {
|
|
8
|
+
return str
|
|
9
|
+
.replace(/&/g, "&")
|
|
10
|
+
.replace(/</g, "<")
|
|
11
|
+
.replace(/>/g, ">")
|
|
12
|
+
.replace(/"/g, """)
|
|
13
|
+
.replace(/'/g, "'");
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function wrapText(input: string, maxLen: number, maxLines: number): string[] {
|
|
17
|
+
const words = input.split(/\s+/).filter(Boolean);
|
|
18
|
+
const lines: string[] = [];
|
|
19
|
+
let current = "";
|
|
20
|
+
for (const w of words) {
|
|
21
|
+
const next = current ? `${current} ${w}` : w;
|
|
22
|
+
if (next.length <= maxLen) {
|
|
23
|
+
current = next;
|
|
24
|
+
continue;
|
|
25
|
+
}
|
|
26
|
+
if (current) lines.push(current);
|
|
27
|
+
current = w;
|
|
28
|
+
if (lines.length >= maxLines - 1) break;
|
|
29
|
+
}
|
|
30
|
+
if (current && lines.length < maxLines) lines.push(current);
|
|
31
|
+
if (lines.length === 0 && input) lines.push(input.slice(0, maxLen));
|
|
32
|
+
if (lines.length === maxLines && words.length > 0) {
|
|
33
|
+
const last = lines[lines.length - 1];
|
|
34
|
+
if (!last) return lines;
|
|
35
|
+
if (last.length > maxLen) {
|
|
36
|
+
lines[lines.length - 1] = last.slice(0, Math.max(0, maxLen - 1)) + "…";
|
|
37
|
+
} else if (words.join(" ").length > lines.join(" ").length) {
|
|
38
|
+
lines[lines.length - 1] = last.slice(0, Math.max(0, maxLen - 1)) + "…";
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
return lines;
|
|
42
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
export const icons = {
|
|
2
|
+
star: `<path fill-rule="evenodd" d="M8 .25a.75.75 0 01.673.418l1.882 3.815 4.21.612a.75.75 0 01.416 1.279l-3.046 2.97.719 4.192a.75.75 0 01-1.088.791L8 12.347l-3.766 1.98a.75.75 0 01-1.088-.79l.72-4.194L.818 6.374a.75.75 0 01.416-1.28l4.21-.611L7.327.668A.75.75 0 018 .25zm0 2.445L6.615 5.5a.75.75 0 01-.564.41l-3.097.45 2.24 2.184a.75.75 0 01.216.664l-.528 3.084 2.769-1.456a.75.75 0 01.698 0l2.77 1.456-.53-3.084a.75.75 0 01.216-.664l2.24-2.183-3.096-.45a.75.75 0 01-.564-.41L8 2.694v.001z"/>`,
|
|
3
|
+
commit: `<path fill-rule="evenodd" d="M10.5 7.75a2.5 2.5 0 11-5 0 2.5 2.5 0 015 0zm1.43.75a4.002 4.002 0 01-7.86 0H.75a.75.75 0 110-1.5h3.32a4.001 4.001 0 017.86 0h3.32a.75.75 0 110 1.5h-3.32z"/>`,
|
|
4
|
+
pr: `<path fill-rule="evenodd" d="M7.177 3.073L9.573.677A.25.25 0 0110 .854v4.792a.25.25 0 01-.427.177L7.177 3.427a.25.25 0 010-.354zM3.75 2.5a.75.75 0 100 1.5.75.75 0 000-1.5zm-2.25.75a2.25 2.25 0 113 2.122v5.256a2.251 2.251 0 11-1.5 0V5.372A2.25 2.25 0 011.5 3.25zM11 2.5h-1V4h1a1 1 0 011 1v5.628a2.251 2.251 0 101.5 0V5A2.5 2.5 0 0011 2.5zm1 10.25a.75.75 0 111.5 0 .75.75 0 01-1.5 0zM3.75 12a.75.75 0 100 1.5.75.75 0 000-1.5z"/>`,
|
|
5
|
+
issue: `<path fill-rule="evenodd" d="M8 1.5a6.5 6.5 0 100 13 6.5 6.5 0 000-13zM0 8a8 8 0 1116 0A8 8 0 010 8zm9 3a1 1 0 11-2 0 1 1 0 012 0zm-.25-6.25a.75.75 0 00-1.5 0v3.5a.75.75 0 001.5 0v-3.5z"/>`,
|
|
6
|
+
repo: `<path fill-rule="evenodd" d="M5 3.25a.75.75 0 11-1.5 0 .75.75 0 011.5 0zm0 2.122a2.25 2.25 0 10-1.5 0v.878A2.25 2.25 0 005.75 8.5h1.5v2.128a2.251 2.251 0 101.5 0V8.5h1.5a2.25 2.25 0 002.25-2.25v-.878a2.25 2.25 0 10-1.5 0v.878a.75.75 0 01-.75.75h-4.5A.75.75 0 015 6.25v-.878zm3.75 7.378a.75.75 0 11-1.5 0 .75.75 0 011.5 0zm3-8.75a.75.75 0 100-1.5.75.75 0 000 1.5z"></path>`,
|
|
7
|
+
x: `<path d="M4 4l11.733 16h4.267l-11.733 -16l-4.267 0" /><path d="M4 20l6.768 -6.768m2.46 -2.46l6.772 -6.772" />`,
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
export function icon(name: keyof typeof icons, color: string, size = 16): string {
|
|
11
|
+
const path = icons[name];
|
|
12
|
+
if (!path) return "";
|
|
13
|
+
const viewBox = name === "x" ? "0 0 24 24" : "0 0 16 16";
|
|
14
|
+
const fill =
|
|
15
|
+
name === "x" ? `stroke="#${color}" stroke-width="2" fill="none"` : `fill="#${color}"`;
|
|
16
|
+
return `<svg width="${size}" height="${size}" viewBox="${viewBox}" ${fill} role="img">${path}</svg>`;
|
|
17
|
+
}
|