a profile on Telegram, GitHub or YouTube from its facts, as a card or a compact row
npx shadcn add @ag/social-profileAlready installed? Preview an update
npx shadcn add @ag/social-profile --dry-runYour installed source stays unchanged until you update it. How updates work
- 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 bySocialProfileas acardor as onecompactrow for a hover card or a list. A composition onPanel, plain or glass from the nearestMaterialProvider(ormaterial,scheme), drawing no box inside a frame already drawn (framedecides when given); the face isavatar's, the button the registry'sButtonin the platform's colour with its mark dimmer than the words.SocialMarkdraws 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 passesmark. telegram-profileis removed. Migration:<TelegramProfile profile={p} />is<SocialProfile facts={p} />; importfetchTelegramProfilefromlib/social-profile-telegraminstead oflib/telegram-profile-data; in the factskind: "user"is"person"andfollowersiscounts[0].value; delete the oldtelegram-profile.tsxandtelegram-profile-data.ts.
also called telegram profile, telegram card, telegram channel, github profile, youtube channel, user card, socials
shelf social & links
the demo above, verbatim
"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
"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>
)
}// 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
})
}// 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())
}// 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)
}// 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()))
}