@shiinasaku/github-card 4.1.0 → 5.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/README.md +182 -98
- package/dist/card.d.ts +2 -15
- package/dist/lib.d.ts +1 -1
- package/dist/lib.js +3951 -380
- package/dist/types.d.ts +8 -3
- package/dist/utils/font.d.ts +3 -1
- package/dist/utils/format.d.ts +7 -0
- package/dist/utils/icons.d.ts +3 -1
- package/dist/utils/index.d.ts +5 -6
- package/dist/utils/languages.d.ts +0 -1
- package/package.json +16 -19
- package/src/card.ts +392 -467
- package/src/gallery.ts +37 -0
- package/src/github.ts +180 -78
- package/src/index.ts +91 -167
- package/src/lib.ts +1 -1
- package/src/types.ts +8 -3
- package/src/utils/font.ts +4 -2
- package/src/utils/format.ts +62 -22
- package/src/utils/github-client.ts +43 -133
- package/src/utils/icons.ts +31 -10
- package/src/utils/index.ts +5 -6
- package/src/utils/languages.ts +1 -1
- package/fonts/Roboto-subset.woff2 +0 -0
- package/src/env.d.ts +0 -4
- package/src/input.css +0 -1
- package/src/tailwind.css +0 -3837
package/src/index.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { Elysia, t } from "elysia";
|
|
1
|
+
import { Elysia, t, type Context, type UnwrapSchema } from "elysia";
|
|
2
2
|
import { cors } from "@elysia/cors";
|
|
3
3
|
import { openapi } from "@elysia/openapi";
|
|
4
4
|
import { serverTiming } from "@elysia/server-timing";
|
|
@@ -13,10 +13,13 @@ import {
|
|
|
13
13
|
} from "./github";
|
|
14
14
|
import { renderCard, type CardOpts } from "./card";
|
|
15
15
|
import { themes } from "./utils";
|
|
16
|
+
import { renderThemeGallery } from "./gallery";
|
|
17
|
+
import { version } from "../package.json";
|
|
16
18
|
|
|
17
19
|
const USERNAME_PATTERN = "^[a-zA-Z0-9-]{1,39}$";
|
|
18
20
|
const CACHE_CONTROL_HEADER = "public, max-age=0, s-maxage=1800, stale-while-revalidate=1800";
|
|
19
21
|
const EDGE_CACHE_CONTROL_HEADER = "public, s-maxage=1800, stale-while-revalidate=1800";
|
|
22
|
+
const DEFAULT_AFFILIATIONS = "owner" as const;
|
|
20
23
|
const appStartedAt = Date.now();
|
|
21
24
|
|
|
22
25
|
const CardQuerySchema = t.Object({
|
|
@@ -29,22 +32,30 @@ const CardQuerySchema = t.Object({
|
|
|
29
32
|
hide_border: t.Optional(t.BooleanString()),
|
|
30
33
|
compact: t.Optional(t.BooleanString()),
|
|
31
34
|
animate: t.Optional(t.BooleanString()),
|
|
32
|
-
fields: t.Optional(t.
|
|
33
|
-
hide: t.Optional(t.
|
|
34
|
-
hide_langs: t.Optional(t.
|
|
35
|
-
show_langs: t.Optional(t.
|
|
35
|
+
fields: t.Optional(t.Array(t.String())),
|
|
36
|
+
hide: t.Optional(t.Array(t.String())),
|
|
37
|
+
hide_langs: t.Optional(t.Array(t.String())),
|
|
38
|
+
show_langs: t.Optional(t.Array(t.String())),
|
|
36
39
|
lang_count: t.Optional(t.Integer({ minimum: 1, maximum: 10 })),
|
|
37
40
|
scope: t.Optional(t.Union([t.Literal("personal"), t.Literal("org"), t.Literal("all")])),
|
|
38
41
|
affiliations: t.Optional(t.Union([t.Literal("owner"), t.Literal("affiliated")])),
|
|
39
|
-
orgs: t.Optional(t.
|
|
42
|
+
orgs: t.Optional(t.Array(t.String())),
|
|
40
43
|
});
|
|
41
44
|
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
45
|
+
const CardParamsSchema = t.Object({ username: t.String({ pattern: USERNAME_PATTERN }) });
|
|
46
|
+
const ErrorSchema = t.Object({ error: t.String() });
|
|
47
|
+
const CardResponseSchema = {
|
|
48
|
+
200: t.String({ description: "SVG markup" }),
|
|
49
|
+
304: t.String(),
|
|
50
|
+
401: ErrorSchema,
|
|
51
|
+
404: ErrorSchema,
|
|
52
|
+
429: ErrorSchema,
|
|
53
|
+
502: ErrorSchema,
|
|
46
54
|
};
|
|
47
55
|
|
|
56
|
+
type CardQuery = UnwrapSchema<typeof CardQuerySchema>;
|
|
57
|
+
type ResponseSet = Context["set"];
|
|
58
|
+
|
|
48
59
|
function normalizeList(values?: string[]): string[] {
|
|
49
60
|
if (!values?.length) return [];
|
|
50
61
|
return values.map((value) => value.trim()).filter(Boolean);
|
|
@@ -61,11 +72,6 @@ function parseHiddenStats(values?: string[]): string[] {
|
|
|
61
72
|
);
|
|
62
73
|
}
|
|
63
74
|
|
|
64
|
-
function parseScope(value?: string): "personal" | "org" | "all" {
|
|
65
|
-
if (value === "org" || value === "all") return value;
|
|
66
|
-
return "personal";
|
|
67
|
-
}
|
|
68
|
-
|
|
69
75
|
function parseOrgs(values?: string[]): string[] {
|
|
70
76
|
return Array.from(new Set(normalizeList(values).map((value) => value.toLowerCase())));
|
|
71
77
|
}
|
|
@@ -97,72 +103,35 @@ function buildCardOptions(query: CardQuery, hide: string[]): CardOpts {
|
|
|
97
103
|
};
|
|
98
104
|
}
|
|
99
105
|
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
const data = await getProfileData(username, {
|
|
110
|
-
includeLanguages,
|
|
111
|
-
langCount,
|
|
112
|
-
scope,
|
|
113
|
-
affiliations,
|
|
114
|
-
orgs,
|
|
106
|
+
function fetchProfile(username: string, query: CardQuery) {
|
|
107
|
+
return getProfileData(username, {
|
|
108
|
+
includeLanguages: shouldIncludeLanguages(parseFieldSet(query.fields)),
|
|
109
|
+
langCount: query.lang_count ?? 5,
|
|
110
|
+
hideLangs: query.hide_langs,
|
|
111
|
+
showLangs: query.show_langs,
|
|
112
|
+
scope: query.scope ?? "personal",
|
|
113
|
+
affiliations: query.affiliations ?? DEFAULT_AFFILIATIONS,
|
|
114
|
+
orgs: parseOrgs(query.orgs),
|
|
115
115
|
});
|
|
116
|
-
|
|
117
|
-
return renderCard(data.user, data.stats, data.languages, buildCardOptions(query, hiddenStats));
|
|
118
116
|
}
|
|
119
117
|
|
|
120
|
-
function
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
const gap = 20;
|
|
129
|
-
const cardW = 500;
|
|
130
|
-
const cardH = 200;
|
|
131
|
-
const labelH = 22;
|
|
132
|
-
const cellH = cardH + labelH;
|
|
133
|
-
const rows = Math.ceil(entries.length / cols);
|
|
134
|
-
const width = cols * cardW + (cols + 1) * gap;
|
|
135
|
-
const height = rows * cellH + (rows + 1) * gap;
|
|
136
|
-
|
|
137
|
-
const blocks = entries
|
|
138
|
-
.map((name, index) => {
|
|
139
|
-
const col = index % cols;
|
|
140
|
-
const row = Math.floor(index / cols);
|
|
141
|
-
const x = gap + col * (cardW + gap);
|
|
142
|
-
const y = gap + row * (cellH + gap) + labelH;
|
|
143
|
-
// Inline the card as a nested <svg> element (GitHub camo-safe, unlike data-URI <image>)
|
|
144
|
-
const cardSvg = renderCard(user, stats, langs, { ...cardOpts, theme: name }).replace(
|
|
145
|
-
"<svg ",
|
|
146
|
-
`<svg x="${x}" y="${y}" `,
|
|
147
|
-
);
|
|
148
|
-
|
|
149
|
-
return `<text x="${x + 4}" y="${y - 8}" fill="#e2e8f0" font-size="13" font-family="Inter,ui-sans-serif,system-ui,-apple-system,Segoe UI,Roboto,Arial" font-weight="700">${name}</text>${cardSvg}`;
|
|
150
|
-
})
|
|
151
|
-
.join("");
|
|
152
|
-
|
|
153
|
-
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">
|
|
154
|
-
<rect width="100%" height="100%" fill="#0b1220"/>
|
|
155
|
-
${blocks}
|
|
156
|
-
</svg>`;
|
|
118
|
+
async function buildCardSvg(username: string, query: CardQuery): Promise<string> {
|
|
119
|
+
const data = await fetchProfile(username, query);
|
|
120
|
+
return renderCard(
|
|
121
|
+
data.user,
|
|
122
|
+
data.stats,
|
|
123
|
+
data.languages,
|
|
124
|
+
buildCardOptions(query, parseHiddenStats(query.hide)),
|
|
125
|
+
);
|
|
157
126
|
}
|
|
158
127
|
|
|
159
|
-
function applyCacheHeaders(set:
|
|
128
|
+
function applyCacheHeaders(set: ResponseSet) {
|
|
160
129
|
set.headers["Cache-Control"] = CACHE_CONTROL_HEADER;
|
|
161
130
|
set.headers["CDN-Cache-Control"] = EDGE_CACHE_CONTROL_HEADER;
|
|
162
131
|
set.headers["Vercel-CDN-Cache-Control"] = EDGE_CACHE_CONTROL_HEADER;
|
|
163
132
|
}
|
|
164
133
|
|
|
165
|
-
function setSvgHeaders(svg: string, ifNoneMatch: string | undefined, set:
|
|
134
|
+
function setSvgHeaders(svg: string, ifNoneMatch: string | undefined, set: ResponseSet): string {
|
|
166
135
|
const etag = `W/"${Bun.hash(svg).toString(36)}"`;
|
|
167
136
|
if (ifNoneMatch === etag) {
|
|
168
137
|
set.status = 304;
|
|
@@ -177,29 +146,11 @@ function setSvgHeaders(svg: string, ifNoneMatch: string | undefined, set: Mutabl
|
|
|
177
146
|
return svg;
|
|
178
147
|
}
|
|
179
148
|
|
|
180
|
-
const app = new Elysia({ name: "github-card" })
|
|
181
|
-
.error({
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
UPSTREAM_ERROR: UpstreamError,
|
|
186
|
-
})
|
|
187
|
-
.onError(({ code, error, set }) => {
|
|
188
|
-
switch (code) {
|
|
189
|
-
case "NOT_FOUND_ERROR":
|
|
190
|
-
set.status = 404;
|
|
191
|
-
return { error: error.message };
|
|
192
|
-
case "RATE_LIMIT_ERROR":
|
|
193
|
-
set.status = 429;
|
|
194
|
-
return { error: error.message };
|
|
195
|
-
case "AUTH_ERROR":
|
|
196
|
-
set.status = 401;
|
|
197
|
-
return { error: error.message };
|
|
198
|
-
case "UPSTREAM_ERROR":
|
|
199
|
-
set.status = 502;
|
|
200
|
-
return { error: error.message };
|
|
201
|
-
}
|
|
202
|
-
})
|
|
149
|
+
export const app = new Elysia({ name: "github-card" })
|
|
150
|
+
.onError(NotFoundError, ({ error, status }) => status(404, { error: error.message }))
|
|
151
|
+
.onError(RateLimitError, ({ error, status }) => status(429, { error: error.message }))
|
|
152
|
+
.onError(AuthError, ({ error, status }) => status(401, { error: error.message }))
|
|
153
|
+
.onError(UpstreamError, ({ error, status }) => status(502, { error: error.message }))
|
|
203
154
|
.use(
|
|
204
155
|
cors({
|
|
205
156
|
origin: true,
|
|
@@ -215,9 +166,9 @@ const app = new Elysia({ name: "github-card" })
|
|
|
215
166
|
documentation: {
|
|
216
167
|
info: {
|
|
217
168
|
title: "GitHub Profile Card API",
|
|
218
|
-
version
|
|
169
|
+
version,
|
|
219
170
|
description:
|
|
220
|
-
"Generate GitHub profile SVG
|
|
171
|
+
"Generate GitHub profile SVG cards for README, web embeds, and social previews.",
|
|
221
172
|
},
|
|
222
173
|
tags: [
|
|
223
174
|
{ name: "Card", description: "Card endpoints" },
|
|
@@ -230,12 +181,6 @@ const app = new Elysia({ name: "github-card" })
|
|
|
230
181
|
.use(serverTiming())
|
|
231
182
|
.get(
|
|
232
183
|
"/",
|
|
233
|
-
() => ({
|
|
234
|
-
message: "GitHub Profile Card API",
|
|
235
|
-
usage: "GET /card/:username",
|
|
236
|
-
themes: "GET /:username/themes",
|
|
237
|
-
docs: "/openapi",
|
|
238
|
-
}),
|
|
239
184
|
{
|
|
240
185
|
detail: { tags: ["Meta"], summary: "API metadata" },
|
|
241
186
|
response: {
|
|
@@ -247,74 +192,48 @@ const app = new Elysia({ name: "github-card" })
|
|
|
247
192
|
}),
|
|
248
193
|
},
|
|
249
194
|
},
|
|
195
|
+
() => ({
|
|
196
|
+
message: "GitHub Profile Card API",
|
|
197
|
+
usage: "GET /card/:username",
|
|
198
|
+
themes: "GET /:username/themes",
|
|
199
|
+
docs: "/openapi",
|
|
200
|
+
}),
|
|
250
201
|
)
|
|
251
202
|
.get(
|
|
252
203
|
"/meta",
|
|
204
|
+
{
|
|
205
|
+
detail: { tags: ["Meta"], summary: "API metadata" },
|
|
206
|
+
},
|
|
253
207
|
() => ({
|
|
254
208
|
message: "GitHub Profile Card API",
|
|
255
209
|
usage: "GET /card/:username",
|
|
256
210
|
themes: Object.keys(themes).join(", "),
|
|
257
|
-
affiliations: "
|
|
211
|
+
affiliations: "owner (default), affiliated",
|
|
258
212
|
organizationSupport: "Use /card/:organization-login",
|
|
259
213
|
}),
|
|
260
|
-
{
|
|
261
|
-
detail: { tags: ["Meta"], summary: "API metadata" },
|
|
262
|
-
},
|
|
263
214
|
)
|
|
264
215
|
.get(
|
|
265
216
|
"/:username/themes",
|
|
266
|
-
async ({ params: { username }, query, headers, set }) => {
|
|
267
|
-
const q = query as CardQuery;
|
|
268
|
-
const scope = parseScope(q.scope);
|
|
269
|
-
const orgs = parseOrgs(q.orgs);
|
|
270
|
-
const fields = parseFieldSet(q.fields);
|
|
271
|
-
const includeLanguages = shouldIncludeLanguages(fields);
|
|
272
|
-
const data = await getProfileData(username, {
|
|
273
|
-
includeLanguages,
|
|
274
|
-
langCount: q.lang_count ?? 5,
|
|
275
|
-
scope,
|
|
276
|
-
affiliations: q.affiliations ?? "affiliated",
|
|
277
|
-
orgs,
|
|
278
|
-
});
|
|
279
|
-
|
|
280
|
-
const svg = renderUserThemesSvg(data.user, data.stats, data.languages, {
|
|
281
|
-
compact: q.compact ?? false,
|
|
282
|
-
hide_border: q.hide_border ?? false,
|
|
283
|
-
hide: parseHiddenStats(q.hide),
|
|
284
|
-
animate: q.animate ?? false,
|
|
285
|
-
});
|
|
286
|
-
|
|
287
|
-
return setSvgHeaders(svg, headers["if-none-match"], set as MutableSet);
|
|
288
|
-
},
|
|
289
217
|
{
|
|
290
218
|
detail: { tags: ["Card"], summary: "Render all built-in themes for a username" },
|
|
291
|
-
params:
|
|
219
|
+
params: CardParamsSchema,
|
|
292
220
|
query: CardQuerySchema,
|
|
293
|
-
response:
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
221
|
+
response: CardResponseSchema,
|
|
222
|
+
},
|
|
223
|
+
async ({ params: { username }, query, headers, set }) => {
|
|
224
|
+
const data = await fetchProfile(username, query);
|
|
225
|
+
const svg = renderThemeGallery(
|
|
226
|
+
data.user,
|
|
227
|
+
data.stats,
|
|
228
|
+
data.languages,
|
|
229
|
+
buildCardOptions(query, parseHiddenStats(query.hide)),
|
|
230
|
+
);
|
|
231
|
+
|
|
232
|
+
return setSvgHeaders(svg, headers["if-none-match"], set);
|
|
302
233
|
},
|
|
303
234
|
)
|
|
304
235
|
.get(
|
|
305
236
|
"/health",
|
|
306
|
-
async () => {
|
|
307
|
-
const cache = getCacheMetrics();
|
|
308
|
-
const redisReachable = await isRedisReachable();
|
|
309
|
-
return {
|
|
310
|
-
status: "ok",
|
|
311
|
-
uptimeSeconds: Math.floor((Date.now() - appStartedAt) / 1000),
|
|
312
|
-
runtime: "vercel-function",
|
|
313
|
-
region: Bun.env.VERCEL_REGION || null,
|
|
314
|
-
cache,
|
|
315
|
-
redisReachable,
|
|
316
|
-
};
|
|
317
|
-
},
|
|
318
237
|
{
|
|
319
238
|
detail: { tags: ["Ops"], summary: "Service health and cache telemetry" },
|
|
320
239
|
response: {
|
|
@@ -336,33 +255,38 @@ const app = new Elysia({ name: "github-card" })
|
|
|
336
255
|
}),
|
|
337
256
|
},
|
|
338
257
|
},
|
|
258
|
+
async () => {
|
|
259
|
+
const cache = getCacheMetrics();
|
|
260
|
+
const redisReachable = await isRedisReachable();
|
|
261
|
+
return {
|
|
262
|
+
status: "ok",
|
|
263
|
+
uptimeSeconds: Math.floor((Date.now() - appStartedAt) / 1000),
|
|
264
|
+
runtime: Bun.env.VERCEL ? "vercel-function" : "bun",
|
|
265
|
+
region: Bun.env.VERCEL_REGION || null,
|
|
266
|
+
cache,
|
|
267
|
+
redisReachable,
|
|
268
|
+
};
|
|
269
|
+
},
|
|
339
270
|
)
|
|
340
271
|
.get(
|
|
341
272
|
"/card/:username",
|
|
342
|
-
async ({ params: { username }, query, headers, set }) => {
|
|
343
|
-
const svg = await buildCardSvg(username, query as CardQuery);
|
|
344
|
-
return setSvgHeaders(svg, headers["if-none-match"], set as MutableSet);
|
|
345
|
-
},
|
|
346
273
|
{
|
|
347
274
|
detail: { tags: ["Card"], summary: "Render GitHub profile card SVG" },
|
|
348
|
-
params:
|
|
275
|
+
params: CardParamsSchema,
|
|
349
276
|
query: CardQuerySchema,
|
|
350
|
-
response:
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
429: t.Object({ error: t.String() }),
|
|
356
|
-
500: t.Object({ error: t.String() }),
|
|
357
|
-
502: t.Object({ error: t.String() }),
|
|
358
|
-
},
|
|
277
|
+
response: CardResponseSchema,
|
|
278
|
+
},
|
|
279
|
+
async ({ params: { username }, query, headers, set }) => {
|
|
280
|
+
const svg = await buildCardSvg(username, query);
|
|
281
|
+
return setSvgHeaders(svg, headers["if-none-match"], set);
|
|
359
282
|
},
|
|
360
283
|
);
|
|
361
284
|
|
|
285
|
+
export type App = typeof app;
|
|
286
|
+
export default app;
|
|
287
|
+
|
|
362
288
|
if (import.meta.main) {
|
|
363
289
|
const port = Number(Bun.env.PORT || 3000);
|
|
364
290
|
app.listen(port);
|
|
365
291
|
console.log(`Dev server running on http://localhost:${port}`);
|
|
366
292
|
}
|
|
367
|
-
|
|
368
|
-
export default app;
|
package/src/lib.ts
CHANGED
|
@@ -15,7 +15,7 @@ export type { UserProfile, UserStats, LanguageStat, ProfileData, CardOptions } f
|
|
|
15
15
|
export { themes, resolveColors, type Theme } from "./utils/themes";
|
|
16
16
|
|
|
17
17
|
// Utilities
|
|
18
|
-
export { kFormat, escapeXml, wrapText } from "./utils/format";
|
|
18
|
+
export { kFormat, escapeXml, wrapText, measureText } from "./utils/format";
|
|
19
19
|
export { icons, icon } from "./utils/icons";
|
|
20
20
|
export { getLangColor } from "./utils/languages";
|
|
21
21
|
export { FONT_FACE, FONT_FAMILY } from "./utils/font";
|
package/src/types.ts
CHANGED
|
@@ -10,9 +10,10 @@ export interface UserProfile {
|
|
|
10
10
|
export interface UserStats {
|
|
11
11
|
stars: number;
|
|
12
12
|
repos: number;
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
13
|
+
/** null means unavailable, not zero (for example, organization activity totals). */
|
|
14
|
+
prs: number | null;
|
|
15
|
+
issues: number | null;
|
|
16
|
+
commits: number | null;
|
|
16
17
|
}
|
|
17
18
|
|
|
18
19
|
export interface LanguageStat {
|
|
@@ -37,5 +38,9 @@ export interface CardOptions {
|
|
|
37
38
|
hide_border?: boolean;
|
|
38
39
|
compact?: boolean;
|
|
39
40
|
hide?: string[];
|
|
41
|
+
hide_langs?: string[];
|
|
42
|
+
show_langs?: string[];
|
|
40
43
|
animate?: boolean;
|
|
44
|
+
/** Stable namespace for multiple cards embedded in the same SVG document. */
|
|
45
|
+
idPrefix?: string;
|
|
41
46
|
}
|
package/src/utils/font.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
/** System font stack — no shipped font, respects the user's OS. */
|
|
1
2
|
export const FONT_FAMILY =
|
|
2
|
-
'ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"';
|
|
3
|
-
|
|
3
|
+
'ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"';
|
|
4
|
+
/** @deprecated No shipped font — kept for API backwards compatibility. */
|
|
5
|
+
export const FONT_FACE = "";
|
package/src/utils/format.ts
CHANGED
|
@@ -1,12 +1,51 @@
|
|
|
1
1
|
export function kFormat(num: number): string {
|
|
2
|
+
const sign = num < 0 ? "-" : "";
|
|
3
|
+
const abs = Math.abs(num);
|
|
2
4
|
const trim = (v: number) => v.toFixed(1).replace(/\.0$/, "");
|
|
3
|
-
if (
|
|
4
|
-
if (
|
|
5
|
-
|
|
5
|
+
if (abs >= 999_950_000) return sign + trim(abs / 1_000_000_000) + "B";
|
|
6
|
+
if (abs >= 999_950) return sign + trim(abs / 1_000_000) + "M";
|
|
7
|
+
if (abs >= 999.95) return sign + trim(abs / 1_000) + "k";
|
|
8
|
+
return sign + String(Math.round(abs));
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Approximate rendered width of a string in SVG user units for the default
|
|
13
|
+
* sans-serif stack. Widths are per-character averages tuned to the card's
|
|
14
|
+
* font sizes and weights, so layout can reserve enough space without relying
|
|
15
|
+
* on browser text measurement (which is unavailable server-side).
|
|
16
|
+
*/
|
|
17
|
+
export function measureText(text: string, size: number, weight = 400): number {
|
|
18
|
+
if (!text) return 0;
|
|
19
|
+
const factor = size;
|
|
20
|
+
const weightFactor = weight >= 700 ? 1.08 : weight >= 600 ? 1.04 : 1;
|
|
21
|
+
let width = 0;
|
|
22
|
+
for (const ch of text) {
|
|
23
|
+
const code = ch.codePointAt(0)!;
|
|
24
|
+
let w: number;
|
|
25
|
+
if (ch === " ") w = 0.28;
|
|
26
|
+
else if (ch === "." || ch === "," || ch === ":" || ch === ";") w = 0.3;
|
|
27
|
+
else if (ch === "i" || ch === "l" || ch === "I" || ch === "t" || ch === "j" || ch === "|")
|
|
28
|
+
w = 0.3;
|
|
29
|
+
else if (ch === "m" || ch === "w" || ch === "W" || ch === "M" || ch === "@") w = 0.95;
|
|
30
|
+
else if (code >= 0x1100 && code <= 0x11ff)
|
|
31
|
+
w = 1.0; // Hangul
|
|
32
|
+
else if (code >= 0x4e00 && code <= 0x9fff)
|
|
33
|
+
w = 1.0; // CJK
|
|
34
|
+
else if (code >= 0x3040 && code <= 0x30ff)
|
|
35
|
+
w = 1.0; // Kana
|
|
36
|
+
else if (code >= 0xac00 && code <= 0xd7af)
|
|
37
|
+
w = 1.0; // Hangul syllables
|
|
38
|
+
else if (code > 0x7f)
|
|
39
|
+
w = 0.72; // other non-ASCII (emoji, accents)
|
|
40
|
+
else w = 0.58; // default latin
|
|
41
|
+
width += w;
|
|
42
|
+
}
|
|
43
|
+
return width * factor * weightFactor;
|
|
6
44
|
}
|
|
7
45
|
|
|
8
46
|
export function escapeXml(str: string): string {
|
|
9
47
|
return str
|
|
48
|
+
.replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\ufffe\uffff\ud800-\udfff]/gu, "")
|
|
10
49
|
.replace(/&/g, "&")
|
|
11
50
|
.replace(/</g, "<")
|
|
12
51
|
.replace(/>/g, ">")
|
|
@@ -15,28 +54,29 @@ export function escapeXml(str: string): string {
|
|
|
15
54
|
}
|
|
16
55
|
|
|
17
56
|
export function wrapText(input: string, maxLen: number, maxLines: number): string[] {
|
|
18
|
-
|
|
57
|
+
if (maxLen < 1 || maxLines < 1) return [];
|
|
58
|
+
let remaining = Array.from(input.trim().replace(/\s+/g, " "));
|
|
19
59
|
const lines: string[] = [];
|
|
20
|
-
let current = "";
|
|
21
60
|
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
continue;
|
|
61
|
+
while (remaining.length && lines.length < maxLines) {
|
|
62
|
+
if (remaining.length <= maxLen) {
|
|
63
|
+
lines.push(remaining.join(""));
|
|
64
|
+
break;
|
|
27
65
|
}
|
|
28
|
-
if (
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
const
|
|
38
|
-
|
|
39
|
-
|
|
66
|
+
if (lines.length === maxLines - 1) {
|
|
67
|
+
lines.push(
|
|
68
|
+
remaining
|
|
69
|
+
.slice(0, maxLen - 1)
|
|
70
|
+
.join("")
|
|
71
|
+
.trimEnd() + "…",
|
|
72
|
+
);
|
|
73
|
+
break;
|
|
74
|
+
}
|
|
75
|
+
const space = remaining.lastIndexOf(" ", maxLen);
|
|
76
|
+
const end = space > 0 ? space : maxLen;
|
|
77
|
+
lines.push(remaining.slice(0, end).join(""));
|
|
78
|
+
remaining = remaining.slice(end);
|
|
79
|
+
if (remaining[0] === " ") remaining.shift();
|
|
40
80
|
}
|
|
41
81
|
return lines;
|
|
42
82
|
}
|