@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/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 || process.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 = 7;
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: 1800,
127
- ttlStaleSeconds: 1800,
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
- function accLangs(edges: any[], map: LangMap) {
217
- if (!edges) return;
218
- for (const e of edges) {
219
- if (!e?.node?.name || !e.size) continue;
220
- const cur = map.get(e.node.name);
221
- if (cur) cur.size += e.size;
222
- else map.set(e.node.name, { size: e.size, color: e.node.color || "#ccc" });
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
- function sumContributions(entries: any[], orgsSet: Set<string> | null) {
248
- let tot = 0;
249
- for (const e of entries || []) {
250
- const owner = e?.repository?.owner;
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
- tot += e?.contributions?.totalCount || 0;
302
+ total += entry?.contributions?.totalCount ?? 0;
254
303
  }
255
- return tot;
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: any = null;
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 = (n: any, scopeCheck: boolean) => {
299
- if (!n || !n.nameWithOwner || seenRepos.has(n.nameWithOwner)) return;
347
+ const processNode = (node: RepoNode, scopeCheck: boolean): void => {
348
+ if (!node?.nameWithOwner || seenRepos.has(node.nameWithOwner)) return;
300
349
  if (
301
350
  scopeCheck &&
302
- n.owner?.__typename === "Organization" &&
351
+ node.owner?.__typename === "Organization" &&
303
352
  orgsSet &&
304
- !orgsSet.has(n.owner.login.toLowerCase())
353
+ !orgsSet.has(node.owner.login.toLowerCase())
305
354
  )
306
355
  return;
307
- seenRepos.add(n.nameWithOwner);
308
- stars += n.stargazers?.totalCount || 0;
356
+ seenRepos.add(node.nameWithOwner);
357
+ stars += node.stargazers?.totalCount ?? 0;
309
358
  reposCount++;
310
- if (fetchLangs) accLangs(n.languages?.edges, langMap);
359
+ if (fetchLangs) accLangs(node.languages?.edges ?? null, langMap);
311
360
  };
312
361
 
313
- const getConnection = (data: any, query: string) =>
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 n of nextConn?.nodes || []) processNode(n, scopeCheck);
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 c = user.contributionsCollection;
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 || 0;
417
+ commits = c?.totalCommitContributions ?? 0;
363
418
  prs =
364
- (user.openPRs?.totalCount || 0) +
365
- (user.closedPRs?.totalCount || 0) +
366
- (user.mergedPRs?.totalCount || 0);
367
- issues = (user.openIssues?.totalCount || 0) + (user.closedIssues?.totalCount || 0);
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.login,
379
- name: user.name,
436
+ login: user!.login,
437
+ name: user!.name,
380
438
  avatarUrl: avatarDataUrl,
381
- bio: user.bio || user.description,
382
- pronouns: user.pronouns,
383
- twitter: user.twitterUsername,
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 || "affiliated"}:${opts.includeLanguages !== false}:${opts.langCount || 5}:${orgs.join("|")}`,
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
- "<svg ",
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.0.0",
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: "affiliated (default), owner",
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
- export const FONT_FACE = ""; // Kept for API backwards compatibility
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 = "";
@@ -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 (num >= 999_950) return trim(num / 1_000_000) + "M";
4
- if (num >= 999.95) return trim(num / 1_000) + "k";
5
- return String(Math.round(num));
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 = any>(query: string, options: RequestOptions = {}): Promise<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: any) {
157
- if (error.name === "AbortError" || error instanceof TimeoutError) {
158
- throw new TimeoutError(error.message || "Request timed out");
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: ${error.message || "Unknown 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 any;
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
- this.classifyError(firstError.message, response.status, response.headers);
182
+ const message = firstError?.message ?? "GraphQL error";
183
+ this.classifyError(message, response.status, response.headers);
179
184
  }
180
185
 
181
186
  if (!body.data) {
@@ -1,18 +1,35 @@
1
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"/>`,
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 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>`,
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
- export function icon(name: keyof typeof icons, color: string, size = 16): string {
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 viewBox = name === "x" ? "0 0 24 24" : "0 0 16 16";
27
+ const meta = iconMeta[name];
28
+ const viewBox = meta?.viewBox ?? "0 0 16 16";
14
29
  const colorStr = color.startsWith("#") ? color : `#${color}`;
15
- const fill =
16
- name === "x" ? `stroke="${colorStr}" stroke-width="2" fill="none"` : `fill="${colorStr}"`;
17
- return `<svg width="${size}" height="${size}" viewBox="${viewBox}" ${fill} role="img">${path}</svg>`;
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
  }
@@ -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