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