diff --git a/app/components/footer.tsx b/app/components/footer.tsx index b058b6b..9dda829 100644 --- a/app/components/footer.tsx +++ b/app/components/footer.tsx @@ -1,8 +1,8 @@ import Link from "next/link"; +import { repoUrl } from "@/lib/github"; import { casePath, headingText, useCases } from "@/lib/use-cases"; import { Logo } from "./logo"; - -const repo = "https://github.com/david-sling/wave"; +import { StarButton } from "./star-button"; export function Footer() { return ( @@ -24,6 +24,10 @@ export function Footer() { .

+ {/* Closing the block that says the project is open source, rather + than sitting in the Project column, where it would have been a + list item repeating what that sentence already said. */} +
@@ -62,10 +66,9 @@ export function Footer() { Project
diff --git a/app/components/nav.tsx b/app/components/nav.tsx index 9b955c9..ebec53b 100644 --- a/app/components/nav.tsx +++ b/app/components/nav.tsx @@ -1,5 +1,6 @@ import { Logo } from "./logo"; import { NavVeil } from "./nav-veil"; +import { StarButton } from "./star-button"; const links = [ { hash: "#how", label: "How it works" }, @@ -7,8 +8,6 @@ const links = [ { hash: "#agents", label: "Agents" }, ]; -const selfHost = "https://github.com/david-sling/wave"; - /** * The primary nav. * @@ -17,6 +16,10 @@ const selfHost = "https://github.com/david-sling/wave"; * fetching the page again; anywhere else they carry the path, so they lead * home. `Create a channel` is always a fragment, because every page that * shows this nav carries the create form. + * + * The star chip stands where a `Self-host` link used to: both led to the same + * repository, and only one of them asks for anything. It hides with the links + * rather than with the pill, so a phone keeps the wordmark and the one action. */ export function Nav({ atHome = true }: { atHome?: boolean }) { const home = atHome ? "" : "/"; @@ -31,13 +34,13 @@ export function Nav({ atHome = true }: { atHome?: boolean }) { {link.label} ))} - - Self-host - - - Create a channel - +
+ + + Create a channel + +
); diff --git a/app/components/star-button.tsx b/app/components/star-button.tsx new file mode 100644 index 0000000..4033172 --- /dev/null +++ b/app/components/star-button.tsx @@ -0,0 +1,70 @@ +import { formatStars, repoUrl, starCount } from "@/lib/github"; + +/** + * GitHub's own mark. + * + * Referential use, under the same policy as `lib/client-marks.ts`: the glyph + * is the official one as published (Simple Icons, CC0 1.0, + * https://simpleicons.org), drawn whole and unaltered, and never larger than + * Wave's own mark beside it. It lives here rather than in `icons.tsx`, + * which is an authored set on one stroke weight and one grid, and rather + * than in `client-marks.ts`, which is keyed by the agent clients that can + * join a channel. GitHub is neither. + */ +function GitHubMark({ size = 16 }: { size?: number }) { + return ( + + + + ); +} + +/** + * The one place the page asks for something other than a channel. + * + * A chip, not a button: the ink pill is the only primary action on any + * surface, and an open-source project asking to be starred must not compete + * with the thing the reader came to do. The mark carries the destination, so + * the nav takes the short label and the footer, which has the room, spells + * it out. + * + * The count is shown whatever it is, nought included. It is the number of + * people who have starred this, not a claim about the project, and the + * accessible name says what it counts since "Star 2" does not. + */ +export async function StarButton({ + label = "Star on GitHub", + className, +}: { + label?: string; + className?: string; +}) { + const stars = await starCount(); + return ( + + + {label} + {stars !== null && ( + + {formatStars(stars)} + + )} + + ); +} diff --git a/lib/github.test.ts b/lib/github.test.ts new file mode 100644 index 0000000..cdc596f --- /dev/null +++ b/lib/github.test.ts @@ -0,0 +1,72 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { formatStars, starCount } from './github' + +/** + * The count is decoration, so what matters is that nothing it touches can + * take a page down with it, and that an unknown count stays distinguishable + * from a real count of nought. + */ +describe('formatStars', () => { + it('writes a small count whole', () => { + expect(formatStars(52)).toBe('52') + expect(formatStars(999)).toBe('999') + }) + + it('writes a thousand as a rounded k, without a trailing .0', () => { + expect(formatStars(1000)).toBe('1k') + expect(formatStars(1249)).toBe('1.2k') + expect(formatStars(9950)).toBe('10k') + expect(formatStars(12400)).toBe('12k') + }) +}) + +describe('starCount', () => { + const answer = (body: unknown, ok = true) => + vi.stubGlobal( + 'fetch', + vi.fn(async () => ({ ok, json: async () => body }) as unknown as Response), + ) + + afterEach(() => { + vi.unstubAllGlobals() + // `starCount` is memoised per render pass; tests share one. + ;(starCount as unknown as { clear?: () => void }).clear?.() + }) + + it('reports a small count rather than hiding it', async () => { + answer({ stargazers_count: 2 }) + expect(await starCount()).toBe(2) + }) + + it('tells nought apart from not knowing', async () => { + answer({ stargazers_count: 0 }) + expect(await starCount()).toBe(0) + }) + + it('returns a count worth reading', async () => { + answer({ stargazers_count: 412 }) + expect(await starCount()).toBe(412) + }) + + it('returns nothing when GitHub refuses, rather than throwing', async () => { + answer({ message: 'API rate limit exceeded' }, false) + expect(await starCount()).toBeNull() + }) + + it('returns nothing when the request fails outright', async () => { + vi.stubGlobal( + 'fetch', + vi.fn(async () => { + throw new Error('timed out') + }), + ) + expect(await starCount()).toBeNull() + }) + + it('returns nothing when the field is missing or not a number', async () => { + answer({}) + expect(await starCount()).toBeNull() + answer({ stargazers_count: '412' }) + expect(await starCount()).toBeNull() + }) +}) diff --git a/lib/github.ts b/lib/github.ts new file mode 100644 index 0000000..4b66963 --- /dev/null +++ b/lib/github.ts @@ -0,0 +1,60 @@ +/** + * The project's own repository, and how many people have starred it. + * + * The URL lives here because the star control, the nav and the footer all + * point at it. The count is a marketing number and nothing depends on it, so + * every failure path returns `null` and the control renders as a plain ask: + * a landing page must not fail to build because GitHub was slow or had run + * out of anonymous requests for this IP. + */ + +import { cache } from "react"; + +export const repoUrl = "https://github.com/david-sling/wave"; + +const api = "https://api.github.com/repos/david-sling/wave"; + +/** How long a count is served before it is fetched again. */ +const REVALIDATE_SECONDS = 3600; + +/** Past this the request is abandoned; a stale count beats a slow page. */ +const TIMEOUT_MS = 2500; + +/** + * The star count, or `null` when GitHub did not answer. + * + * `null` means unknown, not zero: a count of nought is a real answer and is + * shown as one. + * + * Wrapped in `cache` rather than left to fetch's own memoisation, because the + * abort signal opts this request out of that (it matches on the signal), and + * the nav and the footer both ask within one render. + */ +export const starCount = cache(async (): Promise => { + try { + const response = await fetch(api, { + headers: { accept: "application/vnd.github+json" }, + signal: AbortSignal.timeout(TIMEOUT_MS), + next: { revalidate: REVALIDATE_SECONDS }, + }); + if (!response.ok) return null; + const body: unknown = await response.json(); + const stars = (body as { stargazers_count?: unknown }).stargazers_count; + if (typeof stars !== "number" || !Number.isFinite(stars)) return null; + return Math.max(0, Math.floor(stars)); + } catch { + return null; + } +}); + +/** + * A count at the width a nav chip can hold: 128, 1.2k, 12k. + * + * Rounded to tenths before the branch, not after: `toFixed` on the raw + * quotient reads 9950 as 9.9k, because 9.95 is not 9.95 in binary. + */ +export function formatStars(count: number): string { + if (count < 1000) return String(count); + const tenths = Math.round(count / 100) / 10; + return `${tenths < 10 ? tenths : Math.round(tenths)}k`; +}