shadimage
agentblog
blockblog-uisource: ok · hit

AgentBlog reading experience

The components a blog post is made of: post cards, byline with rel=author and machine-readable dates, answer capsule, server-rendered table of contents, author bio, related posts, breadcrumbs, category pills, and real anchor-based pagination. Styled entirely with your shadcn theme tokens, so it inherits your design system rather than replacing it.

npx shadcn add "https://agentblog.dev/r/blog-ui.json"
free

Where it goes

data-tableblog-list

Use cases

dashboard

Tags auto-generated by rules on 9/24/2026 — empty means no evidence, not lack of support.

Dependencies

Bundle weight · light
iconslucide-react

Registry dependencies

cardbadgeseparatoravatarbutton@agentblog/blog-core

Files15 · source cached 10/15/2026, 12:29:08 PM

registry/blog/components/blog/icons.tsx
/**
 * The single icon import site for the whole AgentBlog block.
 *
 * WHAT THIS IS
 * Every icon rendered by `components/blog/*` and `components/mdx/*` is imported
 * from here, never from `lucide-react` directly.
 *
 * WHY IT IS SHAPED THIS WAY
 * A shadcn `registry:base` item sets `iconLibrary`, so a consumer may be on
 * Tabler, Phosphor, or Radix icons rather than Lucide. AgentBlog installs no
 * base in Mode A, which means we inherit whatever the project already chose.
 * Routing every icon through one re-export module makes swapping libraries a
 * single-file edit instead of a grep across twelve components, and it is what
 * `shadcn migrate icons` rewrites.
 *
 * WHAT BREAKS IF YOU CHANGE IT
 * - Import an icon straight from `lucide-react` inside a component and you have
 *   silently reintroduced the grep. Add the icon here first.
 * - Rename an export here and every component using it fails to compile, which
 *   is the intended blast radius: loud, immediate, one file.
 *
 * SWAPPING ICON LIBRARIES
 * Replace the import source below and re-map the names. Every export must keep
 * accepting `className` and `aria-hidden`, because callers style icons with
 * `size-*` utilities and mark them decorative. Nothing else is assumed.
 *
 * NOTE ON BRAND ICONS
 * Lucide deprecated its brand glyphs (`Twitter`, `Linkedin`, `Facebook`) and may
 * drop them in a future minor. `share-buttons.tsx` therefore uses text labels
 * plus neutral icons rather than brand marks, so a Lucide upgrade cannot break a
 * consumer's install.
 *
 * @see https://docs.agentblog.dev/guides/match-your-design
 * @see https://docs.agentblog.dev/guides/match-your-design#installing-the-blog-installs-no-base-and-no-theme
 */
export {
  ArrowLeft as ArrowLeftIcon,
  ArrowRight as ArrowRightIcon,
  BookOpen as BookOpenIcon,
  Check as CheckIcon,
  ChevronDown as ChevronDownIcon,
  ChevronLeft as ChevronLeftIcon,
  ChevronRight as ChevronRightIcon,
  CircleAlert as CircleAlertIcon,
  CircleCheck as CircleCheckIcon,
  CircleHelp as CircleHelpIcon,
  Copy as CopyIcon,
  ExternalLink as ExternalLinkIcon,
  Hash as HashIcon,
  Info as InfoIcon,
  Lightbulb as LightbulbIcon,
  Link2 as LinkIcon,
  List as ListIcon,
  Mail as MailIcon,
  Quote as QuoteIcon,
  Rss as RssIcon,
  Share2 as ShareIcon,
  Tag as TagIcon,
  TriangleAlert as TriangleAlertIcon,
  User as UserIcon,
} from 'lucide-react'
registry/blog/components/blog/type-scale.ts
/**
 * The block's type scale and spacing rhythm, as class strings.
 *
 * ---------------------------------------------------------------------------
 * WHY THIS FILE EXISTS
 * ---------------------------------------------------------------------------
 * Six routes render a page heading and a lede, and before this file they each
 * spelled out their own. They drifted, which is the only thing repeated class
 * strings reliably do: the index heading and the author heading were the same
 * size, the FAQ heading and the sources heading were not, and nothing anywhere
 * said which of them was right.
 *
 * These are plain strings rather than components on purpose. A component would
 * hide the styling behind a prop and break the shadcn premise that you can read
 * a page and see exactly what it renders. A string can be spread into a
 * `className`, overridden by a later utility through `cn`, or deleted outright
 * by a consumer who wants their own scale, and none of those need this file to
 * cooperate.
 *
 * ---------------------------------------------------------------------------
 * THE SCALE
 * ---------------------------------------------------------------------------
 * Four text roles, three weights, three tones. Anything on a blog page is one
 * of these, and a page that needs a fifth role is usually a page that is doing
 * too much.
 *
 *   display   The page H1. One per route.
 *   lede      The sentence under the H1. Muted, and held to the reading
 *             measure even on the wide rail, because a 1,152px line of prose is
 *             not a lede, it is a mistake.
 *   section   An H2 outside the article body: Sources, Related posts, the FAQ.
 *   eyebrow   The small capitalised label above or beside something else.
 *   meta      Bylines, dates, reading times, counts.
 *
 * Weights are 400 for body, 500 for meta emphasis and the display headline, and
 * 600 for the smaller headings. There is no 700 in the block. Tones are
 * `text-foreground`, `text-muted-foreground`, and `text-primary` for links
 * inside prose, and there is no fourth.
 *
 * The display weight is the one that looks inconsistent written down and is not.
 * Weight and size both carry emphasis, so they trade against each other: at 48px
 * a 500 already reads as the heaviest thing on the page, while a 600 at that
 * size reads as a headline shouting over the sentence it introduces. At 24px the
 * section headings have no such size advantage and need the 600 to hold their
 * place above body text. Same optical weight, two different numbers.
 *
 * ---------------------------------------------------------------------------
 * WHY THE TRACKING IS `tracking-tight` AND NEVER TIGHTER
 * ---------------------------------------------------------------------------
 * A display size wants negative tracking, and how much depends entirely on the
 * typeface. This block does not know the typeface: it inherits whatever the
 * consumer set on `--font-sans`, which might be a geometric sans that needs
 * -0.04em or a humanist serif that falls apart below -0.01em. `tracking-tight`
 * is -0.025em, which improves a large heading in almost every face and breaks
 * none of them. Tune it in your own app once you know your font; do not tune it
 * here, where it has to be right for everybody's.
 *
 * ---------------------------------------------------------------------------
 * THE SPACING RULE, WHICH IS NOT NEGOTIABLE
 * ---------------------------------------------------------------------------
 * **No component in this block sets its own outer margin.** Pages compose with
 * a flex column and a `gap`, and that is the only mechanism.
 *
 * The rule exists because the alternative failed in a specific and invisible
 * way. Every component used to carry its own `mt-*`, several carried none, and
 * the ones that carried none were exactly the ones that collided: the category
 * pills sat flush against the post grid, and the byline sat flush against the
 * table of contents, both at a measured zero pixels, on the production site,
 * for as long as the block existed. A missing margin is silent. A missing item
 * in a flex column is impossible.
 *
 * `SECTION_GAP` is the rhythm between top-level regions of a page.
 * `CLUSTER_GAP` is the rhythm between things that belong together.
 *
 * @see styles/agentblog.css for the three layout rails these compose against.
 */

/** Page H1. One per route, and it never changes on page 2 of a paginated list. */
export const DISPLAY =
  'text-foreground text-4xl font-medium tracking-tight text-balance sm:text-5xl'

/**
 * The sentence under the H1. Capped at the reading measure so it stays readable
 * on the wide rail, where the grid beside it is three times as wide.
 */
export const LEDE =
  'text-muted-foreground max-w-(--agentblog-measure) text-lg leading-relaxed text-pretty sm:text-xl'

/** An H2 outside the article body: Sources, Related posts, Areas of expertise. */
export const SECTION_HEADING = 'text-foreground text-2xl font-semibold tracking-tight text-balance'

/** The small capitalised label above a section or beside a control. */
export const EYEBROW = 'text-muted-foreground text-xs font-medium tracking-wider uppercase'

/** Bylines, dates, reading times, post counts. */
export const META = 'text-muted-foreground text-sm'

/** Between top-level regions of a page. */
export const SECTION_GAP = 'flex flex-col gap-12 sm:gap-14'

/** Between things that belong to each other: a heading and its list, pills and their grid. */
export const CLUSTER_GAP = 'flex flex-col gap-6'

/** The header block on every route: breadcrumb, then the title cluster. */
export const PAGE_HEADER = 'flex flex-col items-start gap-6'

/**
 * Inside the title cluster: H1, then whatever belongs to it. A lede and a count
 * line on a list page, the byline and the answer capsule on a post.
 */
export const TITLE_CLUSTER = 'flex flex-col gap-4'
registry/blog/components/blog/prose.tsx
import type { ElementType, ReactNode } from 'react'

import { cn } from '@/lib/utils'

/**
 * Long-form typography wrapper.
 *
 * WHERE IT RENDERS
 * Around the compiled MDX body on `app/blog/[slug]/page.tsx`, and around any
 * other block of authored prose (the editorial policy page, an author's long
 * bio). It is the only place the `prose` utility is applied.
 *
 * WHY IT IS SHAPED THIS WAY
 * Two jobs, and deliberately no third:
 *
 *   1. Apply `prose` from `@tailwindcss/typography`.
 *   2. Constrain the measure to `--agentblog-measure` (68ch by default), because
 *      line length is the one typographic decision the plugin does not make for
 *      you and the one that most affects long-form reading.
 *
 * It does NOT define colours. The `--tw-prose-*` bridge that binds the plugin's
 * palette to the consumer's shadcn tokens lives in `styles/agentblog.css`. That
 * separation is the point: the CSS file is theme wiring, this file is layout.
 *
 * DO NOT ADD THE TYPOGRAPHY PLUGIN'S `prose-invert` MODIFIER, AND DO NOT PUT IT
 * BEHIND A DARK VARIANT.
 * The bridge maps `--tw-prose-body` and friends to `var(--foreground)` and the
 * other shadcn tokens, and those tokens already flip under `.dark`. Adding the
 * inverted palette layers a second, hardcoded set of greys on top of tokens that
 * have already switched, so dark mode ends up fighting itself. It also breaks
 * outright for any consumer whose dark theme is not near-black.
 * @see https://docs.agentblog.dev/guides/match-your-design#only-semantic-tokens-never-a-palette-utility
 *
 * WHAT BREAKS IF YOU CHANGE IT
 * - Adding `'use client'` here pulls the entire article body out of the static
 *   HTML shell. AI crawlers do not execute JavaScript, so the post becomes an
 *   empty div to exactly the readers this block exists to reach.
 * - Removing the heading `scroll-mt-*` utilities makes every table-of-contents
 *   jump land with the target heading tucked under a sticky header.
 */
export interface ProseProps {
  readonly children: ReactNode
  // Every optional prop in this block is declared `?: T | undefined` rather than
  // `?: T`. Under `exactOptionalPropertyTypes` those are not the same type, and
  // the plain form rejects the most ordinary call a consumer makes:
  // `<Prose className={maybeUndefined}>`.
  readonly className?: string | undefined
  /**
   * The rendered element. Defaults to `div`. Pass `'article'` only when this
   * wrapper is the article itself; the post route already supplies its own
   * `<article>` with the H1, the answer capsule, and the byline in a
   * `<header>`, so nesting a second one would give the page two article
   * landmarks.
   */
  readonly as?: ElementType | undefined
}

export function Prose({ children, className, as }: ProseProps) {
  const Component: ElementType = as ?? 'div'

  return (
    <Component
      className={cn(
        // Parentheses, not square brackets. Tailwind v4 removed the v3
        // shorthand that let a bare custom property sit inside square brackets,
        // and it removed it silently: the bracket spelling still produces a
        // rule, and that rule is `max-width:--agentblog-measure`, which is not
        // valid CSS and which every browser drops. Nothing errors, and the
        // measure quietly comes from somewhere else.
        //
        // That somewhere else is `@utility prose` in `styles/agentblog.css`,
        // which sets `max-width: var(--agentblog-measure)` too. The two agree
        // on purpose and neither is redundant: the CSS file is what overrides
        // the typography plugin's own `max-width: 65ch` on `.prose`, and this
        // class is what states the constraint at the element a reader is
        // looking at. Deleting either one leaves the measure working today and
        // one refactor away from silently becoming 65ch.
        'prose max-w-(--agentblog-measure)',
        // Anchor targets clear a sticky header when a TOC link is followed.
        '[&_h2]:scroll-mt-24 [&_h3]:scroll-mt-24 [&_h4]:scroll-mt-24',
        className,
      )}
    >
      {children}
    </Component>
  )
}
registry/blog/components/blog/post-card.tsx
import Image from 'next/image'
import Link from 'next/link'

import { Badge } from '@/components/ui/badge'
import { Card, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card'
import { categoryPath, postPath } from '@/lib/config'
import { readingTime } from '@/lib/reading-time'
import type { PublishedPost } from '@/lib/types'
import { cn } from '@/lib/utils'

import { formatPostDateShort } from './byline'

/**
 * One post, as a card, in a list.
 *
 * WHERE IT RENDERS
 * `post-list.tsx`, which is used by the blog index, category hubs, tag pages,
 * and author pages.
 *
 * WHY IT IS SHAPED THIS WAY
 * The card is built out of the consumer's own `Card` and `Badge` from
 * `@/components/ui/*`, not out of a private copy. `shadcn add` refuses to
 * overwrite a component the user has already customized, so a project that
 * restyled its `Card` gets that restyled card here for free. That inheritance is
 * the entire reason the block declares `card` and `badge` as bare-name registry
 * dependencies.
 *
 * ⚠️ ADDING `'use client'` TO THIS FILE REMOVES EVERY POST LIST FROM THE RAW
 * HTML THAT AI CRAWLERS READ.
 * The blog index, every category hub, and every author page are nothing but a
 * grid of these cards. Client components hydrate; crawlers that do not run
 * JavaScript see whatever was in the first response byte. Keep this a server
 * component. If you need interactivity (a bookmark toggle, a hover preview),
 * put it in a small leaf client component and render it *inside* this card,
 * so the links and the titles stay in the server HTML.
 *
 * THE `priority` PROP
 * When true the hero image is passed `preload`, which emits a
 * `<link rel="preload">` for it. `priority` is the deprecated Next.js prop name
 * and is intentionally not what we pass through. At most ONE image per route may
 * be preloaded, and only when it is definitively the LCP element. Preloading
 * every card in a grid makes the browser fetch twelve images at highest priority
 * and reliably makes LCP worse than doing nothing. `post-list.tsx` exposes
 * `priorityFirst` for the single legitimate case.
 *
 * THE WHOLE CARD IS THE CLICK TARGET, AND IT IS STILL ONE LINK
 * The title's `<Link>` carries `after:absolute after:inset-0`, which stretches a
 * transparent pseudo-element over the whole card. A reader can click anywhere;
 * a screen reader still hears exactly one link per card, whose accessible name
 * is the post title. This is the standard "stretched link" pattern and it has
 * one standard failure mode: any other interactive element inside the card is
 * covered by the pseudo-element and stops being clickable. The category badge
 * therefore carries `relative z-10` to sit above it. If you add a second control
 * to this card, it needs the same two classes or it will look enabled and do
 * nothing.
 *
 * Because the click target is now the card and not the words, the focus ring is
 * on the card too, via `has-[a:focus-visible]:ring-2`. Keyboard focus that lands
 * on a 20px underline inside a 350px card is technically visible and practically
 * missable.
 *
 * WHAT BREAKS IF YOU CHANGE IT
 * - Replacing the title `<Link>` with an onClick handler removes the post from
 *   the crawlable link graph, and the index page stops being a discovery path.
 * - Dropping `sizes` from the `fill` image makes Next.js serve the largest
 *   candidate to every viewport.
 * - Removing `relative` from the `Card` un-anchors the stretched link, which
 *   then covers the nearest positioned ancestor instead. In a grid that is the
 *   whole page.
 *
 * @see https://docs.agentblog.dev/guides/match-your-design#primitives-are-requested-by-bare-name
 * @see https://docs.agentblog.dev/reference/files
 */
export interface PostCardProps {
  readonly post: PublishedPost
  /**
   * Preload the hero image. Only for the single LCP image on a route. Read the
   * note above before setting this on more than one card.
   */
  readonly priority?: boolean | undefined
  /**
   * Heading level for the card title. Defaults to 2, which is correct under a
   * page whose H1 is the list heading. Pass 3 when the list itself sits under an
   * H2, so the document keeps a heading order with no skipped levels.
   */
  readonly headingLevel?: 2 | 3 | undefined
  readonly className?: string | undefined
}

export function PostCard({ post, priority, headingLevel, className }: PostCardProps) {
  const Heading = headingLevel === 3 ? 'h3' : 'h2'
  const href = postPath(post.slug)
  const { minutes } = readingTime(post.body)
  // `PostSchema` refuses a heroImage without a heroAlt, so the pair is either
  // fully present or fully absent. The render path narrows to an object rather
  // than casting, because an unlabelled image is an accessibility failure and a
  // cast would quietly let one through if the schema ever loosened.
  const hero =
    post.heroImage !== undefined && post.heroAlt !== undefined
      ? { src: post.heroImage, alt: post.heroAlt }
      : null

  return (
    <Card
      className={cn(
        // `relative` anchors the stretched link. `h-full` makes every card in a
        // row the same height, which is what lets the meta row line up across
        // the grid instead of floating at whatever height the title ended.
        'group relative h-full gap-4 overflow-hidden pt-0',
        // The hover state is a fill rather than a shadow. A shadow is invisible
        // on the dark theme of a monochrome design system, and `--accent` is the
        // token that already means "this surface is being interacted with".
        'transition-colors duration-150',
        'hover:bg-accent/50',
        'has-[a:focus-visible]:ring-ring has-[a:focus-visible]:ring-offset-background has-[a:focus-visible]:ring-2 has-[a:focus-visible]:ring-offset-2',
        className,
      )}
    >
      {hero ? (
        // The image link duplicates the title link, so it is hidden from
        // assistive tech and removed from the tab order. A screen reader reader
        // hears one link per card, a mouse user can click the picture.
        <Link href={href} aria-hidden="true" tabIndex={-1} className="block">
          <div className="bg-muted relative aspect-[16/9] w-full overflow-hidden">
            <Image
              src={hero.src}
              alt={hero.alt}
              fill
              sizes="(min-width: 1024px) 33vw, (min-width: 640px) 50vw, 100vw"
              preload={priority === true}
              className="object-cover transition-transform duration-300 group-hover:scale-[1.02] motion-reduce:transition-none motion-reduce:group-hover:scale-100"
            />
          </div>
        </Link>
      ) : null}

      {/* `Card` is set to `pt-0` above so the hero can sit flush with the top
          border. With no hero there is nothing to sit flush with, so the header
          takes the padding back. */}
      <CardHeader className={cn('gap-3', hero === null && 'pt-6')}>
        <div className="flex flex-wrap items-center gap-2">
          {/* `relative z-10` lifts the badge above the title's stretched link.
              Without it this is a link that cannot be clicked. */}
          <Link
            href={categoryPath(post.category.slug)}
            className="focus-visible:ring-ring relative z-10 rounded-full focus-visible:ring-2 focus-visible:outline-none"
          >
            <Badge variant="secondary">{post.category.name}</Badge>
          </Link>
        </div>

        <CardTitle>
          <Heading className="text-lg leading-snug font-semibold tracking-tight text-balance">
            <Link href={href} className="after:absolute after:inset-0 focus-visible:outline-none">
              {post.title}
            </Link>
          </Heading>
        </CardTitle>

        <CardDescription className="line-clamp-3 leading-relaxed text-pretty">
          {post.description}
        </CardDescription>
      </CardHeader>

      {/* `mt-auto` pins the meta to the bottom of a `h-full` card, so a
          two-line title and a three-line title still align across a row. The
          hairline gives the row a floor without adding a second surface. */}
      <CardFooter className="text-muted-foreground border-border mt-auto flex items-center justify-between gap-3 border-t pt-4 text-xs">
        {/* Plain text, not a link. One link target per card keeps the card's
            anchor text unambiguous and stops list pages turning into link soup. */}
        <span className="min-w-0 truncate">
          {post.author.name}
          <span aria-hidden="true">{' · '}</span>
          <time dateTime={post.datePublished}>{formatPostDateShort(post.datePublished)}</time>
        </span>
        {/* No clock icon, and the words carry the meaning instead. A glyph beside
            "min read" draws the noun the phrase already contains, and at this
            size it draws it badly. Same reasoning, same wording, as `<Byline>`. */}
        <span className="shrink-0">{`${minutes} min read`}</span>
      </CardFooter>
    </Card>
  )
}
registry/blog/components/blog/post-list.tsx
import type { PublishedPost } from '@/lib/types'
import { cn } from '@/lib/utils'

import { PostCard } from './post-card'

/**
 * A grid of `PostCard`s.
 *
 * WHERE IT RENDERS
 * The blog index (`app/blog/page.tsx`), category hubs, tag pages, and author
 * pages. Every list surface in the block goes through this component so they all
 * paginate, wrap, and empty out the same way.
 *
 * WHY IT IS SHAPED THIS WAY
 * A real `<ul>` of real `<li>`s, not a bag of divs. Screen readers announce the
 * item count, and a parser gets an unambiguous collection boundary instead of
 * having to infer one from class names. The grid lives on the `<ul>`, which is
 * why each `<li>` needs no wrapper of its own.
 *
 * The empty state is text, not an illustration: "No posts published yet." by
 * default, which is what every route currently gets because none of them
 * override it. A hub with no posts is a page a crawler may still fetch, and a
 * plain sentence is a truthful thin-content signal, which is better than an
 * empty grid that reads as broken. Pass `emptyMessage` when a route can say
 * something more specific ("No posts in this category yet.").
 *
 * WHAT BREAKS IF YOU CHANGE IT
 * - Adding `'use client'` (or infinite scroll, or a "load more" button) takes
 *   every post link out of the crawlable HTML. Infinite scroll in particular is
 *   a listed extraction killer: the links below the fold never exist in the
 *   markup at all. Pagination stays server rendered, with real hrefs.
 *   @see https://docs.agentblog.dev/concepts/geo-playbook* - Turning the `<ul>` into a `<div>` loses the list semantics that make the
 *   count announceable.
 */
export interface PostListProps {
  readonly posts: readonly PublishedPost[]
  /**
   * Preload the first card's hero image.
   *
   * Off by default, and it should stay off unless you have confirmed that the
   * first card really is the LCP element on that route. A route that also has
   * its own hero above the list already spent its one preload.
   * @see https://docs.agentblog.dev/reference/files
   */
  readonly priorityFirst?: boolean | undefined
  /** Heading level used by each card title. @see `PostCard` for why it matters. */
  readonly headingLevel?: 2 | 3 | undefined
  /** Text shown when `posts` is empty. */
  readonly emptyMessage?: string | undefined
  readonly className?: string | undefined
}

export function PostList({
  posts,
  priorityFirst,
  headingLevel,
  emptyMessage,
  className,
}: PostListProps) {
  if (posts.length === 0) {
    // A bare sentence on an empty page reads as a rendering failure. A dashed
    // panel reads as a state the design anticipated, which is the difference
    // between "this is broken" and "there is nothing here yet".
    return (
      <div
        className={cn(
          'border-border text-muted-foreground rounded-xl border border-dashed px-6 py-16 text-center text-sm',
          className,
        )}
      >
        {emptyMessage ?? 'No posts published yet.'}
      </div>
    )
  }

  return (
    /*
     * `auto-rows-fr` is what makes every card in a row the same height. Without
     * it each grid row sizes to its tallest card and the shorter ones sit at the
     * top of their track, so the meta line at the foot of each card lands at a
     * different height and the grid stops reading as a grid. `h-full` on the
     * card is the other half of the same fix.
     *
     * Three columns need the wide rail to be worth having. On
     * `--agentblog-measure` this same grid produced 224px columns, which is
     * narrower than the title inside them.
     */
    <ul
      className={cn(
        'grid list-none auto-rows-fr grid-cols-1 gap-6 p-0 sm:grid-cols-2 lg:grid-cols-3 lg:gap-8',
        className,
      )}
    >
      {posts.map((post, index) => (
        <li key={post.slug} className="flex">
          <PostCard
            post={post}
            priority={priorityFirst === true && index === 0}
            headingLevel={headingLevel}
            className="w-full"
          />
        </li>
      ))}
    </ul>
  )
}
registry/blog/components/blog/byline.tsx
import Link from 'next/link'

import { authorPath } from '@/lib/config'
import { readingTime } from '@/lib/reading-time'
import type { Post } from '@/lib/types'
import { cn } from '@/lib/utils'

/**
 * A post's attribution, split across two components in one file.
 *
 * `<Byline>` is who wrote it and how long it takes to read, directly under the
 * H1. `<PostDates>` is when it was published and last updated, at the foot of
 * the article. They live together because they share the date formatters and
 * because they are two halves of one contract with the JSON-LD graph, and
 * splitting them across files is how one of them would come to format a date
 * differently from the other.
 *
 * WHY THE DATES ARE NOT IN THE BYLINE
 * A byline answers who wrote this and how much of my time it wants. The dates
 * answer whether it is still current, which is a question about the article
 * rather than about starting it, and the foot of the page is where the article
 * is over. Author, date, updated date, and reading time was four facts and three
 * bullets on one 14px line, which is a line a reader skips whole.
 *
 * Nothing machine-readable moved with them. `datePublished` and `dateModified`
 * are still in the graph, still in the HTML, and still in the first response
 * byte, and both still carry `<time dateTime>`.
 *
 * Google asks for a visible date that is labelled ("Published February 4, 2019",
 * "Last updated: Feb 14, 2019") and does not require a position on the page.
 * `<PostDates>` renders exactly that shape. Google News is the one surface that
 * does ask for the date between the headline and the article text, so a site
 * that publishes to News should render `<PostDates>` in the header instead. It
 * takes a `className` and no position of its own for that reason.
 * @see https://developers.google.com/search/docs/appearance/publication-dates
 *
 * WHAT IS LOAD BEARING
 *   - `rel="author"` on the author link. It is the machine-readable statement
 *     that this URL identifies the person who wrote the post, and it is what
 *     ties the visible byline to the `Person` node in the JSON-LD graph.
 *   - `<time dateTime>` on both dates, carrying the raw ISO value with its UTC
 *     offset. The visible string is localized for humans; the `dateTime`
 *     attribute is what parsers read, and it must be byte-identical to
 *     `datePublished` / `dateModified` in the JSON-LD.
 *   - The "Updated" date renders only when it differs from the published date.
 *     A post that shows "Updated" on the day it shipped teaches readers and
 *     freshness heuristics alike to ignore the signal.
 *
 * NO AVATAR HERE, ON PURPOSE.
 * The author photo lives in `author-bio.tsx`. Keeping it out of the byline means
 * this component pulls in no image pipeline, needs no `images.remotePatterns`
 * entry for a remote avatar host, and stays pure text in the first response
 * byte, which is the part of the page an extraction engine reads first.
 *
 * WHAT BREAKS IF YOU CHANGE IT
 * - Dropping `rel="author"` breaks the visible-to-structured-data link that
 *   `lib/schema.ts` assumes when it emits `Person`.
 * - Formatting the date into `dateTime` (rather than passing the raw ISO value)
 *   strips the UTC offset, and Google then reinterprets the timestamp in
 *   Googlebot's own timezone, which can move a post across a day boundary.
 * - Dropping `<PostDates>` from the route leaves the graph claiming a
 *   publication date the page never shows.
 * - Adding `'use client'` removes either component from the static HTML shell.
 *
 * @see https://docs.agentblog.dev/concepts/geo-playbook
 */

/**
 * The one place a post date becomes a human-readable string.
 *
 * `post-card.tsx`, `related-posts.tsx`, and `app/not-found.tsx` import this so
 * every surface prints the same date the same way. The formatter is pinned to
 * `en-US` and UTC rather than left to the runtime default, because the build
 * machine's locale is not something a consumer controls and a date that renders
 * differently on CI than on a laptop is a diff that never settles.
 *
 * Change the locale here and it changes everywhere, which is the point.
 */
const dateFormatter = new Intl.DateTimeFormat('en-US', {
  year: 'numeric',
  month: 'long',
  day: 'numeric',
  timeZone: 'UTC',
})

export function formatPostDate(iso: string): string {
  return dateFormatter.format(new Date(iso))
}

/**
 * The same date, abbreviated, for places where it shares a line with other
 * metadata: post cards and the related-posts list.
 *
 * Both formatters exist because the choice is about space, not about locale.
 * `timeZone: 'UTC'` is repeated here for the same reason it appears above: an
 * ISO date with no time component is parsed as midnight UTC, and formatting
 * that in a negative-offset local zone renders the previous day. A card and its
 * article would then print different dates for the same post.
 *
 * The `dateTime` attribute on the `<time>` element is never abbreviated. That is
 * the value a crawler reads, and it stays the full ISO string in both places.
 */
const shortDateFormatter = new Intl.DateTimeFormat('en-US', {
  year: 'numeric',
  month: 'short',
  day: 'numeric',
  timeZone: 'UTC',
})

export function formatPostDateShort(iso: string): string {
  return shortDateFormatter.format(new Date(iso))
}

export interface BylineProps {
  readonly post: Post
  readonly className?: string | undefined
}

/**
 * Who wrote it, and how long it takes to read. Directly under the H1.
 *
 * The reading time carries no icon. A clock beside the words "min read" draws
 * the noun the sentence already contains, and at 14px it draws it badly: it
 * costs an element, an import, and a moment of a reader deciding whether it
 * means anything other than what the words beside it mean.
 *
 * The separating bullet is `aria-hidden`, so the line announces as "By Jane Doe,
 * 7 min read" rather than reading punctuation aloud.
 */
export function Byline({ post, className }: BylineProps) {
  const { minutes } = readingTime(post.body)

  return (
    <p
      className={cn(
        'text-muted-foreground flex flex-wrap items-center gap-x-2 gap-y-1 text-sm',
        className,
      )}
    >
      <span>
        {'By '}
        <Link
          href={authorPath(post.author.slug)}
          rel="author"
          className="text-foreground focus-visible:ring-ring rounded-sm font-medium underline-offset-4 hover:underline focus-visible:ring-2 focus-visible:outline-none"
        >
          {post.author.name}
        </Link>
      </span>

      <span aria-hidden="true">·</span>

      <span>{`${minutes} min read`}</span>
    </p>
  )
}

export interface PostDatesProps {
  readonly post: Post
  readonly className?: string | undefined
}

/**
 * When it was published, and when it was last touched.
 *
 * WHERE IT RENDERS
 * `app/blog/[slug]/page.tsx`, under a hairline at the foot of the article body,
 * below the sources list and above the share controls. That position is the
 * colophon slot: the article has ended, and this is the provenance of what was
 * just read rather than one more thing to do next.
 *
 * Both dates are labelled in words. "July 28, 2026 · Updated August 5, 2026"
 * makes a reader infer that the first date is the publication date;
 * "Published July 28, 2026" tells them, and it is the shape Google's byline date
 * documentation asks for. The label costs nine characters on a line that has
 * room for them, which the byline under the H1 did not.
 */
export function PostDates({ post, className }: PostDatesProps) {
  const isUpdated = post.dateModified !== post.datePublished

  return (
    <p
      className={cn(
        'text-muted-foreground flex flex-wrap items-center gap-x-2 gap-y-1 text-sm',
        className,
      )}
    >
      {/* Raw ISO value with its offset in `dateTime`. Never a formatted string. */}
      <span>
        {'Published '}
        <time dateTime={post.datePublished}>{formatPostDate(post.datePublished)}</time>
      </span>

      {isUpdated ? (
        <>
          <span aria-hidden="true">·</span>
          <span>
            {'Updated '}
            <time dateTime={post.dateModified}>{formatPostDate(post.dateModified)}</time>
          </span>
        </>
      ) : null}
    </p>
  )
}
registry/blog/components/blog/author-bio.tsx
import Link from 'next/link'

import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
import { Badge } from '@/components/ui/badge'
import { Separator } from '@/components/ui/separator'
import { authorPath } from '@/lib/config'
import type { Author } from '@/lib/types'
import { cn } from '@/lib/utils'

import { ExternalLinkIcon } from './icons'

/**
 * The author box below the article body.
 *
 * WHERE IT RENDERS
 * `app/blog/[slug]/page.tsx` after the share controls and before the related
 * posts, and at the top of `app/authors/[slug]/page.tsx`.
 *
 * WHY IT IS SHAPED THIS WAY
 * This is the visible half of the E-E-A-T story. The other half is the `Person`
 * node in the JSON-LD graph, and the two have to agree: `name`, `jobTitle`,
 * `knowsAbout`, and `sameAs` are rendered here from exactly the fields
 * `lib/schema.ts` reads. Search quality raters are directed to look an author up,
 * so this box exists to make that lookup succeed.
 *
 *   - `name` is the person's name alone. Job title, honorifics, and the
 *     publisher name are separate fields, because Google's Article guidance is
 *     explicit that `author.name` must not carry any of them. The schema
 *     enforces the split; this component preserves it visually.
 *   - `sameAs` links are the entity-disambiguation surface. They are rendered as
 *     real anchors with the destination host as the label, so both a human and a
 *     parser can see which profile is being claimed.
 *   - `bio` is required by the schema. An author box with no bio is decoration.
 *
 * WHERE A SHADCN PRIMITIVE DID NOT FIT CLEANLY
 * `Avatar` and `Separator` are Radix based, so in a standard shadcn install both
 * carry `'use client'`. They are used here anyway, and only here, for two
 * reasons: composing on the consumer's own primitives is the point of the block,
 * and neither one holds content.
 * The `Separator` is decorative, and the avatar is a photo whose absence costs a
 * non-JavaScript reader nothing, since the author's name, title, bio, and links
 * are all plain server-rendered text. Do not reach for a Radix primitive to hold
 * anything a crawler needs to read.
 *
 * WHAT BREAKS IF YOU CHANGE IT
 * - Concatenating the job title into `name` produces JSON-LD that fails Google's
 *   Article guidance and an author entity that resolves to nobody.
 * - Making `sameAs` links `rel="nofollow"` defeats their purpose. They exist to
 *   assert an identity, and the assertion is the link.
 * - Adding `'use client'` to this file removes the bio text, which is the actual
 *   trust signal, from the HTML.
 *
 * @see https://docs.agentblog.dev/guides/match-your-design#primitives-are-requested-by-bare-name
 * @see https://docs.agentblog.dev/concepts/geo-playbook
 */
export interface AuthorBioProps {
  readonly author: Author
  /** Rendered as "N posts" when supplied. Omit on the author page itself. */
  readonly postCount?: number | undefined
  /**
   * Link the author's name to their author page. Defaults to true. Set false on
   * `/authors/[slug]` so the page does not link to itself.
   */
  readonly linkName?: boolean | undefined
  /**
   * Render the topic badges and the profile links inside the card.
   *
   * Defaults to true, which is right at the foot of an article where this card
   * is the only thing on the page describing the author. The author page passes
   * `false`, because it renders both as their own labelled sections below, and
   * a page that lists someone's expertise twice and their GitHub profile twice
   * reads as a template that was never looked at.
   */
  readonly showDetails?: boolean | undefined
  readonly className?: string | undefined
}

/** Initials for the avatar fallback. Two letters at most, so the circle fits. */
function initialsOf(name: string): string {
  return name
    .split(/\s+/)
    .filter((part) => part.length > 0)
    .slice(0, 2)
    .map((part) => part.charAt(0).toUpperCase())
    .join('')
}

/** `https://github.com/janedoe` becomes `github.com`, which is the useful label. */
function hostLabel(url: string): string {
  try {
    return new URL(url).hostname.replace(/^www\./, '')
  } catch {
    return url
  }
}

export function AuthorBio({ author, postCount, linkName, showDetails, className }: AuthorBioProps) {
  const shouldLink = linkName !== false
  const withDetails = showDetails !== false
  const href = authorPath(author.slug)

  return (
    <section
      className={cn(
        'not-prose border-border bg-card text-card-foreground rounded-xl border p-6',
        className,
      )}
    >
      <div className="flex flex-col gap-4 sm:flex-row sm:items-start">
        <Avatar className="size-16 shrink-0">
          {/* Empty alt on purpose: the author's name sits directly beside the
              photo, so a described avatar would make a screen reader announce
              the same name twice. This is the one image in the block that is
              genuinely decorative. */}
          {author.avatar !== undefined ? <AvatarImage src={author.avatar} alt="" /> : null}
          <AvatarFallback>{initialsOf(author.name)}</AvatarFallback>
        </Avatar>

        <div className="min-w-0 flex-1">
          {/* Stable id, same rule as every other H2 and H3 in the block. It is
              derived from the author slug rather than fixed, so a page that
              renders more than one author box (a co-authored post, an index of
              contributors) still gets one unique anchor per person. */}
          <h2 className="scroll-mt-24 text-lg font-semibold" id={`author-${author.slug}`}>
            {shouldLink ? (
              <Link
                href={href}
                rel="author"
                className="focus-visible:ring-ring rounded-sm underline-offset-4 hover:underline focus-visible:ring-2 focus-visible:outline-none"
              >
                {author.name}
              </Link>
            ) : (
              author.name
            )}
          </h2>

          {/* Job title and employer stay out of `name`, deliberately. */}
          {author.jobTitle !== undefined || postCount !== undefined ? (
            <p className="text-muted-foreground mt-0.5 text-sm">
              {author.jobTitle}
              {author.jobTitle !== undefined && postCount !== undefined ? (
                <span aria-hidden="true">{' · '}</span>
              ) : null}
              {postCount !== undefined
                ? `${postCount} ${postCount === 1 ? 'post' : 'posts'}`
                : null}
            </p>
          ) : null}

          <p className="text-foreground mt-3 text-sm leading-relaxed">{author.bio}</p>

          {withDetails && author.knowsAbout.length > 0 ? (
            <>
              <Separator className="my-4" />
              <ul
                aria-label={`Topics ${author.name} writes about`}
                className="flex list-none flex-wrap gap-2 p-0"
              >
                {author.knowsAbout.map((topic) => (
                  <li key={topic}>
                    <Badge variant="secondary">{topic}</Badge>
                  </li>
                ))}
              </ul>
            </>
          ) : null}

          {withDetails && author.sameAs.length > 0 ? (
            <ul className="mt-4 flex list-none flex-wrap gap-x-4 gap-y-2 p-0 text-sm">
              {author.sameAs.map((profile) => (
                <li key={profile}>
                  <a
                    href={profile}
                    rel="me noopener noreferrer"
                    target="_blank"
                    className="text-muted-foreground hover:text-foreground focus-visible:ring-ring inline-flex items-center gap-1 rounded-sm underline-offset-4 hover:underline focus-visible:ring-2 focus-visible:outline-none"
                  >
                    {hostLabel(profile)}
                    <ExternalLinkIcon className="size-3.5" aria-hidden="true" />
                  </a>
                </li>
              ))}
            </ul>
          ) : null}
        </div>
      </div>
    </section>
  )
}
registry/blog/components/blog/answer-capsule.tsx
import type { ReactNode } from 'react'

/**
 * The 40 to 60 word direct answer that opens a post.
 *
 * WHERE IT RENDERS
 * `app/blog/[slug]/page.tsx`, inside the article `<header>`, under the H1 and
 * the one-line byline and above everything else. `components/mdx/*` may also
 * render one under an H2 for a section-level answer.
 *
 * The byline above it is a single line of names and numbers, which is what makes
 * it a tolerable neighbour: the capsule is still the first prose in the document
 * and still lands inside the opening chunk of any retrieval system that splits
 * the page. Anything longer than a line belongs below the capsule, not above it.
 *
 * WHY IT IS SHAPED THIS WAY
 * This is the passage an answer engine lifts. Everything about the markup is in
 * service of making that passage cleanly extractable:
 *
 *   - It is a single `<p>`, not a list, a grid, or a set of nested spans. A
 *     retrieval system that chunks the page gets one coherent unit.
 *   - It sits high in the document order, so it survives chunk truncation.
 *   - It carries no preamble. The answer starts at the first word.
 *
 * ⚠️ NO LINKS INSIDE THE CAPSULE. THIS IS THE RULE, NOT A PREFERENCE.
 * A link inside the capsule fragments the extractable answer: it splits one
 * passage into text-anchor-text and signals that the real answer lives at the
 * other end of the href. In the cited corpus, 91% of capsules that engines
 * actually quoted contained no links at all. Put the supporting links in the
 * paragraph after the capsule, where they help a human and cost nothing.
 *
 * The component cannot enforce this on arbitrary `children`, so `agentblog
 * audit` checks it instead. If you are an agent writing capsule copy: plain
 * sentences only, no markdown links, no `<a>`.
 *
 * WHAT BREAKS IF YOU CHANGE IT
 * - Wrapping the text in anything that is not a paragraph (a `<details>`, a
 *   two-column layout, a `<blockquote>` with a citation line) splits the passage
 *   and the extraction stops being clean.
 * - Adding `'use client'` removes the single most valuable passage on the page
 *   from the HTML a non-JavaScript crawler receives.
 * - Moving it below the table of contents pushes it out of the opening chunk.
 *
 * @see https://docs.agentblog.dev/concepts/geo-playbook
 */
export interface AnswerCapsuleProps {
  /**
   * The answer as plain text, for a caller that has a `string` and nothing else.
   * Wins over `children` when both are supplied.
   */
  readonly text?: string | undefined
  /**
   * The normal path, and what the post route uses: it passes
   * `post.answerCapsule` as `children`, so the string in frontmatter is the
   * string on the page and `agentblog audit` can count its words. MDX authors
   * who need inline emphasis or a number inside `<strong>` reach the component
   * the same way. Do not put links in here.
   */
  readonly children?: ReactNode | undefined
  readonly className?: string | undefined
}

export function AnswerCapsule({ text, children, className }: AnswerCapsuleProps) {
  const content = text ?? children

  // Rendering an empty bordered box is worse than rendering nothing: it tells a
  // reader the answer is missing and gives an extractor an empty chunk.
  if (content === undefined || content === null || content === '') return null

  return (
    /*
     * The first paragraph of the article, typeset as the first paragraph of the
     * article.
     *
     * This used to be a filled, bordered, rounded panel, then a 20px standfirst.
     * Both were attempts to make the most important sentence on the page look
     * like the most important sentence on the page, and both bought that at the
     * cost of a seam: the panel put the capsule in the same visual class as the
     * table of contents, the FAQ, and every callout below it, and the standfirst
     * opened a size step down into the body text one paragraph later, which is
     * where the reader has only just settled in.
     *
     * Position is the emphasis now, and position is what an answer engine reads
     * anyway. The capsule is the first prose on the page and the first thing in
     * the opening chunk; a reader who starts at the top starts here, and nothing
     * about the type asks them to stop and change gear one paragraph later.
     *
     * The size comes from `--agentblog-reading-size` in `styles/agentblog.css`,
     * which is the same variable `@utility prose` sets `font-size` from. That is
     * the point of the variable: this paragraph sits in the article `<header>`,
     * outside `.prose`, so it cannot inherit the reading size and would
     * otherwise be a hardcoded 16px that drifts the first time somebody tunes
     * the body text.
     *
     * There is deliberately no left rule and no indent, although both were
     * tried. Any indent puts the most important sentence on the page 24px to the
     * right of the headline and byline above it and every paragraph after it.
     * One left edge down the whole article is worth more than a decorative rule.
     *
     * The markup is unchanged and must stay unchanged: one `<div>` carrying the
     * `data-agentblog` hook, wrapping exactly one `<p>`. Everything in the
     * comment above about extractability is about that shape, not about the
     * classes on it.
     */
    <div data-agentblog="answer-capsule" className={className}>
      <p className="text-foreground text-(length:--agentblog-reading-size) leading-(--agentblog-reading-leading) text-pretty">
        {content}
      </p>
    </div>
  )
}
registry/blog/components/blog/table-of-contents.tsx
'use client'

import { useMemo } from 'react'

import { useTocActiveHeading } from '@/hooks/use-toc-active-heading'
import type { TocEntry } from '@/lib/toc'
import { cn } from '@/lib/utils'

import { ChevronDownIcon } from './icons'

/**
 * In-page table of contents with a scroll-spy highlight.
 *
 * WHERE IT RENDERS
 * `app/blog/[slug]/page.tsx`, after the article `<header>` and the hero image
 * and before the body, on posts long enough to need one (roughly 1,200 words
 * and up). Entries come from the TOC that `renderMdx` extracts while compiling
 * the body, which reads the same headings that get stable ids, so every `href`
 * resolves. The post route appends one synthetic entry for the FAQ heading,
 * which lives outside the MDX body and therefore cannot be extracted.
 *
 * ⚠️ THIS IS ONE OF EXACTLY TWO CLIENT COMPONENTS IN THE BLOCK, AND IT EARNS THE
 * BOUNDARY BY RENDERING EVERYTHING BEFORE ANY JAVASCRIPT RUNS.
 * The complete list of anchors is in the server HTML. `useTocActiveHeading`
 * returns `null` during server rendering and during the first client render, so
 * the only thing hydration adds is a highlight on whichever heading is currently
 * in view. Remove the JavaScript entirely and the component still renders a
 * working table of contents. That is the bar any change here has to clear.
 * @see https://docs.agentblog.dev/reference/files
 *
 * WHY `<details>` AND NOT CONDITIONAL MOUNTING
 * The block ships open (`defaultOpen={false}` starts it collapsed) and the
 * reader can fold it away on a small screen. Nothing collapses it
 * automatically, at any viewport width: that is a decision, not an omission,
 * because a table of contents the reader cannot see is a table of contents that
 * does not do its job.
 *
 * The collapsing is `<details>`, which hides content with CSS and keeps every
 * anchor in the DOM. A React `{isOpen && <ol/>}` would remove the links from the
 * markup entirely, which is the listed extraction killer: accordions that mount
 * content on click. CSS-hidden is fine, JavaScript-mounted is not.
 * @see https://docs.agentblog.dev/concepts/geo-playbook*
 * WHY THE LIST IS NESTED
 * H3 entries render inside their parent H2's `<li>`, not as a flat list with
 * extra padding. Indentation is invisible to a screen reader; list nesting is
 * the thing that actually communicates the outline.
 *
 * WHAT BREAKS IF YOU CHANGE IT
 * - Building the list from a `useEffect` (reading headings out of the DOM
 *   instead of taking `entries` as a prop) empties the TOC in the server HTML
 *   and costs the page its "jump to" links.
 * - Dropping `aria-current` leaves the active state as colour alone.
 * - Removing the heading `scroll-mt-*` utilities in `prose.tsx` makes every jump
 *   land with the target heading behind a sticky header.
 */
export interface TableOfContentsProps {
  readonly entries: readonly TocEntry[]
  /** Visible heading for the block. */
  readonly title?: string | undefined
  /** Start collapsed. Defaults to open, because a visible TOC is the point. */
  readonly defaultOpen?: boolean | undefined
  readonly className?: string | undefined
}

interface TocGroup {
  readonly entry: TocEntry
  readonly children: TocEntry[]
}

/** Fold H3 entries under the H2 before them. A leading H3 becomes a top level item. */
function groupEntries(entries: readonly TocEntry[]): TocGroup[] {
  const groups: TocGroup[] = []

  for (const entry of entries) {
    const current = groups[groups.length - 1]
    if (entry.depth === 3 && current !== undefined) current.children.push(entry)
    else groups.push({ entry, children: [] })
  }

  return groups
}

function TocLink({ entry, isActive }: { entry: TocEntry; isActive: boolean }) {
  return (
    <a
      href={`#${entry.id}`}
      // `aria-current="location"` is the correct value here, not "page": the
      // target is a section of the current document, not a different page.
      {...(isActive ? { 'aria-current': 'location' as const } : {})}
      className={cn(
        'focus-visible:ring-ring block rounded-sm py-1 transition-colors focus-visible:ring-2 focus-visible:outline-none',
        /*
         * At `xl` the list gains a rail and each link becomes a segment of it.
         * `-ml-px` pulls the link's 2px border over the list's 1px one, so the
         * active marker replaces the rule instead of sitting beside it. Without
         * that the text shifts sideways by a pixel every time the active
         * section changes on scroll, which is the kind of movement you notice
         * without being able to say what moved.
         */
        'xl:-ml-px xl:rounded-none xl:border-l-2 xl:border-transparent',
        entry.depth === 3 ? 'xl:pl-7' : 'xl:pl-4',
        isActive
          ? 'text-foreground xl:border-foreground font-medium'
          : 'text-muted-foreground hover:text-foreground',
      )}
    >
      {entry.text}
    </a>
  )
}

export function TableOfContents({ entries, title, defaultOpen, className }: TableOfContentsProps) {
  // The hook takes ids, not entries, so it does not re-subscribe when the
  // parent hands down a fresh array with the same contents.
  const ids = useMemo(() => entries.map((entry) => entry.id), [entries])
  const activeId = useTocActiveHeading(ids)
  const groups = useMemo(() => groupEntries(entries), [entries])

  if (entries.length === 0) return null

  return (
    /*
     * ONE COMPONENT, TWO PRESENTATIONS, AND DELIBERATELY ONE DOM NODE
     *
     * Below `xl` this is a collapsible card sitting in the flow under the
     * byline. At `xl` and above the page moves it into a margin rail, where it
     * drops the card entirely and becomes a bare list against a hairline, with
     * the summary demoted to an eyebrow and the chevron hidden. `app/blog/[slug]`
     * owns the `sticky` and the column placement; this file owns what it looks
     * like once it gets there.
     *
     * The obvious alternative is two instances behind `hidden xl:block` and
     * `xl:hidden`, and it is wrong on this page specifically. It would put two
     * navigation landmarks named "Table of contents" in the accessibility tree,
     * duplicate every heading link in the HTML that answer engines read, and
     * double the anchor count on a page whose entire purpose is being cleanly
     * chunked. One node, styled twice, costs a handful of `xl:` prefixes and
     * none of that.
     *
     * The `<details>` element stays at every width. At `xl` it is always open,
     * because it renders open by default and the summary is made
     * non-interactive there rather than removed: a `<summary>` is what gives
     * the list its accessible heading, and taking it away to hide a chevron
     * would cost more than it saves.
     */
    <nav aria-label="Table of contents" className={cn('not-prose', className)}>
      <details
        className="group border-border bg-card rounded-lg border p-4 xl:rounded-none xl:border-0 xl:bg-transparent xl:p-0"
        open={defaultOpen !== false}
      >
        <summary className="text-card-foreground focus-visible:ring-ring xl:text-muted-foreground flex cursor-pointer items-center justify-between gap-2 rounded-sm text-sm font-semibold focus-visible:ring-2 focus-visible:outline-none xl:pointer-events-none xl:cursor-default xl:text-xs xl:font-medium xl:tracking-wider xl:uppercase">
          {title ?? 'On this page'}
          <ChevronDownIcon
            className="size-4 shrink-0 transition-transform group-open:rotate-180 xl:hidden"
            aria-hidden="true"
          />
        </summary>

        {/*
         * The rail. It only exists at `xl`, where the links carry the active
         * marker that replaces a segment of it. Below `xl` the card border is
         * already doing the job of containing the list, and a second vertical
         * rule inside it would be one line too many.
         */}
        <ol className="border-border mt-3 list-none space-y-0.5 p-0 text-sm xl:mt-4 xl:border-l">
          {groups.map((group) => (
            <li key={group.entry.id}>
              <TocLink entry={group.entry} isActive={group.entry.id === activeId} />

              {group.children.length > 0 ? (
                <ol className="border-border mt-0.5 ml-4 list-none space-y-0.5 border-l pl-3 xl:mt-0 xl:ml-0 xl:border-l-0 xl:pl-0">
                  {group.children.map((child) => (
                    <li key={child.id}>
                      <TocLink entry={child} isActive={child.id === activeId} />
                    </li>
                  ))}
                </ol>
              ) : null}
            </li>
          ))}
        </ol>
      </details>
    </nav>
  )
}
registry/blog/components/blog/related-posts.tsx
import Link from 'next/link'

import { postPath } from '@/lib/config'
import type { PublishedPost } from '@/lib/types'
import { cn } from '@/lib/utils'

import { formatPostDateShort } from './byline'
import { CLUSTER_GAP, SECTION_HEADING } from './type-scale'

/**
 * The internal-link block at the foot of a post.
 *
 * WHERE IT RENDERS
 * `app/blog/[slug]/page.tsx`, after the author bio and before the single CTA.
 * The posts come from `getRelatedPosts(post, limit)`, which returns the author's
 * explicit `relatedPosts` frontmatter first and fills the remainder with shared
 * category, then shared tags, then recency.
 *
 * WHY IT IS SHAPED THIS WAY
 * This is the block that keeps the hub-and-spoke link graph connected, so it is
 * deliberately the least clever component in the set: a heading, a list, and
 * plain server-rendered anchors. No cards, no images, no carousel.
 *
 *   - Descriptive anchor text. Each link's text is the target post's title,
 *     which names the entity being linked to. "Read more" and "click here" are
 *     the anti-pattern: they give the crawler nothing about the destination.
 *   - Every post needs at least one inbound internal link from its cluster, and
 *     this block is how the previous posts in the cluster provide it. A post
 *     with zero inbound links is an orphan, and the pre-publish check fails on
 *     that rather than shipping it.
 *   - A carousel would hide half the links behind a JavaScript interaction,
 *     which removes them from the link graph for any crawler that does not run
 *     JavaScript. That is why the list is flat and complete.
 *
 * @see https://docs.agentblog.dev/concepts/geo-playbook*
 * WHAT BREAKS IF YOU CHANGE IT
 * - Adding `'use client'` or lazy-mounting the list drops these internal links
 *   out of the crawlable graph, and the whole cluster loses its interlinking.
 * - Replacing the titles with generic anchor text throws away the topical signal
 *   that makes internal links worth having.
 */
export interface RelatedPostsProps {
  readonly posts: readonly PublishedPost[]
  /** Section heading. Keep it descriptive; it is a real H2 in the outline. */
  readonly title?: string | undefined
  readonly className?: string | undefined
}

export function RelatedPosts({ posts, title, className }: RelatedPostsProps) {
  // Rendering an empty "Related posts" heading tells a reader the section is
  // broken and gives an extractor a heading with no content underneath it.
  if (posts.length === 0) return null

  const headingId = 'related-posts'

  return (
    /*
     * A list, not a second card grid. The cards on the index are the primary
     * presentation of a post and this is a footnote to the article you just
     * finished, so it gets the tertiary treatment: rules instead of borders,
     * a body-size title instead of a heading-size one, and no surface at all.
     * Repeating `PostCard` here would give the foot of every article three more
     * objects with exactly the weight of the thing a reader came for.
     */
    <section aria-labelledby={headingId} className={cn('not-prose', CLUSTER_GAP, className)}>
      <h2 id={headingId} className={SECTION_HEADING}>
        {title ?? 'Related posts'}
      </h2>

      <ul className="border-border list-none border-t p-0">
        {posts.map((post) => (
          <li key={post.slug} className="border-border border-b">
            <Link
              href={postPath(post.slug)}
              className="hover:bg-accent/50 focus-visible:ring-ring group -mx-3 block rounded-lg px-3 py-4 transition-colors focus-visible:ring-2 focus-visible:outline-none"
            >
              <h3 className="text-foreground text-base font-medium text-pretty">{post.title}</h3>
              <p className="text-muted-foreground mt-1.5 line-clamp-2 text-sm leading-relaxed text-pretty">
                {post.description}
              </p>
              <p className="text-muted-foreground mt-2 text-xs">
                <time dateTime={post.datePublished}>{formatPostDateShort(post.datePublished)}</time>
              </p>
            </Link>
          </li>
        ))}
      </ul>
    </section>
  )
}
registry/blog/components/blog/breadcrumbs.tsx
import Link from 'next/link'

import { cn } from '@/lib/utils'

import { ChevronRightIcon } from './icons'

/**
 * The visible breadcrumb trail: Blog, then where in the blog you are.
 *
 * There is no "Home" crumb, and that is a decision rather than an oversight.
 * Google's breadcrumb documentation states it outright: "It is not required to
 * include a breadcrumb ListItem for the top level path (your site's domain or
 * host name)." Every SERP breadcrumb is already prefixed with the domain, so a
 * "Home" crumb spends the first item of the trail restating the thing the reader
 * can see in the address bar. The trail starts at the first level that tells
 * somebody something.
 *
 * WHERE IT RENDERS
 * Above the H1 on the post route, on category hubs, on tag pages, and on author
 * pages. It is the first thing a block route renders, which means it is the
 * first thing inside the `<main>` your root layout supplies. The block does not
 * render `<main>` itself; see the landmark contract at the top of
 * `app/blog/layout.tsx`, and note that this nav is one of the two the skip link
 * described there exists to bypass.
 *
 * ⚠️ THIS COMPONENT HAS A TWIN, AND THEY MUST AGREE.
 * `lib/schema.ts` emits a `BreadcrumbList` into the JSON-LD graph from the same
 * trail. Google's structured data policy requires that marked-up content be
 * visible on the page, so a `BreadcrumbList` whose items do not match this nav
 * is a policy violation, not a cosmetic mismatch. Build the trail array once in
 * the route and pass the same array to both. Never assemble it twice.
 *
 * WHY IT IS SHAPED THIS WAY
 *   - A real `<nav aria-label="Breadcrumb">`. The label is what distinguishes
 *     this landmark from the site nav for a screen reader user cycling
 *     landmarks. There is no `role="navigation"` because the element already is
 *     one.
 *   - An ordered list, because the order is the meaning. An unordered list says
 *     these items are interchangeable, and they are not.
 *   - The current page is the crumb with no `path`, not the last one. That crumb
 *     is text rather than a link and carries `aria-current="page"`, because a
 *     link to the page you are already on is noise for a reader and a
 *     self-referential edge in the link graph. The two are not the same test: a
 *     post's trail ends at its category hub, which is a real destination and has
 *     to stay clickable, and marking it `aria-current="page"` would tell a screen
 *     reader user they were on the hub. Give the current page a crumb with no
 *     `path` when you want one, and leave it off when the page above is the
 *     honest end of the trail.
 *   - The chevrons are `aria-hidden` and live in their own element, so the
 *     accessible name of each crumb stays clean.
 *
 * WHAT BREAKS IF YOU CHANGE IT
 * - Dropping the trail from the page while leaving the `BreadcrumbList` in the
 *   JSON-LD is the exact "markup for invisible content" case that gets
 *   structured data ignored or penalized.
 * - Giving the current page a `path` renders it as a link and removes
 *   `aria-current="page"`, which is the only signal telling assistive tech where
 *   in the trail the reader is.
 * - Passing an absolute URL rather than a path. See `BreadcrumbTrailItem.path`.
 *
 * @see https://docs.agentblog.dev/concepts/geo-playbook
 */
export interface BreadcrumbTrailItem {
  readonly name: string
  /**
   * Destination, as a root-relative path. Omit it to mark this crumb as the
   * current page, which is what renders it as plain text with
   * `aria-current="page"`.
   *
   * A path, never an absolute URL. Use the path helpers from `@/lib/config`
   * (`blogPath`, `categoryPath`, `postPath`, and so on) rather than their `*Url`
   * counterparts. `buildBreadcrumb` absolutizes the same value for the JSON-LD,
   * which is where an absolute URL is required; here it would be a bug. An
   * `href` whose origin is not the origin the page is being served from makes
   * `next/link` fall back to a plain document navigation, so on a preview
   * deployment or a local dev server every crumb leaves the deployment for the
   * production domain.
   */
  readonly path?: string | undefined
}

export interface BreadcrumbsProps {
  readonly trail: readonly BreadcrumbTrailItem[]
  readonly className?: string | undefined
}

export function Breadcrumbs({ trail, className }: BreadcrumbsProps) {
  if (trail.length === 0) return null

  const lastIndex = trail.length - 1

  return (
    <nav aria-label="Breadcrumb" className={cn('not-prose', className)}>
      <ol className="text-muted-foreground flex list-none flex-wrap items-center gap-1.5 p-0 text-sm">
        {trail.map((item, index) => {
          const isLast = index === lastIndex
          // No destination means this crumb is the page the reader is on.
          const isCurrent = item.path === undefined

          return (
            <li key={`${item.name}-${index}`} className="inline-flex items-center gap-1.5">
              {isCurrent ? (
                <span aria-current="page" className="text-foreground font-medium">
                  {item.name}
                </span>
              ) : (
                <Link
                  href={item.path}
                  className="hover:text-foreground focus-visible:ring-ring rounded-sm underline-offset-4 hover:underline focus-visible:ring-2 focus-visible:outline-none"
                >
                  {item.name}
                </Link>
              )}

              {isLast ? null : (
                <ChevronRightIcon className="size-3.5 shrink-0" aria-hidden="true" />
              )}
            </li>
          )
        })}
      </ol>
    </nav>
  )
}
registry/blog/components/blog/category-pills.tsx
import Link from 'next/link'

import { Badge } from '@/components/ui/badge'
import { categoryPath } from '@/lib/config'
import type { Category } from '@/lib/types'
import { cn } from '@/lib/utils'

/**
 * The category filter row.
 *
 * WHERE IT RENDERS
 * Under the H1 on the blog index and on every category hub, so a reader landing
 * on one hub can reach the others without going back to the index.
 *
 * WHY IT IS SHAPED THIS WAY
 * Categories are indexable hub pages, which makes this row part of the site's
 * internal link graph rather than a UI filter. Three consequences:
 *
 *   - Real `<a href>` per category, always. Not a client-side filter, not a
 *     select that pushes to the router. Every hub has to be reachable by
 *     following a link, or it never enters the crawl.
 *   - A `<nav aria-label="Categories">` wrapping an unordered list. Categories
 *     have no meaningful order, so `<ul>` is correct here where breadcrumbs need
 *     `<ol>`.
 *   - The active pill still renders as a link to itself but carries
 *     `aria-current="page"`. Keeping the anchor keeps the row's shape stable
 *     across pages; `aria-current` is what tells a screen reader which hub the
 *     reader is on.
 *
 * @see https://docs.agentblog.dev/concepts/geo-playbook*
 * WHERE A SHADCN PRIMITIVE DID NOT FIT CLEANLY
 * `Badge` supports `asChild` in current shadcn, which would let the anchor *be*
 * the badge. This component wraps instead: `<Link><Badge/></Link>`. A consumer
 * who customized `badge.tsx` before `asChild` existed would otherwise get an
 * unknown `asChild` attribute spread onto a DOM node, and inheriting the user's
 * existing component is the whole point of the block. The focus ring therefore
 * lives on the anchor, which is the focusable element anyway.
 *
 * WHAT BREAKS IF YOU CHANGE IT
 * - Turning these into buttons that filter a client-side list removes every
 *   category hub from the crawlable graph and makes the hubs unreachable.
 * - Dropping `aria-current` leaves the active state as colour alone, which is
 *   invisible to assistive tech and to anyone who cannot distinguish the tokens.
 */
export interface CategoryPillsProps {
  readonly categories: readonly Category[]
  /** Slug of the hub currently being viewed, if any. */
  readonly activeSlug?: string | undefined
  /** Accessible name for the nav landmark. */
  readonly label?: string | undefined
  readonly className?: string | undefined
}

export function CategoryPills({ categories, activeSlug, label, className }: CategoryPillsProps) {
  if (categories.length === 0) return null

  return (
    /*
     * These are filter controls, and the card badges below them are static
     * labels. Before this they were the same `secondary` badge, so a reader had
     * no way to tell which of the two rows of pills on the index page did
     * anything when clicked.
     *
     * `outline` is the resting state now: an unfilled chip reads as a control,
     * and it also stops a row of eight categories from becoming eight filled
     * blocks competing with the post grid for attention. The active one takes
     * the solid `default` fill, so exactly one pill is filled at a time and it
     * is always the one describing what you are looking at.
     */
    <nav aria-label={label ?? 'Categories'} className={cn('not-prose', className)}>
      <ul className="flex list-none flex-wrap gap-2 p-0">
        {categories.map((category) => {
          const isActive = category.slug === activeSlug

          return (
            <li key={category.slug}>
              <Link
                href={categoryPath(category.slug)}
                {...(isActive ? { 'aria-current': 'page' as const } : {})}
                className="focus-visible:ring-ring group inline-block rounded-full focus-visible:ring-2 focus-visible:outline-none"
              >
                {/*
                 * The hover lives on the parent `<Link>` through `group`, not on
                 * the badge. shadcn's own `outline` variant hovers via `[a&]`,
                 * which matches only when the badge itself is the anchor, and
                 * here it is a `<span>` inside one. Relying on the built-in
                 * would give these pills no hover state at all.
                 */}
                <Badge
                  className={cn(
                    'px-3 py-1 text-sm font-normal transition-colors',
                    !isActive && 'group-hover:bg-accent group-hover:text-accent-foreground',
                  )}
                  variant={isActive ? 'default' : 'outline'}
                >
                  {category.name}
                </Badge>
              </Link>
            </li>
          )
        })}
      </ul>
    </nav>
  )
}
registry/blog/components/blog/pagination.tsx
import Link from 'next/link'

import { Button } from '@/components/ui/button'
import { cn } from '@/lib/utils'

import { ChevronLeftIcon, ChevronRightIcon } from './icons'

/**
 * Archive pagination.
 *
 * WHERE IT RENDERS
 * The foot of the blog index, category hubs, tag pages, and author pages.
 *
 * ⚠️ EVERY CONTROL IN HERE IS A REAL `<a href>`. NEVER A BUTTON THAT PUSHES TO
 * THE ROUTER.
 * Paginated archives are frequently the only discovery path to deep posts. A
 * crawler that does not run JavaScript follows anchors and nothing else, so a
 * `<button onClick={() => router.push(...)}>` makes page 2 and everything below
 * it unreachable. This is the single most consequential line in the file.
 * @see https://docs.agentblog.dev/concepts/geo-playbook*
 * WHY IT IS SHAPED THIS WAY
 *   - `rel="prev"` and `rel="next"` on the adjacent-page links. Google stopped
 *     using them for indexing years ago, so they are here for accessibility, for
 *     other engines, and for browsers that prefetch on them. Cheap, and the
 *     absence is silently costly.
 *   - `aria-current="page"` on the current page number. Colour alone does not
 *     communicate position.
 *   - The window logic (first, last, current and its neighbours, ellipses) is
 *     pure and computed at render time, so a 40 page archive does not emit 40
 *     links, and the output is identical on every render.
 *   - Prev and Next are omitted rather than disabled at the ends. A disabled
 *     `<span>` that reads "Previous" is a control a screen reader announces and
 *     a reader cannot use.
 *
 * ⚠️ URL SHAPE MUST MATCH THE ROUTE THAT ACTUALLY EXISTS.
 * The default is the query form the shipped archive routes use: page 1 is
 * `basePath` itself and page N is `${basePath}?page=${N}`. Page 1 must NOT get a
 * `?page=1` suffix, or the archive ends up with two URLs for one set of posts
 * and splits its own signals. This also has to agree with the self-canonical the
 * route emits, which is why `app/blog/page.tsx` documents the same decision.
 *
 * The query string is appended to `basePath` verbatim, which is why `basePath`
 * has to arrive already normalised by a `@/lib/config` path helper. See the prop
 * doc below.
 *
 * If you switch to the path-segment form, add `app/blog/page/[page]/page.tsx`,
 * pass `hrefForPage={(n) => (n <= 1 ? '/blog' : `/blog/page/${n}`)}` here, and
 * update the canonical in the route. Those edits go together. Doing one without
 * the others produces 404s on page 2.
 *
 * WHAT BREAKS IF YOU CHANGE IT
 * - Replacing the anchors with router pushes: see the warning above.
 * - Adding `noindex` to paginated pages while they are the only path to deep
 *   posts removes those posts from the index along with the archive.
 * - Making page 1 canonical to page 2 (or every page canonical to page 1) tells
 *   Google to drop the deep pages. Each paginated page self-canonicalizes.
 */
export interface PaginationProps {
  /** Current page, 1 based. */
  readonly page: number
  readonly totalPages: number
  /**
   * Archive root, root-relative. For example `/blog`.
   *
   * ⚠️ PASS A VALUE FROM A `@/lib/config` PATH HELPER, NEVER A STRING LITERAL.
   * `blogPath()`, `categoryPath(slug)`, `tagPath(tag)`, `authorPath(slug)`. Those
   * helpers are the only thing that applies `config.trailingSlash`, and this
   * component appends the query string to whatever it is given. A literal
   * `/blog` on a `trailingSlash: true` site therefore produces `/blog?page=2`,
   * which 308s to `/blog/?page=2` before it serves, on every paginated link. The
   * helper form produces the final URL directly.
   */
  readonly basePath: string
  /** Override the page URL scheme. @see the URL SHAPE note above. */
  readonly hrefForPage?: ((page: number) => string) | undefined
  /** Accessible name for the nav landmark. Distinguish multiple paginations. */
  readonly label?: string | undefined
  readonly className?: string | undefined
}

/**
 * Which page numbers to show. Always the first page, the last page, the current
 * page and its immediate neighbours; `null` marks a gap.
 *
 * Kept as a standalone pure function so it stays readable and so the gap rule
 * (only collapse when at least two pages are being hidden) is stated once.
 */
function pageWindow(page: number, totalPages: number): readonly (number | null)[] {
  const shown = new Set<number>([1, totalPages, page - 1, page, page + 1])
  const items: (number | null)[] = []
  let previous = 0

  for (let n = 1; n <= totalPages; n += 1) {
    if (!shown.has(n)) continue
    // A single hidden page is not worth an ellipsis: render the number instead.
    if (previous > 0 && n - previous === 2) items.push(previous + 1)
    else if (previous > 0 && n - previous > 2) items.push(null)
    items.push(n)
    previous = n
  }

  return items
}

export function Pagination({
  page,
  totalPages,
  basePath,
  hrefForPage,
  label,
  className,
}: PaginationProps) {
  // One page is not a paginated archive, and an empty nav landmark is noise.
  if (totalPages <= 1) return null

  const href =
    hrefForPage ??
    ((n: number) =>
      n <= 1 ? basePath : `${basePath}${basePath.includes('?') ? '&' : '?'}page=${n}`)
  const hasPrevious = page > 1
  const hasNext = page < totalPages

  return (
    <nav aria-label={label ?? 'Pagination'} className={cn('not-prose', className)}>
      <ul className="flex list-none flex-wrap items-center justify-center gap-1 p-0">
        {hasPrevious ? (
          <li>
            <Button asChild variant="ghost" size="sm">
              <Link href={href(page - 1)} rel="prev">
                <ChevronLeftIcon className="size-4" aria-hidden="true" />
                Previous
              </Link>
            </Button>
          </li>
        ) : null}

        {pageWindow(page, totalPages).map((n, index) =>
          n === null ? (
            <li key={`gap-${index}`} aria-hidden="true" className="text-muted-foreground px-2">
              …
            </li>
          ) : (
            <li key={n}>
              <Button asChild variant={n === page ? 'default' : 'ghost'} size="icon">
                <Link
                  href={href(n)}
                  aria-label={`Page ${n}`}
                  {...(n === page ? { 'aria-current': 'page' as const } : {})}
                >
                  {n}
                </Link>
              </Button>
            </li>
          ),
        )}

        {hasNext ? (
          <li>
            <Button asChild variant="ghost" size="sm">
              <Link href={href(page + 1)} rel="next">
                Next
                <ChevronRightIcon className="size-4" aria-hidden="true" />
              </Link>
            </Button>
          </li>
        ) : null}
      </ul>
    </nav>
  )
}
registry/blog/components/blog/share-buttons.tsx
'use client'

import { useCallback, useState } from 'react'

import { Button } from '@/components/ui/button'
import { cn } from '@/lib/utils'

import { CheckIcon, LinkIcon, MailIcon, ShareIcon } from './icons'

/**
 * Share controls for a post.
 *
 * WHERE IT RENDERS
 * `app/blog/[slug]/page.tsx`, at the foot of the article body (after the FAQ
 * and the sources list) and immediately before the author bio.
 *
 * ⚠️ THIS IS THE SECOND AND LAST CLIENT COMPONENT IN THE BLOCK, AND IT EARNS THE
 * BOUNDARY THE SAME WAY THE TABLE OF CONTENTS DOES.
 * Every share target is a real `<a href>` that works with JavaScript disabled:
 * the X and LinkedIn intents are ordinary URLs and the email option is a
 * `mailto:`. The only thing the client boundary buys is the copy-link button,
 * which needs `navigator.clipboard`, and its "Copied" confirmation. Turn the
 * JavaScript off and the component still shares.
 * @see https://docs.agentblog.dev/reference/files
 *
 * WHY NO BRAND ICONS
 * Lucide deprecated its brand glyphs and may remove them in a minor release,
 * which would break a consumer's build on an unrelated upgrade. Each control
 * therefore carries a visible text label instead, which also means none of them
 * is an icon-only control without an accessible name. The icons that remain are
 * neutral and `aria-hidden`.
 * @see `icons.tsx`
 *
 * WHY THE URL IS A PROP
 * The canonical, absolute post URL is composed on the server by the helpers in
 * `@/lib/config` and passed down. Reading `window.location.href` in an effect
 * would produce a different string in preview deployments, behind a proxy, and
 * on `localhost`, and would leave the anchors empty in the server HTML.
 *
 * WHAT BREAKS IF YOU CHANGE IT
 * - Turning the anchors into `onClick` handlers that call `window.open` makes
 *   every share target vanish from the markup and blocks middle-click and
 *   open-in-new-tab.
 * - Dropping `rel="noopener noreferrer"` on the `target="_blank"` links hands
 *   the opened page a reference to this one.
 */
export interface ShareButtonsProps {
  /** Absolute canonical URL of the post. Build it with `postUrl` from `@/lib/config`. */
  readonly url: string
  /** Post title, used as the pre-filled share text. */
  readonly title: string
  /** Accessible name for the group. */
  readonly label?: string | undefined
  readonly className?: string | undefined
}

export function ShareButtons({ url, title, label, className }: ShareButtonsProps) {
  const [copied, setCopied] = useState(false)

  const copy = useCallback(() => {
    // Older browsers and any non-secure context have no clipboard API. Failing
    // quietly is correct: the share links beside this button still work.
    if (typeof navigator === 'undefined' || navigator.clipboard === undefined) return

    void navigator.clipboard.writeText(url).then(
      () => {
        setCopied(true)
        window.setTimeout(() => setCopied(false), 2000)
      },
      () => setCopied(false),
    )
  }, [url])

  const encodedUrl = encodeURIComponent(url)
  const encodedTitle = encodeURIComponent(title)

  const targets = [
    { name: 'X', href: `https://x.com/intent/post?url=${encodedUrl}&text=${encodedTitle}` },
    {
      name: 'LinkedIn',
      href: `https://www.linkedin.com/sharing/share-offsite/?url=${encodedUrl}`,
    },
  ] as const

  return (
    <div
      role="group"
      aria-label={label ?? 'Share this post'}
      className={cn('not-prose flex flex-wrap items-center gap-2', className)}
    >
      <span className="text-muted-foreground inline-flex items-center gap-1.5 text-sm">
        <ShareIcon className="size-4" aria-hidden="true" />
        Share
      </span>

      {targets.map((target) => (
        <Button key={target.name} asChild variant="outline" size="sm">
          <a href={target.href} target="_blank" rel="noopener noreferrer">
            {target.name}
          </a>
        </Button>
      ))}

      <Button asChild variant="outline" size="sm">
        <a href={`mailto:?subject=${encodedTitle}&body=${encodedUrl}`}>
          <MailIcon className="size-4" aria-hidden="true" />
          Email
        </a>
      </Button>

      <Button type="button" variant="outline" size="sm" onClick={copy}>
        {copied ? (
          <CheckIcon className="size-4" aria-hidden="true" />
        ) : (
          <LinkIcon className="size-4" aria-hidden="true" />
        )}
        {copied ? 'Copied' : 'Copy link'}
      </Button>

      {/* The confirmation is announced rather than only shown, so a screen
          reader user gets the same feedback as a sighted one. */}
      <span aria-live="polite" className="sr-only">
        {copied ? 'Link copied to clipboard' : ''}
      </span>
    </div>
  )
}
registry/blog/hooks/use-toc-active-heading.ts
'use client'

/**
 * `useTocActiveHeading` - which section is the reader looking at?
 *
 * ---------------------------------------------------------------------------
 * THIS IS THE ONLY CLIENT CODE IN THE ARTICLE RENDER PATH
 * ---------------------------------------------------------------------------
 * The table of contents is already complete in the server-rendered HTML: every
 * entry, every `href`, in document order. This hook adds one thing on top of
 * that, a highlight on whichever entry matches the current scroll position.
 *
 * Delete this hook and the TOC still renders, still links, and is still fully
 * crawlable. Nothing that matters for search or for AI retrieval depends on it.
 * That is the test any client code in this blog has to pass before it ships.
 *
 * ---------------------------------------------------------------------------
 * WHY `IntersectionObserver` AND NOT A SCROLL LISTENER
 * ---------------------------------------------------------------------------
 * A scroll handler runs on every frame and forces layout every time it reads
 * `getBoundingClientRect`. `IntersectionObserver` is computed off the main
 * thread and fires only when a heading actually crosses a boundary, so a long
 * post with sixty headings costs nothing while the reader is not moving.
 *
 * @see https://docs.agentblog.dev/reference/files
 */
import { useEffect, useState } from 'react'

/**
 * How far up the viewport the "active" band sits. A heading is considered
 * current once it has scrolled into the top 30% of the screen, which is where
 * a reader's eye actually is, rather than at the exact top edge.
 */
const ACTIVE_BAND = '0px 0px -70% 0px'

/**
 * The reduced-motion band. Narrower, so the highlight moves once per section
 * boundary instead of tracking continuously through momentum scrolling. Less
 * on-screen movement is the whole request behind `prefers-reduced-motion`, and
 * a marker that skitters between three entries during one flick is exactly the
 * movement it asks us to drop.
 */
const ACTIVE_BAND_REDUCED = '0px 0px -90% 0px'

export function useTocActiveHeading(ids: readonly string[]): string | null {
  const [activeId, setActiveId] = useState<string | null>(null)

  // Depend on the joined string, not the array. A parent that rebuilds the
  // entry list on every render would otherwise tear down and rebuild the
  // observer on every render too, for an identical list of ids.
  const key = ids.join('\n')

  useEffect(() => {
    const headingIds = key.length === 0 ? [] : key.split('\n')
    if (headingIds.length === 0) return

    const elements = headingIds
      .map((id) => document.getElementById(id))
      .filter((el): el is HTMLElement => el !== null)
    if (elements.length === 0) return

    const motionQuery = window.matchMedia('(prefers-reduced-motion: reduce)')
    let observer: IntersectionObserver | null = null

    const subscribe = (): undefined => {
      observer?.disconnect()
      const visible = new Set<string>()

      observer = new IntersectionObserver(
        (entries) => {
          for (const entry of entries) {
            if (entry.isIntersecting) visible.add(entry.target.id)
            else visible.delete(entry.target.id)
          }
          // Highest heading still in the band wins, so the marker matches the
          // section the reader is reading rather than the one about to arrive.
          const current = headingIds.find((id) => visible.has(id))
          if (current !== undefined) setActiveId(current)
        },
        {
          rootMargin: motionQuery.matches ? ACTIVE_BAND_REDUCED : ACTIVE_BAND,
          threshold: 0,
        },
      )

      for (const el of elements) observer.observe(el)
    }

    subscribe()
    // Re-subscribe if the reader changes the system setting mid-session.
    motionQuery.addEventListener('change', subscribe)

    return () => {
      motionQuery.removeEventListener('change', subscribe)
      observer?.disconnect()
      observer = null
    }
  }, [key])

  return activeId
}

More from agentblog

Browse all components in this library →