Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions web/app/docs/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,12 @@ import styles from '../../components/docs/docs.module.css';
import { getSearchIndex } from '../../lib/docs';
import { productBasePath, productSections, getProductSearchIndex } from '../../lib/product-docs';

// Docs are deployed as pre-rendered assets. The Cloudflare incremental cache
// configured for this site is intentionally read-only, so allowing Next's
// default hourly revalidation would eventually send a stale docs request down
// a regeneration path that cannot be persisted.
export const revalidate = false;

const searchIndex = getSearchIndex();
const productScopes = productSections.map((section) => ({
id: section.id,
Expand Down
79 changes: 72 additions & 7 deletions web/components/DocsGitHubStarsBadge.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
'use client';

import { useEffect, useState } from 'react';
import { usePathname } from 'next/navigation';

import { getProductSectionForPath } from '../lib/product-docs-nav';
Expand All @@ -8,11 +9,43 @@ import s from './github-stars.module.css';
export type DocsStarRepo = {
/** Product section id (`file`, `loop`) or `null` for the default Agent Relay repo. */
id: string | null;
repo: string;
href: string;
label: string;
count: string | null;
};

type StarCacheEntry = { count: string; expiresAt: number };

const STAR_CACHE_PREFIX = 'agentrelay:github-stars:';
const STAR_CACHE_TTL_MS = 60 * 60 * 1000;

function formatStarCount(stars: number): string {
return stars >= 1000 ? `${(stars / 1000).toFixed(1)}k` : String(stars);
}

function getCachedStarCount(repo: string): string | null {
try {
const cached = localStorage.getItem(`${STAR_CACHE_PREFIX}${repo}`);
if (!cached) return null;

const entry = JSON.parse(cached) as StarCacheEntry;
return entry.expiresAt > Date.now() ? entry.count : null;
} catch {
return null;
}
}

function cacheStarCount(repo: string, count: string) {
try {
localStorage.setItem(
`${STAR_CACHE_PREFIX}${repo}`,
JSON.stringify({ count, expiresAt: Date.now() + STAR_CACHE_TTL_MS } satisfies StarCacheEntry)
);
} catch {
// Storage can be unavailable in private browsing; the badge still works.
}
}

function GithubIcon() {
return (
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true">
Expand All @@ -24,14 +57,46 @@ function GithubIcon() {
/**
* GitHub stars badge that targets the repo for the docs section currently being
* viewed: Relayfile under `/docs/file`, Relayloop under `/docs/loop`, and Agent
* Relay everywhere else. Star counts are fetched on the server and handed in;
* this component just picks the right one for the active path.
* Relay everywhere else. It loads the active repo's count directly from
* GitHub and keeps it in browser storage for an hour, so docs rendering never
* depends on a server-side refresh.
*/
export function DocsGitHubStarsBadge({ repos }: { repos: DocsStarRepo[] }) {
const pathname = usePathname() ?? '/docs';
const section = getProductSectionForPath(pathname);
const entry =
repos.find((r) => r.id === (section?.id ?? null)) ?? repos.find((r) => r.id === null) ?? repos[0];
const [count, setCount] = useState<string | null>(null);

useEffect(() => {
if (!entry) return;

const cached = getCachedStarCount(entry.repo);
if (cached) {
setCount(cached);
return;
}

const controller = new AbortController();
void fetch(`https://api.github.com/repos/${entry.repo}`, {
headers: { Accept: 'application/vnd.github+json' },
cache: 'force-cache',
signal: controller.signal,
})
.then(async (response) => {
if (!response.ok) return null;
const data = (await response.json()) as { stargazers_count?: number };
return typeof data.stargazers_count === 'number' ? formatStarCount(data.stargazers_count) : null;
})
.then((nextCount) => {
if (!nextCount) return;
cacheStarCount(entry.repo, nextCount);
setCount(nextCount);
})
.catch(() => {});

return () => controller.abort();
}, [entry]);

if (!entry) return null;

Expand All @@ -42,16 +107,16 @@ export function DocsGitHubStarsBadge({ repos }: { repos: DocsStarRepo[] }) {
rel="noopener noreferrer"
className={s.badge}
aria-label={
entry.count
? `View ${entry.label} on GitHub (${entry.count} stars)`
count
? `View ${entry.label} on GitHub (${count} stars)`
: `View ${entry.label} on GitHub`
}
>
<GithubIcon />
{entry.count ? (
{count ? (
<span className={s.meta}>
<span className={s.divider} />
<span className={s.count}>{entry.count}</span>
<span className={s.count}>{count}</span>
</span>
) : null}
</a>
Expand Down
9 changes: 4 additions & 5 deletions web/components/GitHubStars.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -41,8 +41,8 @@ async function getGitHubStars(repo: string = DEFAULT_REPO): Promise<string | nul

/**
* Docs header badge that follows the active section: Relayfile under
* `/docs/file`, Relayloop under `/docs/loop`, Agent Relay elsewhere. Fetches
* every section's star count on the server, then the client picks by path.
* `/docs/file`, Relayloop under `/docs/loop`, Agent Relay elsewhere. The
* client loads only the active repo's count, so the docs route stays static.
*/
export async function DocsGitHubStarsBadgeServer() {
const targets: { id: string | null; repo: string; label: string }[] = [
Expand All @@ -54,12 +54,11 @@ export async function DocsGitHubStarsBadgeServer() {
})),
];

const counts = await Promise.all(targets.map((t) => getGitHubStars(t.repo)));
const repos: DocsStarRepo[] = targets.map((t, i) => ({
const repos: DocsStarRepo[] = targets.map((t) => ({
id: t.id,
repo: t.repo,
href: `https://github.com/${t.repo}`,
label: t.label,
count: counts[i],
}));

return <DocsGitHubStarsBadge repos={repos} />;
Expand Down
Loading