← ui

github

ui

GitHub drawn from its facts, no API at render: repo, issue, comment, profile

npx shadcn add @ag/github
Already installed? Preview an update
npx shadcn add @ag/github --dry-run

Your installed source stays unchanged until you update it. How updates work

Latest changes · 2026-10-13
  • Avatars arrive through Image: held until loaded, then faded in, hidden on error so the card's own tone shows. Installing github now brings image.
Updated 2026-10-13 · release notes

also called contribution graph

shelf social & links

uses card-link, reactions, image, panel

the demo above, verbatim

github.demo.tsx
"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>&apos;s job, once, with
        the author&apos;s own token: no API and no token on the page.
      </p>
    </div>
  )
}

the source, every file an install writes

github.tsx
// 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-data.ts
// 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)
}