---
title: "Content"
description: "Load local Markdown, MDX, JSON, and YAML as schema-validated, typed server content without a second config file."
canonical_url: "https://mcp.farmjs.dev/docs/plugins/content"
markdown_url: "https://mcp.farmjs.dev/docs/plugins/content.md"
last_updated: "2026-10-20"
x_farming_labs_generated_preamble: true
---

# Content
URL: /docs/plugins/content
LLM index: /llms.txt
Description: Load local Markdown, MDX, JSON, and YAML as schema-validated, typed server content without a second config file.

# Content

`@farm.js/content` turns local Markdown, MDX, JSON, and YAML files into typed server data. Farm
discovers the files, validates them while configuring the application, watches them in development,
and bundles the validated result into production output.

Collections are ordinary plugin options in `farm.config.ts`.

## Install

```bash
pnpm add @farm.js/content zod
```

Zod is optional. The plugin accepts Standard Schema validators, including Valibot and ArkType, or a
small custom validator with `parse` or `parseAsync`.

## Define a collection

**farm.config.ts**

```ts
import { collection, content, files } from "@farm.js/content";
import { defineConfig } from "@farm.js/core";
import { z } from "zod";

const post = z.object({
  title: z.string().min(1),
  description: z.string().min(1),
  publishedAt: z.coerce.date(),
  tags: z.array(z.string()).default([]),
  draft: z.boolean().default(false),
});

export default defineConfig({
  plugins: [
    content({
      collections: {
        posts: collection({
          source: files("content/posts/**/*.{md,mdx}"),
          schema: post,
          transform: ({ data, words }) => ({
            ...data,
            readingMinutes: Math.max(1, Math.ceil(words / 220)),
          }),
        }),
      },
    }),
  ],
});
```

Adding `content()` enables the plugin. Remove it from `plugins` to disable content processing. There
is no separate `enabled` flag.

Content source files must resolve inside the app root, including when a glob's directory contains a
symlink. Links to files elsewhere in the app keep their logical entry IDs and are watched for changes;
links that resolve outside the app are rejected before the file is read.

The schema validates frontmatter for Markdown and MDX. For JSON and YAML, it validates the complete
file value. `transform` runs after validation and can add or replace fields. Its output becomes the
inferred type returned to the application.

Markdown and MDX frontmatter is data only: use YAML (the default, or `---yaml` / `---yml`) or JSON
(`---json`). Executable formats such as `---js` and `---javascript` are rejected before parsing or
schema validation, including empty frontmatter blocks. Put computed metadata in `transform` instead.

Asset declarations are separate from `schema`. A schema string remains a string; Farm never changes
its output into image metadata behind the validator's back.

## Write content

**content/posts/hello-farm.md**

```md
---
title: Hello, Farm
description: A first typed post.
publishedAt: 2026-09-09
tags:
  - Farm.js
---

The Markdown body remains available separately from validated frontmatter.
```

## Manage local assets

Use `assets: true` when a collection only needs relative assets from Markdown or MDX body syntax:

```ts
collection({
  source: files("content/posts/**/*.{md,mdx}"),
  schema: z.object({ title: z.string() }),
  assets: true,
});
```

URL-encode filename characters such as `#`, `?`, and `%`: `[Guide](./guide%23v1.pdf)` refers to
`guide#v1.pdf`. Farm gives these assets a safe generated filename before passing them through Vite's
normal asset pipeline. Their original metadata names, URL query strings, fragments, and development
reload behavior are preserved. The generated copies live under `.farm/content/assets` and are
recreated when the app's generated `.farm` directory is cleared.

Farm recognizes inline and reference-style Markdown images and file links:

```md
![Architecture](./architecture.png)

[Download the guide](./guide.pdf)

![Release diagram][release-diagram]

[release-diagram]: ./release-diagram.svg
```

Reference labels follow Markdown's case-insensitive matching. If a label is defined more than
once, the first definition wins; later duplicates are left unchanged and do not add asset imports
or missing-file errors.

Relative image destinations are checked as real images with intrinsic dimensions. Relative file
links are managed when their destination has a file extension; extensionless links and links to
`.md` or `.mdx` remain content navigation. Remote URLs, root-relative URLs, fragments, data URLs,
and dynamic MDX expressions are left unchanged. Static `src` or `href` attributes inside JSX and
HTML are not interpreted in this first API; use Markdown syntax or a normal module import there.

For `.mdx` files, JavaScript strings, comments, imports/exports, and JSX attributes are not scanned
for Markdown assets. For example, `{"![Example](./missing.png)"}` stays literal code and does not
require that image to exist. Actual Markdown images and links nested inside JSX are still managed.
Farm parses MDX syntax without executing expressions or importing modules; invalid syntax reports
the content filename. Ordinary `.md` files keep Markdown parsing rules.

The body keeps its original Markdown except for managed destinations, which become content-hashed
public URLs. `entry.bodyAssets` contains the corresponding image or file metadata for a custom
Markdown component mapping. Missing assets report the content file plus the Markdown line and
column. Asset changes participate in development reloads.

### Type frontmatter assets

Declare frontmatter assets by the field names used by the application. They do not belong in the
ordinary schema:

```ts
import { asset, collection, content, files } from "@farm.js/content";
import { defineConfig } from "@farm.js/core";
import { z } from "zod";

export default defineConfig({
  plugins: [
    content({
      collections: {
        posts: collection({
          source: files("content/posts/**/*.{md,mdx}"),
          schema: z.object({
            title: z.string(),
            description: z.string(),
          }),
          assets: {
            image: asset.image().optional(),
            downloads: asset.files().default([]),
          },
        }),
      },
    }),
  ],
});
```

```md
---
title: Introducing Farm Content
description: A typed local publishing workflow.
image: ./preview.png
downloads:
  - ./guide.pdf
  - ./example.zip
---

The frontmatter remains concise while its resolved values are fully typed.
```

The asset fields are removed before the Standard Schema validator runs, resolved relative to the
content file, then merged back into `entry.data`:

```ts
post.data.title; // string
post.data.image; // ContentImageAsset | undefined
post.data.downloads; // readonly ContentFileAsset[]
```

`asset.image()` validates one required image. `asset.images()` validates an array of images;
`asset.file()` and `asset.files()` provide the equivalent generic-file forms. Add `.optional()` or
`.default(...)` when the frontmatter value may be omitted. Declarations can be nested to match a
nested frontmatter object.

Images expose `src`, `width`, `height`, `type`, `bytes`, `name`, and the original `source`. Their
shape can be passed directly to Farm's `Image` component. Files expose the same fields except for
image dimensions. All managed paths must be relative, resolve to ordinary files inside the Farm
project, and use forward slashes. Keep remote or public-root URLs in the ordinary schema when the
application intentionally owns those strings.

## Entry IDs

Entry IDs come from the path below the common static directory of the source globs. The extension
and a trailing `index` are removed:

| Source file                                | Entry ID                 |
| ------------------------------------------ | ------------------------ |
| `content/posts/hello-farm.md`              | `hello-farm`             |
| `content/posts/guides/getting-started.mdx` | `guides/getting-started` |
| `content/posts/guides/index.md`            | `guides`                 |

Pass `files(pattern, { base: "content" })` when the ID should use an explicit base. The `ignore`
option accepts one glob or an array of globs.

## Read typed entries

Use the server entry from a Server Component, static path function, server query, server function,
or API route.

```tsx
import { getCollection } from "@farm.js/content/server";

export default async function BlogPage() {
  const posts = (await getCollection("posts"))
    .filter((entry) => !entry.data.draft)
    .sort((left, right) => right.data.publishedAt.getTime() - left.data.publishedAt.getTime());

  return (
    <ul>
      {posts.map((entry) => (
        <li key={entry.id}>
          {entry.data.title} · {entry.data.readingMinutes} min
        </li>
      ))}
    </ul>
  );
}
```

Each entry contains:

| Field        | Meaning                                                                   |
| ------------ | ------------------------------------------------------------------------- |
| `id`         | Stable path-derived identifier used with `getEntry`.                      |
| `data`       | Validated schema output, followed by the optional transform.              |
| `body`       | Markdown or MDX body. Managed asset destinations contain emitted URLs.    |
| `bodyAssets` | Image and file metadata discovered from managed Markdown body syntax.     |
| `filePath`   | Project-relative source path, useful for diagnostics and editorial tools. |

Read one entry when the route already has its ID:

```ts
import { getEntry, getEntryOrThrow } from "@farm.js/content/server";

const optionalPost = await getEntry("posts", "guides/getting-started");
const post = await getEntryOrThrow("posts", "guides/getting-started");
```

`getEntry` returns `undefined` for a miss. `getEntryOrThrow` includes the collection and ID in its
error, which is usually better for a path Farm already generated from the same collection.

## Generate static pages

Use a catch-all route when IDs can contain `/`.

**src/app/posts/[...slug]/page.tsx**

```tsx
import { getCollection, getEntryOrThrow } from "@farm.js/content/server";
import type { PageProps } from "@farm.js/core";

export const dynamic = "force-static";

export async function getStaticPaths() {
  return (await getCollection("posts")).map((entry) => ({
    slug: entry.id.split("/"),
  }));
}

export default async function PostPage({ params }: PageProps<"/posts/[...slug]">) {
  const post = await getEntryOrThrow("posts", params.slug);
  return <article>{post.body}</article>;
}
```

## Use a hosted CMS

A collection's source does not have to be files. `remote()` is the one interface every CMS,
API, or database plugs into - implement it once and the rest of this page applies unchanged:
the same schema validation, transforms, generated types, and server reads and writes.

### The source interface

```ts
import { collection, content, remote } from "@farm.js/content";

source: remote({
  // names the source in error messages, e.g. "mycms:posts"
  name: "mycms:posts",

  // REQUIRED - resolve to an array of documents:
  //   id:   stable, route-friendly, unique in the collection (a slug)
  //   data: goes through the collection schema, so YOUR schema is the boundary
  //   body: optional Markdown, for entry.body and word counts
  fetch: async () => [{ id, data, body }],

  // OPTIONAL - dev only: re-fetch this often and reload when documents changed
  refreshInterval: 30_000,

  // OPTIONAL - the write surface, one callback per verb, same shape as fetch.
  // Each returns the provider's CONFIRMED document; omit any verb and
  // collections.<name>.<verb>() says exactly what is missing.
  create: async ({ data, body }) => confirmedDocument,
  update: async (id, patch) => confirmedDocument,
  delete: async (id) => {},
}),
```

That is the entire integration surface. Everything below holds for every provider:

- **Snapshot semantics.** Documents are fetched while the configuration loads, validated, and
  bundled into production output. Publishing means rebuilding: point the provider's webhook at a
  deploy hook. Entries sort by ID so fetch order never churns the generated module.
- **Development.** `refreshInterval` polls and reloads only when documents actually changed. A
  successful write rebuilds the snapshot immediately.
- **Writes.** `collections.<name>.create/update/delete` route through the callbacks, and the
  confirmed document is re-validated against the collection schema before anyone trusts it. In
  production the write lands in the CMS while the running deployment serves the bundled snapshot
  until the next build. Gate every write behind your own
  [server function](/docs/api-client): Farm never exposes content writes to the browser.
- **Limits.** Remote collections cannot declare [typed asset fields](#manage-local-assets),
  which resolve files on disk.

### Reading and writing entries

```ts
import { collections } from "@farm.js/content/server";

const posts = await collections.posts.all(); // the validated snapshot
await collections.posts.update("hello-world", { data: { title: "New title" } });
await collections.posts.create({ data: { title: "Drafted from the app" } });
await collections.posts.delete("old-post");
```

### Official sources

Provider packages implement the interface for you - each is one import plus its credentials, and
everything above applies as written:

| Provider   | Source                                        | Setup                                                                                     |
| ---------- | --------------------------------------------- | ----------------------------------------------------------------------------------------- |
| Sanity     | `sanitySource` from `@farm.js/sanity`         | [Sanity integration → Content collections](/docs/integrations/sanity#content-collections) |
| Contentful | `contentfulSource` from `@farm.js/contentful` | [below](#contentful-setup)                                                                |

#### Contentful setup

```ts
import { contentfulSource } from "@farm.js/contentful";

posts: collection({
  source: contentfulSource({ contentType: "post", query: { order: ["-sys.createdAt"] } }),
  schema: post,
});
```

- credentials resolve from `CONTENTFUL_SPACE_ID` and `CONTENTFUL_ACCESS_TOKEN`, or pass an
  existing client.
- entry IDs default to a string `fields.slug`, falling back to `sys.id`.
- pagination past Contentful's 1000-entry page cap is handled internally, bounded by the
  reported total; `limit` caps the total entries loaded. `content_type` and
  `skip` are owned by the source, everything else in `query` passes through.
- set `host: "preview.contentful.com"` with `CONTENTFUL_PREVIEW_TOKEN` to load drafts.
- the source is read-only for now: Contentful writes go through its separate management SDK in a
  [server function](/docs/api-client).

### Bring your own CMS

Any system that can answer "give me the documents" plugs in the same way - here is the whole
direction, using WordPress's REST API as a stand-in for whatever you run. No Farm package, no
SDK, just the interface:

```ts
import { collection, content, remote } from "@farm.js/content";

const wordpress = remote({
  name: "wp:posts",
  fetch: async () => {
    const posts = await fetch("https://blog.example.com/wp-json/wp/v2/posts?per_page=100").then(
      (response) => response.json(),
    );
    return posts.map((post) => ({
      id: post.slug, // 1. pick a stable, route-friendly id
      data: { title: post.title.rendered, publishedAt: post.date }, // 2. shape data for YOUR schema
      body: post.content.rendered, // 3. optional body
    }));
  },
  refreshInterval: 30_000, // 4. dev reloads when documents change

  // 5. optional: implement writes and collections.<name>.update() lights up
  update: async (id, patch) => {
    const updated = await wpApi(`posts?slug=${id}`, { method: "POST", body: patch.data });
    return { id, data: updated };
  },
});
```

The direction, in order:

1. **Map documents** to `{ id, data, body? }` in `fetch` - the provider's SDK, a REST call, a
   database query, anything.
2. **Pick stable IDs** - a slug beats an internal database id, because entry IDs become routes.
3. **Let your schema be the boundary** - shape `data` for the collection schema and consumers
   never learn which CMS is behind it. Swapping providers later changes only the `source:` line.
4. **Wire updates** - the provider's publish webhook points at your deploy hook, and
   `refreshInterval` covers development.
5. **Add writes when the app needs them** - one callback per verb, each returning the provider's
   confirmed document, gated behind your own server functions.

#### Even the official providers work this way

The packages under `@farm.js/*` are the same interface published with conventions attached -
nothing more. The callbacks are ordinary functions, so the natural way to bring your own Sanity
is your own `@sanity/client` instance: GROQ for reads (mutations are not GROQ - Sanity's mutation
API is `create`/`patch`/`delete`, in the SDK and over HTTP alike), and the client's mutation
methods for writes:

```ts
import { createClient } from "@sanity/client";

const sanity = createClient({
  projectId: process.env.SANITY_PROJECT_ID!,
  dataset: process.env.SANITY_DATASET!,
  apiVersion: "2026-03-01",
  token: process.env.SANITY_API_WRITE_TOKEN, // server-only; enables the write callbacks
  useCdn: false, // builds want the freshest documents
});

// The collection's read set and its id mapping, shared with the write
// callbacks below.
const postsQuery = `*[_type == "post"]{ _id, slug, title, publishedAt }`;
const entryId = (doc) => doc.slug?.current ?? doc._id;

// Mutations address `_id`, entry ids are slugs: resolve the bridge against the
// same query the collection reads, so a write can only ever land on a document
// this collection owns. A bare `*[slug.current == $id][0]._id` would happily
// resolve an unrelated `_type` that shares the slug - and then `delete` it.
const resolveDocumentId = async (id) => {
  const docs = await sanity.fetch(postsQuery);
  const match = docs.find((doc) => entryId(doc) === id);
  if (!match) throw new Error(`sanity:posts has no document for ${id}`);
  return match._id;
};

const sanityPosts = remote({
  name: "sanity:posts",

  fetch: async () => {
    const docs = await sanity.fetch(postsQuery);
    return docs.map((doc) => ({ id: entryId(doc), data: doc }));
  },

  update: async (id, patch) => {
    const updated = await sanity
      .patch(await resolveDocumentId(id))
      .set(patch.data)
      .commit();
    return { id, data: updated };
  },

  create: async ({ data }) => {
    const doc = await sanity.create({ _type: "post", ...data });
    return { id: entryId(doc), data: doc };
  },

  delete: async (id) => {
    await sanity.delete(await resolveDocumentId(id));
  },
});
```

This is, almost line for line, what `sanitySource` does for you - it only adds the env
conventions, this write-target resolution, and the error naming. The same also works with no SDK
at all, over Sanity's plain HTTP API:

```ts
const sanityApi = `https://${process.env.SANITY_PROJECT_ID}.api.sanity.io/v2026-03-01`;
const dataset = process.env.SANITY_DATASET;

const sanityPosts = remote({
  name: "sanity:posts",

  fetch: async () => {
    const query = encodeURIComponent(`*[_type == "post"]{ _id, slug, title, publishedAt }`);
    const { result } = await fetch(`${sanityApi}/data/query/${dataset}?query=${query}`).then(
      (response) => response.json(),
    );
    return result.map((doc) => ({ id: doc.slug?.current ?? doc._id, data: doc }));
  },

  // Mutations are one endpoint away; the write surface, schema re-validation,
  // and dev rebuild behave identically to the SDK version above.
  update: async (id, patch) => {
    const response = await fetch(`${sanityApi}/data/mutate/${dataset}?returnDocuments=true`, {
      method: "POST",
      headers: {
        "content-type": "application/json",
        authorization: `Bearer ${process.env.SANITY_API_WRITE_TOKEN}`,
      },
      body: JSON.stringify({
        mutations: [
          // Constrained to the collection's own `_type`, for the same reason
          // the SDK version resolves against its read query.
          {
            patch: {
              query: `*[_type == "post" && slug.current == $id][0]`,
              params: { id },
              set: patch.data,
            },
          },
        ],
      }),
    });
    const { results } = await response.json();
    return { id, data: results[0].document };
  },
});
```

Contentful reads inline the same way:

```ts
fetch: async () => {
  const url =
    `https://cdn.contentful.com/spaces/${process.env.CONTENTFUL_SPACE_ID}` +
    `/environments/master/entries?access_token=${process.env.CONTENTFUL_ACCESS_TOKEN}&content_type=post`;
  const { items } = await fetch(url).then((response) => response.json());
  return items.map((item) => ({ id: item.fields.slug ?? item.sys.id, data: item.fields }));
},
```

What the inline versions leave to you is exactly what the packages add: pagination past provider
page caps, linked-entry resolution, slug-to-id mapping for writes, lazy credential errors, and
preview switches. Use whichever tier fits - they are interchangeable, and this path is covered by
the plugin's own test suite, so it stays working.

A dedicated `@farm.js/<provider>` package is only ever ergonomics on top of this: conventional
env vars, a default id, provider-aware errors. Start with `remote()`; extract the package when
the conventions settle.

### Publishing a source package

Anyone can ship an adapter - a CMS vendor, a community author. Farm's own `sanitySource` and
`contentfulSource` use nothing a third party cannot: they import exactly two **type-only** names
from `@farm.js/content` and return the plain `remote()` shape. The conventions that make an
adapter feel first-party:

```ts
import type { ContentRemoteDocument, ContentRemoteSource } from "@farm.js/content";

export function myCmsSource(options: MyCmsSourceOptions): ContentRemoteSource {
  // ...
}
```

1. **Type-only dependency.** Import only types from `@farm.js/content`; declare it as an
   optional peer. Your package then has zero runtime coupling to Farm and works with every
   version that keeps the shape.
2. **The provider SDK is a peer dependency** with literal imports, so bundlers can trace it and
   apps control the version.
3. **Resolve credentials lazily**, from conventional env vars (`MYCMS_TOKEN`) with explicit
   options and an existing-client escape hatch. Importing `farm.config.ts` without the env set
   must not throw; the first `fetch()` should, with a message naming the env vars.
4. **Pick route-friendly default IDs** (a slug over an internal id) and document the fallback.
   If writes address documents differently than reads identify them, resolve the mapping inside
   the source, the way `sanitySource` maps slugs back to `_id`.
5. **Name errors after yourself.** Every thrown message should carry the source `name`, so a
   failing build says which provider and which document.
6. **Implement writes only when the provider can confirm them.** Each verb returns the stored
   document, which Farm re-validates against the collection schema. Omit verbs you cannot
   honor - the collection handle explains what is missing better than a broken write.
7. **Test against the real SDK shape** - the reference suites in `packages/farm-sanity` and
   `packages/farm-contentful` cover fetching, id derivation, pagination, credential failures,
   and write flows, and are the template worth copying.

An adapter that follows these is indistinguishable from an official one. Open a pull request to
add it to the table above.

For **live content** that must update without a rebuild, keep the provider SDK in a server-only
module and fetch through a [Server Query](/docs/server-queries) instead. Farm can validate the
response, cache published content, and share the typed result with server or client consumers.

For example, a Sanity-backed query can stay small:

**src/lib/cms.ts**

```ts
import { createServerQuery } from "@farm.js/core/server-query";
import { createClient } from "@sanity/client";
import { z } from "zod";

const Post = z.object({
  id: z.string(),
  title: z.string(),
  slug: z.string(),
});

const sanity = createClient({
  projectId: process.env.SANITY_PROJECT_ID!,
  dataset: process.env.SANITY_DATASET!,
  apiVersion: "2026-09-09",
  useCdn: true,
});

export const cmsPosts = createServerQuery({
  output: z.array(Post),
  key: () => ["cms", "posts"],
  staleTime: "5m",
  handler: () =>
    sanity.fetch(`*[_type == "post"]{
      "id": _id,
      title,
      "slug": slug.current
    }`),
});
```

A Server Component can call `await cmsPosts()`. Point a verified CMS webhook at a Farm API route
and call `await invalidate(["cms", "posts"])` when published content changes; see
[Cache and PPR](/docs/cache-ppr). For draft previews, use a server-only read token, bypass the
provider CDN, authorize the request, and avoid a shared persistent cache. Never expose a CMS token
to a Client Component.

The same shape works with another provider: replace the client and query language, while keeping
the server-only boundary, output schema, cache key, and invalidation path. See the
[Sanity JavaScript client guide](https://www.sanity.io/docs/apis-and-sdks/js-client-getting-started)
for provider-specific setup.

## Type generation

Farm adds content inference to the same `src/farm.d.ts` used for routes and environment values. It
reads the plugin type from `farm.config.ts`, so collection keys, Zod output, coerced `Date` values,
and transform results flow into `getCollection` and `getEntry` without a handwritten registry.

Development and production builds refresh the file automatically. Run this after changing config
if an editor is open before the development server:

```bash
farm generate
```

Commit `src/farm.d.ts` when the application already commits Farm's generated declarations.

## Split a large setup

An inline config is the shortest starting point. If collections grow, extract ordinary values to a
server-owned application module rather than introducing another framework config:

**src/content.ts**

```ts
import { collection, files } from "@farm.js/content";
import { z } from "zod";

export const collections = {
  posts: collection({
    source: files("content/posts/**/*.md"),
    schema: z.object({ title: z.string() }),
  }),
};
```

**farm.config.ts**

```ts
import { content } from "@farm.js/content";
import { defineConfig } from "@farm.js/core";
import { collections } from "./src/content";

export default defineConfig({ plugins: [content({ collections })] });
```

The plugin still receives the collection through its options. The extra file is only organization
chosen by the application.

## Rendering and security

The plugin returns `body` text and does not execute MDX or emit HTML automatically. Without the
`assets` option, the body is unchanged. With asset handling enabled, only recognized Markdown
destinations are rewritten. This keeps the collection renderer-neutral and lets the application
choose its component mapping, Markdown renderer, and sanitization policy. Treat author-provided
HTML as untrusted unless the source is controlled and the chosen renderer sanitizes it.

`@farm.js/content/server` fails the client build when imported into a browser environment. Use a
[Server Query](/docs/server-queries) or API route when a Client Component needs content data.

## Development and production

- Development regenerates content after supported source files are added, changed, or removed, then
  reloads the current page. Referenced asset changes do the same. Validation failures appear in
  Vite's error overlay with the collection, source file, field path or Markdown position, and
  validation message.
- Production serializes only validated values into a private `.farm/content/server.mjs` build input.
  Nitro bundles that module, so the deployed server does not read project source files per request.
  Generated output must stay inside the app root. Outside or dangling output symlinks fail before
  Farm creates directories or writes files; output-directory links within the app remain supported.
- Dates and bigints keep their runtime types. Plain objects, arrays, `undefined`, and primitives are
  preserved. Functions, symbols, class instances, circular structures, invalid dates, and
  non-finite numbers fail the build instead of being silently changed.

The runnable
[`examples/content-demo`](https://github.com/farming-labs/farm.js/tree/main/examples/content-demo)
shows inline configuration, Zod coercion, computed reading time, nested IDs, static generation, and
the finished editorial UI.

## Sitemap

See the full [sitemap](/sitemap.md) for all pages.
Docs-scoped sitemap: [/docs/sitemap.md](/docs/sitemap.md).
Well-known sitemap: [/.well-known/sitemap.md](/.well-known/sitemap.md).
