content
Product docs, guides and changelogs start as markdown files, and turning them into pages with a sidebar, highlighted code and search is a project of its own. @kitr/content renders each folder of markdown at build time into typed JSON that your routes load with loadPage('docs', slug). It builds on unified and Shiki, and adds collections, a nav.json sidebar, callouts, package-manager command blocks, search and llms.txt for coding agents.
Rendering happens in kitr generate, so the browser downloads HTML and JSON, never a markdown parser. The components run on both sides. The examples follow Acme's docs at docs.acme.com. For records that users create and edit, use a database with db instead.
Install
kitr add contentFirst time? Run npm i -g @kitr/kitr once. Inside a project, the global kitr runs your project's own version.
Everything kitr add changes
What kitr add does:
- Installs
@kitr/contentand adds it topackagesinkitrrc.ts. It registers no provider. - Turns on
kitr generate, sokitr dev,kitr buildandkitr typecheckrender the markdown into.kitr/content/. - Writes
config/content.ts(shown in Configure) and regeneratesconfig/index.ts. - Writes
content/docs/nav.jsonand a first page,content/docs/getting-started.md. - Adds the content Vite plugin, which serves
llms.txtin dev and writes it in the build. - Adds a
## kitr: contentsection toAGENTS.mdon frontmatter,nav.json,loadPage()and the markdown extras.
It asks no questions and adds no env variables. The docs kit includes content and adds the sidebar, search and pages.
kitr remove content removes the packages entry, the dependency and the AGENTS.md section. It leaves content/ and your routes in place.
Configure
kitr add content writes this file:
import { defineConfig } from '@kitr/content'
export default defineConfig({
collections: {
// content/docs/*.md, served at /docs/<slug>; nav.json orders and groups them.
docs: { source: 'content/docs', url: '/docs', nav: 'nav.json' },
},
// Serve and build llms.txt, llms-full.txt and <url>.md of every page, for coding agents.
llms: true,
})kitr generate runs this file with the project's TypeScript, so it can import helpers and compute values. The app never reads it at run time.
| Option | Default | Description |
|---|---|---|
collections |
none | Collections by name; the name is what loadPage(name, slug) and nav[name] use. |
collections.<name>.source |
none | The folder of *.md files, from the project root. |
collections.<name>.url |
none | The URL prefix: /docs serves cli.md at /docs/cli. |
collections.<name>.nav |
none | A JSON file in source that orders and groups pages. |
collections.<name>.title |
the name, capitalised | The group title when there is no nav. |
llms |
false |
Write llms.txt, llms-full.txt and <url>.md; true or { title, description, url }. |
llms.title |
package name as words | The # heading of llms.txt: acme-docs becomes Acme Docs. |
llms.description |
none | The summary line under the heading. |
llms.url |
relative links | The site origin, such as https://docs.acme.com, for absolute links. |
code.themes |
github-light-high-contrast, github-dark |
Shiki themes for light and dark. |
code.langs |
ts, tsx, js, jsx, json, sh, bash, css, html, md, yaml, diff | Languages to load; others render as plain text. |
editUrl |
none | The start of an "Edit this page" link; the file path is appended. |
There are no env variables.
Render a page
A page is a markdown file with title and description frontmatter. Acme's guide to invoices:
---
title: Send an invoice
description: Create an invoice for a team and email it.
---
# Send an invoice
Invoices belong to a team. Create one from the billing page.
## Email it
Grace gets a copy at [email protected].List its slug in content/docs/nav.json, then load it in the route's loader. On the server and in the client, loadPage() imports only that page's JSON:
import { defineRoutes } from '@kitr/kitr/app'
import { notFound } from '@kitr/router'
import router from '@kitr/router/routes'
import { seo } from '@kitr/seo'
import { loadPage } from '#kitr/content'
export const routes = defineRoutes([router], (r) =>
r.page('/docs/$slug', {
loader: async ({ params }) => {
const page = await loadPage('docs', params.slug)
if (!page) throw notFound()
return page
},
head: (ctx) => seo({ type: 'article' }, ctx),
lazy: () => import('./pages/docs/slug'),
}),
)Render it with <DocBody> in a component:
import { DocBody, Toc } from '@kitr/content/react'
import { getRouteApi } from '@kitr/router'
const route = getRouteApi('/docs/$slug')
export default function DocPage() {
const page = route.useLoaderData()
return (
<main className="mx-auto flex max-w-5xl gap-10 px-4 py-12">
<article className="min-w-0 flex-1">
<h1 className="text-3xl font-bold">{page.title}</h1>
<DocBody page={page} />
</article>
<Toc page={page} />
</main>
)
}/docs/send-an-invoice shows the title, the body and an "On this page" list with "Email it" marked while you read it. The leading # heading is left out of the body because the layout renders page.title. A slug with no file returns null, and the loader's notFound() renders the route's not-found component.
Order and group pages
nav.json lists the groups of the sidebar and the pages in each, in order. A page missing from it is not built:
[
{
"title": "Billing",
"description": "Invoices, plans and payments.",
"pages": ["send-an-invoice", { "slug": "plans", "title": "Plans and seats" }]
}
]An entry is a slug or { slug, title?, description?, source? }; source points at a file outside the folder, so two collections can share a page. A collection without nav lists every *.md in one group, sorted by the frontmatter order, then by title.
nav.docs from #kitr/content is that file, typed. Slug<'docs'> is the union of its slugs, so a link to a missing page fails kitr typecheck:
import { Link } from '@kitr/router'
import { nav, type Slug } from '#kitr/content'
const featured: Slug<'docs'> = 'send-an-invoice'
export function BillingLinks() {
return (
<ul>
{nav.docs.flatMap((group) => group.items).map((item) => (
<li key={item.slug}>
<Link to="/docs/$slug" params={{ slug: item.slug }}>
{item.slug === featured ? <strong>{item.title}</strong> : item.title}
</Link>
</li>
))}
</ul>
)
}Highlight code
Shiki highlights every fenced block for both themes at build time. The first line names the file when it is a path comment, and it becomes the block's caption:
export const total = (lines: { amount: number }[]) =>
lines.reduce((sum, line) => sum + line.amount, 0)The page renders a server/billing/invoice.ts caption above the code, without the comment line, and a Copy button. The CSS variables switch colours when <html data-theme="dark"> is set, which theme does. Add languages or change themes in code; a fence in a language that is not loaded renders as plain text.
Show commands for every package manager
A sh block whose lines all start with kitr becomes a command block instead of HTML:
kitr add mailFirst time? Run npm i -g @kitr/kitr once. Inside a project, the global kitr runs your project's own version.
<DocBody> renders it as a plain code block. Pass renderCommand to show a picker; the registry's command component, which the docs kit installs, shows npm, pnpm, yarn and bun tabs and remembers the choice:
import type { Page } from '@kitr/content'
import { DocBody } from '@kitr/content/react'
import { Command } from '#components/ui/command'
export function Body({ page }: { page: Page }) {
return <DocBody page={page} renderCommand={(cmd) => <Command cmd={cmd} />} />
}Write callouts, steps and next steps
Markdown extras that render without any component:
| Write | Renders as |
|---|---|
> [!NOTE], > [!TIP], > [!WARNING], > [!SUMMARY] |
A callout; text after the marker is its title. |
## 1. Create the team |
A numbered step; the number leaves the heading. |
A last ## Next steps with one list of links |
Cards, one per link. |
[the CLI](./cli.md#flags) |
A link to /docs/cli#flags. |
A link to https:// |
Opens in a new tab with rel="noreferrer". |
| A table | A table that scrolls sideways on phones. |
Every h2 and h3 gets an id, and the h2s and h3s fill page.toc. Link between pages by file name, so links keep working in your editor and on GitHub.
Search the pages
kitr generate writes a search index with one entry per page and per h2 or h3. search() ranks it: titles first, then headings, then text, at most two hits per page:
import { search } from '@kitr/content'
import { loadSearch } from '#kitr/content'
export async function find(query: string) {
const entries = await loadSearch()
return search(entries, query, 5)
}
// await find('invoice')
// => [{ url: '/docs/send-an-invoice', title: 'Send an invoice', section: 'Billing', heading: '', text: '...' }]loadSearch() downloads the index on first use only. The registry's search-dialog component wraps this in a dialog that opens on Cmd+K.
Serve llms.txt
With llms: true, kitr dev answers /llms.txt, /llms-full.txt and every page's markdown at <url>.md, and kitr build writes the same files next to the HTML:
curl http://localhost:3000/llms.txt
# # Acme Docs
#
# ## Billing
#
# - [Send an invoice](/docs/send-an-invoice.md): Create an invoice for a team and email it.Coding agents read the markdown instead of scraping HTML. Set llms.url to make the links absolute.
Testing
Vitest runs kitr generate before the tests, so #kitr/content is current. Load a page and render it like any component:
import { search } from '@kitr/content'
import { DocBody } from '@kitr/content/react'
import { render } from '@testing-library/react'
import { expect, it } from 'vitest'
import { loadPage, loadSearch } from '#kitr/content'
it('renders the getting started page', async () => {
const page = await loadPage('docs', 'getting-started')
expect(page).toMatchObject({ url: '/docs/getting-started', title: 'Getting started' })
const view = render(<DocBody page={page!} />)
expect(view.getByRole('heading', { name: 'Add a page' })).toBeTruthy()
})
it('finds the page by its heading', async () => {
const [hit] = search(await loadSearch(), 'add a page')
expect(hit?.url).toBe('/docs/getting-started#add-a-page')
})
it('has no page for an unknown slug', async () => {
expect(await loadPage('docs', 'no-such-page')).toBeNull()
})<DocBody> works without a router; inside one, it turns clicks on internal links into client-side navigation.