← ui

social-profile

ui

a profile on Telegram, GitHub or YouTube from its facts, as a card or a compact row

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

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

Latest changes · 2026-10-14
  • New, replacing telegram-profile: who is behind a handle on Telegram, GitHub or YouTube, from one shape of facts (ProfileFacts: platform, url, handle, name, kind, bio, photo, verified, counts), drawn by SocialProfile as a card or as one compact row for a hover card or a list. A composition on Panel, plain or glass from the nearest MaterialProvider (or material, scheme), drawing no box inside a frame already drawn (frame decides when given); the face is avatar's, the button the registry's Button in the platform's colour with its mark dimmer than the words. SocialMark draws a platform's mark. Fetchers, at a build or into a cache, no key: fetchTelegramProfile (lib/social-profile-telegram), fetchGithubProfile (lib/social-profile-github, a token optional), fetchYoutubeChannel (lib/social-profile-youtube). Any other platform maps to the same facts and passes mark.
  • telegram-profile is removed. Migration: <TelegramProfile profile={p} /> is <SocialProfile facts={p} />; import fetchTelegramProfile from lib/social-profile-telegram instead of lib/telegram-profile-data; in the facts kind: "user" is "person" and followers is counts[0].value; delete the old telegram-profile.tsx and telegram-profile-data.ts.
Updated 2026-10-14 · release notes

also called telegram profile, telegram card, telegram channel, github profile, youtube channel, user card, socials

shelf social & links

uses avatar, button, card-link, material, panel

the demo above, verbatim

social-profile.demo.tsx
"use client"

import { Sample } from "@/app/samples"
import type { ProfileFacts } from "@/registry/base-nova/lib/social-profile-core"
import { Button } from "@/registry/base-nova/ui/button"
import {
  HoverCard,
  HoverCardContent,
  HoverCardTrigger,
} from "@/registry/base-nova/ui/hover-card"
import { MaterialProvider } from "@/registry/base-nova/ui/material"
import { Panel } from "@/registry/base-nova/ui/panel"
import {
  SocialMark,
  SocialProfile,
} from "@/registry/base-nova/ui/social-profile"

// #region profiles
/** Profiles as data, read once by each platform's fetcher (fetchTelegramProfile,
 *  fetchGithubProfile, fetchYoutubeChannel) on 2026-10-14. Nothing here fetched. */
const TELEGRAM: ProfileFacts = {
  platform: "telegram",
  url: "https://t.me/adriangalilea",
  handle: "adriangalilea",
  name: "Adrian Galilea",
  kind: "person",
  bio: "https://adriangalilea.com",
  photo: "/adriangalilea.jpg",
  counts: [],
}
const GITHUB: ProfileFacts = {
  platform: "github",
  url: "https://github.com/adriangalilea",
  handle: "adriangalilea",
  name: "Adrian Galilea",
  kind: "person",
  bio: "✨",
  photo: "/adriangalilea.jpg",
  counts: [
    { value: 52, unit: "followers" },
    { value: 98, unit: "repositories" },
  ],
}
const YOUTUBE: ProfileFacts = {
  platform: "youtube",
  url: "https://www.youtube.com/@mkbhd",
  handle: "mkbhd",
  name: "Marques Brownlee",
  kind: "channel",
  bio: "MKBHD: Quality Tech Videos | YouTuber | Geek | Consumer Electronics | Tech Head | Internet Personality!\n\nbusiness@MKBHD.com\n\nNYC",
  photo:
    "https://yt3.googleusercontent.com/qu4TmIaYUlS41-dJ9gZ7DUR3nilvmB5_11i6OKSdvNnBNiyOusZP1bMN6ICnuxtjFBb6ioKgRQ=s176-c-k-c0x00ffffff-no-rj",
  verified: true,
  counts: [
    { value: 21_400_000, unit: "subscribers" },
    { value: 1800, unit: "videos" },
  ],
}
const ORG: ProfileFacts = {
  platform: "github",
  url: "https://github.com/vercel",
  handle: "vercel",
  name: "Vercel",
  kind: "organization",
  bio: "Agentic infrastructure for every app and agent.",
  photo: "https://avatars.githubusercontent.com/u/14985020?v=4&s=128",
  counts: [{ value: 238, unit: "repositories" }],
}
const CHANNEL: ProfileFacts = {
  platform: "telegram",
  url: "https://t.me/telegram",
  handle: "telegram",
  name: "Telegram News",
  kind: "channel",
  bio: "The official Telegram on Telegram. Much recursion. Very Telegram. Wow.",
  verified: true,
  counts: [{ value: 9_343_606, unit: "subscribers" }],
}
// #endregion

// #region places
/** A person's places as their marks, each opening its profile, its compact view
 *  behind it while the hand rests there. */
function Places({ profiles }: { profiles: ProfileFacts[] }) {
  return (
    <div className="flex items-center gap-1">
      {profiles.map((p) => (
        <HoverCard key={p.platform}>
          <Button
            variant="ghost"
            size="icon"
            aria-label={`${p.name} on ${p.platform}`}
            icon={<SocialMark platform={p.platform} />}
            render={<HoverCardTrigger href={p.url} delay={300} />}
          />
          <HoverCardContent className="w-80 p-3">
            <SocialProfile facts={p} form="compact" />
          </HoverCardContent>
        </HoverCard>
      ))}
    </div>
  )
}
// #endregion

export default function SocialProfileDemo() {
  return (
    <div className="space-y-8">
      <Sample name="card" glance title="a card" with="profiles">
        <SocialProfile facts={TELEGRAM} className="w-80" />
      </Sample>
      <Sample
        name="platforms"
        title="telegram, github, youtube"
        description="one shape for every platform: what it counts after the handle, its colour on the button where it has one, an organization's face square."
        with="profiles"
      >
        <div className="grid gap-4 lg:grid-cols-3">
          <SocialProfile facts={TELEGRAM} />
          <SocialProfile facts={GITHUB} />
          <SocialProfile facts={YOUTUBE} />
        </div>
      </Sample>
      <Sample
        name="glass"
        title="plain and glass"
        description="the same card in either material: glass from the provider round it, over a picture."
        with="profiles"
      >
        <div className="grid gap-4 lg:grid-cols-2">
          <SocialProfile facts={YOUTUBE} />
          <MaterialProvider material="glass" scheme="dark">
            <div className="rounded-xl bg-[url(/lightbox/lake.jpg)] bg-cover bg-center p-6">
              <SocialProfile facts={YOUTUBE} />
            </div>
          </MaterialProvider>
        </div>
      </Sample>
      <Sample
        name="compact"
        title="compact"
        description="one row: the face, the name, the handle and the count that leads, the platform's mark."
        with="profiles"
      >
        <div className="grid gap-4 lg:grid-cols-2">
          <div className="flex flex-col gap-2">
            <SocialProfile facts={TELEGRAM} form="compact" />
            <SocialProfile facts={GITHUB} form="compact" />
            <SocialProfile facts={YOUTUBE} form="compact" />
          </div>
          <MaterialProvider material="glass" scheme="dark">
            <div className="flex flex-col gap-2 rounded-xl bg-[url(/lightbox/shore.jpg)] bg-cover bg-center p-6">
              <SocialProfile facts={TELEGRAM} form="compact" />
              <SocialProfile facts={GITHUB} form="compact" />
              <SocialProfile facts={YOUTUBE} form="compact" />
            </div>
          </MaterialProvider>
        </div>
      </Sample>
      <Sample
        name="hover"
        title="behind each mark"
        description="a person's places as marks, each one's compact profile in a hover card; in the card it draws no box of its own."
        with="profiles places"
      >
        <div className="grid gap-4 lg:grid-cols-2">
          <div className="grid place-items-center p-6">
            <Places profiles={[TELEGRAM, GITHUB, YOUTUBE]} />
          </div>
          <MaterialProvider material="glass" scheme="dark">
            <div className="grid place-items-center rounded-xl bg-[url(/lightbox/field.jpg)] bg-cover bg-center p-6">
              <Places profiles={[TELEGRAM, GITHUB, YOUTUBE]} />
            </div>
          </MaterialProvider>
        </div>
      </Sample>
      <Sample
        name="in-a-panel"
        title="inside a glass panel"
        description="in a frame already drawn it draws no second box: it stands on the panel's glass, in the panel's scheme."
        with="profiles"
      >
        <div className="rounded-xl bg-[url(/lightbox/peaks.jpg)] bg-cover bg-center p-6">
          <Panel
            material="glass"
            scheme="dark"
            title="where to find me"
            description="telegram, for anything quick"
            className="mx-auto max-w-96"
          >
            <SocialProfile facts={TELEGRAM} />
          </Panel>
        </div>
      </Sample>
      <Sample
        name="kinds"
        title="a channel, an organization"
        description="what it is is said beside the handle unless it is what the platform's profiles usually are; with no photo, the name's initials in the platform's colour."
        with="profiles"
      >
        <div className="grid gap-4 lg:grid-cols-2">
          <SocialProfile facts={CHANNEL} />
          <SocialProfile facts={ORG} />
        </div>
      </Sample>
    </div>
  )
}

the source, every file an install writes

social-profile.tsx
"use client"

// A PROFILE ON A PLATFORM, drawn from its facts (`lib/social-profile-core`): a person, a
// bot, a channel, a group or an organization on Telegram, GitHub or YouTube, or any
// profile a consumer maps to the same facts. One component, two forms:
//
//   card     the photo, the name with its check, the handle with what it is and what
//            it counts, the bio, and the way in: a button into the platform.
//   compact  one row, for a hover card or a list: the face, the name, the handle and
//            the one count that matters, the platform's mark at its end.
//
// Its surface is a `Panel`'s, in either material: plain, the page's card; glass, the
// panel's glass, read from the nearest MaterialProvider like any control. Inside a
// frame already drawn (a panel, a hover card) it draws no box of its own and stands in
// that one, in its scheme; `frame` decides when given.
//
// The whole of it opens the profile (`card-link`); a real link inside is the
// keyboard's way there (the card's button, the compact row's name). The button is the
// registry's, its default face in the platform's colour where it has one (the theme's
// primary, set on it), plain in both materials: it stands on the card, and glass on
// the card's glass is what the registry does not draw by default. No request at
// render: the facts were read before (a build, a cache).

import { BadgeCheck } from "lucide-react"
import type * as React from "react"
import { type SimpleIcon, siGithub, siTelegram, siYoutube } from "simple-icons"
import { cn } from "@/lib/utils"
import {
  countText,
  type ProfileFacts,
  platformOf,
  type SocialPlatform,
} from "@/registry/base-nova/lib/social-profile-core"
import {
  Avatar,
  type AvatarSize,
  avatarLook,
} from "@/registry/base-nova/ui/avatar"
import { Button } from "@/registry/base-nova/ui/button"
import { CardLink } from "@/registry/base-nova/ui/card-link"
import {
  type Material,
  type Scheme,
  useMaterial,
} from "@/registry/base-nova/ui/material"
import { Panel, useInFrame } from "@/registry/base-nova/ui/panel"

export type SocialProfileForm = "card" | "compact"

const MARKS: Record<SocialPlatform, SimpleIcon> = {
  telegram: siTelegram,
  github: siGithub,
  youtube: siYoutube,
}

/** A platform's mark, in the words' colour: what a row of a person's places draws,
 *  and what the profile says it is on. Unnamed: what holds it says the platform (a
 *  button's label, the words beside it). Nothing for a platform this family does not
 *  know. */
export function SocialMark({
  platform,
  className,
}: {
  platform: string
  className?: string
}) {
  if (!platformOf(platform)) return null
  const icon = MARKS[platform as SocialPlatform]
  return (
    <svg
      data-slot="social-mark"
      aria-hidden
      viewBox="0 0 24 24"
      fill="currentColor"
      className={cn("size-4 shrink-0", className)}
    >
      <path d={icon.path} />
    </svg>
  )
}

/** The profile under the hand: plain, a step of the words' colour into the card;
 *  glass, its rim catching more light (important, over the glass's own light). */
const LIFT: Record<Material, string> = {
  plain:
    "transition-colors hover:bg-[color-mix(in_srgb,var(--card),var(--foreground)_3%)]",
  glass: "hover:[--glass-rim:1.5]!",
}

/** The face: the photo on the avatar's rungs, an organization's a rounded square as
 *  its platform draws it; with no photo, the name's initials. */
function Face({ facts, size }: { facts: ProfileFacts; size: AvatarSize }) {
  const square = facts.kind === "organization" && "rounded-[22%]"
  if (facts.photo)
    return (
      <Avatar
        src={facts.photo}
        alt=""
        size={size}
        className={square || undefined}
      />
    )
  const letters = facts.name
    .split(/\s+/)
    .slice(0, 2)
    .map((w) => Array.from(w)[0] ?? "")
    .join("")
    .toUpperCase()
  const look = avatarLook({ size })
  const tone = platformOf(facts.platform)?.tone
  return (
    <span
      aria-hidden
      data-slot="avatar"
      className={cn(
        look.className,
        square,
        "grid place-items-center font-medium",
        size === "lg" ? "text-xl" : "text-sm",
        tone
          ? "bg-(--ag-profile-tone) text-white"
          : "bg-current/12 text-current",
      )}
      style={
        tone
          ? ({
              ...look.style,
              "--ag-profile-tone": tone,
            } as React.CSSProperties)
          : look.style
      }
    >
      {letters}
    </span>
  )
}

/** The name, and its check when the platform verified it. */
function Name({
  facts,
  href,
  className,
}: {
  facts: ProfileFacts
  href?: string
  className: string
}) {
  const check = platformOf(facts.platform)?.check
  const name = <span className="truncate">{facts.name}</span>
  return (
    <span
      data-slot="social-profile-name"
      className={cn("flex min-w-0 items-center gap-1.5", className)}
    >
      {href ? (
        <a
          href={href}
          target="_blank"
          rel="noopener noreferrer"
          className="min-w-0 truncate no-underline hover:underline"
        >
          {facts.name}
        </a>
      ) : (
        name
      )}
      {facts.verified && (
        <BadgeCheck
          aria-label="verified"
          className={cn(
            "size-[1em] shrink-0",
            check ? "text-(--ag-profile-check)" : "text-current/55",
          )}
          style={
            check
              ? ({ "--ag-profile-check": check } as React.CSSProperties)
              : undefined
          }
        />
      )}
    </span>
  )
}

export interface SocialProfileProps {
  facts: ProfileFacts
  /** `card` (default), or one `compact` row. */
  form?: SocialProfileForm
  /** The platform's mark, for a platform this family does not know (a page of the
   *  consumer's own); a known one draws its own. */
  mark?: React.ReactNode
  /** Its own surface (a panel). Default drawn: inside a frame already drawn,
   *  the inset one (Panel). */
  frame?: boolean
  material?: Material
  /** The colour scheme its panel stands in (material): default the nearest
   *  provider's. */
  scheme?: Scheme
  className?: string
}

export function SocialProfile({
  facts,
  form = "card",
  mark,
  frame,
  material,
  scheme,
  className,
}: SocialProfileProps) {
  const m = useMaterial(material)
  // A profile has its identity, so it draws its own surface; inside a frame
  // already drawn, Panel makes it the inset one, which answers the hand itself.
  const inFrame = useInFrame()
  const framed = frame ?? true
  const lift = framed && !inFrame && LIFT[m]
  const platform = platformOf(facts.platform)
  const theMark = mark ?? <SocialMark platform={facts.platform} />
  const panel = {
    render: <CardLink href={facts.url} />,
    "data-slot": "social-profile",
    "data-form": form,
    "data-platform": facts.platform,
    material: m,
    scheme,
    frame: framed,
  }

  if (form === "compact") {
    const lead = facts.counts[0]
    return (
      <Panel
        {...panel}
        bodyClassName={cn(
          "flex-row items-center gap-3",
          framed ? "px-3 py-2.5" : "p-0",
        )}
        className={cn("group font-sans text-sm", lift, className)}
      >
        <Face facts={facts} size="md" />
        <div className="min-w-0 flex-1">
          <Name
            facts={facts}
            href={facts.url}
            className="font-semibold leading-tight"
          />
          <div className="truncate text-[13px] text-current/55">
            @{facts.handle}
            {lead && <span> · {countText(lead)}</span>}
          </div>
        </div>
        <span data-slot="social-profile-mark" className="opacity-60">
          {theMark}
        </span>
      </Panel>
    )
  }

  const what = [
    platform && facts.kind !== platform.usual ? facts.kind : undefined,
    ...facts.counts.map(countText),
  ].filter(Boolean)
  return (
    <Panel
      {...panel}
      bodyClassName={framed ? "gap-4 p-4" : "gap-4 p-0"}
      className={cn("group font-sans text-[15px]", lift, className)}
    >
      <div className="flex items-center gap-4">
        <Face facts={facts} size="lg" />
        <div className="min-w-0">
          <Name
            facts={facts}
            className="font-semibold text-[18px] leading-tight"
          />
          <div className="mt-0.5 truncate text-[14px] text-current/55">
            @{facts.handle}
            {what.map((w) => (
              <span key={w}> · {w}</span>
            ))}
          </div>
        </div>
      </div>
      {facts.bio && (
        <p
          data-slot="social-profile-bio"
          className="line-clamp-4 whitespace-pre-line text-current/80 leading-relaxed"
        >
          {facts.bio}
        </p>
      )}
      <Button
        render={
          <a href={facts.url} target="_blank" rel="noopener noreferrer" />
        }
        material="plain"
        corners={m === "glass" ? "pill" : "soft"}
        size="lg"
        icon={theMark}
        style={
          platform?.tone
            ? ({
                "--primary": platform.tone,
                "--primary-foreground": "#fff",
              } as React.CSSProperties)
            : undefined
        }
        className="self-start"
      >
        {platform ? platform.open(facts.kind) : "open"}
      </Button>
    </Panel>
  )
}
social-profile-core.ts
// A PROFILE ON A PLATFORM, as facts, framework-free: who is behind a handle on
// Telegram, GitHub, YouTube (and anything a consumer maps to the same shape: a page of
// its own, a platform with no fetcher here). One shape for every platform, so one
// component (`ui/social-profile`) draws them all, as a card or as a compact row.
//
// Each platform's fetcher is its own module (`social-profile-telegram`,
// `-github`, `-youtube`), run at a build or into a cache, never at render: a page never
// depends on the platform answering to draw. A handle the platform does not know is a
// THROW, never a profile drawn from nothing.

/** The platforms this family reads. A profile may name any other (`platform` is
 *  free): it is drawn the same, with no mark unless the caller gives one. */
export type SocialPlatform = "telegram" | "github" | "youtube"

/** What is behind the handle. Said beside the handle unless it is what the
 *  platform's profiles usually are (`PLATFORMS[p].usual`). */
export type ProfileKind =
  | "person"
  | "organization"
  | "bot"
  | "channel"
  | "group"

/** A count the platform shows, in its own unit: 9 343 606 "subscribers". */
export interface ProfileCount {
  value: number
  /** Plural, lowercase: "followers", "subscribers", "repositories". */
  unit: string
}

export interface ProfileFacts {
  platform: SocialPlatform | (string & {})
  /** The profile's page. */
  url: string
  /** The handle, without the @, as the platform spells it. */
  handle: string
  /** The name they go by; the handle when they set none. */
  name: string
  kind: ProfileKind
  bio?: string
  /** Absolute URL of the photo. Platforms' CDN addresses change: keep facts days. */
  photo?: string
  verified?: boolean
  /** What the platform counts, the one that matters most first (a compact row
   *  shows only it). */
  counts: ProfileCount[]
}

/** What a platform is called and how its profiles are opened. `tone` is the colour
 *  its button wears, where the platform has one people know it by. */
export interface PlatformFacts {
  name: string
  usual: ProfileKind
  tone?: string
  /** The colour of its verified check, where the platform draws it in one. */
  check?: string
  /** The words that open a profile there. */
  open: (kind: ProfileKind) => string
}

export const PLATFORMS: Record<SocialPlatform, PlatformFacts> = {
  telegram: {
    name: "Telegram",
    usual: "person",
    tone: "#2aabee",
    check: "#2aabee",
    open: (kind) => (kind === "person" ? "send a message" : "open in Telegram"),
  },
  github: {
    name: "GitHub",
    usual: "person",
    open: () => "open on GitHub",
  },
  youtube: {
    name: "YouTube",
    usual: "channel",
    tone: "#ff0033",
    open: () => "open on YouTube",
  },
}

/** The platform's facts when this family knows it, else undefined. */
export function platformOf(platform: string): PlatformFacts | undefined {
  return Object.hasOwn(PLATFORMS, platform)
    ? PLATFORMS[platform as SocialPlatform]
    : undefined
}

const COMPACT = new Intl.NumberFormat("en", { notation: "compact" })

/** "9.3M subscribers", "1 follower". */
export function countText({ value, unit }: ProfileCount): string {
  const word = value === 1 ? unit.replace(/s$/, "") : unit
  return `${COMPACT.format(value)} ${word}`
}

/** "21.4M" → 21 400 000, "1.8K" → 1800, "1,234" → 1234: a count a platform shows
 *  rounded, read back as the number it stands for. THROWS on anything else. */
export function compactNumber(text: string): number {
  const m = /^([\d.,]+)\s*([KMB])?$/i.exec(text.trim())
  if (!m?.[1]) throw new Error(`social-profile: "${text}" is no count`)
  const n = Number(m[1].replace(/,/g, ""))
  const scale = { K: 1e3, M: 1e6, B: 1e9 }[
    (m[2] ?? "").toUpperCase() as "K" | "M" | "B"
  ]
  return Math.round(n * (scale ?? 1))
}

const ENTITIES: Record<string, string> = {
  amp: "&",
  lt: "<",
  gt: ">",
  quot: '"',
  apos: "'",
  "#39": "'",
  nbsp: " ",
}

/** HTML entities decoded: what a page's markup says as text. */
export function decodeEntities(s: string): string {
  return s.replace(/&(#\d+|#x[0-9a-f]+|[a-z]+);/gi, (m, code: string) => {
    if (code[0] === "#") {
      const n =
        code[1] === "x" || code[1] === "X"
          ? Number.parseInt(code.slice(2), 16)
          : Number.parseInt(code.slice(1), 10)
      return Number.isFinite(n) ? String.fromCodePoint(n) : m
    }
    return ENTITIES[code.toLowerCase()] ?? m
  })
}
social-profile-telegram.ts
// A TELEGRAM PROFILE'S FACTS (social-profile), read from the page t.me serves for a
// public handle, the one Telegram itself shows to a browser without the app: the
// name, the photo, the bio, and for a channel or a group how many follow it. No key,
// no login, one GET. The Bot API answers only for chats its bot has seen and MTProto
// needs an account: neither belongs in a component's data path.
//
// A handle t.me does not know answers HTTP 200 with a "Contact @handle" page and no
// profile on it: a THROW. The photo is a link to cdn*.telesco.pe, whose addresses
// change: keep the facts days, or mirror the picture.

import {
  compactNumber,
  decodeEntities,
  type ProfileFacts,
  type ProfileKind,
} from "@/registry/base-nova/lib/social-profile-core"

/** A handle as Telegram accepts it: 4 to 32 of letters, digits and underscores,
 *  starting with a letter; an @, a t.me link or spaces around it are forgiven. */
export function telegramHandle(input: string): string {
  const h = input
    .trim()
    .replace(/^(?:https?:\/\/)?(?:www\.)?(?:t|telegram)\.me\//i, "")
    .replace(/^@/, "")
    .replace(/\/$/, "")
  if (!/^[a-z][a-z0-9_]{3,31}$/i.test(h))
    throw new Error(`telegram: "${input}" is no handle`)
  return h
}

/** The text of the first element with this class: its tags dropped, `<br>` a line. */
function textOf(html: string, cls: string): string | undefined {
  const m = new RegExp(
    `<div class="${cls}\\b[^"]*"[^>]*>([\\s\\S]*?)</div>`,
    "i",
  ).exec(html)
  if (!m?.[1]) return undefined
  const text = decodeEntities(
    m[1]
      .replace(/<br\s*\/?>/gi, "\n")
      .replace(/<[^>]+>/g, "")
      .trim(),
  )
  return text || undefined
}

/** "10 404 944 subscribers", "1 234 members, 56 online": the first number in its
 *  unit, the groupings read through (`\s` takes the thin and no-break spaces t.me
 *  groups digits with). */
function countOf(extra: string): ProfileFacts["counts"] {
  const m = /^([\d\s,.]+?)\s+(subscribers|members)/i.exec(extra.trim())
  if (!m?.[1] || !m[2]) return []
  return [
    {
      value: compactNumber(m[1].replace(/[^\d]/g, "")),
      unit: m[2].toLowerCase(),
    },
  ]
}

/** Read a profile's facts from the HTML of its t.me page. THROWS on a page with no
 *  profile (a handle nobody has, a private one). */
export function telegramFromHtml(handle: string, html: string): ProfileFacts {
  const h = telegramHandle(handle)
  const titleBlock =
    /<div class="tgme_page_title"[^>]*>([\s\S]*?)<\/div>/i.exec(html)?.[1]
  const name = textOf(html, "tgme_page_title")?.replace(/\s*✔$/, "")
  if (!titleBlock || !name)
    throw new Error(
      `telegram: no profile at t.me/${h} (no one has it, or private)`,
    )
  const extra = textOf(html, "tgme_page_extra") ?? ""
  const kind: ProfileKind = /subscribers/i.test(extra)
    ? "channel"
    : /members/i.test(extra)
      ? "group"
      : // t.me offers a bot "Start Bot" where it offers a person "Send Message".
        />\s*Start Bot\s*</i.test(html)
        ? "bot"
        : "person"
  const photo = /<img class="tgme_page_photo_image"[^>]*\bsrc="([^"]+)"/i.exec(
    html,
  )?.[1]
  const bio = textOf(html, "tgme_page_description")
  return {
    platform: "telegram",
    url: `https://t.me/${h}`,
    handle: h,
    name,
    kind,
    ...(bio ? { bio } : {}),
    ...(photo ? { photo: decodeEntities(photo) } : {}),
    ...(/verified-icon/.test(titleBlock) ? { verified: true } : {}),
    counts: countOf(extra),
  }
}

/** Fetch a public profile by its handle (or its t.me link). */
export async function fetchTelegramProfile(
  handle: string,
): Promise<ProfileFacts> {
  const h = telegramHandle(handle)
  const res = await fetch(`https://t.me/${h}`, {
    headers: { "accept-language": "en", accept: "text/html" },
  })
  if (!res.ok)
    throw new Error(`telegram: t.me/${h} answered HTTP ${res.status}`)
  return telegramFromHtml(h, await res.text())
}
social-profile-github.ts
// A GITHUB PROFILE'S FACTS (social-profile): a person or an organization by its
// login, from GitHub's REST `users` endpoint. No token needed (60 reads an hour from
// one address); give one (`gh auth token`, a server's secret) for 5000. Who someone is
// and what they count, nothing more: the contribution calendar, repositories and
// threads are the `github` item's.
//
// A login GitHub does not know is HTTP 404: a THROW, as is any other answer but 200.

import type { ProfileFacts } from "@/registry/base-nova/lib/social-profile-core"

/** A login as GitHub accepts it: letters, digits and single hyphens, at most 39,
 *  never starting or ending with a hyphen; an @, a github.com link or spaces around
 *  it are forgiven. */
export function githubLogin(input: string): string {
  const l = input
    .trim()
    .replace(/^(?:https?:\/\/)?(?:www\.)?github\.com\//i, "")
    .replace(/^@/, "")
    .replace(/\/$/, "")
  if (!/^[a-z\d](?:[a-z\d]|-(?=[a-z\d])){0,38}$/i.test(l))
    throw new Error(`github: "${input}" is no login`)
  return l
}

/** What the endpoint answers that the profile reads. */
export interface GithubUserJson {
  login: string
  html_url: string
  avatar_url: string
  type: string
  name: string | null
  bio: string | null
  followers: number
  public_repos: number
}

/** A profile's facts from the endpoint's answer. */
export function githubFromJson(user: GithubUserJson): ProfileFacts {
  const bio = user.bio?.trim()
  const org = user.type === "Organization"
  return {
    platform: "github",
    url: user.html_url,
    handle: user.login,
    name: user.name?.trim() || user.login,
    kind: org ? "organization" : "person",
    ...(bio ? { bio } : {}),
    photo: user.avatar_url,
    // An organization has no followers worth leading with; its repositories are it.
    counts: org
      ? [{ value: user.public_repos, unit: "repositories" }]
      : [
          { value: user.followers, unit: "followers" },
          { value: user.public_repos, unit: "repositories" },
        ],
  }
}

/** Fetch a profile by its login (or its github.com link). */
export async function fetchGithubProfile(
  login: string,
  { token }: { token?: string } = {},
): Promise<ProfileFacts> {
  const l = githubLogin(login)
  const res = await fetch(`https://api.github.com/users/${l}`, {
    headers: {
      accept: "application/vnd.github+json",
      "x-github-api-version": "2022-11-28",
      ...(token ? { authorization: `Bearer ${token}` } : {}),
    },
  })
  if (res.status === 404) throw new Error(`github: no user ${l}`)
  if (!res.ok) throw new Error(`github: users/${l} answered HTTP ${res.status}`)
  return githubFromJson((await res.json()) as GithubUserJson)
}
social-profile-youtube.ts
// A YOUTUBE CHANNEL'S FACTS (social-profile), read from the channel's own page: the
// data YouTube renders it from (`ytInitialData`) is in the HTML, so one GET and no key
// (the Data API needs one, and a quota). The name, the handle, the picture, the
// description, the check, and the subscribers and videos as the header shows them,
// rounded by YouTube ("21.4M"), read back as the numbers they stand for.
//
// In the EU YouTube answers a first visit with a consent page; the fetch declines the
// cookies (`SOCS=CAI`) and asks for English, so the header's words are the ones read.
// A channel YouTube does not know is HTTP 404: a THROW, as is a page whose data has
// moved (YouTube's markup is not a contract: the example in `scripts/examples` is
// what tells).

import {
  compactNumber,
  type ProfileCount,
  type ProfileFacts,
} from "@/registry/base-nova/lib/social-profile-core"

/** A channel as its address names it: `@handle`, a youtube.com/@handle link, or a
 *  /channel/UC… link, or the handle bare (`Adrian_Galilea`, as a profile stores
 *  it), as the path to fetch. THROWS on anything else. */
export function youtubeChannelPath(input: string): string {
  const s = input.trim().replace(/\/$/, "")
  // A channel id first: bare, it would read as a handle too.
  const id =
    /^(?:(?:https?:\/\/)?(?:www\.|m\.)?youtube\.com\/channel\/)?(UC[\w-]{22})$/.exec(
      s,
    )?.[1]
  if (id) return `channel/${id}`
  const handle =
    // `@handle`, its address, or the handle bare (as a profile stores it).
    /^(?:(?:https?:\/\/)?(?:www\.|m\.)?youtube\.com\/)?@([\w.-]{3,30})$/i.exec(
      s,
    )?.[1] ?? /^([\w.-]{3,30})$/.exec(s)?.[1]
  if (handle) return `@${handle}`
  throw new Error(`youtube: "${input}" is no channel`)
}

type Json = Record<string, unknown>

/** The value at a path of keys, or undefined where the path breaks. */
function at(value: unknown, ...path: (string | number)[]): unknown {
  let v = value
  for (const key of path) {
    if (v === null || typeof v !== "object") return undefined
    v = (v as Json)[key]
  }
  return v
}

const text = (v: unknown): string | undefined =>
  typeof v === "string" && v.trim() ? v.trim() : undefined

/** The data the page is rendered from. THROWS when the page carries none. */
export function youtubeInitialData(html: string): Json {
  const start = html.indexOf("var ytInitialData = ")
  if (start < 0) throw new Error("youtube: the page carries no ytInitialData")
  const from = start + "var ytInitialData = ".length
  const end = html.indexOf(";</script>", from)
  if (end < 0) throw new Error("youtube: ytInitialData has no end")
  return JSON.parse(html.slice(from, end)) as Json
}

/** "21.4M subscribers", "1.8K videos", "1 subscriber". */
function countIn(part: string): ProfileCount | undefined {
  const m = /^([\d.,]+[KMB]?)\s+(subscribers?|videos?)$/i.exec(part.trim())
  if (!m?.[1] || !m[2]) return undefined
  const unit = m[2].toLowerCase()
  return {
    value: compactNumber(m[1]),
    unit: unit.endsWith("s") ? unit : `${unit}s`,
  }
}

/** A channel's facts from its page's data. THROWS when the channel's metadata is
 *  not where it was read from. */
export function youtubeFromData(data: Json): ProfileFacts {
  const meta = at(data, "metadata", "channelMetadataRenderer")
  const name = text(at(meta, "title"))
  const vanity = text(at(meta, "vanityChannelUrl"))
  const id = text(at(meta, "externalId"))
  if (!name || !id)
    throw new Error("youtube: no channel metadata on the page (moved, or none)")
  const handle = vanity ? /\/@([^/]+)$/.exec(vanity)?.[1] : undefined
  const header = at(
    data,
    "header",
    "pageHeaderRenderer",
    "content",
    "pageHeaderViewModel",
  )
  const rows = at(
    header,
    "metadata",
    "contentMetadataViewModel",
    "metadataRows",
  )
  const parts = (Array.isArray(rows) ? rows : []).flatMap((row) => {
    const p = at(row, "metadataParts")
    return Array.isArray(p) ? p : []
  })
  const counts = parts
    .map((p) => text(at(p, "text", "content")))
    .flatMap((t) => (t ? (countIn(t) ?? []) : []))
    // Subscribers lead: they say how many follow it.
    .sort((a, b) =>
      a.unit === "subscribers" ? -1 : b.unit === "subscribers" ? 1 : 0,
    )
  const runs = at(
    header,
    "title",
    "dynamicTextViewModel",
    "text",
    "attachmentRuns",
  )
  const verified = JSON.stringify(runs ?? null).includes("CHECK_CIRCLE_FILLED")
  const photo = text(at(meta, "avatar", "thumbnails", 0, "url"))
  const bio = text(at(meta, "description"))
  return {
    platform: "youtube",
    url: handle
      ? `https://www.youtube.com/@${handle}`
      : `https://www.youtube.com/channel/${id}`,
    handle: handle ?? id,
    name,
    kind: "channel",
    ...(bio ? { bio } : {}),
    ...(photo ? { photo } : {}),
    ...(verified ? { verified: true } : {}),
    counts,
  }
}

/** Fetch a channel by its handle or its address. */
export async function fetchYoutubeChannel(
  channel: string,
): Promise<ProfileFacts> {
  const path = youtubeChannelPath(channel)
  const res = await fetch(`https://www.youtube.com/${path}?hl=en`, {
    headers: {
      "accept-language": "en",
      accept: "text/html",
      cookie: "SOCS=CAI",
      "user-agent":
        "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130 Safari/537.36",
    },
  })
  if (res.status === 404) throw new Error(`youtube: no channel ${path}`)
  if (!res.ok) throw new Error(`youtube: ${path} answered HTTP ${res.status}`)
  return youtubeFromData(youtubeInitialData(await res.text()))
}