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 · lightRegistry 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 `` 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 ``. 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)} />
}