{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "blog-core",
  "title": "AgentBlog core",
  "description": "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.",
  "dependencies": [
    "zod",
    "server-only",
    "@tailwindcss/typography"
  ],
  "files": [
    {
      "path": "registry/blog/agentblog.config.ts",
      "content": "/**\n * agentblog.config.ts\n *\n * ---------------------------------------------------------------------------\n * THE ONE FILE YOU EDIT\n * ---------------------------------------------------------------------------\n * Everything else AgentBlog installed reads from here through `@/lib/config`.\n * Canonical URLs, the sitemap, `robots.txt`, RSS, every JSON-LD `@id`, and the\n * Open Graph cards are all composed from these values, so a wrong value here is\n * wrong in roughly forty places at once.\n *\n * This file must sit at the **project root**, next to `package.json` and\n * `next.config.ts`, including in a `src/` layout. Next.js documents that config\n * files stay at the root, and the registry writes this one with a `~/` target,\n * which means project root by definition.\n *\n * Values are validated once, at module scope, by `resolveConfig` inside\n * `lib/config.ts`. A bad value fails the build with a message naming the key\n * rather than producing `undefined` inside a JSON-LD graph three files later.\n *\n * ---------------------------------------------------------------------------\n * FOR A CODING AGENT EDITING THIS FILE\n * ---------------------------------------------------------------------------\n * Change values, not structure. Do not add fields that are not documented below;\n * `resolveConfig` ignores unknown keys, so an invented field is silently dead.\n * Do not import from this file anywhere: `lib/config.ts` is the only module in\n * the block permitted to read it, and `agentblog doctor` enforces that.\n */\nimport { defineConfig } from '@/lib/define-config'\nimport { mdxSource } from '@/lib/sources/mdx'\n\n/**\n * The author a post falls back to when its frontmatter omits `author`.\n *\n * **This value must be a slug that exists in `content/authors.json`.** It ships\n * as `your-name`, which is a placeholder and matches no record, so the first\n * post you write without an explicit `author` fails the build with\n * `unknown author slug \"your-name\"`. `agentblog init` replaces it with the\n * default author you choose at the prompt; on the registry-only path you set it\n * by hand. Either way, set it to a slug the roster actually has before you write\n * a post that relies on it.\n *\n * The seed posts do not rely on it. Both name `editorial` explicitly, which is\n * the record shipped at the top of `content/authors.json`.\n *\n * It is declared once here because it has to reach two places: `mdxSource`\n * below, which applies it while parsing frontmatter, and the `defaultAuthor`\n * config field, which is the documented surface. The MDX adapter cannot read\n * the config field directly, because `lib/config.ts` imports this file and this\n * file builds the source, so a read back the other way would close an import\n * cycle. One constant, no drift, and nothing imports anything it should not.\n */\nconst DEFAULT_AUTHOR = 'your-name'\n\nexport default defineConfig({\n  /**\n   * Production origin. https, no trailing slash, no path.\n   *\n   * Every canonical link, sitemap entry, RSS `<link>`, and schema.org `@id` is\n   * built from this string. Point it at the domain readers actually visit, not\n   * at a preview deployment, or you will publish canonicals that tell Google to\n   * index a URL nobody links to.\n   */\n  siteUrl: 'https://yourdomain.com',\n\n  /**\n   * The Open Graph locale: a language tag and a region joined by an underscore,\n   * such as `en_US`, `en_GB`, or `pt_BR`. Emitted verbatim as `og:locale`.\n   *\n   * It is not a BCP 47 tag, which spells the same value `en-US`. Everywhere BCP\n   * 47 is required (schema.org `inLanguage`, the RSS `<language>` element, and\n   * `Intl` formatting) the block converts this value through `bcp47Locale` in\n   * `lib/config.ts`, so one field serves both spellings and they cannot drift.\n   * Write the underscore form here.\n   *\n   * Multi-locale is a v1 non-goal; the field exists so adding it later is not a\n   * breaking change.\n   */\n  locale: 'en_US',\n\n  brand: {\n    /** Publisher name. Appears in `og:site_name` and as `Organization.name`. */\n    name: 'Your Brand',\n\n    /**\n     * Publisher logo. `width` and `height` are required, not optional.\n     *\n     * Google's Organization markup needs both, and a logo without dimensions is\n     * one of the most common structured data errors on the web. The path is\n     * resolved against `siteUrl`, so `/logo.png` means\n     * `https://yourdomain.com/logo.png`.\n     */\n    logo: { url: '/logo.png', width: 512, height: 512 },\n\n    /**\n     * Profile and entity URLs for the organization, as absolute URLs.\n     *\n     * This is the highest value single property in the file. It maps to\n     * `Organization.sameAs`, which is how a search engine or an AI assistant\n     * resolves \"the company that published this\" to a real entity instead of a\n     * string that happens to look like a company name. Entity resolution is what\n     * lets a model cite you by name and attribute the claim correctly.\n     *\n     * List the profiles that a third party can verify: LinkedIn, Crunchbase,\n     * GitHub, YouTube, and Wikidata carry the most weight. Bare handles are\n     * rejected at build time, because `@yourbrand` disambiguates nothing.\n     *\n     * If you fill in exactly one field in this file beyond `siteUrl`, fill in\n     * this one.\n     */\n    sameAs: [\n      'https://www.linkedin.com/company/yourbrand',\n      'https://www.crunchbase.com/organization/yourbrand',\n      'https://github.com/yourbrand',\n      'https://www.youtube.com/@yourbrand',\n    ],\n  },\n\n  /**\n   * Where posts come from.\n   *\n   * `mdxSource` reads `.mdx` files off disk, so publishing is a git push and a\n   * deploy. Swapping this for a database backed source changes one line here and\n   * nothing else in the blog, which is the whole point of the adapter. Sources\n   * that cannot publish without a rebuild also require a `deployHook`, and the\n   * compiler will tell you so the moment you swap them in.\n   */\n  source: mdxSource({ dir: 'content/blog', defaultAuthor: DEFAULT_AUTHOR }),\n\n  /**\n   * ISR window in seconds for blog routes. 3600 means a published post is at\n   * most an hour stale without a rebuild. The publish webhook revalidates\n   * immediately, so this is the floor, not the latency you should expect.\n   */\n  revalidate: 3600,\n\n  /** Posts per page on `/blog`. Pagination uses real `<a href>` links. */\n  postsPerPage: 12,\n\n  /**\n   * Tag pages with fewer than this many posts get `robots: { index: false }`.\n   *\n   * Thin tag pages are the classic way a blog generates hundreds of near empty\n   * indexable URLs and spends its own crawl budget on them. Categories are\n   * always indexable; tags have to earn it.\n   */\n  noindexTagsBelow: 5,\n\n  /**\n   * IndexNow submission from the publish webhook. Bing, Yandex, Seznam, and\n   * Naver consume it, and ChatGPT search leans on Bing's index.\n   *\n   * Turn this on after `agentblog init` has generated a key, or set\n   * `INDEXNOW_KEY` in your environment. `lib/indexnow.ts` reads the config key\n   * first and falls back to `process.env.INDEXNOW_KEY`, so the secret never has\n   * to live in this committed file. The matching `<key>.txt` must be served from\n   * your domain root or every submission comes back 403.\n   */\n  indexnow: { enabled: false },\n\n  /**\n   * Search engine ownership verification, mapped straight onto Next.js\n   * `metadata.verification`. One config line instead of a hand edited layout.\n   * Paste the token from the HTML tag method, not the whole meta tag.\n   */\n  // verification: { google: 'your-search-console-token' },\n\n  /**\n   * Declared AI access preferences, emitted into `robots.txt`.\n   *\n   * All three default to `true`. Setting `train: false` is a real tradeoff and\n   * not a free one: several crawlers are multi purpose, so opting out of\n   * training can also opt you out of the search index that would have cited you.\n   */\n  aiAccess: { train: true, search: true, agent: true },\n\n  /**\n   * Build time config linting. On by default.\n   *\n   * It reads `next.config.*` off disk and warns when required settings are\n   * missing. Set it to `false` only once you know your config is correct and you\n   * want the warning gone. Deleting the `preflight` import from\n   * `app/blog/layout.tsx` is not the sanctioned way to silence it, because then\n   * nothing tells you when a later edit breaks the config again.\n   */\n  preflight: true,\n\n  /**\n   * Pick one URL form and let Next.js redirect the other. This must match\n   * `trailingSlash` in `next.config.ts`, because the URL helpers in\n   * `lib/config.ts` compose canonicals from it. Two different answers means\n   * every canonical points at a URL that immediately redirects.\n   */\n  trailingSlash: false,\n\n  /**\n   * Author slug used when a post omits one. See `DEFAULT_AUTHOR` at the top of\n   * this file: the same constant is handed to `mdxSource` above, which is what\n   * actually applies it while parsing frontmatter.\n   */\n  defaultAuthor: DEFAULT_AUTHOR,\n})\n",
      "type": "registry:file",
      "target": "~/agentblog.config.ts"
    },
    {
      "path": "registry/blog/lib/config.ts",
      "content": "/**\n * The resolved config singleton, plus the only sanctioned way to build a URL.\n *\n * ---------------------------------------------------------------------------\n * WHY THIS FILE EXISTS\n * ---------------------------------------------------------------------------\n * This is the **only** module in the entire block that imports\n * `@/agentblog.config`. That is a deliberate, enforced constraint rather than a\n * convention:\n *\n * 1. `agentblog.config.ts` lives at the project root, but `@/` maps to the root\n *    in a flat layout and to `src/` in a `src/` layout. One import specifier\n *    cannot be correct in both. With exactly one importer, fixing a `src/`\n *    project is a one line change (or one `tsconfig` path) instead of a grep\n *    across forty files. `agentblog init` performs that fix; `lib/preflight.ts`\n *    reports a named error when nobody did.\n *\n * 2. `resolveConfig` runs once, here, at module scope. A bad value fails the\n *    build with a message naming the key. Everywhere downstream a\n *    `ResolvedAgentBlogConfig` is validated, branded, and fully defaulted, so no\n *    other file needs a `?? 'en_US'` or a null check.\n *\n * ---------------------------------------------------------------------------\n * URL COMPOSITION: USE THESE HELPERS, NEVER STRING CONCATENATION\n * ---------------------------------------------------------------------------\n * `${config.siteUrl}/blog/${slug}` looks harmless and is how a blog ends up\n * serving two canonical URLs for one post. The helpers below are the single\n * place that knows the three rules that matter:\n *\n *   - `siteUrl` is already normalised to carry no trailing slash, so the path\n *     always supplies exactly one leading slash and never two.\n *   - `config.trailingSlash` has to be honoured, or every canonical points at a\n *     URL that immediately 308s to its other form. Search engines treat that as\n *     a self referential redirect chain and AI crawlers frequently do not follow\n *     it at all.\n *   - Paths that end in a file extension (`/feed.xml`, `/<key>.txt`) never take\n *     a trailing slash, matching what Next.js itself does. Getting this wrong\n *     breaks the IndexNow key file and the RSS `alternates` link.\n *\n * If you find yourself writing a template literal that starts with\n * `config.siteUrl`, add a helper here instead.\n *\n * @see https://docs.agentblog.dev/reference/configuration\n */\nimport 'server-only'\n\nimport userConfig from '@/agentblog.config'\nimport { resolveConfig } from '@/lib/define-config'\nimport type { ResolvedAgentBlogConfig } from '@/lib/define-config'\n\n/**\n * Parsed, defaulted, and branded once per process. Import this everywhere;\n * import `@/agentblog.config` nowhere.\n */\nexport const config: ResolvedAgentBlogConfig = resolveConfig(userConfig)\n\n/** A last path segment that looks like a file, e.g. `feed.xml` or `abc123.txt`. */\nconst LOOKS_LIKE_A_FILE = /\\.[a-z0-9]+$/i\n\n/** An already absolute URL, which the helpers pass through untouched. */\nconst ALREADY_ABSOLUTE = /^[a-z][a-z0-9+.-]*:\\/\\//i\n\n/**\n * Apply every path rule in one place: one leading slash, no doubled slashes, and\n * the configured trailing slash policy, with the query string and fragment left\n * alone.\n */\nfunction normalizePath(input: string): string {\n  const separator = input.search(/[?#]/)\n  const pathname = separator === -1 ? input : input.slice(0, separator)\n  const suffix = separator === -1 ? '' : input.slice(separator)\n\n  const withLeadingSlash = pathname.startsWith('/') ? pathname : `/${pathname}`\n  const collapsed = withLeadingSlash.replace(/\\/{2,}/g, '/')\n  const bare = collapsed.length > 1 ? collapsed.replace(/\\/+$/, '') : '/'\n\n  if (bare === '/') return `/${suffix}`\n\n  const lastSegment = bare.slice(bare.lastIndexOf('/') + 1)\n  const wantsTrailingSlash = config.trailingSlash && !LOOKS_LIKE_A_FILE.test(lastSegment)\n\n  return `${wantsTrailingSlash ? `${bare}/` : bare}${suffix}`\n}\n\n/* -------------------------------------------------------------------------- */\n/*  URL and path helpers                                                      */\n/* -------------------------------------------------------------------------- */\n\n/**\n * There are two families of helper here and using the wrong one is a real bug,\n * so the naming is deliberate:\n *\n *   `xPath()`  root-relative, e.g. `/blog/hello-world`. Use for every `href`,\n *              every `<Link>`, and every internal anchor.\n *   `xUrl()`   absolute, e.g. `https://yoursite.dev/blog/hello-world`. Use for\n *              canonicals, the sitemap, the RSS feed, Open Graph URLs, and every\n *              JSON-LD `@id`.\n *\n * Why this split exists. `config.siteUrl` is your production origin. If a `href`\n * used the absolute form, every internal link on `http://localhost:3000` would\n * navigate to production, which is confusing in development and actively\n * dangerous in a preview deployment where a reviewer clicking through the blog\n * silently leaves the branch they were reviewing.\n *\n * Canonicals and structured data must be absolute, so both families are needed\n * and neither is a superset of the other.\n */\n\n/**\n * Absolute URL for a path on this site.\n *\n * An input that is already absolute (a CDN hosted hero image, for example) is\n * returned unchanged, so callers do not have to branch before every call.\n */\nexport function absoluteUrl(path: string): string {\n  if (ALREADY_ABSOLUTE.test(path)) return path\n  return `${config.siteUrl}${normalizePath(path)}`\n}\n\n/**\n * Canonical absolute URL of the site root, in one form, for every surface.\n *\n * There are two spellings of the same place, `https://example.com` and\n * `https://example.com/`, and they are equivalent per RFC 3986 because an empty\n * path means `/`. Equivalent is not the same as interchangeable: the sitemap,\n * `Organization.url`, `WebSite.url`, and the \"Home\" breadcrumb are compared as\n * strings by the systems that read them, so two spellings across four surfaces\n * reads as two entities that happen to look alike.\n *\n * The trailing slash form wins because it is what `absoluteUrl('/')` already\n * produces and what the site-level `@id` values in `lib/schema.ts` are built\n * from. The root is exempt from `config.trailingSlash`: that setting picks\n * between `/blog/` and `/blog`, and there is no slashless spelling of the root\n * that a server can serve.\n *\n * Use this everywhere the site root appears as a URL. Never `config.siteUrl`.\n */\nexport function homeUrl(): string {\n  return absoluteUrl('/')\n}\n\n/**\n * `config.locale` as a BCP 47 language tag.\n *\n * The config value is the Open Graph spelling, `en_US`, because that is what\n * `og:locale` requires. BCP 47, which schema.org `inLanguage`, the RSS\n * `<language>` element, and `Intl` all require, writes the same value `en-US`.\n * One config field serves both, which beats asking the user to keep two fields\n * in sync and to notice when they drift.\n *\n * The global flag is load bearing. `replace('_', '-')` rewrites only the first\n * underscore, so an extended tag such as `zh_Hans_CN` comes back as\n * `zh-Hans_CN`, which is not a valid tag and which `Intl.DateTimeFormat` throws\n * on. Every conversion in the block goes through this function for that reason.\n */\nexport function bcp47Locale(locale: string = config.locale): string {\n  return locale.replaceAll('_', '-')\n}\n\n/**\n * Root-relative path for any internal route that has no dedicated helper.\n *\n * Use this rather than a bare string literal for anything you link to inside the\n * site, for example `sitePath('/editorial-policy')`. It applies the same rules\n * as every other helper here: one leading slash, no doubled slashes, and the\n * configured trailing-slash policy, with a last segment that looks like a file\n * (`/feed.xml`, `/robots.txt`) exempted from the trailing slash.\n *\n * The named helpers above exist because those four routes are ours and their\n * shape is fixed. This one exists so that a project running `trailingSlash: true`\n * does not have to hand-audit every remaining literal in the block.\n */\nexport function sitePath(path: string): string {\n  return normalizePath(path)\n}\n\n/** Root-relative path of the blog index. */\nexport function blogPath(): string {\n  return normalizePath('/blog')\n}\n\n/** Root-relative path of a post. Use this for `href`. */\nexport function postPath(slug: string): string {\n  return normalizePath(`/blog/${encodeURIComponent(slug)}`)\n}\n\n/** Root-relative path of a category hub. Use this for `href`. */\nexport function categoryPath(slug: string): string {\n  return normalizePath(`/blog/category/${encodeURIComponent(slug)}`)\n}\n\n/**\n * Root-relative path of a tag listing. Use this for `href`.\n *\n * The slug is percent encoded, exactly as `categoryPath` and `authorPath` encode\n * theirs. That became load bearing when `tagSlug` stopped discarding non-Latin\n * characters: a tag of `日本語` now produces a real slug, and an unencoded one\n * would put raw UTF-8 bytes into the sitemap and into every canonical, where the\n * sitemap protocol requires them escaped.\n *\n * A tag that slugs to nothing has no page. Returning `/blog/tag/` would collapse\n * to `/blog/tag`, which is a route that does not exist, so this returns the blog\n * index instead: a link that goes somewhere real beats a link to a 404.\n */\nexport function tagPath(tag: string): string {\n  const slug = tagSlug(tag)\n  if (slug === '') return blogPath()\n  return normalizePath(`/blog/tag/${encodeURIComponent(slug)}`)\n}\n\n/**\n * URL form of a free-text tag.\n *\n * Tags are authored as human text (\"AI crawlers\"), which cannot go into a URL\n * as written. Percent encoding it would work and would produce\n * `/blog/tag/AI%20crawlers`, a URL that is technically valid, reads badly\n * everywhere it is displayed, and is trivially duplicated by a different\n * encoding of the same tag. Slugifying gives one canonical form per tag.\n *\n * `lib/posts.ts` matches on this same function, so a tag page and the posts it\n * lists cannot disagree about what counts as the same tag.\n *\n * ---------------------------------------------------------------------------\n * THE CHARACTER CLASS IS UNICODE AWARE, AND THAT IS NOT COSMETIC\n * ---------------------------------------------------------------------------\n * `[^a-z0-9]+` looks like the obviously correct filter and quietly deletes every\n * non-Latin script. `日本語`, `Ελλάδα`, and `Москва` all slugged to the empty\n * string, which meant `getAllTags` dropped them, they got no route and no\n * sitemap entry, and they were still emitted in `keywords` and\n * `BlogPosting.keywords`. A blog written in any of those languages had zero\n * working tag pages and no error anywhere to say so.\n *\n * `\\p{L}\\p{N}` is the same class `slugifyOnce` in `lib/toc.ts` uses, for the\n * same reason. The one deliberate difference: `slugifyOnce` also keeps `_`\n * because it has to reproduce `github-slugger` byte for byte, while a tag slug\n * is a URL segment and `-` is the separator readers and search engines expect\n * there.\n *\n * Returns `''` for a tag with no letters or digits at all (\"!!!\", an emoji).\n * That tag has no addressable URL and the callers treat `''` as \"not\n * addressable\": `getAllTags` skips it, `getPostsByTag` returns `[]`, and\n * `tagPath` refuses to compose a link (see below). It is dropped deliberately\n * rather than crashing or producing `/blog/tag/`.\n */\nexport function tagSlug(tag: string): string {\n  return (\n    tag\n      .normalize('NFKD')\n      // Strip combining marks so \"Café\" and \"Cafe\" produce the same slug. This\n      // is the Combining Diacritical Marks block and not `\\p{Mn}`: `\\p{Mn}`\n      // would also delete the marks that carry meaning in Devanagari, Hebrew,\n      // and Arabic, which is the same script-erasing bug in a new place.\n      .replace(/[̀-ͯ]/g, '')\n      .toLowerCase()\n      .trim()\n      .replace(/[^\\p{L}\\p{N}]+/gu, '-')\n      .replace(/^-+|-+$/g, '')\n  )\n}\n\n/** Root-relative path of an author page. Use this for `href`. */\nexport function authorPath(slug: string): string {\n  return normalizePath(`/authors/${encodeURIComponent(slug)}`)\n}\n\n/** Canonical URL of the blog index. */\nexport function blogUrl(): string {\n  return absoluteUrl(blogPath())\n}\n\n/** Canonical URL of a post. Use this for canonicals, JSON-LD, and the sitemap. */\nexport function postUrl(slug: string): string {\n  return absoluteUrl(postPath(slug))\n}\n\n/** Canonical URL of a category hub. Categories are indexable. */\nexport function categoryUrl(slug: string): string {\n  return absoluteUrl(categoryPath(slug))\n}\n\n/**\n * Absolute URL of a tag listing. Tags are noindexed below\n * `config.noindexTagsBelow`, so this URL is not always a canonical one.\n */\nexport function tagUrl(tag: string): string {\n  return absoluteUrl(tagPath(tag))\n}\n\n/** Canonical URL of an author page. Doubles as the `Person` node `@id` base. */\nexport function authorUrl(slug: string): string {\n  return absoluteUrl(authorPath(slug))\n}\n\n/* -------------------------------------------------------------------------- */\n/*  XML escaping                                                              */\n/* -------------------------------------------------------------------------- */\n\n/**\n * Escape a value for XML character data or an XML attribute.\n *\n * ---------------------------------------------------------------------------\n * WHY THIS LIVES NEXT TO THE URL HELPERS\n * ---------------------------------------------------------------------------\n * Because every value it protects is a URL those helpers produced. `app/sitemap.ts`\n * hands Next.js a list of `absoluteUrl`, `postUrl`, `categoryUrl`, `tagUrl`, and\n * `authorUrl` results plus each post's `heroImage`, and Next.js writes them into\n * XML with no escaping of its own. Verified in\n * `next/dist/build/webpack/loaders/metadata/resolve-route-data.js@16.3.0`:\n *\n *     content += `<loc>${item.url}</loc>\\n`\n *     content += `<image:image>\\n<image:loc>${image}</image:loc>\\n</image:image>\\n`\n *\n * Straight interpolation. So the escape has to happen before the value reaches\n * the framework, and this module is the one every sitemap value already passes\n * through.\n *\n * ---------------------------------------------------------------------------\n * THE ACCIDENT MATTERS MORE THAN THE ATTACK\n * ---------------------------------------------------------------------------\n * `&` is legal in a URI and ordinary in a CDN URL:\n * `https://cdn.example/a.png?w=1200&h=630&fit=crop`. Unescaped, that single hero\n * image makes the whole document not well-formed, every XML parser rejects it,\n * and the site silently has no sitemap at all. `ImageRef` in `lib/schemas.ts`\n * refuses the characters that have no business in a URI (`<`, `>`, `\"`, spaces,\n * controls), which closes the deliberate injection; `&` cannot be refused there\n * without breaking real URLs, so it is escaped here.\n *\n * All five predefined entities are escaped rather than the three that are\n * strictly required in character data, because the same function is used for\n * attribute values, and a function whose safety depends on where you call it is\n * a function that will eventually be called in the other place. `&` goes first,\n * or the ampersands introduced by the later replacements get escaped again.\n *\n * `app/feed.xml/route.ts` carries its own copy of this, written before this one\n * existed. Point it here when that file is next touched.\n */\nexport function escapeXml(value: string): string {\n  return stripInvalidXmlChars(value)\n    .replaceAll('&', '&amp;')\n    .replaceAll('<', '&lt;')\n    .replaceAll('>', '&gt;')\n    .replaceAll('\"', '&quot;')\n    .replaceAll(\"'\", '&apos;')\n}\n\n/**\n * Remove code points that XML 1.0 forbids anywhere, CDATA included.\n *\n * Escaping is not enough on its own. A control character such as `\\v` or a stray\n * `\\x00` in a title, an author name, or a description makes the whole document\n * unparseable, and there is no escape sequence that rescues it: XML 1.0 simply\n * has no representation for those code points. A lone surrogate is the same\n * problem arriving from a truncated string.\n *\n * The failure is total and it is silent from our side. One bad character in one\n * post and every feed reader gets a hard parse error on the whole feed, or the\n * sitemap stops parsing entirely. Verified against a real parser: element\n * breakout and CDATA terminator payloads are handled by escaping, and these are\n * the ones that get through it.\n *\n * Permitted, per the XML 1.0 Char production: tab, newline, carriage return,\n * and everything from U+0020 up, minus the surrogate range and the two\n * noncharacters at the end of the BMP.\n */\nexport function stripInvalidXmlChars(value: string): string {\n  return (\n    value\n      // eslint-disable-next-line no-control-regex -- the point is to match control characters\n      .replace(/[\\u0000-\\u0008\\u000B\\u000C\\u000E-\\u001F\\uFFFE\\uFFFF]/g, '')\n      // A surrogate that is not part of a pair. A valid pair is left alone.\n      .replace(/[\\uD800-\\uDBFF](?![\\uDC00-\\uDFFF])/g, '')\n      .replace(/(?<![\\uD800-\\uDBFF])[\\uDC00-\\uDFFF]/g, '')\n  )\n}\n",
      "type": "registry:lib",
      "target": "lib/config.ts"
    },
    {
      "path": "registry/blog/lib/define-config.ts",
      "content": "/**\n * ! GENERATED FILE. DO NOT EDIT.\n *\n * Source of truth: `packages/schema/src/define-config.ts` in the AgentBlog repository.\n * Regenerate with `pnpm codegen`.\n *\n * This file is copied verbatim so that the code running in your project is the\n * same code the AgentBlog test suite runs against. Edits here are safe to make\n * in your own repository once installed, but they will be overwritten if you\n * reinstall the block.\n */\nimport 'server-only'\n\n// `server-only` makes a client import of this module a build error rather\n// than silent bundle growth. Everything below runs at build time or in a\n// route handler, never in the browser.\n\n/**\n * `agentblog.config.ts`: the type, the helper, and the resolver.\n *\n * ---------------------------------------------------------------------------\n * THE SHAPE OF THIS FILE\n * ---------------------------------------------------------------------------\n * There are two config types here, and the distinction is load bearing:\n *\n *   `AgentBlogConfig`          what you write. Plain strings, most keys optional.\n *   `ResolvedAgentBlogConfig`  what the blog reads. Validated, branded, defaulted.\n *\n * `resolveConfig` is the boundary between them. It runs once at module scope in\n * `lib/config.ts`, so a bad value fails the build with a message that names the\n * key rather than producing `undefined` somewhere inside a JSON-LD graph three\n * files later.\n *\n * Branding only exists on the resolved side on purpose. If `siteUrl` were a\n * branded type on the input side you could not write `siteUrl: 'https://x.dev'`\n * in your own config file, which would be a strange thing to ask of a config\n * file. Validate at the boundary, brand on the inside.\n *\n * ---------------------------------------------------------------------------\n * WHY `defineConfig` INSTEAD OF `satisfies`\n * ---------------------------------------------------------------------------\n * `defineConfig` gives contextual inference, so the `deployHook` requirement\n * below can depend on which content source you passed. `satisfies` checks the\n * object against a fixed type and cannot do that.\n *\n * @see https://docs.agentblog.dev/reference/configuration\n */\nimport { z } from 'zod'\n\nimport { AbsoluteUrl, HttpsUrl, Slug } from '@/lib/schemas'\nimport type { ContentSource } from '@/lib/types'\nimport { AgentBlogContentError } from '@/lib/types'\n\n/* -------------------------------------------------------------------------- */\n/*  What you write                                                            */\n/* -------------------------------------------------------------------------- */\n\nexport interface AgentBlogConfigBase {\n  /**\n   * The production origin, https, no trailing slash. Every canonical URL, every\n   * sitemap entry, and every JSON-LD `@id` is composed from this.\n   */\n  readonly siteUrl: string\n\n  /**\n   * The Open Graph locale form, `language_TERRITORY` with an underscore, not the\n   * BCP-47 hyphen form. `en_US`, not `en-US`. Default `en_US`.\n   *\n   * It is written this way because `og:locale` accepts only this spelling, and\n   * `bcp47Locale` converts it to `en-US` for `inLanguage`, the RSS `<language>`\n   * element, and `Intl`. One field, both surfaces, no drift. `resolveConfig`\n   * rejects the hyphen form with a message that says so.\n   */\n  readonly locale?: string\n\n  readonly brand: {\n    readonly name: string\n    /**\n     * `width` and `height` are required, not optional. Google's Organization\n     * markup needs both, and a logo without dimensions is one of the most common\n     * structured data errors on the web.\n     */\n    readonly logo: { readonly url: string; readonly width: number; readonly height: number }\n    /**\n     * Profile and entity URLs for the organization, as absolute URLs.\n     *\n     * This is the single most valuable property in the whole config. It maps\n     * to `Organization.sameAs`, which is how an AI search engine resolves \"the\n     * company that published this\" to a real entity instead of a string. LinkedIn,\n     * Crunchbase, GitHub, YouTube, and Wikidata are the ones worth listing.\n     */\n    readonly sameAs?: readonly string[]\n  }\n\n  /** ISR window in seconds for blog routes. Default 3600. */\n  readonly revalidate?: number\n\n  /** Posts per page on the index. Default 12. */\n  readonly postsPerPage?: number\n\n  /**\n   * Tag pages with fewer than this many posts get `robots: { index: false }`.\n   *\n   * Thin tag pages are the classic way a blog generates hundreds of near-empty\n   * indexable URLs and dilutes its own crawl budget. Default 5.\n   */\n  readonly noindexTagsBelow?: number\n\n  /** IndexNow submission. Requires a key; `agentblog init` generates one. */\n  readonly indexnow?: { readonly enabled: boolean; readonly key?: string }\n\n  /** Search engine site verification. Maps straight to Next.js `metadata.verification`. */\n  readonly verification?: {\n    readonly google?: string\n    readonly yandex?: string\n    readonly yahoo?: string\n    readonly other?: Record<string, string | string[]>\n  }\n\n  /**\n   * Declared AI access preferences, emitted into `robots.txt`.\n   *\n   * A forward looking seam. Three standards are converging on machine-readable\n   * AI usage preferences (Cloudflare Content Signals, RSL, and the IETF AIPREF\n   * draft) and all three attach to robots.txt or HTTP. When one lands, this same\n   * config object emits it with no change to your config file.\n   *\n   * All three default to `true`. Setting `train: false` is a real tradeoff and\n   * not a free one: some crawlers are multi-purpose, so opting out of training\n   * can also opt you out of the search index that cites you.\n   */\n  readonly aiAccess?: {\n    readonly train?: boolean\n    readonly search?: boolean\n    readonly agent?: boolean\n  }\n\n  /**\n   * Build-time config linting. On by default.\n   *\n   * Set to `false` only if you know your `next.config.ts` is correct and you\n   * want the warning gone. This is the sanctioned opt out; deleting the\n   * `preflight` import from `app/blog/layout.tsx` is not, because then nothing\n   * tells you when a later edit breaks the config.\n   */\n  readonly preflight?: boolean\n\n  /** Pick one URL form and redirect the other. Default `false`. */\n  readonly trailingSlash?: boolean\n\n  /** Author slug used when a post omits one. */\n  readonly defaultAuthor?: string\n}\n\n/**\n * The full config type.\n *\n * `deployHook` is required when the content source cannot publish without a\n * rebuild, and rejected when it can. That moves the cold-slug failure (publishing\n * a post that was never prerendered, then pinging a crawler to come look at it)\n * from a runtime surprise to a compile error. `scripts/assert-cold-slug.mjs` is\n * the end-to-end proof of the same thing.\n *\n * Swap `mdxSource` for `supabaseSource` in your config and the file stops type\n * checking until you supply a deploy hook. That is the single highest value\n * thing the type layer does here.\n */\nexport type AgentBlogConfig<S extends ContentSource = ContentSource> = AgentBlogConfigBase &\n  (S extends ContentSource<'deploy-hook'>\n    ? { readonly source: S; readonly deployHook: string }\n    : { readonly source: S; readonly deployHook?: never })\n\n/**\n * Identity function that exists purely for its type signature. Wrap your config\n * object in it so TypeScript can infer the content source and apply the\n * `deployHook` rule above.\n */\nexport function defineConfig<S extends ContentSource>(\n  config: AgentBlogConfig<S>,\n): AgentBlogConfig<S> {\n  return config\n}\n\n/* -------------------------------------------------------------------------- */\n/*  What the blog reads                                                       */\n/* -------------------------------------------------------------------------- */\n\nexport interface ResolvedAgentBlogConfig {\n  readonly siteUrl: HttpsUrl\n  readonly locale: string\n  readonly brand: {\n    readonly name: string\n    readonly logo: { readonly url: string; readonly width: number; readonly height: number }\n    readonly sameAs: readonly AbsoluteUrl[]\n  }\n  readonly source: ContentSource\n  readonly deployHook: string | undefined\n  readonly revalidate: number\n  readonly postsPerPage: number\n  readonly noindexTagsBelow: number\n  readonly indexnow: { readonly enabled: boolean; readonly key: string | undefined }\n  readonly verification: {\n    readonly google: string | undefined\n    readonly yandex: string | undefined\n    readonly yahoo: string | undefined\n    readonly other: Record<string, string | string[]> | undefined\n  }\n  readonly aiAccess: { readonly train: boolean; readonly search: boolean; readonly agent: boolean }\n  readonly preflight: boolean\n  readonly trailingSlash: boolean\n  readonly defaultAuthor: Slug | undefined\n}\n\n/**\n * The Open Graph locale form: `language_TERRITORY`, optionally with a script\n * subtag between them. `en_US`, `pt_BR`, `zh_Hans_CN`.\n *\n * Open Graph requires the underscore form. BCP 47, which `inLanguage`, the RSS\n * `<language>` element, and `Intl` all want, writes the same value with a\n * hyphen. One config field serves both because `bcp47Locale` in `lib/config.ts`\n * converts one way, and the conversion only runs one way: an `en-US` written\n * into the config reaches `og:locale` untouched and is invalid there.\n */\nconst OG_LOCALE = /^[a-z]{2,3}(?:_[A-Z][a-z]{3})*_[A-Z]{2}$/\n\n/**\n * Validation for everything in the config except `source`, which is an object\n * with methods and is checked structurally below instead.\n *\n * ---------------------------------------------------------------------------\n * WHY `.strict()`, AND THE TRADEOFF THAT USUALLY ARGUES AGAINST IT\n * ---------------------------------------------------------------------------\n * `.strict()` on a config object is normally a hostile default, because it\n * forbids a user from parking their own keys next to ours and there is no way\n * for them to opt out.\n *\n * It is right here for one specific reason: the TypeScript side already forbids\n * unknown keys. `defineConfig` takes `AgentBlogConfig`, which has no index\n * signature, so `defineConfig({ revalidatee: 60, ... })` is already an excess\n * property error on the object literal. Without `.strict()` the runtime was\n * simply more permissive than the type, and the gap was reachable in the two\n * ways that matter: a JavaScript `agentblog.config.js`, and a config assembled\n * with a spread (`{ ...base, ...overrides }`), which suppresses excess property\n * checking. `revalidatee: 60` parsed clean and the blog ran on the default ISR\n * window with nothing anywhere saying so.\n *\n * So this does not remove a capability users have. It makes the runtime agree\n * with the type they are already held to. If a real need to carry extra keys\n * appears, the alternative is a declared `extra: Record<string, unknown>` field\n * that is validated as opaque and never read by the block, which keeps typo\n * detection on every key we do own.\n */\nconst ConfigShapeSchema = z\n  .object({\n    siteUrl: z\n      .string()\n      // A trailing slash here produces `https://site.dev//blog/post` in every\n      // canonical, so normalise before branding rather than defending downstream.\n      .transform((s) => s.replace(/\\/+$/, ''))\n      .pipe(HttpsUrl),\n    locale: z\n      .string()\n      .default('en_US')\n      // Two refinements rather than one, so the far more likely mistake gets the\n      // message that names it. `en-US` is what every other locale-shaped API in\n      // a Next.js project wants, so it is what people type, and it produces an\n      // `og:locale` that Facebook, LinkedIn, and Slack all reject silently.\n      .refine((value) => !value.includes('-'), {\n        message:\n          'is BCP 47 (`en-US`), but `og:locale` requires the Open Graph form with an ' +\n          'underscore (`en_US`). Write the underscore form here: `bcp47Locale` in ' +\n          '`lib/config.ts` converts it to the hyphen form for `inLanguage`, the RSS ' +\n          '`<language>` element, and `Intl`, so this one field serves both.',\n      })\n      .refine((value) => OG_LOCALE.test(value), {\n        message:\n          'must be `language_TERRITORY`, lowercase language and uppercase territory, with ' +\n          'an optional script subtag between them. `en_US`, `pt_BR`, `zh_Hans_CN`. ' +\n          'A bare language (`en`) is not a valid `og:locale`.',\n      }),\n    brand: z.object({\n      name: z.string().min(1),\n      logo: z.object({\n        url: z.string().min(1),\n        width: z.number().int().positive(),\n        height: z.number().int().positive(),\n      }),\n      sameAs: z.array(AbsoluteUrl).default([]),\n    }),\n    deployHook: z\n      .url({ protocol: /^https$/, error: 'deployHook must be an absolute https URL' })\n      .describe('Rebuild trigger. Plain http would leak the secret path in transit.')\n      .optional(),\n    revalidate: z.number().int().nonnegative().default(3600),\n    postsPerPage: z.number().int().positive().default(12),\n    noindexTagsBelow: z.number().int().nonnegative().default(5),\n    indexnow: z\n      .object({\n        enabled: z.boolean(),\n        /*\n         * Exactly the pattern the CLI's `isValidIndexNowKey` enforces. The two\n         * have to agree: the key becomes a file name at your domain root\n         * (`public/<key>.txt`), so a slash or a dot produces a file the CLI and\n         * the running app disagree about, and every submission returns 403 with\n         * nothing on either side saying why.\n         */\n        key: z\n          .string()\n          .regex(\n            /^[A-Za-z0-9-]{8,128}$/,\n            '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.',\n          )\n          .optional(),\n      })\n      .default({ enabled: false }),\n    verification: z\n      .object({\n        google: z.string().optional(),\n        yandex: z.string().optional(),\n        yahoo: z.string().optional(),\n        other: z.record(z.string(), z.union([z.string(), z.array(z.string())])).optional(),\n      })\n      .default({}),\n    aiAccess: z\n      .object({\n        train: z.boolean().default(true),\n        search: z.boolean().default(true),\n        agent: z.boolean().default(true),\n      })\n      .default({ train: true, search: true, agent: true }),\n    preflight: z.boolean().default(true),\n    trailingSlash: z.boolean().default(false),\n    defaultAuthor: Slug.optional(),\n  })\n  .strict()\n\nconst CONTENT_SOURCE_METHODS = [\n  'getAllPosts',\n  'getPost',\n  'getAllCategories',\n  'getAllAuthors',\n  'getPostsByCategory',\n  'getPostsByAuthor',\n  'getRelatedPosts',\n] as const\n\n/**\n * Parse, default, and brand the user's config. Called exactly once, at module\n * scope in `lib/config.ts`.\n *\n * @throws {AgentBlogContentError} naming the offending key, at build time.\n */\nexport function resolveConfig(input: unknown): ResolvedAgentBlogConfig {\n  if (typeof input !== 'object' || input === null) {\n    throw new AgentBlogContentError('agentblog.config.ts', [\n      {\n        path: [],\n        message:\n          'default export is missing or is not an object. It should be `export default defineConfig({ ... })`.',\n      },\n    ])\n  }\n\n  const { source, ...rest } = input as Record<string, unknown>\n\n  const parsed = ConfigShapeSchema.safeParse(rest)\n  if (!parsed.success) {\n    throw new AgentBlogContentError(\n      'agentblog.config.ts',\n      parsed.error.issues.map((i) => ({ path: i.path, message: i.message })),\n    )\n  }\n\n  const sourceIssues = validateSource(source)\n  if (sourceIssues.length > 0) {\n    throw new AgentBlogContentError('agentblog.config.ts', sourceIssues)\n  }\n  const contentSource = source as ContentSource\n\n  if (contentSource.prerenderStrategy === 'deploy-hook' && !parsed.data.deployHook) {\n    throw new AgentBlogContentError('agentblog.config.ts', [\n      {\n        path: ['deployHook'],\n        message:\n          `source \"${contentSource.name}\" declares prerenderStrategy \"deploy-hook\", which means a ` +\n          'newly published post is not prerendered until a rebuild runs. Set `deployHook` to a ' +\n          'rebuild trigger URL so the publish webhook can rebuild before pinging IndexNow.',\n      },\n    ])\n  }\n\n  return {\n    ...parsed.data,\n    source: contentSource,\n    deployHook: parsed.data.deployHook,\n    indexnow: { enabled: parsed.data.indexnow.enabled, key: parsed.data.indexnow.key },\n    verification: {\n      google: parsed.data.verification.google,\n      yandex: parsed.data.verification.yandex,\n      yahoo: parsed.data.verification.yahoo,\n      other: parsed.data.verification.other,\n    },\n    defaultAuthor: parsed.data.defaultAuthor,\n  }\n}\n\nfunction validateSource(source: unknown): { path: string[]; message: string }[] {\n  if (typeof source !== 'object' || source === null) {\n    return [\n      {\n        path: ['source'],\n        message:\n          'is missing. Set it to a content source, for example `mdxSource({ dir: \"content/blog\" })`.',\n      },\n    ]\n  }\n  const candidate = source as Record<string, unknown>\n  const missing = CONTENT_SOURCE_METHODS.filter((m) => typeof candidate[m] !== 'function')\n  if (missing.length > 0) {\n    return [\n      {\n        path: ['source'],\n        message: `does not implement the ContentSource interface. Missing: ${missing.join(', ')}.`,\n      },\n    ]\n  }\n  if (\n    candidate['prerenderStrategy'] !== 'build' &&\n    candidate['prerenderStrategy'] !== 'deploy-hook' &&\n    candidate['prerenderStrategy'] !== 'on-demand'\n  ) {\n    return [\n      {\n        path: ['source', 'prerenderStrategy'],\n        message: 'must be one of \"build\", \"deploy-hook\", or \"on-demand\".',\n      },\n    ]\n  }\n  return []\n}\n",
      "type": "registry:lib",
      "target": "lib/define-config.ts"
    },
    {
      "path": "registry/blog/lib/schemas.ts",
      "content": "/**\n * ! GENERATED FILE. DO NOT EDIT.\n *\n * Source of truth: `packages/schema/src/schemas.ts` in the AgentBlog repository.\n * Regenerate with `pnpm codegen`.\n *\n * This file is copied verbatim so that the code running in your project is the\n * same code the AgentBlog test suite runs against. Edits here are safe to make\n * in your own repository once installed, but they will be overwritten if you\n * reinstall the block.\n */\nimport 'server-only'\n\n// `server-only` makes a client import of this module a build error rather\n// than silent bundle growth. Everything below runs at build time or in a\n// route handler, never in the browser.\n\n/**\n * AgentBlog domain schemas.\n *\n * ---------------------------------------------------------------------------\n * WHY THIS FILE LOOKS THE WAY IT DOES\n * ---------------------------------------------------------------------------\n * Every domain type in AgentBlog is *inferred* from the Zod schemas below with\n * `z.infer`. There is no hand-written `interface Post` anywhere in the codebase,\n * because a validator and a parallel interface always drift, and here they would\n * drift between what we validate and what we type.\n *\n * The five branded primitives at the top exist because five invariants this\n * product depends on are unrepresentable as `string`:\n *\n *   - a slug that is lowercase-hyphenated and carries no date\n *   - a URL that is absolute rather than a bare social handle\n *   - a URL that is specifically https and is a bare origin, for `siteUrl`\n *   - an image reference that is a URL or a root-relative path, and nothing else\n *   - a timestamp that carries a UTC offset\n *\n * Each of those, when violated, produces output that validates cleanly and is\n * silently wrong: Google reinterprets an offset-less date in Googlebot's own\n * timezone, and a `sameAs` entry of \"@janedoe\" disambiguates nothing. Branding\n * makes the invalid value unconstructible instead of merely discouraged.\n *\n * `ImageRef` is the one that is also a security boundary rather than a quality\n * one. `heroImage` was `z.string()`, and it lands unescaped inside `<image:loc>`\n * in the sitemap that Next.js serializes. See the comment on `ImageRef`.\n *\n * ---------------------------------------------------------------------------\n * EDITING THIS FILE\n * ---------------------------------------------------------------------------\n * Adding a frontmatter field is a two-line change: add it to `PostSchema` here,\n * then read it wherever you need it. The type flows everywhere automatically.\n * If you add a field that maps to schema.org, also add a row to the mapping\n * table in `lib/schema.ts` so the JSON-LD builders stay reviewable.\n *\n * @see https://docs.agentblog.dev/reference/content-sources\n */\nimport { z } from 'zod'\n\n/* -------------------------------------------------------------------------- */\n/*  Branded primitives                                                        */\n/* -------------------------------------------------------------------------- */\n\n/**\n * A URL path segment: lowercase, hyphen separated, no slashes, no leading date.\n *\n * Dates in slugs are banned deliberately. A slug like `2024-03-post-title`\n * advertises the post's age in every search result and makes an evergreen\n * refresh look stale, which is the opposite of what the freshness signal in the\n * GEO playbook rewards.\n */\nexport const Slug = z\n  .string()\n  // A slug reaches `revalidatePath()`, a cache key, and a probe URL. None of\n  // those has a length limit of its own, and no real post needs more than this.\n  .max(120, 'must be at most 120 characters')\n  .regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/, 'must be lowercase and hyphen separated, with no slashes')\n  .refine((s) => !/^\\d{4}-\\d{2}(?:-\\d{2})?-/.test(s), {\n    error: 'must not begin with a date. Dates in slugs make evergreen posts look stale.',\n  })\n  .brand<'Slug'>()\n\n/**\n * The printable characters RFC 3986 excludes from a URI. `hasUriExcludedChar`\n * below adds space, the C0 controls, and DEL.\n *\n * `z.url()` does not reject these. It parses with the WHATWG URL parser, which\n * is a browser-compatibility parser rather than a validator: it happily accepts\n * `https://a.dev/<x>` and hands the string back with the `<` intact. Everything\n * downstream then embeds that string in markup. Next.js 16.3.0's own sitemap\n * serializer does no escaping at all (`resolve-route-data.js` interpolates\n * `<loc>${item.url}</loc>` directly), so a `<` here is a new XML element in\n * someone else's sitemap.\n *\n * Note what is deliberately NOT on this list: `&`. It is legal in a URI and a\n * perfectly ordinary CDN URL carries several of them, so it has to be escaped at\n * serialization rather than banned at the boundary. `escapeXml` in\n * `lib/config.ts` is that escape.\n */\nconst URI_EXCLUDED_PRINTABLE = /[\"<>\\\\^`{|}]/\n\n/**\n * True when `value` contains a character RFC 3986 excludes from a URI.\n *\n * Space, the C0 controls, and DEL are tested by code point rather than as a\n * regex range. A control character inside a character class is a\n * `no-control-regex` lint error, and the rule is right in general: a literal\n * NUL in a regex is nearly always a typo rather than an intention.\n */\nfunction hasUriExcludedChar(value: string): boolean {\n  for (const char of value) {\n    const code = char.codePointAt(0) ?? 0\n    if (code <= 0x20 || code === 0x7f) return true\n  }\n  return URI_EXCLUDED_PRINTABLE.test(value)\n}\n\nconst URI_EXCLUDED_ERROR =\n  'must not contain a space, a control character, or any of \" < > \\\\ ^ ` { | }. RFC 3986 ' +\n  'excludes them from a URI and they break XML and HTML attribute contexts. Percent-encode them.'\n\n/** An absolute http(s) URL. A bare handle such as `@janedoe` is rejected. */\nexport const AbsoluteUrl = z\n  .url({ protocol: /^https?$/, error: 'must be an absolute http or https URL' })\n  .refine((url) => !hasUriExcludedChar(url), { error: URI_EXCLUDED_ERROR })\n  .brand<'AbsoluteUrl'>()\n\n/**\n * An absolute https URL with no query and no fragment. Used for `siteUrl`, which\n * anchors every canonical.\n *\n * Query and fragment are refused because `absoluteUrl` composes URLs as\n * `${siteUrl}${path}`. A `siteUrl` of `https://a.dev/?a=1` produces\n * `https://a.dev/?a=1/blog/post` for every post on the site, which is a\n * correctness failure before it is anything else. It is also the second half of\n * the sitemap injection above, since every entry in the sitemap is built from\n * this value.\n */\nexport const HttpsUrl = z\n  .url({ protocol: /^https$/, error: 'must be an absolute https URL' })\n  .refine((url) => !hasUriExcludedChar(url), { error: URI_EXCLUDED_ERROR })\n  .refine((url) => !/[?#]/.test(url), {\n    error:\n      'must be a bare origin with no query string and no fragment. Every canonical on the ' +\n      'site is composed as siteUrl + path, so a query here lands in the middle of every URL.',\n  })\n  .brand<'HttpsUrl'>()\n\n/**\n * A reference to an image: an absolute http(s) URL, or a root-relative path\n * beginning with a single `/`.\n *\n * `z.string()` was the previous type and it was the vector for a real attack.\n * `Post.heroImage` reaches `<image:loc>` in the sitemap, which Next.js does not\n * escape, so a frontmatter value of\n * `x.png</image:loc></image:image></url><url><loc>https://spam.tld/</loc>`\n * added an attacker-controlled `<url>` element to the victim's own sitemap. It\n * also reaches `og:image` and `BlogPosting.image`.\n *\n * `//evil.tld/x.png` is rejected along with everything else that is not\n * http(s): a protocol-relative URL reads as a path and loads from another\n * origin. `absoluteUrl` returns an already-absolute input untouched, so\n * whatever is accepted here is what ships.\n */\nexport const ImageRef = z\n  .string()\n  .min(1)\n  .refine((value) => /^(?:https?:\\/\\/|\\/(?!\\/))/.test(value), {\n    error:\n      'must be an absolute http(s) URL or a root-relative path beginning with a single \"/\". ' +\n      'A protocol-relative \"//host/path\" and a bare \"img/hero.png\" are both rejected.',\n  })\n  .refine((value) => !hasUriExcludedChar(value), { error: URI_EXCLUDED_ERROR })\n  .brand<'ImageRef'>()\n\n/**\n * ISO 8601 with an explicit UTC offset, for example `2026-08-06T09:30:00-04:00`.\n *\n * The offset is not optional. Google falls back to Googlebot's own timezone when\n * a date carries no offset, which silently shifts every published date by hours\n * and can move a post across a day boundary.\n */\nexport const IsoDateTime = z.iso\n  .datetime({\n    offset: true,\n    error: 'must be ISO 8601 with a UTC offset, e.g. 2026-08-06T09:30:00Z',\n  })\n  .brand<'IsoDateTime'>()\n\nexport type Slug = z.infer<typeof Slug>\nexport type AbsoluteUrl = z.infer<typeof AbsoluteUrl>\nexport type HttpsUrl = z.infer<typeof HttpsUrl>\nexport type ImageRef = z.infer<typeof ImageRef>\nexport type IsoDateTime = z.infer<typeof IsoDateTime>\n\n/* -------------------------------------------------------------------------- */\n/*  Domain schemas                                                            */\n/* -------------------------------------------------------------------------- */\n\n/**\n * A post author.\n *\n * `name` is the person's name and nothing else. Google's Article guidance is\n * explicit that `author.name` must exclude job titles, honorifics, and the\n * publisher name, and a CMS field labelled \"author\" actively invites all three.\n * `jobTitle` is a separate property precisely so the correct output is the only\n * constructible one.\n */\nexport const AuthorSchema = z.object({\n  slug: Slug,\n  name: z.string().min(1, 'author name is required'),\n  bio: z.string().min(1, 'author bio is required; it is the E-E-A-T surface'),\n  avatar: AbsoluteUrl.optional(),\n  jobTitle: z.string().optional(),\n  worksFor: AbsoluteUrl.optional(),\n  /** Topics this author is credible on. Maps to `Person.knowsAbout`. */\n  knowsAbout: z.array(z.string()).default([]),\n  alumniOf: z.string().optional(),\n  /** Absolute profile URLs. Maps to `Person.sameAs`, the entity link. */\n  sameAs: z.array(AbsoluteUrl).default([]),\n  email: z.email().optional(),\n})\n\n/**\n * A category. Categories are indexable hub pages, so the description is\n * required: a thin hub with nothing but a post list is a crawl liability rather\n * than an asset.\n */\nexport const CategorySchema = z.object({\n  slug: Slug,\n  name: z.string().min(1),\n  description: z.string().min(1, 'category description is required; the hub page is indexable'),\n  latestPostDate: IsoDateTime.optional(),\n})\n\n/** A cited source. `kind` is ours for auditing and is never emitted as JSON-LD. */\nexport const CitationSchema = z.object({\n  name: z.string().min(1),\n  url: AbsoluteUrl,\n  author: z.string().optional(),\n  datePublished: IsoDateTime.optional(),\n  kind: z.enum(['peer-reviewed', 'official-docs', 'industry', 'news', 'other']).default('other'),\n})\n\n/**\n * One question and answer pair.\n *\n * These render visibly on the page before they are ever emitted as `FAQPage`\n * JSON-LD. Google's structured data policy forbids marking up content that is\n * not visible to readers, and FAQ markup is the most common way blogs trip it.\n */\nexport const FaqEntrySchema = z.object({\n  question: z.string().min(1),\n  answer: z.string().min(1),\n})\n\n/** Everything about a post except its body. Split out so frontmatter can reuse it. */\nconst PostFieldsSchema = z.object({\n  slug: Slug,\n\n  /**\n   * Title. 60 characters is the target, because that is roughly where Google\n   * truncates a title in the SERP. 70 is the hard stop.\n   */\n  title: z.string().min(1).max(70, 'title must be at most 70 characters; aim for 60'),\n\n  /** Meta description. Must match `BlogPosting.description` exactly. */\n  description: z\n    .string()\n    .min(50, 'description must be at least 50 characters')\n    .max(160, 'description must be at most 160 characters'),\n\n  /**\n   * The 40 to 60 word direct answer that sits under the H1.\n   *\n   * Word count is checked by `agentblog audit` rather than here, because a post\n   * that is 8 words over should be reported, not refused at build time.\n   */\n  answerCapsule: z.string().optional(),\n\n  datePublished: IsoDateTime,\n  dateModified: IsoDateTime,\n\n  /** Fully hydrated, never a reference. See the hydration rule in `types.ts`. */\n  author: AuthorSchema,\n  category: CategorySchema,\n\n  tags: z.array(z.string()).default([]),\n\n  /**\n   * `ImageRef`, not `z.string()`. This value is written into `<image:loc>` in a\n   * sitemap that Next.js does not escape, into `og:image`, and into\n   * `BlogPosting.image`. See `ImageRef` above for the injection it closes.\n   */\n  heroImage: ImageRef.optional(),\n  heroAlt: z.string().optional(),\n\n  /** Editorial related posts, in the order they should appear. */\n  relatedPosts: z.array(Slug).default([]),\n  citations: z.array(CitationSchema).default([]),\n  faq: z.array(FaqEntrySchema).default([]),\n\n  draft: z.boolean().default(false),\n\n  /**\n   * Multi-locale is an explicit v1 non-goal. These two fields exist so that\n   * adding it later is additive rather than a breaking change to\n   * `ContentSource`, which is the one interface that has to stay stable.\n   *\n   * v1 emits `inLanguage` from `locale ?? config.locale` and ignores the rest.\n   */\n  locale: z.string().optional(),\n  translations: z.record(z.string(), Slug).optional(),\n})\n\n/* Two invariants that need more than one field, so they cannot live on a field. */\n\n/** Dates are compared as instants, not as strings: offsets are not lexicographic. */\nconst dateOrderIsSane = (p: { datePublished: string; dateModified: string }) =>\n  Date.parse(p.dateModified) >= Date.parse(p.datePublished)\n\nconst heroImageHasAlt = (p: { heroImage?: string | undefined; heroAlt?: string | undefined }) =>\n  !p.heroImage || Boolean(p.heroAlt)\n\nconst DATE_ORDER_ERROR = {\n  error: 'dateModified precedes datePublished',\n  path: ['dateModified'],\n}\nconst HERO_ALT_ERROR = {\n  error: 'heroImage requires heroAlt. An unlabelled hero image is an accessibility failure.',\n  path: ['heroAlt'],\n}\n\n/** A complete post, body included. This is what `ContentSource` returns. */\nexport const PostSchema = PostFieldsSchema.extend({ body: z.string() })\n  .refine(dateOrderIsSane, DATE_ORDER_ERROR)\n  .refine(heroImageHasAlt, HERO_ALT_ERROR)\n\n/**\n * Frontmatter as authored, without the body.\n *\n * Note that `z.input` and `z.output` genuinely differ here: `.default([])` means\n * the parsed post has `tags: string[]` while the authored frontmatter may omit\n * the key entirely. That difference is the whole reason these types are inferred\n * rather than hand-written, since a hand-written interface can only describe one\n * of the two shapes.\n */\nexport const PostFrontmatterSchema = PostFieldsSchema.refine(\n  dateOrderIsSane,\n  DATE_ORDER_ERROR,\n).refine(heroImageHasAlt, HERO_ALT_ERROR)\n",
      "type": "registry:lib",
      "target": "lib/schemas.ts"
    },
    {
      "path": "registry/blog/lib/types.ts",
      "content": "/**\n * ! GENERATED FILE. DO NOT EDIT.\n *\n * Source of truth: `packages/schema/src/types.ts` in the AgentBlog repository.\n * Regenerate with `pnpm codegen`.\n *\n * This file is copied verbatim so that the code running in your project is the\n * same code the AgentBlog test suite runs against. Edits here are safe to make\n * in your own repository once installed, but they will be overwritten if you\n * reinstall the block.\n */\n/**\n * AgentBlog domain types and the `ContentSource` contract.\n *\n * ---------------------------------------------------------------------------\n * THE ONE INTERFACE THAT MATTERS\n * ---------------------------------------------------------------------------\n * `ContentSource` is where your posts come from. MDX files on disk is the only\n * implementation shipped in v1, but the interface is deliberately the narrowest\n * thing that a database, a headless CMS, or a hosted service can also satisfy.\n * Swapping storage should be one line in `agentblog.config.ts`.\n *\n * If you are writing your own adapter, read `THE PURITY RULE` below first, then\n * run `runSourceContractTests` from `@agentblog/schema/contract` against it. The\n * suite asserts the eight properties the rest of the blog silently assumes.\n *\n * ---------------------------------------------------------------------------\n * THE PURITY RULE\n * ---------------------------------------------------------------------------\n * Every method must be callable at build time, from `generateStaticParams()` and\n * `generateMetadata()`. No React hooks. No client-only SDKs. No request-scoped\n * auth, which means no `cookies()` and no `headers()`.\n *\n * This is the rule that preserves full prerendering, and full prerendering is\n * what puts the complete article in the first response byte. AI crawlers do not\n * execute JavaScript. A source that needs a request context turns every post\n * into an empty shell for exactly the readers this project exists to reach.\n *\n * @see https://docs.agentblog.dev/reference/content-sources\n */\nimport type { z } from 'zod'\n\nimport type {\n  AuthorSchema,\n  CategorySchema,\n  CitationSchema,\n  FaqEntrySchema,\n  PostSchema,\n} from '@/lib/schemas'\nimport type { Slug } from '@/lib/schemas'\n\n/* -------------------------------------------------------------------------- */\n/*  Inferred domain types. Every name below is inferred, never authored.       */\n/* -------------------------------------------------------------------------- */\n\nexport type Post = z.infer<typeof PostSchema>\nexport type Author = z.infer<typeof AuthorSchema>\nexport type Category = z.infer<typeof CategorySchema>\nexport type Citation = z.infer<typeof CitationSchema>\nexport type FaqEntry = z.infer<typeof FaqEntrySchema>\n\n/** Frontmatter as authored, before defaults are applied. */\nexport type PostInput = z.input<typeof PostSchema>\n\n/**\n * A post guaranteed past the draft gate.\n *\n * `generateStaticParams` accepts only these, which makes it structurally\n * impossible to prerender a draft rather than merely unlikely.\n */\nexport type PublishedPost = Post & { draft: false }\n\n/* -------------------------------------------------------------------------- */\n/*  The ContentSource contract                                                */\n/* -------------------------------------------------------------------------- */\n\n/**\n * How a newly published post becomes a prerendered route.\n *\n * This matters more than it looks. Next.js does not re-run\n * `generateStaticParams` during ISR, so a slug that did not exist at build time\n * was never in the prerendered set. Each strategy states how that gap is closed:\n *\n * - `'build'`       Content lives in the repository, so publishing is a deploy.\n *                   Nothing extra to do. This is what `mdxSource` returns.\n * - `'deploy-hook'` The publish webhook triggers a rebuild and waits for it to\n *                   succeed before pinging IndexNow. Pinging first invites a\n *                   crawler to a URL that does not exist yet.\n * - `'on-demand'`   The adapter accepts a request-time first render. Only use\n *                   this if you have measured what that render actually returns\n *                   to a non-JavaScript crawler.\n */\nexport type PrerenderStrategy = 'build' | 'deploy-hook' | 'on-demand'\n\nexport interface PostQuery {\n  /**\n   * Drafts must be opted into, never out of. Defaults to `false` everywhere,\n   * including in adapters you write yourself.\n   */\n  readonly includeDrafts?: boolean\n  /** Reserved for multi-locale. v1 adapters may ignore this. */\n  readonly locale?: string\n}\n\n/**\n * The storage interface. One implementation ships in v1 (`mdxSource`); the rest\n * are drop-in replacements.\n *\n * Two contract points that are easy to miss and expensive to get wrong:\n *\n * 1. **Hydration.** `Post.author` and `Post.category` are fully hydrated\n *    objects, and `getAllPosts()` must return them in a single round trip. Build\n *    time iterates every post, so a lazy reference becomes an N+1 against your\n *    database at exactly the moment you have the most posts. For a SQL adapter\n *    this means a join. Contract test 3 asserts the call count does not scale\n *    with post count.\n *\n * 2. **Errors.** `getPost` returns `null` only when the post is genuinely\n *    absent, which the route turns into `notFound()`. Every other failure\n *    throws `AgentBlogSourceError`. There is no `Result` type here on purpose:\n *    Next.js calls these functions itself, and at build time throwing is\n *    correct. A source that cannot be reached should fail the build rather than\n *    deploy a site whose sitemap quietly lost half its URLs.\n *\n * ---------------------------------------------------------------------------\n * `Post.body` IS EXECUTED AS CODE. THIS IS THE ONE THING TO GET RIGHT\n * ---------------------------------------------------------------------------\n * The body you return is compiled as MDX and evaluated in your server process by\n * `renderMdx`. MDX is a programming language: an expression in the document has\n * `process` and `process.env` in scope, and no option anywhere in the toolchain\n * turns that off, because evaluating expressions is what MDX is for.\n *\n * So the security question an adapter has to answer is not \"is this string\n * well-formed\", it is **who can write to this store**. `mdxSource` answers it\n * cleanly: bodies are files in the repository, so writing one requires commit\n * access and passes through review and a deploy.\n *\n * An adapter over a database, a CMS, or an HTTP API answers it only if every\n * write path is restricted to authors you would give a shell account to. If it\n * is not, do not return raw MDX from `getPost` and `getAllPosts`. Render the\n * stored content through a CommonMark pipeline with raw HTML disabled and return\n * the result, or gate MDX behind an author role your own code checks. Treating\n * a `body` column as untrusted input after it has reached `renderMdx` is too\n * late.\n */\nexport interface ContentSource<Strategy extends PrerenderStrategy = PrerenderStrategy> {\n  /** How new posts enter the prerendered set. See `PrerenderStrategy`. */\n  readonly prerenderStrategy: Strategy\n\n  /** Stable identifier used in error messages and diagnostics, e.g. `'mdx'`. */\n  readonly name: string\n\n  getAllPosts(opts?: PostQuery): Promise<PublishedPost[]>\n  getPost(slug: Slug, opts?: PostQuery): Promise<Post | null>\n  getAllCategories(): Promise<Category[]>\n  getAllAuthors(): Promise<Author[]>\n  getPostsByCategory(slug: Slug): Promise<PublishedPost[]>\n  getPostsByAuthor(slug: Slug): Promise<PublishedPost[]>\n\n  /**\n   * Related posts, editorial first.\n   *\n   * Returns `post.relatedPosts` in the order the author wrote them, then fills\n   * to `limit` with computed matches: shared category, then shared tags, then\n   * recency. Explicit beats computed; computed fills the gap.\n   */\n  getRelatedPosts(post: Post, limit: number): Promise<PublishedPost[]>\n}\n\n/* -------------------------------------------------------------------------- */\n/*  Errors                                                                    */\n/* -------------------------------------------------------------------------- */\n\n/**\n * A content source failed to answer.\n *\n * Thrown for transport failures, auth failures, and malformed responses. Never\n * thrown for \"no such post\", which is `null`.\n */\nexport class AgentBlogSourceError extends Error {\n  /** The `ContentSource.name` that failed, e.g. `'mdx'`. */\n  readonly source: string\n  /** The method that failed, e.g. `'getAllPosts'`. */\n  readonly operation: string\n\n  // Fields are declared and assigned rather than written as constructor\n  // parameter properties, so this file runs unchanged under every transpiler\n  // including Node's own type stripping, which does not support them.\n  constructor(source: string, operation: string, options?: { cause?: unknown }) {\n    super(`[agentblog:${source}] ${operation} failed`, options)\n    this.name = 'AgentBlogSourceError'\n    this.source = source\n    this.operation = operation\n  }\n}\n\n/** One issue from a failed parse, flattened so this type needs no Zod import. */\nexport interface ContentIssue {\n  readonly path: readonly (string | number | symbol)[]\n  readonly message: string\n}\n\n/**\n * A post, an author record, or the config failed validation.\n *\n * `location` is always a file path or a record id. \"Invalid input\" at build time\n * is worthless when two hundred files could be the culprit, so a malformed post\n * has to name itself.\n */\nexport class AgentBlogContentError extends Error {\n  /** File path or record id of the offending content. */\n  readonly location: string\n  /** Every issue found, so one parse reports every problem rather than the first. */\n  readonly issues: readonly ContentIssue[]\n\n  constructor(location: string, issues: readonly ContentIssue[]) {\n    const detail = issues\n      .map((i) => `  ${i.path.map(String).join('.') || '(root)'}: ${i.message}`)\n      .join('\\n')\n    super(`[agentblog] ${location} is invalid:\\n${detail}`)\n    this.name = 'AgentBlogContentError'\n    this.location = location\n    this.issues = issues\n  }\n}\n",
      "type": "registry:lib",
      "target": "lib/types.ts"
    },
    {
      "path": "registry/blog/lib/posts.ts",
      "content": "/**\n * The content facade. Every route reads posts through this file.\n *\n * ---------------------------------------------------------------------------\n * WHY A FACADE INSTEAD OF CALLING `config.source` DIRECTLY\n * ---------------------------------------------------------------------------\n * `config.source` is a `ContentSource`: MDX files today, a database or a hosted\n * service later. Swapping it should be one line in `agentblog.config.ts`. That\n * promise only holds if nothing outside this file knows the adapter exists, so\n * routes import `@/lib/posts` and never `config.source`. When the adapter grows\n * a new capability, or an old one needs a shim, this file absorbs it.\n *\n * The facade also owns three things the adapter contract deliberately leaves\n * out, because they are presentation concerns rather than storage concerns:\n * pagination, tag aggregation, and slug validation.\n *\n * ---------------------------------------------------------------------------\n * SLUGS ARRIVE AS UNVALIDATED STRINGS\n * ---------------------------------------------------------------------------\n * `ContentSource` takes a branded `Slug`, which can only be produced by parsing.\n * Route params are plain strings straight off the URL bar, so every entry point\n * here parses first. A slug that cannot be valid returns `null` or `[]` rather\n * than throwing: `/blog/../../etc/passwd` is a 404, not a 500. Genuine failures\n * (the source is unreachable, a post is malformed) still throw, because at build\n * time a thrown error is the correct outcome and a swallowed one ships a sitemap\n * that quietly lost half its URLs.\n *\n * ---------------------------------------------------------------------------\n * CACHING\n * ---------------------------------------------------------------------------\n * React's `cache()` deduplicates within a single render pass and, at build time,\n * within a single page's `generateStaticParams` plus `generateMetadata` plus\n * component tree. That matters because a post page asks for the same data three\n * times through three different framework entry points. The cache keys are\n * primitives, not option objects, because `cache()` compares arguments by\n * identity and a fresh `{ includeDrafts: false }` literal would miss on every\n * call.\n *\n * @see https://docs.agentblog.dev/reference/content-sources\n */\nimport 'server-only'\n\nimport { cache } from 'react'\n\nimport { config, tagSlug } from '@/lib/config'\nimport { Slug } from '@/lib/schemas'\nimport type { Author, Category, Post, PostQuery, PublishedPost } from '@/lib/types'\n\n/* -------------------------------------------------------------------------- */\n/*  Cached adapter calls. Primitive arguments only, so `cache()` actually hits. */\n/* -------------------------------------------------------------------------- */\n\nconst allPosts = cache(\n  (includeDrafts: boolean, locale: string | undefined): Promise<PublishedPost[]> =>\n    config.source.getAllPosts(buildQuery(includeDrafts, locale)),\n)\n\nconst postBySlug = cache(\n  (slug: Slug, includeDrafts: boolean, locale: string | undefined): Promise<Post | null> =>\n    config.source.getPost(slug, buildQuery(includeDrafts, locale)),\n)\n\nconst allCategories = cache((): Promise<Category[]> => config.source.getAllCategories())\n\nconst allAuthors = cache((): Promise<Author[]> => config.source.getAllAuthors())\n\nconst postsByCategory = cache((slug: Slug): Promise<PublishedPost[]> =>\n  config.source.getPostsByCategory(slug),\n)\n\nconst postsByAuthor = cache((slug: Slug): Promise<PublishedPost[]> =>\n  config.source.getPostsByAuthor(slug),\n)\n\n/**\n * Build a `PostQuery` without ever writing an explicit `undefined`.\n * `exactOptionalPropertyTypes` treats `{ locale: undefined }` and `{}` as\n * different types, and adapters are entitled to tell them apart.\n */\nfunction buildQuery(includeDrafts: boolean, locale: string | undefined): PostQuery {\n  return locale === undefined ? { includeDrafts } : { includeDrafts, locale }\n}\n\n/** `null` for anything that could not be a slug, so a bad URL is a 404. */\nfunction parseSlug(slug: string): Slug | null {\n  const parsed = Slug.safeParse(slug)\n  return parsed.success ? parsed.data : null\n}\n\n/* -------------------------------------------------------------------------- */\n/*  Reads                                                                     */\n/* -------------------------------------------------------------------------- */\n\n/**\n * Every published post. Ordering is the adapter's responsibility; `mdxSource`\n * returns newest first.\n *\n * `generateStaticParams` feeds on this and must return **every** slug, never a\n * page of them. Slicing here is what turns a prerendered blog into a partly\n * prerendered one, and the missing posts are invisible until a crawler asks.\n */\nexport function getAllPosts(opts?: PostQuery): Promise<PublishedPost[]> {\n  return allPosts(opts?.includeDrafts ?? false, opts?.locale)\n}\n\n/** One post, or `null` when the slug is absent or malformed. */\nexport function getPost(slug: string, opts?: PostQuery): Promise<Post | null> {\n  const parsed = parseSlug(slug)\n  if (parsed === null) return Promise.resolve(null)\n  return postBySlug(parsed, opts?.includeDrafts ?? false, opts?.locale)\n}\n\n/** Every category, including ones with no published posts. */\nexport function getAllCategories(): Promise<Category[]> {\n  return allCategories()\n}\n\n/** Every author record, whether or not they have published. */\nexport function getAllAuthors(): Promise<Author[]> {\n  return allAuthors()\n}\n\n/** One category by slug, or `null`. Resolved from the full list, which is cached. */\nexport async function getCategory(slug: string): Promise<Category | null> {\n  const parsed = parseSlug(slug)\n  if (parsed === null) return null\n  const categories = await allCategories()\n  return categories.find((category) => category.slug === parsed) ?? null\n}\n\n/** One author by slug, or `null`. */\nexport async function getAuthor(slug: string): Promise<Author | null> {\n  const parsed = parseSlug(slug)\n  if (parsed === null) return null\n  const authors = await allAuthors()\n  return authors.find((author) => author.slug === parsed) ?? null\n}\n\n/** Published posts in a category. `[]` for an unknown or malformed slug. */\nexport function getPostsByCategory(slug: string): Promise<PublishedPost[]> {\n  const parsed = parseSlug(slug)\n  if (parsed === null) return Promise.resolve([])\n  return postsByCategory(parsed)\n}\n\n/** Published posts by an author. `[]` for an unknown or malformed slug. */\nexport function getPostsByAuthor(slug: string): Promise<PublishedPost[]> {\n  const parsed = parseSlug(slug)\n  if (parsed === null) return Promise.resolve([])\n  return postsByAuthor(parsed)\n}\n\n/**\n * Published posts carrying a tag, matched case insensitively.\n *\n * Tags are free text rather than slugs, so this is a filter over the full post\n * list rather than an adapter call. That is deliberate: adding a tag index to\n * `ContentSource` would make every future adapter implement it, for a feature\n * whose whole point is that it needs no schema.\n */\nexport async function getPostsByTag(tag: string): Promise<PublishedPost[]> {\n  // The argument arrives from a route param, so it is already the slug form.\n  // Comparing slug to slug is what keeps `/blog/tag/ai-crawlers` and a post\n  // tagged \"AI crawlers\" describing the same set.\n  const needle = tagSlug(tag)\n  if (needle === '') return []\n  const posts = await getAllPosts()\n  return posts.filter((post) => post.tags.some((t) => tagSlug(t) === needle))\n}\n\n/**\n * Every tag with its published post count, most used first, then alphabetical.\n *\n * `tag` is the display text as first authored, `slug` is its URL form, and the\n * count is what `config.noindexTagsBelow` compares against, so the tag route can\n * decide whether a given tag page is worth indexing before it renders. Returning\n * both spellings is what keeps `generateStaticParams` from emitting a route\n * segment containing a space.\n */\nexport async function getAllTags(): Promise<{ tag: string; slug: string; count: number }[]> {\n  const posts = await getAllPosts()\n  const counts = new Map<string, { tag: string; slug: string; count: number }>()\n\n  for (const post of posts) {\n    for (const raw of post.tags) {\n      const tag = raw.trim()\n      if (tag === '') continue\n      // Keyed by slug rather than by lowercase text, so \"AI crawlers\" and\n      // \"ai-crawlers\" collapse into the one tag page they both link to.\n      const key = tagSlug(tag)\n      if (key === '') continue\n      const existing = counts.get(key)\n      // First spelling seen wins, so display casing stays stable across builds.\n      if (existing) existing.count += 1\n      else counts.set(key, { tag, slug: key, count: 1 })\n    }\n  }\n\n  return [...counts.values()].sort((a, b) => b.count - a.count || a.tag.localeCompare(b.tag))\n}\n\n/**\n * Related posts: the author's editorial list first, in their order, then\n * computed matches to fill the gap. The precedence lives in the adapter, which\n * is the only place that can resolve editorial slugs in one round trip.\n */\nexport function getRelatedPosts(post: Post, limit = 3): Promise<PublishedPost[]> {\n  return config.source.getRelatedPosts(post, limit)\n}\n\n/**\n * One page of the index, clamped to a page that exists.\n *\n * `page` comes from a URL segment, so it can be `0`, `-3`, `NaN`, or larger than\n * the last page. Clamping rather than 404ing keeps `/blog/page/999` a real page\n * with real links instead of a soft 404 that a crawler has to interpret. The\n * returned `page` is the clamped value, so pagination links stay consistent.\n */\nexport async function getPage(\n  page: number,\n): Promise<{ posts: PublishedPost[]; totalPages: number; page: number }> {\n  const posts = await getAllPosts()\n  const perPage = config.postsPerPage\n  const totalPages = Math.max(1, Math.ceil(posts.length / perPage))\n\n  const requested = Number.isFinite(page) ? Math.trunc(page) : 1\n  const current = Math.min(Math.max(requested, 1), totalPages)\n  const start = (current - 1) * perPage\n\n  return { posts: posts.slice(start, start + perPage), totalPages, page: current }\n}\n",
      "type": "registry:lib",
      "target": "lib/posts.ts"
    },
    {
      "path": "registry/blog/lib/metadata.ts",
      "content": "/**\n * Shared metadata defaults. This file exists because of one Next.js rule.\n *\n * ===========================================================================\n * NEXT.JS MERGES METADATA SHALLOWLY\n * ===========================================================================\n * From the `generateMetadata` reference: metadata objects from multiple segments\n * are **shallowly** merged, and *\"metadata with nested fields such as\n * `openGraph` and `robots` that are defined in an earlier segment are\n * overwritten by the last segment to define them.\"*\n *\n * Read that again with a real example. The root layout sets:\n *\n *     openGraph: { siteName: 'Your Brand', locale: 'en_US', type: 'website' }\n *\n * The post page sets:\n *\n *     openGraph: { type: 'article', title, description, url, images }\n *\n * Because the page defined `openGraph` **at all**, the root layout's entire\n * `openGraph` object is discarded. Every post ships an Open Graph card with no\n * `og:site_name` and no `og:locale`. Nothing errors. No validator complains. The\n * types are correct. The only way to notice is to view source on a built page or\n * paste a URL into a social preview debugger.\n *\n * The identical trap applies to `robots`. A page that sets `robots` at all\n * replaces the layout's `googleBot` block, and `max-snippet: -1` is what permits\n * full length snippets in AI Overviews and AI Mode. Losing it is a retrieval\n * regression dressed up as a formatting detail.\n *\n * ===========================================================================\n * THE RULE THIS FILE ENFORCES\n * ===========================================================================\n * Every route segment that sets `openGraph` or `robots` spreads the defaults\n * below, or calls one of the two builders. Nothing hand rolls a metadata object.\n * If you are adding a route, copy an existing one rather than writing\n * `openGraph` from scratch.\n *\n * ===========================================================================\n * AN ABSENT KEY AND A KEY SET TO `undefined` ARE DIFFERENT THINGS HERE\n * ===========================================================================\n * READ THIS BEFORE TIDYING ANY OBJECT BELOW INTO SHORTHAND.\n *\n * Next.js merges a file-based `opengraph-image` route into a segment's metadata\n * only when that segment does not already own the key. The check in\n * `next/dist/lib/metadata/resolve-metadata.js` is literally:\n *\n *     if (openGraph && !source?.openGraph?.hasOwnProperty('images')) { ... }\n *\n * `hasOwnProperty`, not a truthiness test. So writing `images` with shorthand,\n * `{ ...defaults, images }`, puts the key on the object even when the value is\n * `undefined`, the merge is skipped, and the page ships with no `og:image` and\n * no `twitter:image` while `opengraph-image.tsx` is still built and referenced\n * by nothing. Nothing errors, no type is wrong, and the only symptom is a blank\n * card in a social preview debugger.\n *\n * Both builders therefore use a conditional spread,\n * `...(images?.length ? { images } : {})`, so an unset image leaves the key\n * absent and the generated card wins. The length test rather than a plain\n * truthiness test is there because `images: []` is truthy and would block the\n * merge exactly like `images: undefined` does.\n *\n * The same reasoning applies to any optional key here: omit it, never assign\n * `undefined` to it, because a `for (const key in metadata)` loop in the same\n * merge visits keys whose value is `undefined` and overwrites the parent's.\n *\n * Key casing is exactly as Next.js 16 declares it and is not stylistic:\n * `googleBot` is camelCase, while the granular directives are kebab-case string\n * literals (`'max-snippet'`, `'max-image-preview'`, `'max-video-preview'`).\n * Use `index: false` and `follow: false`; `noindex` and `nofollow` are typed\n * `never` and deprecated.\n *\n * @see https://docs.agentblog.dev/reference/files\n */\nimport type { Metadata } from 'next'\n\nimport { absoluteUrl, authorUrl, config, postUrl } from '@/lib/config'\nimport type { Post } from '@/lib/types'\n\n/**\n * The nested Open Graph fields a page would otherwise destroy. Spread these\n * first, then add the page specific keys.\n */\nexport const openGraphDefaults: { siteName: string; locale: string } = {\n  siteName: config.brand.name,\n  locale: config.locale,\n}\n\n/**\n * Googlebot directives that unlock full snippets, large image previews, and\n * unlimited video previews in classic results and in AI surfaces alike.\n *\n * `-1` means \"no limit\". `'large'` is the difference between a thumbnail and a\n * full width image in a rich result.\n */\nexport const googleBotDefaults = {\n  index: true,\n  follow: true,\n  'max-image-preview': 'large',\n  'max-snippet': -1,\n  'max-video-preview': -1,\n} as const\n\n/**\n * The default `robots` object, with `googleBotDefaults` nested under `googleBot`\n * where Next.js expects it.\n *\n * Prefer `buildPostMetadata` or `buildListMetadata` over reaching for this\n * directly. It is exported for the root layout and for any route this block does\n * not ship.\n */\nexport const robotsDefaults: Metadata['robots'] = {\n  index: true,\n  follow: true,\n  googleBot: googleBotDefaults,\n}\n\n/** The RSS link that rides along in `alternates.types` on every blog surface. */\nfunction rssAlternate(): Record<string, { url: string; title: string }[]> {\n  return {\n    'application/rss+xml': [{ url: absoluteUrl('/feed.xml'), title: `${config.brand.name} blog` }],\n  }\n}\n\n/**\n * Turn a hero image into the Open Graph and Twitter image shape.\n *\n * `absoluteUrl` passes an already absolute URL through untouched, so a CDN\n * hosted hero and a `/public` relative one both work without a branch here.\n */\nfunction heroImages(post: Post): { url: string; alt: string }[] | undefined {\n  if (!post.heroImage) return undefined\n  return [{ url: absoluteUrl(post.heroImage), alt: post.heroAlt ?? post.title }]\n}\n\n/**\n * The complete `Metadata` object for a post route.\n *\n * Four things it gets right that a hand written object usually does not:\n * the canonical goes in `alternates.canonical` (not a raw `<link>`), the feed\n * goes in `alternates.types`, `openGraph` carries the full article shape with\n * the site defaults intact, and `robots` carries the `googleBot` block.\n */\nexport function buildPostMetadata(post: Post): Metadata {\n  const url = postUrl(post.slug)\n  const images = heroImages(post)\n\n  return {\n    title: post.title,\n    description: post.description,\n\n    authors: [{ name: post.author.name, url: authorUrl(post.author.slug) }],\n    // Omitted rather than set to `undefined` when the post has no tags. See the\n    // header: a key present with an `undefined` value still overwrites whatever\n    // the parent segment resolved.\n    ...(post.tags.length > 0 ? { keywords: post.tags } : {}),\n\n    alternates: {\n      // The canonical is absolute on purpose. A relative one is resolved against\n      // `metadataBase`, which is one more thing that can be wrong in a way that\n      // only shows up in production.\n      canonical: url,\n      types: rssAlternate(),\n    },\n\n    robots: robotsDefaults,\n\n    openGraph: {\n      ...openGraphDefaults,\n      type: 'article',\n      title: post.title,\n      description: post.description,\n      url,\n      publishedTime: post.datePublished,\n      modifiedTime: post.dateModified,\n      authors: [post.author.name],\n      section: post.category.name,\n      tags: post.tags,\n      // Conditional spread, never `images`. With no hero image the key must be\n      // absent so `app/blog/[slug]/opengraph-image.tsx` is merged in.\n      ...(images?.length ? { images } : {}),\n    },\n\n    twitter: {\n      card: 'summary_large_image',\n      title: post.title,\n      description: post.description,\n      ...(images?.length ? { images } : {}),\n    },\n  }\n}\n\n/**\n * The blog-level Open Graph card, generated by `app/blog/opengraph-image.tsx`.\n *\n * A list surface cannot get this card the way a post does. The file-based merge\n * described in the header only fires for a static image file **in the same route\n * segment**, and only `app/blog/[slug]/` has one. Every other segment defines\n * `openGraph` for its own `og:url` and `og:title`, which replaces the parent's\n * resolved object outright and takes the parent's card with it. Measured on a\n * real build: before this constant existed, `/blog`, every category hub, every\n * tag page, and every author page shipped six `og:*` tags and no `og:image`,\n * while `/` (which defines no `openGraph` of its own) had one.\n *\n * Naming the route here is what closes that hole. The route ships in the same\n * registry item as the surfaces that point at it, `@agentblog/blog-routes`, so\n * the image and its callers cannot be installed apart.\n *\n * This is a blog path rather than a site path, and that is the point. Until\n * AgentBlog 0.4 the card was `app/opengraph-image.tsx` at the app root, which\n * meant installing a blog silently claimed the social card for the host site's\n * whole domain. Your own `app/opengraph-image.*` now wins for everything\n * outside `/blog`, and nothing this block ships will overwrite it.\n */\nconst BLOG_OG_IMAGE = '/blog/opengraph-image'\n\n/**\n * The dimensions and MIME type of the card `app/blog/opengraph-image.tsx`\n * produces, declared here because this file cannot ask the route for them.\n *\n * WHY THESE ARE WRITTEN OUT RATHER THAN DERIVED\n * When Next.js merges a file-based `opengraph-image` itself, it emits\n * `og:image:type`, `og:image:width`, and `og:image:height` alongside the URL,\n * and it appends its own content hash to the URL:\n * `…/opengraph-image?9e1e2d0f397a82a1`. Naming the route by hand, which is the\n * only way a list surface gets the card at all (see `BLOG_OG_IMAGE`), gets none\n * of that. Measured on a real build, every list surface shipped the bare URL\n * with only `og:image:alt`, so a scraper had no dimensions to lay out with and\n * frequently declined to show a large card.\n *\n * Three of the four are recoverable here and are set below. The hash is not:\n * it is Next's cache key, computed from the route's compiled output at build\n * time, and there is no exported API for reading it from application code.\n * Importing the route module would not help either, since the hash\n * comes from the build and not from the module, and a lib importing a route is\n * the wrong direction regardless.\n *\n * THE TRADEOFF, STATED PLAINLY\n * The URL is stable across rebuilds, which is what a cache key exists to avoid.\n * Rebrand (change `config.brand`) and the bytes at `/blog/opengraph-image` change\n * while its URL does not, so a social scraper or CDN that already cached the old\n * card keeps serving it until its own TTL expires. There is no cache bust\n * available from this file. What to do about it: re-scrape the affected URLs\n * through the platform's own tool after a rebrand (Facebook's Sharing Debugger,\n * LinkedIn's Post Inspector, X's Card Validator each refetch on demand), and\n * accept that everything else refreshes on its own schedule. If that is not good\n * enough for your rebrand, pass `images` explicitly at the call site with a\n * versioned filename you control.\n *\n * Keep these numbers equal to `OG_SIZE` in `lib/og-card.tsx`, which is what both\n * card routes export as their `size`. Dimensions that disagree with the file are\n * worse than none: they are what a scraper lays out against before it ever\n * fetches the image.\n */\nconst BLOG_OG_IMAGE_META = { width: 1200, height: 630, type: 'image/png' } as const\n\n/**\n * Metadata for a list surface: the blog index, a category hub, a tag page, an\n * author page.\n *\n * `index` defaults to `true`. Pass `false` for a thin tag page, and note that\n * `follow` stays `true` in that case so the posts linked from it are still\n * discovered. A blanket `noindex, nofollow` on a tag page hides the posts too.\n *\n * `images` is optional and falls back to the site card. Pass one only when the\n * surface has an image of its own worth showing, such as a category with real\n * artwork.\n */\nexport function buildListMetadata(input: {\n  title: string\n  description: string\n  path: string\n  index?: boolean\n  images?: string[]\n}): Metadata {\n  const url = absoluteUrl(input.path)\n  const indexable = input.index ?? true\n  // A caller-supplied image gets no dimensions, because we do not know them and\n  // a wrong `og:image:width` is worse than a missing one. The blog card does,\n  // because `lib/og-card.tsx` declares them. See `BLOG_OG_IMAGE_META`.\n  const images = input.images?.length\n    ? input.images.map((image) => ({ url: absoluteUrl(image) }))\n    : [{ url: absoluteUrl(BLOG_OG_IMAGE), alt: config.brand.name, ...BLOG_OG_IMAGE_META }]\n\n  return {\n    title: input.title,\n    description: input.description,\n\n    alternates: { canonical: url, types: rssAlternate() },\n\n    robots: indexable\n      ? robotsDefaults\n      : { index: false, follow: true, googleBot: { ...googleBotDefaults, index: false } },\n\n    openGraph: {\n      ...openGraphDefaults,\n      type: 'website',\n      title: input.title,\n      description: input.description,\n      url,\n      // Always set, and always non-empty. Unlike a post, a list surface has no\n      // segment-local `opengraph-image.tsx` to be merged in, so leaving the key\n      // absent here produces a card with no image at all. See `BLOG_OG_IMAGE`.\n      images,\n    },\n\n    twitter: {\n      card: 'summary_large_image',\n      title: input.title,\n      description: input.description,\n      images,\n    },\n  }\n}\n",
      "type": "registry:lib",
      "target": "lib/metadata.ts"
    },
    {
      "path": "registry/blog/lib/preflight.ts",
      "content": "/**\n * Build time configuration lint. Warns. Never throws. Never runs per request.\n *\n * ===========================================================================\n * WHY A FILE IN YOUR REPO LINTS YOUR CONFIG\n * ===========================================================================\n * A shadcn registry can write files. It cannot patch them. The single highest\n * value setting AgentBlog needs (`htmlLimitedBots` in `next.config.ts`) is a\n * modification to a file you already own, so installing the block through the\n * registry alone leaves it unset. The result is a blog that looks perfect and\n * hands AI crawlers `<title>` and `<meta>` inside `<body>` on any page with\n * deferred metadata.\n *\n * That failure is completely silent. This module is what makes it loud. It reads\n * `next.config.*` off disk at build and dev time and prints what is missing,\n * using the same predicates `agentblog doctor` uses, so the two tools cannot\n * drift and contradict each other in front of you.\n *\n * `app/blog/layout.tsx` imports and calls it. Do not delete that call to silence\n * the warning; set `preflight: false` in `agentblog.config.ts` instead. The\n * difference matters: the config flag keeps working when a future edit breaks\n * something new, and deleting the import means nothing ever tells you again.\n *\n * ===========================================================================\n * CONSTRAINTS, ALL BINDING\n * ===========================================================================\n * 1. **Warn, never throw.** Failing a production build over a config lint is\n *    hostile and gets the import deleted, which costs more than the lint is\n *    worth. Every path here is wrapped so a filesystem surprise cannot become a\n *    failed deploy.\n * 2. **Once per process.** A module scope flag, not a per render check. This\n *    runs inside a layout, and a layout renders once per route.\n * 3. **No cost at request time.** See the phase detection below.\n * 4. **No dependencies.** `lib/preflight-checks.ts` is dependency free by\n *    contract and is generated from `packages/checks`; this file adds only\n *    `node:fs` and `node:path`, which is why it carries `server-only`.\n * 5. **A missing `next.config.*` is a warning, not a crash.**\n *\n * @see https://docs.agentblog.dev/installation\n */\nimport 'server-only'\n\nimport { existsSync, readFileSync } from 'node:fs'\nimport { join } from 'node:path'\n\nimport { config } from '@/lib/config'\nimport { bySeverity, checkNextConfig } from '@/lib/preflight-checks'\nimport type { Finding } from '@/lib/preflight-checks'\n\n/** In the order Next.js itself resolves them. */\nconst CONFIG_FILENAMES = [\n  'next.config.ts',\n  'next.config.mts',\n  'next.config.mjs',\n  'next.config.js',\n] as const\n\n/** Constraint 2: module scope, so repeated renders cost nothing. */\nlet hasRun = false\n\n/**\n * Run the config lint once and print any findings.\n *\n * Safe to call from anywhere on the server. Calling it twice is a no op.\n */\nexport function preflight(): void {\n  if (hasRun) return\n  hasRun = true\n\n  try {\n    if (!shouldRun()) return\n    report([...checkConfigResolution(), ...checkNextConfigOnDisk()])\n  } catch {\n    // Constraint 1. A lint that can fail a build is a lint people delete.\n  }\n}\n\n/**\n * Whether this process is a build or a dev server rather than a live request.\n *\n * Being honest about what is actually detected here: `NEXT_PHASE` is set by\n * Next.js and is not a documented public API, so this is a best effort signal\n * rather than a guarantee. The fallback is `NODE_ENV`, which is `'production'`\n * both during `next build` and while serving production traffic, so on its own\n * it cannot tell those two apart.\n *\n * The combination gives: run during a production build (phase matches), run in\n * development (NODE_ENV is not production), stay silent while serving production\n * traffic. If `NEXT_PHASE` ever disappears the check degrades to development\n * only, which is the safe direction to fail in.\n */\nfunction shouldRun(): boolean {\n  if (!config.preflight) return false\n  if (process.env.NEXT_PHASE === 'phase-production-build') return true\n  return process.env.NODE_ENV !== 'production'\n}\n\n/**\n * Confirm `lib/config.ts` actually resolved the user's config file.\n *\n * `agentblog.config.ts` lives at the project root while `@/` maps to `src/` in a\n * `src/` layout, so `import userConfig from '@/agentblog.config'` can resolve to\n * nothing in a project nobody ran `agentblog init` on. The types say this is\n * unreachable, which is precisely why it needs a runtime check: the type system\n * is describing what the import is supposed to produce, not what a broken alias\n * actually produced.\n */\nfunction checkConfigResolution(): Finding[] {\n  const resolved: Partial<typeof config> | undefined = config\n\n  if (!resolved || !resolved.siteUrl) {\n    return [\n      {\n        id: 'config-unresolved',\n        severity: 'error',\n        message:\n          'lib/config.ts resolved no `siteUrl`. `@/agentblog.config` most likely did not ' +\n          'resolve, which happens in a `src/` layout because the config file belongs at the ' +\n          'project root while `@/` points at `src/`. Every canonical URL, sitemap entry, and ' +\n          'JSON-LD id is composed from `siteUrl`, so nothing downstream is correct until ' +\n          'this is fixed.',\n        remedy:\n          'npx agentblog@latest doctor --fix, or add an `\"@/agentblog.config\"` path to ' +\n          'tsconfig.json pointing at the root file.',\n        fixable: true,\n      },\n    ]\n  }\n\n  return []\n}\n\n/** Read the first `next.config.*` that exists and run the shared predicates. */\nfunction checkNextConfigOnDisk(): Finding[] {\n  const root = process.cwd()\n\n  for (const filename of CONFIG_FILENAMES) {\n    /*\n     * `turbopackIgnore` on all three calls, and it is not a workaround.\n     *\n     * Turbopack traces filesystem access statically. It cannot prove which file\n     * a computed path resolves to, so it assumes the worst and traces the whole\n     * project, which copies your source tree and `public/` next to the server\n     * bundle. That inflates every deployment and can trip a platform size limit.\n     *\n     * These three calls genuinely need a computed path: this function exists to\n     * read whichever `next.config.*` you happen to have, at your project root.\n     * There is nothing to declare statically. The annotation tells Turbopack\n     * that we take responsibility for the dependency, and the honest statement\n     * of that responsibility is: this runs at build and dev time only, it reads\n     * exactly one file that is already part of your project, and it never runs\n     * at request time (see `shouldRun`). Nothing here needs to be bundled.\n     */\n    const path = join(/* turbopackIgnore: true */ root, filename)\n    if (!existsSync(/* turbopackIgnore: true */ path)) continue\n\n    try {\n      return checkNextConfig({\n        source: readFileSync(/* turbopackIgnore: true */ path, 'utf8'),\n        path: filename,\n      })\n    } catch {\n      // Unreadable is not the same as absent, and neither is worth a build failure.\n      return []\n    }\n  }\n\n  return [\n    {\n      id: 'next-config-missing',\n      severity: 'warning',\n      message:\n        'No next.config.ts, .mts, .mjs, or .js was found in the working directory, so ' +\n        'AgentBlog cannot confirm that `htmlLimitedBots` and `images.qualities` are set. In a ' +\n        'monorepo this usually means the process is running from the workspace root rather ' +\n        'than the app directory.',\n      remedy: 'npx agentblog@latest doctor --fix, run from the Next.js app directory.',\n      fixable: false,\n    },\n  ]\n}\n\n/** One grouped `console.warn`, most severe first, so build logs stay readable. */\nfunction report(findings: readonly Finding[]): void {\n  if (findings.length === 0) return\n\n  /*\n   * One line per finding, deliberately.\n   *\n   * `next build` forks worker processes and each one runs this module, so an\n   * unfixed config prints this block several times per build. The module-scope\n   * memo cannot dedupe across processes and neither can an environment marker,\n   * because the workers are forked before the first report runs.\n   *\n   * That leaves controlling the volume rather than the count. A four line entry\n   * repeated five times is forty lines of build output, and the plan's own risk\n   * table names \"users delete the preflight import to silence the warning\" as a\n   * real failure mode. The full explanation lives in `agentblog doctor`, which\n   * runs once and can also fix what it finds, so the job here is to be\n   * unmissable and short, then hand off.\n   */\n  const sorted = [...findings].sort(bySeverity)\n  const lines = sorted.map(\n    (finding) => `  AgentBlog [${finding.severity}] ${finding.id}: ${finding.message}`,\n  )\n  lines.push('  AgentBlog: run `npx agentblog@latest doctor --fix` to repair these.')\n\n  console.warn(lines.join('\\n'))\n}\n",
      "type": "registry:lib",
      "target": "lib/preflight.ts"
    },
    {
      "path": "registry/blog/lib/preflight-checks.ts",
      "content": "/**\n * ! GENERATED FILE. DO NOT EDIT.\n *\n * Source of truth: `packages/checks/src/core.ts` in the AgentBlog repository.\n * Regenerate with `pnpm codegen`.\n *\n * This file is copied verbatim so that the code running in your project is the\n * same code the AgentBlog test suite runs against. Edits here are safe to make\n * in your own repository once installed, but they will be overwritten if you\n * reinstall the block.\n */\n/**\n * AgentBlog configuration checks.\n *\n * ===========================================================================\n * THIS FILE IS COPIED VERBATIM INTO EVERY CONSUMER PROJECT\n * ===========================================================================\n * `scripts/codegen.mjs` copies this file to\n * `apps/web/registry/blog/lib/preflight-checks.ts`, which the registry then\n * writes into a consumer's repository as `lib/preflight-checks.ts`. CI fails if\n * the copy has drifted.\n *\n * Two consequences, both binding:\n *\n *   1. **Zero imports.** Not `zod`, not `node:fs`, not another file in this\n *      package. The consumer's copy has no `node_modules` entry to import from.\n *      Everything here is pure string and RegExp work over file contents that\n *      the caller supplies.\n *\n *   2. **No side effects.** The caller decides whether to warn, throw, or fix.\n *      `lib/preflight.ts` warns at build time; `agentblog doctor` reports and\n *      can fix. Both read the same predicates, which is the only way those two\n *      tools cannot drift and disagree in front of a user.\n *\n * @see https://docs.agentblog.dev/reference/cli\n */\n\n/* ========================================================================== */\n/*  Severity and findings                                                     */\n/* ========================================================================== */\n\n/**\n * How badly a check failed.\n *\n * The distinction between `error` and `warning` is not cosmetic. A narrowed\n * `htmlLimitedBots` is an *active regression* against Googlebot and every social\n * preview bot, whereas a missing one is only a missed opportunity. Reporting\n * both as \"problem\" would bury the one that is actively costing traffic.\n */\nexport type Severity = 'error' | 'warning' | 'info'\n\nexport interface Finding {\n  /** Stable id, e.g. `'html-limited-bots-narrowed'`. Used by `--fix` routing. */\n  readonly id: string\n  readonly severity: Severity\n  /** One line, present tense, states what is wrong. */\n  readonly message: string\n  /** What to do about it. Omitted when there is nothing actionable. */\n  readonly remedy?: string\n  /** Whether `agentblog doctor --fix` can repair this automatically. */\n  readonly fixable: boolean\n}\n\n/* ========================================================================== */\n/*  The Next.js default HTML-limited bot list, vendored                       */\n/* ========================================================================== */\n\n/**\n * Next.js 16.3.0's built-in `htmlLimitedBots` pattern, copied verbatim from\n * `next/dist/shared/lib/router/utils/html-bots.js`.\n *\n * ---------------------------------------------------------------------------\n * WHY THIS IS VENDORED AND WHY IT MATTERS MORE THAN IT LOOKS\n * ---------------------------------------------------------------------------\n * Setting `htmlLimitedBots` in `next.config.ts` **overrides** this list. It does\n * not extend it. From the Next.js documentation:\n *\n *   \"Specifying a `htmlLimitedBots` config will override the Next.js' default\n *    list.\"\n *\n * So a config that lists only the AI crawlers silently drops Googlebot, Bingbot,\n * Applebot, Twitterbot, LinkedInBot, Slackbot, Discordbot, facebookexternalhit,\n * and WhatsApp from HTML-limited treatment. Those bots then receive `<title>`\n * and `<meta>` inside `<body>` on any page with streamed metadata. That trades a\n * GEO win for an SEO and social-preview loss, invisibly.\n *\n * Every write path in AgentBlog therefore unions rather than replaces, and\n * `agentblog doctor` asserts a superset rather than merely asserting that GPTBot\n * appears.\n *\n * This list is a moving target. `scripts/assert-html-bots-current.mjs` diffs it\n * against the installed Next.js on every CI run, so the day Next adds a bot we\n * find out from a red build rather than from a customer.\n *\n * @see https://nextjs.org/docs/app/api-reference/config/next-config-js/htmlLimitedBots\n */\nexport const NEXT_DEFAULT_HTML_LIMITED_BOTS =\n  '[\\\\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'\n\n/** The Next.js version the list above was vendored from. */\nexport const NEXT_DEFAULT_HTML_LIMITED_BOTS_VERSION = '16.3.0'\n\n/**\n * A crawler AgentBlog adds to the HTML-limited set.\n *\n * `purpose` records what the operator says the bot does, because the answer\n * changes the advice we give. A `search` bot fetching your page is how you get\n * cited; a `train` bot is how you enter a model's weights. Those are different\n * decisions, and a site owner is entitled to make them separately.\n */\nexport interface AiCrawler {\n  /** The user-agent token, matched case-insensitively as a substring. */\n  readonly ua: string\n  readonly operator: string\n  readonly purpose: 'search' | 'train' | 'agent' | 'ads'\n  /** Operator documentation, so a reader can verify rather than trust us. */\n  readonly docs: string\n}\n\n/**\n * AI crawlers that fetch and read HTML.\n *\n * Deliberately excluded: `Google-Extended` and `Applebot-Extended`, which are\n * training opt-out tokens rather than crawlers. They never issue a request, so\n * adding them here would be noise. The Google and Apple families are already\n * covered by the vendored default list above.\n */\nexport const AI_CRAWLERS: readonly AiCrawler[] = [\n  // OpenAI. Note that OAI-AdsBot does not respect robots.txt.\n  {\n    ua: 'GPTBot',\n    operator: 'OpenAI',\n    purpose: 'train',\n    docs: 'https://developers.openai.com/api/docs/bots',\n  },\n  {\n    ua: 'OAI-SearchBot',\n    operator: 'OpenAI',\n    purpose: 'search',\n    docs: 'https://developers.openai.com/api/docs/bots',\n  },\n  {\n    ua: 'ChatGPT-User',\n    operator: 'OpenAI',\n    purpose: 'agent',\n    docs: 'https://developers.openai.com/api/docs/bots',\n  },\n  {\n    ua: 'OAI-AdsBot',\n    operator: 'OpenAI',\n    purpose: 'ads',\n    docs: 'https://developers.openai.com/api/docs/bots',\n  },\n\n  // Anthropic. IP ranges are published at https://claude.com/crawling/bots.json\n  {\n    ua: 'ClaudeBot',\n    operator: 'Anthropic',\n    purpose: 'train',\n    docs: 'https://claude.com/crawling/bots.json',\n  },\n  {\n    ua: 'Claude-SearchBot',\n    operator: 'Anthropic',\n    purpose: 'search',\n    docs: 'https://claude.com/crawling/bots.json',\n  },\n  {\n    ua: 'Claude-User',\n    operator: 'Anthropic',\n    purpose: 'agent',\n    docs: 'https://claude.com/crawling/bots.json',\n  },\n  {\n    ua: 'anthropic-ai',\n    operator: 'Anthropic',\n    purpose: 'train',\n    docs: 'https://claude.com/crawling/bots.json',\n  },\n\n  // Perplexity\n  {\n    ua: 'PerplexityBot',\n    operator: 'Perplexity',\n    purpose: 'search',\n    docs: 'https://docs.perplexity.ai/docs/resources/perplexity-crawlers',\n  },\n  {\n    ua: 'Perplexity-User',\n    operator: 'Perplexity',\n    purpose: 'agent',\n    docs: 'https://docs.perplexity.ai/docs/resources/perplexity-crawlers',\n  },\n\n  // Meta\n  {\n    ua: 'Meta-ExternalAgent',\n    operator: 'Meta',\n    purpose: 'train',\n    docs: 'https://developers.facebook.com/docs/sharing/webmasters/web-crawlers',\n  },\n  {\n    ua: 'Meta-ExternalFetcher',\n    operator: 'Meta',\n    purpose: 'agent',\n    docs: 'https://developers.facebook.com/docs/sharing/webmasters/web-crawlers',\n  },\n\n  // Others with published crawler documentation\n  {\n    ua: 'CCBot',\n    operator: 'Common Crawl',\n    purpose: 'train',\n    docs: 'https://commoncrawl.org/ccbot',\n  },\n  { ua: 'Bytespider', operator: 'ByteDance', purpose: 'train', docs: 'https://www.bytedance.com' },\n  {\n    ua: 'Amazonbot',\n    operator: 'Amazon',\n    purpose: 'search',\n    docs: 'https://developer.amazon.com/amazonbot',\n  },\n  {\n    ua: 'MistralAI-User',\n    operator: 'Mistral',\n    purpose: 'agent',\n    docs: 'https://docs.mistral.ai/robots',\n  },\n  { ua: 'cohere-ai', operator: 'Cohere', purpose: 'train', docs: 'https://cohere.com' },\n  {\n    ua: 'DuckAssistBot',\n    operator: 'DuckDuckGo',\n    purpose: 'search',\n    docs: 'https://duckduckgo.com/duckduckgo-help-pages/results/duckassistbot/',\n  },\n  {\n    ua: 'Diffbot',\n    operator: 'Diffbot',\n    purpose: 'train',\n    docs: 'https://docs.diffbot.com/docs/en/guides-diffbot-crawler',\n  },\n  { ua: 'YouBot', operator: 'You.com', purpose: 'search', docs: 'https://about.you.com/youbot/' },\n  {\n    ua: 'Applebot-Extended',\n    operator: 'Apple',\n    purpose: 'train',\n    docs: 'https://support.apple.com/en-us/119829',\n  },\n] as const\n\n/** Just the user-agent tokens, in declaration order. */\nexport const AI_CRAWLER_UAS: readonly string[] = AI_CRAWLERS.map((c) => c.ua)\n\n/* ========================================================================== */\n/*  Pattern union                                                             */\n/* ========================================================================== */\n\n/**\n * Split a regex alternation into trimmed, non-empty **top level** branches.\n *\n * \"Top level\" is the whole job. A naive `pattern.split('|')` is correct only\n * while every `|` in the pattern separates two branches, and it silently\n * shreds anything else: `(GPTBot|Foo)|(GPTBot|Bar)` becomes four fragments,\n * two of which carry an unbalanced parenthesis, and `[a|b]-Bot` becomes `[a`\n * and `b]-Bot`. Rejoining those fragments produces either a regex that throws\n * at `next.config` evaluation time (so the application stops starting) or,\n * worse, one that compiles and no longer matches what the user wrote.\n *\n * So the scanner below tracks three things and nothing else: backslash\n * escapes, character classes, and group nesting. That is the entire grammar\n * needed to find the top level pipes, and it is small enough to audit without\n * shipping a regex parser into a consumer's repository.\n */\nfunction splitAlternation(pattern: string): string[] {\n  const branches: string[] = []\n  let current = ''\n  let depth = 0\n  let inClass = false\n\n  for (let i = 0; i < pattern.length; i += 1) {\n    const ch = pattern[i]!\n    if (ch === '\\\\') {\n      current += ch + (pattern[i + 1] ?? '')\n      i += 1\n      continue\n    }\n    if (inClass) {\n      if (ch === ']') inClass = false\n      current += ch\n      continue\n    }\n    if (ch === '[') {\n      inClass = true\n      current += ch\n      continue\n    }\n    if (ch === '(') {\n      depth += 1\n      current += ch\n      continue\n    }\n    if (ch === ')') {\n      if (depth > 0) depth -= 1\n      current += ch\n      continue\n    }\n    if (ch === '|' && depth === 0) {\n      branches.push(current)\n      current = ''\n      continue\n    }\n    current += ch\n  }\n  branches.push(current)\n\n  return branches.map((s) => s.trim()).filter((s) => s.length > 0)\n}\n\n/**\n * `true` when every `|` in the pattern separates two top level branches.\n *\n * This is the exact condition under which branch-wise union is safe, and it is\n * deliberately narrower than \"contains a group or a character class\". Our own\n * written value contains `[\\w-]+-Google`, so a test that rejected every `[`\n * would classify the value this module itself emits as unreadable and wrap it\n * on the second run, which would end idempotency. What actually matters is\n * whether a `|` hides inside a group, a class, or an escape.\n */\nfunction isFlatAlternation(pattern: string): boolean {\n  let depth = 0\n  let inClass = false\n\n  for (let i = 0; i < pattern.length; i += 1) {\n    const ch = pattern[i]!\n    if (ch === '\\\\') {\n      if (pattern[i + 1] === '|') return false\n      i += 1\n      continue\n    }\n    if (inClass) {\n      if (ch === '|') return false\n      if (ch === ']') inClass = false\n      continue\n    }\n    if (ch === '[') {\n      inClass = true\n      continue\n    }\n    if (ch === '(') {\n      depth += 1\n      continue\n    }\n    if (ch === ')') {\n      if (depth > 0) depth -= 1\n      continue\n    }\n    if (ch === '|' && depth > 0) return false\n  }\n  return true\n}\n\n/**\n * Split `(?:core)|rest` back into its two halves, or `null` when the pattern is\n * not in that shape.\n *\n * This is what makes the opaque path below a fixed point. Wrapping produces\n * `(?:whatever the user wrote)|<our union>`, and a second run has to recognise\n * its own output rather than wrapping it again, which would nest a group per\n * run forever.\n */\nfunction splitWrapped(pattern: string): { core: string; rest: string } | null {\n  if (!pattern.startsWith('(?:')) return null\n\n  let depth = 0\n  let inClass = false\n  for (let i = 0; i < pattern.length; i += 1) {\n    const ch = pattern[i]!\n    if (ch === '\\\\') {\n      i += 1\n      continue\n    }\n    if (inClass) {\n      if (ch === ']') inClass = false\n      continue\n    }\n    if (ch === '[') {\n      inClass = true\n      continue\n    }\n    if (ch === '(') {\n      depth += 1\n      continue\n    }\n    if (ch !== ')') continue\n    depth -= 1\n    if (depth > 0) continue\n    // `i` is the parenthesis closing the leading group.\n    if (pattern[i + 1] !== '|') return null\n    return { core: pattern.slice(3, i), rest: pattern.slice(i + 2) }\n  }\n  return null\n}\n\n/**\n * `true` when the source is a legal regular expression on its own.\n *\n * The gate for the third path in `buildHtmlLimitedBotsPattern`. Nothing here\n * imports anything, so `RegExp` doing the parsing is both the simplest answer\n * and the only one available.\n */\nfunction compilesAsPattern(source: string): boolean {\n  try {\n    new RegExp(source, 'i')\n    return true\n  } catch {\n    return false\n  }\n}\n\n/** The Next.js defaults union the AI crawlers, deduplicated case-insensitively. */\nfunction unionBranches(...groups: readonly (readonly string[])[]): string[] {\n  const seen = new Set<string>()\n  const out: string[] = []\n  for (const group of groups) {\n    for (const branch of group) {\n      const key = branch.toLowerCase()\n      if (seen.has(key)) continue\n      seen.add(key)\n      out.push(branch)\n    }\n  }\n  return out\n}\n\n/**\n * Build the `htmlLimitedBots` pattern AgentBlog wants: the Next.js default list,\n * union whatever the project already had, union the AI crawlers.\n *\n * ---------------------------------------------------------------------------\n * TWO PATHS, AND WHICH SHAPE TAKES WHICH\n * ---------------------------------------------------------------------------\n * **Flat path.** The existing pattern is a plain alternation: every `|` in it\n * separates two branches, so the branches can be read out, merged with ours,\n * and written back in one list. This is the ordinary case, including every\n * value this function has ever written, and it is the path worth keeping\n * because the result is one readable line where a human can see their own bot\n * sitting next to Googlebot.\n *\n * **Opaque path.** The existing pattern hides a `|` inside a group, a\n * character class, or an escape (`(GPTBot|Foo)|Bar`, `[a|b]-Bot`, `Acme\\|Bot`).\n * Branch-wise merging is not defined for those, and attempting it produced\n * exactly the failures this path exists to prevent: an unbalanced `)` that\n * throws when Next.js evaluates the config, a `Lone quantifier brackets` error\n * under the `u` flag, and a valid-looking regex whose second branch had\n * quietly become `b]-Cat`. So the whole pattern is wrapped as\n * `(?:<theirs>)|<ours>` and never taken apart. Their expression keeps its exact\n * meaning, ours is appended, and nothing is parsed that we cannot parse.\n *\n * Both paths are fixed points. Re-running the flat path finds every branch\n * already present. Re-running the opaque path recognises its own\n * `(?:core)|rest` shape via `splitWrapped` and rebuilds the same string rather\n * than nesting another group.\n *\n * **Third path: an input that does not compile is discarded.** Both paths above\n * assume the input is a regular expression, and neither is a fixed point when\n * it is not. Feeding in `(AcmeBot`, which has an unbalanced parenthesis, grew\n * the result from 581 to 1158 to 1735 bytes over three runs and never produced\n * anything that compiles, because `splitWrapped` cannot recognise a wrap whose\n * group never closes, so every run wrapped the previous run's output again. The\n * CLI's own write path is protected by a `compiles()` gate, but this function\n * ships verbatim into every consumer project as `lib/preflight-checks.ts`,\n * where the caller has no such gate. So a value we cannot even parse as a regex\n * is treated as opaque and not unioned at all: the result is the union we can\n * vouch for, it compiles, and it is stable on the second run.\n *\n * @param existing The project's current pattern source, if it already has one.\n */\nexport function buildHtmlLimitedBotsPattern(existing?: string): string {\n  const defaults = splitAlternation(NEXT_DEFAULT_HTML_LIMITED_BOTS)\n\n  // Next's defaults first, so a diff against upstream stays readable.\n  if (!existing) return unionBranches(defaults, AI_CRAWLER_UAS).join('|')\n\n  if (!compilesAsPattern(existing)) return unionBranches(defaults, AI_CRAWLER_UAS).join('|')\n\n  if (isFlatAlternation(existing)) {\n    return unionBranches(defaults, splitAlternation(existing), AI_CRAWLER_UAS).join('|')\n  }\n\n  const wrapped = splitWrapped(existing)\n  const core = wrapped ? wrapped.core : existing\n  // Branches the user appended after a previous wrap are kept, as long as that\n  // tail is itself flat. When it is not, the whole thing goes back in the group.\n  const tail = wrapped && isFlatAlternation(wrapped.rest) ? splitAlternation(wrapped.rest) : []\n  if (wrapped && tail.length === 0 && wrapped.rest.trim().length > 0) {\n    return `(?:${existing})|${unionBranches(defaults, AI_CRAWLER_UAS).join('|')}`\n  }\n\n  return `(?:${core})|${unionBranches(defaults, tail, AI_CRAWLER_UAS).join('|')}`\n}\n\n/**\n * Which required branches a pattern is missing.\n *\n * Matching is case-insensitive and exact per branch. A pattern that happens to\n * match `GPTBot` through some broader expression still counts as missing the\n * branch, because we cannot prove equivalence of two regexes and a false pass\n * here is the failure this whole module exists to prevent.\n */\nexport function missingBranches(pattern: string, required: readonly string[]): string[] {\n  const present = new Set(splitAlternation(pattern).map((b) => b.toLowerCase()))\n  return required.filter((r) => !present.has(r.toLowerCase()))\n}\n\nexport interface HtmlLimitedBotsReport {\n  /** `true` when `next.config.*` sets the key at all. */\n  readonly configured: boolean\n  /** Default-list branches the configured pattern dropped. Empty is correct. */\n  readonly missingDefaults: readonly string[]\n  /** AI crawler branches not covered. Empty is correct. */\n  readonly missingAiCrawlers: readonly string[]\n  /** The pattern found in the config, if any. */\n  readonly pattern?: string\n}\n\n/**\n * Locate and evaluate `htmlLimitedBots` in the text of a `next.config.*` file.\n *\n * Text matching rather than an AST walk is deliberate. This same function runs\n * inside `lib/preflight.ts` in a consumer's repository, where there is no\n * TypeScript compiler to borrow and no dependency we are allowed to add. The\n * CLI does use an AST when it *writes*, because writing needs precision that\n * reading does not.\n *\n * Note that Next.js 16 requires a `RegExp` here, not a string: the config schema\n * is `z.instanceof(RegExp)` and a string fails config validation outright.\n */\nexport function analyzeHtmlLimitedBots(configSource: string): HtmlLimitedBotsReport {\n  // Strings are blanked as well as comments. `env: { NOTE: 'set\n  // htmlLimitedBots: /GPTBot/i' }` sitting above a perfectly correct real value\n  // used to win, because this finder takes the first match in the file. The\n  // result was a permanent ERROR that `--fix` could not clear, since the patcher\n  // writes through the AST while the reader kept reading the string.\n  const stripped = blankCommentsAndStrings(configSource)\n  const match = /htmlLimitedBots\\s*:\\s*\\/((?:[^/\\\\\\n]|\\\\.)+)\\/([gimsuy]*)/.exec(stripped)\n\n  if (!match?.[1]) {\n    return { configured: false, missingDefaults: [], missingAiCrawlers: [] }\n  }\n\n  const pattern = match[1]\n  return {\n    configured: true,\n    pattern,\n    missingDefaults: missingBranches(pattern, splitAlternation(NEXT_DEFAULT_HTML_LIMITED_BOTS)),\n    missingAiCrawlers: missingBranches(pattern, AI_CRAWLER_UAS),\n  }\n}\n\n/* ========================================================================== */\n/*  next.config checks                                                        */\n/* ========================================================================== */\n\nexport interface NextConfigInput {\n  /** Full text of `next.config.ts` / `.mjs` / `.js`. */\n  readonly source: string\n  /** Path, used only in messages. */\n  readonly path: string\n}\n\n/**\n * Every `next.config.*` finding AgentBlog knows how to report.\n *\n * `lib/preflight.ts` prints the errors and warnings at build time.\n * `agentblog doctor` prints all of them and can fix the fixable ones.\n */\nexport function checkNextConfig(input: NextConfigInput): Finding[] {\n  const findings: Finding[] = []\n  const source = blankCommentsAndStrings(input.source)\n  const bots = analyzeHtmlLimitedBots(input.source)\n\n  if (!bots.configured) {\n    findings.push({\n      id: 'html-limited-bots-missing',\n      severity: 'error',\n      message:\n        `${input.path} does not set \\`htmlLimitedBots\\`. GPTBot, ClaudeBot, and PerplexityBot ` +\n        'will receive `<title>` and `<meta>` inside `<body>` on any page with streamed metadata.',\n      remedy: 'npx agentblog@latest doctor --fix',\n      fixable: true,\n    })\n  } else {\n    // Reported separately and at a higher severity than the AI half, because a\n    // narrowed pattern is an active regression rather than a missed chance.\n    if (bots.missingDefaults.length > 0) {\n      findings.push({\n        id: 'html-limited-bots-narrowed',\n        severity: 'error',\n        message:\n          `${input.path} sets \\`htmlLimitedBots\\` to a pattern that drops ` +\n          `${bots.missingDefaults.length} bot(s) from the Next.js default list, including ` +\n          `${bots.missingDefaults.slice(0, 4).join(', ')}. This config overrides the default ` +\n          'list rather than extending it, so those bots have lost HTML-limited treatment.',\n        remedy:\n          'npx agentblog@latest doctor --fix, which unions your pattern with the Next.js ' +\n          'default list instead of replacing it.',\n        fixable: true,\n      })\n    }\n    if (bots.missingAiCrawlers.length > 0) {\n      findings.push({\n        id: 'html-limited-bots-incomplete',\n        severity: 'warning',\n        message:\n          `${input.path} sets \\`htmlLimitedBots\\` but omits ` +\n          `${bots.missingAiCrawlers.join(', ')}.`,\n        remedy: 'npx agentblog@latest doctor --fix',\n        fixable: true,\n      })\n    }\n  }\n\n  if (!/qualities\\s*:/.test(source)) {\n    findings.push({\n      id: 'image-qualities-missing',\n      severity: 'warning',\n      message:\n        `${input.path} does not set \\`images.qualities\\`. Next.js 16 defaults it to [75] and the ` +\n        'image optimizer returns 400 for any other quality, so a hero image at quality 90 fails ' +\n        'in production while working in development.',\n      remedy: 'npx agentblog@latest doctor --fix',\n      fixable: true,\n    })\n  }\n\n  if (/cacheComponents\\s*:\\s*true/.test(source)) {\n    findings.push({\n      id: 'cache-components-enabled',\n      severity: 'warning',\n      message:\n        `${input.path} enables \\`cacheComponents\\`. The default AgentBlog routes use classic ` +\n        'prerendering with `export const revalidate`, which is not how Cache Components wants to ' +\n        'be driven.',\n      remedy:\n        'Set `cacheComponents: false`, or convert the blog routes yourself to `use cache` with ' +\n        '`cacheLife` and `cacheTag`. There is no AgentBlog route variant for Cache Components yet.',\n      fixable: false,\n    })\n  }\n\n  return findings\n}\n\n/* ========================================================================== */\n/*  Root layout checks                                                        */\n/* ========================================================================== */\n\n/**\n * `metadataBase` and `title.template` both belong in the root layout.\n *\n * `title.template` applies to *child* segments only, so a template declared in\n * `app/blog/layout.tsx` does not apply to `app/blog/page.tsx`. Putting it\n * anywhere but the root is a silent no-op for the blog index.\n */\nexport function checkRootLayout(input: { source: string; path: string }): Finding[] {\n  const findings: Finding[] = []\n  const source = blankCommentsAndStrings(input.source)\n\n  if (!/metadataBase\\s*:/.test(source)) {\n    findings.push({\n      id: 'metadata-base-missing',\n      severity: 'error',\n      message:\n        `${input.path} does not set \\`metadataBase\\`. Next.js needs it to turn relative metadata ` +\n        'URLs into absolute ones, and the build errors without it once any metadata field is relative.',\n      remedy: 'npx agentblog@latest doctor --fix',\n      fixable: true,\n    })\n  }\n\n  if (!/template\\s*:/.test(source)) {\n    findings.push({\n      id: 'title-template-missing',\n      severity: 'warning',\n      message: `${input.path} does not set \\`title.template\\`, so post titles carry no site suffix.`,\n      remedy: 'npx agentblog@latest doctor --fix',\n      fixable: true,\n    })\n  }\n\n  return findings\n}\n\n/* ========================================================================== */\n/*  Shared helpers                                                            */\n/* ========================================================================== */\n\n/**\n * Remove line and block comments so a commented-out example does not read as a\n * live setting.\n *\n * String **contents survive**, and two callers depend on that: `readJsonc` in\n * the CLI parses the result as JSON, and doctor check 26 looks for the\n * placeholder `siteUrl`, which is a string literal. Use\n * `blankCommentsAndStrings` for anything that matches on code shape rather than\n * on string values.\n */\nexport function stripComments(source: string): string {\n  let out = ''\n  let i = 0\n  let inString: string | null = null\n\n  while (i < source.length) {\n    const ch = source[i]!\n    const next = source[i + 1]\n\n    if (inString) {\n      if (ch === '\\\\') {\n        out += ch + (next ?? '')\n        i += 2\n        continue\n      }\n      if (ch === inString) inString = null\n      out += ch\n      i += 1\n      continue\n    }\n\n    if (ch === '\"' || ch === \"'\" || ch === '`') {\n      inString = ch\n      out += ch\n      i += 1\n      continue\n    }\n\n    if (ch === '/' && next === '/') {\n      while (i < source.length && source[i] !== '\\n') i += 1\n      continue\n    }\n\n    if (ch === '/' && next === '*') {\n      i += 2\n      while (i < source.length && !(source[i] === '*' && source[i + 1] === '/')) i += 1\n      i += 2\n      continue\n    }\n\n    out += ch\n    i += 1\n  }\n\n  return out\n}\n\n/**\n * Blank comments **and string contents**, replacing every blanked character\n * with a space and keeping newlines, so byte offsets and line numbers are\n * identical to the input.\n *\n * ---------------------------------------------------------------------------\n * WHY BLANKING STRINGS IS NOT OPTIONAL HERE\n * ---------------------------------------------------------------------------\n * The matchers in this file look for a setting by name and take the first hit.\n * A string that merely mentions the setting therefore beat the real value:\n *\n *   env: { NOTE: 'remember to set htmlLimitedBots: /GPTBot/i' },\n *   htmlLimitedBots: <the correct union>,\n *\n * read as a pattern of `GPTBot`, which reports 27 dropped default bots at\n * `error` severity forever. `doctor` exits 1, `lib/preflight.ts` warns on every\n * build, and `--fix` cannot clear it, because the patcher writes through the\n * AST while the reader keeps reading the string.\n *\n * The quotes themselves are kept so the result still parses as the same shape,\n * and offsets are preserved so a caller can map a match back into the original\n * text. Regex literals are left alone: reading one is the whole point.\n */\nexport function blankCommentsAndStrings(source: string): string {\n  const out: string[] = []\n  let i = 0\n  let inString: string | null = null\n\n  /** Emit spaces for `text`, keeping any newline it contains. */\n  const blank = (text: string) => {\n    out.push(text.replace(/[^\\n]/g, ' '))\n  }\n\n  while (i < source.length) {\n    const ch = source[i]!\n    const next = source[i + 1]\n\n    if (inString) {\n      if (ch === '\\\\') {\n        blank(ch + (next ?? ''))\n        i += 2\n        continue\n      }\n      if (ch === inString) {\n        inString = null\n        out.push(ch)\n        i += 1\n        continue\n      }\n      blank(ch)\n      i += 1\n      continue\n    }\n\n    if (ch === '\"' || ch === \"'\" || ch === '`') {\n      inString = ch\n      out.push(ch)\n      i += 1\n      continue\n    }\n\n    if (ch === '/' && next === '/') {\n      const start = i\n      while (i < source.length && source[i] !== '\\n') i += 1\n      blank(source.slice(start, i))\n      continue\n    }\n\n    if (ch === '/' && next === '*') {\n      const start = i\n      i += 2\n      while (i < source.length && !(source[i] === '*' && source[i + 1] === '/')) i += 1\n      i = Math.min(i + 2, source.length)\n      blank(source.slice(start, i))\n      continue\n    }\n\n    out.push(ch)\n    i += 1\n  }\n\n  return out.join('')\n}\n\n/** Sort findings so the most severe are reported first. */\nexport function bySeverity(a: Finding, b: Finding): number {\n  const rank = { error: 0, warning: 1, info: 2 } as const\n  return rank[a.severity] - rank[b.severity]\n}\n\n/** `true` when any finding would fail a build gate. */\nexport function hasErrors(findings: readonly Finding[]): boolean {\n  return findings.some((f) => f.severity === 'error')\n}\n",
      "type": "registry:lib",
      "target": "lib/preflight-checks.ts"
    },
    {
      "path": "registry/blog/lib/ai-referrers.ts",
      "content": "/**\n * Classify a referrer as AI assistant traffic. Pure function, zero dependencies.\n *\n * ===========================================================================\n * WE SHIP THE CLASSIFIER, NOT AN ANALYTICS INTEGRATION\n * ===========================================================================\n * This file does not talk to GA4, Vercel Analytics, PostHog, or anything else,\n * and it never will. It takes a referrer string and tells you which AI assistant\n * it came from. Where that answer goes is your decision, and it stays your\n * decision: the block adds no analytics dependency, sends no data anywhere, and\n * sets no cookie.\n *\n * That is the honest scope. Wiring it up is three lines against whatever you\n * already run:\n *\n *     // Google Analytics 4\n *     const ai = classifyReferrer(document.referrer)\n *     if (ai) gtag('event', 'ai_referral', { ai_source: ai.source, ai_host: ai.host })\n *\n *     // Vercel Analytics\n *     const ai = classifyReferrer(document.referrer)\n *     if (ai) track('ai_referral', { source: ai.source, host: ai.host })\n *\n *     // PostHog\n *     const ai = classifyReferrer(document.referrer)\n *     if (ai) posthog.capture('ai_referral', { source: ai.source, host: ai.host })\n *\n * On the server the same call works against the `Referer` header.\n *\n * ===========================================================================\n * WHY THIS IS WORTH MEASURING SEPARATELY\n * ===========================================================================\n * Assistant referrals do not group with search traffic in any default report.\n * `chatgpt.com` shows up as an ordinary referral alongside a forum link, so the\n * one number that tells you whether writing for AI retrieval is working is\n * scattered across a dozen rows that nobody totals. Classifying at the source\n * gives you a single dimension you can chart.\n *\n * ===========================================================================\n * SAFE IN A CLIENT COMPONENT\n * ===========================================================================\n * No `server-only`, no Node built ins, no config import, no I/O. This module is\n * intentionally importable from a `'use client'` component so it can read\n * `document.referrer` on the first paint of a post.\n */\n\nexport type AiReferrerSource =\n  'chatgpt' | 'perplexity' | 'gemini' | 'copilot' | 'claude' | 'grok' | 'you' | 'other-ai'\n\nexport interface AiReferrer {\n  readonly source: AiReferrerSource\n  /** The normalised hostname, lowercased, so you can chart the long tail too. */\n  readonly host: string\n}\n\n/**\n * Exact hostnames, including the ones that only differ by a `www.` or a legacy\n * name kept alive by old links (`chat.openai.com`, `bard.google.com`).\n */\nconst EXACT_HOSTS: Readonly<Record<string, AiReferrerSource>> = {\n  'chatgpt.com': 'chatgpt',\n  'www.chatgpt.com': 'chatgpt',\n  'chat.openai.com': 'chatgpt',\n  'perplexity.ai': 'perplexity',\n  'www.perplexity.ai': 'perplexity',\n  'gemini.google.com': 'gemini',\n  'bard.google.com': 'gemini',\n  'copilot.microsoft.com': 'copilot',\n  'claude.ai': 'claude',\n  'www.claude.ai': 'claude',\n  'grok.com': 'grok',\n  'www.grok.com': 'grok',\n  'x.ai': 'grok',\n  'www.x.ai': 'grok',\n  'you.com': 'you',\n  'www.you.com': 'you',\n}\n\n/**\n * Apex domains whose subdomains belong to the same assistant.\n *\n * These products add and rename subdomains regularly. Matching the apex means a\n * new one keeps reporting as itself instead of silently dropping out of the\n * numbers, which is the failure mode that makes a metric quietly wrong rather\n * than obviously broken.\n */\nconst APEX_SUFFIXES: readonly (readonly [string, AiReferrerSource])[] = [\n  ['.chatgpt.com', 'chatgpt'],\n  ['.perplexity.ai', 'perplexity'],\n  ['.claude.ai', 'claude'],\n  ['.grok.com', 'grok'],\n  ['.x.ai', 'grok'],\n  ['.you.com', 'you'],\n]\n\n/**\n * Hosts that are AI assistants but have no dedicated member in the union.\n *\n * `other-ai` exists so that assistant traffic is never counted as an ordinary\n * referral just because the product is newer than this file. Add to this list\n * freely; it is the low risk edit.\n */\nconst OTHER_AI_HOSTS: readonly string[] = [\n  'poe.com',\n  'phind.com',\n  'meta.ai',\n  'chat.mistral.ai',\n  'chat.deepseek.com',\n]\n\n/**\n * Assistants that live on a path of a general purpose host. Bing's chat surface\n * is the one that matters: `bing.com` alone is a search referral, and counting\n * it as AI would overstate the number badly.\n */\nconst PATH_SCOPED: readonly (readonly [string, string, AiReferrerSource])[] = [\n  ['bing.com', '/chat', 'copilot'],\n  ['www.bing.com', '/chat', 'copilot'],\n  ['cn.bing.com', '/chat', 'copilot'],\n]\n\n/**\n * Parse a referrer that may be a full URL or a bare hostname.\n *\n * `document.referrer` is a full URL; a `Referer` header usually is too, but log\n * pipelines and tag managers hand over bare hosts often enough to be worth\n * handling here rather than at every call site.\n */\nfunction parse(referrer: string): { host: string; path: string } | null {\n  const trimmed = referrer.trim()\n  if (trimmed === '') return null\n\n  const candidate = /^[a-z][a-z0-9+.-]*:\\/\\//i.test(trimmed) ? trimmed : `https://${trimmed}`\n\n  try {\n    const url = new URL(candidate)\n    if (url.hostname === '') return null\n    return { host: url.hostname.toLowerCase(), path: url.pathname.toLowerCase() }\n  } catch {\n    return null\n  }\n}\n\n/**\n * The AI assistant a visit came from, or `null` for everything else.\n *\n * `null` covers empty referrers, direct traffic, unparseable strings, and every\n * ordinary referral, so a caller can branch on truthiness and be done.\n */\nexport function classifyReferrer(referrer: string | null | undefined): AiReferrer | null {\n  if (referrer === null || referrer === undefined) return null\n\n  const parsed = parse(referrer)\n  if (parsed === null) return null\n  const { host, path } = parsed\n\n  const exact = EXACT_HOSTS[host]\n  if (exact !== undefined) return { source: exact, host }\n\n  for (const [scopedHost, prefix, source] of PATH_SCOPED) {\n    if (host === scopedHost && path.startsWith(prefix)) return { source, host }\n  }\n\n  for (const [suffix, source] of APEX_SUFFIXES) {\n    if (host.endsWith(suffix)) return { source, host }\n  }\n\n  if (OTHER_AI_HOSTS.includes(host)) return { source: 'other-ai', host }\n\n  return null\n}\n\n/** Whether a referrer is any AI assistant. Thin wrapper for filters and guards. */\nexport function isAiReferrer(referrer: string | null | undefined): boolean {\n  return classifyReferrer(referrer) !== null\n}\n",
      "type": "registry:lib",
      "target": "lib/ai-referrers.ts"
    },
    {
      "path": "registry/blog/lib/reading-time.ts",
      "content": "/**\n * Reading time, computed from markdown source with no dependencies.\n *\n * ---------------------------------------------------------------------------\n * WHY NOT AN NPM PACKAGE\n * ---------------------------------------------------------------------------\n * Every reading time package counts words in whatever string you hand it. Hand\n * it raw MDX and it counts `import`, `className`, `rehype-pretty-code`, and\n * every token inside a fenced code block. On a technical post with three code\n * samples that inflates the estimate by a third, and the number that appears\n * under the headline is the one readers use to decide whether to start reading.\n *\n * So the interesting work here is not the arithmetic, it is the stripping. This\n * file removes, in order: the frontmatter block, MDX `import`/`export` lines,\n * fenced and indented code, inline code spans, JSX and HTML tags, images, link\n * targets (keeping link text, which a reader does read), and the punctuation\n * that carries markdown structure rather than meaning.\n *\n * 220 words per minute is the middle of the range measured for adult silent\n * reading of non fiction prose. It is a deliberate round number, not a tuned\n * one; the estimate is a courtesy, not a measurement.\n *\n * The result is deterministic and pure, so it is safe at build time, at request\n * time, and inside a client component.\n */\n\nconst WORDS_PER_MINUTE = 220\n\n/** A token counts as a word only if it contains a letter or a digit. */\nconst IS_WORD = /[\\p{L}\\p{N}]/u\n\n/**\n * Strip everything a reader does not read.\n *\n * Order matters. Code fences go before inline code, or a stray backtick inside a\n * fence eats the rest of the document. Images go before links, because an image\n * is a link with a bang in front of it.\n */\nfunction stripMarkdown(markdown: string): string {\n  return (\n    markdown\n      // Frontmatter, written as a fence of three or more hyphens at the very top.\n      .replace(/^\\uFEFF?[ \\t]*-{3,}[ \\t]*\\r?\\n[\\s\\S]*?\\r?\\n-{3,}[ \\t]*(?:\\r?\\n|$)/, '')\n      // Fenced code blocks, backtick or tilde, with or without a language.\n      .replace(/^[ \\t]*(`{3,}|~{3,})[^\\n]*\\n[\\s\\S]*?^[ \\t]*\\1[ \\t]*$/gm, '')\n      // An unterminated fence at the end of the file.\n      .replace(/^[ \\t]*(`{3,}|~{3,})[\\s\\S]*$/m, '')\n      // MDX module syntax. These lines are code that happens to sit in prose.\n      .replace(/^[ \\t]*(?:import|export)\\s[^\\n]*$/gm, '')\n      // HTML comments and MDX expression braces.\n      .replace(/<!--[\\s\\S]*?-->/g, '')\n      // Inline code spans.\n      .replace(/`+[^`\\n]*`+/g, ' ')\n      // Images, including the alt text: it is not body copy.\n      .replace(/!\\[[^\\]]*\\]\\([^)]*\\)/g, ' ')\n      // Links and reference links: keep the visible text, drop the target.\n      .replace(/\\[([^\\]]*)\\]\\([^)]*\\)/g, '$1')\n      .replace(/\\[([^\\]]*)\\]\\[[^\\]]*\\]/g, '$1')\n      // Link reference definitions on their own line.\n      .replace(/^[ \\t]*\\[[^\\]]+\\]:[^\\n]*$/gm, '')\n      // JSX and HTML tags. The children of a JSX component stay, which is right:\n      // a <Callout> body is prose the reader reads.\n      .replace(/<\\/?[A-Za-z][^>]*>/g, ' ')\n      // Heading markers, blockquote markers, list bullets, and table pipes.\n      .replace(/^[ \\t]*#{1,6}[ \\t]+/gm, '')\n      .replace(/^[ \\t]*>[ \\t]?/gm, '')\n      .replace(/^[ \\t]*(?:[*+-]|\\d+[.)])[ \\t]+/gm, '')\n      .replace(/^[ \\t]*\\|?[ \\t]*:?-{3,}:?[ \\t]*(?:\\|[ \\t]*:?-{3,}:?[ \\t]*)*\\|?[ \\t]*$/gm, '')\n      .replace(/\\|/g, ' ')\n      // Thematic breaks.\n      .replace(/^[ \\t]*(?:\\*[ \\t]*){3,}$|^[ \\t]*(?:-[ \\t]*){3,}$|^[ \\t]*(?:_[ \\t]*){3,}$/gm, '')\n      // Emphasis, strikethrough, and the escape backslash.\n      .replace(/[*_~]+/g, '')\n      .replace(/\\\\([\\\\`*_{}[\\]()#+\\-.!])/g, '$1')\n  )\n}\n\n/**\n * Word count and estimated reading minutes for a post body.\n *\n * `minutes` is never zero: a two sentence post still takes a moment to read, and\n * \"0 min read\" reads like a bug.\n */\nexport function readingTime(markdown: string): { minutes: number; words: number } {\n  const words = stripMarkdown(markdown)\n    .split(/\\s+/)\n    .filter((token) => IS_WORD.test(token)).length\n\n  return { minutes: Math.max(1, Math.round(words / WORDS_PER_MINUTE)), words }\n}\n",
      "type": "registry:lib",
      "target": "lib/reading-time.ts"
    },
    {
      "path": "registry/blog/lib/toc.ts",
      "content": "/**\n * Table of contents extraction from markdown source, with no dependencies.\n *\n * ---------------------------------------------------------------------------\n * WHAT THIS IS FOR, AND WHAT CALLS IT TODAY\n * ---------------------------------------------------------------------------\n * Two exports, with different standing.\n *\n * `TocEntry` is the shared TOC type. `remarkExtractToc`, `renderMdx`, and\n * `<TableOfContents>` all speak it, which is why the interface lives in a\n * dependency-free module rather than next to the remark plugin.\n *\n * `extractToc` is a supported public helper that **nothing in the block calls**.\n * That is deliberate and is not an oversight to be fixed by wiring it in: the\n * rendered TOC comes from `remarkExtractToc` during the same compile that\n * produces the article (see `lib/render-mdx.tsx`), and computing it twice would\n * give two answers that can disagree. This function exists for callers that\n * must not compile MDX, which is every caller running outside a render: a\n * content linter, an editor preview, a migration script, a custom\n * `ContentSource` that wants an outline without a React tree.\n *\n * If you add a caller, say so here. If you find yourself calling it from a route\n * that has already rendered the post, use the compiler's TOC instead.\n *\n * ---------------------------------------------------------------------------\n * THE IDS MUST MATCH `rehype-slug`, EXACTLY\n * ---------------------------------------------------------------------------\n * The rendered headings get their `id` from `rehype-slug`, which uses the\n * `github-slugger` algorithm. If this file produced different ids the TOC would\n * render links to anchors that do not exist, and the failure would be a link\n * that silently does nothing rather than an error anyone sees.\n *\n * The algorithm, in full:\n *\n *   1. lowercase\n *   2. remove every character that is not a letter, a digit, an underscore,\n *      whitespace, or a hyphen\n *   3. replace runs of whitespace with a single hyphen\n *   4. if that id has already been used in this document, append `-1`, then\n *      `-2`, and so on until it is unique\n *\n * Step 4 is why this function walks the document rather than mapping over\n * headings independently: two sections both titled \"Overview\" must produce\n * `overview` and `overview-1`, in document order.\n *\n * ---------------------------------------------------------------------------\n * FENCED CODE IS SKIPPED\n * ---------------------------------------------------------------------------\n * A shell sample containing `# install dependencies` is a comment, not a\n * heading, and a TOC that lists it looks broken to a reader and to a crawler\n * parsing the outline. The line walk tracks fence state for backtick and tilde\n * fences, including the rule that a closing fence must be at least as long as\n * the opening one and use the same character.\n */\n\n/** H2 and H3 only. H4 and deeper make a TOC longer than the section it indexes. */\nconst HEADING = /^[ \\t]{0,3}(#{2,3})[ \\t]+(.+?)[ \\t]*#*[ \\t]*$/\nconst FENCE = /^[ \\t]{0,3}(`{3,}|~{3,})/\nconst FRONTMATTER_FENCE = /^-{3,}[ \\t]*$/\n\nexport interface TocEntry {\n  readonly id: string\n  readonly text: string\n  readonly depth: 2 | 3\n}\n\n/**\n * The `github-slugger` character filter. Unicode aware, so a heading written in\n * a non Latin script keeps its characters instead of collapsing to an empty id.\n */\nfunction slugifyOnce(text: string): string {\n  return text\n    .trim()\n    .toLowerCase()\n    .replace(/[^\\p{L}\\p{N}_\\s-]/gu, '')\n    .replace(/\\s+/g, '-')\n}\n\n/**\n * Reduce heading markdown to the text a reader sees.\n *\n * Kept deliberately small: it handles the inline syntax that actually shows up\n * in headings (code spans, emphasis, links, escapes) and nothing else.\n */\nfunction headingText(raw: string): string {\n  return raw\n    .replace(/!\\[[^\\]]*\\]\\([^)]*\\)/g, '')\n    .replace(/\\[([^\\]]*)\\]\\([^)]*\\)/g, '$1')\n    .replace(/\\[([^\\]]*)\\]\\[[^\\]]*\\]/g, '$1')\n    .replace(/`+([^`]*)`+/g, '$1')\n    .replace(/<\\/?[A-Za-z][^>]*>/g, '')\n    .replace(/[*_~]+/g, '')\n    .replace(/\\\\([\\\\`*_{}[\\]()#+\\-.!])/g, '$1')\n    .replace(/\\s+/g, ' ')\n    .trim()\n}\n\n/** `overview`, then `overview-1`, then `overview-2`, as github-slugger does. */\nfunction uniqueId(base: string, used: Map<string, number>): string {\n  const seen = used.get(base)\n  if (seen === undefined) {\n    used.set(base, 0)\n    return base\n  }\n\n  let next = seen + 1\n  let candidate = `${base}-${next}`\n  // A heading literally named \"Overview 1\" can already have taken `overview-1`.\n  while (used.has(candidate)) {\n    next += 1\n    candidate = `${base}-${next}`\n  }\n\n  used.set(base, next)\n  used.set(candidate, 0)\n  return candidate\n}\n\n/**\n * Every H2 and H3 in a markdown or MDX document, in order, with the ids\n * `rehype-slug` will put on the rendered headings.\n *\n * Returns `[]` rather than throwing for any input, including an empty string.\n * A post with no headings is a valid post; it just has no TOC.\n */\nexport function extractToc(markdown: string): TocEntry[] {\n  const lines = markdown.split(/\\r?\\n/)\n  const entries: TocEntry[] = []\n  // Counts per base slug, so the dedupe suffix matches github-slugger's.\n  const used = new Map<string, number>()\n\n  let fence: string | null = null\n  let index = 0\n\n  // Skip a frontmatter block, so a `#` line inside YAML is never a heading.\n  const first = lines[0]\n  if (first !== undefined && FRONTMATTER_FENCE.test(first)) {\n    index = 1\n    while (index < lines.length && !FRONTMATTER_FENCE.test(lines[index] ?? '')) index += 1\n    index += 1\n  }\n\n  for (; index < lines.length; index += 1) {\n    const line = lines[index]\n    if (line === undefined) continue\n\n    const fenceMatch = FENCE.exec(line)\n    const marker = fenceMatch?.[1]\n    if (marker !== undefined) {\n      if (fence === null) fence = marker\n      else if (marker[0] === fence[0] && marker.length >= fence.length) fence = null\n      continue\n    }\n    if (fence !== null) continue\n\n    const match = HEADING.exec(line)\n    const hashes = match?.[1]\n    const body = match?.[2]\n    if (hashes === undefined || body === undefined) continue\n\n    const text = headingText(body)\n    if (text === '') continue\n\n    entries.push({\n      id: uniqueId(slugifyOnce(text), used),\n      text,\n      depth: hashes.length === 2 ? 2 : 3,\n    })\n  }\n\n  return entries\n}\n",
      "type": "registry:lib",
      "target": "lib/toc.ts"
    },
    {
      "path": "registry/blog/styles/agentblog.css",
      "content": "/**\n * agentblog.css - where long-form typography meets the consumer's design system.\n *\n * ---------------------------------------------------------------------------\n * WHAT THIS FILE IS FOR\n * ---------------------------------------------------------------------------\n * `@tailwindcss/typography` ships its own grey palette. That palette is exactly\n * the thing the block is forbidden to introduce, because a blog that arrives\n * with its own colours makes the user's first job after installing \"undo the\n * design\". This file is the single bridge: it loads the plugin and rebinds\n * every `--tw-prose-*` variable to a shadcn semantic token, so an article\n * inherits the product it was bolted onto.\n *\n * Import it once from your global stylesheet, after the Tailwind import. From\n * `app/globals.css` that is `@import '../styles/agentblog.css';`. Nothing else\n * loads this file, so skipping that line leaves every article unstyled.\n *\n * The `@plugin` line below is a hard requirement, not a nicety: Tailwind fails\n * to resolve it if `@tailwindcss/typography` is not installed, and the build\n * stops. `@agentblog/blog-core` declares the package as a dependency for exactly\n * that reason, so an install has it before this file is ever compiled.\n *\n * ---------------------------------------------------------------------------\n * MANDATORY NOTE 1: DO NOT ADD `dark:prose-invert`\n * ---------------------------------------------------------------------------\n * It is unnecessary here, and worse than unnecessary. The tokens below already\n * flip under `.dark`, because that is what shadcn tokens do. `prose-invert`\n * swaps in the plugin's *inverted grey palette*, which then fights the tokens\n * for the same properties and wins on specificity for some of them. The result\n * is a half-inverted article whose body text follows the theme and whose\n * headings do not. If dark mode looks wrong, a token binding below is wrong.\n *\n * ---------------------------------------------------------------------------\n * MANDATORY NOTE 2: THE `hsl(var(--x))` VERSION TRAP\n * ---------------------------------------------------------------------------\n * Write `var(--foreground)`. Never `hsl(var(--foreground))`.\n *\n * shadcn's Tailwind v3 era stored colours as bare HSL channel triplets\n * (`--foreground: 0 0% 3.9%`), so the idiom was `hsl(var(--foreground))` and\n * that is what almost every blog post and Stack Overflow answer still shows.\n * Tailwind v4 shadcn stores complete `oklch()` values\n * (`--foreground: oklch(0.145 0 0)`), so wrapping it produces\n * `hsl(oklch(0.145 0 0))`, which is not a colour. CSS drops the invalid\n * declaration, the property inherits, and the page looks *nearly* right. That\n * silence is the trap: nothing errors, some text is just quietly the wrong\n * colour. If you are copying a snippet from anywhere, check this first.\n *\n * @see https://docs.agentblog.dev/guides/match-your-design#the-typography-plugin-is-bridged-rather-than-fought\n */\n\n@plugin '@tailwindcss/typography';\n\n/**\n * AgentBlog's own tokens: namespaced, and each one derived from a token the\n * consumer already owns. No new colours enter the design system here.\n *\n * ---------------------------------------------------------------------------\n * THE THREE RAILS\n * ---------------------------------------------------------------------------\n * Every page in the block is laid out on one of three widths, and nothing else\n * is allowed to invent a fourth. Changing one of these three values restyles\n * the whole blog, which is the point.\n *\n *   --agentblog-measure  The reading column. Article text, and everything that\n *                        sits beside article text: the H1, the lede, the byline,\n *                        the FAQ, the sources. One edge down the whole page.\n *   --agentblog-aside    The table-of-contents rail, used only at `xl` and up.\n *   --agentblog-article  measure + aside + the 3rem column gap. The outer bound\n *                        of an article page once the contents list moves out of\n *                        the flow and into the margin. Derived, so change the\n *                        two values it derives from rather than this one.\n *   --agentblog-rail     Index, category, tag, and author pages, where a card\n *                        grid needs room that a reading column would deny it.\n *                        64rem is `max-w-5xl`, which is the width most\n *                        application shells already use for their own header and\n *                        footer, so the block lines up with its host out of the\n *                        box. If yours is wider, this is the one value to change.\n *   --agentblog-gutter   The horizontal page gutter, 1.5rem, which is `px-6`.\n *\n * ---------------------------------------------------------------------------\n * EACH RAIL INCLUDES ITS OWN GUTTER\n * ---------------------------------------------------------------------------\n * Every container in the block is written `mx-auto w-full max-w-(--rail) px-6`,\n * one element carrying both the cap and the padding, so the number above is the\n * width of the element and the content inside it is 3rem narrower.\n *\n * That is the shadcn and Tailwind convention, and matching it is the whole\n * reason for the choice. The alternative, padding on a parent and the cap on a\n * child, gives a content column exactly one gutter wider, so a blog written\n * that way sits 24px outboard of the header and footer of any host that wrote\n * theirs the ordinary way. It is small enough that nobody files it and large\n * enough that everybody sees it.\n *\n * So `--agentblog-measure` at 42rem is a 39rem reading column between two\n * 1.5rem gutters, and 39rem measures 68 characters at the 17px reading size set\n * on `prose` below.\n *\n * ---------------------------------------------------------------------------\n * WHY THE MEASURE IS IN `rem` AND NOT IN `ch`\n * ---------------------------------------------------------------------------\n * `ch` is the advance width of the digit zero, which in most UI typefaces is\n * substantially wider than the average character. This block shipped `68ch` for\n * exactly that reason and it measured 84 characters per line, well outside the\n * 45 to 75 that every readability study since Bringhurst converges on. `ch`\n * reads like a character count and is not one.\n *\n * The reading column is 39rem, or 624px, which measures 68 characters at the\n * 17px reading size set on `prose` below. If you change the reading size,\n * re-measure rather than re-derive: the ratio depends on the consumer's\n * typeface.\n *\n * A `rem` value has a second, structural advantage. `ch` resolves against the\n * font size of the element it is written on, so a `ch` measure on the article\n * wrapper (16px) and the same `ch` measure on `.prose` (17px) produce two\n * different widths and the page loses its left edge. `rem` cannot drift.\n */\n/*\n * THE RAILS ARE IN A PLAIN `@theme`, NOT IN `@theme inline`, AND THE DIFFERENCE\n * IS NOT COSMETIC.\n *\n * `@theme inline` substitutes a variable's value into the utilities it\n * generates and never emits the variable itself. That is correct for the colour\n * aliases below, which exist only to name a token. It is wrong for these four,\n * because they are read back at runtime by arbitrary-property utilities like\n * `max-w-(--agentblog-measure)` and `grid-cols-[...var(--agentblog-aside)]`.\n *\n * They were declared `inline` here for as long as the block existed, so\n * `var(--agentblog-measure)` resolved to nothing and every `max-w-` referencing\n * it silently fell back to full width. It was invisible because `.prose` also\n * sets its own `max-width` from the same value, inlined at build time, so the\n * article body was the right width while every element around it was not.\n * A plain `@theme` emits them to `:root`, where a `var()` can find them.\n */\n@theme {\n  --agentblog-measure: 42rem;\n  --agentblog-aside: 15rem;\n  --agentblog-article: 60rem;\n  --agentblog-rail: 64rem;\n  --agentblog-gutter: 1.5rem;\n\n  /*\n   * The reading size, declared here rather than on `prose` below because two\n   * elements need it and only one of them is inside `.prose`.\n   *\n   * The plugin's own default is 16px, which is a UI size rather than a reading\n   * size: at the measure above it produces a line that is technically legible\n   * and tiring over 1,500 words. 17px from the `sm` breakpoint up is the step\n   * that long-form publishers converge on, and it is a step rather than a flat\n   * bump because 17px on a 375px phone costs more in wrapping than it returns in\n   * comfort. The override sits in the media query below.\n   *\n   * Every other space in an article is proportional to this number. The\n   * typography plugin expresses its margins in `em`, so raising the font size\n   * raises paragraph spacing, list indents, and heading leading by the same\n   * ratio, and the rhythm survives. That is why this is a font-size change and\n   * not a stack of margin overrides.\n   *\n   * The other element is the answer capsule, which is a paragraph of body text\n   * sitting in the article `<header>`, outside `.prose` and therefore outside\n   * the cascade that would have given it these values for free. It reads them\n   * back with `text-(length:--agentblog-reading-size)`. One reading size for the\n   * page: change it here and the capsule and the body move together, which is\n   * the whole reason the number left `@utility prose`.\n   */\n  --agentblog-reading-size: 1rem;\n  --agentblog-reading-leading: 1.75;\n}\n\n/*\n * Unlayered on purpose, and it has to stay that way to work.\n *\n * `@theme` emits its variables inside `@layer theme`, and an unlayered rule beats\n * a layered one in the cascade no matter which came first in the file. So this\n * plain `:root` wins at `sm` and above. Wrapping it in `@layer theme` to make it\n * match the block above would silently restore 16px at every width, and the only\n * visible symptom would be an article that reads slightly small.\n */\n@media (width >= 40rem) {\n  :root {\n    --agentblog-reading-size: 1.0625rem;\n  }\n}\n\n@theme inline {\n  --color-agentblog-prose-body: var(--foreground);\n  --color-agentblog-prose-muted: var(--muted-foreground);\n  --color-agentblog-prose-rule: var(--border);\n  --color-agentblog-prose-link: var(--primary);\n  --color-agentblog-prose-surface: var(--muted);\n}\n\n/**\n * The binding. All eighteen colour variables the typography plugin reads are\n * listed, so none of them can fall through to the plugin's default grey.\n *\n * Eighteen is the whole set in `@tailwindcss/typography@0.5.x`. Count them\n * against `src/styles.js` in the plugin before deleting one: the file used to\n * claim completeness while binding fifteen, and the three it missed\n * (`pre-code`, `kbd`, `kbd-shadows`) kept the plugin's greys in both themes.\n */\n@utility prose {\n  /*\n   * A ceiling, not the layout. The article column is already the reading width\n   * because its container is, so this only matters if you drop `<Prose>` into\n   * something wider. The gutter comes off because the rails include theirs.\n   */\n  max-width: calc(var(--agentblog-measure) - 2 * var(--agentblog-gutter));\n\n  /*\n   * The reading size and its leading, both read from the `@theme` block at the\n   * top of this file. See the comment on `--agentblog-reading-size` there for\n   * why 16px steps to 17px at `sm`, and for the second element that reads the\n   * same two values.\n   */\n  font-size: var(--agentblog-reading-size);\n  line-height: var(--agentblog-reading-leading);\n\n  --tw-prose-body: var(--foreground);\n  --tw-prose-headings: var(--foreground);\n  --tw-prose-lead: var(--muted-foreground);\n  --tw-prose-links: var(--primary);\n  --tw-prose-bold: var(--foreground);\n  --tw-prose-counters: var(--muted-foreground);\n  /*\n   * `--muted-foreground`, not `--border`, and `--border` is the trap here.\n   * A bullet is text the reader has to see, not a hairline. Against\n   * `--background`, `--border` measures about 1.2:1 in light mode and 1.4:1 in\n   * dark, so `<ul>` markers were effectively invisible while `<ol>` numbers,\n   * already bound to `--muted-foreground` at about 4.7:1, were fine. The two\n   * list types looked like different components.\n   */\n  --tw-prose-bullets: var(--muted-foreground);\n  --tw-prose-hr: var(--border);\n  --tw-prose-quotes: var(--foreground);\n  --tw-prose-quote-borders: var(--border);\n  --tw-prose-captions: var(--muted-foreground);\n  --tw-prose-code: var(--foreground);\n  /*\n   * `--tw-prose-pre-bg` is rebound to `--muted` just below, so `pre-code` has to\n   * be a foreground token or a plain `<pre>` renders the plugin's `#e5e7eb` on\n   * `#f5f5f5`. That was invisible in practice only because rehype-pretty-code\n   * writes its own colour onto every token and outranks this, which is a fix\n   * that stops working the moment a fence has no language or a raw `<pre>`\n   * reaches the page.\n   */\n  --tw-prose-pre-code: var(--foreground);\n  --tw-prose-pre-bg: var(--muted);\n  --tw-prose-kbd: var(--foreground);\n  /*\n   * The plugin uses this directly as a box-shadow colour, not as an RGB triplet:\n   * `0 0 0 1px var(--tw-prose-kbd-shadows), 0 3px 0 var(--tw-prose-kbd-shadows)`.\n   * Its default is the text colour at 10% opacity, which is a hand-mixed edge\n   * colour. `--border` is the token that already means \"edge\", already carries\n   * the low contrast the plugin was approximating, and already flips, so it is\n   * the binding rather than a `color-mix()` of `--foreground`: a colour function\n   * here would be the block computing a colour of its own, which is the thing\n   * this file exists to avoid.\n   */\n  --tw-prose-kbd-shadows: var(--border);\n  --tw-prose-th-borders: var(--border);\n  --tw-prose-td-borders: var(--border);\n}\n\n/**\n * Line-breaking, which is the difference between typesetting and text.\n *\n * `balance` on headings spreads a two-line heading evenly instead of leaving one\n * word on the second line. It is capped by the browser at a handful of lines,\n * which is why it is scoped to headings rather than applied to everything.\n *\n * `pretty` on body copy asks the browser to avoid a single-word last line. It\n * costs nothing where it is unsupported, because the property is ignored.\n *\n * Both are written against `:is()` rather than `:where()` deliberately: these\n * should outrank the plugin's defaults, and a caller who wants a different\n * behaviour on one element still wins with a Tailwind utility.\n */\n.prose :is(h1, h2, h3, h4) {\n  text-wrap: balance;\n}\n\n.prose :is(p, li, blockquote, figcaption) {\n  text-wrap: pretty;\n}\n\n/**\n * Delete the typography plugin's backticks around inline code.\n *\n * `@tailwindcss/typography` renders `content: \"\\`\"` in a `::before` and an\n * `::after` on every inline `<code>`. That is a reasonable default for raw\n * markdown output with no other styling, and it is wrong here: `InlineCode` in\n * `components/mdx/code-block.tsx` already gives the element a border, a fill,\n * and padding, so the plugin's quotes render as two literal backtick glyphs\n * INSIDE a chip that already says \"this is code\". Every inline code span in\n * every post read as `` `curl` `` rather than as `curl`.\n *\n * `:is()` rather than `:where()`, because the plugin's own rule is zero\n * specificity and this has to outrank it. The `pre` case is included and costs\n * nothing: the plugin already excludes it, so there is no content to remove.\n */\n.prose :is(code)::before,\n.prose :is(code)::after {\n  content: none;\n}\n\n/**\n * Heading anchors, appended by `rehype-autolink-headings` in `lib/render-mdx.tsx`.\n *\n * The link is always in the HTML, which is the point: it gives search and\n * retrieval systems a stable section boundary whether or not anyone can see it.\n * Visually it stays out of the way until the heading is hovered, or until the\n * link itself is focused by keyboard, which is why the `:focus-visible` rule is\n * not optional.\n */\n.agentblog-anchor {\n  margin-left: 0.375rem;\n  color: var(--muted-foreground);\n  opacity: 0;\n  text-decoration: none;\n  transition: opacity 150ms ease-out;\n}\n\n.agentblog-anchor::after {\n  content: '#';\n}\n\n.agentblog-heading:hover .agentblog-anchor,\n.agentblog-anchor:focus-visible {\n  opacity: 1;\n}\n\n@media (prefers-reduced-motion: reduce) {\n  .agentblog-anchor {\n    transition: none;\n  }\n}\n\n/**\n * Shiki dual themes.\n *\n * `lib/render-mdx.tsx` configures `theme: { light: 'github-light', dark: 'github-dark' }`,\n * so `rehype-pretty-code` writes both colours onto every token as\n * `--shiki-light` and `--shiki-dark` and marks the `<code>` element with a\n * space-separated `data-theme`. These two rules are what pick between them.\n *\n * This is the one place in the block that uses a `.dark` selector, and it does\n * not break the \"no dark variants\" rule: it is choosing between two colours the\n * *highlighter* generated, not hard-coding a colour of our own. There is no\n * token that can express \"the colour Shiki assigned to a keyword\".\n */\ncode[data-theme*=' '],\ncode[data-theme*=' '] span {\n  color: var(--shiki-light);\n}\n\n.dark code[data-theme*=' '],\n.dark code[data-theme*=' '] span {\n  color: var(--shiki-dark);\n}\n\n/**\n * `rehype-pretty-code` wraps each block in a figure and, when the fence carries\n * a `title=\"...\"`, prepends a figcaption. Both inherit the block's border so a\n * titled snippet reads as one object.\n */\nfigure[data-rehype-pretty-code-figure] {\n  margin-block: 1.5rem;\n}\n\nfigure[data-rehype-pretty-code-figure] > [data-rehype-pretty-code-title] {\n  border: 1px solid var(--border);\n  border-bottom: 0;\n  border-top-left-radius: var(--radius);\n  border-top-right-radius: var(--radius);\n  background-color: var(--muted);\n  color: var(--muted-foreground);\n  font-size: 0.8125rem;\n  padding: 0.5rem 1rem;\n}\n\nfigure[data-rehype-pretty-code-figure] > [data-rehype-pretty-code-title] + pre {\n  margin-top: 0;\n  border-top-left-radius: 0;\n  border-top-right-radius: 0;\n}\n\n/**\n * FAQ disclosure widgets.\n *\n * The default triangle marker is removed because `Faq` renders its own\n * indicator. The content stays in the HTML in both states, which is what makes\n * the `FAQPage` JSON-LD in `lib/schema.ts` legitimate rather than a guidelines\n * violation. Do not replace `<details>` with a conditional mount.\n */\n.agentblog-faq > summary::-webkit-details-marker {\n  display: none;\n}\n\n.agentblog-faq > summary::marker {\n  content: '';\n}\n",
      "type": "registry:file",
      "target": "~/styles/agentblog.css"
    }
  ],
  "docs": "REQUIRED, and nothing will warn you if you skip it: import the installed styles/agentblog.css from your global stylesheet, after the Tailwind import. From app/globals.css that is `@import '../styles/agentblog.css';`, and from src/app/globals.css it is `@import '../../styles/agentblog.css';`, because the file lands at your project root. Nothing else loads that file, and without it article prose renders with no typography, no error, and no failing check. It also loads @tailwindcss/typography, which was installed for you as a dependency. Then edit agentblog.config.ts and run `npx agentblog@latest doctor --fix` to finish wiring next.config.ts. Skip that last step and AI crawlers receive <title> inside <body> on any page with streamed metadata.",
  "categories": [
    "blog",
    "seo",
    "library"
  ],
  "type": "registry:lib"
}