Skip to content

Commit f316970

Browse files
andrii-bodnarclaude
andcommitted
fix: load repo stars/forks from GitHub API at build time
RepoCard fetched shields.io badges client-side and scraped an undocumented SVG element (#rlink). With several cards per page, the concurrent requests were rate-limited, so some cards failed with "Cannot read properties of null". Fetch stargazers_count/forks_count from the GitHub API at build time instead, baking the numbers into the HTML. No runtime fetch, no rate-limiting, no skeleton flicker. Failures fall back to empty counts so the build never breaks. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent d3a3634 commit f316970

2 files changed

Lines changed: 52 additions & 37 deletions

File tree

src/components/RepoCard.astro

Lines changed: 6 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
---
22
import { Icon } from 'astro-icon/components';
3+
import { getRepoStats } from '~/utils/github';
34
45
export interface Props {
56
organization: string;
@@ -8,7 +9,9 @@ export interface Props {
89
}
910
1011
const { organization, slug, title } = Astro.props;
11-
const official = Astro.props.organization === 'crowdin';
12+
const official = organization === 'crowdin';
13+
14+
const { stars, forks } = await getRepoStats(organization, slug);
1215
---
1316

1417
<a href={`https://github.com/${organization}/${slug}`} target="_blank" class="no-underline">
@@ -30,16 +33,12 @@ const official = Astro.props.organization === 'crowdin';
3033
<div class="flex flex-row gap-x-4 text-md text-gray-500 dark:text-gray-300">
3134
<span>
3235
<Icon name="mdi:star" class="inline-icon align-middle size-4" />
33-
<span class="stars inline-flex align-middle font-semibold">
34-
<span class="animate-pulse w-8 h-7 bg-slate-200 dark:bg-slate-700 rounded" />
35-
</span>
36+
<span class="stars inline-flex align-middle font-semibold">{stars}</span>
3637
</span>
3738

3839
<span>
3940
<Icon name="mdi:source-fork" class="inline-icon align-middle size-4" />
40-
<span class="forks inline-flex align-middle font-semibold">
41-
<span class="animate-pulse w-8 h-7 bg-slate-200 dark:bg-slate-700 rounded" />
42-
</span>
41+
<span class="forks inline-flex align-middle font-semibold">{forks}</span>
4342
</span>
4443
</div>
4544
<p>{Astro.locals.t('repo.viewInstall')}</p>
@@ -59,33 +58,3 @@ const official = Astro.props.organization === 'crowdin';
5958
border-color: var(--sl-color-gray-2);
6059
}
6160
</style>
62-
63-
<script is:inline define:vars={{organization, slug}}>
64-
async function fetchRepoData(organization, repoSlug) {
65-
try {
66-
// Fetch GitHub stars data
67-
const starsResponse = await fetch(`https://img.shields.io/github/stars/${organization}/${repoSlug}`);
68-
const starsText = await starsResponse.text();
69-
const starsSvg = new DOMParser().parseFromString(starsText, "image/svg+xml");
70-
71-
// Fetch GitHub forks data
72-
const forksResponse = await fetch(`https://img.shields.io/github/forks/${organization}/${repoSlug}`);
73-
const forksText = await forksResponse.text();
74-
const forksSvg = new DOMParser().parseFromString(forksText, "image/svg+xml");
75-
76-
return {
77-
stars: +starsSvg.querySelector('#rlink').textContent,
78-
forks: +forksSvg.querySelector('#rlink').textContent,
79-
};
80-
} catch (error) {
81-
console.error('Error fetching repo data:', error);
82-
}
83-
}
84-
85-
const repoElement = document.getElementById(slug);
86-
87-
fetchRepoData(organization, slug).then(response => {
88-
repoElement.querySelector('.stars').innerHTML = `<span>${response.stars}</span>`;
89-
repoElement.querySelector('.forks').innerHTML = `<span>${response.forks}</span>`;
90-
})
91-
</script>

src/utils/github.ts

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
export interface RepoStats {
2+
stars: number | null;
3+
forks: number | null;
4+
}
5+
6+
// Cached per build so multiple <RepoCard /> instances for the same repo only hit the GitHub API once.
7+
const statsCache = new Map<string, RepoStats>();
8+
9+
/**
10+
* Fetches a repository's star and fork counts from the GitHub API at build time.
11+
*
12+
* Returns null counts on any failure (network error, rate limit, missing repo)
13+
* so the page still builds — RepoCard renders a dash in that case.
14+
*
15+
* Set the GITHUB_TOKEN env var to raise the API rate limit (60 → 5000 req/h).
16+
*/
17+
export async function getRepoStats(organization: string, slug: string): Promise<RepoStats> {
18+
const key = `${organization}/${slug}`;
19+
20+
const cached = statsCache.get(key);
21+
if (cached) return cached;
22+
23+
const result: RepoStats = { stars: null, forks: null };
24+
25+
try {
26+
const headers: Record<string, string> = { Accept: 'application/vnd.github+json' };
27+
28+
const token = import.meta.env.GITHUB_TOKEN;
29+
if (token) headers.Authorization = `Bearer ${token}`;
30+
31+
const response = await fetch(`https://api.github.com/repos/${key}`, { headers });
32+
33+
if (response.ok) {
34+
const data = await response.json();
35+
result.stars = data.stargazers_count ?? null;
36+
result.forks = data.forks_count ?? null;
37+
} else {
38+
console.warn(`[RepoCard] GitHub API responded ${response.status} for ${key}`);
39+
}
40+
} catch (error) {
41+
console.warn(`[RepoCard] Failed to fetch stats for ${key}:`, error);
42+
}
43+
44+
statsCache.set(key, result);
45+
return result;
46+
}

0 commit comments

Comments
 (0)