shadimage
agentblog
componentmdx-componentssource: ok · refreshed

AgentBlog MDX components

The component map an AgentBlog post renders through: headings with stable anchors, real tables, server-highlighted code blocks, callouts, statistics with a required source, pull quotes, key takeaways, and an FAQ built from details elements so the answers are in the raw HTML rather than mounted by JavaScript.

npx shadcn add "https://agentblog.dev/r/mdx-components.json"
free

Where it goes

faqstatsdata-tableblog-list

Visual style

terminal-mono

Interaction

animated-counter

Use cases

dashboard

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

Dependencies

Bundle weight · light

Registry dependencies

@agentblog/blog-ui

Files9 · source cached 10/8/2026, 12:22:02 PM

registry/blog/components/mdx/index.tsx
/**
 * The MDX component map.
 *
 * ---------------------------------------------------------------------------
 * WHAT THIS FILE DECIDES
 * ---------------------------------------------------------------------------
 * Two different things live in one object here, and it is worth naming them:
 *
 *   1. **Element overrides.** Lowercase keys (`h2`, `a`, `table`, `pre`) replace
 *      what plain markdown compiles to. An author gets these by writing
 *      markdown and never thinks about them.
 *   2. **Author-usable components.** Capitalised keys (`Callout`, `Stat`,
 *      `Quote`, `Faq`, `Figure`, `KeyTakeaways`) are what makes `<Stat .../>`
 *      resolve inside a `.mdx` file. Without an entry here, MDX renders an
 *      unknown component as an error.
 *
 * `lib/render-mdx.tsx` passes this object to `evaluate()`. It is the only
 * consumer.
 *
 * ---------------------------------------------------------------------------
 * WHAT IS DELIBERATELY NOT MAPPED, AND WHY
 * ---------------------------------------------------------------------------
 * `p`, `ul`, `ol`, `li`, `strong`, and `em` are left to
 * `@tailwindcss/typography`, which `styles/agentblog.css` binds to the
 * consumer's tokens through `--tw-prose-*`. Adding pass-through components for
 * them would put long-form styling in two places that have to agree, and the
 * second one always loses. If body copy looks wrong, fix the binding in
 * `agentblog.css`, not here.
 *
 * ---------------------------------------------------------------------------
 * WHY EVERY COMPONENT HERE MERGES `className` INSTEAD OF REPLACING IT
 * ---------------------------------------------------------------------------
 * The pipeline DOES pass a `className` to these elements, and a component that
 * writes its own `className` after `{...props}` silently throws that one away.
 * The case that proved it: `remark-rehype` emits the GFM footnote section label
 * as `<h2 class="sr-only" id="footnote-label">`. Dropping `sr-only` puts a
 * full-size heading reading "Footnotes" in the middle of the article, with an
 * autolink anchor, and it is not in the table of contents because the extractor
 * never saw it. `rehype-autolink-headings` is the other source: it appends an
 * `<a class="agentblog-anchor">` inside every heading, and replacing that class
 * is what makes the anchor render as a full body link instead of the faint
 * marker `styles/agentblog.css` styles.
 *
 * So every override below destructures `className` out and merges it with
 * `mergeClasses`. Component styles come first and the incoming class comes last,
 * which is only a reading order: CSS precedence is decided by the stylesheet,
 * not by the order of names in the attribute. There is no `cn()` import, so the
 * block has no dependency on the consumer having `lib/utils.ts`.
 *
 * @see https://docs.agentblog.dev/guides/match-your-design
 */
import type { ComponentPropsWithoutRef } from 'react'

import { Callout } from '@/components/mdx/callout'
import { CodeBlock, InlineCode } from '@/components/mdx/code-block'
import { Faq } from '@/components/mdx/faq'
import { Figure, MdxImage } from '@/components/mdx/figure'
import { KeyTakeaways } from '@/components/mdx/key-takeaways'
import { Quote } from '@/components/mdx/quote'
import { Stat } from '@/components/mdx/stat'
import { Table, Tbody, Td, Th, Thead, Tr } from '@/components/mdx/table'
import { sitePath } from '@/lib/config'

/**
 * Join a component's own classes with whatever the pipeline passed in, dropping
 * the empty cases so the attribute never gains a stray space.
 */
function mergeClasses(own: string, incoming: string | undefined): string {
  return incoming === undefined || incoming === '' ? own : `${own} ${incoming}`
}

/**
 * `scroll-mt-24` on every heading. Without it, following a table of contents
 * link parks the heading underneath a sticky header and the reader lands on the
 * paragraph above the section they asked for.
 *
 * The trailing `<a class="agentblog-anchor">` inside each heading is appended
 * by `rehype-autolink-headings`. It arrives as part of `children` and is styled
 * in `styles/agentblog.css`, where it stays faint until the heading is hovered.
 *
 * `className` is merged rather than replaced. See the file header: the GFM
 * footnote label arrives here as an `sr-only` H2 and has to stay hidden.
 */
function H2({ className, ...props }: ComponentPropsWithoutRef<'h2'>) {
  return (
    <h2
      {...props}
      className={mergeClasses(
        'agentblog-heading text-foreground mt-12 mb-4 scroll-mt-24 text-2xl font-semibold tracking-tight first:mt-0',
        className,
      )}
    />
  )
}

function H3({ className, ...props }: ComponentPropsWithoutRef<'h3'>) {
  return (
    <h3
      {...props}
      className={mergeClasses(
        'agentblog-heading text-foreground mt-8 mb-3 scroll-mt-24 text-xl font-semibold tracking-tight first:mt-0',
        className,
      )}
    />
  )
}

function H4({ className, ...props }: ComponentPropsWithoutRef<'h4'>) {
  return (
    <h4
      {...props}
      className={mergeClasses(
        'agentblog-heading text-foreground mt-6 mb-2 scroll-mt-24 text-base font-semibold first:mt-0',
        className,
      )}
    />
  )
}

/**
 * Anything with a scheme (`https:`, `mailto:`, `tel:`), a protocol-relative
 * `//host/path`, or a bare fragment belongs to somewhere this block does not
 * route. Everything else that starts with a single `/` is an internal,
 * root-relative path and is the trailing-slash policy's business.
 *
 * A relative href such as `./sibling` is left alone deliberately: resolving it
 * needs the URL of the page the post is rendered on, and `renderMdx` does not
 * know that by design.
 */
const HAS_ITS_OWN_DESTINATION = /^(?:[a-z][a-z0-9+.-]*:|\/\/|#)/i

/**
 * Links.
 *
 * External links open in a new tab and carry `rel="noopener noreferrer"`.
 * Internal ones do not, because opening your own site in a new tab is a
 * decision the reader did not ask for.
 *
 * `noopener` matters: without it the opened page can reach back through
 * `window.opener`. `noreferrer` goes with it so the target sees no referrer.
 *
 * Outbound citations are an asset rather than a leak. The GEO paper measures a
 * 30 to 40% band for citing sources, so this component is built to make them
 * comfortable to write, not to discourage them.
 *
 * ---------------------------------------------------------------------------
 * WHY AN INTERNAL HREF GOES THROUGH `sitePath()`
 * ---------------------------------------------------------------------------
 * MDX is the one surface where a href is typed by hand, in a `.mdx` file, by
 * somebody who is thinking about the sentence rather than about
 * `config.trailingSlash`. Every other internal link in the block comes from a
 * `@/lib/config` helper, so it is the only place the policy could not be
 * applied, and nothing lints for it.
 *
 * On a `trailingSlash: true` site an authored `/blog/some-post` is a 308 to
 * `/blog/some-post/` on every in-article link. AI crawlers frequently do not
 * follow those, which loses exactly the internal link graph the citations are
 * there to build. `sitePath` splits a query or fragment off before it
 * normalises, so `/blog/post#section` survives as `/blog/post/#section`.
 */
function MdxLink({ href, className, children, ...props }: ComponentPropsWithoutRef<'a'>) {
  const resolved =
    href === undefined || HAS_ITS_OWN_DESTINATION.test(href) || !href.startsWith('/')
      ? href
      : sitePath(href)
  const isExternal = resolved !== undefined && /^https?:\/\//i.test(resolved)

  return (
    <a
      {...(resolved !== undefined ? { href: resolved } : {})}
      {...(isExternal ? { target: '_blank', rel: 'noopener noreferrer' } : {})}
      {...props}
      className={mergeClasses(
        'text-primary font-medium underline underline-offset-4 hover:no-underline',
        className,
      )}
    >
      {children}
    </a>
  )
}

/**
 * The markdown `>` blockquote. Attribution is unavailable in markdown syntax,
 * so this path renders the unattributed form. Authors who can name a source
 * should write `<Quote source="..." cite="...">` instead, which is measurably
 * more effective.
 */
function MdxBlockquote({ children, cite }: ComponentPropsWithoutRef<'blockquote'>) {
  return <Quote {...(cite !== undefined ? { cite } : {})}>{children}</Quote>
}

function Hr({ className, ...props }: ComponentPropsWithoutRef<'hr'>) {
  return <hr {...props} className={mergeClasses('border-border my-10', className)} />
}

export const mdxComponents = {
  /* Element overrides */
  h2: H2,
  h3: H3,
  h4: H4,
  a: MdxLink,
  hr: Hr,
  img: MdxImage,
  pre: CodeBlock,
  code: InlineCode,
  blockquote: MdxBlockquote,
  table: Table,
  thead: Thead,
  tbody: Tbody,
  tr: Tr,
  th: Th,
  td: Td,

  /* Author-usable components */
  Callout,
  Faq,
  Figure,
  KeyTakeaways,
  Quote,
  Stat,
}

export default mdxComponents
registry/blog/components/mdx/callout.tsx
/**
 * `<Callout>` - a labelled aside inside an article.
 *
 * ---------------------------------------------------------------------------
 * WHY THE LABEL IS TEXT AND NOT AN ICON
 * ---------------------------------------------------------------------------
 * A callout carries meaning ("this one will bite you") and that meaning has to
 * survive being read as plain text, because that is how every AI crawler reads
 * it. An icon carries nothing to a text extractor, and an icon plus
 * `aria-label` still leaves the visible text unlabelled in the extracted chunk.
 * So the variant renders a real word, in the markup, above the content.
 *
 * It also keeps this component free of an icon-library import, which matters
 * because the consumer may be on Tabler or Phosphor rather than Lucide.
 *
 * ---------------------------------------------------------------------------
 * THEMING
 * ---------------------------------------------------------------------------
 * Every variant is built from the consumer's own tokens. `warning` is the only
 * one that reaches for a second colour, and it uses `destructive`, which every
 * shadcn theme defines. There is no amber here, because there is no amber in
 * the token set and inventing one would make the blog stop looking like the
 * product it is bolted onto.
 *
 * @see https://docs.agentblog.dev/guides/match-your-design#only-semantic-tokens-never-a-palette-utility
 */
import type { ReactNode } from 'react'

export type CalloutVariant = 'note' | 'tip' | 'important' | 'warning'

export interface CalloutProps {
  // 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:
  // `<Callout title={maybeUndefined}>`. @see components/blog/prose.tsx
  /** Defaults to `note`. */
  readonly variant?: CalloutVariant | undefined
  /** Overrides the default label text. Keep it to one or two words. */
  readonly title?: string | undefined
  readonly children: ReactNode
}

const LABELS: Record<CalloutVariant, string> = {
  note: 'Note',
  tip: 'Tip',
  important: 'Important',
  warning: 'Warning',
}

const ACCENTS: Record<CalloutVariant, string> = {
  note: 'border-l-border',
  tip: 'border-l-primary',
  important: 'border-l-primary',
  warning: 'border-l-destructive',
}

const LABEL_TONE: Record<CalloutVariant, string> = {
  note: 'text-muted-foreground',
  tip: 'text-foreground',
  important: 'text-foreground',
  warning: 'text-destructive',
}

export function Callout({ variant = 'note', title, children }: CalloutProps) {
  return (
    <aside
      className={`border-border bg-muted my-8 rounded-xl border border-l-4 px-5 py-4 ${ACCENTS[variant]}`}
    >
      <p
        className={`mt-0 mb-2 text-xs font-medium tracking-wider uppercase ${LABEL_TONE[variant]}`}
      >
        {title ?? LABELS[variant]}
      </p>
      {/*
       * `[&>*]:my-0` then `[&>*+*]:mt-3`, rather than a bottom margin on
       * paragraphs. Everything in here is inside `.prose`, so the typography
       * plugin has already given every `<p>` and `<ul>` a 1.25em margin on
       * BOTH sides; overriding only the bottom leaves the first child pushed
       * 21px down from the label above it. Zero them and re-space between
       * siblings instead. Lists and links keep their prose styling, which is
       * why this is not `not-prose`.
       */}
      <div className="text-foreground text-sm leading-relaxed [&>*]:my-0 [&>*+*]:mt-3">
        {children}
      </div>
    </aside>
  )
}

export default Callout
registry/blog/components/mdx/code-block.tsx
/**
 * Code rendering for MDX. Server Components, both of them.
 *
 * ---------------------------------------------------------------------------
 * THERE IS NO CLIENT-SIDE HIGHLIGHTER HERE, AND THERE MUST NOT BE ONE
 * ---------------------------------------------------------------------------
 * `rehype-pretty-code` runs Shiki during compilation in `lib/render-mdx.tsx`, so by
 * the time this component exists the code has already been tokenised into
 * spans, each carrying `--shiki-light` and `--shiki-dark` custom properties.
 * These components add layout and nothing else.
 *
 * That means zero highlighting JavaScript reaches the browser, and, more to the
 * point, the code is fully present in the server-rendered HTML. A crawler that
 * does not execute JavaScript reads your snippets. A client-side highlighter
 * would ship roughly a megabyte of grammars to render text the server already
 * had.
 *
 * If you want a copy button, it belongs in its own small client component
 * rendered *beside* the `<pre>`. The code itself stays server-rendered. Do not
 * turn this file into a client component to get one.
 *
 * ---------------------------------------------------------------------------
 * BLOCK CODE VERSUS INLINE CODE
 * ---------------------------------------------------------------------------
 * MDX maps both to `code`. The difference is the parent: block code sits inside
 * a `<pre>`, inline code does not. Rather than sniff for that at runtime, the
 * `pre` wrapper resets the styles its child `code` would otherwise inherit, so
 * `InlineCode` can style itself unconditionally and both cases come out right.
 *
 * @see https://docs.agentblog.dev/reference/files
 */
import type { ComponentPropsWithoutRef } from 'react'

/**
 * The block. `rehype-pretty-code` wraps this in a
 * `<figure data-rehype-pretty-code-figure>` and may put a `<figcaption>` title
 * above it, both styled in `styles/agentblog.css`.
 *
 * `keepBackground: false` in `lib/render-mdx.tsx` is why `bg-muted` here
 * actually shows: Shiki's own surface colour has been stripped, so the block
 * inherits the consumer's theme instead of GitHub's.
 *
 * `tabIndex={0}` is not optional. `overflow-x-auto` on a non-focusable element
 * gives a mouse user a scrollbar and a keyboard-only user no way to reach the
 * end of a long line, which is a WCAG 2.1.1 (Keyboard) failure. With the block
 * in the tab order the arrow keys scroll it. The code itself is still server
 * rendered: this changes focus behaviour and nothing about the markup a crawler
 * reads.
 *
 * ---------------------------------------------------------------------------
 * WHY THE FOCUSABLE `<pre>` CARRIES A ROLE AND A NAME
 * ---------------------------------------------------------------------------
 * The implicit ARIA role of `<pre>` is `generic`, and `generic` is one of the
 * roles that prohibits `aria-label` outright: a name set on it is not exposed.
 * So a `<pre tabindex="0">` on its own is a stop in the tab order that a screen
 * reader announces with no name at all, and the reader has to start reading
 * source code to find out where they landed.
 *
 * `role="group"` is what makes the name exposable. Deliberately not
 * `role="region"`: a region is a landmark, and a post with six snippets would
 * put six of them in the landmark list beside the article's real sections.
 *
 * The name includes the language because `data-language`, written by
 * `rehype-pretty-code` from the fence info string, is the one fact about the
 * block that is already true and needs no author to maintain it.
 */
interface CodeBlockProps extends ComponentPropsWithoutRef<'pre'> {
  /** Written onto the `<pre>` by `rehype-pretty-code` from the fence info string. */
  readonly 'data-language'?: string | undefined
}

export function CodeBlock({ className, ...props }: CodeBlockProps) {
  const language = props['data-language']

  return (
    <pre
      aria-label={
        language === undefined || language === '' ? 'Code block' : `Code block, ${language}`
      }
      role="group"
      tabIndex={0}
      {...props}
      className={merge(
        'border-border bg-muted focus-visible:ring-ring my-8 overflow-x-auto rounded-xl border p-4 text-sm leading-relaxed focus-visible:ring-2 focus-visible:outline-none [&_code]:bg-transparent [&_code]:p-0 [&_code]:font-normal [&_code]:text-inherit',
        className,
      )}
    />
  )
}

/**
 * Inline code. The `[&_code]` resets on `CodeBlock` above neutralise these
 * styles when the same element appears inside a `<pre>`.
 */
export function InlineCode({ className, ...props }: ComponentPropsWithoutRef<'code'>) {
  return (
    <code
      {...props}
      className={merge(
        'border-border bg-muted text-foreground rounded border px-[0.35em] py-[0.15em] font-mono text-[0.875em]',
        className,
      )}
    />
  )
}

/**
 * Join the component's own classes with whatever the pipeline passed in.
 * Written out rather than imported from `lib/utils.ts`, so the MDX components
 * stay installable without `cn()`. See the header of `components/mdx/index.tsx`
 * for why replacing an incoming `className` is a bug rather than a style.
 */
function merge(own: string, incoming: string | undefined): string {
  return incoming === undefined || incoming === '' ? own : `${own} ${incoming}`
}
registry/blog/components/mdx/faq.tsx
/**
 * `<Faq>` - question and answer pairs, rendered with `<details>` and CSS.
 *
 * ---------------------------------------------------------------------------
 * THE RULE THIS COMPONENT EXISTS TO ENFORCE
 * ---------------------------------------------------------------------------
 * `lib/schema.ts` emits `FAQPage` JSON-LD from `post.faq`. Google's structured
 * data policy permits that markup only when the questions and answers are
 * genuinely visible to a reader on the page. Marking up FAQs that are not
 * rendered is a guidelines violation, and it is the most common way a blog
 * trips one.
 *
 * So the coupling is deliberate and it runs in one direction: the post route
 * renders this component from the same `post.faq` array that `buildFaq()` in
 * `lib/schema.ts` serialises. If you stop rendering the FAQ section, stop
 * emitting the schema in the same commit.
 *
 * ---------------------------------------------------------------------------
 * WHY `<details>` AND NEVER A CONDITIONAL MOUNT
 * ---------------------------------------------------------------------------
 * A React accordion that mounts its panel on click puts the answers nowhere in
 * the HTML. Every AI crawler reads the raw HTML your server returned and does
 * not click anything, so a JavaScript-mounted FAQ is invisible to exactly the
 * readers the schema was for, while the schema still claims the content is
 * visible. That combination is the worst of both.
 *
 * `<details>` collapses with CSS. The answer text is in the document either
 * way, closed or open, which is what makes the markup honest. This is the same
 * reason `TableOfContents` avoids conditional mounting.
 *
 * @see https://docs.agentblog.dev/concepts/geo-playbook
 * @see lib/schema.ts, `buildFaq`
 */
import { CLUSTER_GAP, SECTION_HEADING } from '@/components/blog/type-scale'
import type { FaqEntry } from '@/lib/types'
import { cn } from '@/lib/utils'

/**
 * The section heading text.
 *
 * `lib/render-mdx.tsx` passes this string to `remarkExtractToc` as a
 * `trailingHeadings` entry, which is what gets an id issued for it by the same
 * slugger that issued every body heading id. So this constant is not only the
 * visible text: it is the input the id is derived from, and changing it changes
 * the anchor.
 *
 * @see lib/mdx-plugins/remark-extract-toc.ts, `trailingHeadings`
 */
export const FAQ_HEADING = 'Frequently asked questions'

/**
 * The id this heading gets when nobody allocated one for it.
 *
 * It is `github-slugger`'s slug of `FAQ_HEADING`, so it is the id the
 * allocation produces in the ordinary case where no body heading claimed the
 * name first. Keep the two in step.
 *
 * The post route does NOT use this. It passes `faqHeadingId` from `renderMdx`,
 * which is collision-checked against the body. The previous value here was the
 * hardcoded `'faq'`, which the slugger had never issued: a post with a body H2
 * titled "FAQ" then shipped two `<h2 id="faq">` and a contents link that landed
 * on the wrong one. Do not reintroduce a short, guessable id here.
 */
export const FAQ_HEADING_ID = 'frequently-asked-questions'

export interface FaqProps {
  readonly items: readonly FaqEntry[]
  /** Section heading, rendered as an H2. */
  readonly heading?: string | undefined
  /**
   * `id` on the H2. Every H2 and H3 in this block carries a stable id, because
   * an anchor a reader can link to is worth more than the markup costs.
   *
   * The post route passes `faqHeadingId` from `renderMdx`, which is the only id
   * that has been checked against the post's own body headings. Pass something
   * else only when you know it is unused on the page: two elements sharing an
   * id makes both anchors ambiguous, and the browser resolves `#id` to the
   * first one in document order.
   */
  readonly headingId?: string | undefined
  /**
   * Render every entry expanded. Closed is the default and costs nothing for
   * extraction, since the text is in the HTML regardless.
   */
  readonly defaultOpen?: boolean | undefined
}

export function Faq({
  items,
  heading = FAQ_HEADING,
  headingId = FAQ_HEADING_ID,
  defaultOpen = false,
}: FaqProps) {
  if (items.length === 0) return null

  return (
    // No outer margin. The post route composes this into a flex column with the
    // rest of the article, and a margin here would add to that gap rather than
    // replace it. See `components/blog/type-scale.ts`.
    <section className={CLUSTER_GAP}>
      <h2 className={cn(SECTION_HEADING, 'scroll-mt-24')} id={headingId}>
        {heading}
      </h2>
      <div className="divide-border border-border divide-y overflow-hidden rounded-xl border">
        {items.map((item) => (
          <details key={item.question} open={defaultOpen} className="agentblog-faq group">
            <summary className="text-foreground hover:bg-muted flex cursor-pointer list-none items-center justify-between gap-4 px-5 py-4 text-base font-medium transition-colors">
              {item.question}
              <span
                aria-hidden="true"
                className="text-muted-foreground shrink-0 transition-transform group-open:rotate-45"
              >
                +
              </span>
            </summary>
            <div className="text-muted-foreground px-5 pb-5 text-sm leading-relaxed text-pretty">
              {item.answer}
            </div>
          </details>
        ))}
      </div>
    </section>
  )
}

export default Faq
registry/blog/components/mdx/figure.tsx
/**
 * `<Figure>` - an image with a caption, plus the `img` adapter for MDX.
 *
 * ---------------------------------------------------------------------------
 * WHY A FIGURE AND NOT A BARE IMAGE
 * ---------------------------------------------------------------------------
 * `alt` says what the image *is*. `<figcaption>` says what it *means*, and it
 * is the part that survives into an extracted chunk as ordinary prose. An
 * original chart of your own data is one of the stronger assets a post can
 * carry, and it is worth roughly nothing to a machine unless the numbers exist
 * in HTML nearby. Pair a chart with the table that produced it.
 *
 * If critical text lives only inside the image, a crawler that does not run OCR
 * never sees it. Mirror it in the caption or the body.
 *
 * ---------------------------------------------------------------------------
 * DIMENSIONS, AND WHY THEY ARE NOT OPTIONAL IN PRACTICE
 * ---------------------------------------------------------------------------
 * `next/image` needs intrinsic dimensions to reserve space, and space it does
 * not reserve is a layout shift. Markdown's `![alt](src)` carries no
 * dimensions, so `MdxImage` falls back to a 16:9 box. Authors who care about
 * CLS should use `<Figure width height />` explicitly.
 *
 * `preload` (not `priority`, which is deprecated in Next 16) belongs on at most
 * one image per route, and only when that image is definitively the LCP
 * element. Setting it on three images preloads three and prioritises none.
 *
 * Remote sources need `images.remotePatterns` in `next.config.ts`. `agentblog
 * doctor` checks for it.
 *
 * @see https://docs.agentblog.dev/concepts/geo-playbook*/
import Image from 'next/image'
import type { ComponentPropsWithoutRef, ReactNode } from 'react'

const FALLBACK_WIDTH = 1600
const FALLBACK_HEIGHT = 900

/** Article measure at desktop, full viewport below it. Keeps srcset honest. */
const SIZES = '(min-width: 768px) 720px, 100vw'

export interface FigureProps {
  readonly src: string
  /** Describe what the image shows, in plain language. Never leave it empty. */
  readonly alt: string
  readonly caption?: ReactNode
  readonly width?: number
  readonly height?: number
  /** At most one per route, and only for the LCP image. */
  readonly preload?: boolean
}

export function Figure({ src, alt, caption, width, height, preload }: FigureProps) {
  return (
    <figure className="my-8">
      <Image
        src={src}
        alt={alt}
        width={width ?? FALLBACK_WIDTH}
        height={height ?? FALLBACK_HEIGHT}
        sizes={SIZES}
        className="border-border h-auto w-full rounded-xl border"
        {...(preload === true ? { preload: true } : {})}
      />
      {caption !== undefined && (
        <figcaption className="text-muted-foreground mt-3 text-sm leading-relaxed">
          {caption}
        </figcaption>
      )}
    </figure>
  )
}

/**
 * The `img` mapping for MDX.
 *
 * Markdown gives us `src`, `alt`, and the title in `![alt](src "title")`. The
 * title becomes the caption, which is the closest markdown gets to a
 * `<figcaption>` without dropping to JSX.
 */
export function MdxImage({ src, alt, title, width, height }: ComponentPropsWithoutRef<'img'>) {
  // MDX types `src` as `string | Blob | undefined`. Anything but a string is
  // not something `next/image` can render, and rendering nothing is better than
  // rendering a broken box.
  if (typeof src !== 'string' || src.length === 0) return null

  const parsedWidth = toDimension(width)
  const parsedHeight = toDimension(height)

  return (
    <Figure
      src={src}
      // An empty alt is an accessibility failure that `agentblog audit` reports.
      // It is not this component's job to invent one.
      alt={alt ?? ''}
      {...(title !== undefined && title.length > 0 ? { caption: title } : {})}
      {...(parsedWidth !== undefined ? { width: parsedWidth } : {})}
      {...(parsedHeight !== undefined ? { height: parsedHeight } : {})}
    />
  )
}

function toDimension(value: string | number | undefined): number | undefined {
  if (value === undefined) return undefined
  const n = typeof value === 'number' ? value : Number.parseInt(value, 10)
  return Number.isFinite(n) && n > 0 ? n : undefined
}

export default Figure
registry/blog/components/mdx/key-takeaways.tsx
/**
 * `<KeyTakeaways>` - the summary block near the top of a long post.
 *
 * ---------------------------------------------------------------------------
 * WHY THIS IS A REAL `<ul>` INSIDE AN `<aside>`
 * ---------------------------------------------------------------------------
 * A retrieval system splits a page into chunks and embeds each one. A short
 * list of self-contained statements is close to the ideal chunk: one idea per
 * item, no pronouns pointing at context that was cut away, and the highest
 * value sentence first. That is why the markup is a list rather than a styled
 * paragraph, and why each item should be a full sentence that survives on its
 * own.
 *
 * The writing rule that goes with it: repeat the entity name instead of using
 * "it". A takeaway that reads "It caches by default" is worthless once the
 * chunk that named the subject is gone.
 *
 * This block is not a replacement for the answer capsule. The capsule answers
 * the post's question in 40 to 60 words of prose. This lists what the reader
 * will know by the end.
 *
 * @see https://docs.agentblog.dev/concepts/geo-playbook*/
import type { ReactNode } from 'react'

export interface KeyTakeawaysProps {
  /** One complete sentence per item. Three to six items reads best. */
  readonly items: readonly ReactNode[]
  // `?: T | undefined`, not `?: T`. Under `exactOptionalPropertyTypes` the plain
  // form rejects `<KeyTakeaways heading={maybeUndefined} />`.
  // @see components/blog/prose.tsx
  readonly heading?: string | undefined
}

export function KeyTakeaways({ items, heading = 'Key takeaways' }: KeyTakeawaysProps) {
  if (items.length === 0) return null

  return (
    <aside className="not-prose border-border my-8 rounded-xl border px-6 py-5">
      <p className="text-muted-foreground mb-3 text-xs font-medium tracking-wider uppercase">
        {heading}
      </p>
      <ul className="text-foreground marker:text-muted-foreground list-disc space-y-2 pl-5 text-sm leading-relaxed">
        {items.map((item, index) => (
          // Takeaways are a fixed, ordered, author-written list that never
          // reorders between renders, so the index is a stable key.
          <li key={index}>{item}</li>
        ))}
      </ul>
    </aside>
  )
}

export default KeyTakeaways
registry/blog/components/mdx/quote.tsx
/**
 * `<Quote>` - a quotation, ideally from a named source.
 *
 * ---------------------------------------------------------------------------
 * WHY THIS COMPONENT IS WORTH CARING ABOUT
 * ---------------------------------------------------------------------------
 * Adding quotations from named sources is the highest-yield single change
 * measured in the GEO paper (Aggarwal et al., ACM KDD 2024): roughly +41% on
 * citation visibility, and around +22% on Perplexity specifically. That figure
 * was measured on 2023 models and is directional rather than a guarantee, but
 * the mechanism is durable. A quotation with an attributed speaker is a piece
 * of evidence an engine can pass through; an unattributed one is just your
 * prose in italics.
 *
 * So `cite` and `source` are the reason this component exists. A `<blockquote>`
 * with no attribution renders fine, but it is leaving the effect on the table.
 *
 * ---------------------------------------------------------------------------
 * MARKUP
 * ---------------------------------------------------------------------------
 * `<figure><blockquote>` plus `<figcaption>` is the correct pairing. Attribution
 * inside the `<blockquote>` would claim the speaker's name is part of what they
 * said. `cite` on the blockquote points at the source document, and the visible
 * link is what a reader and a text extractor actually follow.
 *
 * The markdown `>` blockquote maps here too, without attribution, which is why
 * every prop is optional.
 *
 * @see https://docs.agentblog.dev/concepts/geo-playbook*/
import type { ReactNode } from 'react'

export interface QuoteProps {
  readonly children: ReactNode
  // `?: T | undefined`, not `?: T`. Under `exactOptionalPropertyTypes` the plain
  // form rejects `<Quote source={maybeUndefined}>`, which is exactly how an MDX
  // wrapper passes an attribution it may not have.
  // @see components/blog/prose.tsx
  /** Who said it. This is the part that carries the measured effect. */
  readonly source?: string | undefined
  /** Their role or publication, shown after the name. */
  readonly context?: string | undefined
  /** URL of the document being quoted. Sets `cite` and links the attribution. */
  readonly cite?: string | undefined
}

export function Quote({ children, source, context, cite }: QuoteProps) {
  const attribution = source ?? context

  return (
    <figure className="my-8">
      <blockquote
        {...(cite !== undefined ? { cite } : {})}
        className="border-border text-foreground border-l-2 pl-6 text-xl leading-relaxed text-pretty italic [&>*:last-child]:mb-0"
      >
        {children}
      </blockquote>
      {attribution !== undefined && (
        <figcaption className="text-muted-foreground mt-3 pl-6 text-sm not-italic">
          {source !== undefined &&
            (cite !== undefined ? (
              <a
                href={cite}
                target="_blank"
                rel="noopener noreferrer"
                className="text-foreground font-medium underline underline-offset-4"
              >
                {source}
              </a>
            ) : (
              <span className="text-foreground font-medium">{source}</span>
            ))}
          {context !== undefined && (
            <span>
              {source !== undefined ? ', ' : ''}
              {context}
            </span>
          )}
        </figcaption>
      )}
    </figure>
  )
}

export default Quote
registry/blog/components/mdx/stat.tsx
/**
 * `<Stat>` - one number, labelled and sourced.
 *
 * ---------------------------------------------------------------------------
 * WHY A COMPONENT FOR A NUMBER
 * ---------------------------------------------------------------------------
 * Adding statistics is the second strongest change measured in the GEO paper
 * (Aggarwal et al., ACM KDD 2024): about +31% on position-adjusted word count,
 * and around +37% on Perplexity. Directional, measured on 2023 models, and
 * still the cheapest edit available to most posts.
 *
 * The component exists to make the *sourced* version the easy one to write. An
 * unsourced number is a claim; a number with an attributed source is evidence,
 * and evidence is what gets quoted. `source` and `href` are optional in the
 * type and mandatory in practice.
 *
 * For more than two or three related numbers, use a real `<table>` instead. A
 * table states the relationship between the numbers, which a row of stat blocks
 * only implies.
 *
 * ---------------------------------------------------------------------------
 * MARKUP
 * ---------------------------------------------------------------------------
 * `<figure>` with the value in a `<p>` and the label in `<figcaption>`. The
 * value is deliberately not a heading: it is not a section boundary, and
 * putting it in an H2 would corrupt the document outline and the table of
 * contents built from it.
 *
 * @see https://docs.agentblog.dev/concepts/geo-playbook*/
export interface StatProps {
  /** The number as it should read, units included. For example `2.5x` or `72.4%`. */
  readonly value: string
  /** What the number measures. A full clause reads better than a fragment. */
  readonly label: string
  // `?: T | undefined`, not `?: T`. Under `exactOptionalPropertyTypes` the plain
  // form rejects `<Stat source={maybeUndefined} />`.
  // @see components/blog/prose.tsx
  /** Who reported it. */
  readonly source?: string | undefined
  /** Where it was reported. Turns `source` into a link. */
  readonly href?: string | undefined
}

export function Stat({ value, label, source, href }: StatProps) {
  return (
    <figure className="border-border my-8 border-y py-6">
      <p className="text-foreground text-4xl font-semibold tracking-tight tabular-nums">{value}</p>
      <figcaption className="text-muted-foreground mt-2 text-sm leading-relaxed text-pretty">
        {label}
        {source !== undefined && (
          <>
            {' '}
            <span className="text-muted-foreground">
              (
              {href !== undefined ? (
                <a
                  href={href}
                  target="_blank"
                  rel="noopener noreferrer"
                  className="underline underline-offset-4"
                >
                  {source}
                </a>
              ) : (
                source
              )}
              )
            </span>
          </>
        )}
      </figcaption>
    </figure>
  )
}

export default Stat
registry/blog/components/mdx/table.tsx
/**
 * Table primitives for MDX.
 *
 * ---------------------------------------------------------------------------
 * WHY COMPARISON AND SPECIFICATION DATA BELONGS IN A REAL TABLE
 * ---------------------------------------------------------------------------
 * The same five rows written as prose and written as a `<table>` are not the
 * same content to a retrieval system. A table gives every cell an explicit row
 * and column, so "what is the price of the Pro plan" resolves to one cell,
 * while the prose version requires the model to reconstruct a relationship the
 * markup never stated. Industry measurements put tables at roughly 2.5 to 4.2x
 * the citation rate of the equivalent prose (correlational, but the mechanism
 * is not mysterious).
 *
 * So: `<thead>` and `<tbody>` are real, headers are `<th>` with a scope, and
 * the table is never replaced by a grid of divs. `remark-gfm` in
 * `lib/render-mdx.tsx` is what lets an author write pipe tables in MDX and get
 * this markup out.
 *
 * One writing rule that belongs with the component: a table has to be readable
 * where it sits. Do not rely on a caption two sections earlier to explain what
 * the columns mean, because the chunk that gets retrieved is the table.
 *
 * ---------------------------------------------------------------------------
 * WHY THE WRAPPER IS A DIV AND NOT `overflow` ON THE TABLE
 * ---------------------------------------------------------------------------
 * A table cannot scroll itself. The wrapper is what keeps a six-column
 * comparison from forcing horizontal scroll on the whole page at 375px, and it
 * is inside the article, so the table markup a crawler reads is untouched.
 *
 * ---------------------------------------------------------------------------
 * WHY THE WRAPPER IS FOCUSABLE
 * ---------------------------------------------------------------------------
 * `overflow-x-auto` on a plain `<div>` gives a mouse user a scrollbar and gives
 * a keyboard-only user nothing: there is no way to move focus into the region,
 * so the columns past the right edge are unreachable. That is a WCAG 2.1.1
 * (Keyboard) failure, and it is invisible in review because the table looks
 * fine on a desktop viewport.
 *
 * `tabIndex={0}` puts the scroll container in the tab order, at which point the
 * arrow keys scroll it.
 *
 * ---------------------------------------------------------------------------
 * WHY `role="region"` IS CONDITIONAL
 * ---------------------------------------------------------------------------
 * A region is a landmark, and a landmark is only useful if it has a name a
 * reader can tell apart from the others. This component used to default
 * `aria-label` to the literal string `'Table'`, and since a pipe table written
 * in MDX never passes `label`, a post with two tables shipped two landmarks
 * both announced as "Table". That is worse than no landmark: it adds two
 * indistinguishable stops to the landmark list and tells the reader nothing.
 *
 * So the name is derived, never invented. An explicit `label` wins; failing
 * that, a `<caption>` inside the table supplies it, which is the element that
 * exists for exactly this purpose and which also names the table visibly, so
 * the visible name and the accessible one cannot drift. With no name available,
 * `role="region"` and `aria-label` are both omitted and the wrapper stays a
 * plain focusable scroll container: the `<table>` inside it is still announced
 * as a table with its own row and column semantics, so nothing about the
 * content becomes unreachable.
 *
 * `tabIndex` stays on unconditionally, because WCAG 2.1.1 does not care whether
 * the container has a name.
 *
 * BE CLEAR ABOUT WHICH BRANCH A POST TAKES. A `remark-gfm` pipe table, which is
 * how every table in a `.mdx` file is written, produces `thead` and `tbody` and
 * nothing else, and it has no syntax for passing a prop. Writing literal
 * `<table>` JSX in MDX does not help either: `components` is applied to
 * markdown constructs only, and `@mdx-js/mdx@3` compiles a lowercase JSX tag
 * straight to the intrinsic element, so such a table never reaches this file at
 * all. An MDX-authored table therefore always takes the unnamed branch, on
 * purpose. `label` and `<caption>` are for `<Table>` rendered from your own
 * TSX.
 *
 * @see https://docs.agentblog.dev/concepts/geo-playbook
 */
import { Children, isValidElement } from 'react'
import type { ComponentPropsWithoutRef, ReactNode } from 'react'

export interface TableProps extends ComponentPropsWithoutRef<'table'> {
  /**
   * Accessible name for the scrollable region around the table. Set it when a
   * post has several tables and none of them carry a `<caption>`, so the
   * landmarks are distinguishable.
   */
  readonly label?: string | undefined
}

export function Table({ label, className, children, ...props }: TableProps) {
  const name = label ?? captionText(children)

  return (
    <div
      /*
       * `not-prose` on the wrapper, not on the table.
       *
       * `@tailwindcss/typography` gives every `<table>` a 2em block margin. Ours
       * is inside a bordered panel, so that margin rendered as an unexplained
       * 40px strip between the panel's top border and the header row, on every
       * table in every post. Overriding it with a `my-0` utility is a
       * specificity race against a plugin-generated `:where()` rule, and it is
       * the wrong fix anyway: `Thead`, `Tbody`, `Tr`, `Th`, and `Td` below all
       * carry their own classes, so there is nothing in here the plugin should
       * be styling at all.
       */
      className="not-prose border-border focus-visible:ring-ring my-8 w-full overflow-x-auto rounded-xl border focus-visible:ring-2 focus-visible:outline-none"
      // Both attributes or neither. See "WHY `role=\"region\"` IS CONDITIONAL".
      {...(name === undefined ? {} : { role: 'region', 'aria-label': name })}
      tabIndex={0}
    >
      <table {...props} className={merge('w-full border-collapse text-left text-sm', className)}>
        {children}
      </table>
    </div>
  )
}

/**
 * The text of a `<caption>` among the table's children, if there is one.
 *
 * A caption written in TSX arrives here as a React element whose `type` is the
 * string `'caption'`. Reading it rather than asking for a second `label` is
 * what keeps the visible name and the accessible name the same string. See the
 * header for why an MDX-authored table never reaches this function.
 */
function captionText(children: ReactNode): string | undefined {
  for (const child of Children.toArray(children)) {
    if (!isValidElement(child) || child.type !== 'caption') continue
    const text = plainText((child.props as { children?: ReactNode }).children).trim()
    return text.length > 0 ? text : undefined
  }
  return undefined
}

/** The visible text of a React subtree. Enough for a caption, and no more. */
function plainText(node: ReactNode): string {
  if (typeof node === 'string') return node
  if (typeof node === 'number') return String(node)
  if (Array.isArray(node)) return (node as ReactNode[]).map(plainText).join('')
  if (isValidElement(node)) return plainText((node.props as { children?: ReactNode }).children)
  return ''
}

/**
 * The row and cell primitives all merge `className` rather than replacing it,
 * for the reason set out in the header of `components/mdx/index.tsx`: a
 * component that writes its own `className` after `{...props}` discards
 * whatever the pipeline sent, and the pipeline does send one. Nothing sends one
 * to these particular elements today, and that is not a reason to leave the
 * trap armed in five more places.
 */
function merge(own: string, incoming: string | undefined): string {
  return incoming === undefined || incoming === '' ? own : `${own} ${incoming}`
}

export function Thead({ className, ...props }: ComponentPropsWithoutRef<'thead'>) {
  return <thead {...props} className={merge('bg-muted text-muted-foreground', className)} />
}

export function Tbody({ className, ...props }: ComponentPropsWithoutRef<'tbody'>) {
  return <tbody {...props} className={merge('divide-border divide-y', className)} />
}

export function Tr({ className, ...props }: ComponentPropsWithoutRef<'tr'>) {
  return <tr {...props} className={merge('border-border border-b last:border-0', className)} />
}

/**
 * `scope` is set unconditionally. A header cell without one is ambiguous to a
 * screen reader in any table wider than two columns, and `remark-gfm` does not
 * emit it.
 */
export function Th({ className, ...props }: ComponentPropsWithoutRef<'th'>) {
  return (
    <th
      scope="col"
      {...props}
      className={merge('text-foreground px-4 py-3 align-top text-sm font-semibold', className)}
    />
  )
}

export function Td({ className, ...props }: ComponentPropsWithoutRef<'td'>) {
  return <td {...props} className={merge('text-foreground px-4 py-3 align-top', className)} />
}

More from agentblog

Browse all components in this library →