agentblog
libblog-schemasource: ok · hit
AgentBlog JSON-LD builders
Typed schema.org @graph builders for BlogPosting, WebPage, BreadcrumbList, Person, Organization, WebSite, ImageObject, and FAQPage. Cross-references are @id stubs from one helper so the graph cannot drift apart, and there is a single serialization point that escapes the output.
npx shadcn add "https://agentblog.dev/r/blog-schema.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 · lightRegistry dependencies
@agentblog/blog-core
Files2 · source cached 10/9/2026, 5:23:48 PM
registry/blog/lib/schema.ts
/**
* JSON-LD `@graph` builders. This is the file AgentBlog exists to ship.
*
* ---------------------------------------------------------------------------
* WHAT THIS FILE IS
* ---------------------------------------------------------------------------
* Every page in the block emits one `<script type="application/ld+json">`
* containing a single `@graph`. The builders below produce the nodes; the
* `<JsonLd>` server component in `components/blog/json-ld.tsx` renders them.
*
* Three rules hold the whole thing together, and each one exists because
* breaking it produces output that passes every validator and still means
* nothing to a search engine:
*
* 1. **Every `@id` comes from the `ids` object below.** Never compose one at a
* call site. Cross-references between nodes are `{ '@id': string }` stubs, so
* an `@id` that drifts by one character turns a connected graph into a pile
* of orphan nodes. That output validates clean. It also tells Google nothing
* about who wrote the article or who published it, which is the entire point
* of emitting it.
*
* 2. **Cross-references are stubs, never inlined duplicates.** Inlining the
* author `Person` inside `BlogPosting.author` and also emitting it as a node
* creates two entities where there is one person.
*
* 3. **`renderJsonLd` is the only serialization point.** The `<` escape that
* stops a post title from closing the `<script>` element lives there and
* nowhere else, so it can be reviewed and tested once.
*
* ---------------------------------------------------------------------------
* THE FIELD MAPPING TABLE
* ---------------------------------------------------------------------------
* This table is the product. Review the builders against it. The relevant row
* is repeated as a comment on each builder.
*
* | Domain field | schema.org | Notes |
* | ------------------------------------- | ---------------------------------------- | ------------------------------------------------------------------ |
* | `Post.title` | `BlogPosting.headline` | 110-char limit removed Jan 2025; stay concise anyway |
* | `Post.description` | `BlogPosting.description` | Must match the meta description |
* | `Post.datePublished` / `dateModified` | same names | ISO 8601 with offset, enforced by the `IsoDateTime` branded type |
* | `Post.author` | `BlogPosting.author` -> `{ '@id': ... }` | Never inline, never a bare string |
* | `Post.category.name` | `BlogPosting.articleSection` | Ignored for ranking, harmless |
* | `Post.tags` | `BlogPosting.keywords` | Same |
* | `Post.heroImage` + `heroAlt` | `BlogPosting.image` -> `ImageObject` | Emitted as its own node and referenced by `@id` |
* | `Post.citations[]` | `BlogPosting.citation` -> `CreativeWork[]` | `Citation.kind` is ours for auditing and is never emitted |
* | `Post.faq[]` | separate `FAQPage` node | Emitted only when the FAQs render visibly. See `buildFaq` |
* | `Post.relatedPosts` | not emitted | Internal linking is HTML, not schema |
* | `Post.locale` | `BlogPosting.inLanguage`, `WebPage.inLanguage` | Falls back to `config.locale` |
* | `Post.translations` | not emitted in v1 | Reserved for `alternates.languages` and `WebPage.workTranslation` |
* | `Post.body` | `BlogPosting.wordCount` | Counted by `readingTime`, so the schema and the visible count agree. Omitted when zero |
* | `Author.name` | `Person.name` | Name only. No job title, no honorific, no publisher name |
* | `Author.jobTitle` | `Person.jobTitle` | Kept separate precisely so it never reaches `name` |
* | `Author.bio` | `Person.description` | |
* | `Author.knowsAbout` | `Person.knowsAbout` | Topical authority |
* | `Author.sameAs` | `Person.sameAs` | Absolute URLs, enforced by `AbsoluteUrl` |
* | `Author.worksFor` | `Person.worksFor` -> `{ '@id': ... }` | Defaults to the site `Organization` |
* | `Author.avatar` | `Person.image` | |
* | `Author.alumniOf` | `Person.alumniOf` | |
* | `config.brand.name` / `.logo` | `Organization.name` / `.logo` | Logo needs `width` and `height` |
* | `config.brand.sameAs` | `Organization.sameAs` | The entity-disambiguation property |
* | `homeUrl()` | `WebSite.url`, every site-level `@id` | One spelling of the root across schema, sitemap, and breadcrumb |
*
* @see https://docs.agentblog.dev/reference/files
*/
import type {
Blog,
BlogPosting,
BreadcrumbList,
CreativeWork,
Distance,
FAQPage,
Graph,
ImageObject,
ListItem,
Organization as OrganizationOrText,
Person as PersonOrText,
Question,
WebPage,
WebSite,
} from 'schema-dts'
import { absoluteUrl, authorUrl, bcp47Locale, config, homeUrl, postUrl } from '@/lib/config'
import { readingTime } from '@/lib/reading-time'
import type { Author, Citation, FaqEntry, Post } from '@/lib/types'
/* -------------------------------------------------------------------------- */
/* Types */
/* -------------------------------------------------------------------------- */
/**
* One node in the page's `@graph`.
*
* `Graph['@graph']` is `readonly Thing[]`, and schema-dts folds a bare text
* value into several `Thing` members, so the `string` arm has to come off before
* anything is assignable here.
*/
export type GraphNode = Exclude<Graph['@graph'][number], string>
/**
* schema.org allows `Person` and `Organization` to be a bare text value, so
* schema-dts models both as `Leaf | ... | string`. A node that sits in `@graph`
* is never a string, and leaving the string arm in place makes these types
* unassignable to `GraphNode`. Narrowing once here beats casting at every call
* site, and it is the only concession this file makes to schema-dts.
*/
type Organization = Exclude<OrganizationOrText, string>
type Person = Exclude<PersonOrText, string>
/* -------------------------------------------------------------------------- */
/* Routes and identifiers */
/* -------------------------------------------------------------------------- */
/**
* Root-relative route paths.
*
* `@/lib/config` composes absolute URLs but does not expose the paths behind
* them, and `ids.webpage` and `ids.breadcrumb` are keyed by path because a
* `WebPage` node exists for routes that have no dedicated `*Url` helper. Private
* on purpose: route shape belongs to the app, not to the schema layer.
*/
const routes = {
post: (slug: string): string => `/blog/${slug}`,
} as const
/**
* Every `@id` in the graph, in one object.
*
* Read rule 1 in the file header before adding to this. The convention is
* `${url}#${fragment}`, where the fragment names the node's role on that URL, so
* two nodes describing the same page (a `WebPage` and an `FAQPage`, say) get
* distinct identifiers instead of merging into one confused node.
*
* The site-level ids are built from `homeUrl()` rather than from
* `config.siteUrl`, so the `@id` prefix is the same string the sitemap and
* `Organization.url` use. See `homeUrl` in `@/lib/config` for why one spelling
* of the root matters.
*
* Note: `blog()` was added after the rest. `buildBlogGraph` needs an identifier
* for the `Blog` node, and rule 1 says a call site cannot invent one, so it is
* declared here rather than composed inline.
*
* `blog()` takes a path for the same reason `breadcrumb()` and `webpage()` do,
* and the reason is not symmetry. The index is paginated, so `/blog` and
* `/blog?page=2` are two URLs carrying two different `blogPost` lists. A path-less
* id gave both the same `@id` and the same `url`, which is one entity described
* two incompatible ways in the same crawl: whichever page is fetched last wins,
* and the losing page's posts drop out of the graph. Pass the route's own path,
* exactly as it appears in the canonical.
*/
export const ids = {
org: (): string => `${homeUrl()}#organization`,
website: (): string => `${homeUrl()}#website`,
blog: (path: string): string => `${absoluteUrl(path)}#blog`,
person: (slug: string): string => `${authorUrl(slug)}#person`,
article: (slug: string): string => `${postUrl(slug)}#article`,
webpage: (path: string): string => `${absoluteUrl(path)}#webpage`,
breadcrumb: (path: string): string => `${absoluteUrl(path)}#breadcrumb`,
faq: (slug: string): string => `${postUrl(slug)}#faq`,
image: (url: string): string => `${url}#primaryimage`,
}
/* -------------------------------------------------------------------------- */
/* Private helpers */
/* -------------------------------------------------------------------------- */
/**
* Pixel counts for `width` and `height`.
*
* THE ONE CAST IN THIS FILE, and it is a schema-dts artifact rather than a
* modelling choice. schema.org types `MediaObject.width` and `.height` as
* `Distance`, a quantity whose range is Text, so schema-dts resolves `Distance`
* to `string`. Google's Article and Organization documentation both show integer
* pixel counts and the Rich Results Test reads them as integers, so emitting
* `"512"` would be legal JSON-LD and the wrong output. The cast is confined to
* this function so the rest of the file stays honestly typed.
*/
function px(pixels: number): Distance {
return pixels as unknown as Distance
}
/**
* JSON-LD has no notion of a document base, so every URL in the graph has to be
* absolute. Config and frontmatter both accept a root-relative path for
* convenience (`/logo.png`), so normalise once here instead of in five places.
*/
function toAbsolute(url: string): string {
return /^https?:\/\//i.test(url) ? url : absoluteUrl(url)
}
/**
* `Post.locale` wins over the site default. The whole of the v1 locale seam.
*
* The Open Graph to BCP 47 conversion lives in `bcp47Locale` in `@/lib/config`
* and is imported rather than reimplemented. A second local copy is how the
* feed and this file came to disagree about whether `zh_Hans_CN` converts fully.
*/
function languageOf(post: Post): string {
return bcp47Locale(post.locale ?? config.locale)
}
/**
* `Post.citations[]` -> `BlogPosting.citation` -> `CreativeWork[]`.
*
* `Citation.kind` is deliberately absent from the output. It is our own
* auditing field (peer-reviewed, official-docs, industry, news) with no
* schema.org equivalent, and inventing a property name for it would produce
* markup no consumer understands.
*/
function citationNode(source: Citation): CreativeWork {
return {
'@type': 'CreativeWork',
name: source.name,
url: source.url,
...(source.author === undefined ? {} : { author: source.author }),
...(source.datePublished === undefined ? {} : { datePublished: source.datePublished }),
}
}
/** One visible question and answer pair as a `Question` node. */
function questionNode(entry: FaqEntry): Question {
return {
'@type': 'Question',
name: entry.question,
acceptedAnswer: { '@type': 'Answer', text: entry.answer },
}
}
/**
* `Post.heroImage` + `Post.heroAlt` -> `ImageObject`.
*
* The node carries no `width` or `height`. The domain model does not record the
* intrinsic size of a hero image, and declared dimensions that disagree with the
* file are a structured data error rather than a missing recommendation, so
* guessing 1200x630 would be worse than omitting them. Adding `heroWidth` and
* `heroHeight` to `PostFieldsSchema` and reading them here is a two-line change
* on both sides when the content pipeline can supply real numbers.
*
* `representativeOfPage` is what tells a consumer this is the article's primary
* image rather than one of the inline figures.
*/
function heroNode(post: Post, canonical: string): ImageObject | null {
if (post.heroImage === undefined) return null
const url = toAbsolute(post.heroImage)
return {
'@type': 'ImageObject',
'@id': ids.image(canonical),
url,
contentUrl: url,
representativeOfPage: true,
// `PostSchema` refuses a hero image without alt text, so this is present in
// practice; the guard is here because the refinement is not in the type.
...(post.heroAlt === undefined ? {} : { caption: post.heroAlt }),
}
}
/* -------------------------------------------------------------------------- */
/* Builders */
/* -------------------------------------------------------------------------- */
/**
* The site-level nodes. Emitted once, from the blog layout, so every page on the
* site resolves the same publisher entity.
*
* | `config.brand.name` / `.logo` | `Organization.name` / `.logo` | Logo needs `width` and `height` |
* | `config.brand.sameAs` | `Organization.sameAs` | Entity disambiguation |
* | `homeUrl()` | `WebSite.url` | One spelling of the site root |
*
* `sameAs` is the single highest value property in the whole config. It is the
* entity-disambiguation property: it is how an AI search engine resolves "the
* company that published this" from a string into a real entity it already
* knows about, which is a different and more durable thing than ranking for a
* query. LinkedIn, Crunchbase, GitHub, YouTube, and Wikidata are the entries
* worth having.
*
* The logo is a full `ImageObject` rather than a URL string because Google's
* Organization markup requires `width` and `height`, and a logo without
* dimensions is one of the most common structured data errors on the web.
*/
export function buildOrgGraph(): [Organization, WebSite] {
const organization: Organization = {
'@type': 'Organization',
'@id': ids.org(),
name: config.brand.name,
// `homeUrl()`, not `config.siteUrl`. The sitemap emits the same string for
// the home page, and an entity whose `url` differs from the crawled URL by a
// trailing slash is an entity that has to be reconciled rather than matched.
url: homeUrl(),
logo: {
'@type': 'ImageObject',
url: toAbsolute(config.brand.logo.url),
width: px(config.brand.logo.width),
height: px(config.brand.logo.height),
},
...(config.brand.sameAs.length === 0 ? {} : { sameAs: config.brand.sameAs }),
}
const website: WebSite = {
'@type': 'WebSite',
'@id': ids.website(),
url: homeUrl(),
name: config.brand.name,
publisher: { '@id': ids.org() },
inLanguage: bcp47Locale(),
}
return [organization, website]
}
/**
* `Author` -> `Person`. Emitted on the author page and inside every article
* graph, always with the same `@id`, so the two references describe one entity.
*
* | `Author.name` | `Person.name` | Name only |
* | `Author.bio` | `Person.description` | |
* | `Author.jobTitle` | `Person.jobTitle` | Separate so it never reaches name |
* | `Author.knowsAbout` | `Person.knowsAbout` | Topical authority |
* | `Author.sameAs` | `Person.sameAs` | Absolute URLs |
* | `Author.worksFor` | `Person.worksFor` | `@id` ref, site org by default |
* | `Author.avatar` | `Person.image` | |
* | `Author.alumniOf` | `Person.alumniOf` | |
*
* `name` is the person's name and nothing else. Google's Article guidance is
* explicit that it must exclude job titles, honorifics, and the publisher name,
* and "Dr. Jane Doe, Head of Research at Acme" in an author field is one of the
* most common ways a byline stops resolving to a person. `AuthorSchema` gives
* `jobTitle` its own field precisely so the correct output is the only one that
* can be built here.
*/
export function buildPersonGraph(author: Author): Person {
return {
'@type': 'Person',
'@id': ids.person(author.slug),
name: author.name,
url: authorUrl(author.slug),
description: author.bio,
// An author who works somewhere else supplies that organization's URL, which
// is a perfectly good external entity identifier. Everyone else works for
// the site's own Organization node.
worksFor: { '@id': author.worksFor ?? ids.org() },
...(author.jobTitle === undefined ? {} : { jobTitle: author.jobTitle }),
...(author.avatar === undefined ? {} : { image: toAbsolute(author.avatar) }),
...(author.alumniOf === undefined ? {} : { alumniOf: author.alumniOf }),
...(author.knowsAbout.length === 0 ? {} : { knowsAbout: author.knowsAbout }),
...(author.sameAs.length === 0 ? {} : { sameAs: author.sameAs }),
}
}
/**
* The breadcrumb trail as a `BreadcrumbList`.
*
* An entry with no `path` emits no `item`, which is Google's documented shape
* for the current page rather than an omission: "If `item` isn't included for
* the last item, Google uses the URL of the containing page." It is also
* legitimate for every entry to have one, which is what a post's trail looks
* like now that it ends at the category hub rather than at the post itself.
* Google does not require the trail to reach the current page, and it does not
* require it to start at the site root either: "It is not required to include a
* breadcrumb ListItem for the top level path (your site's domain or host name)."
* @see https://developers.google.com/search/docs/appearance/structured-data/breadcrumb
*
* Crumbs carry root-relative paths and this function absolutizes them, which is
* the one place that conversion happens. `item` has to be an absolute URL to
* identify a page; a breadcrumb `href` has to be a path so `next/link` keeps
* routing inside whatever origin the page is being served from. Storing the
* absolute form in the trail satisfies the first and breaks the second, on every
* preview deployment. See `BreadcrumbTrailItem.path` in `components/blog/breadcrumbs.tsx`.
*
* The trail here must match the trail the page renders through `<Breadcrumbs>`,
* which is why every caller builds one array and passes it to both. Marked-up
* navigation that a reader cannot see is the same policy violation as an
* invisible FAQ.
*/
export function buildBreadcrumb(
trail: readonly { name: string; path?: string }[],
// The path of the page carrying the trail, which is what the node's `@id` is
// keyed by. Named apart from `crumb.path` because they are different things.
pagePath: string,
): BreadcrumbList {
return {
'@type': 'BreadcrumbList',
'@id': ids.breadcrumb(pagePath),
itemListElement: trail.map((crumb, index): ListItem => {
return {
'@type': 'ListItem',
position: index + 1,
name: crumb.name,
...(crumb.path === undefined ? {} : { item: absoluteUrl(crumb.path) }),
}
}),
}
}
/**
* `Post.faq[]` -> a standalone `FAQPage` node, or `null` when there are none.
*
* ---------------------------------------------------------------------------
* READ THIS BEFORE CALLING IT FROM A NEW ROUTE
* ---------------------------------------------------------------------------
* `FAQPage` markup may only be emitted when the FAQs actually render, visibly,
* in the HTML of the same page. Google's structured data policy forbids marking
* up content that is not visible to readers, and FAQ markup is the single most
* common way a blog earns a manual action, which removes rich result eligibility
* for the whole site rather than for the one page.
*
* The post route satisfies this by rendering `<Faq>` from the MDX component map
* inside the article body before this node is ever emitted. `<Faq>` uses
* `<details>` and CSS, never a conditional mount, so the answers are in the raw
* HTML even when the disclosure is closed and even for a crawler that runs no
* JavaScript.
*
* If you call `buildFaq` from a route that does not render the questions, delete
* the call. Do not add the markup and plan to add the UI later.
*
* Note also that Google restricted FAQ rich results to authoritative government
* and health sites in 2023, so this node is not going to produce a rich result
* on a company blog. It is here because it is a clean, machine-readable
* question-to-answer mapping that AI search engines do read, which is worth
* having on its own terms.
*/
export function buildFaq(post: Post): FAQPage | null {
if (post.faq.length === 0) return null
return {
'@type': 'FAQPage',
'@id': ids.faq(post.slug),
mainEntity: post.faq.map(questionNode),
}
}
/**
* The complete node set for one post page.
*
* Returns, in order: the `BlogPosting`, the `WebPage` it is the main entity of,
* the `BreadcrumbList`, the author `Person`, the hero `ImageObject` when the
* post has one, and the `FAQPage` when and only when the post has FAQ entries.
*
* `trail` is the same array the route hands to `<Breadcrumbs>`, and it is a
* required argument rather than something this function assembles. It used to
* assemble one, and the two drifted exactly as you would expect: the page
* rendered Home, Blog, Category, Post while the markup claimed Home, Blog, Post,
* so the site shipped a `BreadcrumbList` describing a trail no reader could see.
* Nothing catches that. Both are valid, both validate, and they disagree.
*
* | `Post.title` | `BlogPosting.headline` | |
* | `Post.description` | `BlogPosting.description` | Matches the meta description |
* | `Post.datePublished` | `BlogPosting.datePublished` | Verbatim, ISO 8601 with offset |
* | `Post.dateModified` | `BlogPosting.dateModified` | Verbatim, ISO 8601 with offset |
* | `Post.author` | `BlogPosting.author` | `@id` ref, never a string |
* | `Post.category.name` | `BlogPosting.articleSection` | |
* | `Post.tags` | `BlogPosting.keywords` | |
* | `Post.heroImage`/`heroAlt` | `BlogPosting.image` | `@id` ref to the `ImageObject` |
* | `Post.citations[]` | `BlogPosting.citation` | `Citation.kind` never emitted |
* | `Post.faq[]` | `FAQPage` | Only when the FAQs render |
* | `Post.locale` | `inLanguage` on both nodes | Falls back to `config.locale` |
* | `Post.body` | `BlogPosting.wordCount` | `readingTime`; omitted when zero |
* | `Post.relatedPosts` | not emitted | Internal linking is HTML |
*
* The dates go out exactly as they arrive. `IsoDateTime` has already refused
* anything without a UTC offset, which matters because Google reinterprets an
* offset-less timestamp in Googlebot's own timezone and can move a post across a
* day boundary in the process.
*
* `wordCount` comes from `readingTime` rather than a second word counter, so the
* number in the markup is the same number the byline shows a reader. Two
* counters would eventually disagree, and disagreeing with your own visible page
* is the failure mode this whole file is built to avoid. It is omitted entirely
* when the count is zero, which happens for a post whose body is nothing but a
* fenced code block: `readingTime` strips fences before counting.
*
* The `Organization` and `WebSite` nodes this graph references by `@id` are
* emitted once from the blog layout via `buildOrgGraph`. Google merges every
* JSON-LD block on a page into one graph, so the references resolve even though
* the nodes arrive in a different `<script>`.
*/
export function buildArticleGraph(
post: Post,
trail: readonly { name: string; path?: string }[],
): GraphNode[] {
const path = routes.post(post.slug)
const canonical = postUrl(post.slug)
const webpageId = ids.webpage(path)
const language = languageOf(post)
const hero = heroNode(post, canonical)
const citations = post.citations.map(citationNode)
// `readingTime` strips fenced code before counting, so a post whose body is
// only a code block counts zero words. `"wordCount": 0` is a claim that the
// article has no text, which is both false and worse than saying nothing,
// and it contradicts the byline, which floors the estimate at "1 min read".
const { words } = readingTime(post.body)
const article: BlogPosting = {
'@type': 'BlogPosting',
'@id': ids.article(post.slug),
isPartOf: { '@id': ids.website() },
mainEntityOfPage: { '@id': webpageId },
headline: post.title,
description: post.description,
datePublished: post.datePublished,
dateModified: post.dateModified,
author: { '@id': ids.person(post.author.slug) },
publisher: { '@id': ids.org() },
inLanguage: language,
articleSection: post.category.name,
...(words === 0 ? {} : { wordCount: words }),
...(post.tags.length === 0 ? {} : { keywords: post.tags }),
...(citations.length === 0 ? {} : { citation: citations }),
...(hero === null ? {} : { image: { '@id': ids.image(canonical) } }),
}
const webpage: WebPage = {
'@type': 'WebPage',
'@id': webpageId,
url: canonical,
name: post.title,
description: post.description,
isPartOf: { '@id': ids.website() },
breadcrumb: { '@id': ids.breadcrumb(path) },
datePublished: post.datePublished,
dateModified: post.dateModified,
inLanguage: language,
...(hero === null ? {} : { primaryImageOfPage: { '@id': ids.image(canonical) } }),
}
const nodes: GraphNode[] = [
article,
webpage,
buildBreadcrumb(trail, path),
buildPersonGraph(post.author),
]
if (hero !== null) nodes.push(hero)
const faq = buildFaq(post)
if (faq !== null) nodes.push(faq)
return nodes
}
/**
* The blog index as a `Blog` node whose `blogPost` entries are `@id` references
* to the article nodes on the post pages.
*
* References rather than inlined `BlogPosting` nodes, for the same reason as
* everywhere else: the article node lives on the article's own URL, and copying
* an abridged version of it onto the index would describe a second, thinner
* article with the same identity.
*
* `path` is the route's own path, including any pagination query string, and it
* is required rather than defaulted for the reason spelled out on `ids.blog`.
* The route already computes this string for its canonical, so pass that same
* value here: `buildBlogGraph(posts, pagePath(page))`. A default of `/blog`
* would make page 2 compile and still emit page 1's identity.
*/
export function buildBlogGraph(posts: readonly Post[], path: string): Blog {
return {
'@type': 'Blog',
'@id': ids.blog(path),
url: absoluteUrl(path),
name: `${config.brand.name} Blog`,
publisher: { '@id': ids.org() },
isPartOf: { '@id': ids.website() },
inLanguage: bcp47Locale(),
blogPost: posts.map((post) => ({ '@id': ids.article(post.slug) })),
}
}
/* -------------------------------------------------------------------------- */
/* Serialization: the single point */
/* -------------------------------------------------------------------------- */
/**
* Drop `undefined` recursively.
*
* `JSON.stringify` already omits object properties whose value is `undefined`,
* so this is not entirely redundant work but it is not entirely redundant
* either: an `undefined` sitting in an array is serialized as `null`, and a
* `null` inside `itemListElement` or `sameAs` is a validation error rather than
* an ignored value. Building the nodes conditionally (which
* `exactOptionalPropertyTypes` forces anyway) should mean nothing here has
* anything to do. This runs so that a future builder written less carefully
* cannot emit `"author": undefined` and fail a validator on a Friday.
*/
function prune(value: unknown): unknown {
if (Array.isArray(value)) {
return value.filter((entry) => entry !== undefined).map(prune)
}
if (typeof value === 'object' && value !== null) {
const out: Record<string, unknown> = {}
for (const [key, entry] of Object.entries(value)) {
if (entry === undefined) continue
out[key] = prune(entry)
}
return out
}
return value
}
/**
* Wrap the nodes in one `@graph` and serialize. The only place in the block that
* turns schema objects into a string.
*
* Replacing every `<` with its JSON unicode escape is the security-relevant
* part. `JSON.stringify` escapes quotes and backslashes but leaves `<` alone, so
* a post title containing a closing script tag would otherwise end the element
* early and the rest of the JSON would be parsed as HTML. The escape decodes
* back to `<`, so a consumer reads the original text and a browser never sees a
* tag. Every `<` in the output is inside a JSON string, because JSON's own
* structural characters do not include it, which is why a blanket replacement is
* safe.
*
* Keep this the only serialization point. A second `JSON.stringify` of a schema
* object somewhere else in the codebase is a hole in exactly this defence.
*/
export function renderJsonLd(nodes: readonly GraphNode[]): string {
const graph: Graph = { '@context': 'https://schema.org', '@graph': nodes }
return JSON.stringify(prune(graph)).replaceAll('<', '\\u003c')
}
registry/blog/components/blog/json-ld.tsx
/**
* The `<script type="application/ld+json">` tag. One line of rendering, and two
* decisions that are worth reading before you change either of them.
*
* ---------------------------------------------------------------------------
* WHY THIS IS A SERVER COMPONENT, AND MUST STAY ONE
* ---------------------------------------------------------------------------
* There is no `'use client'` at the top of this file and adding one breaks the
* product rather than the build.
*
* AI crawlers do not execute JavaScript. GPTBot, ClaudeBot, and PerplexityBot
* read the raw HTML the server returns and nothing else. Structured data
* injected by a client component exists only in the rendered DOM, so a
* DOM-based validator would pass it and every crawler this block exists to reach
* would see a page with no schema at all. That is the worst class of failure
* here: silent, invisible to the type checker, and invisible to the tools people
* check with.
*
* `lib/schema.ts` imports `@/lib/config`, which is `server-only`, so the module
* graph enforces this too. The comment is here because the error message that
* enforcement produces will not explain why.
*
* ---------------------------------------------------------------------------
* WHY `dangerouslySetInnerHTML` IS THE RIGHT CALL HERE
* ---------------------------------------------------------------------------
* React escapes text children as HTML entities, so `{json}` as a child would
* write `"` into the script body and produce JSON that no consumer can
* parse. Setting the tag's inner HTML directly is the documented Next.js pattern
* for JSON-LD and the only one that emits valid output.
*
* It is safe because of where the string comes from, not because of what it
* contains. Every byte is machine-generated by `renderJsonLd`, which serializes
* with `JSON.stringify` and then replaces `<` with its JSON unicode escape. That
* replacement is what stops a post title containing a closing script tag from
* ending the element early and turning the remaining JSON into markup. It
* happens at exactly one point in the codebase so it can be reviewed once and
* cannot be forgotten at a call site.
*
* The rule that follows: never pass this component a string built anywhere else,
* and never serialize a schema object outside `renderJsonLd`.
*
* @see https://docs.agentblog.dev/reference/files
*/
import type { GraphNode } from '@/lib/schema'
import { renderJsonLd } from '@/lib/schema'
export interface JsonLdProps {
/** The nodes for this page, from the builders in `lib/schema.ts`. */
readonly nodes: readonly GraphNode[]
}
export function JsonLd({ nodes }: JsonLdProps) {
// An empty `@graph` is markup that says nothing. Emit no tag rather than an
// empty one, so a page that genuinely has no schema is obvious in view-source.
if (nodes.length === 0) return null
return (
<script type="application/ld+json" dangerouslySetInnerHTML={{ __html: renderJsonLd(nodes) }} />
)
}
/** Alias so a route can import this either way. Same component. */
export default JsonLd