shadimage
agentblog
libblog-coresource: ok · refreshed

AgentBlog core

The library layer every other AgentBlog item builds on: the typed config, the content source facade, shared metadata defaults that survive Next.js's shallow metadata merge, the AI referrer classifier, and the build-time preflight that warns when next.config is missing the wiring a registry cannot write.

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

Where it goes

blog-list

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

Dependencies

Bundle weight · light
formszod

Files13 · source cached 10/7/2026, 10:16:22 PM

registry/blog/agentblog.config.ts
/**
 * agentblog.config.ts
 *
 * ---------------------------------------------------------------------------
 * THE ONE FILE YOU EDIT
 * ---------------------------------------------------------------------------
 * Everything else AgentBlog installed reads from here through `@/lib/config`.
 * Canonical URLs, the sitemap, `robots.txt`, RSS, every JSON-LD `@id`, and the
 * Open Graph cards are all composed from these values, so a wrong value here is
 * wrong in roughly forty places at once.
 *
 * This file must sit at the **project root**, next to `package.json` and
 * `next.config.ts`, including in a `src/` layout. Next.js documents that config
 * files stay at the root, and the registry writes this one with a `~/` target,
 * which means project root by definition.
 *
 * Values are validated once, at module scope, by `resolveConfig` inside
 * `lib/config.ts`. A bad value fails the build with a message naming the key
 * rather than producing `undefined` inside a JSON-LD graph three files later.
 *
 * ---------------------------------------------------------------------------
 * FOR A CODING AGENT EDITING THIS FILE
 * ---------------------------------------------------------------------------
 * Change values, not structure. Do not add fields that are not documented below;
 * `resolveConfig` ignores unknown keys, so an invented field is silently dead.
 * Do not import from this file anywhere: `lib/config.ts` is the only module in
 * the block permitted to read it, and `agentblog doctor` enforces that.
 */
import { defineConfig } from '@/lib/define-config'
import { mdxSource } from '@/lib/sources/mdx'

/**
 * The author a post falls back to when its frontmatter omits `author`.
 *
 * **This value must be a slug that exists in `content/authors.json`.** It ships
 * as `your-name`, which is a placeholder and matches no record, so the first
 * post you write without an explicit `author` fails the build with
 * `unknown author slug "your-name"`. `agentblog init` replaces it with the
 * default author you choose at the prompt; on the registry-only path you set it
 * by hand. Either way, set it to a slug the roster actually has before you write
 * a post that relies on it.
 *
 * The seed posts do not rely on it. Both name `editorial` explicitly, which is
 * the record shipped at the top of `content/authors.json`.
 *
 * It is declared once here because it has to reach two places: `mdxSource`
 * below, which applies it while parsing frontmatter, and the `defaultAuthor`
 * config field, which is the documented surface. The MDX adapter cannot read
 * the config field directly, because `lib/config.ts` imports this file and this
 * file builds the source, so a read back the other way would close an import
 * cycle. One constant, no drift, and nothing imports anything it should not.
 */
const DEFAULT_AUTHOR = 'your-name'

export default defineConfig({
  /**
   * Production origin. https, no trailing slash, no path.
   *
   * Every canonical link, sitemap entry, RSS `<link>`, and schema.org `@id` is
   * built from this string. Point it at the domain readers actually visit, not
   * at a preview deployment, or you will publish canonicals that tell Google to
   * index a URL nobody links to.
   */
  siteUrl: 'https://yourdomain.com',

  /**
   * The Open Graph locale: a language tag and a region joined by an underscore,
   * such as `en_US`, `en_GB`, or `pt_BR`. Emitted verbatim as `og:locale`.
   *
   * It is not a BCP 47 tag, which spells the same value `en-US`. Everywhere BCP
   * 47 is required (schema.org `inLanguage`, the RSS `<language>` element, and
   * `Intl` formatting) the block converts this value through `bcp47Locale` in
   * `lib/config.ts`, so one field serves both spellings and they cannot drift.
   * Write the underscore form here.
   *
   * Multi-locale is a v1 non-goal; the field exists so adding it later is not a
   * breaking change.
   */
  locale: 'en_US',

  brand: {
    /** Publisher name. Appears in `og:site_name` and as `Organization.name`. */
    name: 'Your Brand',

    /**
     * Publisher logo. `width` and `height` are required, not optional.
     *
     * Google's Organization markup needs both, and a logo without dimensions is
     * one of the most common structured data errors on the web. The path is
     * resolved against `siteUrl`, so `/logo.png` means
     * `https://yourdomain.com/logo.png`.
     */
    logo: { url: '/logo.png', width: 512, height: 512 },

    /**
     * Profile and entity URLs for the organization, as absolute URLs.
     *
     * This is the highest value single property in the file. It maps to
     * `Organization.sameAs`, which is how a search engine or an AI assistant
     * resolves "the company that published this" to a real entity instead of a
     * string that happens to look like a company name. Entity resolution is what
     * lets a model cite you by name and attribute the claim correctly.
     *
     * List the profiles that a third party can verify: LinkedIn, Crunchbase,
     * GitHub, YouTube, and Wikidata carry the most weight. Bare handles are
     * rejected at build time, because `@yourbrand` disambiguates nothing.
     *
     * If you fill in exactly one field in this file beyond `siteUrl`, fill in
     * this one.
     */
    sameAs: [
      'https://www.linkedin.com/company/yourbrand',
      'https://www.crunchbase.com/organization/yourbrand',
      'https://github.com/yourbrand',
      'https://www.youtube.com/@yourbrand',
    ],
  },

  /**
   * Where posts come from.
   *
   * `mdxSource` reads `.mdx` files off disk, so publishing is a git push and a
   * deploy. Swapping this for a database backed source changes one line here and
   * nothing else in the blog, which is the whole point of the adapter. Sources
   * that cannot publish without a rebuild also require a `deployHook`, and the
   * compiler will tell you so the moment you swap them in.
   */
  source: mdxSource({ dir: 'content/blog', defaultAuthor: DEFAULT_AUTHOR }),

  /**
   * ISR window in seconds for blog routes. 3600 means a published post is at
   * most an hour stale without a rebuild. The publish webhook revalidates
   * immediately, so this is the floor, not the latency you should expect.
   */
  revalidate: 3600,

  /** Posts per page on `/blog`. Pagination uses real `<a href>` links. */
  postsPerPage: 12,

  /**
   * Tag pages with fewer than this many posts get `robots: { index: false }`.
   *
   * Thin tag pages are the classic way a blog generates hundreds of near empty
   * indexable URLs and spends its own crawl budget on them. Categories are
   * always indexable; tags have to earn it.
   */
  noindexTagsBelow: 5,

  /**
   * IndexNow submission from the publish webhook. Bing, Yandex, Seznam, and
   * Naver consume it, and ChatGPT search leans on Bing's index.
   *
   * Turn this on after `agentblog init` has generated a key, or set
   * `INDEXNOW_KEY` in your environment. `lib/indexnow.ts` reads the config key
   * first and falls back to `process.env.INDEXNOW_KEY`, so the secret never has
   * to live in this committed file. The matching `<key>.txt` must be served from
   * your domain root or every submission comes back 403.
   */
  indexnow: { enabled: false },

  /**
   * Search engine ownership verification, mapped straight onto Next.js
   * `metadata.verification`. One config line instead of a hand edited layout.
   * Paste the token from the HTML tag method, not the whole meta tag.
   */
  // verification: { google: 'your-search-console-token' },

  /**
   * Declared AI access preferences, emitted into `robots.txt`.
   *
   * All three default to `true`. Setting `train: false` is a real tradeoff and
   * not a free one: several crawlers are multi purpose, so opting out of
   * training can also opt you out of the search index that would have cited you.
   */
  aiAccess: { train: true, search: true, agent: true },

  /**
   * Build time config linting. On by default.
   *
   * It reads `next.config.*` off disk and warns when required settings are
   * missing. Set it to `false` only once you know your config is correct and you
   * want the warning gone. Deleting the `preflight` import from
   * `app/blog/layout.tsx` is not the sanctioned way to silence it, because then
   * nothing tells you when a later edit breaks the config again.
   */
  preflight: true,

  /**
   * Pick one URL form and let Next.js redirect the other. This must match
   * `trailingSlash` in `next.config.ts`, because the URL helpers in
   * `lib/config.ts` compose canonicals from it. Two different answers means
   * every canonical points at a URL that immediately redirects.
   */
  trailingSlash: false,

  /**
   * Author slug used when a post omits one. See `DEFAULT_AUTHOR` at the top of
   * this file: the same constant is handed to `mdxSource` above, which is what
   * actually applies it while parsing frontmatter.
   */
  defaultAuthor: DEFAULT_AUTHOR,
})
registry/blog/lib/config.ts
/**
 * The resolved config singleton, plus the only sanctioned way to build a URL.
 *
 * ---------------------------------------------------------------------------
 * WHY THIS FILE EXISTS
 * ---------------------------------------------------------------------------
 * This is the **only** module in the entire block that imports
 * `@/agentblog.config`. That is a deliberate, enforced constraint rather than a
 * convention:
 *
 * 1. `agentblog.config.ts` lives at the project root, but `@/` maps to the root
 *    in a flat layout and to `src/` in a `src/` layout. One import specifier
 *    cannot be correct in both. With exactly one importer, fixing a `src/`
 *    project is a one line change (or one `tsconfig` path) instead of a grep
 *    across forty files. `agentblog init` performs that fix; `lib/preflight.ts`
 *    reports a named error when nobody did.
 *
 * 2. `resolveConfig` runs once, here, at module scope. A bad value fails the
 *    build with a message naming the key. Everywhere downstream a
 *    `ResolvedAgentBlogConfig` is validated, branded, and fully defaulted, so no
 *    other file needs a `?? 'en_US'` or a null check.
 *
 * ---------------------------------------------------------------------------
 * URL COMPOSITION: USE THESE HELPERS, NEVER STRING CONCATENATION
 * ---------------------------------------------------------------------------
 * `${config.siteUrl}/blog/${slug}` looks harmless and is how a blog ends up
 * serving two canonical URLs for one post. The helpers below are the single
 * place that knows the three rules that matter:
 *
 *   - `siteUrl` is already normalised to carry no trailing slash, so the path
 *     always supplies exactly one leading slash and never two.
 *   - `config.trailingSlash` has to be honoured, or every canonical points at a
 *     URL that immediately 308s to its other form. Search engines treat that as
 *     a self referential redirect chain and AI crawlers frequently do not follow
 *     it at all.
 *   - Paths that end in a file extension (`/feed.xml`, `/<key>.txt`) never take
 *     a trailing slash, matching what Next.js itself does. Getting this wrong
 *     breaks the IndexNow key file and the RSS `alternates` link.
 *
 * If you find yourself writing a template literal that starts with
 * `config.siteUrl`, add a helper here instead.
 *
 * @see https://docs.agentblog.dev/reference/configuration
 */
import 'server-only'

import userConfig from '@/agentblog.config'
import { resolveConfig } from '@/lib/define-config'
import type { ResolvedAgentBlogConfig } from '@/lib/define-config'

/**
 * Parsed, defaulted, and branded once per process. Import this everywhere;
 * import `@/agentblog.config` nowhere.
 */
export const config: ResolvedAgentBlogConfig = resolveConfig(userConfig)

/** A last path segment that looks like a file, e.g. `feed.xml` or `abc123.txt`. */
const LOOKS_LIKE_A_FILE = /\.[a-z0-9]+$/i

/** An already absolute URL, which the helpers pass through untouched. */
const ALREADY_ABSOLUTE = /^[a-z][a-z0-9+.-]*:\/\//i

/**
 * Apply every path rule in one place: one leading slash, no doubled slashes, and
 * the configured trailing slash policy, with the query string and fragment left
 * alone.
 */
function normalizePath(input: string): string {
  const separator = input.search(/[?#]/)
  const pathname = separator === -1 ? input : input.slice(0, separator)
  const suffix = separator === -1 ? '' : input.slice(separator)

  const withLeadingSlash = pathname.startsWith('/') ? pathname : `/${pathname}`
  const collapsed = withLeadingSlash.replace(/\/{2,}/g, '/')
  const bare = collapsed.length > 1 ? collapsed.replace(/\/+$/, '') : '/'

  if (bare === '/') return `/${suffix}`

  const lastSegment = bare.slice(bare.lastIndexOf('/') + 1)
  const wantsTrailingSlash = config.trailingSlash && !LOOKS_LIKE_A_FILE.test(lastSegment)

  return `${wantsTrailingSlash ? `${bare}/` : bare}${suffix}`
}

/* -------------------------------------------------------------------------- */
/*  URL and path helpers                                                      */
/* -------------------------------------------------------------------------- */

/**
 * There are two families of helper here and using the wrong one is a real bug,
 * so the naming is deliberate:
 *
 *   `xPath()`  root-relative, e.g. `/blog/hello-world`. Use for every `href`,
 *              every `<Link>`, and every internal anchor.
 *   `xUrl()`   absolute, e.g. `https://yoursite.dev/blog/hello-world`. Use for
 *              canonicals, the sitemap, the RSS feed, Open Graph URLs, and every
 *              JSON-LD `@id`.
 *
 * Why this split exists. `config.siteUrl` is your production origin. If a `href`
 * used the absolute form, every internal link on `http://localhost:3000` would
 * navigate to production, which is confusing in development and actively
 * dangerous in a preview deployment where a reviewer clicking through the blog
 * silently leaves the branch they were reviewing.
 *
 * Canonicals and structured data must be absolute, so both families are needed
 * and neither is a superset of the other.
 */

/**
 * Absolute URL for a path on this site.
 *
 * An input that is already absolute (a CDN hosted hero image, for example) is
 * returned unchanged, so callers do not have to branch before every call.
 */
export function absoluteUrl(path: string): string {
  if (ALREADY_ABSOLUTE.test(path)) return path
  return `${config.siteUrl}${normalizePath(path)}`
}

/**
 * Canonical absolute URL of the site root, in one form, for every surface.
 *
 * There are two spellings of the same place, `https://example.com` and
 * `https://example.com/`, and they are equivalent per RFC 3986 because an empty
 * path means `/`. Equivalent is not the same as interchangeable: the sitemap,
 * `Organization.url`, `WebSite.url`, and the "Home" breadcrumb are compared as
 * strings by the systems that read them, so two spellings across four surfaces
 * reads as two entities that happen to look alike.
 *
 * The trailing slash form wins because it is what `absoluteUrl('/')` already
 * produces and what the site-level `@id` values in `lib/schema.ts` are built
 * from. The root is exempt from `config.trailingSlash`: that setting picks
 * between `/blog/` and `/blog`, and there is no slashless spelling of the root
 * that a server can serve.
 *
 * Use this everywhere the site root appears as a URL. Never `config.siteUrl`.
 */
export function homeUrl(): string {
  return absoluteUrl('/')
}

/**
 * `config.locale` as a BCP 47 language tag.
 *
 * The config value is the Open Graph spelling, `en_US`, because that is what
 * `og:locale` requires. BCP 47, which schema.org `inLanguage`, the RSS
 * `<language>` element, and `Intl` all require, writes the same value `en-US`.
 * One config field serves both, which beats asking the user to keep two fields
 * in sync and to notice when they drift.
 *
 * The global flag is load bearing. `replace('_', '-')` rewrites only the first
 * underscore, so an extended tag such as `zh_Hans_CN` comes back as
 * `zh-Hans_CN`, which is not a valid tag and which `Intl.DateTimeFormat` throws
 * on. Every conversion in the block goes through this function for that reason.
 */
export function bcp47Locale(locale: string = config.locale): string {
  return locale.replaceAll('_', '-')
}

/**
 * Root-relative path for any internal route that has no dedicated helper.
 *
 * Use this rather than a bare string literal for anything you link to inside the
 * site, for example `sitePath('/editorial-policy')`. It applies the same rules
 * as every other helper here: one leading slash, no doubled slashes, and the
 * configured trailing-slash policy, with a last segment that looks like a file
 * (`/feed.xml`, `/robots.txt`) exempted from the trailing slash.
 *
 * The named helpers above exist because those four routes are ours and their
 * shape is fixed. This one exists so that a project running `trailingSlash: true`
 * does not have to hand-audit every remaining literal in the block.
 */
export function sitePath(path: string): string {
  return normalizePath(path)
}

/** Root-relative path of the blog index. */
export function blogPath(): string {
  return normalizePath('/blog')
}

/** Root-relative path of a post. Use this for `href`. */
export function postPath(slug: string): string {
  return normalizePath(`/blog/${encodeURIComponent(slug)}`)
}

/** Root-relative path of a category hub. Use this for `href`. */
export function categoryPath(slug: string): string {
  return normalizePath(`/blog/category/${encodeURIComponent(slug)}`)
}

/**
 * Root-relative path of a tag listing. Use this for `href`.
 *
 * The slug is percent encoded, exactly as `categoryPath` and `authorPath` encode
 * theirs. That became load bearing when `tagSlug` stopped discarding non-Latin
 * characters: a tag of `日本語` now produces a real slug, and an unencoded one
 * would put raw UTF-8 bytes into the sitemap and into every canonical, where the
 * sitemap protocol requires them escaped.
 *
 * A tag that slugs to nothing has no page. Returning `/blog/tag/` would collapse
 * to `/blog/tag`, which is a route that does not exist, so this returns the blog
 * index instead: a link that goes somewhere real beats a link to a 404.
 */
export function tagPath(tag: string): string {
  const slug = tagSlug(tag)
  if (slug === '') return blogPath()
  return normalizePath(`/blog/tag/${encodeURIComponent(slug)}`)
}

/**
 * URL form of a free-text tag.
 *
 * Tags are authored as human text ("AI crawlers"), which cannot go into a URL
 * as written. Percent encoding it would work and would produce
 * `/blog/tag/AI%20crawlers`, a URL that is technically valid, reads badly
 * everywhere it is displayed, and is trivially duplicated by a different
 * encoding of the same tag. Slugifying gives one canonical form per tag.
 *
 * `lib/posts.ts` matches on this same function, so a tag page and the posts it
 * lists cannot disagree about what counts as the same tag.
 *
 * ---------------------------------------------------------------------------
 * THE CHARACTER CLASS IS UNICODE AWARE, AND THAT IS NOT COSMETIC
 * ---------------------------------------------------------------------------
 * `[^a-z0-9]+` looks like the obviously correct filter and quietly deletes every
 * non-Latin script. `日本語`, `Ελλάδα`, and `Москва` all slugged to the empty
 * string, which meant `getAllTags` dropped them, they got no route and no
 * sitemap entry, and they were still emitted in `keywords` and
 * `BlogPosting.keywords`. A blog written in any of those languages had zero
 * working tag pages and no error anywhere to say so.
 *
 * `\p{L}\p{N}` is the same class `slugifyOnce` in `lib/toc.ts` uses, for the
 * same reason. The one deliberate difference: `slugifyOnce` also keeps `_`
 * because it has to reproduce `github-slugger` byte for byte, while a tag slug
 * is a URL segment and `-` is the separator readers and search engines expect
 * there.
 *
 * Returns `''` for a tag with no letters or digits at all ("!!!", an emoji).
 * That tag has no addressable URL and the callers treat `''` as "not
 * addressable": `getAllTags` skips it, `getPostsByTag` returns `[]`, and
 * `tagPath` refuses to compose a link (see below). It is dropped deliberately
 * rather than crashing or producing `/blog/tag/`.
 */
export function tagSlug(tag: string): string {
  return (
    tag
      .normalize('NFKD')
      // Strip combining marks so "Café" and "Cafe" produce the same slug. This
      // is the Combining Diacritical Marks block and not `\p{Mn}`: `\p{Mn}`
      // would also delete the marks that carry meaning in Devanagari, Hebrew,
      // and Arabic, which is the same script-erasing bug in a new place.
      .replace(/[̀-ͯ]/g, '')
      .toLowerCase()
      .trim()
      .replace(/[^\p{L}\p{N}]+/gu, '-')
      .replace(/^-+|-+$/g, '')
  )
}

/** Root-relative path of an author page. Use this for `href`. */
export function authorPath(slug: string): string {
  return normalizePath(`/authors/${encodeURIComponent(slug)}`)
}

/** Canonical URL of the blog index. */
export function blogUrl(): string {
  return absoluteUrl(blogPath())
}

/** Canonical URL of a post. Use this for canonicals, JSON-LD, and the sitemap. */
export function postUrl(slug: string): string {
  return absoluteUrl(postPath(slug))
}

/** Canonical URL of a category hub. Categories are indexable. */
export function categoryUrl(slug: string): string {
  return absoluteUrl(categoryPath(slug))
}

/**
 * Absolute URL of a tag listing. Tags are noindexed below
 * `config.noindexTagsBelow`, so this URL is not always a canonical one.
 */
export function tagUrl(tag: string): string {
  return absoluteUrl(tagPath(tag))
}

/** Canonical URL of an author page. Doubles as the `Person` node `@id` base. */
export function authorUrl(slug: string): string {
  return absoluteUrl(authorPath(slug))
}

/* -------------------------------------------------------------------------- */
/*  XML escaping                                                              */
/* -------------------------------------------------------------------------- */

/**
 * Escape a value for XML character data or an XML attribute.
 *
 * ---------------------------------------------------------------------------
 * WHY THIS LIVES NEXT TO THE URL HELPERS
 * ---------------------------------------------------------------------------
 * Because every value it protects is a URL those helpers produced. `app/sitemap.ts`
 * hands Next.js a list of `absoluteUrl`, `postUrl`, `categoryUrl`, `tagUrl`, and
 * `authorUrl` results plus each post's `heroImage`, and Next.js writes them into
 * XML with no escaping of its own. Verified in
 * `next/dist/build/webpack/loaders/metadata/resolve-route-data.js@16.3.0`:
 *
 *     content += `<loc>${item.url}</loc>\n`
 *     content += `<image:image>\n<image:loc>${image}</image:loc>\n</image:image>\n`
 *
 * Straight interpolation. So the escape has to happen before the value reaches
 * the framework, and this module is the one every sitemap value already passes
 * through.
 *
 * ---------------------------------------------------------------------------
 * THE ACCIDENT MATTERS MORE THAN THE ATTACK
 * ---------------------------------------------------------------------------
 * `&` is legal in a URI and ordinary in a CDN URL:
 * `https://cdn.example/a.png?w=1200&h=630&fit=crop`. Unescaped, that single hero
 * image makes the whole document not well-formed, every XML parser rejects it,
 * and the site silently has no sitemap at all. `ImageRef` in `lib/schemas.ts`
 * refuses the characters that have no business in a URI (`<`, `>`, `"`, spaces,
 * controls), which closes the deliberate injection; `&` cannot be refused there
 * without breaking real URLs, so it is escaped here.
 *
 * All five predefined entities are escaped rather than the three that are
 * strictly required in character data, because the same function is used for
 * attribute values, and a function whose safety depends on where you call it is
 * a function that will eventually be called in the other place. `&` goes first,
 * or the ampersands introduced by the later replacements get escaped again.
 *
 * `app/feed.xml/route.ts` carries its own copy of this, written before this one
 * existed. Point it here when that file is next touched.
 */
export function escapeXml(value: string): string {
  return stripInvalidXmlChars(value)
    .replaceAll('&', '&amp;')
    .replaceAll('<', '&lt;')
    .replaceAll('>', '&gt;')
    .replaceAll('"', '&quot;')
    .replaceAll("'", '&apos;')
}

/**
 * Remove code points that XML 1.0 forbids anywhere, CDATA included.
 *
 * Escaping is not enough on its own. A control character such as `\v` or a stray
 * `\x00` in a title, an author name, or a description makes the whole document
 * unparseable, and there is no escape sequence that rescues it: XML 1.0 simply
 * has no representation for those code points. A lone surrogate is the same
 * problem arriving from a truncated string.
 *
 * The failure is total and it is silent from our side. One bad character in one
 * post and every feed reader gets a hard parse error on the whole feed, or the
 * sitemap stops parsing entirely. Verified against a real parser: element
 * breakout and CDATA terminator payloads are handled by escaping, and these are
 * the ones that get through it.
 *
 * Permitted, per the XML 1.0 Char production: tab, newline, carriage return,
 * and everything from U+0020 up, minus the surrogate range and the two
 * noncharacters at the end of the BMP.
 */
export function stripInvalidXmlChars(value: string): string {
  return (
    value
      // eslint-disable-next-line no-control-regex -- the point is to match control characters
      .replace(/[\u0000-\u0008\u000B\u000C\u000E-\u001F\uFFFE\uFFFF]/g, '')
      // A surrogate that is not part of a pair. A valid pair is left alone.
      .replace(/[\uD800-\uDBFF](?![\uDC00-\uDFFF])/g, '')
      .replace(/(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/g, '')
  )
}
registry/blog/lib/define-config.ts
/**
 * ! GENERATED FILE. DO NOT EDIT.
 *
 * Source of truth: `packages/schema/src/define-config.ts` in the AgentBlog repository.
 * Regenerate with `pnpm codegen`.
 *
 * This file is copied verbatim so that the code running in your project is the
 * same code the AgentBlog test suite runs against. Edits here are safe to make
 * in your own repository once installed, but they will be overwritten if you
 * reinstall the block.
 */
import 'server-only'

// `server-only` makes a client import of this module a build error rather
// than silent bundle growth. Everything below runs at build time or in a
// route handler, never in the browser.

/**
 * `agentblog.config.ts`: the type, the helper, and the resolver.
 *
 * ---------------------------------------------------------------------------
 * THE SHAPE OF THIS FILE
 * ---------------------------------------------------------------------------
 * There are two config types here, and the distinction is load bearing:
 *
 *   `AgentBlogConfig`          what you write. Plain strings, most keys optional.
 *   `ResolvedAgentBlogConfig`  what the blog reads. Validated, branded, defaulted.
 *
 * `resolveConfig` is the boundary between them. It runs once at module scope in
 * `lib/config.ts`, so a bad value fails the build with a message that names the
 * key rather than producing `undefined` somewhere inside a JSON-LD graph three
 * files later.
 *
 * Branding only exists on the resolved side on purpose. If `siteUrl` were a
 * branded type on the input side you could not write `siteUrl: 'https://x.dev'`
 * in your own config file, which would be a strange thing to ask of a config
 * file. Validate at the boundary, brand on the inside.
 *
 * ---------------------------------------------------------------------------
 * WHY `defineConfig` INSTEAD OF `satisfies`
 * ---------------------------------------------------------------------------
 * `defineConfig` gives contextual inference, so the `deployHook` requirement
 * below can depend on which content source you passed. `satisfies` checks the
 * object against a fixed type and cannot do that.
 *
 * @see https://docs.agentblog.dev/reference/configuration
 */
import { z } from 'zod'

import { AbsoluteUrl, HttpsUrl, Slug } from '@/lib/schemas'
import type { ContentSource } from '@/lib/types'
import { AgentBlogContentError } from '@/lib/types'

/* -------------------------------------------------------------------------- */
/*  What you write                                                            */
/* -------------------------------------------------------------------------- */

export interface AgentBlogConfigBase {
  /**
   * The production origin, https, no trailing slash. Every canonical URL, every
   * sitemap entry, and every JSON-LD `@id` is composed from this.
   */
  readonly siteUrl: string

  /**
   * The Open Graph locale form, `language_TERRITORY` with an underscore, not the
   * BCP-47 hyphen form. `en_US`, not `en-US`. Default `en_US`.
   *
   * It is written this way because `og:locale` accepts only this spelling, and
   * `bcp47Locale` converts it to `en-US` for `inLanguage`, the RSS `<language>`
   * element, and `Intl`. One field, both surfaces, no drift. `resolveConfig`
   * rejects the hyphen form with a message that says so.
   */
  readonly locale?: string

  readonly brand: {
    readonly name: string
    /**
     * `width` and `height` are required, not optional. Google's Organization
     * markup needs both, and a logo without dimensions is one of the most common
     * structured data errors on the web.
     */
    readonly logo: { readonly url: string; readonly width: number; readonly height: number }
    /**
     * Profile and entity URLs for the organization, as absolute URLs.
     *
     * This is the single most valuable property in the whole config. It maps
     * to `Organization.sameAs`, which is how an AI search engine resolves "the
     * company that published this" to a real entity instead of a string. LinkedIn,
     * Crunchbase, GitHub, YouTube, and Wikidata are the ones worth listing.
     */
    readonly sameAs?: readonly string[]
  }

  /** ISR window in seconds for blog routes. Default 3600. */
  readonly revalidate?: number

  /** Posts per page on the index. Default 12. */
  readonly postsPerPage?: number

  /**
   * Tag pages with fewer than this many posts get `robots: { index: false }`.
   *
   * Thin tag pages are the classic way a blog generates hundreds of near-empty
   * indexable URLs and dilutes its own crawl budget. Default 5.
   */
  readonly noindexTagsBelow?: number

  /** IndexNow submission. Requires a key; `agentblog init` generates one. */
  readonly indexnow?: { readonly enabled: boolean; readonly key?: string }

  /** Search engine site verification. Maps straight to Next.js `metadata.verification`. */
  readonly verification?: {
    readonly google?: string
    readonly yandex?: string
    readonly yahoo?: string
    readonly other?: Record<string, string | string[]>
  }

  /**
   * Declared AI access preferences, emitted into `robots.txt`.
   *
   * A forward looking seam. Three standards are converging on machine-readable
   * AI usage preferences (Cloudflare Content Signals, RSL, and the IETF AIPREF
   * draft) and all three attach to robots.txt or HTTP. When one lands, this same
   * config object emits it with no change to your config file.
   *
   * All three default to `true`. Setting `train: false` is a real tradeoff and
   * not a free one: some crawlers are multi-purpose, so opting out of training
   * can also opt you out of the search index that cites you.
   */
  readonly aiAccess?: {
    readonly train?: boolean
    readonly search?: boolean
    readonly agent?: boolean
  }

  /**
   * Build-time config linting. On by default.
   *
   * Set to `false` only if you know your `next.config.ts` is correct and you
   * want the warning gone. This is the sanctioned opt out; deleting the
   * `preflight` import from `app/blog/layout.tsx` is not, because then nothing
   * tells you when a later edit breaks the config.
   */
  readonly preflight?: boolean

  /** Pick one URL form and redirect the other. Default `false`. */
  readonly trailingSlash?: boolean

  /** Author slug used when a post omits one. */
  readonly defaultAuthor?: string
}

/**
 * The full config type.
 *
 * `deployHook` is required when the content source cannot publish without a
 * rebuild, and rejected when it can. That moves the cold-slug failure (publishing
 * a post that was never prerendered, then pinging a crawler to come look at it)
 * from a runtime surprise to a compile error. `scripts/assert-cold-slug.mjs` is
 * the end-to-end proof of the same thing.
 *
 * Swap `mdxSource` for `supabaseSource` in your config and the file stops type
 * checking until you supply a deploy hook. That is the single highest value
 * thing the type layer does here.
 */
export type AgentBlogConfig<S extends ContentSource = ContentSource> = AgentBlogConfigBase &
  (S extends ContentSource<'deploy-hook'>
    ? { readonly source: S; readonly deployHook: string }
    : { readonly source: S; readonly deployHook?: never })

/**
 * Identity function that exists purely for its type signature. Wrap your config
 * object in it so TypeScript can infer the content source and apply the
 * `deployHook` rule above.
 */
export function defineConfig<S extends ContentSource>(
  config: AgentBlogConfig<S>,
): AgentBlogConfig<S> {
  return config
}

/* -------------------------------------------------------------------------- */
/*  What the blog reads                                                       */
/* -------------------------------------------------------------------------- */

export interface ResolvedAgentBlogConfig {
  readonly siteUrl: HttpsUrl
  readonly locale: string
  readonly brand: {
    readonly name: string
    readonly logo: { readonly url: string; readonly width: number; readonly height: number }
    readonly sameAs: readonly AbsoluteUrl[]
  }
  readonly source: ContentSource
  readonly deployHook: string | undefined
  readonly revalidate: number
  readonly postsPerPage: number
  readonly noindexTagsBelow: number
  readonly indexnow: { readonly enabled: boolean; readonly key: string | undefined }
  readonly verification: {
    readonly google: string | undefined
    readonly yandex: string | undefined
    readonly yahoo: string | undefined
    readonly other: Record<string, string | string[]> | undefined
  }
  readonly aiAccess: { readonly train: boolean; readonly search: boolean; readonly agent: boolean }
  readonly preflight: boolean
  readonly trailingSlash: boolean
  readonly defaultAuthor: Slug | undefined
}

/**
 * The Open Graph locale form: `language_TERRITORY`, optionally with a script
 * subtag between them. `en_US`, `pt_BR`, `zh_Hans_CN`.
 *
 * Open Graph requires the underscore form. BCP 47, which `inLanguage`, the RSS
 * `<language>` element, and `Intl` all want, writes the same value with a
 * hyphen. One config field serves both because `bcp47Locale` in `lib/config.ts`
 * converts one way, and the conversion only runs one way: an `en-US` written
 * into the config reaches `og:locale` untouched and is invalid there.
 */
const OG_LOCALE = /^[a-z]{2,3}(?:_[A-Z][a-z]{3})*_[A-Z]{2}$/

/**
 * Validation for everything in the config except `source`, which is an object
 * with methods and is checked structurally below instead.
 *
 * ---------------------------------------------------------------------------
 * WHY `.strict()`, AND THE TRADEOFF THAT USUALLY ARGUES AGAINST IT
 * ---------------------------------------------------------------------------
 * `.strict()` on a config object is normally a hostile default, because it
 * forbids a user from parking their own keys next to ours and there is no way
 * for them to opt out.
 *
 * It is right here for one specific reason: the TypeScript side already forbids
 * unknown keys. `defineConfig` takes `AgentBlogConfig`, which has no index
 * signature, so `defineConfig({ revalidatee: 60, ... })` is already an excess
 * property error on the object literal. Without `.strict()` the runtime was
 * simply more permissive than the type, and the gap was reachable in the two
 * ways that matter: a JavaScript `agentblog.config.js`, and a config assembled
 * with a spread (`{ ...base, ...overrides }`), which suppresses excess property
 * checking. `revalidatee: 60` parsed clean and the blog ran on the default ISR
 * window with nothing anywhere saying so.
 *
 * So this does not remove a capability users have. It makes the runtime agree
 * with the type they are already held to. If a real need to carry extra keys
 * appears, the alternative is a declared `extra: Record<string, unknown>` field
 * that is validated as opaque and never read by the block, which keeps typo
 * detection on every key we do own.
 */
const ConfigShapeSchema = z
  .object({
    siteUrl: z
      .string()
      // A trailing slash here produces `https://site.dev//blog/post` in every
      // canonical, so normalise before branding rather than defending downstream.
      .transform((s) => s.replace(/\/+$/, ''))
      .pipe(HttpsUrl),
    locale: z
      .string()
      .default('en_US')
      // Two refinements rather than one, so the far more likely mistake gets the
      // message that names it. `en-US` is what every other locale-shaped API in
      // a Next.js project wants, so it is what people type, and it produces an
      // `og:locale` that Facebook, LinkedIn, and Slack all reject silently.
      .refine((value) => !value.includes('-'), {
        message:
          'is BCP 47 (`en-US`), but `og:locale` requires the Open Graph form with an ' +
          'underscore (`en_US`). Write the underscore form here: `bcp47Locale` in ' +
          '`lib/config.ts` converts it to the hyphen form for `inLanguage`, the RSS ' +
          '`<language>` element, and `Intl`, so this one field serves both.',
      })
      .refine((value) => OG_LOCALE.test(value), {
        message:
          'must be `language_TERRITORY`, lowercase language and uppercase territory, with ' +
          'an optional script subtag between them. `en_US`, `pt_BR`, `zh_Hans_CN`. ' +
          'A bare language (`en`) is not a valid `og:locale`.',
      }),
    brand: z.object({
      name: z.string().min(1),
      logo: z.object({
        url: z.string().min(1),
        width: z.number().int().positive(),
        height: z.number().int().positive(),
      }),
      sameAs: z.array(AbsoluteUrl).default([]),
    }),
    deployHook: z
      .url({ protocol: /^https$/, error: 'deployHook must be an absolute https URL' })
      .describe('Rebuild trigger. Plain http would leak the secret path in transit.')
      .optional(),
    revalidate: z.number().int().nonnegative().default(3600),
    postsPerPage: z.number().int().positive().default(12),
    noindexTagsBelow: z.number().int().nonnegative().default(5),
    indexnow: z
      .object({
        enabled: z.boolean(),
        /*
         * Exactly the pattern the CLI's `isValidIndexNowKey` enforces. The two
         * have to agree: the key becomes a file name at your domain root
         * (`public/<key>.txt`), so a slash or a dot produces a file the CLI and
         * the running app disagree about, and every submission returns 403 with
         * nothing on either side saying why.
         */
        key: z
          .string()
          .regex(
            /^[A-Za-z0-9-]{8,128}$/,
            'IndexNow keys are 8 to 128 characters of A-Z, a-z, 0-9, and hyphen. The key becomes a file name at your domain root, so a slash or a dot silently breaks verification.',
          )
          .optional(),
      })
      .default({ enabled: false }),
    verification: z
      .object({
        google: z.string().optional(),
        yandex: z.string().optional(),
        yahoo: z.string().optional(),
        other: z.record(z.string(), z.union([z.string(), z.array(z.string())])).optional(),
      })
      .default({}),
    aiAccess: z
      .object({
        train: z.boolean().default(true),
        search: z.boolean().default(true),
        agent: z.boolean().default(true),
      })
      .default({ train: true, search: true, agent: true }),
    preflight: z.boolean().default(true),
    trailingSlash: z.boolean().default(false),
    defaultAuthor: Slug.optional(),
  })
  .strict()

const CONTENT_SOURCE_METHODS = [
  'getAllPosts',
  'getPost',
  'getAllCategories',
  'getAllAuthors',
  'getPostsByCategory',
  'getPostsByAuthor',
  'getRelatedPosts',
] as const

/**
 * Parse, default, and brand the user's config. Called exactly once, at module
 * scope in `lib/config.ts`.
 *
 * @throws {AgentBlogContentError} naming the offending key, at build time.
 */
export function resolveConfig(input: unknown): ResolvedAgentBlogConfig {
  if (typeof input !== 'object' || input === null) {
    throw new AgentBlogContentError('agentblog.config.ts', [
      {
        path: [],
        message:
          'default export is missing or is not an object. It should be `export default defineConfig({ ... })`.',
      },
    ])
  }

  const { source, ...rest } = input as Record<string, unknown>

  const parsed = ConfigShapeSchema.safeParse(rest)
  if (!parsed.success) {
    throw new AgentBlogContentError(
      'agentblog.config.ts',
      parsed.error.issues.map((i) => ({ path: i.path, message: i.message })),
    )
  }

  const sourceIssues = validateSource(source)
  if (sourceIssues.length > 0) {
    throw new AgentBlogContentError('agentblog.config.ts', sourceIssues)
  }
  const contentSource = source as ContentSource

  if (contentSource.prerenderStrategy === 'deploy-hook' && !parsed.data.deployHook) {
    throw new AgentBlogContentError('agentblog.config.ts', [
      {
        path: ['deployHook'],
        message:
          `source "${contentSource.name}" declares prerenderStrategy "deploy-hook", which means a ` +
          'newly published post is not prerendered until a rebuild runs. Set `deployHook` to a ' +
          'rebuild trigger URL so the publish webhook can rebuild before pinging IndexNow.',
      },
    ])
  }

  return {
    ...parsed.data,
    source: contentSource,
    deployHook: parsed.data.deployHook,
    indexnow: { enabled: parsed.data.indexnow.enabled, key: parsed.data.indexnow.key },
    verification: {
      google: parsed.data.verification.google,
      yandex: parsed.data.verification.yandex,
      yahoo: parsed.data.verification.yahoo,
      other: parsed.data.verification.other,
    },
    defaultAuthor: parsed.data.defaultAuthor,
  }
}

function validateSource(source: unknown): { path: string[]; message: string }[] {
  if (typeof source !== 'object' || source === null) {
    return [
      {
        path: ['source'],
        message:
          'is missing. Set it to a content source, for example `mdxSource({ dir: "content/blog" })`.',
      },
    ]
  }
  const candidate = source as Record<string, unknown>
  const missing = CONTENT_SOURCE_METHODS.filter((m) => typeof candidate[m] !== 'function')
  if (missing.length > 0) {
    return [
      {
        path: ['source'],
        message: `does not implement the ContentSource interface. Missing: ${missing.join(', ')}.`,
      },
    ]
  }
  if (
    candidate['prerenderStrategy'] !== 'build' &&
    candidate['prerenderStrategy'] !== 'deploy-hook' &&
    candidate['prerenderStrategy'] !== 'on-demand'
  ) {
    return [
      {
        path: ['source', 'prerenderStrategy'],
        message: 'must be one of "build", "deploy-hook", or "on-demand".',
      },
    ]
  }
  return []
}
registry/blog/lib/schemas.ts
/**
 * ! GENERATED FILE. DO NOT EDIT.
 *
 * Source of truth: `packages/schema/src/schemas.ts` in the AgentBlog repository.
 * Regenerate with `pnpm codegen`.
 *
 * This file is copied verbatim so that the code running in your project is the
 * same code the AgentBlog test suite runs against. Edits here are safe to make
 * in your own repository once installed, but they will be overwritten if you
 * reinstall the block.
 */
import 'server-only'

// `server-only` makes a client import of this module a build error rather
// than silent bundle growth. Everything below runs at build time or in a
// route handler, never in the browser.

/**
 * AgentBlog domain schemas.
 *
 * ---------------------------------------------------------------------------
 * WHY THIS FILE LOOKS THE WAY IT DOES
 * ---------------------------------------------------------------------------
 * Every domain type in AgentBlog is *inferred* from the Zod schemas below with
 * `z.infer`. There is no hand-written `interface Post` anywhere in the codebase,
 * because a validator and a parallel interface always drift, and here they would
 * drift between what we validate and what we type.
 *
 * The five branded primitives at the top exist because five invariants this
 * product depends on are unrepresentable as `string`:
 *
 *   - a slug that is lowercase-hyphenated and carries no date
 *   - a URL that is absolute rather than a bare social handle
 *   - a URL that is specifically https and is a bare origin, for `siteUrl`
 *   - an image reference that is a URL or a root-relative path, and nothing else
 *   - a timestamp that carries a UTC offset
 *
 * Each of those, when violated, produces output that validates cleanly and is
 * silently wrong: Google reinterprets an offset-less date in Googlebot's own
 * timezone, and a `sameAs` entry of "@janedoe" disambiguates nothing. Branding
 * makes the invalid value unconstructible instead of merely discouraged.
 *
 * `ImageRef` is the one that is also a security boundary rather than a quality
 * one. `heroImage` was `z.string()`, and it lands unescaped inside `<image:loc>`
 * in the sitemap that Next.js serializes. See the comment on `ImageRef`.
 *
 * ---------------------------------------------------------------------------
 * EDITING THIS FILE
 * ---------------------------------------------------------------------------
 * Adding a frontmatter field is a two-line change: add it to `PostSchema` here,
 * then read it wherever you need it. The type flows everywhere automatically.
 * If you add a field that maps to schema.org, also add a row to the mapping
 * table in `lib/schema.ts` so the JSON-LD builders stay reviewable.
 *
 * @see https://docs.agentblog.dev/reference/content-sources
 */
import { z } from 'zod'

/* -------------------------------------------------------------------------- */
/*  Branded primitives                                                        */
/* -------------------------------------------------------------------------- */

/**
 * A URL path segment: lowercase, hyphen separated, no slashes, no leading date.
 *
 * Dates in slugs are banned deliberately. A slug like `2024-03-post-title`
 * advertises the post's age in every search result and makes an evergreen
 * refresh look stale, which is the opposite of what the freshness signal in the
 * GEO playbook rewards.
 */
export const Slug = z
  .string()
  // A slug reaches `revalidatePath()`, a cache key, and a probe URL. None of
  // those has a length limit of its own, and no real post needs more than this.
  .max(120, 'must be at most 120 characters')
  .regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/, 'must be lowercase and hyphen separated, with no slashes')
  .refine((s) => !/^\d{4}-\d{2}(?:-\d{2})?-/.test(s), {
    error: 'must not begin with a date. Dates in slugs make evergreen posts look stale.',
  })
  .brand<'Slug'>()

/**
 * The printable characters RFC 3986 excludes from a URI. `hasUriExcludedChar`
 * below adds space, the C0 controls, and DEL.
 *
 * `z.url()` does not reject these. It parses with the WHATWG URL parser, which
 * is a browser-compatibility parser rather than a validator: it happily accepts
 * `https://a.dev/<x>` and hands the string back with the `<` intact. Everything
 * downstream then embeds that string in markup. Next.js 16.3.0's own sitemap
 * serializer does no escaping at all (`resolve-route-data.js` interpolates
 * `<loc>${item.url}</loc>` directly), so a `<` here is a new XML element in
 * someone else's sitemap.
 *
 * Note what is deliberately NOT on this list: `&`. It is legal in a URI and a
 * perfectly ordinary CDN URL carries several of them, so it has to be escaped at
 * serialization rather than banned at the boundary. `escapeXml` in
 * `lib/config.ts` is that escape.
 */
const URI_EXCLUDED_PRINTABLE = /["<>\\^`{|}]/

/**
 * True when `value` contains a character RFC 3986 excludes from a URI.
 *
 * Space, the C0 controls, and DEL are tested by code point rather than as a
 * regex range. A control character inside a character class is a
 * `no-control-regex` lint error, and the rule is right in general: a literal
 * NUL in a regex is nearly always a typo rather than an intention.
 */
function hasUriExcludedChar(value: string): boolean {
  for (const char of value) {
    const code = char.codePointAt(0) ?? 0
    if (code <= 0x20 || code === 0x7f) return true
  }
  return URI_EXCLUDED_PRINTABLE.test(value)
}

const URI_EXCLUDED_ERROR =
  'must not contain a space, a control character, or any of " < > \\ ^ ` { | }. RFC 3986 ' +
  'excludes them from a URI and they break XML and HTML attribute contexts. Percent-encode them.'

/** An absolute http(s) URL. A bare handle such as `@janedoe` is rejected. */
export const AbsoluteUrl = z
  .url({ protocol: /^https?$/, error: 'must be an absolute http or https URL' })
  .refine((url) => !hasUriExcludedChar(url), { error: URI_EXCLUDED_ERROR })
  .brand<'AbsoluteUrl'>()

/**
 * An absolute https URL with no query and no fragment. Used for `siteUrl`, which
 * anchors every canonical.
 *
 * Query and fragment are refused because `absoluteUrl` composes URLs as
 * `${siteUrl}${path}`. A `siteUrl` of `https://a.dev/?a=1` produces
 * `https://a.dev/?a=1/blog/post` for every post on the site, which is a
 * correctness failure before it is anything else. It is also the second half of
 * the sitemap injection above, since every entry in the sitemap is built from
 * this value.
 */
export const HttpsUrl = z
  .url({ protocol: /^https$/, error: 'must be an absolute https URL' })
  .refine((url) => !hasUriExcludedChar(url), { error: URI_EXCLUDED_ERROR })
  .refine((url) => !/[?#]/.test(url), {
    error:
      'must be a bare origin with no query string and no fragment. Every canonical on the ' +
      'site is composed as siteUrl + path, so a query here lands in the middle of every URL.',
  })
  .brand<'HttpsUrl'>()

/**
 * A reference to an image: an absolute http(s) URL, or a root-relative path
 * beginning with a single `/`.
 *
 * `z.string()` was the previous type and it was the vector for a real attack.
 * `Post.heroImage` reaches `<image:loc>` in the sitemap, which Next.js does not
 * escape, so a frontmatter value of
 * `x.png</image:loc></image:image></url><url><loc>https://spam.tld/</loc>`
 * added an attacker-controlled `<url>` element to the victim's own sitemap. It
 * also reaches `og:image` and `BlogPosting.image`.
 *
 * `//evil.tld/x.png` is rejected along with everything else that is not
 * http(s): a protocol-relative URL reads as a path and loads from another
 * origin. `absoluteUrl` returns an already-absolute input untouched, so
 * whatever is accepted here is what ships.
 */
export const ImageRef = z
  .string()
  .min(1)
  .refine((value) => /^(?:https?:\/\/|\/(?!\/))/.test(value), {
    error:
      'must be an absolute http(s) URL or a root-relative path beginning with a single "/". ' +
      'A protocol-relative "//host/path" and a bare "img/hero.png" are both rejected.',
  })
  .refine((value) => !hasUriExcludedChar(value), { error: URI_EXCLUDED_ERROR })
  .brand<'ImageRef'>()

/**
 * ISO 8601 with an explicit UTC offset, for example `2026-08-06T09:30:00-04:00`.
 *
 * The offset is not optional. Google falls back to Googlebot's own timezone when
 * a date carries no offset, which silently shifts every published date by hours
 * and can move a post across a day boundary.
 */
export const IsoDateTime = z.iso
  .datetime({
    offset: true,
    error: 'must be ISO 8601 with a UTC offset, e.g. 2026-08-06T09:30:00Z',
  })
  .brand<'IsoDateTime'>()

export type Slug = z.infer<typeof Slug>
export type AbsoluteUrl = z.infer<typeof AbsoluteUrl>
export type HttpsUrl = z.infer<typeof HttpsUrl>
export type ImageRef = z.infer<typeof ImageRef>
export type IsoDateTime = z.infer<typeof IsoDateTime>

/* -------------------------------------------------------------------------- */
/*  Domain schemas                                                            */
/* -------------------------------------------------------------------------- */

/**
 * A post author.
 *
 * `name` is the person's name and nothing else. Google's Article guidance is
 * explicit that `author.name` must exclude job titles, honorifics, and the
 * publisher name, and a CMS field labelled "author" actively invites all three.
 * `jobTitle` is a separate property precisely so the correct output is the only
 * constructible one.
 */
export const AuthorSchema = z.object({
  slug: Slug,
  name: z.string().min(1, 'author name is required'),
  bio: z.string().min(1, 'author bio is required; it is the E-E-A-T surface'),
  avatar: AbsoluteUrl.optional(),
  jobTitle: z.string().optional(),
  worksFor: AbsoluteUrl.optional(),
  /** Topics this author is credible on. Maps to `Person.knowsAbout`. */
  knowsAbout: z.array(z.string()).default([]),
  alumniOf: z.string().optional(),
  /** Absolute profile URLs. Maps to `Person.sameAs`, the entity link. */
  sameAs: z.array(AbsoluteUrl).default([]),
  email: z.email().optional(),
})

/**
 * A category. Categories are indexable hub pages, so the description is
 * required: a thin hub with nothing but a post list is a crawl liability rather
 * than an asset.
 */
export const CategorySchema = z.object({
  slug: Slug,
  name: z.string().min(1),
  description: z.string().min(1, 'category description is required; the hub page is indexable'),
  latestPostDate: IsoDateTime.optional(),
})

/** A cited source. `kind` is ours for auditing and is never emitted as JSON-LD. */
export const CitationSchema = z.object({
  name: z.string().min(1),
  url: AbsoluteUrl,
  author: z.string().optional(),
  datePublished: IsoDateTime.optional(),
  kind: z.enum(['peer-reviewed', 'official-docs', 'industry', 'news', 'other']).default('other'),
})

/**
 * One question and answer pair.
 *
 * These render visibly on the page before they are ever emitted as `FAQPage`
 * JSON-LD. Google's structured data policy forbids marking up content that is
 * not visible to readers, and FAQ markup is the most common way blogs trip it.
 */
export const FaqEntrySchema = z.object({
  question: z.string().min(1),
  answer: z.string().min(1),
})

/** Everything about a post except its body. Split out so frontmatter can reuse it. */
const PostFieldsSchema = z.object({
  slug: Slug,

  /**
   * Title. 60 characters is the target, because that is roughly where Google
   * truncates a title in the SERP. 70 is the hard stop.
   */
  title: z.string().min(1).max(70, 'title must be at most 70 characters; aim for 60'),

  /** Meta description. Must match `BlogPosting.description` exactly. */
  description: z
    .string()
    .min(50, 'description must be at least 50 characters')
    .max(160, 'description must be at most 160 characters'),

  /**
   * The 40 to 60 word direct answer that sits under the H1.
   *
   * Word count is checked by `agentblog audit` rather than here, because a post
   * that is 8 words over should be reported, not refused at build time.
   */
  answerCapsule: z.string().optional(),

  datePublished: IsoDateTime,
  dateModified: IsoDateTime,

  /** Fully hydrated, never a reference. See the hydration rule in `types.ts`. */
  author: AuthorSchema,
  category: CategorySchema,

  tags: z.array(z.string()).default([]),

  /**
   * `ImageRef`, not `z.string()`. This value is written into `<image:loc>` in a
   * sitemap that Next.js does not escape, into `og:image`, and into
   * `BlogPosting.image`. See `ImageRef` above for the injection it closes.
   */
  heroImage: ImageRef.optional(),
  heroAlt: z.string().optional(),

  /** Editorial related posts, in the order they should appear. */
  relatedPosts: z.array(Slug).default([]),
  citations: z.array(CitationSchema).default([]),
  faq: z.array(FaqEntrySchema).default([]),

  draft: z.boolean().default(false),

  /**
   * Multi-locale is an explicit v1 non-goal. These two fields exist so that
   * adding it later is additive rather than a breaking change to
   * `ContentSource`, which is the one interface that has to stay stable.
   *
   * v1 emits `inLanguage` from `locale ?? config.locale` and ignores the rest.
   */
  locale: z.string().optional(),
  translations: z.record(z.string(), Slug).optional(),
})

/* Two invariants that need more than one field, so they cannot live on a field. */

/** Dates are compared as instants, not as strings: offsets are not lexicographic. */
const dateOrderIsSane = (p: { datePublished: string; dateModified: string }) =>
  Date.parse(p.dateModified) >= Date.parse(p.datePublished)

const heroImageHasAlt = (p: { heroImage?: string | undefined; heroAlt?: string | undefined }) =>
  !p.heroImage || Boolean(p.heroAlt)

const DATE_ORDER_ERROR = {
  error: 'dateModified precedes datePublished',
  path: ['dateModified'],
}
const HERO_ALT_ERROR = {
  error: 'heroImage requires heroAlt. An unlabelled hero image is an accessibility failure.',
  path: ['heroAlt'],
}

/** A complete post, body included. This is what `ContentSource` returns. */
export const PostSchema = PostFieldsSchema.extend({ body: z.string() })
  .refine(dateOrderIsSane, DATE_ORDER_ERROR)
  .refine(heroImageHasAlt, HERO_ALT_ERROR)

/**
 * Frontmatter as authored, without the body.
 *
 * Note that `z.input` and `z.output` genuinely differ here: `.default([])` means
 * the parsed post has `tags: string[]` while the authored frontmatter may omit
 * the key entirely. That difference is the whole reason these types are inferred
 * rather than hand-written, since a hand-written interface can only describe one
 * of the two shapes.
 */
export const PostFrontmatterSchema = PostFieldsSchema.refine(
  dateOrderIsSane,
  DATE_ORDER_ERROR,
).refine(heroImageHasAlt, HERO_ALT_ERROR)
registry/blog/lib/types.ts
/**
 * ! GENERATED FILE. DO NOT EDIT.
 *
 * Source of truth: `packages/schema/src/types.ts` in the AgentBlog repository.
 * Regenerate with `pnpm codegen`.
 *
 * This file is copied verbatim so that the code running in your project is the
 * same code the AgentBlog test suite runs against. Edits here are safe to make
 * in your own repository once installed, but they will be overwritten if you
 * reinstall the block.
 */
/**
 * AgentBlog domain types and the `ContentSource` contract.
 *
 * ---------------------------------------------------------------------------
 * THE ONE INTERFACE THAT MATTERS
 * ---------------------------------------------------------------------------
 * `ContentSource` is where your posts come from. MDX files on disk is the only
 * implementation shipped in v1, but the interface is deliberately the narrowest
 * thing that a database, a headless CMS, or a hosted service can also satisfy.
 * Swapping storage should be one line in `agentblog.config.ts`.
 *
 * If you are writing your own adapter, read `THE PURITY RULE` below first, then
 * run `runSourceContractTests` from `@agentblog/schema/contract` against it. The
 * suite asserts the eight properties the rest of the blog silently assumes.
 *
 * ---------------------------------------------------------------------------
 * THE PURITY RULE
 * ---------------------------------------------------------------------------
 * Every method must be callable at build time, from `generateStaticParams()` and
 * `generateMetadata()`. No React hooks. No client-only SDKs. No request-scoped
 * auth, which means no `cookies()` and no `headers()`.
 *
 * This is the rule that preserves full prerendering, and full prerendering is
 * what puts the complete article in the first response byte. AI crawlers do not
 * execute JavaScript. A source that needs a request context turns every post
 * into an empty shell for exactly the readers this project exists to reach.
 *
 * @see https://docs.agentblog.dev/reference/content-sources
 */
import type { z } from 'zod'

import type {
  AuthorSchema,
  CategorySchema,
  CitationSchema,
  FaqEntrySchema,
  PostSchema,
} from '@/lib/schemas'
import type { Slug } from '@/lib/schemas'

/* -------------------------------------------------------------------------- */
/*  Inferred domain types. Every name below is inferred, never authored.       */
/* -------------------------------------------------------------------------- */

export type Post = z.infer<typeof PostSchema>
export type Author = z.infer<typeof AuthorSchema>
export type Category = z.infer<typeof CategorySchema>
export type Citation = z.infer<typeof CitationSchema>
export type FaqEntry = z.infer<typeof FaqEntrySchema>

/** Frontmatter as authored, before defaults are applied. */
export type PostInput = z.input<typeof PostSchema>

/**
 * A post guaranteed past the draft gate.
 *
 * `generateStaticParams` accepts only these, which makes it structurally
 * impossible to prerender a draft rather than merely unlikely.
 */
export type PublishedPost = Post & { draft: false }

/* -------------------------------------------------------------------------- */
/*  The ContentSource contract                                                */
/* -------------------------------------------------------------------------- */

/**
 * How a newly published post becomes a prerendered route.
 *
 * This matters more than it looks. Next.js does not re-run
 * `generateStaticParams` during ISR, so a slug that did not exist at build time
 * was never in the prerendered set. Each strategy states how that gap is closed:
 *
 * - `'build'`       Content lives in the repository, so publishing is a deploy.
 *                   Nothing extra to do. This is what `mdxSource` returns.
 * - `'deploy-hook'` The publish webhook triggers a rebuild and waits for it to
 *                   succeed before pinging IndexNow. Pinging first invites a
 *                   crawler to a URL that does not exist yet.
 * - `'on-demand'`   The adapter accepts a request-time first render. Only use
 *                   this if you have measured what that render actually returns
 *                   to a non-JavaScript crawler.
 */
export type PrerenderStrategy = 'build' | 'deploy-hook' | 'on-demand'

export interface PostQuery {
  /**
   * Drafts must be opted into, never out of. Defaults to `false` everywhere,
   * including in adapters you write yourself.
   */
  readonly includeDrafts?: boolean
  /** Reserved for multi-locale. v1 adapters may ignore this. */
  readonly locale?: string
}

/**
 * The storage interface. One implementation ships in v1 (`mdxSource`); the rest
 * are drop-in replacements.
 *
 * Two contract points that are easy to miss and expensive to get wrong:
 *
 * 1. **Hydration.** `Post.author` and `Post.category` are fully hydrated
 *    objects, and `getAllPosts()` must return them in a single round trip. Build
 *    time iterates every post, so a lazy reference becomes an N+1 against your
 *    database at exactly the moment you have the most posts. For a SQL adapter
 *    this means a join. Contract test 3 asserts the call count does not scale
 *    with post count.
 *
 * 2. **Errors.** `getPost` returns `null` only when the post is genuinely
 *    absent, which the route turns into `notFound()`. Every other failure
 *    throws `AgentBlogSourceError`. There is no `Result` type here on purpose:
 *    Next.js calls these functions itself, and at build time throwing is
 *    correct. A source that cannot be reached should fail the build rather than
 *    deploy a site whose sitemap quietly lost half its URLs.
 *
 * ---------------------------------------------------------------------------
 * `Post.body` IS EXECUTED AS CODE. THIS IS THE ONE THING TO GET RIGHT
 * ---------------------------------------------------------------------------
 * The body you return is compiled as MDX and evaluated in your server process by
 * `renderMdx`. MDX is a programming language: an expression in the document has
 * `process` and `process.env` in scope, and no option anywhere in the toolchain
 * turns that off, because evaluating expressions is what MDX is for.
 *
 * So the security question an adapter has to answer is not "is this string
 * well-formed", it is **who can write to this store**. `mdxSource` answers it
 * cleanly: bodies are files in the repository, so writing one requires commit
 * access and passes through review and a deploy.
 *
 * An adapter over a database, a CMS, or an HTTP API answers it only if every
 * write path is restricted to authors you would give a shell account to. If it
 * is not, do not return raw MDX from `getPost` and `getAllPosts`. Render the
 * stored content through a CommonMark pipeline with raw HTML disabled and return
 * the result, or gate MDX behind an author role your own code checks. Treating
 * a `body` column as untrusted input after it has reached `renderMdx` is too
 * late.
 */
export interface ContentSource<Strategy extends PrerenderStrategy = PrerenderStrategy> {
  /** How new posts enter the prerendered set. See `PrerenderStrategy`. */
  readonly prerenderStrategy: Strategy

  /** Stable identifier used in error messages and diagnostics, e.g. `'mdx'`. */
  readonly name: string

  getAllPosts(opts?: PostQuery): Promise<PublishedPost[]>
  getPost(slug: Slug, opts?: PostQuery): Promise<Post | null>
  getAllCategories(): Promise<Category[]>
  getAllAuthors(): Promise<Author[]>
  getPostsByCategory(slug: Slug): Promise<PublishedPost[]>
  getPostsByAuthor(slug: Slug): Promise<PublishedPost[]>

  /**
   * Related posts, editorial first.
   *
   * Returns `post.relatedPosts` in the order the author wrote them, then fills
   * to `limit` with computed matches: shared category, then shared tags, then
   * recency. Explicit beats computed; computed fills the gap.
   */
  getRelatedPosts(post: Post, limit: number): Promise<PublishedPost[]>
}

/* -------------------------------------------------------------------------- */
/*  Errors                                                                    */
/* -------------------------------------------------------------------------- */

/**
 * A content source failed to answer.
 *
 * Thrown for transport failures, auth failures, and malformed responses. Never
 * thrown for "no such post", which is `null`.
 */
export class AgentBlogSourceError extends Error {
  /** The `ContentSource.name` that failed, e.g. `'mdx'`. */
  readonly source: string
  /** The method that failed, e.g. `'getAllPosts'`. */
  readonly operation: string

  // Fields are declared and assigned rather than written as constructor
  // parameter properties, so this file runs unchanged under every transpiler
  // including Node's own type stripping, which does not support them.
  constructor(source: string, operation: string, options?: { cause?: unknown }) {
    super(`[agentblog:${source}] ${operation} failed`, options)
    this.name = 'AgentBlogSourceError'
    this.source = source
    this.operation = operation
  }
}

/** One issue from a failed parse, flattened so this type needs no Zod import. */
export interface ContentIssue {
  readonly path: readonly (string | number | symbol)[]
  readonly message: string
}

/**
 * A post, an author record, or the config failed validation.
 *
 * `location` is always a file path or a record id. "Invalid input" at build time
 * is worthless when two hundred files could be the culprit, so a malformed post
 * has to name itself.
 */
export class AgentBlogContentError extends Error {
  /** File path or record id of the offending content. */
  readonly location: string
  /** Every issue found, so one parse reports every problem rather than the first. */
  readonly issues: readonly ContentIssue[]

  constructor(location: string, issues: readonly ContentIssue[]) {
    const detail = issues
      .map((i) => `  ${i.path.map(String).join('.') || '(root)'}: ${i.message}`)
      .join('\n')
    super(`[agentblog] ${location} is invalid:\n${detail}`)
    this.name = 'AgentBlogContentError'
    this.location = location
    this.issues = issues
  }
}
registry/blog/lib/posts.ts
/**
 * The content facade. Every route reads posts through this file.
 *
 * ---------------------------------------------------------------------------
 * WHY A FACADE INSTEAD OF CALLING `config.source` DIRECTLY
 * ---------------------------------------------------------------------------
 * `config.source` is a `ContentSource`: MDX files today, a database or a hosted
 * service later. Swapping it should be one line in `agentblog.config.ts`. That
 * promise only holds if nothing outside this file knows the adapter exists, so
 * routes import `@/lib/posts` and never `config.source`. When the adapter grows
 * a new capability, or an old one needs a shim, this file absorbs it.
 *
 * The facade also owns three things the adapter contract deliberately leaves
 * out, because they are presentation concerns rather than storage concerns:
 * pagination, tag aggregation, and slug validation.
 *
 * ---------------------------------------------------------------------------
 * SLUGS ARRIVE AS UNVALIDATED STRINGS
 * ---------------------------------------------------------------------------
 * `ContentSource` takes a branded `Slug`, which can only be produced by parsing.
 * Route params are plain strings straight off the URL bar, so every entry point
 * here parses first. A slug that cannot be valid returns `null` or `[]` rather
 * than throwing: `/blog/../../etc/passwd` is a 404, not a 500. Genuine failures
 * (the source is unreachable, a post is malformed) still throw, because at build
 * time a thrown error is the correct outcome and a swallowed one ships a sitemap
 * that quietly lost half its URLs.
 *
 * ---------------------------------------------------------------------------
 * CACHING
 * ---------------------------------------------------------------------------
 * React's `cache()` deduplicates within a single render pass and, at build time,
 * within a single page's `generateStaticParams` plus `generateMetadata` plus
 * component tree. That matters because a post page asks for the same data three
 * times through three different framework entry points. The cache keys are
 * primitives, not option objects, because `cache()` compares arguments by
 * identity and a fresh `{ includeDrafts: false }` literal would miss on every
 * call.
 *
 * @see https://docs.agentblog.dev/reference/content-sources
 */
import 'server-only'

import { cache } from 'react'

import { config, tagSlug } from '@/lib/config'
import { Slug } from '@/lib/schemas'
import type { Author, Category, Post, PostQuery, PublishedPost } from '@/lib/types'

/* -------------------------------------------------------------------------- */
/*  Cached adapter calls. Primitive arguments only, so `cache()` actually hits. */
/* -------------------------------------------------------------------------- */

const allPosts = cache(
  (includeDrafts: boolean, locale: string | undefined): Promise<PublishedPost[]> =>
    config.source.getAllPosts(buildQuery(includeDrafts, locale)),
)

const postBySlug = cache(
  (slug: Slug, includeDrafts: boolean, locale: string | undefined): Promise<Post | null> =>
    config.source.getPost(slug, buildQuery(includeDrafts, locale)),
)

const allCategories = cache((): Promise<Category[]> => config.source.getAllCategories())

const allAuthors = cache((): Promise<Author[]> => config.source.getAllAuthors())

const postsByCategory = cache((slug: Slug): Promise<PublishedPost[]> =>
  config.source.getPostsByCategory(slug),
)

const postsByAuthor = cache((slug: Slug): Promise<PublishedPost[]> =>
  config.source.getPostsByAuthor(slug),
)

/**
 * Build a `PostQuery` without ever writing an explicit `undefined`.
 * `exactOptionalPropertyTypes` treats `{ locale: undefined }` and `{}` as
 * different types, and adapters are entitled to tell them apart.
 */
function buildQuery(includeDrafts: boolean, locale: string | undefined): PostQuery {
  return locale === undefined ? { includeDrafts } : { includeDrafts, locale }
}

/** `null` for anything that could not be a slug, so a bad URL is a 404. */
function parseSlug(slug: string): Slug | null {
  const parsed = Slug.safeParse(slug)
  return parsed.success ? parsed.data : null
}

/* -------------------------------------------------------------------------- */
/*  Reads                                                                     */
/* -------------------------------------------------------------------------- */

/**
 * Every published post. Ordering is the adapter's responsibility; `mdxSource`
 * returns newest first.
 *
 * `generateStaticParams` feeds on this and must return **every** slug, never a
 * page of them. Slicing here is what turns a prerendered blog into a partly
 * prerendered one, and the missing posts are invisible until a crawler asks.
 */
export function getAllPosts(opts?: PostQuery): Promise<PublishedPost[]> {
  return allPosts(opts?.includeDrafts ?? false, opts?.locale)
}

/** One post, or `null` when the slug is absent or malformed. */
export function getPost(slug: string, opts?: PostQuery): Promise<Post | null> {
  const parsed = parseSlug(slug)
  if (parsed === null) return Promise.resolve(null)
  return postBySlug(parsed, opts?.includeDrafts ?? false, opts?.locale)
}

/** Every category, including ones with no published posts. */
export function getAllCategories(): Promise<Category[]> {
  return allCategories()
}

/** Every author record, whether or not they have published. */
export function getAllAuthors(): Promise<Author[]> {
  return allAuthors()
}

/** One category by slug, or `null`. Resolved from the full list, which is cached. */
export async function getCategory(slug: string): Promise<Category | null> {
  const parsed = parseSlug(slug)
  if (parsed === null) return null
  const categories = await allCategories()
  return categories.find((category) => category.slug === parsed) ?? null
}

/** One author by slug, or `null`. */
export async function getAuthor(slug: string): Promise<Author | null> {
  const parsed = parseSlug(slug)
  if (parsed === null) return null
  const authors = await allAuthors()
  return authors.find((author) => author.slug === parsed) ?? null
}

/** Published posts in a category. `[]` for an unknown or malformed slug. */
export function getPostsByCategory(slug: string): Promise<PublishedPost[]> {
  const parsed = parseSlug(slug)
  if (parsed === null) return Promise.resolve([])
  return postsByCategory(parsed)
}

/** Published posts by an author. `[]` for an unknown or malformed slug. */
export function getPostsByAuthor(slug: string): Promise<PublishedPost[]> {
  const parsed = parseSlug(slug)
  if (parsed === null) return Promise.resolve([])
  return postsByAuthor(parsed)
}

/**
 * Published posts carrying a tag, matched case insensitively.
 *
 * Tags are free text rather than slugs, so this is a filter over the full post
 * list rather than an adapter call. That is deliberate: adding a tag index to
 * `ContentSource` would make every future adapter implement it, for a feature
 * whose whole point is that it needs no schema.
 */
export async function getPostsByTag(tag: string): Promise<PublishedPost[]> {
  // The argument arrives from a route param, so it is already the slug form.
  // Comparing slug to slug is what keeps `/blog/tag/ai-crawlers` and a post
  // tagged "AI crawlers" describing the same set.
  const needle = tagSlug(tag)
  if (needle === '') return []
  const posts = await getAllPosts()
  return posts.filter((post) => post.tags.some((t) => tagSlug(t) === needle))
}

/**
 * Every tag with its published post count, most used first, then alphabetical.
 *
 * `tag` is the display text as first authored, `slug` is its URL form, and the
 * count is what `config.noindexTagsBelow` compares against, so the tag route can
 * decide whether a given tag page is worth indexing before it renders. Returning
 * both spellings is what keeps `generateStaticParams` from emitting a route
 * segment containing a space.
 */
export async function getAllTags(): Promise<{ tag: string; slug: string; count: number }[]> {
  const posts = await getAllPosts()
  const counts = new Map<string, { tag: string; slug: string; count: number }>()

  for (const post of posts) {
    for (const raw of post.tags) {
      const tag = raw.trim()
      if (tag === '') continue
      // Keyed by slug rather than by lowercase text, so "AI crawlers" and
      // "ai-crawlers" collapse into the one tag page they both link to.
      const key = tagSlug(tag)
      if (key === '') continue
      const existing = counts.get(key)
      // First spelling seen wins, so display casing stays stable across builds.
      if (existing) existing.count += 1
      else counts.set(key, { tag, slug: key, count: 1 })
    }
  }

  return [...counts.values()].sort((a, b) => b.count - a.count || a.tag.localeCompare(b.tag))
}

/**
 * Related posts: the author's editorial list first, in their order, then
 * computed matches to fill the gap. The precedence lives in the adapter, which
 * is the only place that can resolve editorial slugs in one round trip.
 */
export function getRelatedPosts(post: Post, limit = 3): Promise<PublishedPost[]> {
  return config.source.getRelatedPosts(post, limit)
}

/**
 * One page of the index, clamped to a page that exists.
 *
 * `page` comes from a URL segment, so it can be `0`, `-3`, `NaN`, or larger than
 * the last page. Clamping rather than 404ing keeps `/blog/page/999` a real page
 * with real links instead of a soft 404 that a crawler has to interpret. The
 * returned `page` is the clamped value, so pagination links stay consistent.
 */
export async function getPage(
  page: number,
): Promise<{ posts: PublishedPost[]; totalPages: number; page: number }> {
  const posts = await getAllPosts()
  const perPage = config.postsPerPage
  const totalPages = Math.max(1, Math.ceil(posts.length / perPage))

  const requested = Number.isFinite(page) ? Math.trunc(page) : 1
  const current = Math.min(Math.max(requested, 1), totalPages)
  const start = (current - 1) * perPage

  return { posts: posts.slice(start, start + perPage), totalPages, page: current }
}
registry/blog/lib/metadata.ts
/**
 * Shared metadata defaults. This file exists because of one Next.js rule.
 *
 * ===========================================================================
 * NEXT.JS MERGES METADATA SHALLOWLY
 * ===========================================================================
 * From the `generateMetadata` reference: metadata objects from multiple segments
 * are **shallowly** merged, and *"metadata with nested fields such as
 * `openGraph` and `robots` that are defined in an earlier segment are
 * overwritten by the last segment to define them."*
 *
 * Read that again with a real example. The root layout sets:
 *
 *     openGraph: { siteName: 'Your Brand', locale: 'en_US', type: 'website' }
 *
 * The post page sets:
 *
 *     openGraph: { type: 'article', title, description, url, images }
 *
 * Because the page defined `openGraph` **at all**, the root layout's entire
 * `openGraph` object is discarded. Every post ships an Open Graph card with no
 * `og:site_name` and no `og:locale`. Nothing errors. No validator complains. The
 * types are correct. The only way to notice is to view source on a built page or
 * paste a URL into a social preview debugger.
 *
 * The identical trap applies to `robots`. A page that sets `robots` at all
 * replaces the layout's `googleBot` block, and `max-snippet: -1` is what permits
 * full length snippets in AI Overviews and AI Mode. Losing it is a retrieval
 * regression dressed up as a formatting detail.
 *
 * ===========================================================================
 * THE RULE THIS FILE ENFORCES
 * ===========================================================================
 * Every route segment that sets `openGraph` or `robots` spreads the defaults
 * below, or calls one of the two builders. Nothing hand rolls a metadata object.
 * If you are adding a route, copy an existing one rather than writing
 * `openGraph` from scratch.
 *
 * ===========================================================================
 * AN ABSENT KEY AND A KEY SET TO `undefined` ARE DIFFERENT THINGS HERE
 * ===========================================================================
 * READ THIS BEFORE TIDYING ANY OBJECT BELOW INTO SHORTHAND.
 *
 * Next.js merges a file-based `opengraph-image` route into a segment's metadata
 * only when that segment does not already own the key. The check in
 * `next/dist/lib/metadata/resolve-metadata.js` is literally:
 *
 *     if (openGraph && !source?.openGraph?.hasOwnProperty('images')) { ... }
 *
 * `hasOwnProperty`, not a truthiness test. So writing `images` with shorthand,
 * `{ ...defaults, images }`, puts the key on the object even when the value is
 * `undefined`, the merge is skipped, and the page ships with no `og:image` and
 * no `twitter:image` while `opengraph-image.tsx` is still built and referenced
 * by nothing. Nothing errors, no type is wrong, and the only symptom is a blank
 * card in a social preview debugger.
 *
 * Both builders therefore use a conditional spread,
 * `...(images?.length ? { images } : {})`, so an unset image leaves the key
 * absent and the generated card wins. The length test rather than a plain
 * truthiness test is there because `images: []` is truthy and would block the
 * merge exactly like `images: undefined` does.
 *
 * The same reasoning applies to any optional key here: omit it, never assign
 * `undefined` to it, because a `for (const key in metadata)` loop in the same
 * merge visits keys whose value is `undefined` and overwrites the parent's.
 *
 * Key casing is exactly as Next.js 16 declares it and is not stylistic:
 * `googleBot` is camelCase, while the granular directives are kebab-case string
 * literals (`'max-snippet'`, `'max-image-preview'`, `'max-video-preview'`).
 * Use `index: false` and `follow: false`; `noindex` and `nofollow` are typed
 * `never` and deprecated.
 *
 * @see https://docs.agentblog.dev/reference/files
 */
import type { Metadata } from 'next'

import { absoluteUrl, authorUrl, config, postUrl } from '@/lib/config'
import type { Post } from '@/lib/types'

/**
 * The nested Open Graph fields a page would otherwise destroy. Spread these
 * first, then add the page specific keys.
 */
export const openGraphDefaults: { siteName: string; locale: string } = {
  siteName: config.brand.name,
  locale: config.locale,
}

/**
 * Googlebot directives that unlock full snippets, large image previews, and
 * unlimited video previews in classic results and in AI surfaces alike.
 *
 * `-1` means "no limit". `'large'` is the difference between a thumbnail and a
 * full width image in a rich result.
 */
export const googleBotDefaults = {
  index: true,
  follow: true,
  'max-image-preview': 'large',
  'max-snippet': -1,
  'max-video-preview': -1,
} as const

/**
 * The default `robots` object, with `googleBotDefaults` nested under `googleBot`
 * where Next.js expects it.
 *
 * Prefer `buildPostMetadata` or `buildListMetadata` over reaching for this
 * directly. It is exported for the root layout and for any route this block does
 * not ship.
 */
export const robotsDefaults: Metadata['robots'] = {
  index: true,
  follow: true,
  googleBot: googleBotDefaults,
}

/** The RSS link that rides along in `alternates.types` on every blog surface. */
function rssAlternate(): Record<string, { url: string; title: string }[]> {
  return {
    'application/rss+xml': [{ url: absoluteUrl('/feed.xml'), title: `${config.brand.name} blog` }],
  }
}

/**
 * Turn a hero image into the Open Graph and Twitter image shape.
 *
 * `absoluteUrl` passes an already absolute URL through untouched, so a CDN
 * hosted hero and a `/public` relative one both work without a branch here.
 */
function heroImages(post: Post): { url: string; alt: string }[] | undefined {
  if (!post.heroImage) return undefined
  return [{ url: absoluteUrl(post.heroImage), alt: post.heroAlt ?? post.title }]
}

/**
 * The complete `Metadata` object for a post route.
 *
 * Four things it gets right that a hand written object usually does not:
 * the canonical goes in `alternates.canonical` (not a raw `<link>`), the feed
 * goes in `alternates.types`, `openGraph` carries the full article shape with
 * the site defaults intact, and `robots` carries the `googleBot` block.
 */
export function buildPostMetadata(post: Post): Metadata {
  const url = postUrl(post.slug)
  const images = heroImages(post)

  return {
    title: post.title,
    description: post.description,

    authors: [{ name: post.author.name, url: authorUrl(post.author.slug) }],
    // Omitted rather than set to `undefined` when the post has no tags. See the
    // header: a key present with an `undefined` value still overwrites whatever
    // the parent segment resolved.
    ...(post.tags.length > 0 ? { keywords: post.tags } : {}),

    alternates: {
      // The canonical is absolute on purpose. A relative one is resolved against
      // `metadataBase`, which is one more thing that can be wrong in a way that
      // only shows up in production.
      canonical: url,
      types: rssAlternate(),
    },

    robots: robotsDefaults,

    openGraph: {
      ...openGraphDefaults,
      type: 'article',
      title: post.title,
      description: post.description,
      url,
      publishedTime: post.datePublished,
      modifiedTime: post.dateModified,
      authors: [post.author.name],
      section: post.category.name,
      tags: post.tags,
      // Conditional spread, never `images`. With no hero image the key must be
      // absent so `app/blog/[slug]/opengraph-image.tsx` is merged in.
      ...(images?.length ? { images } : {}),
    },

    twitter: {
      card: 'summary_large_image',
      title: post.title,
      description: post.description,
      ...(images?.length ? { images } : {}),
    },
  }
}

/**
 * The blog-level Open Graph card, generated by `app/blog/opengraph-image.tsx`.
 *
 * A list surface cannot get this card the way a post does. The file-based merge
 * described in the header only fires for a static image file **in the same route
 * segment**, and only `app/blog/[slug]/` has one. Every other segment defines
 * `openGraph` for its own `og:url` and `og:title`, which replaces the parent's
 * resolved object outright and takes the parent's card with it. Measured on a
 * real build: before this constant existed, `/blog`, every category hub, every
 * tag page, and every author page shipped six `og:*` tags and no `og:image`,
 * while `/` (which defines no `openGraph` of its own) had one.
 *
 * Naming the route here is what closes that hole. The route ships in the same
 * registry item as the surfaces that point at it, `@agentblog/blog-routes`, so
 * the image and its callers cannot be installed apart.
 *
 * This is a blog path rather than a site path, and that is the point. Until
 * AgentBlog 0.4 the card was `app/opengraph-image.tsx` at the app root, which
 * meant installing a blog silently claimed the social card for the host site's
 * whole domain. Your own `app/opengraph-image.*` now wins for everything
 * outside `/blog`, and nothing this block ships will overwrite it.
 */
const BLOG_OG_IMAGE = '/blog/opengraph-image'

/**
 * The dimensions and MIME type of the card `app/blog/opengraph-image.tsx`
 * produces, declared here because this file cannot ask the route for them.
 *
 * WHY THESE ARE WRITTEN OUT RATHER THAN DERIVED
 * When Next.js merges a file-based `opengraph-image` itself, it emits
 * `og:image:type`, `og:image:width`, and `og:image:height` alongside the URL,
 * and it appends its own content hash to the URL:
 * `…/opengraph-image?9e1e2d0f397a82a1`. Naming the route by hand, which is the
 * only way a list surface gets the card at all (see `BLOG_OG_IMAGE`), gets none
 * of that. Measured on a real build, every list surface shipped the bare URL
 * with only `og:image:alt`, so a scraper had no dimensions to lay out with and
 * frequently declined to show a large card.
 *
 * Three of the four are recoverable here and are set below. The hash is not:
 * it is Next's cache key, computed from the route's compiled output at build
 * time, and there is no exported API for reading it from application code.
 * Importing the route module would not help either, since the hash
 * comes from the build and not from the module, and a lib importing a route is
 * the wrong direction regardless.
 *
 * THE TRADEOFF, STATED PLAINLY
 * The URL is stable across rebuilds, which is what a cache key exists to avoid.
 * Rebrand (change `config.brand`) and the bytes at `/blog/opengraph-image` change
 * while its URL does not, so a social scraper or CDN that already cached the old
 * card keeps serving it until its own TTL expires. There is no cache bust
 * available from this file. What to do about it: re-scrape the affected URLs
 * through the platform's own tool after a rebrand (Facebook's Sharing Debugger,
 * LinkedIn's Post Inspector, X's Card Validator each refetch on demand), and
 * accept that everything else refreshes on its own schedule. If that is not good
 * enough for your rebrand, pass `images` explicitly at the call site with a
 * versioned filename you control.
 *
 * Keep these numbers equal to `OG_SIZE` in `lib/og-card.tsx`, which is what both
 * card routes export as their `size`. Dimensions that disagree with the file are
 * worse than none: they are what a scraper lays out against before it ever
 * fetches the image.
 */
const BLOG_OG_IMAGE_META = { width: 1200, height: 630, type: 'image/png' } as const

/**
 * Metadata for a list surface: the blog index, a category hub, a tag page, an
 * author page.
 *
 * `index` defaults to `true`. Pass `false` for a thin tag page, and note that
 * `follow` stays `true` in that case so the posts linked from it are still
 * discovered. A blanket `noindex, nofollow` on a tag page hides the posts too.
 *
 * `images` is optional and falls back to the site card. Pass one only when the
 * surface has an image of its own worth showing, such as a category with real
 * artwork.
 */
export function buildListMetadata(input: {
  title: string
  description: string
  path: string
  index?: boolean
  images?: string[]
}): Metadata {
  const url = absoluteUrl(input.path)
  const indexable = input.index ?? true
  // A caller-supplied image gets no dimensions, because we do not know them and
  // a wrong `og:image:width` is worse than a missing one. The blog card does,
  // because `lib/og-card.tsx` declares them. See `BLOG_OG_IMAGE_META`.
  const images = input.images?.length
    ? input.images.map((image) => ({ url: absoluteUrl(image) }))
    : [{ url: absoluteUrl(BLOG_OG_IMAGE), alt: config.brand.name, ...BLOG_OG_IMAGE_META }]

  return {
    title: input.title,
    description: input.description,

    alternates: { canonical: url, types: rssAlternate() },

    robots: indexable
      ? robotsDefaults
      : { index: false, follow: true, googleBot: { ...googleBotDefaults, index: false } },

    openGraph: {
      ...openGraphDefaults,
      type: 'website',
      title: input.title,
      description: input.description,
      url,
      // Always set, and always non-empty. Unlike a post, a list surface has no
      // segment-local `opengraph-image.tsx` to be merged in, so leaving the key
      // absent here produces a card with no image at all. See `BLOG_OG_IMAGE`.
      images,
    },

    twitter: {
      card: 'summary_large_image',
      title: input.title,
      description: input.description,
      images,
    },
  }
}
registry/blog/lib/preflight.ts
/**
 * Build time configuration lint. Warns. Never throws. Never runs per request.
 *
 * ===========================================================================
 * WHY A FILE IN YOUR REPO LINTS YOUR CONFIG
 * ===========================================================================
 * A shadcn registry can write files. It cannot patch them. The single highest
 * value setting AgentBlog needs (`htmlLimitedBots` in `next.config.ts`) is a
 * modification to a file you already own, so installing the block through the
 * registry alone leaves it unset. The result is a blog that looks perfect and
 * hands AI crawlers `<title>` and `<meta>` inside `<body>` on any page with
 * deferred metadata.
 *
 * That failure is completely silent. This module is what makes it loud. It reads
 * `next.config.*` off disk at build and dev time and prints what is missing,
 * using the same predicates `agentblog doctor` uses, so the two tools cannot
 * drift and contradict each other in front of you.
 *
 * `app/blog/layout.tsx` imports and calls it. Do not delete that call to silence
 * the warning; set `preflight: false` in `agentblog.config.ts` instead. The
 * difference matters: the config flag keeps working when a future edit breaks
 * something new, and deleting the import means nothing ever tells you again.
 *
 * ===========================================================================
 * CONSTRAINTS, ALL BINDING
 * ===========================================================================
 * 1. **Warn, never throw.** Failing a production build over a config lint is
 *    hostile and gets the import deleted, which costs more than the lint is
 *    worth. Every path here is wrapped so a filesystem surprise cannot become a
 *    failed deploy.
 * 2. **Once per process.** A module scope flag, not a per render check. This
 *    runs inside a layout, and a layout renders once per route.
 * 3. **No cost at request time.** See the phase detection below.
 * 4. **No dependencies.** `lib/preflight-checks.ts` is dependency free by
 *    contract and is generated from `packages/checks`; this file adds only
 *    `node:fs` and `node:path`, which is why it carries `server-only`.
 * 5. **A missing `next.config.*` is a warning, not a crash.**
 *
 * @see https://docs.agentblog.dev/installation
 */
import 'server-only'

import { existsSync, readFileSync } from 'node:fs'
import { join } from 'node:path'

import { config } from '@/lib/config'
import { bySeverity, checkNextConfig } from '@/lib/preflight-checks'
import type { Finding } from '@/lib/preflight-checks'

/** In the order Next.js itself resolves them. */
const CONFIG_FILENAMES = [
  'next.config.ts',
  'next.config.mts',
  'next.config.mjs',
  'next.config.js',
] as const

/** Constraint 2: module scope, so repeated renders cost nothing. */
let hasRun = false

/**
 * Run the config lint once and print any findings.
 *
 * Safe to call from anywhere on the server. Calling it twice is a no op.
 */
export function preflight(): void {
  if (hasRun) return
  hasRun = true

  try {
    if (!shouldRun()) return
    report([...checkConfigResolution(), ...checkNextConfigOnDisk()])
  } catch {
    // Constraint 1. A lint that can fail a build is a lint people delete.
  }
}

/**
 * Whether this process is a build or a dev server rather than a live request.
 *
 * Being honest about what is actually detected here: `NEXT_PHASE` is set by
 * Next.js and is not a documented public API, so this is a best effort signal
 * rather than a guarantee. The fallback is `NODE_ENV`, which is `'production'`
 * both during `next build` and while serving production traffic, so on its own
 * it cannot tell those two apart.
 *
 * The combination gives: run during a production build (phase matches), run in
 * development (NODE_ENV is not production), stay silent while serving production
 * traffic. If `NEXT_PHASE` ever disappears the check degrades to development
 * only, which is the safe direction to fail in.
 */
function shouldRun(): boolean {
  if (!config.preflight) return false
  if (process.env.NEXT_PHASE === 'phase-production-build') return true
  return process.env.NODE_ENV !== 'production'
}

/**
 * Confirm `lib/config.ts` actually resolved the user's config file.
 *
 * `agentblog.config.ts` lives at the project root while `@/` maps to `src/` in a
 * `src/` layout, so `import userConfig from '@/agentblog.config'` can resolve to
 * nothing in a project nobody ran `agentblog init` on. The types say this is
 * unreachable, which is precisely why it needs a runtime check: the type system
 * is describing what the import is supposed to produce, not what a broken alias
 * actually produced.
 */
function checkConfigResolution(): Finding[] {
  const resolved: Partial<typeof config> | undefined = config

  if (!resolved || !resolved.siteUrl) {
    return [
      {
        id: 'config-unresolved',
        severity: 'error',
        message:
          'lib/config.ts resolved no `siteUrl`. `@/agentblog.config` most likely did not ' +
          'resolve, which happens in a `src/` layout because the config file belongs at the ' +
          'project root while `@/` points at `src/`. Every canonical URL, sitemap entry, and ' +
          'JSON-LD id is composed from `siteUrl`, so nothing downstream is correct until ' +
          'this is fixed.',
        remedy:
          'npx agentblog@latest doctor --fix, or add an `"@/agentblog.config"` path to ' +
          'tsconfig.json pointing at the root file.',
        fixable: true,
      },
    ]
  }

  return []
}

/** Read the first `next.config.*` that exists and run the shared predicates. */
function checkNextConfigOnDisk(): Finding[] {
  const root = process.cwd()

  for (const filename of CONFIG_FILENAMES) {
    /*
     * `turbopackIgnore` on all three calls, and it is not a workaround.
     *
     * Turbopack traces filesystem access statically. It cannot prove which file
     * a computed path resolves to, so it assumes the worst and traces the whole
     * project, which copies your source tree and `public/` next to the server
     * bundle. That inflates every deployment and can trip a platform size limit.
     *
     * These three calls genuinely need a computed path: this function exists to
     * read whichever `next.config.*` you happen to have, at your project root.
     * There is nothing to declare statically. The annotation tells Turbopack
     * that we take responsibility for the dependency, and the honest statement
     * of that responsibility is: this runs at build and dev time only, it reads
     * exactly one file that is already part of your project, and it never runs
     * at request time (see `shouldRun`). Nothing here needs to be bundled.
     */
    const path = join(/* turbopackIgnore: true */ root, filename)
    if (!existsSync(/* turbopackIgnore: true */ path)) continue

    try {
      return checkNextConfig({
        source: readFileSync(/* turbopackIgnore: true */ path, 'utf8'),
        path: filename,
      })
    } catch {
      // Unreadable is not the same as absent, and neither is worth a build failure.
      return []
    }
  }

  return [
    {
      id: 'next-config-missing',
      severity: 'warning',
      message:
        'No next.config.ts, .mts, .mjs, or .js was found in the working directory, so ' +
        'AgentBlog cannot confirm that `htmlLimitedBots` and `images.qualities` are set. In a ' +
        'monorepo this usually means the process is running from the workspace root rather ' +
        'than the app directory.',
      remedy: 'npx agentblog@latest doctor --fix, run from the Next.js app directory.',
      fixable: false,
    },
  ]
}

/** One grouped `console.warn`, most severe first, so build logs stay readable. */
function report(findings: readonly Finding[]): void {
  if (findings.length === 0) return

  /*
   * One line per finding, deliberately.
   *
   * `next build` forks worker processes and each one runs this module, so an
   * unfixed config prints this block several times per build. The module-scope
   * memo cannot dedupe across processes and neither can an environment marker,
   * because the workers are forked before the first report runs.
   *
   * That leaves controlling the volume rather than the count. A four line entry
   * repeated five times is forty lines of build output, and the plan's own risk
   * table names "users delete the preflight import to silence the warning" as a
   * real failure mode. The full explanation lives in `agentblog doctor`, which
   * runs once and can also fix what it finds, so the job here is to be
   * unmissable and short, then hand off.
   */
  const sorted = [...findings].sort(bySeverity)
  const lines = sorted.map(
    (finding) => `  AgentBlog [${finding.severity}] ${finding.id}: ${finding.message}`,
  )
  lines.push('  AgentBlog: run `npx agentblog@latest doctor --fix` to repair these.')

  console.warn(lines.join('\n'))
}
registry/blog/lib/preflight-checks.ts
/**
 * ! GENERATED FILE. DO NOT EDIT.
 *
 * Source of truth: `packages/checks/src/core.ts` in the AgentBlog repository.
 * Regenerate with `pnpm codegen`.
 *
 * This file is copied verbatim so that the code running in your project is the
 * same code the AgentBlog test suite runs against. Edits here are safe to make
 * in your own repository once installed, but they will be overwritten if you
 * reinstall the block.
 */
/**
 * AgentBlog configuration checks.
 *
 * ===========================================================================
 * THIS FILE IS COPIED VERBATIM INTO EVERY CONSUMER PROJECT
 * ===========================================================================
 * `scripts/codegen.mjs` copies this file to
 * `apps/web/registry/blog/lib/preflight-checks.ts`, which the registry then
 * writes into a consumer's repository as `lib/preflight-checks.ts`. CI fails if
 * the copy has drifted.
 *
 * Two consequences, both binding:
 *
 *   1. **Zero imports.** Not `zod`, not `node:fs`, not another file in this
 *      package. The consumer's copy has no `node_modules` entry to import from.
 *      Everything here is pure string and RegExp work over file contents that
 *      the caller supplies.
 *
 *   2. **No side effects.** The caller decides whether to warn, throw, or fix.
 *      `lib/preflight.ts` warns at build time; `agentblog doctor` reports and
 *      can fix. Both read the same predicates, which is the only way those two
 *      tools cannot drift and disagree in front of a user.
 *
 * @see https://docs.agentblog.dev/reference/cli
 */

/* ========================================================================== */
/*  Severity and findings                                                     */
/* ========================================================================== */

/**
 * How badly a check failed.
 *
 * The distinction between `error` and `warning` is not cosmetic. A narrowed
 * `htmlLimitedBots` is an *active regression* against Googlebot and every social
 * preview bot, whereas a missing one is only a missed opportunity. Reporting
 * both as "problem" would bury the one that is actively costing traffic.
 */
export type Severity = 'error' | 'warning' | 'info'

export interface Finding {
  /** Stable id, e.g. `'html-limited-bots-narrowed'`. Used by `--fix` routing. */
  readonly id: string
  readonly severity: Severity
  /** One line, present tense, states what is wrong. */
  readonly message: string
  /** What to do about it. Omitted when there is nothing actionable. */
  readonly remedy?: string
  /** Whether `agentblog doctor --fix` can repair this automatically. */
  readonly fixable: boolean
}

/* ========================================================================== */
/*  The Next.js default HTML-limited bot list, vendored                       */
/* ========================================================================== */

/**
 * Next.js 16.3.0's built-in `htmlLimitedBots` pattern, copied verbatim from
 * `next/dist/shared/lib/router/utils/html-bots.js`.
 *
 * ---------------------------------------------------------------------------
 * WHY THIS IS VENDORED AND WHY IT MATTERS MORE THAN IT LOOKS
 * ---------------------------------------------------------------------------
 * Setting `htmlLimitedBots` in `next.config.ts` **overrides** this list. It does
 * not extend it. From the Next.js documentation:
 *
 *   "Specifying a `htmlLimitedBots` config will override the Next.js' default
 *    list."
 *
 * So a config that lists only the AI crawlers silently drops Googlebot, Bingbot,
 * Applebot, Twitterbot, LinkedInBot, Slackbot, Discordbot, facebookexternalhit,
 * and WhatsApp from HTML-limited treatment. Those bots then receive `<title>`
 * and `<meta>` inside `<body>` on any page with streamed metadata. That trades a
 * GEO win for an SEO and social-preview loss, invisibly.
 *
 * Every write path in AgentBlog therefore unions rather than replaces, and
 * `agentblog doctor` asserts a superset rather than merely asserting that GPTBot
 * appears.
 *
 * This list is a moving target. `scripts/assert-html-bots-current.mjs` diffs it
 * against the installed Next.js on every CI run, so the day Next adds a bot we
 * find out from a red build rather than from a customer.
 *
 * @see https://nextjs.org/docs/app/api-reference/config/next-config-js/htmlLimitedBots
 */
export const NEXT_DEFAULT_HTML_LIMITED_BOTS =
  '[\\w-]+-Google|Google-[\\w-]+|Chrome-Lighthouse|Slurp|DuckDuckBot|baiduspider|yandex|sogou|bitlybot|tumblr|vkShare|quora link preview|redditbot|ia_archiver|Bingbot|BingPreview|applebot|facebookexternalhit|facebookcatalog|Twitterbot|LinkedInBot|Slackbot|Discordbot|WhatsApp|SkypeUriPreview|Yeti|googleweblight'

/** The Next.js version the list above was vendored from. */
export const NEXT_DEFAULT_HTML_LIMITED_BOTS_VERSION = '16.3.0'

/**
 * A crawler AgentBlog adds to the HTML-limited set.
 *
 * `purpose` records what the operator says the bot does, because the answer
 * changes the advice we give. A `search` bot fetching your page is how you get
 * cited; a `train` bot is how you enter a model's weights. Those are different
 * decisions, and a site owner is entitled to make them separately.
 */
export interface AiCrawler {
  /** The user-agent token, matched case-insensitively as a substring. */
  readonly ua: string
  readonly operator: string
  readonly purpose: 'search' | 'train' | 'agent' | 'ads'
  /** Operator documentation, so a reader can verify rather than trust us. */
  readonly docs: string
}

/**
 * AI crawlers that fetch and read HTML.
 *
 * Deliberately excluded: `Google-Extended` and `Applebot-Extended`, which are
 * training opt-out tokens rather than crawlers. They never issue a request, so
 * adding them here would be noise. The Google and Apple families are already
 * covered by the vendored default list above.
 */
export const AI_CRAWLERS: readonly AiCrawler[] = [
  // OpenAI. Note that OAI-AdsBot does not respect robots.txt.
  {
    ua: 'GPTBot',
    operator: 'OpenAI',
    purpose: 'train',
    docs: 'https://developers.openai.com/api/docs/bots',
  },
  {
    ua: 'OAI-SearchBot',
    operator: 'OpenAI',
    purpose: 'search',
    docs: 'https://developers.openai.com/api/docs/bots',
  },
  {
    ua: 'ChatGPT-User',
    operator: 'OpenAI',
    purpose: 'agent',
    docs: 'https://developers.openai.com/api/docs/bots',
  },
  {
    ua: 'OAI-AdsBot',
    operator: 'OpenAI',
    purpose: 'ads',
    docs: 'https://developers.openai.com/api/docs/bots',
  },

  // Anthropic. IP ranges are published at https://claude.com/crawling/bots.json
  {
    ua: 'ClaudeBot',
    operator: 'Anthropic',
    purpose: 'train',
    docs: 'https://claude.com/crawling/bots.json',
  },
  {
    ua: 'Claude-SearchBot',
    operator: 'Anthropic',
    purpose: 'search',
    docs: 'https://claude.com/crawling/bots.json',
  },
  {
    ua: 'Claude-User',
    operator: 'Anthropic',
    purpose: 'agent',
    docs: 'https://claude.com/crawling/bots.json',
  },
  {
    ua: 'anthropic-ai',
    operator: 'Anthropic',
    purpose: 'train',
    docs: 'https://claude.com/crawling/bots.json',
  },

  // Perplexity
  {
    ua: 'PerplexityBot',
    operator: 'Perplexity',
    purpose: 'search',
    docs: 'https://docs.perplexity.ai/docs/resources/perplexity-crawlers',
  },
  {
    ua: 'Perplexity-User',
    operator: 'Perplexity',
    purpose: 'agent',
    docs: 'https://docs.perplexity.ai/docs/resources/perplexity-crawlers',
  },

  // Meta
  {
    ua: 'Meta-ExternalAgent',
    operator: 'Meta',
    purpose: 'train',
    docs: 'https://developers.facebook.com/docs/sharing/webmasters/web-crawlers',
  },
  {
    ua: 'Meta-ExternalFetcher',
    operator: 'Meta',
    purpose: 'agent',
    docs: 'https://developers.facebook.com/docs/sharing/webmasters/web-crawlers',
  },

  // Others with published crawler documentation
  {
    ua: 'CCBot',
    operator: 'Common Crawl',
    purpose: 'train',
    docs: 'https://commoncrawl.org/ccbot',
  },
  { ua: 'Bytespider', operator: 'ByteDance', purpose: 'train', docs: 'https://www.bytedance.com' },
  {
    ua: 'Amazonbot',
    operator: 'Amazon',
    purpose: 'search',
    docs: 'https://developer.amazon.com/amazonbot',
  },
  {
    ua: 'MistralAI-User',
    operator: 'Mistral',
    purpose: 'agent',
    docs: 'https://docs.mistral.ai/robots',
  },
  { ua: 'cohere-ai', operator: 'Cohere', purpose: 'train', docs: 'https://cohere.com' },
  {
    ua: 'DuckAssistBot',
    operator: 'DuckDuckGo',
    purpose: 'search',
    docs: 'https://duckduckgo.com/duckduckgo-help-pages/results/duckassistbot/',
  },
  {
    ua: 'Diffbot',
    operator: 'Diffbot',
    purpose: 'train',
    docs: 'https://docs.diffbot.com/docs/en/guides-diffbot-crawler',
  },
  { ua: 'YouBot', operator: 'You.com', purpose: 'search', docs: 'https://about.you.com/youbot/' },
  {
    ua: 'Applebot-Extended',
    operator: 'Apple',
    purpose: 'train',
    docs: 'https://support.apple.com/en-us/119829',
  },
] as const

/** Just the user-agent tokens, in declaration order. */
export const AI_CRAWLER_UAS: readonly string[] = AI_CRAWLERS.map((c) => c.ua)

/* ========================================================================== */
/*  Pattern union                                                             */
/* ========================================================================== */

/**
 * Split a regex alternation into trimmed, non-empty **top level** branches.
 *
 * "Top level" is the whole job. A naive `pattern.split('|')` is correct only
 * while every `|` in the pattern separates two branches, and it silently
 * shreds anything else: `(GPTBot|Foo)|(GPTBot|Bar)` becomes four fragments,
 * two of which carry an unbalanced parenthesis, and `[a|b]-Bot` becomes `[a`
 * and `b]-Bot`. Rejoining those fragments produces either a regex that throws
 * at `next.config` evaluation time (so the application stops starting) or,
 * worse, one that compiles and no longer matches what the user wrote.
 *
 * So the scanner below tracks three things and nothing else: backslash
 * escapes, character classes, and group nesting. That is the entire grammar
 * needed to find the top level pipes, and it is small enough to audit without
 * shipping a regex parser into a consumer's repository.
 */
function splitAlternation(pattern: string): string[] {
  const branches: string[] = []
  let current = ''
  let depth = 0
  let inClass = false

  for (let i = 0; i < pattern.length; i += 1) {
    const ch = pattern[i]!
    if (ch === '\\') {
      current += ch + (pattern[i + 1] ?? '')
      i += 1
      continue
    }
    if (inClass) {
      if (ch === ']') inClass = false
      current += ch
      continue
    }
    if (ch === '[') {
      inClass = true
      current += ch
      continue
    }
    if (ch === '(') {
      depth += 1
      current += ch
      continue
    }
    if (ch === ')') {
      if (depth > 0) depth -= 1
      current += ch
      continue
    }
    if (ch === '|' && depth === 0) {
      branches.push(current)
      current = ''
      continue
    }
    current += ch
  }
  branches.push(current)

  return branches.map((s) => s.trim()).filter((s) => s.length > 0)
}

/**
 * `true` when every `|` in the pattern separates two top level branches.
 *
 * This is the exact condition under which branch-wise union is safe, and it is
 * deliberately narrower than "contains a group or a character class". Our own
 * written value contains `[\w-]+-Google`, so a test that rejected every `[`
 * would classify the value this module itself emits as unreadable and wrap it
 * on the second run, which would end idempotency. What actually matters is
 * whether a `|` hides inside a group, a class, or an escape.
 */
function isFlatAlternation(pattern: string): boolean {
  let depth = 0
  let inClass = false

  for (let i = 0; i < pattern.length; i += 1) {
    const ch = pattern[i]!
    if (ch === '\\') {
      if (pattern[i + 1] === '|') return false
      i += 1
      continue
    }
    if (inClass) {
      if (ch === '|') return false
      if (ch === ']') inClass = false
      continue
    }
    if (ch === '[') {
      inClass = true
      continue
    }
    if (ch === '(') {
      depth += 1
      continue
    }
    if (ch === ')') {
      if (depth > 0) depth -= 1
      continue
    }
    if (ch === '|' && depth > 0) return false
  }
  return true
}

/**
 * Split `(?:core)|rest` back into its two halves, or `null` when the pattern is
 * not in that shape.
 *
 * This is what makes the opaque path below a fixed point. Wrapping produces
 * `(?:whatever the user wrote)|<our union>`, and a second run has to recognise
 * its own output rather than wrapping it again, which would nest a group per
 * run forever.
 */
function splitWrapped(pattern: string): { core: string; rest: string } | null {
  if (!pattern.startsWith('(?:')) return null

  let depth = 0
  let inClass = false
  for (let i = 0; i < pattern.length; i += 1) {
    const ch = pattern[i]!
    if (ch === '\\') {
      i += 1
      continue
    }
    if (inClass) {
      if (ch === ']') inClass = false
      continue
    }
    if (ch === '[') {
      inClass = true
      continue
    }
    if (ch === '(') {
      depth += 1
      continue
    }
    if (ch !== ')') continue
    depth -= 1
    if (depth > 0) continue
    // `i` is the parenthesis closing the leading group.
    if (pattern[i + 1] !== '|') return null
    return { core: pattern.slice(3, i), rest: pattern.slice(i + 2) }
  }
  return null
}

/**
 * `true` when the source is a legal regular expression on its own.
 *
 * The gate for the third path in `buildHtmlLimitedBotsPattern`. Nothing here
 * imports anything, so `RegExp` doing the parsing is both the simplest answer
 * and the only one available.
 */
function compilesAsPattern(source: string): boolean {
  try {
    new RegExp(source, 'i')
    return true
  } catch {
    return false
  }
}

/** The Next.js defaults union the AI crawlers, deduplicated case-insensitively. */
function unionBranches(...groups: readonly (readonly string[])[]): string[] {
  const seen = new Set<string>()
  const out: string[] = []
  for (const group of groups) {
    for (const branch of group) {
      const key = branch.toLowerCase()
      if (seen.has(key)) continue
      seen.add(key)
      out.push(branch)
    }
  }
  return out
}

/**
 * Build the `htmlLimitedBots` pattern AgentBlog wants: the Next.js default list,
 * union whatever the project already had, union the AI crawlers.
 *
 * ---------------------------------------------------------------------------
 * TWO PATHS, AND WHICH SHAPE TAKES WHICH
 * ---------------------------------------------------------------------------
 * **Flat path.** The existing pattern is a plain alternation: every `|` in it
 * separates two branches, so the branches can be read out, merged with ours,
 * and written back in one list. This is the ordinary case, including every
 * value this function has ever written, and it is the path worth keeping
 * because the result is one readable line where a human can see their own bot
 * sitting next to Googlebot.
 *
 * **Opaque path.** The existing pattern hides a `|` inside a group, a
 * character class, or an escape (`(GPTBot|Foo)|Bar`, `[a|b]-Bot`, `Acme\|Bot`).
 * Branch-wise merging is not defined for those, and attempting it produced
 * exactly the failures this path exists to prevent: an unbalanced `)` that
 * throws when Next.js evaluates the config, a `Lone quantifier brackets` error
 * under the `u` flag, and a valid-looking regex whose second branch had
 * quietly become `b]-Cat`. So the whole pattern is wrapped as
 * `(?:<theirs>)|<ours>` and never taken apart. Their expression keeps its exact
 * meaning, ours is appended, and nothing is parsed that we cannot parse.
 *
 * Both paths are fixed points. Re-running the flat path finds every branch
 * already present. Re-running the opaque path recognises its own
 * `(?:core)|rest` shape via `splitWrapped` and rebuilds the same string rather
 * than nesting another group.
 *
 * **Third path: an input that does not compile is discarded.** Both paths above
 * assume the input is a regular expression, and neither is a fixed point when
 * it is not. Feeding in `(AcmeBot`, which has an unbalanced parenthesis, grew
 * the result from 581 to 1158 to 1735 bytes over three runs and never produced
 * anything that compiles, because `splitWrapped` cannot recognise a wrap whose
 * group never closes, so every run wrapped the previous run's output again. The
 * CLI's own write path is protected by a `compiles()` gate, but this function
 * ships verbatim into every consumer project as `lib/preflight-checks.ts`,
 * where the caller has no such gate. So a value we cannot even parse as a regex
 * is treated as opaque and not unioned at all: the result is the union we can
 * vouch for, it compiles, and it is stable on the second run.
 *
 * @param existing The project's current pattern source, if it already has one.
 */
export function buildHtmlLimitedBotsPattern(existing?: string): string {
  const defaults = splitAlternation(NEXT_DEFAULT_HTML_LIMITED_BOTS)

  // Next's defaults first, so a diff against upstream stays readable.
  if (!existing) return unionBranches(defaults, AI_CRAWLER_UAS).join('|')

  if (!compilesAsPattern(existing)) return unionBranches(defaults, AI_CRAWLER_UAS).join('|')

  if (isFlatAlternation(existing)) {
    return unionBranches(defaults, splitAlternation(existing), AI_CRAWLER_UAS).join('|')
  }

  const wrapped = splitWrapped(existing)
  const core = wrapped ? wrapped.core : existing
  // Branches the user appended after a previous wrap are kept, as long as that
  // tail is itself flat. When it is not, the whole thing goes back in the group.
  const tail = wrapped && isFlatAlternation(wrapped.rest) ? splitAlternation(wrapped.rest) : []
  if (wrapped && tail.length === 0 && wrapped.rest.trim().length > 0) {
    return `(?:${existing})|${unionBranches(defaults, AI_CRAWLER_UAS).join('|')}`
  }

  return `(?:${core})|${unionBranches(defaults, tail, AI_CRAWLER_UAS).join('|')}`
}

/**
 * Which required branches a pattern is missing.
 *
 * Matching is case-insensitive and exact per branch. A pattern that happens to
 * match `GPTBot` through some broader expression still counts as missing the
 * branch, because we cannot prove equivalence of two regexes and a false pass
 * here is the failure this whole module exists to prevent.
 */
export function missingBranches(pattern: string, required: readonly string[]): string[] {
  const present = new Set(splitAlternation(pattern).map((b) => b.toLowerCase()))
  return required.filter((r) => !present.has(r.toLowerCase()))
}

export interface HtmlLimitedBotsReport {
  /** `true` when `next.config.*` sets the key at all. */
  readonly configured: boolean
  /** Default-list branches the configured pattern dropped. Empty is correct. */
  readonly missingDefaults: readonly string[]
  /** AI crawler branches not covered. Empty is correct. */
  readonly missingAiCrawlers: readonly string[]
  /** The pattern found in the config, if any. */
  readonly pattern?: string
}

/**
 * Locate and evaluate `htmlLimitedBots` in the text of a `next.config.*` file.
 *
 * Text matching rather than an AST walk is deliberate. This same function runs
 * inside `lib/preflight.ts` in a consumer's repository, where there is no
 * TypeScript compiler to borrow and no dependency we are allowed to add. The
 * CLI does use an AST when it *writes*, because writing needs precision that
 * reading does not.
 *
 * Note that Next.js 16 requires a `RegExp` here, not a string: the config schema
 * is `z.instanceof(RegExp)` and a string fails config validation outright.
 */
export function analyzeHtmlLimitedBots(configSource: string): HtmlLimitedBotsReport {
  // Strings are blanked as well as comments. `env: { NOTE: 'set
  // htmlLimitedBots: /GPTBot/i' }` sitting above a perfectly correct real value
  // used to win, because this finder takes the first match in the file. The
  // result was a permanent ERROR that `--fix` could not clear, since the patcher
  // writes through the AST while the reader kept reading the string.
  const stripped = blankCommentsAndStrings(configSource)
  const match = /htmlLimitedBots\s*:\s*\/((?:[^/\\\n]|\\.)+)\/([gimsuy]*)/.exec(stripped)

  if (!match?.[1]) {
    return { configured: false, missingDefaults: [], missingAiCrawlers: [] }
  }

  const pattern = match[1]
  return {
    configured: true,
    pattern,
    missingDefaults: missingBranches(pattern, splitAlternation(NEXT_DEFAULT_HTML_LIMITED_BOTS)),
    missingAiCrawlers: missingBranches(pattern, AI_CRAWLER_UAS),
  }
}

/* ========================================================================== */
/*  next.config checks                                                        */
/* ========================================================================== */

export interface NextConfigInput {
  /** Full text of `next.config.ts` / `.mjs` / `.js`. */
  readonly source: string
  /** Path, used only in messages. */
  readonly path: string
}

/**
 * Every `next.config.*` finding AgentBlog knows how to report.
 *
 * `lib/preflight.ts` prints the errors and warnings at build time.
 * `agentblog doctor` prints all of them and can fix the fixable ones.
 */
export function checkNextConfig(input: NextConfigInput): Finding[] {
  const findings: Finding[] = []
  const source = blankCommentsAndStrings(input.source)
  const bots = analyzeHtmlLimitedBots(input.source)

  if (!bots.configured) {
    findings.push({
      id: 'html-limited-bots-missing',
      severity: 'error',
      message:
        `${input.path} does not set \`htmlLimitedBots\`. GPTBot, ClaudeBot, and PerplexityBot ` +
        'will receive `<title>` and `<meta>` inside `<body>` on any page with streamed metadata.',
      remedy: 'npx agentblog@latest doctor --fix',
      fixable: true,
    })
  } else {
    // Reported separately and at a higher severity than the AI half, because a
    // narrowed pattern is an active regression rather than a missed chance.
    if (bots.missingDefaults.length > 0) {
      findings.push({
        id: 'html-limited-bots-narrowed',
        severity: 'error',
        message:
          `${input.path} sets \`htmlLimitedBots\` to a pattern that drops ` +
          `${bots.missingDefaults.length} bot(s) from the Next.js default list, including ` +
          `${bots.missingDefaults.slice(0, 4).join(', ')}. This config overrides the default ` +
          'list rather than extending it, so those bots have lost HTML-limited treatment.',
        remedy:
          'npx agentblog@latest doctor --fix, which unions your pattern with the Next.js ' +
          'default list instead of replacing it.',
        fixable: true,
      })
    }
    if (bots.missingAiCrawlers.length > 0) {
      findings.push({
        id: 'html-limited-bots-incomplete',
        severity: 'warning',
        message:
          `${input.path} sets \`htmlLimitedBots\` but omits ` +
          `${bots.missingAiCrawlers.join(', ')}.`,
        remedy: 'npx agentblog@latest doctor --fix',
        fixable: true,
      })
    }
  }

  if (!/qualities\s*:/.test(source)) {
    findings.push({
      id: 'image-qualities-missing',
      severity: 'warning',
      message:
        `${input.path} does not set \`images.qualities\`. Next.js 16 defaults it to [75] and the ` +
        'image optimizer returns 400 for any other quality, so a hero image at quality 90 fails ' +
        'in production while working in development.',
      remedy: 'npx agentblog@latest doctor --fix',
      fixable: true,
    })
  }

  if (/cacheComponents\s*:\s*true/.test(source)) {
    findings.push({
      id: 'cache-components-enabled',
      severity: 'warning',
      message:
        `${input.path} enables \`cacheComponents\`. The default AgentBlog routes use classic ` +
        'prerendering with `export const revalidate`, which is not how Cache Components wants to ' +
        'be driven.',
      remedy:
        'Set `cacheComponents: false`, or convert the blog routes yourself to `use cache` with ' +
        '`cacheLife` and `cacheTag`. There is no AgentBlog route variant for Cache Components yet.',
      fixable: false,
    })
  }

  return findings
}

/* ========================================================================== */
/*  Root layout checks                                                        */
/* ========================================================================== */

/**
 * `metadataBase` and `title.template` both belong in the root layout.
 *
 * `title.template` applies to *child* segments only, so a template declared in
 * `app/blog/layout.tsx` does not apply to `app/blog/page.tsx`. Putting it
 * anywhere but the root is a silent no-op for the blog index.
 */
export function checkRootLayout(input: { source: string; path: string }): Finding[] {
  const findings: Finding[] = []
  const source = blankCommentsAndStrings(input.source)

  if (!/metadataBase\s*:/.test(source)) {
    findings.push({
      id: 'metadata-base-missing',
      severity: 'error',
      message:
        `${input.path} does not set \`metadataBase\`. Next.js needs it to turn relative metadata ` +
        'URLs into absolute ones, and the build errors without it once any metadata field is relative.',
      remedy: 'npx agentblog@latest doctor --fix',
      fixable: true,
    })
  }

  if (!/template\s*:/.test(source)) {
    findings.push({
      id: 'title-template-missing',
      severity: 'warning',
      message: `${input.path} does not set \`title.template\`, so post titles carry no site suffix.`,
      remedy: 'npx agentblog@latest doctor --fix',
      fixable: true,
    })
  }

  return findings
}

/* ========================================================================== */
/*  Shared helpers                                                            */
/* ========================================================================== */

/**
 * Remove line and block comments so a commented-out example does not read as a
 * live setting.
 *
 * String **contents survive**, and two callers depend on that: `readJsonc` in
 * the CLI parses the result as JSON, and doctor check 26 looks for the
 * placeholder `siteUrl`, which is a string literal. Use
 * `blankCommentsAndStrings` for anything that matches on code shape rather than
 * on string values.
 */
export function stripComments(source: string): string {
  let out = ''
  let i = 0
  let inString: string | null = null

  while (i < source.length) {
    const ch = source[i]!
    const next = source[i + 1]

    if (inString) {
      if (ch === '\\') {
        out += ch + (next ?? '')
        i += 2
        continue
      }
      if (ch === inString) inString = null
      out += ch
      i += 1
      continue
    }

    if (ch === '"' || ch === "'" || ch === '`') {
      inString = ch
      out += ch
      i += 1
      continue
    }

    if (ch === '/' && next === '/') {
      while (i < source.length && source[i] !== '\n') i += 1
      continue
    }

    if (ch === '/' && next === '*') {
      i += 2
      while (i < source.length && !(source[i] === '*' && source[i + 1] === '/')) i += 1
      i += 2
      continue
    }

    out += ch
    i += 1
  }

  return out
}

/**
 * Blank comments **and string contents**, replacing every blanked character
 * with a space and keeping newlines, so byte offsets and line numbers are
 * identical to the input.
 *
 * ---------------------------------------------------------------------------
 * WHY BLANKING STRINGS IS NOT OPTIONAL HERE
 * ---------------------------------------------------------------------------
 * The matchers in this file look for a setting by name and take the first hit.
 * A string that merely mentions the setting therefore beat the real value:
 *
 *   env: { NOTE: 'remember to set htmlLimitedBots: /GPTBot/i' },
 *   htmlLimitedBots: <the correct union>,
 *
 * read as a pattern of `GPTBot`, which reports 27 dropped default bots at
 * `error` severity forever. `doctor` exits 1, `lib/preflight.ts` warns on every
 * build, and `--fix` cannot clear it, because the patcher writes through the
 * AST while the reader keeps reading the string.
 *
 * The quotes themselves are kept so the result still parses as the same shape,
 * and offsets are preserved so a caller can map a match back into the original
 * text. Regex literals are left alone: reading one is the whole point.
 */
export function blankCommentsAndStrings(source: string): string {
  const out: string[] = []
  let i = 0
  let inString: string | null = null

  /** Emit spaces for `text`, keeping any newline it contains. */
  const blank = (text: string) => {
    out.push(text.replace(/[^\n]/g, ' '))
  }

  while (i < source.length) {
    const ch = source[i]!
    const next = source[i + 1]

    if (inString) {
      if (ch === '\\') {
        blank(ch + (next ?? ''))
        i += 2
        continue
      }
      if (ch === inString) {
        inString = null
        out.push(ch)
        i += 1
        continue
      }
      blank(ch)
      i += 1
      continue
    }

    if (ch === '"' || ch === "'" || ch === '`') {
      inString = ch
      out.push(ch)
      i += 1
      continue
    }

    if (ch === '/' && next === '/') {
      const start = i
      while (i < source.length && source[i] !== '\n') i += 1
      blank(source.slice(start, i))
      continue
    }

    if (ch === '/' && next === '*') {
      const start = i
      i += 2
      while (i < source.length && !(source[i] === '*' && source[i + 1] === '/')) i += 1
      i = Math.min(i + 2, source.length)
      blank(source.slice(start, i))
      continue
    }

    out.push(ch)
    i += 1
  }

  return out.join('')
}

/** Sort findings so the most severe are reported first. */
export function bySeverity(a: Finding, b: Finding): number {
  const rank = { error: 0, warning: 1, info: 2 } as const
  return rank[a.severity] - rank[b.severity]
}

/** `true` when any finding would fail a build gate. */
export function hasErrors(findings: readonly Finding[]): boolean {
  return findings.some((f) => f.severity === 'error')
}
registry/blog/lib/ai-referrers.ts
/**
 * Classify a referrer as AI assistant traffic. Pure function, zero dependencies.
 *
 * ===========================================================================
 * WE SHIP THE CLASSIFIER, NOT AN ANALYTICS INTEGRATION
 * ===========================================================================
 * This file does not talk to GA4, Vercel Analytics, PostHog, or anything else,
 * and it never will. It takes a referrer string and tells you which AI assistant
 * it came from. Where that answer goes is your decision, and it stays your
 * decision: the block adds no analytics dependency, sends no data anywhere, and
 * sets no cookie.
 *
 * That is the honest scope. Wiring it up is three lines against whatever you
 * already run:
 *
 *     // Google Analytics 4
 *     const ai = classifyReferrer(document.referrer)
 *     if (ai) gtag('event', 'ai_referral', { ai_source: ai.source, ai_host: ai.host })
 *
 *     // Vercel Analytics
 *     const ai = classifyReferrer(document.referrer)
 *     if (ai) track('ai_referral', { source: ai.source, host: ai.host })
 *
 *     // PostHog
 *     const ai = classifyReferrer(document.referrer)
 *     if (ai) posthog.capture('ai_referral', { source: ai.source, host: ai.host })
 *
 * On the server the same call works against the `Referer` header.
 *
 * ===========================================================================
 * WHY THIS IS WORTH MEASURING SEPARATELY
 * ===========================================================================
 * Assistant referrals do not group with search traffic in any default report.
 * `chatgpt.com` shows up as an ordinary referral alongside a forum link, so the
 * one number that tells you whether writing for AI retrieval is working is
 * scattered across a dozen rows that nobody totals. Classifying at the source
 * gives you a single dimension you can chart.
 *
 * ===========================================================================
 * SAFE IN A CLIENT COMPONENT
 * ===========================================================================
 * No `server-only`, no Node built ins, no config import, no I/O. This module is
 * intentionally importable from a `'use client'` component so it can read
 * `document.referrer` on the first paint of a post.
 */

export type AiReferrerSource =
  'chatgpt' | 'perplexity' | 'gemini' | 'copilot' | 'claude' | 'grok' | 'you' | 'other-ai'

export interface AiReferrer {
  readonly source: AiReferrerSource
  /** The normalised hostname, lowercased, so you can chart the long tail too. */
  readonly host: string
}

/**
 * Exact hostnames, including the ones that only differ by a `www.` or a legacy
 * name kept alive by old links (`chat.openai.com`, `bard.google.com`).
 */
const EXACT_HOSTS: Readonly<Record<string, AiReferrerSource>> = {
  'chatgpt.com': 'chatgpt',
  'www.chatgpt.com': 'chatgpt',
  'chat.openai.com': 'chatgpt',
  'perplexity.ai': 'perplexity',
  'www.perplexity.ai': 'perplexity',
  'gemini.google.com': 'gemini',
  'bard.google.com': 'gemini',
  'copilot.microsoft.com': 'copilot',
  'claude.ai': 'claude',
  'www.claude.ai': 'claude',
  'grok.com': 'grok',
  'www.grok.com': 'grok',
  'x.ai': 'grok',
  'www.x.ai': 'grok',
  'you.com': 'you',
  'www.you.com': 'you',
}

/**
 * Apex domains whose subdomains belong to the same assistant.
 *
 * These products add and rename subdomains regularly. Matching the apex means a
 * new one keeps reporting as itself instead of silently dropping out of the
 * numbers, which is the failure mode that makes a metric quietly wrong rather
 * than obviously broken.
 */
const APEX_SUFFIXES: readonly (readonly [string, AiReferrerSource])[] = [
  ['.chatgpt.com', 'chatgpt'],
  ['.perplexity.ai', 'perplexity'],
  ['.claude.ai', 'claude'],
  ['.grok.com', 'grok'],
  ['.x.ai', 'grok'],
  ['.you.com', 'you'],
]

/**
 * Hosts that are AI assistants but have no dedicated member in the union.
 *
 * `other-ai` exists so that assistant traffic is never counted as an ordinary
 * referral just because the product is newer than this file. Add to this list
 * freely; it is the low risk edit.
 */
const OTHER_AI_HOSTS: readonly string[] = [
  'poe.com',
  'phind.com',
  'meta.ai',
  'chat.mistral.ai',
  'chat.deepseek.com',
]

/**
 * Assistants that live on a path of a general purpose host. Bing's chat surface
 * is the one that matters: `bing.com` alone is a search referral, and counting
 * it as AI would overstate the number badly.
 */
const PATH_SCOPED: readonly (readonly [string, string, AiReferrerSource])[] = [
  ['bing.com', '/chat', 'copilot'],
  ['www.bing.com', '/chat', 'copilot'],
  ['cn.bing.com', '/chat', 'copilot'],
]

/**
 * Parse a referrer that may be a full URL or a bare hostname.
 *
 * `document.referrer` is a full URL; a `Referer` header usually is too, but log
 * pipelines and tag managers hand over bare hosts often enough to be worth
 * handling here rather than at every call site.
 */
function parse(referrer: string): { host: string; path: string } | null {
  const trimmed = referrer.trim()
  if (trimmed === '') return null

  const candidate = /^[a-z][a-z0-9+.-]*:\/\//i.test(trimmed) ? trimmed : `https://${trimmed}`

  try {
    const url = new URL(candidate)
    if (url.hostname === '') return null
    return { host: url.hostname.toLowerCase(), path: url.pathname.toLowerCase() }
  } catch {
    return null
  }
}

/**
 * The AI assistant a visit came from, or `null` for everything else.
 *
 * `null` covers empty referrers, direct traffic, unparseable strings, and every
 * ordinary referral, so a caller can branch on truthiness and be done.
 */
export function classifyReferrer(referrer: string | null | undefined): AiReferrer | null {
  if (referrer === null || referrer === undefined) return null

  const parsed = parse(referrer)
  if (parsed === null) return null
  const { host, path } = parsed

  const exact = EXACT_HOSTS[host]
  if (exact !== undefined) return { source: exact, host }

  for (const [scopedHost, prefix, source] of PATH_SCOPED) {
    if (host === scopedHost && path.startsWith(prefix)) return { source, host }
  }

  for (const [suffix, source] of APEX_SUFFIXES) {
    if (host.endsWith(suffix)) return { source, host }
  }

  if (OTHER_AI_HOSTS.includes(host)) return { source: 'other-ai', host }

  return null
}

/** Whether a referrer is any AI assistant. Thin wrapper for filters and guards. */
export function isAiReferrer(referrer: string | null | undefined): boolean {
  return classifyReferrer(referrer) !== null
}
registry/blog/lib/reading-time.ts
/**
 * Reading time, computed from markdown source with no dependencies.
 *
 * ---------------------------------------------------------------------------
 * WHY NOT AN NPM PACKAGE
 * ---------------------------------------------------------------------------
 * Every reading time package counts words in whatever string you hand it. Hand
 * it raw MDX and it counts `import`, `className`, `rehype-pretty-code`, and
 * every token inside a fenced code block. On a technical post with three code
 * samples that inflates the estimate by a third, and the number that appears
 * under the headline is the one readers use to decide whether to start reading.
 *
 * So the interesting work here is not the arithmetic, it is the stripping. This
 * file removes, in order: the frontmatter block, MDX `import`/`export` lines,
 * fenced and indented code, inline code spans, JSX and HTML tags, images, link
 * targets (keeping link text, which a reader does read), and the punctuation
 * that carries markdown structure rather than meaning.
 *
 * 220 words per minute is the middle of the range measured for adult silent
 * reading of non fiction prose. It is a deliberate round number, not a tuned
 * one; the estimate is a courtesy, not a measurement.
 *
 * The result is deterministic and pure, so it is safe at build time, at request
 * time, and inside a client component.
 */

const WORDS_PER_MINUTE = 220

/** A token counts as a word only if it contains a letter or a digit. */
const IS_WORD = /[\p{L}\p{N}]/u

/**
 * Strip everything a reader does not read.
 *
 * Order matters. Code fences go before inline code, or a stray backtick inside a
 * fence eats the rest of the document. Images go before links, because an image
 * is a link with a bang in front of it.
 */
function stripMarkdown(markdown: string): string {
  return (
    markdown
      // Frontmatter, written as a fence of three or more hyphens at the very top.
      .replace(/^\uFEFF?[ \t]*-{3,}[ \t]*\r?\n[\s\S]*?\r?\n-{3,}[ \t]*(?:\r?\n|$)/, '')
      // Fenced code blocks, backtick or tilde, with or without a language.
      .replace(/^[ \t]*(`{3,}|~{3,})[^\n]*\n[\s\S]*?^[ \t]*\1[ \t]*$/gm, '')
      // An unterminated fence at the end of the file.
      .replace(/^[ \t]*(`{3,}|~{3,})[\s\S]*$/m, '')
      // MDX module syntax. These lines are code that happens to sit in prose.
      .replace(/^[ \t]*(?:import|export)\s[^\n]*$/gm, '')
      // HTML comments and MDX expression braces.
      .replace(/<!--[\s\S]*?-->/g, '')
      // Inline code spans.
      .replace(/`+[^`\n]*`+/g, ' ')
      // Images, including the alt text: it is not body copy.
      .replace(/!\[[^\]]*\]\([^)]*\)/g, ' ')
      // Links and reference links: keep the visible text, drop the target.
      .replace(/\[([^\]]*)\]\([^)]*\)/g, '$1')
      .replace(/\[([^\]]*)\]\[[^\]]*\]/g, '$1')
      // Link reference definitions on their own line.
      .replace(/^[ \t]*\[[^\]]+\]:[^\n]*$/gm, '')
      // JSX and HTML tags. The children of a JSX component stay, which is right:
      // a <Callout> body is prose the reader reads.
      .replace(/<\/?[A-Za-z][^>]*>/g, ' ')
      // Heading markers, blockquote markers, list bullets, and table pipes.
      .replace(/^[ \t]*#{1,6}[ \t]+/gm, '')
      .replace(/^[ \t]*>[ \t]?/gm, '')
      .replace(/^[ \t]*(?:[*+-]|\d+[.)])[ \t]+/gm, '')
      .replace(/^[ \t]*\|?[ \t]*:?-{3,}:?[ \t]*(?:\|[ \t]*:?-{3,}:?[ \t]*)*\|?[ \t]*$/gm, '')
      .replace(/\|/g, ' ')
      // Thematic breaks.
      .replace(/^[ \t]*(?:\*[ \t]*){3,}$|^[ \t]*(?:-[ \t]*){3,}$|^[ \t]*(?:_[ \t]*){3,}$/gm, '')
      // Emphasis, strikethrough, and the escape backslash.
      .replace(/[*_~]+/g, '')
      .replace(/\\([\\`*_{}[\]()#+\-.!])/g, '$1')
  )
}

/**
 * Word count and estimated reading minutes for a post body.
 *
 * `minutes` is never zero: a two sentence post still takes a moment to read, and
 * "0 min read" reads like a bug.
 */
export function readingTime(markdown: string): { minutes: number; words: number } {
  const words = stripMarkdown(markdown)
    .split(/\s+/)
    .filter((token) => IS_WORD.test(token)).length

  return { minutes: Math.max(1, Math.round(words / WORDS_PER_MINUTE)), words }
}
registry/blog/lib/toc.ts
/**
 * Table of contents extraction from markdown source, with no dependencies.
 *
 * ---------------------------------------------------------------------------
 * WHAT THIS IS FOR, AND WHAT CALLS IT TODAY
 * ---------------------------------------------------------------------------
 * Two exports, with different standing.
 *
 * `TocEntry` is the shared TOC type. `remarkExtractToc`, `renderMdx`, and
 * `<TableOfContents>` all speak it, which is why the interface lives in a
 * dependency-free module rather than next to the remark plugin.
 *
 * `extractToc` is a supported public helper that **nothing in the block calls**.
 * That is deliberate and is not an oversight to be fixed by wiring it in: the
 * rendered TOC comes from `remarkExtractToc` during the same compile that
 * produces the article (see `lib/render-mdx.tsx`), and computing it twice would
 * give two answers that can disagree. This function exists for callers that
 * must not compile MDX, which is every caller running outside a render: a
 * content linter, an editor preview, a migration script, a custom
 * `ContentSource` that wants an outline without a React tree.
 *
 * If you add a caller, say so here. If you find yourself calling it from a route
 * that has already rendered the post, use the compiler's TOC instead.
 *
 * ---------------------------------------------------------------------------
 * THE IDS MUST MATCH `rehype-slug`, EXACTLY
 * ---------------------------------------------------------------------------
 * The rendered headings get their `id` from `rehype-slug`, which uses the
 * `github-slugger` algorithm. If this file produced different ids the TOC would
 * render links to anchors that do not exist, and the failure would be a link
 * that silently does nothing rather than an error anyone sees.
 *
 * The algorithm, in full:
 *
 *   1. lowercase
 *   2. remove every character that is not a letter, a digit, an underscore,
 *      whitespace, or a hyphen
 *   3. replace runs of whitespace with a single hyphen
 *   4. if that id has already been used in this document, append `-1`, then
 *      `-2`, and so on until it is unique
 *
 * Step 4 is why this function walks the document rather than mapping over
 * headings independently: two sections both titled "Overview" must produce
 * `overview` and `overview-1`, in document order.
 *
 * ---------------------------------------------------------------------------
 * FENCED CODE IS SKIPPED
 * ---------------------------------------------------------------------------
 * A shell sample containing `# install dependencies` is a comment, not a
 * heading, and a TOC that lists it looks broken to a reader and to a crawler
 * parsing the outline. The line walk tracks fence state for backtick and tilde
 * fences, including the rule that a closing fence must be at least as long as
 * the opening one and use the same character.
 */

/** H2 and H3 only. H4 and deeper make a TOC longer than the section it indexes. */
const HEADING = /^[ \t]{0,3}(#{2,3})[ \t]+(.+?)[ \t]*#*[ \t]*$/
const FENCE = /^[ \t]{0,3}(`{3,}|~{3,})/
const FRONTMATTER_FENCE = /^-{3,}[ \t]*$/

export interface TocEntry {
  readonly id: string
  readonly text: string
  readonly depth: 2 | 3
}

/**
 * The `github-slugger` character filter. Unicode aware, so a heading written in
 * a non Latin script keeps its characters instead of collapsing to an empty id.
 */
function slugifyOnce(text: string): string {
  return text
    .trim()
    .toLowerCase()
    .replace(/[^\p{L}\p{N}_\s-]/gu, '')
    .replace(/\s+/g, '-')
}

/**
 * Reduce heading markdown to the text a reader sees.
 *
 * Kept deliberately small: it handles the inline syntax that actually shows up
 * in headings (code spans, emphasis, links, escapes) and nothing else.
 */
function headingText(raw: string): string {
  return raw
    .replace(/!\[[^\]]*\]\([^)]*\)/g, '')
    .replace(/\[([^\]]*)\]\([^)]*\)/g, '$1')
    .replace(/\[([^\]]*)\]\[[^\]]*\]/g, '$1')
    .replace(/`+([^`]*)`+/g, '$1')
    .replace(/<\/?[A-Za-z][^>]*>/g, '')
    .replace(/[*_~]+/g, '')
    .replace(/\\([\\`*_{}[\]()#+\-.!])/g, '$1')
    .replace(/\s+/g, ' ')
    .trim()
}

/** `overview`, then `overview-1`, then `overview-2`, as github-slugger does. */
function uniqueId(base: string, used: Map<string, number>): string {
  const seen = used.get(base)
  if (seen === undefined) {
    used.set(base, 0)
    return base
  }

  let next = seen + 1
  let candidate = `${base}-${next}`
  // A heading literally named "Overview 1" can already have taken `overview-1`.
  while (used.has(candidate)) {
    next += 1
    candidate = `${base}-${next}`
  }

  used.set(base, next)
  used.set(candidate, 0)
  return candidate
}

/**
 * Every H2 and H3 in a markdown or MDX document, in order, with the ids
 * `rehype-slug` will put on the rendered headings.
 *
 * Returns `[]` rather than throwing for any input, including an empty string.
 * A post with no headings is a valid post; it just has no TOC.
 */
export function extractToc(markdown: string): TocEntry[] {
  const lines = markdown.split(/\r?\n/)
  const entries: TocEntry[] = []
  // Counts per base slug, so the dedupe suffix matches github-slugger's.
  const used = new Map<string, number>()

  let fence: string | null = null
  let index = 0

  // Skip a frontmatter block, so a `#` line inside YAML is never a heading.
  const first = lines[0]
  if (first !== undefined && FRONTMATTER_FENCE.test(first)) {
    index = 1
    while (index < lines.length && !FRONTMATTER_FENCE.test(lines[index] ?? '')) index += 1
    index += 1
  }

  for (; index < lines.length; index += 1) {
    const line = lines[index]
    if (line === undefined) continue

    const fenceMatch = FENCE.exec(line)
    const marker = fenceMatch?.[1]
    if (marker !== undefined) {
      if (fence === null) fence = marker
      else if (marker[0] === fence[0] && marker.length >= fence.length) fence = null
      continue
    }
    if (fence !== null) continue

    const match = HEADING.exec(line)
    const hashes = match?.[1]
    const body = match?.[2]
    if (hashes === undefined || body === undefined) continue

    const text = headingText(body)
    if (text === '') continue

    entries.push({
      id: uniqueId(slugifyOnce(text), used),
      text,
      depth: hashes.length === 2 ? 2 : 3,
    })
  }

  return entries
}
registry/blog/styles/agentblog.css
/**
 * agentblog.css - where long-form typography meets the consumer's design system.
 *
 * ---------------------------------------------------------------------------
 * WHAT THIS FILE IS FOR
 * ---------------------------------------------------------------------------
 * `@tailwindcss/typography` ships its own grey palette. That palette is exactly
 * the thing the block is forbidden to introduce, because a blog that arrives
 * with its own colours makes the user's first job after installing "undo the
 * design". This file is the single bridge: it loads the plugin and rebinds
 * every `--tw-prose-*` variable to a shadcn semantic token, so an article
 * inherits the product it was bolted onto.
 *
 * Import it once from your global stylesheet, after the Tailwind import. From
 * `app/globals.css` that is `@import '../styles/agentblog.css';`. Nothing else
 * loads this file, so skipping that line leaves every article unstyled.
 *
 * The `@plugin` line below is a hard requirement, not a nicety: Tailwind fails
 * to resolve it if `@tailwindcss/typography` is not installed, and the build
 * stops. `@agentblog/blog-core` declares the package as a dependency for exactly
 * that reason, so an install has it before this file is ever compiled.
 *
 * ---------------------------------------------------------------------------
 * MANDATORY NOTE 1: DO NOT ADD `dark:prose-invert`
 * ---------------------------------------------------------------------------
 * It is unnecessary here, and worse than unnecessary. The tokens below already
 * flip under `.dark`, because that is what shadcn tokens do. `prose-invert`
 * swaps in the plugin's *inverted grey palette*, which then fights the tokens
 * for the same properties and wins on specificity for some of them. The result
 * is a half-inverted article whose body text follows the theme and whose
 * headings do not. If dark mode looks wrong, a token binding below is wrong.
 *
 * ---------------------------------------------------------------------------
 * MANDATORY NOTE 2: THE `hsl(var(--x))` VERSION TRAP
 * ---------------------------------------------------------------------------
 * Write `var(--foreground)`. Never `hsl(var(--foreground))`.
 *
 * shadcn's Tailwind v3 era stored colours as bare HSL channel triplets
 * (`--foreground: 0 0% 3.9%`), so the idiom was `hsl(var(--foreground))` and
 * that is what almost every blog post and Stack Overflow answer still shows.
 * Tailwind v4 shadcn stores complete `oklch()` values
 * (`--foreground: oklch(0.145 0 0)`), so wrapping it produces
 * `hsl(oklch(0.145 0 0))`, which is not a colour. CSS drops the invalid
 * declaration, the property inherits, and the page looks *nearly* right. That
 * silence is the trap: nothing errors, some text is just quietly the wrong
 * colour. If you are copying a snippet from anywhere, check this first.
 *
 * @see https://docs.agentblog.dev/guides/match-your-design#the-typography-plugin-is-bridged-rather-than-fought
 */

@plugin '@tailwindcss/typography';

/**
 * AgentBlog's own tokens: namespaced, and each one derived from a token the
 * consumer already owns. No new colours enter the design system here.
 *
 * ---------------------------------------------------------------------------
 * THE THREE RAILS
 * ---------------------------------------------------------------------------
 * Every page in the block is laid out on one of three widths, and nothing else
 * is allowed to invent a fourth. Changing one of these three values restyles
 * the whole blog, which is the point.
 *
 *   --agentblog-measure  The reading column. Article text, and everything that
 *                        sits beside article text: the H1, the lede, the byline,
 *                        the FAQ, the sources. One edge down the whole page.
 *   --agentblog-aside    The table-of-contents rail, used only at `xl` and up.
 *   --agentblog-article  measure + aside + the 3rem column gap. The outer bound
 *                        of an article page once the contents list moves out of
 *                        the flow and into the margin. Derived, so change the
 *                        two values it derives from rather than this one.
 *   --agentblog-rail     Index, category, tag, and author pages, where a card
 *                        grid needs room that a reading column would deny it.
 *                        64rem is `max-w-5xl`, which is the width most
 *                        application shells already use for their own header and
 *                        footer, so the block lines up with its host out of the
 *                        box. If yours is wider, this is the one value to change.
 *   --agentblog-gutter   The horizontal page gutter, 1.5rem, which is `px-6`.
 *
 * ---------------------------------------------------------------------------
 * EACH RAIL INCLUDES ITS OWN GUTTER
 * ---------------------------------------------------------------------------
 * Every container in the block is written `mx-auto w-full max-w-(--rail) px-6`,
 * one element carrying both the cap and the padding, so the number above is the
 * width of the element and the content inside it is 3rem narrower.
 *
 * That is the shadcn and Tailwind convention, and matching it is the whole
 * reason for the choice. The alternative, padding on a parent and the cap on a
 * child, gives a content column exactly one gutter wider, so a blog written
 * that way sits 24px outboard of the header and footer of any host that wrote
 * theirs the ordinary way. It is small enough that nobody files it and large
 * enough that everybody sees it.
 *
 * So `--agentblog-measure` at 42rem is a 39rem reading column between two
 * 1.5rem gutters, and 39rem measures 68 characters at the 17px reading size set
 * on `prose` below.
 *
 * ---------------------------------------------------------------------------
 * WHY THE MEASURE IS IN `rem` AND NOT IN `ch`
 * ---------------------------------------------------------------------------
 * `ch` is the advance width of the digit zero, which in most UI typefaces is
 * substantially wider than the average character. This block shipped `68ch` for
 * exactly that reason and it measured 84 characters per line, well outside the
 * 45 to 75 that every readability study since Bringhurst converges on. `ch`
 * reads like a character count and is not one.
 *
 * The reading column is 39rem, or 624px, which measures 68 characters at the
 * 17px reading size set on `prose` below. If you change the reading size,
 * re-measure rather than re-derive: the ratio depends on the consumer's
 * typeface.
 *
 * A `rem` value has a second, structural advantage. `ch` resolves against the
 * font size of the element it is written on, so a `ch` measure on the article
 * wrapper (16px) and the same `ch` measure on `.prose` (17px) produce two
 * different widths and the page loses its left edge. `rem` cannot drift.
 */
/*
 * THE RAILS ARE IN A PLAIN `@theme`, NOT IN `@theme inline`, AND THE DIFFERENCE
 * IS NOT COSMETIC.
 *
 * `@theme inline` substitutes a variable's value into the utilities it
 * generates and never emits the variable itself. That is correct for the colour
 * aliases below, which exist only to name a token. It is wrong for these four,
 * because they are read back at runtime by arbitrary-property utilities like
 * `max-w-(--agentblog-measure)` and `grid-cols-[...var(--agentblog-aside)]`.
 *
 * They were declared `inline` here for as long as the block existed, so
 * `var(--agentblog-measure)` resolved to nothing and every `max-w-` referencing
 * it silently fell back to full width. It was invisible because `.prose` also
 * sets its own `max-width` from the same value, inlined at build time, so the
 * article body was the right width while every element around it was not.
 * A plain `@theme` emits them to `:root`, where a `var()` can find them.
 */
@theme {
  --agentblog-measure: 42rem;
  --agentblog-aside: 15rem;
  --agentblog-article: 60rem;
  --agentblog-rail: 64rem;
  --agentblog-gutter: 1.5rem;

  /*
   * The reading size, declared here rather than on `prose` below because two
   * elements need it and only one of them is inside `.prose`.
   *
   * The plugin's own default is 16px, which is a UI size rather than a reading
   * size: at the measure above it produces a line that is technically legible
   * and tiring over 1,500 words. 17px from the `sm` breakpoint up is the step
   * that long-form publishers converge on, and it is a step rather than a flat
   * bump because 17px on a 375px phone costs more in wrapping than it returns in
   * comfort. The override sits in the media query below.
   *
   * Every other space in an article is proportional to this number. The
   * typography plugin expresses its margins in `em`, so raising the font size
   * raises paragraph spacing, list indents, and heading leading by the same
   * ratio, and the rhythm survives. That is why this is a font-size change and
   * not a stack of margin overrides.
   *
   * The other element is the answer capsule, which is a paragraph of body text
   * sitting in the article `<header>`, outside `.prose` and therefore outside
   * the cascade that would have given it these values for free. It reads them
   * back with `text-(length:--agentblog-reading-size)`. One reading size for the
   * page: change it here and the capsule and the body move together, which is
   * the whole reason the number left `@utility prose`.
   */
  --agentblog-reading-size: 1rem;
  --agentblog-reading-leading: 1.75;
}

/*
 * Unlayered on purpose, and it has to stay that way to work.
 *
 * `@theme` emits its variables inside `@layer theme`, and an unlayered rule beats
 * a layered one in the cascade no matter which came first in the file. So this
 * plain `:root` wins at `sm` and above. Wrapping it in `@layer theme` to make it
 * match the block above would silently restore 16px at every width, and the only
 * visible symptom would be an article that reads slightly small.
 */
@media (width >= 40rem) {
  :root {
    --agentblog-reading-size: 1.0625rem;
  }
}

@theme inline {
  --color-agentblog-prose-body: var(--foreground);
  --color-agentblog-prose-muted: var(--muted-foreground);
  --color-agentblog-prose-rule: var(--border);
  --color-agentblog-prose-link: var(--primary);
  --color-agentblog-prose-surface: var(--muted);
}

/**
 * The binding. All eighteen colour variables the typography plugin reads are
 * listed, so none of them can fall through to the plugin's default grey.
 *
 * Eighteen is the whole set in `@tailwindcss/typography@0.5.x`. Count them
 * against `src/styles.js` in the plugin before deleting one: the file used to
 * claim completeness while binding fifteen, and the three it missed
 * (`pre-code`, `kbd`, `kbd-shadows`) kept the plugin's greys in both themes.
 */
@utility prose {
  /*
   * A ceiling, not the layout. The article column is already the reading width
   * because its container is, so this only matters if you drop `<Prose>` into
   * something wider. The gutter comes off because the rails include theirs.
   */
  max-width: calc(var(--agentblog-measure) - 2 * var(--agentblog-gutter));

  /*
   * The reading size and its leading, both read from the `@theme` block at the
   * top of this file. See the comment on `--agentblog-reading-size` there for
   * why 16px steps to 17px at `sm`, and for the second element that reads the
   * same two values.
   */
  font-size: var(--agentblog-reading-size);
  line-height: var(--agentblog-reading-leading);

  --tw-prose-body: var(--foreground);
  --tw-prose-headings: var(--foreground);
  --tw-prose-lead: var(--muted-foreground);
  --tw-prose-links: var(--primary);
  --tw-prose-bold: var(--foreground);
  --tw-prose-counters: var(--muted-foreground);
  /*
   * `--muted-foreground`, not `--border`, and `--border` is the trap here.
   * A bullet is text the reader has to see, not a hairline. Against
   * `--background`, `--border` measures about 1.2:1 in light mode and 1.4:1 in
   * dark, so `<ul>` markers were effectively invisible while `<ol>` numbers,
   * already bound to `--muted-foreground` at about 4.7:1, were fine. The two
   * list types looked like different components.
   */
  --tw-prose-bullets: var(--muted-foreground);
  --tw-prose-hr: var(--border);
  --tw-prose-quotes: var(--foreground);
  --tw-prose-quote-borders: var(--border);
  --tw-prose-captions: var(--muted-foreground);
  --tw-prose-code: var(--foreground);
  /*
   * `--tw-prose-pre-bg` is rebound to `--muted` just below, so `pre-code` has to
   * be a foreground token or a plain `<pre>` renders the plugin's `#e5e7eb` on
   * `#f5f5f5`. That was invisible in practice only because rehype-pretty-code
   * writes its own colour onto every token and outranks this, which is a fix
   * that stops working the moment a fence has no language or a raw `<pre>`
   * reaches the page.
   */
  --tw-prose-pre-code: var(--foreground);
  --tw-prose-pre-bg: var(--muted);
  --tw-prose-kbd: var(--foreground);
  /*
   * The plugin uses this directly as a box-shadow colour, not as an RGB triplet:
   * `0 0 0 1px var(--tw-prose-kbd-shadows), 0 3px 0 var(--tw-prose-kbd-shadows)`.
   * Its default is the text colour at 10% opacity, which is a hand-mixed edge
   * colour. `--border` is the token that already means "edge", already carries
   * the low contrast the plugin was approximating, and already flips, so it is
   * the binding rather than a `color-mix()` of `--foreground`: a colour function
   * here would be the block computing a colour of its own, which is the thing
   * this file exists to avoid.
   */
  --tw-prose-kbd-shadows: var(--border);
  --tw-prose-th-borders: var(--border);
  --tw-prose-td-borders: var(--border);
}

/**
 * Line-breaking, which is the difference between typesetting and text.
 *
 * `balance` on headings spreads a two-line heading evenly instead of leaving one
 * word on the second line. It is capped by the browser at a handful of lines,
 * which is why it is scoped to headings rather than applied to everything.
 *
 * `pretty` on body copy asks the browser to avoid a single-word last line. It
 * costs nothing where it is unsupported, because the property is ignored.
 *
 * Both are written against `:is()` rather than `:where()` deliberately: these
 * should outrank the plugin's defaults, and a caller who wants a different
 * behaviour on one element still wins with a Tailwind utility.
 */
.prose :is(h1, h2, h3, h4) {
  text-wrap: balance;
}

.prose :is(p, li, blockquote, figcaption) {
  text-wrap: pretty;
}

/**
 * Delete the typography plugin's backticks around inline code.
 *
 * `@tailwindcss/typography` renders `content: "\`"` in a `::before` and an
 * `::after` on every inline `<code>`. That is a reasonable default for raw
 * markdown output with no other styling, and it is wrong here: `InlineCode` in
 * `components/mdx/code-block.tsx` already gives the element a border, a fill,
 * and padding, so the plugin's quotes render as two literal backtick glyphs
 * INSIDE a chip that already says "this is code". Every inline code span in
 * every post read as `` `curl` `` rather than as `curl`.
 *
 * `:is()` rather than `:where()`, because the plugin's own rule is zero
 * specificity and this has to outrank it. The `pre` case is included and costs
 * nothing: the plugin already excludes it, so there is no content to remove.
 */
.prose :is(code)::before,
.prose :is(code)::after {
  content: none;
}

/**
 * Heading anchors, appended by `rehype-autolink-headings` in `lib/render-mdx.tsx`.
 *
 * The link is always in the HTML, which is the point: it gives search and
 * retrieval systems a stable section boundary whether or not anyone can see it.
 * Visually it stays out of the way until the heading is hovered, or until the
 * link itself is focused by keyboard, which is why the `:focus-visible` rule is
 * not optional.
 */
.agentblog-anchor {
  margin-left: 0.375rem;
  color: var(--muted-foreground);
  opacity: 0;
  text-decoration: none;
  transition: opacity 150ms ease-out;
}

.agentblog-anchor::after {
  content: '#';
}

.agentblog-heading:hover .agentblog-anchor,
.agentblog-anchor:focus-visible {
  opacity: 1;
}

@media (prefers-reduced-motion: reduce) {
  .agentblog-anchor {
    transition: none;
  }
}

/**
 * Shiki dual themes.
 *
 * `lib/render-mdx.tsx` configures `theme: { light: 'github-light', dark: 'github-dark' }`,
 * so `rehype-pretty-code` writes both colours onto every token as
 * `--shiki-light` and `--shiki-dark` and marks the `<code>` element with a
 * space-separated `data-theme`. These two rules are what pick between them.
 *
 * This is the one place in the block that uses a `.dark` selector, and it does
 * not break the "no dark variants" rule: it is choosing between two colours the
 * *highlighter* generated, not hard-coding a colour of our own. There is no
 * token that can express "the colour Shiki assigned to a keyword".
 */
code[data-theme*=' '],
code[data-theme*=' '] span {
  color: var(--shiki-light);
}

.dark code[data-theme*=' '],
.dark code[data-theme*=' '] span {
  color: var(--shiki-dark);
}

/**
 * `rehype-pretty-code` wraps each block in a figure and, when the fence carries
 * a `title="..."`, prepends a figcaption. Both inherit the block's border so a
 * titled snippet reads as one object.
 */
figure[data-rehype-pretty-code-figure] {
  margin-block: 1.5rem;
}

figure[data-rehype-pretty-code-figure] > [data-rehype-pretty-code-title] {
  border: 1px solid var(--border);
  border-bottom: 0;
  border-top-left-radius: var(--radius);
  border-top-right-radius: var(--radius);
  background-color: var(--muted);
  color: var(--muted-foreground);
  font-size: 0.8125rem;
  padding: 0.5rem 1rem;
}

figure[data-rehype-pretty-code-figure] > [data-rehype-pretty-code-title] + pre {
  margin-top: 0;
  border-top-left-radius: 0;
  border-top-right-radius: 0;
}

/**
 * FAQ disclosure widgets.
 *
 * The default triangle marker is removed because `Faq` renders its own
 * indicator. The content stays in the HTML in both states, which is what makes
 * the `FAQPage` JSON-LD in `lib/schema.ts` legitimate rather than a guidelines
 * violation. Do not replace `<details>` with a conditional mount.
 */
.agentblog-faq > summary::-webkit-details-marker {
  display: none;
}

.agentblog-faq > summary::marker {
  content: '';
}

More from agentblog

Browse all components in this library →