@shiinasaku/github-card 4.1.0 → 4.2.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 +155 -98
- package/dist/lib.d.ts +1 -1
- package/dist/lib.js +258 -120
- 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 +1 -1
- package/package.json +8 -9
- package/src/card.ts +307 -404
- package/src/github.ts +107 -49
- package/src/index.ts +7 -6
- package/src/lib.ts +1 -1
- package/src/utils/font.ts +4 -2
- package/src/utils/format.ts +41 -3
- package/src/utils/github-client.ts +13 -8
- package/src/utils/icons.ts +26 -9
- package/src/utils/index.ts +1 -1
- package/fonts/Roboto-subset.woff2 +0 -0
package/src/github.ts
CHANGED
|
@@ -11,13 +11,13 @@ import {
|
|
|
11
11
|
export { NotFoundError, RateLimitError, AuthError, UpstreamError };
|
|
12
12
|
|
|
13
13
|
const githubClient = new GitHubClient({
|
|
14
|
-
token: Bun.env.GITHUB_TOKEN
|
|
14
|
+
token: Bun.env.GITHUB_TOKEN ?? "",
|
|
15
15
|
userAgent: "github-card",
|
|
16
16
|
});
|
|
17
17
|
|
|
18
18
|
const CACHE_FRESH_MS = 30 * 60 * 1000;
|
|
19
19
|
const CACHE_STALE_MS = 30 * 60 * 1000;
|
|
20
|
-
const CACHE_VERSION =
|
|
20
|
+
const CACHE_VERSION = 8;
|
|
21
21
|
const REDIS_TTL_SECONDS = Math.ceil((CACHE_FRESH_MS + CACHE_STALE_MS) / 1000);
|
|
22
22
|
const FETCH_TIMEOUT_MS = 6000;
|
|
23
23
|
const AVATAR_TIMEOUT_MS = 4000;
|
|
@@ -123,8 +123,8 @@ export function getCacheMetrics() {
|
|
|
123
123
|
fresh,
|
|
124
124
|
stale,
|
|
125
125
|
expired,
|
|
126
|
-
ttlFreshSeconds:
|
|
127
|
-
ttlStaleSeconds:
|
|
126
|
+
ttlFreshSeconds: CACHE_FRESH_MS / 1000,
|
|
127
|
+
ttlStaleSeconds: CACHE_STALE_MS / 1000,
|
|
128
128
|
};
|
|
129
129
|
}
|
|
130
130
|
|
|
@@ -145,6 +145,44 @@ type FetchOptions = {
|
|
|
145
145
|
forceRefresh?: boolean;
|
|
146
146
|
};
|
|
147
147
|
|
|
148
|
+
type RepoNode = {
|
|
149
|
+
nameWithOwner: string;
|
|
150
|
+
stargazers: { totalCount: number };
|
|
151
|
+
owner?: { login: string; __typename: string } | null;
|
|
152
|
+
languages?: { edges: LangEdge[] | null } | null;
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
type PageInfo = { hasNextPage: boolean; endCursor: string | null };
|
|
156
|
+
type RepoConnection = { pageInfo: PageInfo; nodes: RepoNode[] | null };
|
|
157
|
+
|
|
158
|
+
type ProfileUser = {
|
|
159
|
+
login: string;
|
|
160
|
+
name: string | null;
|
|
161
|
+
avatarUrl: string;
|
|
162
|
+
bio: string | null;
|
|
163
|
+
pronouns: string | null;
|
|
164
|
+
twitterUsername: string | null;
|
|
165
|
+
openPRs: { totalCount: number };
|
|
166
|
+
closedPRs: { totalCount: number };
|
|
167
|
+
mergedPRs: { totalCount: number };
|
|
168
|
+
openIssues: { totalCount: number };
|
|
169
|
+
closedIssues: { totalCount: number };
|
|
170
|
+
contributionsCollection: {
|
|
171
|
+
totalCommitContributions: number;
|
|
172
|
+
commitContributionsByRepository?: ContributionEntry[] | null;
|
|
173
|
+
pullRequestContributionsByRepository?: ContributionEntry[] | null;
|
|
174
|
+
issueContributionsByRepository?: ContributionEntry[] | null;
|
|
175
|
+
};
|
|
176
|
+
};
|
|
177
|
+
|
|
178
|
+
type OrgProfile = {
|
|
179
|
+
login: string;
|
|
180
|
+
name: string | null;
|
|
181
|
+
avatarUrl: string;
|
|
182
|
+
description: string | null;
|
|
183
|
+
twitterUsername: string | null;
|
|
184
|
+
};
|
|
185
|
+
|
|
148
186
|
const QUERY_PROFILE = `
|
|
149
187
|
query fullProfile($login: String!, $from: DateTime!, $to: DateTime!, $isOrg: Boolean!) {
|
|
150
188
|
user(login: $login) {
|
|
@@ -205,21 +243,23 @@ query paginateOrg($login: String!, $cursor: String, $pageSize: Int!, $fetchLangs
|
|
|
205
243
|
}
|
|
206
244
|
`;
|
|
207
245
|
|
|
208
|
-
async function postGraphQL(query: string, variables: Record<string, unknown>) {
|
|
209
|
-
return githubClient.request(query, {
|
|
246
|
+
async function postGraphQL<T>(query: string, variables: Record<string, unknown>): Promise<T> {
|
|
247
|
+
return githubClient.request<T>(query, {
|
|
210
248
|
variables,
|
|
211
249
|
timeoutMs: FETCH_TIMEOUT_MS,
|
|
212
250
|
});
|
|
213
251
|
}
|
|
214
252
|
|
|
215
253
|
type LangMap = Map<string, { size: number; color: string }>;
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
if (
|
|
222
|
-
|
|
254
|
+
type LangEdge = { size: number; node: { name: string; color: string | null } };
|
|
255
|
+
|
|
256
|
+
function accLangs(edges: LangEdge[] | null | undefined, map: LangMap): void {
|
|
257
|
+
if (!edges?.length) return;
|
|
258
|
+
for (const edge of edges) {
|
|
259
|
+
if (!edge?.node?.name || !edge.size) continue;
|
|
260
|
+
const existing = map.get(edge.node.name);
|
|
261
|
+
if (existing) existing.size += edge.size;
|
|
262
|
+
else map.set(edge.node.name, { size: edge.size, color: edge.node.color ?? "#ccc" });
|
|
223
263
|
}
|
|
224
264
|
}
|
|
225
265
|
|
|
@@ -244,15 +284,24 @@ async function fetchAvatarAsBase64(url: string | null | undefined): Promise<stri
|
|
|
244
284
|
}
|
|
245
285
|
}
|
|
246
286
|
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
287
|
+
type ContributionEntry = {
|
|
288
|
+
repository?: { owner?: { login?: string; __typename?: string } | null } | null;
|
|
289
|
+
contributions?: { totalCount?: number } | null;
|
|
290
|
+
};
|
|
291
|
+
|
|
292
|
+
function sumContributions(
|
|
293
|
+
entries: ContributionEntry[] | null | undefined,
|
|
294
|
+
orgsSet: Set<string> | null,
|
|
295
|
+
): number {
|
|
296
|
+
if (!entries?.length) return 0;
|
|
297
|
+
let total = 0;
|
|
298
|
+
for (const entry of entries) {
|
|
299
|
+
const owner = entry?.repository?.owner;
|
|
251
300
|
if (owner?.__typename !== "Organization" || !owner?.login) continue;
|
|
252
301
|
if (orgsSet && !orgsSet.has(owner.login.toLowerCase())) continue;
|
|
253
|
-
|
|
302
|
+
total += entry?.contributions?.totalCount ?? 0;
|
|
254
303
|
}
|
|
255
|
-
return
|
|
304
|
+
return total;
|
|
256
305
|
}
|
|
257
306
|
|
|
258
307
|
async function directFetch(username: string, opts: FetchOptions): Promise<ProfileData> {
|
|
@@ -268,10 +317,10 @@ async function directFetch(username: string, opts: FetchOptions): Promise<Profil
|
|
|
268
317
|
const from = new Date(Date.UTC(now.getUTCFullYear(), 0, 1)).toISOString();
|
|
269
318
|
const to = now.toISOString();
|
|
270
319
|
|
|
271
|
-
let user:
|
|
320
|
+
let user: ProfileUser | OrgProfile | null = null;
|
|
272
321
|
let isOrgAccount = false;
|
|
273
322
|
try {
|
|
274
|
-
const data = await postGraphQL(QUERY_PROFILE, {
|
|
323
|
+
const data = await postGraphQL<{ user: ProfileUser | null }>(QUERY_PROFILE, {
|
|
275
324
|
login: username,
|
|
276
325
|
from,
|
|
277
326
|
to,
|
|
@@ -281,7 +330,7 @@ async function directFetch(username: string, opts: FetchOptions): Promise<Profil
|
|
|
281
330
|
if (!user) throw new NotFoundError();
|
|
282
331
|
} catch (err) {
|
|
283
332
|
if (err instanceof NotFoundError) {
|
|
284
|
-
const data = await postGraphQL(QUERY_ORG, {
|
|
333
|
+
const data = await postGraphQL<{ organization: OrgProfile | null }>(QUERY_ORG, {
|
|
285
334
|
login: username,
|
|
286
335
|
});
|
|
287
336
|
user = data.organization;
|
|
@@ -295,35 +344,40 @@ async function directFetch(username: string, opts: FetchOptions): Promise<Profil
|
|
|
295
344
|
let stars = 0;
|
|
296
345
|
let reposCount = 0;
|
|
297
346
|
|
|
298
|
-
const processNode = (
|
|
299
|
-
if (!
|
|
347
|
+
const processNode = (node: RepoNode, scopeCheck: boolean): void => {
|
|
348
|
+
if (!node?.nameWithOwner || seenRepos.has(node.nameWithOwner)) return;
|
|
300
349
|
if (
|
|
301
350
|
scopeCheck &&
|
|
302
|
-
|
|
351
|
+
node.owner?.__typename === "Organization" &&
|
|
303
352
|
orgsSet &&
|
|
304
|
-
!orgsSet.has(
|
|
353
|
+
!orgsSet.has(node.owner.login.toLowerCase())
|
|
305
354
|
)
|
|
306
355
|
return;
|
|
307
|
-
seenRepos.add(
|
|
308
|
-
stars +=
|
|
356
|
+
seenRepos.add(node.nameWithOwner);
|
|
357
|
+
stars += node.stargazers?.totalCount ?? 0;
|
|
309
358
|
reposCount++;
|
|
310
|
-
if (fetchLangs) accLangs(
|
|
359
|
+
if (fetchLangs) accLangs(node.languages?.edges ?? null, langMap);
|
|
311
360
|
};
|
|
312
361
|
|
|
313
|
-
|
|
362
|
+
type PaginateData = {
|
|
363
|
+
user?: { repositories?: RepoConnection; repositoriesContributedTo?: RepoConnection } | null;
|
|
364
|
+
organization?: { repositories: RepoConnection } | null;
|
|
365
|
+
};
|
|
366
|
+
|
|
367
|
+
const getConnection = (data: PaginateData, query: string): RepoConnection | undefined =>
|
|
314
368
|
data.user
|
|
315
369
|
? query.includes("repositoriesContributedTo")
|
|
316
|
-
? data.user.repositoriesContributedTo
|
|
317
|
-
: data.user.repositories
|
|
370
|
+
? (data.user.repositoriesContributedTo ?? undefined)
|
|
371
|
+
: (data.user.repositories ?? undefined)
|
|
318
372
|
: data.organization?.repositories;
|
|
319
373
|
|
|
320
|
-
const traversePages = async (query: string, scopeCheck: boolean) => {
|
|
374
|
+
const traversePages = async (query: string, scopeCheck: boolean): Promise<void> => {
|
|
321
375
|
let cursor: string | null = null;
|
|
322
376
|
let page = 0;
|
|
323
377
|
let hasNext = true;
|
|
324
378
|
|
|
325
379
|
while (hasNext && page++ < MAX_PAGES) {
|
|
326
|
-
const data = await postGraphQL(query, {
|
|
380
|
+
const data = await postGraphQL<PaginateData>(query, {
|
|
327
381
|
login: username,
|
|
328
382
|
cursor,
|
|
329
383
|
pageSize: REPO_PAGE_SIZE,
|
|
@@ -331,9 +385,9 @@ async function directFetch(username: string, opts: FetchOptions): Promise<Profil
|
|
|
331
385
|
affiliations,
|
|
332
386
|
});
|
|
333
387
|
const nextConn = getConnection(data, query);
|
|
334
|
-
for (const
|
|
335
|
-
hasNext = nextConn?.pageInfo?.hasNextPage;
|
|
336
|
-
cursor = nextConn?.pageInfo?.endCursor;
|
|
388
|
+
for (const node of nextConn?.nodes ?? []) processNode(node, scopeCheck);
|
|
389
|
+
hasNext = nextConn?.pageInfo?.hasNextPage ?? false;
|
|
390
|
+
cursor = nextConn?.pageInfo?.endCursor ?? null;
|
|
337
391
|
if (hasNext && !cursor) break;
|
|
338
392
|
}
|
|
339
393
|
};
|
|
@@ -353,18 +407,19 @@ async function directFetch(username: string, opts: FetchOptions): Promise<Profil
|
|
|
353
407
|
let issues = 0;
|
|
354
408
|
|
|
355
409
|
if (!isOrgAccount) {
|
|
356
|
-
const
|
|
410
|
+
const profile = user as ProfileUser;
|
|
411
|
+
const c = profile.contributionsCollection;
|
|
357
412
|
if (opts.scope === "org") {
|
|
358
413
|
commits = sumContributions(c?.commitContributionsByRepository, orgsSet);
|
|
359
414
|
prs = sumContributions(c?.pullRequestContributionsByRepository, orgsSet);
|
|
360
415
|
issues = sumContributions(c?.issueContributionsByRepository, orgsSet);
|
|
361
416
|
} else {
|
|
362
|
-
commits = c?.totalCommitContributions
|
|
417
|
+
commits = c?.totalCommitContributions ?? 0;
|
|
363
418
|
prs =
|
|
364
|
-
(
|
|
365
|
-
(
|
|
366
|
-
(
|
|
367
|
-
issues = (
|
|
419
|
+
(profile.openPRs?.totalCount ?? 0) +
|
|
420
|
+
(profile.closedPRs?.totalCount ?? 0) +
|
|
421
|
+
(profile.mergedPRs?.totalCount ?? 0);
|
|
422
|
+
issues = (profile.openIssues?.totalCount ?? 0) + (profile.closedIssues?.totalCount ?? 0);
|
|
368
423
|
}
|
|
369
424
|
}
|
|
370
425
|
|
|
@@ -373,14 +428,17 @@ async function directFetch(username: string, opts: FetchOptions): Promise<Profil
|
|
|
373
428
|
.sort((a, b) => b.size - a.size)
|
|
374
429
|
.slice(0, opts.langCount || 5);
|
|
375
430
|
|
|
431
|
+
const profileUser = !isOrgAccount ? (user as ProfileUser) : null;
|
|
432
|
+
const orgUser = isOrgAccount ? (user as OrgProfile) : null;
|
|
433
|
+
|
|
376
434
|
return {
|
|
377
435
|
user: {
|
|
378
|
-
login: user
|
|
379
|
-
name: user
|
|
436
|
+
login: user!.login,
|
|
437
|
+
name: user!.name,
|
|
380
438
|
avatarUrl: avatarDataUrl,
|
|
381
|
-
bio:
|
|
382
|
-
pronouns:
|
|
383
|
-
twitter: user
|
|
439
|
+
bio: isOrgAccount ? (orgUser?.description ?? null) : (profileUser?.bio ?? null),
|
|
440
|
+
pronouns: isOrgAccount ? null : (profileUser?.pronouns ?? null),
|
|
441
|
+
twitter: user!.twitterUsername ?? null,
|
|
384
442
|
},
|
|
385
443
|
stats: { stars, repos: reposCount, prs, issues, commits },
|
|
386
444
|
languages,
|
|
@@ -394,7 +452,7 @@ export async function getProfileData(
|
|
|
394
452
|
const norm = normalizeUsername(username);
|
|
395
453
|
const orgs = normalizeOrgFilters(opts.orgs);
|
|
396
454
|
const cacheKey = Bun.hash(
|
|
397
|
-
`v${CACHE_VERSION}:${norm}:${opts.scope || "personal"}:${opts.affiliations || "
|
|
455
|
+
`v${CACHE_VERSION}:${norm}:${opts.scope || "personal"}:${opts.affiliations || "owner"}:${opts.includeLanguages !== false}:${opts.langCount || 5}:${orgs.join("|")}`,
|
|
398
456
|
).toString(36);
|
|
399
457
|
|
|
400
458
|
if (!opts.forceRefresh) {
|
package/src/index.ts
CHANGED
|
@@ -17,6 +17,7 @@ import { themes } from "./utils";
|
|
|
17
17
|
const USERNAME_PATTERN = "^[a-zA-Z0-9-]{1,39}$";
|
|
18
18
|
const CACHE_CONTROL_HEADER = "public, max-age=0, s-maxage=1800, stale-while-revalidate=1800";
|
|
19
19
|
const EDGE_CACHE_CONTROL_HEADER = "public, s-maxage=1800, stale-while-revalidate=1800";
|
|
20
|
+
const DEFAULT_AFFILIATIONS = "owner" as const;
|
|
20
21
|
const appStartedAt = Date.now();
|
|
21
22
|
|
|
22
23
|
const CardQuerySchema = t.Object({
|
|
@@ -104,7 +105,7 @@ async function buildCardSvg(username: string, query: CardQuery): Promise<string>
|
|
|
104
105
|
const orgs = parseOrgs(query.orgs);
|
|
105
106
|
const fields = parseFieldSet(query.fields);
|
|
106
107
|
const includeLanguages = shouldIncludeLanguages(fields);
|
|
107
|
-
const affiliations = query.affiliations ?? "affiliated";
|
|
108
|
+
const affiliations = (query.affiliations ?? DEFAULT_AFFILIATIONS) as "owner" | "affiliated";
|
|
108
109
|
|
|
109
110
|
const data = await getProfileData(username, {
|
|
110
111
|
includeLanguages,
|
|
@@ -142,8 +143,8 @@ function renderUserThemesSvg(
|
|
|
142
143
|
const y = gap + row * (cellH + gap) + labelH;
|
|
143
144
|
// Inline the card as a nested <svg> element (GitHub camo-safe, unlike data-URI <image>)
|
|
144
145
|
const cardSvg = renderCard(user, stats, langs, { ...cardOpts, theme: name }).replace(
|
|
145
|
-
|
|
146
|
-
`<svg x="${x}" y="${y}"
|
|
146
|
+
/^<svg\b/,
|
|
147
|
+
`<svg x="${x}" y="${y}"`,
|
|
147
148
|
);
|
|
148
149
|
|
|
149
150
|
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}`;
|
|
@@ -215,7 +216,7 @@ const app = new Elysia({ name: "github-card" })
|
|
|
215
216
|
documentation: {
|
|
216
217
|
info: {
|
|
217
218
|
title: "GitHub Profile Card API",
|
|
218
|
-
version: "4.
|
|
219
|
+
version: "4.2.0",
|
|
219
220
|
description:
|
|
220
221
|
"Generate GitHub profile SVG/PNG cards for README, web embeds, and social previews.",
|
|
221
222
|
},
|
|
@@ -254,7 +255,7 @@ const app = new Elysia({ name: "github-card" })
|
|
|
254
255
|
message: "GitHub Profile Card API",
|
|
255
256
|
usage: "GET /card/:username",
|
|
256
257
|
themes: Object.keys(themes).join(", "),
|
|
257
|
-
affiliations: "
|
|
258
|
+
affiliations: "owner (default), affiliated",
|
|
258
259
|
organizationSupport: "Use /card/:organization-login",
|
|
259
260
|
}),
|
|
260
261
|
{
|
|
@@ -273,7 +274,7 @@ const app = new Elysia({ name: "github-card" })
|
|
|
273
274
|
includeLanguages,
|
|
274
275
|
langCount: q.lang_count ?? 5,
|
|
275
276
|
scope,
|
|
276
|
-
affiliations: q.affiliations ?? "affiliated",
|
|
277
|
+
affiliations: (q.affiliations ?? DEFAULT_AFFILIATIONS) as "owner" | "affiliated",
|
|
277
278
|
orgs,
|
|
278
279
|
});
|
|
279
280
|
|
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/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,8 +1,46 @@
|
|
|
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 / 16;
|
|
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 {
|
|
@@ -116,7 +116,7 @@ export class GitHubClient {
|
|
|
116
116
|
* @param options Request options including variables and fetch options.
|
|
117
117
|
* @returns Strongly typed response data.
|
|
118
118
|
*/
|
|
119
|
-
public async request<T =
|
|
119
|
+
public async request<T = unknown>(query: string, options: RequestOptions = {}): Promise<T> {
|
|
120
120
|
const { variables, timeoutMs = this.timeoutMs, signal: userSignal, ...fetchOptions } = options;
|
|
121
121
|
|
|
122
122
|
const headers = new Headers(fetchOptions.headers);
|
|
@@ -153,11 +153,13 @@ export class GitHubClient {
|
|
|
153
153
|
body: JSON.stringify({ query, variables }),
|
|
154
154
|
signal: abortController.signal,
|
|
155
155
|
});
|
|
156
|
-
} catch (error:
|
|
157
|
-
|
|
158
|
-
|
|
156
|
+
} catch (error: unknown) {
|
|
157
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
158
|
+
const name = error instanceof Error ? error.name : "";
|
|
159
|
+
if (name === "AbortError" || error instanceof TimeoutError) {
|
|
160
|
+
throw new TimeoutError(message || "Request timed out");
|
|
159
161
|
}
|
|
160
|
-
throw new GitHubError(`Network error: ${
|
|
162
|
+
throw new GitHubError(`Network error: ${message || "Unknown error"}`);
|
|
161
163
|
} finally {
|
|
162
164
|
if (timeoutId) clearTimeout(timeoutId);
|
|
163
165
|
if (userSignal) {
|
|
@@ -170,12 +172,15 @@ export class GitHubClient {
|
|
|
170
172
|
this.classifyError(errorText || response.statusText, response.status, response.headers);
|
|
171
173
|
}
|
|
172
174
|
|
|
173
|
-
const body = (await response.json().catch(() => ({}))) as
|
|
175
|
+
const body = (await response.json().catch(() => ({}))) as {
|
|
176
|
+
data?: T;
|
|
177
|
+
errors?: { message: string }[];
|
|
178
|
+
};
|
|
174
179
|
|
|
175
180
|
if (body.errors && body.errors.length > 0) {
|
|
176
|
-
// Throw the first error specifically, but could be enhanced to aggregate
|
|
177
181
|
const firstError = body.errors[0];
|
|
178
|
-
|
|
182
|
+
const message = firstError?.message ?? "GraphQL error";
|
|
183
|
+
this.classifyError(message, response.status, response.headers);
|
|
179
184
|
}
|
|
180
185
|
|
|
181
186
|
if (!body.data) {
|
package/src/utils/icons.ts
CHANGED
|
@@ -1,18 +1,35 @@
|
|
|
1
1
|
export const icons = {
|
|
2
|
-
star: `<path
|
|
2
|
+
star: `<path d="M10.92 2.868a1.25 1.25 0 0 1 2.16 0l2.795 4.798l5.428 1.176a1.25 1.25 0 0 1 .667 2.054l-3.7 4.141l.56 5.525a1.25 1.25 0 0 1-1.748 1.27L12 19.592l-5.082 2.24a1.25 1.25 0 0 1-1.748-1.27l.56-5.525l-3.7-4.14a1.25 1.25 0 0 1 .667-2.055l5.428-1.176z"/>`,
|
|
3
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
|
|
5
|
-
issue: `<path
|
|
6
|
-
repo: `<path
|
|
4
|
+
pr: `<path d="M4 18a2 2 0 1 0 4 0a2 2 0 1 0-4 0M4 6a2 2 0 1 0 4 0a2 2 0 1 0-4 0m12 12a2 2 0 1 0 4 0a2 2 0 1 0-4 0M6 8v8"/><path d="M11 6h5a2 2 0 0 1 2 2v8"/><path d="m14 9l-3-3l3-3"/>`,
|
|
5
|
+
issue: `<path d="M15.314 2a2 2 0 0 1 1.414.586l4.686 4.686A2 2 0 0 1 22 8.686v6.628a2 2 0 0 1-.586 1.414l-4.686 4.686a2 2 0 0 1-1.414.586H8.686a2 2 0 0 1-1.414-.586l-4.686-4.686A2 2 0 0 1 2 15.314V8.686a2 2 0 0 1 .586-1.414l4.686-4.686A2 2 0 0 1 8.686 2zm0 2H8.686L4 8.686v6.628L8.686 20h6.628L20 15.314V8.686zM12 15a1 1 0 1 1 0 2a1 1 0 0 1 0-2m0-9a1 1 0 0 1 1 1v6a1 1 0 1 1-2 0V7a1 1 0 0 1 1-1"/>`,
|
|
6
|
+
repo: `<path d="M6 19.623c-.69-.195-1.232-.491-1.682-.941C3 17.364 3 15.242 3 11s0-6.364 1.318-7.682S7.758 2 12 2s6.364 0 7.682 1.318S21 6.758 21 11s0 6.364-1.318 7.682c-.45.45-.993.746-1.682.941"/><path d="M12 20.193c-.414 0-.748.32-1.414.958c-.647.619-.97.929-1.242.831a.5.5 0 0 1-.096-.046C9 21.779 9 21.31 9 20.376v-3.125c0-1.532 0-2.299.44-2.775C9.878 14 10.585 14 12 14s2.121 0 2.56.476s.44 1.243.44 2.775v3.125c0 .935 0 1.403-.248 1.56a.5.5 0 0 1-.096.046c-.272.098-.595-.212-1.242-.831c-.666-.639-1-.958-1.414-.958M8 10h8M8 6h4"/>`,
|
|
7
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
8
|
};
|
|
9
9
|
|
|
10
|
-
|
|
10
|
+
type IconName = keyof typeof icons;
|
|
11
|
+
|
|
12
|
+
const iconMeta: Record<
|
|
13
|
+
IconName,
|
|
14
|
+
{ viewBox: string; kind: "fill" | "stroke"; strokeWidth?: string }
|
|
15
|
+
> = {
|
|
16
|
+
star: { viewBox: "0 0 24 24", kind: "fill" },
|
|
17
|
+
commit: { viewBox: "0 0 16 16", kind: "fill" },
|
|
18
|
+
pr: { viewBox: "0 0 24 24", kind: "stroke", strokeWidth: "2" },
|
|
19
|
+
issue: { viewBox: "0 0 24 24", kind: "fill" },
|
|
20
|
+
repo: { viewBox: "0 0 24 24", kind: "stroke", strokeWidth: "1.5" },
|
|
21
|
+
x: { viewBox: "0 0 24 24", kind: "stroke", strokeWidth: "2" },
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
export function icon(name: IconName, color: string, size = 16): string {
|
|
11
25
|
const path = icons[name];
|
|
12
26
|
if (!path) return "";
|
|
13
|
-
const
|
|
27
|
+
const meta = iconMeta[name];
|
|
28
|
+
const viewBox = meta?.viewBox ?? "0 0 16 16";
|
|
14
29
|
const colorStr = color.startsWith("#") ? color : `#${color}`;
|
|
15
|
-
const
|
|
16
|
-
|
|
17
|
-
|
|
30
|
+
const attrs =
|
|
31
|
+
meta?.kind === "stroke"
|
|
32
|
+
? `fill="none" stroke="${colorStr}" stroke-width="${meta.strokeWidth ?? "1.8"}" stroke-linecap="round" stroke-linejoin="round"`
|
|
33
|
+
: `fill="${colorStr}"`;
|
|
34
|
+
return `<svg width="${size}" height="${size}" viewBox="${viewBox}" ${attrs} role="img">${path}</svg>`;
|
|
18
35
|
}
|
package/src/utils/index.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
export { resolveColors, themes, type Theme } from "./themes";
|
|
2
|
-
export { kFormat, escapeXml, wrapText } from "./format";
|
|
2
|
+
export { kFormat, escapeXml, wrapText, measureText } from "./format";
|
|
3
3
|
export { getLangColor } from "./languages";
|
|
4
4
|
export { icons, icon } from "./icons";
|
|
5
5
|
export { FONT_FACE, FONT_FAMILY } from "./font";
|
|
Binary file
|