GitHub drawn from its facts, no API at render: repo, issue, comment, profile
npx shadcn add @ag/githubAlready installed? Preview an update
npx shadcn add @ag/github --dry-runYour installed source stays unchanged until you update it. How updates work
- Avatars arrive through
Image: held until loaded, then faded in, hidden on error so the card's own tone shows. Installinggithubnow bringsimage.
the demo above, verbatim
"use client"
import { Sample } from "@/app/samples"
import type {
GithubComment,
GithubProfile,
GithubRepo,
GithubThread,
} from "@/registry/base-nova/lib/github-data"
import { Github } from "@/registry/base-nova/ui/github"
// #region facts
/** Facts as data, written by `mise github <url>` for five real URLs. Nothing here
* fetched; the calendar alone is a year of days, so it lives in a file. */
import FACTS from "./github.demo.json"
const ISSUE = FACTS.issue as GithubThread
const COMMENT = FACTS.comment as GithubComment
const LONG = FACTS.long as GithubComment
const PR = FACTS.pr as GithubThread
const REPO = FACTS.repo as GithubRepo
const PROFILE = FACTS.profile as GithubProfile
// #endregion
export default function Demo() {
return (
<div className="space-y-16">
<Sample
name="comment"
with="facts"
title="01 · a comment"
description="one reply in its thread, the thread on a line above it, GitHub's own rendering of the markdown"
>
<Github facts={COMMENT} className="mx-auto max-w-2xl" />
</Sample>
<Sample
name="cut"
title="02 · cut"
description="lines={14} on a long comment and a short one: the long stops at fourteen lines with the rest a link away, the short never reaches the cut and shows whole"
>
<div className="mx-auto max-w-2xl space-y-6">
<Github facts={LONG} lines={14} />
<Github facts={COMMENT} lines={14} />
</div>
</Sample>
<Sample
name="threads"
glance="large"
title="03 · an issue and a pull request"
description="state in GitHub's colours, labels in their own, reactions, the size of the change and commits={3}"
>
<div className="mx-auto grid max-w-4xl items-start gap-6 md:grid-cols-2">
<Github facts={ISSUE} />
<Github facts={PR} commits={3} />
</div>
</Sample>
<Sample
name="repo"
title="04 · a repository"
description="the pinned card: description, language, stars, forks, and commits={3} from the default branch"
>
<Github facts={REPO} commits={3} className="mx-auto max-w-xl" />
</Sample>
<Sample
name="profile"
title="05 · a profile"
description="the person and the last year of contributions; hover a day for its count"
>
<Github facts={PROFILE} className="mx-auto max-w-3xl" />
</Sample>
<p className="max-w-prose text-foreground/60 text-sm">
Every card is a pure function of its facts and opens its place on
GitHub. Fetching them is <code>github-data</code>'s job, once, with
the author's own token: no API and no token on the page.
</p>
</div>
)
}the source, every file an install writes
// GITHUB, drawn from its facts (`github-data`): a repository as its pinned card, an
// issue or a pull request as GitHub unfurls one, a single comment as it sits in its
// thread, a profile with the contribution calendar. No API at render, no token on the
// page: the facts were fetched once, at authoring time.
//
// GitHub's own vocabulary where a reader already knows it: the state icons and their
// colours (open green, done and merged purple, not planned and draft grey, a closed
// pull request red), labels in their own colour, the calendar's five greens. On the
// page's surfaces otherwise, light and dark.
//
// Every card opens its place on GitHub (`card-link`): a click anywhere but a link, a
// control or a selection. Inside it, names open profiles, the title and the date open
// the thread, which is also the keyboard's way there.
import {
BookMarked,
Building2,
CircleCheck,
CircleDot,
CircleSlash,
GitFork,
GitMerge,
GitPullRequest,
GitPullRequestClosed,
GitPullRequestDraft,
Link2,
MapPin,
MessageSquare,
Scale,
Star,
Users,
} from "lucide-react"
import type * as React from "react"
import { cn } from "@/lib/utils"
import type {
GithubComment,
GithubCommit,
GithubDay,
GithubFacts,
GithubLabel,
GithubProfile,
GithubReaction,
GithubRepo,
GithubState,
GithubThread,
GithubThreadHeader,
GithubUser,
} from "@/registry/base-nova/lib/github-data"
import { CardLink } from "@/registry/base-nova/ui/card-link"
import { Image } from "@/registry/base-nova/ui/image"
import { Panel } from "@/registry/base-nova/ui/panel"
import { Reactions } from "@/registry/base-nova/ui/reactions"
const ANCHOR = { target: "_blank", rel: "noopener noreferrer" } as const
/** A card's type, on the one surface every box here is (Panel: the card alone,
* the inset one inside a frame already drawn). */
const CARD = "group font-sans text-[15px]"
const MUTED = "text-foreground/60"
const LINK = "text-[#0969da] hover:underline dark:text-[#4493f8]"
const ICON = "size-4 shrink-0"
const DATE = new Intl.DateTimeFormat("en", {
month: "short",
day: "numeric",
year: "numeric",
timeZone: "UTC",
})
const date = (iso: string) => DATE.format(new Date(iso))
const COUNT = new Intl.NumberFormat("en", { notation: "compact" })
// ── state ──
const STATE: Record<GithubState, { word: string; ink: string; fill: string }> =
{
open: {
word: "Open",
ink: "text-[#1a7f37] dark:text-[#3fb950]",
fill: "bg-[#1f883d] dark:bg-[#238636]",
},
draft: {
word: "Draft",
ink: "text-[#59636e] dark:text-[#9198a1]",
fill: "bg-[#59636e] dark:bg-[#656c76]",
},
completed: {
word: "Closed",
ink: "text-[#8250df] dark:text-[#ab7df8]",
fill: "bg-[#8250df] dark:bg-[#8957e5]",
},
"not-planned": {
word: "Closed",
ink: "text-[#59636e] dark:text-[#9198a1]",
fill: "bg-[#59636e] dark:bg-[#656c76]",
},
merged: {
word: "Merged",
ink: "text-[#8250df] dark:text-[#ab7df8]",
fill: "bg-[#8250df] dark:bg-[#8957e5]",
},
closed: {
word: "Closed",
ink: "text-[#d1242f] dark:text-[#f85149]",
fill: "bg-[#cf222e] dark:bg-[#da3633]",
},
}
function StateIcon({
kind,
state,
className,
}: {
kind: "issue" | "pr"
state: GithubState
className?: string
}) {
const Icon =
kind === "pr"
? state === "merged"
? GitMerge
: state === "closed"
? GitPullRequestClosed
: state === "draft"
? GitPullRequestDraft
: GitPullRequest
: state === "completed"
? CircleCheck
: state === "not-planned"
? CircleSlash
: CircleDot
return <Icon aria-hidden="true" className={cn(ICON, className)} />
}
function StatePill({ thread }: { thread: GithubThreadHeader }) {
const s = STATE[thread.state]
return (
<span
data-slot="github-state"
className={cn(
"inline-flex items-center gap-1 rounded-full px-2.5 py-0.5 font-medium text-[13px] text-white",
s.fill,
)}
>
<StateIcon kind={thread.kind} state={thread.state} className="size-3.5" />
{s.word}
</span>
)
}
// ── parts ──
/** A face as GitHub draws it: a person round, an organization a rounded square. */
function Avatar({
user,
size,
}: {
user: Pick<GithubUser, "avatar" | "org">
size: string
}) {
return (
<Image
src={user.avatar}
alt=""
className={cn(
size,
"shrink-0 bg-foreground/8 object-cover",
user.org ? "rounded-[22%]" : "rounded-full",
)}
/>
)
}
/** Reactions as GitHub lists them under a post: the emoji and how many gave it, as
* `reactions`' readout. Never yours (the facts were fetched with no viewer) and never
* buttons: pressing one here would promise a reaction this page cannot send. */
function Given({ reactions }: { reactions: GithubReaction[] }) {
if (reactions.length === 0) return null
return <Reactions reactions={reactions.map((r) => ({ ...r, mine: false }))} />
}
/** Commits as GitHub lists them: who, the first line of the message, the short sha and
* the day, each a link to the commit. */
function Commits({
commits,
count,
}: {
commits: GithubCommit[]
count: number
}) {
if (!Number.isInteger(count) || count < 1)
throw new Error(`github: commits must be a whole number from 1`)
const shown = commits.slice(0, count)
if (shown.length === 0) return null
return (
<ol
data-slot="github-commits"
className="divide-y divide-border overflow-hidden rounded-lg border border-border text-[13px]"
>
{shown.map((c) => (
<li key={c.sha} className="flex items-center gap-2 px-3 py-2">
<Avatar user={c.author} size="size-4" />
<a
href={c.url}
{...ANCHOR}
className="min-w-0 flex-1 truncate hover:underline"
>
{c.message}
</a>
<a
href={c.url}
{...ANCHOR}
className={cn("shrink-0 font-mono text-[12px]", LINK)}
>
{c.sha.slice(0, 7)}
</a>
<time dateTime={c.date} className={cn("shrink-0", MUTED)}>
{date(c.date)}
</time>
</li>
))}
</ol>
)
}
function UserLink({ user }: { user: GithubUser }) {
return (
<a href={user.url} {...ANCHOR} className="font-semibold hover:underline">
{user.login}
</a>
)
}
/** A label in its own colour, legible on either ground: a wash of the colour, its rim,
* and the text mixed toward the page's ink. */
function Label({ label }: { label: GithubLabel }) {
return (
<span
data-slot="github-label"
style={{ "--ag-gh-label": `#${label.color}` } as React.CSSProperties}
className="rounded-full border border-[color-mix(in_srgb,var(--ag-gh-label)_45%,transparent)] bg-[color-mix(in_srgb,var(--ag-gh-label)_18%,transparent)] px-2 py-px font-medium text-[12px] text-[color-mix(in_srgb,var(--ag-gh-label)_55%,var(--foreground))]"
>
{label.name}
</span>
)
}
/** GitHub's own markdown rendering, sanitized by GitHub, styled here: the page's type,
* GitHub's link blue, code on a step of the surface, and the syntax colours GitHub's
* highlighter names (`pl-*`). edge: GitHub's <pre> is GitHub's markup, framed on
* itself; a long line scrolls in it as on GitHub. */
const MARKDOWN = cn(
"break-words leading-relaxed [&>:first-child]:mt-0 [&>:last-child]:mb-0",
"[&_p]:my-2 [&_ul]:my-2 [&_ul]:list-disc [&_ul]:pl-5 [&_ol]:my-2 [&_ol]:list-decimal [&_ol]:pl-5 [&_li]:my-0.5",
"[&_h1]:mt-4 [&_h1]:mb-2 [&_h1]:font-semibold [&_h1]:text-lg [&_h2]:mt-4 [&_h2]:mb-2 [&_h2]:font-semibold [&_h3]:mt-3 [&_h3]:mb-1 [&_h3]:font-semibold",
"[&_a]:text-[#0969da] [&_a:hover]:underline dark:[&_a]:text-[#4493f8]",
"[&_code]:rounded-md [&_code]:bg-foreground/8 [&_code]:px-1 [&_code]:py-0.5 [&_code]:font-mono [&_code]:text-[0.85em]",
"[&_pre]:my-3 [&_pre]:overflow-x-auto [&_pre]:rounded-lg [&_pre]:bg-foreground/5 [&_pre]:p-3 [&_pre]:font-mono [&_pre]:text-[13px] [&_pre]:leading-normal [&_pre_code]:bg-transparent [&_pre_code]:p-0 [&_pre_code]:text-inherit",
"[&_blockquote]:my-2 [&_blockquote]:border-foreground/20 [&_blockquote]:border-l-4 [&_blockquote]:pl-3 [&_blockquote]:text-foreground/60",
"[&_img]:max-w-full [&_hr]:my-4 [&_hr]:border-border",
"[&_.pl-k]:text-[#cf222e] [&_.pl-s]:text-[#0a3069] [&_.pl-c1]:text-[#0550ae] [&_.pl-en]:text-[#6639ba] [&_.pl-c]:text-[#59636e] [&_.pl-smi]:text-inherit",
"dark:[&_.pl-k]:text-[#ff7b72] dark:[&_.pl-s]:text-[#a5d6ff] dark:[&_.pl-c1]:text-[#79c0ff] dark:[&_.pl-en]:text-[#d2a8ff] dark:[&_.pl-c]:text-[#9198a1]",
)
function Markdown({ html }: { html: string }) {
return (
<div
data-slot="github-markdown"
className={MARKDOWN}
// biome-ignore lint/security/noDangerouslySetInnerHtml: GitHub's own sanitized rendering, fetched by the author at authoring time
dangerouslySetInnerHTML={{ __html: html }}
/>
)
}
/** The thread a card is about, on one line: its state, where it lives, its title. */
function ThreadLine({ thread }: { thread: GithubThreadHeader }) {
return (
<a
href={thread.url}
{...ANCHOR}
className="group flex min-w-0 items-center gap-1.5 text-[14px]"
>
<StateIcon
kind={thread.kind}
state={thread.state}
className={STATE[thread.state].ink}
/>
<span className={cn("shrink-0", MUTED)}>
{thread.repo}#{thread.number}
</span>
<span className="truncate font-medium group-hover:underline">
{thread.title}
</span>
</a>
)
}
// ── cards ──
export function GithubRepoCard({
repo,
commits,
className,
}: {
repo: GithubRepo
/** List the default branch's latest commits under the card, this many. */
commits?: number
className?: string
}) {
const [owner, name] = repo.name.split("/")
return (
<Panel
render={<CardLink href={repo.url} />}
data-slot="github-repo"
bodyClassName="gap-3 p-4"
className={cn(CARD, className)}
>
<div className="flex items-center gap-2">
<BookMarked aria-hidden="true" className={cn(ICON, MUTED)} />
<a
href={repo.url}
{...ANCHOR}
className="min-w-0 truncate text-[#0969da] hover:underline dark:text-[#4493f8]"
>
{owner}/<span className="font-semibold">{name}</span>
</a>
{repo.archived && (
<span className="rounded-full border border-[#9a6700]/50 px-2 text-[#9a6700] text-[12px] dark:border-[#d29922]/50 dark:text-[#d29922]">
Archived
</span>
)}
</div>
{repo.description && (
<p className={cn("line-clamp-2", MUTED)}>{repo.description}</p>
)}
{repo.topics.length > 0 && (
<div className="flex flex-wrap gap-1.5">
{repo.topics.map((t) => (
<a
key={t}
href={`https://github.com/topics/${t}`}
{...ANCHOR}
className="rounded-full bg-[#ddf4ff] px-2.5 py-0.5 font-medium text-[#0969da] text-[12px] hover:bg-[#0969da] hover:text-white dark:bg-[#388bfd]/10 dark:text-[#4493f8] dark:hover:bg-[#4493f8] dark:hover:text-black"
>
{t}
</a>
))}
</div>
)}
<div
className={cn(
"flex flex-wrap items-center gap-x-4 gap-y-1 text-[13px]",
MUTED,
)}
>
{repo.language && (
<span className="flex items-center gap-1.5">
<span
aria-hidden="true"
className="size-3 rounded-full"
style={{ background: repo.language.color }}
/>
{repo.language.name}
</span>
)}
<a
href={`${repo.url}/stargazers`}
{...ANCHOR}
className="flex items-center gap-1 hover:text-[#0969da] dark:hover:text-[#4493f8]"
>
<Star aria-hidden="true" className="size-3.5" />
{COUNT.format(repo.stars)}
<span className="sr-only"> stars</span>
</a>
<a
href={`${repo.url}/forks`}
{...ANCHOR}
className="flex items-center gap-1 hover:text-[#0969da] dark:hover:text-[#4493f8]"
>
<GitFork aria-hidden="true" className="size-3.5" />
{COUNT.format(repo.forks)}
<span className="sr-only"> forks</span>
</a>
{repo.license && (
<span className="flex items-center gap-1">
<Scale aria-hidden="true" className="size-3.5" />
{repo.license}
</span>
)}
<span>Updated {date(repo.pushedAt)}</span>
</div>
{commits !== undefined && (
<Commits commits={repo.commits} count={commits} />
)}
</Panel>
)
}
export function GithubThreadCard({
thread,
body = false,
lines,
commits,
className,
}: {
thread: GithubThread
/** Draw the opening post under the header, as the thread's first comment. */
body?: boolean
/** Cut that opening post at this many lines. Whole when unset. */
lines?: number
/** A pull request: list its last commits, this many. */
commits?: number
className?: string
}) {
const verb =
thread.state === "merged" ? "merged" : thread.closedAt ? "closed" : "opened"
return (
<Panel
render={<CardLink href={thread.url} />}
data-slot="github-thread"
bodyClassName="gap-3 p-4"
className={cn(CARD, className)}
>
<div className={cn("flex items-center gap-2 text-[13px]", MUTED)}>
<Avatar user={thread.author} size="size-5" />
<span className="min-w-0 truncate">{thread.repo}</span>
<span aria-hidden="true">·</span>
<span>{thread.kind === "pr" ? "Pull request" : "Issue"}</span>
</div>
<a
href={thread.url}
{...ANCHOR}
className="block font-semibold text-[18px] leading-snug hover:underline"
>
{thread.title}{" "}
<span className="font-normal text-foreground/50">#{thread.number}</span>
</a>
<div
className={cn(
"flex flex-wrap items-center gap-x-2 gap-y-1 text-[13px]",
MUTED,
)}
>
<StatePill thread={thread} />
<span>
<UserLink user={thread.author} /> opened on{" "}
<time dateTime={thread.createdAt}>{date(thread.createdAt)}</time>
{thread.closedAt && verb !== "opened" && (
<>
{", "}
{verb} on{" "}
<time dateTime={thread.closedAt}>{date(thread.closedAt)}</time>
</>
)}
</span>
</div>
{thread.labels.length > 0 && (
<div className="flex flex-wrap gap-1">
{thread.labels.map((l) => (
<Label key={l.name} label={l} />
))}
</div>
)}
{body && thread.bodyHtml && (
<div className="border-border border-t pt-3">
<Body html={thread.bodyHtml} lines={lines} href={thread.url} />
</div>
)}
<Given reactions={thread.reactions} />
{commits !== undefined && thread.commits && (
<Commits commits={thread.commits} count={commits} />
)}
<div
className={cn(
"flex flex-wrap items-center gap-x-4 gap-y-1 border-border border-t pt-3 text-[13px]",
MUTED,
)}
>
<span className="flex items-center gap-1">
<MessageSquare aria-hidden="true" className="size-3.5" />
{thread.comments} {thread.comments === 1 ? "comment" : "comments"}
</span>
{thread.diff && (
<span className="font-mono">
<span className="text-[#1a7f37] dark:text-[#3fb950]">
+{thread.diff.additions.toLocaleString("en")}
</span>{" "}
<span className="text-[#d1242f] dark:text-[#f85149]">
−{thread.diff.deletions.toLocaleString("en")}
</span>{" "}
· {thread.diff.files} {thread.diff.files === 1 ? "file" : "files"}
</span>
)}
{thread.branches && (
<span className="min-w-0 truncate font-mono text-[12px]">
{thread.branches.base} ← {thread.branches.head}
</span>
)}
</div>
</Panel>
)
}
export function GithubCommentCard({
comment,
lines,
className,
}: {
comment: GithubComment
/** Cut a long comment at this many lines, the rest a link away. Whole when unset. */
lines?: number
className?: string
}) {
return (
<Panel
render={<CardLink href={comment.url} />}
data-slot="github-comment"
bodyClassName="gap-0 p-0"
className={cn(CARD, "overflow-hidden", className)}
>
<div className="border-border border-b px-4 py-2.5">
<ThreadLine thread={comment.thread} />
</div>
<div className="flex items-center gap-2 bg-foreground/[0.03] px-4 py-2 text-[14px]">
<Avatar user={comment.author} size="size-6" />
<span className="min-w-0 truncate">
<UserLink user={comment.author} />{" "}
<span className={MUTED}>
commented on{" "}
<a href={comment.url} {...ANCHOR} className="hover:underline">
<time dateTime={comment.createdAt}>
{date(comment.createdAt)}
</time>
</a>
</span>
</span>
</div>
<div className="px-4 py-3">
<Body html={comment.bodyHtml} lines={lines} href={comment.url} />
</div>
{comment.reactions.length > 0 && (
<div className="px-4 pb-3">
<Given reactions={comment.reactions} />
</div>
)}
</Panel>
)
}
/** How many lines the fade spans when a body is cut. */
const FADE_LINES = 4
/** A body, whole, or cut at `lines` lines of its own type with the rest fading out and
* a way to read it on GitHub. No measuring: the fade and the link sit AT the cut, so
* a body shorter than the cut ends before them and the clip hides both. */
function Body({
html,
lines,
href,
}: {
html: string
lines?: number
href: string
}) {
if (lines === undefined) return <Markdown html={html} />
if (!Number.isInteger(lines) || lines <= FADE_LINES)
throw new Error(`github: lines must be a whole number over ${FADE_LINES}`)
return (
<div
className="relative max-h-(--ag-gh-cut) overflow-hidden leading-relaxed"
style={
{
"--ag-gh-cut": `${lines}lh`,
"--ag-gh-fade": `${FADE_LINES}lh`,
"--ag-gh-fade-top": `${lines - FADE_LINES}lh`,
"--ag-gh-more-top": `${lines - 1}lh`,
} as React.CSSProperties
}
>
<Markdown html={html} />
<div
aria-hidden="true"
className="pointer-events-none absolute inset-x-0 top-(--ag-gh-fade-top) h-(--ag-gh-fade) bg-linear-to-b from-transparent to-card transition-colors group-hover:to-[color-mix(in_srgb,var(--card),var(--foreground)_3%)]"
/>
<a
href={href}
{...ANCHOR}
className={cn(
"absolute top-(--ag-gh-more-top) left-0 font-medium",
LINK,
)}
>
Read the rest on GitHub
</a>
</div>
)
}
/** The website as typed, as a link: GitHub stores it without a scheme sometimes. */
const site = (w: string) => (/^https?:\/\//.test(w) ? w : `https://${w}`)
export function GithubProfileCard({
profile,
className,
}: {
profile: GithubProfile
className?: string
}) {
return (
<Panel
render={<CardLink href={profile.url} />}
data-slot="github-profile"
bodyClassName="gap-4 p-4"
className={cn(CARD, className)}
>
<div className="flex items-center gap-4">
<Avatar user={profile.user} size="size-16" />
<div className="min-w-0">
{profile.name && (
<div className="truncate font-semibold text-[20px] leading-tight">
{profile.name}
</div>
)}
<a
href={profile.url}
{...ANCHOR}
className={cn("text-[16px] hover:underline", MUTED)}
>
{profile.user.login}
</a>
</div>
</div>
{profile.bio && <p>{profile.bio}</p>}
<div
className={cn(
"flex flex-wrap items-center gap-x-4 gap-y-1 text-[13px]",
MUTED,
)}
>
<span className="flex items-center gap-1">
<Users aria-hidden="true" className="size-3.5" />
<span className="font-semibold text-foreground">
{COUNT.format(profile.followers)}
</span>{" "}
followers ·{" "}
<span className="font-semibold text-foreground">
{COUNT.format(profile.following)}
</span>{" "}
following
</span>
<span className="flex items-center gap-1">
<BookMarked aria-hidden="true" className="size-3.5" />
<span className="font-semibold text-foreground">{profile.repos}</span>{" "}
repositories
</span>
{profile.company && (
<span className="flex items-center gap-1">
<Building2 aria-hidden="true" className="size-3.5" />
{profile.company}
</span>
)}
{profile.location && (
<span className="flex items-center gap-1">
<MapPin aria-hidden="true" className="size-3.5" />
{profile.location}
</span>
)}
{profile.website && (
<a
href={site(profile.website)}
{...ANCHOR}
className={cn("flex items-center gap-1", LINK)}
>
<Link2 aria-hidden="true" className="size-3.5" />
{profile.website}
</a>
)}
</div>
<ContributionCalendar calendar={profile.calendar} />
</Panel>
)
}
// ── the calendar ──
/** GitHub's five greens, by quartile, on light and on dark. */
const LEVEL = [
"fill-[#ebedf0] dark:fill-[#151b23]",
"fill-[#9be9a8] dark:fill-[#033a16]",
"fill-[#40c463] dark:fill-[#196c2e]",
"fill-[#30a14e] dark:fill-[#2ea043]",
"fill-[#216e39] dark:fill-[#56d364]",
] as const
const CELL = 10
const GAP = 3
const STEP = CELL + GAP
const LEFT = 28
const TOP = 16
const MONTH = new Intl.DateTimeFormat("en", { month: "short", timeZone: "UTC" })
function dayTitle(d: GithubDay): string {
const when = date(`${d.date}T00:00:00Z`)
return d.count === 0
? `No contributions on ${when}`
: `${d.count} contribution${d.count === 1 ? "" : "s"} on ${when}`
}
/** The last year as GitHub draws it: a column per week, Sunday on top, a square per day
* in the green of its quartile, the months over the columns where they begin. An SVG
* in its own units, so it scales to the width it is given; each day says its count on
* hover (`<title>`). */
export function ContributionCalendar({
calendar,
className,
}: {
calendar: GithubProfile["calendar"]
className?: string
}) {
const weeks = calendar.weeks
const width = LEFT + weeks.length * STEP
const height = TOP + 7 * STEP
// A month is named over the first week that starts in it, unless the previous name
// sits closer than three columns (the first, partial month usually does).
const months: { x: number; label: string }[] = []
weeks.forEach((week, i) => {
const first = week[0]
if (!first) return
const label = MONTH.format(new Date(`${first.date}T00:00:00Z`))
const prev = i > 0 ? weeks[i - 1]?.[0] : undefined
const changed =
!prev || MONTH.format(new Date(`${prev.date}T00:00:00Z`)) !== label
if (!changed) return
const x = LEFT + i * STEP
const last = months.at(-1)
if (last && x - last.x < 3 * STEP) months.pop()
months.push({ x, label })
})
return (
<figure
data-slot="github-calendar"
className={cn("m-0 space-y-2", className)}
>
<figcaption className="text-[13px] text-foreground/60">
{calendar.total.toLocaleString("en")} contributions in the last year
</figcaption>
<svg
viewBox={`0 0 ${width} ${height}`}
className="block h-auto w-full"
role="img"
aria-label={`${calendar.total.toLocaleString("en")} contributions in the last year`}
>
<g className="fill-foreground/55 text-[9px]">
{months.map((m) => (
<text key={m.x} x={m.x} y={10}>
{m.label}
</text>
))}
{(["Mon", "Wed", "Fri"] as const).map((d, i) => (
<text key={d} x={0} y={TOP + (2 * i + 1) * STEP + CELL - 1}>
{d}
</text>
))}
</g>
{weeks.map((week, x) => {
// The first week of the year may start mid-week: its days keep their weekday.
const offset = 7 - week.length
return week.map((d, y) => (
<rect
key={d.date}
x={LEFT + x * STEP}
y={TOP + (x === 0 ? y + offset : y) * STEP}
width={CELL}
height={CELL}
rx={2}
className={LEVEL[d.level]}
>
<title>{dayTitle(d)}</title>
</rect>
))
})}
</svg>
<div className="flex items-center justify-end gap-1 text-[12px] text-foreground/60">
Less
<svg
viewBox={`0 0 ${5 * STEP} ${CELL}`}
className="h-[10px] w-auto"
aria-hidden="true"
>
{LEVEL.map((fill, i) => (
<rect
key={fill}
x={i * STEP}
y={0}
width={CELL}
height={CELL}
rx={2}
className={fill}
/>
))}
</svg>
More
</div>
</figure>
)
}
/** Whatever a github.com URL turned out to be, drawn by its own card. */
export function Github({
facts,
lines,
commits,
className,
}: {
facts: GithubFacts
/** A comment, or an issue or pull request's opening post, cut at this many lines.
* An issue or pull request with `lines` shows its opening post; without, only its
* header. */
lines?: number
/** A repository's latest commits or a pull request's last ones, this many. */
commits?: number
className?: string
}) {
if (facts.kind === "repo")
return (
<GithubRepoCard repo={facts} commits={commits} className={className} />
)
if (facts.kind === "comment")
return (
<GithubCommentCard comment={facts} lines={lines} className={className} />
)
if (facts.kind === "profile")
return <GithubProfileCard profile={facts} className={className} />
return (
<GithubThreadCard
thread={facts}
body={lines !== undefined}
lines={lines}
commits={commits}
className={className}
/>
)
}// GITHUB, as data: a repository, an issue or a pull request, one comment on either,
// a profile with its contribution calendar. The facts `github` draws, and the one
// fetch that reads them.
//
// WHERE THE FETCH RUNS is the web-preview decision: at authoring time, with the
// author's own token (`gh auth token`), once. The facts ship beside the page; a deploy
// never asks GitHub anything and never carries a token. GraphQL for everything it can
// name; a single comment comes from REST, since GraphQL cannot find one by its id.
//
// One URL says which: `github.com/o` a profile, `/o/r` a repository, `/o/r/issues/n`
// and `/o/r/pull/n` the thread, `#issuecomment-id` one comment in it. Anything else is
// a THROW, never a guess.
export interface GithubUser {
login: string
avatar: string
url: string
/** An organization: GitHub draws its avatar as a rounded square, a person's round. */
org?: boolean
}
/** One of GitHub's eight reactions and how many gave it, in GitHub's order. */
export interface GithubReaction {
emoji: string
count: number
}
export interface GithubCommit {
sha: string
/** The first line of the message. */
message: string
url: string
/** ISO 8601 */
date: string
/** Who wrote it: a GitHub account when the email maps to one, else the git name. */
author: { name: string; avatar: string; url?: string }
}
export interface GithubLabel {
name: string
/** Hex without `#`, as GitHub stores it. */
color: string
}
export interface GithubRepo {
kind: "repo"
url: string
/** "vercel/ai" */
name: string
owner: GithubUser
description?: string
homepage?: string
stars: number
forks: number
language?: { name: string; color: string }
topics: string[]
license?: string
archived: boolean
/** ISO 8601 */
pushedAt: string
/** The default branch's latest commits, newest first. */
commits: GithubCommit[]
}
/** Where a thread stands, in GitHub's own words: an issue closes as done or as not
* planned, a pull request merges or closes unmerged, and either may be a draft. */
export type GithubState =
| "open"
| "draft"
| "completed"
| "not-planned"
| "merged"
| "closed"
export interface GithubThread {
kind: "issue" | "pr"
url: string
repo: string
number: number
title: string
state: GithubState
author: GithubUser
createdAt: string
/** When it closed or merged. */
closedAt?: string
/** GitHub's own rendering of the opening post, sanitized by GitHub. */
bodyHtml: string
comments: number
labels: GithubLabel[]
/** Pull requests: the size of the change and where it goes. */
diff?: { additions: number; deletions: number; files: number }
branches?: { base: string; head: string }
/** Pull requests: the last commits, newest first. */
commits?: GithubCommit[]
/** Reactions to the opening post. */
reactions: GithubReaction[]
}
export interface GithubComment {
kind: "comment"
url: string
author: GithubUser
createdAt: string
/** GitHub's own rendering of the comment, sanitized by GitHub. */
bodyHtml: string
reactions: GithubReaction[]
/** The thread it answers: its header only. */
thread: GithubThreadHeader
}
/** A thread as one line names it: no body, commits or reactions. */
export type GithubThreadHeader = Omit<
GithubThread,
"bodyHtml" | "commits" | "reactions"
>
export interface GithubDay {
date: string
count: number
/** GitHub's quartile, 0 (none) to 4. */
level: 0 | 1 | 2 | 3 | 4
}
export interface GithubProfile {
kind: "profile"
url: string
user: GithubUser
name?: string
bio?: string
company?: string
location?: string
website?: string
followers: number
following: number
repos: number
/** The last year, by week (Sunday first), as GitHub draws it. */
calendar: { total: number; weeks: GithubDay[][] }
}
export type GithubFacts =
| GithubRepo
| GithubThread
| GithubComment
| GithubProfile
export type GithubTarget =
| { kind: "profile"; login: string }
| { kind: "repo"; owner: string; name: string }
| { kind: "thread"; owner: string; name: string; number: number }
| {
kind: "comment"
owner: string
name: string
number: number
id: number
}
/** What a github.com URL points at. THROWS on any other shape. */
export function githubTarget(url: string): GithubTarget {
const u = new URL(url)
if (u.hostname !== "github.com")
throw new Error(`github: not github.com: ${url}`)
const parts = u.pathname.split("/").filter(Boolean)
const comment = /^#issuecomment-(\d+)$/.exec(u.hash)?.[1]
const [owner, name, section, n] = parts
if (parts.length === 1 && owner) return { kind: "profile", login: owner }
if (parts.length === 2 && owner && name) return { kind: "repo", owner, name }
if (
parts.length === 4 &&
owner &&
name &&
(section === "issues" || section === "pull") &&
n &&
/^\d+$/.test(n)
) {
const number = Number(n)
return comment
? { kind: "comment", owner, name, number, id: Number(comment) }
: { kind: "thread", owner, name, number }
}
throw new Error(`github: not a profile, repo, issue, pull or comment: ${url}`)
}
async function graphql<T>(
token: string,
query: string,
variables: Record<string, unknown>,
): Promise<T> {
const res = await fetch("https://api.github.com/graphql", {
method: "POST",
headers: {
authorization: `bearer ${token}`,
"content-type": "application/json",
},
body: JSON.stringify({ query, variables }),
})
if (!res.ok) throw new Error(`github graphql: HTTP ${res.status}`)
const json = (await res.json()) as {
data?: T
errors?: { message: string }[]
}
if (json.errors?.length)
throw new Error(
`github graphql: ${json.errors.map((e) => e.message).join("; ")}`,
)
if (!json.data) throw new Error("github graphql: no data")
return json.data
}
// `s=` asks the avatar service for the size a card draws at 2x.
const avatar = (url: string) => `${url}${url.includes("?") ? "&" : "?"}s=96`
type RawUser = {
__typename?: string
login: string
avatarUrl: string
url: string
}
const user = (u: RawUser | null): GithubUser =>
u
? {
login: u.login,
avatar: avatar(u.avatarUrl),
url: u.url,
...(u.__typename === "Organization" ? { org: true } : {}),
}
: // A deleted account is GitHub's "ghost".
{
login: "ghost",
avatar: "https://avatars.githubusercontent.com/u/10137?s=96",
url: "https://github.com/ghost",
}
const USER = "__typename login avatarUrl url"
/** GitHub's eight reactions, in the order GitHub lists them, keyed by the GraphQL
* enum and by the REST field. */
const REACTIONS: { graphql: string; rest: string; emoji: string }[] = [
{ graphql: "THUMBS_UP", rest: "+1", emoji: "👍" },
{ graphql: "THUMBS_DOWN", rest: "-1", emoji: "👎" },
{ graphql: "LAUGH", rest: "laugh", emoji: "😄" },
{ graphql: "HOORAY", rest: "hooray", emoji: "🎉" },
{ graphql: "CONFUSED", rest: "confused", emoji: "😕" },
{ graphql: "HEART", rest: "heart", emoji: "❤️" },
{ graphql: "ROCKET", rest: "rocket", emoji: "🚀" },
{ graphql: "EYES", rest: "eyes", emoji: "👀" },
]
const reactionsOf = (count: (r: (typeof REACTIONS)[number]) => number) =>
REACTIONS.map((r) => ({ emoji: r.emoji, count: count(r) })).filter(
(r) => r.count > 0,
)
const REACTION_GROUPS = "reactionGroups{content reactors{totalCount}}"
type RawReactionGroups = {
content: string
reactors: { totalCount: number }
}[]
const COMMIT =
"oid messageHeadline committedDate url author{name avatarUrl user{login url}}"
type RawCommit = {
oid: string
messageHeadline: string
committedDate: string
url: string
author: {
name: string | null
avatarUrl: string
user: { login: string; url: string } | null
} | null
}
function commit(c: RawCommit): GithubCommit {
const a = c.author
const name = a?.user?.login ?? a?.name
if (!a || !name) throw new Error(`github: commit ${c.oid} has no author`)
return {
sha: c.oid,
message: c.messageHeadline,
url: c.url,
date: c.committedDate,
author: {
name,
avatar: avatar(a.avatarUrl),
...(a.user ? { url: a.user.url } : {}),
},
}
}
/** How many commits a card can draw: enough for a recent history, few enough that the
* facts stay small. */
const COMMITS = 5
async function repo(
token: string,
owner: string,
name: string,
): Promise<GithubRepo> {
type R = {
repository: {
nameWithOwner: string
url: string
description: string | null
homepageUrl: string | null
stargazerCount: number
forkCount: number
isArchived: boolean
pushedAt: string
licenseInfo: { spdxId: string } | null
primaryLanguage: { name: string; color: string | null } | null
repositoryTopics: { nodes: { topic: { name: string } }[] }
owner: RawUser
defaultBranchRef: { target: { history?: { nodes: RawCommit[] } } } | null
} | null
}
const { repository: r } = await graphql<R>(
token,
`query($owner:String!,$name:String!){repository(owner:$owner,name:$name){
nameWithOwner url description homepageUrl stargazerCount forkCount isArchived pushedAt
licenseInfo{spdxId} primaryLanguage{name color}
repositoryTopics(first:8){nodes{topic{name}}} owner{${USER}}
defaultBranchRef{target{... on Commit{history(first:${COMMITS}){nodes{${COMMIT}}}}}}}}`,
{ owner, name },
)
if (!r) throw new Error(`github: no repository ${owner}/${name}`)
return {
kind: "repo",
url: r.url,
name: r.nameWithOwner,
owner: user(r.owner),
...(r.description ? { description: r.description } : {}),
...(r.homepageUrl ? { homepage: r.homepageUrl } : {}),
stars: r.stargazerCount,
forks: r.forkCount,
...(r.primaryLanguage
? {
language: {
name: r.primaryLanguage.name,
color: r.primaryLanguage.color ?? "#8b949e",
},
}
: {}),
topics: r.repositoryTopics.nodes.map((n) => n.topic.name),
...(r.licenseInfo && r.licenseInfo.spdxId !== "NOASSERTION"
? { license: r.licenseInfo.spdxId }
: {}),
archived: r.isArchived,
pushedAt: r.pushedAt,
// An empty repository has no default branch, so no history.
commits: (r.defaultBranchRef?.target.history?.nodes ?? []).map(commit),
}
}
async function thread(
token: string,
owner: string,
name: string,
number: number,
): Promise<GithubThread> {
type T = {
repository: {
nameWithOwner: string
issueOrPullRequest:
| ({
__typename: "Issue" | "PullRequest"
number: number
title: string
url: string
state: "OPEN" | "CLOSED" | "MERGED"
createdAt: string
closedAt: string | null
author: RawUser | null
bodyHTML: string
comments: { totalCount: number }
labels: { nodes: GithubLabel[] } | null
reactionGroups: RawReactionGroups
} & {
commits?: { nodes: { commit: RawCommit }[] }
stateReason?: "COMPLETED" | "NOT_PLANNED" | "REOPENED" | null
isDraft?: boolean
mergedAt?: string | null
additions?: number
deletions?: number
changedFiles?: number
baseRefName?: string
headRefName?: string
})
| null
} | null
}
const shared = `number title url state createdAt closedAt author{${USER}} bodyHTML
comments{totalCount} labels(first:8){nodes{name color}} ${REACTION_GROUPS}`
const { repository } = await graphql<T>(
token,
`query($owner:String!,$name:String!,$n:Int!){repository(owner:$owner,name:$name){
nameWithOwner issueOrPullRequest(number:$n){__typename
... on Issue{${shared} stateReason}
... on PullRequest{${shared} isDraft mergedAt additions deletions changedFiles baseRefName headRefName
commits(last:${COMMITS}){nodes{commit{${COMMIT}}}}}}}}`,
{ owner, name, n: number },
)
const t = repository?.issueOrPullRequest
if (!repository || !t)
throw new Error(`github: no #${number} in ${owner}/${name}`)
const pr = t.__typename === "PullRequest"
const state: GithubState = pr
? t.state === "MERGED"
? "merged"
: t.state === "CLOSED"
? "closed"
: t.isDraft
? "draft"
: "open"
: t.state === "OPEN"
? "open"
: t.stateReason === "NOT_PLANNED"
? "not-planned"
: "completed"
const closedAt = (pr ? (t.mergedAt ?? t.closedAt) : t.closedAt) ?? undefined
return {
kind: pr ? "pr" : "issue",
url: t.url,
repo: repository.nameWithOwner,
number: t.number,
title: t.title,
state,
author: user(t.author),
createdAt: t.createdAt,
...(closedAt ? { closedAt } : {}),
bodyHtml: t.bodyHTML,
comments: t.comments.totalCount,
labels: t.labels?.nodes ?? [],
...(pr
? {
diff: {
additions: t.additions ?? 0,
deletions: t.deletions ?? 0,
files: t.changedFiles ?? 0,
},
branches: { base: t.baseRefName ?? "", head: t.headRefName ?? "" },
// GitHub lists a pull's commits oldest first; newest first, like a branch.
commits: (t.commits?.nodes ?? [])
.map((n) => commit(n.commit))
.reverse(),
}
: {}),
reactions: reactionsOf(
(r) =>
t.reactionGroups.find((g) => g.content === r.graphql)?.reactors
.totalCount ?? 0,
),
}
}
async function comment(
token: string,
target: Extract<GithubTarget, { kind: "comment" }>,
): Promise<GithubComment> {
const res = await fetch(
`https://api.github.com/repos/${target.owner}/${target.name}/issues/comments/${target.id}`,
{
headers: {
authorization: `bearer ${token}`,
// GitHub's own rendering of the markdown, sanitized by GitHub.
accept: "application/vnd.github.html+json",
},
},
)
if (!res.ok)
throw new Error(`github: comment ${target.id}: HTTP ${res.status}`)
const c = (await res.json()) as {
html_url: string
created_at: string
body_html: string
user: {
login: string
avatar_url: string
html_url: string
type: string
} | null
reactions: Record<string, number>
}
const {
bodyHtml: _body,
commits: _commits,
reactions: _reactions,
...parent
} = await thread(token, target.owner, target.name, target.number)
return {
kind: "comment",
url: c.html_url,
author: user(
c.user
? {
__typename: c.user.type,
login: c.user.login,
avatarUrl: c.user.avatar_url,
url: c.user.html_url,
}
: null,
),
createdAt: c.created_at,
bodyHtml: c.body_html,
reactions: reactionsOf((r) => c.reactions[r.rest] ?? 0),
thread: parent,
}
}
const LEVEL: Record<string, GithubDay["level"]> = {
NONE: 0,
FIRST_QUARTILE: 1,
SECOND_QUARTILE: 2,
THIRD_QUARTILE: 3,
FOURTH_QUARTILE: 4,
}
async function profile(token: string, login: string): Promise<GithubProfile> {
type P = {
user: {
login: string
avatarUrl: string
url: string
name: string | null
bio: string | null
company: string | null
location: string | null
websiteUrl: string | null
followers: { totalCount: number }
following: { totalCount: number }
repositories: { totalCount: number }
contributionsCollection: {
contributionCalendar: {
totalContributions: number
weeks: {
contributionDays: {
date: string
contributionCount: number
contributionLevel: string
}[]
}[]
}
}
} | null
}
const { user: p } = await graphql<P>(
token,
`query($login:String!){user(login:$login){${USER} name bio company location websiteUrl
followers{totalCount} following{totalCount}
repositories(ownerAffiliations:OWNER,privacy:PUBLIC){totalCount}
contributionsCollection{contributionCalendar{totalContributions
weeks{contributionDays{date contributionCount contributionLevel}}}}}}`,
{ login },
)
if (!p)
throw new Error(
`github: no user ${login} (an organization's profile is not drawn yet)`,
)
const cal = p.contributionsCollection.contributionCalendar
return {
kind: "profile",
url: p.url,
user: user(p),
...(p.name ? { name: p.name } : {}),
...(p.bio ? { bio: p.bio } : {}),
...(p.company ? { company: p.company } : {}),
...(p.location ? { location: p.location } : {}),
...(p.websiteUrl ? { website: p.websiteUrl } : {}),
followers: p.followers.totalCount,
following: p.following.totalCount,
repos: p.repositories.totalCount,
calendar: {
total: cal.totalContributions,
weeks: cal.weeks.map((w) =>
w.contributionDays.map((d) => {
const level = LEVEL[d.contributionLevel]
if (level === undefined)
throw new Error(`github: contribution level ${d.contributionLevel}`)
return { date: d.date, count: d.contributionCount, level }
}),
),
},
}
}
/** Fetch what a github.com URL points at, with the author's token. */
export async function fetchGithub(
url: string,
token: string,
): Promise<GithubFacts> {
if (!token) throw new Error("github: a token is required (gh auth token)")
const t = githubTarget(url)
if (t.kind === "profile") return profile(token, t.login)
if (t.kind === "repo") return repo(token, t.owner, t.name)
if (t.kind === "thread") return thread(token, t.owner, t.name, t.number)
return comment(token, t)
}