Skip to content

Commit 552092c

Browse files
committed
fix: generate website github stats at build time
1 parent 87ee300 commit 552092c

9 files changed

Lines changed: 133 additions & 119 deletions

File tree

website/.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,3 +21,6 @@ pnpm-debug.log*
2121
.DS_Store
2222
.vercel/output
2323
.wrangler/
24+
25+
# generated data
26+
src/data/github-snapshot.ts

website/DEPLOYMENT.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,11 +20,16 @@ Recommended settings:
2020
- Path: `website`
2121
- Environment variable: `SITE_URL=https://bub.build`
2222
- Environment variable: `NODE_VERSION=22.16.0`
23+
- Build secret: `GITHUB_TOKEN=<GitHub PAT>` (optional, recommended for higher GitHub API limits)
2324

2425
The repo keeps a minimal [wrangler.jsonc](./wrangler.jsonc) and relies on
2526
Astro/Wrangler's default Cloudflare integration for the generated Worker
2627
configuration.
2728

29+
GitHub repo stats are snapshotted during `pnpm build` into
30+
`src/data/github-snapshot.ts`. The Worker does not call the GitHub API at
31+
runtime, so `GITHUB_TOKEN` only needs to exist as a build secret.
32+
2833
The repo also includes [public/.assetsignore](./public/.assetsignore) for the
2934
SSR Worker build:
3035

website/astro.config.mjs

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -13,11 +13,6 @@ export default defineConfig({
1313
site: process.env.SITE_URL ?? 'https://bub.build',
1414
env: {
1515
schema: {
16-
GITHUB_TOKEN: envField.string({
17-
context: 'server',
18-
access: 'secret',
19-
optional: true,
20-
}),
2116
SITE_URL: envField.string({
2217
context: 'client',
2318
access: 'public',

website/package.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,10 @@
66
"node": ">=22.12.0"
77
},
88
"scripts": {
9+
"generate:github-snapshot": "node ./scripts/generate-github-snapshot.mjs",
10+
"predev": "pnpm run generate:github-snapshot",
911
"dev": "astro dev",
12+
"prebuild": "pnpm run generate:github-snapshot",
1013
"build": "astro build",
1114
"preview": "wrangler dev"
1215
},
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
import { mkdir, writeFile } from 'node:fs/promises';
2+
import path from 'node:path';
3+
import process from 'node:process';
4+
5+
const repoOwner = 'bubbuild';
6+
const repoName = 'bub';
7+
const contributorLimit = 15;
8+
const outputPath = path.resolve('src/data/github-snapshot.ts');
9+
const apiBase = 'https://api.github.com';
10+
const fallbackSnapshot = {
11+
stars: 0,
12+
starsFormatted: undefined,
13+
contributors: [],
14+
};
15+
16+
function formatStars(count) {
17+
if (count <= 0) return undefined;
18+
if (count >= 1_000) {
19+
return `${(count / 1_000).toFixed(1).replace(/\\.0$/, '')}k`;
20+
}
21+
return String(count);
22+
}
23+
24+
function buildHeaders() {
25+
return {
26+
Accept: 'application/vnd.github+json',
27+
'X-GitHub-Api-Version': '2022-11-28',
28+
...(process.env.GITHUB_TOKEN ? { Authorization: `Bearer ${process.env.GITHUB_TOKEN}` } : {}),
29+
};
30+
}
31+
32+
function renderSnapshot(stats) {
33+
return `import type { RepoStats } from '@/lib/github';
34+
35+
const repoStatsSnapshot: RepoStats = {
36+
stars: ${stats.stars},
37+
starsFormatted: ${stats.starsFormatted === undefined ? 'undefined' : JSON.stringify(stats.starsFormatted)},
38+
contributors: ${JSON.stringify(stats.contributors, null, 2)},
39+
};
40+
41+
export default repoStatsSnapshot;
42+
`;
43+
}
44+
45+
async function fetchRepoStats() {
46+
const headers = buildHeaders();
47+
const [repoRes, contribRes] = await Promise.allSettled([
48+
fetch(`${apiBase}/repos/${repoOwner}/${repoName}`, { headers }),
49+
fetch(
50+
`${apiBase}/repos/${repoOwner}/${repoName}/contributors?per_page=${contributorLimit + 10}&anon=false`,
51+
{ headers },
52+
),
53+
]);
54+
55+
let stars = 0;
56+
if (repoRes.status === 'fulfilled' && repoRes.value.ok) {
57+
const data = await repoRes.value.json();
58+
stars = data.stargazers_count ?? 0;
59+
}
60+
61+
let contributors = [];
62+
if (contribRes.status === 'fulfilled' && contribRes.value.ok) {
63+
const data = await contribRes.value.json();
64+
contributors = Array.isArray(data)
65+
? data
66+
.filter((contributor) => contributor.type === 'User')
67+
.slice(0, contributorLimit)
68+
.map((contributor) => ({
69+
login: contributor.login,
70+
avatar_url: contributor.avatar_url,
71+
html_url: contributor.html_url,
72+
contributions: contributor.contributions,
73+
type: contributor.type,
74+
}))
75+
: [];
76+
}
77+
78+
return {
79+
stars,
80+
starsFormatted: formatStars(stars),
81+
contributors,
82+
};
83+
}
84+
85+
async function main() {
86+
await mkdir(path.dirname(outputPath), { recursive: true });
87+
88+
try {
89+
const snapshot = await fetchRepoStats();
90+
await writeFile(outputPath, renderSnapshot(snapshot), 'utf8');
91+
console.log(
92+
`Generated GitHub snapshot: stars=${snapshot.stars}, contributors=${snapshot.contributors.length}`,
93+
);
94+
} catch (error) {
95+
await writeFile(outputPath, renderSnapshot(fallbackSnapshot), 'utf8');
96+
console.warn('GitHub snapshot fetch failed; wrote an empty fallback snapshot.');
97+
console.warn(error instanceof Error ? error.message : String(error));
98+
}
99+
}
100+
101+
await main();

website/src/layouts/BaseLayout.astro

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@ const t = useTranslations(locale);
4747
const navProps = { ...getNavProps(locale, Astro.url.pathname), ...(Astro.props.navOverrides ?? {}) };
4848
const footerCopyright = `© ${new Date().getUTCFullYear()} Bub Contributors`;
4949
50-
const { starsFormatted: stars } = await getRepoStats();
50+
const { starsFormatted: stars } = getRepoStats();
5151
5252
const {
5353
title = t('site.title'),

website/src/layouts/LandingLayout.astro

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import { SITE_URL } from 'astro:env/client';
1010
1111
const { title, description, ogImage, hero, features = {}, hookIntro = {}, tapeModel = {}, testimonials = {} } = Astro.props;
1212
13-
const { contributors } = await getRepoStats();
13+
const { contributors } = getRepoStats();
1414
---
1515

1616
<BaseLayout title={title} description={description} ogImage={ogImage} canonicalUrl={SITE_URL} bodyClass="ambient-page min-h-screen antialiased font-sans">

website/src/lib/github.ts

Lines changed: 3 additions & 111 deletions
Original file line numberDiff line numberDiff line change
@@ -1,27 +1,10 @@
1-
// ---------------------------------------------------------------------------
2-
// GitHub API utilities
3-
//
4-
// Data is fetched at most once every CACHE_TTL_MS (24 h) via a module-level
5-
// in-memory cache. This means:
6-
// • SSG — cache is cold on every `astro build`, so data is always fresh.
7-
// • SSR — the cache is shared across requests within the same server
8-
// process and expires after 24 h, keeping API usage minimal.
9-
//
10-
// GITHUB_TOKEN is provided through Astro's server-only env API and is never
11-
// bundled into any client-side output.
12-
// ---------------------------------------------------------------------------
13-
14-
import { GITHUB_TOKEN } from 'astro:env/server';
1+
import repoStatsSnapshot from '@/data/github-snapshot';
152

163
export const REPO_OWNER = 'bubbuild';
174
export const REPO_NAME = 'bub';
185
export const REPO_SLUG = `${REPO_OWNER}/${REPO_NAME}`;
196
export const REPO_URL = `https://github.com/${REPO_SLUG}`;
207
export const CONTRIBUTORS_URL = `${REPO_URL}/graphs/contributors`;
21-
const API_BASE = 'https://api.github.com';
22-
23-
/** How long (ms) to keep cached data before re-fetching. Default: 24 h. */
24-
const CACHE_TTL_MS = 24 * 60 * 60 * 1_000;
258

269
export interface GitHubContributor {
2710
login: string;
@@ -32,106 +15,15 @@ export interface GitHubContributor {
3215
}
3316

3417
export interface RepoStats {
35-
/** Raw star count (0 when unavailable). */
3618
stars: number;
37-
/** Formatted star string, e.g. "1.2k" (undefined when stars === 0). */
3819
starsFormatted: string | undefined;
39-
/** Top contributors ordered by contribution count. */
4020
contributors: GitHubContributor[];
4121
}
4222

43-
// ---------------------------------------------------------------------------
44-
// Module-level cache — survives across requests in SSR; cold on each build.
45-
// ---------------------------------------------------------------------------
46-
interface Cache {
47-
stats: RepoStats;
48-
fetchedAt: number;
49-
}
50-
51-
let _cache: Cache | null = null;
52-
53-
// ---------------------------------------------------------------------------
54-
// Internal helpers
55-
// ---------------------------------------------------------------------------
56-
57-
function buildHeaders(): HeadersInit {
58-
return {
59-
Accept: 'application/vnd.github+json',
60-
'X-GitHub-Api-Version': '2022-11-28',
61-
...(GITHUB_TOKEN ? { Authorization: `Bearer ${GITHUB_TOKEN}` } : {}),
62-
};
63-
}
64-
65-
/** Format a raw star count into a human-readable string (e.g. 1234 → "1.2k"). */
66-
export function formatStars(count: number): string | undefined {
67-
if (count <= 0) return undefined;
68-
if (count >= 1_000) {
69-
return `${(count / 1_000).toFixed(1).replace(/\.0$/, '')}k`;
70-
}
71-
return String(count);
72-
}
73-
74-
async function fetchFromAPI(contributorLimit: number): Promise<RepoStats> {
75-
const headers = buildHeaders();
76-
77-
const [repoRes, contribRes] = await Promise.allSettled([
78-
fetch(`${API_BASE}/repos/${REPO_OWNER}/${REPO_NAME}`, { headers }),
79-
fetch(
80-
// Fetch extra entries so bots filtered out still leave `contributorLimit` humans.
81-
`${API_BASE}/repos/${REPO_OWNER}/${REPO_NAME}/contributors?per_page=${contributorLimit + 10}&anon=false`,
82-
{ headers },
83-
),
84-
]);
85-
86-
let stars = 0;
87-
if (repoRes.status === 'fulfilled' && repoRes.value.ok) {
88-
const data = await repoRes.value.json() as { stargazers_count: number };
89-
stars = data.stargazers_count ?? 0;
90-
}
91-
92-
let contributors: GitHubContributor[] = [];
93-
if (contribRes.status === 'fulfilled' && contribRes.value.ok) {
94-
const data = await contribRes.value.json() as GitHubContributor[];
95-
contributors = Array.isArray(data)
96-
? data.filter((c) => c.type === 'User').slice(0, contributorLimit)
97-
: [];
98-
}
99-
100-
return { stars, starsFormatted: formatStars(stars), contributors };
101-
}
102-
103-
// ---------------------------------------------------------------------------
104-
// Public API
105-
// ---------------------------------------------------------------------------
106-
107-
/**
108-
* Return cached repo stats, refreshing when the cache is cold or stale.
109-
*
110-
* @param contributorLimit Max number of contributors to return (default 15).
111-
*/
112-
export async function getRepoStats(contributorLimit = 15): Promise<RepoStats> {
113-
const now = Date.now();
114-
if (_cache && now - _cache.fetchedAt < CACHE_TTL_MS) {
115-
return _cache.stats;
116-
}
117-
118-
try {
119-
const stats = await fetchFromAPI(contributorLimit);
120-
_cache = { stats, fetchedAt: now };
121-
return stats;
122-
} catch {
123-
// On total failure, return stale cache if available, else empty defaults.
124-
if (_cache) return _cache.stats;
125-
return { stars: 0, starsFormatted: undefined, contributors: [] };
126-
}
23+
export function getRepoStats(): RepoStats {
24+
return repoStatsSnapshot;
12725
}
12826

129-
/**
130-
* Build a GitHub avatar URL for a username.
131-
*
132-
* Uses GitHub's redirect-based endpoint — no API call, no token, no rate limit.
133-
* Returns `undefined` when no username is provided.
134-
*/
13527
export function gitHubAvatarUrl(username: string | undefined, size = 80): string | undefined {
13628
return username ? `https://github.com/${username}.png?size=${size}` : undefined;
13729
}

website/wrangler.jsonc

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,19 @@
11
{
22
"$schema": "./node_modules/wrangler/config-schema.json",
3-
"name": "bub"
3+
"name": "bub",
4+
"observability": {
5+
"enabled": false,
6+
"head_sampling_rate": 1,
7+
"logs": {
8+
"enabled": true,
9+
"head_sampling_rate": 1,
10+
"persist": true,
11+
"invocation_logs": true
12+
},
13+
"traces": {
14+
"enabled": false,
15+
"persist": true,
16+
"head_sampling_rate": 1
17+
}
18+
}
419
}

0 commit comments

Comments
 (0)