diff --git a/apps/www/app/(home)/_components/OpenSourceSection.tsx b/apps/www/app/(home)/_components/OpenSourceSection.tsx index e5047b2a673..4271c8a3276 100644 --- a/apps/www/app/(home)/_components/OpenSourceSection.tsx +++ b/apps/www/app/(home)/_components/OpenSourceSection.tsx @@ -55,7 +55,7 @@ const COLS = 72 const ROWS = 40 const githubStars = staticContent.githubStars -const TEXT = kFormatter(githubStars) +const TEXT = typeof githubStars === 'number' && githubStars > 0 ? kFormatter(githubStars) : '' const TEXT_MASK = textToPixelMask(TEXT) const TEXT_W = TEXT.length * 6 - 1 const TEXT_H = 7 diff --git a/apps/www/components/Nav/GitHubButton.tsx b/apps/www/components/Nav/GitHubButton.tsx index 46c7f728a4e..909187e5566 100644 --- a/apps/www/components/Nav/GitHubButton.tsx +++ b/apps/www/components/Nav/GitHubButton.tsx @@ -45,7 +45,7 @@ const GitHubButton = () => { fill="currentColor" /> - {kFormatter(githubStars)} + {typeof githubStars === 'number' && githubStars > 0 ? kFormatter(githubStars) : 'GitHub'} diff --git a/apps/www/scripts/generateStaticContent.mjs b/apps/www/scripts/generateStaticContent.mjs index 04f694b38c0..8c58ef78149 100644 --- a/apps/www/scripts/generateStaticContent.mjs +++ b/apps/www/scripts/generateStaticContent.mjs @@ -8,6 +8,8 @@ import advancedFormat from 'dayjs/plugin/advancedFormat.js' import utc from 'dayjs/plugin/utc.js' import matter from 'gray-matter' +import { getGitHubStars } from './lib/githubStars.mjs' + /** * Plain `node` does not read `.env` / `.env.local` (Next.js loads those when you run `next`). * Minimal parser: no extra dependency; `.env` first, then `.env.local` overrides. @@ -232,26 +234,8 @@ const getLatestBlogPosts = async () => { return latestPosts } -let stars = 0 - // GitHub Stars -const fetchOctoData = async () => { - const { Octokit } = await import('@octokit/core') - const octokit = new Octokit(process.env.GITHUB_TOKEN ? { auth: process.env.GITHUB_TOKEN } : {}) - const res = await octokit.request('GET /repos/{org}/{repo}', { - org: 'supabase', - repo: 'supabase', - type: 'public', - }) - - return res.data?.stargazers_count -} - -try { - stars = await fetchOctoData() -} catch (error) { - console.warn('Error fetching GitHub stars:', error) -} +const stars = await getGitHubStars(path.join(wwwRoot, '.generated/staticContent/_index.json')) // Careers Jobs count const getCareerCount = async () => { @@ -397,9 +381,8 @@ async function generateChangelogContent() { const { getPublishedChangelogEntries, fetchChangelogEntryFilesFromTarball, CHANGE_TYPE_LABELS } = await import('../lib/changelog-entries-core.mjs') - const { generateChangelogRssXml, generateChangelogTagRssXml, labelToFileSlug } = await import( - '../lib/changelog-rss.mjs' - ) + const { generateChangelogRssXml, generateChangelogTagRssXml, labelToFileSlug } = + await import('../lib/changelog-rss.mjs') const { createAppAuth } = await import('@octokit/auth-app') const { Octokit } = await import('@octokit/core') const octokit = new Octokit({ @@ -460,7 +443,8 @@ async function generateChangelogContent() { // LLM-friendly changelog markdown index (RSS remains canonical syndication format). const mdSections = entries.map((entry) => { const date = dayjs(entry.sortDate).isValid() ? dayjs(entry.sortDate).format('YYYY-MM-DD') : '' - const changeType = CHANGE_TYPE_LABELS[entry.frontmatter.change_type] ?? entry.frontmatter.change_type + const changeType = + CHANGE_TYPE_LABELS[entry.frontmatter.change_type] ?? entry.frontmatter.change_type const products = (entry.frontmatter.affected_products ?? []).join(', ') const meta = [ date, diff --git a/apps/www/scripts/lib/githubStars.mjs b/apps/www/scripts/lib/githubStars.mjs new file mode 100644 index 00000000000..74464a50acc --- /dev/null +++ b/apps/www/scripts/lib/githubStars.mjs @@ -0,0 +1,31 @@ +import { readFile } from 'node:fs/promises' +import { Octokit } from '@octokit/core' + +const isValidCount = (count) => Number.isSafeInteger(count) && count > 0 + +/** Preserve a successful count when GitHub is unavailable; null means unknown. */ +export async function getGitHubStars(cachePath) { + try { + const octokit = new Octokit(process.env.GITHUB_TOKEN ? { auth: process.env.GITHUB_TOKEN } : {}) + const { data } = await octokit.request('GET /repos/{org}/{repo}', { + org: 'supabase', + repo: 'supabase', + type: 'public', + }) + if (!isValidCount(data?.stargazers_count)) { + throw new Error('GitHub returned an invalid star count') + } + return data.stargazers_count + } catch (error) { + console.warn('Error fetching GitHub stars:', error.message) + } + + try { + const { githubStars } = JSON.parse(await readFile(cachePath, 'utf8')) + if (isValidCount(githubStars)) return githubStars + } catch { + // A clean build may have no previously generated content. + } + + return null +} diff --git a/apps/www/scripts/lib/githubStars.test.ts b/apps/www/scripts/lib/githubStars.test.ts new file mode 100644 index 00000000000..ea795ca1d5a --- /dev/null +++ b/apps/www/scripts/lib/githubStars.test.ts @@ -0,0 +1,81 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import { getGitHubStars } from './githubStars.mjs' + +const { request, readFile, Octokit } = vi.hoisted(() => ({ + request: vi.fn(), + readFile: vi.fn(), + Octokit: vi.fn(), +})) + +vi.mock('@octokit/core', () => ({ Octokit })) +vi.mock('node:fs/promises', () => ({ readFile })) + +const cachePath = '/staticContent/_index.json' + +describe('getGitHubStars', () => { + beforeEach(() => { + vi.resetAllMocks() + vi.stubEnv('GITHUB_TOKEN', '') + Octokit.mockImplementation(function () { + return { request } + }) + vi.spyOn(console, 'warn').mockImplementation(() => {}) + readFile.mockRejectedValue(new Error('ENOENT')) + }) + + afterEach(() => { + vi.restoreAllMocks() + vi.unstubAllEnvs() + }) + + it('uses the live count and authenticates when a token is available', async () => { + vi.stubEnv('GITHUB_TOKEN', 'test-token') + request.mockResolvedValue({ data: { stargazers_count: 110565 } }) + + expect(await getGitHubStars(cachePath)).toBe(110565) + expect(Octokit).toHaveBeenCalledWith({ auth: 'test-token' }) + expect(readFile).not.toHaveBeenCalled() + }) + + it('can fetch public stars without a token', async () => { + request.mockResolvedValue({ data: { stargazers_count: 110565 } }) + + expect(await getGitHubStars(cachePath)).toBe(110565) + expect(Octokit).toHaveBeenCalledWith({}) + }) + + it('keeps the previous count when GitHub rate limits the build', async () => { + request.mockRejectedValue(Object.assign(new Error('API rate limit exceeded'), { status: 403 })) + readFile.mockResolvedValue(JSON.stringify({ githubStars: 109037 })) + + expect(await getGitHubStars(cachePath)).toBe(109037) + expect(readFile).toHaveBeenCalledWith(cachePath, 'utf8') + }) + + it.each([undefined, null, 0, -1, '110565', NaN, Infinity, 1.5])( + 'falls back to the previous count for invalid API data: %s', + async (stargazers_count) => { + request.mockResolvedValue({ data: { stargazers_count } }) + readFile.mockResolvedValue(JSON.stringify({ githubStars: 109037 })) + + expect(await getGitHubStars(cachePath)).toBe(109037) + } + ) + + it('returns unknown when the request fails on a clean build', async () => { + request.mockRejectedValue(new Error('Network error')) + + expect(await getGitHubStars(cachePath)).toBeNull() + }) + + it.each(['invalid json', '{}', '{"githubStars":0}', '{"githubStars":null}'])( + 'does not reuse invalid cached content: %s', + async (content) => { + request.mockRejectedValue(new Error('Network error')) + readFile.mockResolvedValue(content) + + expect(await getGitHubStars(cachePath)).toBeNull() + } + ) +}) diff --git a/apps/www/turbo.jsonc b/apps/www/turbo.jsonc index 61fef916f7d..acbc04712ad 100644 --- a/apps/www/turbo.jsonc +++ b/apps/www/turbo.jsonc @@ -15,6 +15,7 @@ "CI", "CF_ACCESS_CLIENT_ID", "CF_ACCESS_CLIENT_SECRET", + "GITHUB_TOKEN", "NEXT_PUBLIC_MISC_USE_URL", "NEXT_PUBLIC_MISC_USE_ANON_KEY", "NEXT_PUBLIC_STUDIO_URL",