diff --git a/docs/chronicle.yaml b/docs/chronicle.yaml
index 305a6594..61ca1385 100644
--- a/docs/chronicle.yaml
+++ b/docs/chronicle.yaml
@@ -2,6 +2,12 @@ site:
title: Chronicle
description: Config-driven documentation framework
+url: https://chronicle.raystack.org
+
+logo:
+ light: /logo.svg
+ dark: /logo-dark.svg
+
content:
- dir: docs
label: Docs
@@ -18,6 +24,30 @@ search:
enabled: true
placeholder: Search docs...
+# The docs were reorganised into groups. These keep every old link working.
+redirects:
+ - from: /docs/features
+ to: /docs
+ permanent: true
+ - from: /docs/cli
+ to: /docs/reference/cli
+ permanent: true
+ - from: /docs/configuration
+ to: /docs/reference/config
+ permanent: true
+ - from: /docs/frontmatter
+ to: /docs/reference/frontmatter
+ permanent: true
+ - from: /docs/components
+ to: /docs/writing/components
+ permanent: true
+ - from: /docs/image-optimization
+ to: /docs/writing/images
+ permanent: true
+ - from: /docs/docker
+ to: /docs/deploy/docker
+ permanent: true
+
telemetry:
enabled: true
diff --git a/docs/content/docs/deploy/build.mdx b/docs/content/docs/deploy/build.mdx
new file mode 100644
index 00000000..55a9151a
--- /dev/null
+++ b/docs/content/docs/deploy/build.mdx
@@ -0,0 +1,107 @@
+---
+title: Build and serve
+description: What a production build produces, and how presets change it.
+order: 1
+---
+
+```bash
+chronicle build
+chronicle start
+```
+
+`build` writes the production site. `start` serves what `build` produced — it
+does not build for you, so the order matters. `chronicle serve` runs both, which
+is handy locally and wrong in a deployment, where you want the build to happen
+once and the server to start many times.
+
+## Two kinds of output
+
+The `preset` decides which you get, and it is the only decision that really
+matters here.
+
+**A server build** produces a small server. Pages are rendered per request,
+which is what makes search, image resizing and the API request tester work.
+This is the default.
+
+**A static build** produces a single-page app: one `index.html`, a JavaScript
+bundle, and a small JSON file per page that the app fetches as the reader
+navigates. There is no process to run and nothing to keep alive.
+
+```yaml
+preset: static
+```
+
+Or per build:
+
+```bash
+chronicle build --preset static
+```
+
+## What you give up going static
+
+There is no server, so the parts that needed one change:
+
+| | Server build | Static build |
+|---|---|---|
+| Page HTML | Rendered per request, works with JavaScript off | One shell; pages filled in by JavaScript |
+| Search | Queried per request | Whole index downloaded, searched in the browser |
+| Images | Resized on demand, then cached | Resized once, during the build |
+| API request tester | Proxies through your server | Not available |
+| Health and readiness | `/api/health`, `/api/ready` | Not available |
+| Redirects | Served by Chronicle | Your host has to do them |
+
+Everything else is generated either way: navigation, versions, the API
+reference, markdown URLs, `llms.txt`, the sitemap and social cards.
+
+The first row matters most. A server build sends finished HTML, so a crawler or
+a reader with JavaScript off gets the page. A static build sends a shell, so
+they get very little. If search ranking matters to you, that is the argument for
+a server build.
+
+Pick static for a small or medium site you want on a CDN with nothing to
+operate. Pick a server build for a large site, when search needs to stay fast,
+or when the request tester matters.
+
+## Where the output goes
+
+| Preset | Output directory |
+|---|---|
+| unset, `node-server`, `cloudflare` | `.output/` |
+| `static`, `cloudflare-pages`, `github-pages` | `.output/public/` |
+| `vercel`, `vercel-static` | `.vercel/output/` |
+
+A static host has to send unknown paths to `index.html`, because every page
+shares that one file. Getting this wrong is the usual reason a deep link 404s
+while the home page works — see [Deploy](/docs/deploy/hosting).
+
+Gitignore whichever applies. `chronicle init` adds `.output` for you.
+
+## Build failures
+
+The build stops on a page it cannot parse, and names the file and the line. This
+is deliberate — a docs site that silently drops a broken page is worse than one
+that refuses to build.
+
+The usual cause is MDX being stricter than markdown about `<` and `{`, which it
+reads as the start of a component or an expression. Wrap the character in
+backticks.
+
+## In CI
+
+```bash
+bun install
+bun run chronicle build --preset static
+```
+
+Then publish the output directory. The build needs no network access beyond
+installing packages, and no services.
+
+If your build machine is not the machine that serves the site, remember `start`
+needs the `.output/` directory that `build` wrote — copy it, or build on the
+host.
+
+## Next
+
+- [Deploy](/docs/deploy/hosting) — putting the output on a host
+- [Docker](/docs/deploy/docker) — running from the container image
+- [Monitoring](/docs/deploy/monitoring) — health checks and metrics
diff --git a/docs/content/docs/docker.mdx b/docs/content/docs/deploy/docker.mdx
similarity index 94%
rename from docs/content/docs/docker.mdx
rename to docs/content/docs/deploy/docker.mdx
index 75eaf10a..edb3cdec 100644
--- a/docs/content/docs/docker.mdx
+++ b/docs/content/docs/deploy/docker.mdx
@@ -1,11 +1,9 @@
---
title: Docker
-description: Run Chronicle with Docker.
-order: 8
+description: Run Chronicle from the official container image.
+order: 3
---
-# Docker
-
Chronicle is available as a Docker image on [Docker Hub](https://hub.docker.com/r/raystack/chronicle).
## Pull the Image
diff --git a/docs/content/docs/deploy/hosting.mdx b/docs/content/docs/deploy/hosting.mdx
new file mode 100644
index 00000000..a35cdde7
--- /dev/null
+++ b/docs/content/docs/deploy/hosting.mdx
@@ -0,0 +1,186 @@
+---
+title: Deploy
+description: Put a built site on Vercel, Cloudflare, a Node host, or any static host.
+order: 2
+---
+
+Pick the preset that matches your host, build, and publish the output. The
+preset is the only Chronicle-specific part — everything after it is your host's
+normal workflow.
+
+Set it in the config so every build agrees:
+
+```yaml
+preset: vercel
+```
+
+Or pass it per build, which is what you want if the same repository deploys to
+more than one place:
+
+```bash
+chronicle build --preset static
+```
+
+## Vercel
+
+```yaml
+preset: vercel
+```
+
+```bash
+chronicle build
+```
+
+The build writes `.vercel/output`, which is the directory Vercel deploys
+directly. Set the build command to `chronicle build` and leave the output
+directory at its default.
+
+For a site that does not need per-request search or the API request tester, use
+`vercel-static` instead. Same output location, built as a single-page app.
+
+## Cloudflare
+
+For Workers:
+
+```yaml
+preset: cloudflare
+```
+
+The build writes `.output/`. Deploy it with Wrangler.
+
+For Pages, use the static preset built for it:
+
+```yaml
+preset: cloudflare-pages
+```
+
+That writes `.output/public/`. Point your Pages project at that directory.
+
+## A Node host
+
+Any host that runs a Node process — a VM, a container platform, a PaaS.
+
+```yaml
+preset: node-server
+```
+
+```bash
+chronicle build
+chronicle start --port 3000 --host 0.0.0.0
+```
+
+`--host 0.0.0.0` matters. The default binds to localhost, which works on your
+machine and refuses connections from outside a container.
+
+Point the platform's health check at `/api/health`, and its readiness check at
+`/api/ready` if it has a separate one. See
+[Monitoring](/docs/deploy/monitoring).
+
+There is a container image if you would rather not build your own — see
+[Docker](/docs/deploy/docker).
+
+## GitHub Pages
+
+```yaml
+preset: github-pages
+```
+
+The build writes `.output/public/`. A workflow that builds and publishes it:
+
+```yaml
+name: docs
+on:
+ push:
+ branches: [main]
+
+permissions:
+ contents: read
+ pages: write
+ id-token: write
+
+jobs:
+ build:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ - uses: oven-sh/setup-bun@v2
+ - run: bun install
+ - run: bun run chronicle build --preset github-pages
+ # GitHub Pages cannot rewrite unknown paths, but it does serve 404.html
+ # for them — so a copy of the app shell makes deep links work.
+ - run: cp .output/public/index.html .output/public/404.html
+ - uses: actions/upload-pages-artifact@v3
+ with:
+ path: .output/public
+ deploy:
+ needs: build
+ runs-on: ubuntu-latest
+ environment: github-pages
+ steps:
+ - uses: actions/deploy-pages@v4
+```
+
+## Any other static host
+
+```yaml
+preset: static
+```
+
+`chronicle build` writes `.output/public/`. Upload it to S3, Netlify, a CDN, or
+an nginx document root. There is nothing to run.
+
+### Send unknown paths to index.html
+
+This one step catches most people. A static build is a single-page app, so every
+page is served by the same `index.html`. Without a rewrite rule the home page
+works and `/docs/quick-start` returns a 404.
+
+On nginx:
+
+```nginx
+location / {
+ try_files $uri $uri/ /index.html;
+}
+```
+
+On Netlify, a `_redirects` file in the published directory:
+
+```
+/* /index.html 200
+```
+
+Most CDNs call this a "SPA fallback" or "rewrite to index". Whatever the name,
+it is the same rule.
+
+## Set `url` before you ship
+
+```yaml
+url: https://docs.example.com
+```
+
+Without it there are no absolute URLs for the sitemap, no canonical link tags,
+and no social cards — a social network needs an absolute address to fetch a card
+image from. It is the single easiest thing to forget and the one most visible
+once the site is public.
+
+## Redirects survive the move
+
+If your docs used to live somewhere else, or you moved pages during the
+migration, `redirects` in the config are served by Chronicle itself. They work
+the same on every server preset.
+
+Static presets have no server to run them, so on a static host use the host's
+own redirect mechanism — the same place you configured the rewrite above. See
+[Links and redirects](/docs/writing/links).
+
+## A checklist
+
+Before you call it done:
+
+- `url` is set
+- `/sitemap.xml` and `/robots.txt` return something sensible
+- Search finds a page you know exists
+- A shared link shows a social card
+- Old URLs still resolve, if any moved
+- Health check points at `/api/health`, on a server build
+- A deep link like `/docs/quick-start` loads directly, on a static build
diff --git a/docs/content/docs/deploy/meta.json b/docs/content/docs/deploy/meta.json
new file mode 100644
index 00000000..d1fda0e2
--- /dev/null
+++ b/docs/content/docs/deploy/meta.json
@@ -0,0 +1 @@
+{ "title": "Deploy and operate", "order": 8 }
diff --git a/docs/content/docs/deploy/monitoring.mdx b/docs/content/docs/deploy/monitoring.mdx
new file mode 100644
index 00000000..e339b40f
--- /dev/null
+++ b/docs/content/docs/deploy/monitoring.mdx
@@ -0,0 +1,120 @@
+---
+title: Monitoring
+description: Health checks, Prometheus metrics, and page analytics for a running site.
+order: 4
+---
+
+Everything here applies to a server build. A static site has no server, so it has
+nothing to report — see [Build and serve](/docs/deploy/build).
+
+## Health and readiness
+
+Two endpoints, and they answer different questions. Most platforms want both.
+
+### /api/health
+
+Is the process alive? Always returns `200` while the server is running. Wire
+your platform's liveness check here — if this stops answering, restart the
+process.
+
+```bash
+curl -i https://docs.example.com/api/health
+```
+
+### /api/ready
+
+Is it ready to serve properly? Returns `200` once the search index has finished
+building, and `503` before that.
+
+```json
+{ "status": "ready", "search": true }
+```
+
+```json
+{ "status": "not_ready", "search": false }
+```
+
+The first request to this endpoint is what starts the index building, in the
+background. A large site is briefly up but not ready. Point your readiness or
+startup check here and a rolling deploy will not send traffic to an instance
+whose search is still empty — and will get the indexing under way.
+
+Using `/api/health` for both is the common mistake. It reports success
+immediately, so traffic arrives before search works.
+
+## Prometheus metrics
+
+Off by default. Turn it on and metrics are exported on a separate port:
+
+```yaml
+telemetry:
+ enabled: true
+ serviceName: my-docs
+ port: 9090
+```
+
+```bash
+curl http://localhost:9090/metrics
+```
+
+The output is standard Prometheus exposition format, carrying request counts,
+status codes and durations by route.
+
+The separate port is deliberate — it lets you scrape metrics from inside your
+network without exposing them on the public site. Do not publish port 9090.
+
+A scrape config:
+
+```yaml
+scrape_configs:
+ - job_name: docs
+ static_configs:
+ - targets: ['docs-internal:9090']
+```
+
+`serviceName` is the name your traces and metrics are tagged with. Set it if you
+run more than one docs site, or every one of them reports as `chronicle`.
+
+## Page analytics
+
+Separate from the above, and about readers rather than the server:
+
+```yaml
+analytics:
+ enabled: true
+ googleAnalytics:
+ measurementId: G-XXXXXXXXXX
+```
+
+Both fields are needed — `enabled: true` on its own does nothing without a
+measurement ID.
+
+Page views are tracked as readers navigate, including navigation within the site
+that never reloads the page.
+
+### Outbound link tracking
+
+Links in the sidebar footer, set through `links`, are tagged with UTM parameters
+so the destination can see where the visit came from:
+
+| Parameter | Value |
+|---|---|
+| `utm_source` | The docs site's hostname |
+| `utm_medium` | Your site title, slugified |
+| `utm_content` | The path the reader clicked from |
+
+This needs nothing turned on and works whether or not analytics is enabled. It
+is the destination's analytics that benefits. See
+[`links`](/docs/reference/config).
+
+## What to watch
+
+For a docs site, the numbers worth an alert are few:
+
+- `/api/health` failing — the process is gone
+- `/api/ready` stuck at `503` well past startup — the index is failing to build
+- A rise in 404s — usually a moved page with no redirect. See
+ [Links and redirects](/docs/writing/links)
+
+Response times rarely need watching. Pages are rendered from files already in
+memory, and there is no database behind them.
diff --git a/docs/content/docs/features.mdx b/docs/content/docs/features.mdx
deleted file mode 100644
index 21551220..00000000
--- a/docs/content/docs/features.mdx
+++ /dev/null
@@ -1,146 +0,0 @@
----
-title: Features
-description: Overview of Chronicle features
-order: 2
----
-
-# Features
-
-Chronicle is a self-hosted documentation platform built with Vite + Nitro.
-
-## Content
-
-- **MDX support** — write documentation in MDX with React component embedding
-- **Frontmatter** — `title`, `short`, `description`, `identifiers`, `order`, `icon`, `lastModified`, `authors`, `draft`
-- **Directory metadata** — `meta.json` for folder titles, ordering, and sidebar config
-- **Remark plugins** — directives, admonitions, image resolution, link resolution, mermaid, reading time
-- **Syntax highlighting** — powered by Shiki via Apsara CodeBlock
-- **Versioning** — multiple documentation versions with URL-based routing
-
-## API Reference
-
-- **OpenAPI / Swagger support** — auto-generates API reference pages from specs (OpenAPI 3.x and Swagger 2.0)
-- **Read-only overview** — field names, types, required badges, examples, response schemas
-- **Playground dialog** — test requests with editable fields, JSON body editor, auth switching
-- **Auth types** — API Key, Bearer Token, Basic Auth (auto-detected from spec `securitySchemes`)
-- **Code snippets** — cURL, Python, Go, TypeScript with language switcher
-- **Response panel** — status code tabs with JSON syntax highlighting
-- **`.md` export** — every API endpoint has a `.md` URL with full documentation
-
-## Navigation
-
-- **Sidebar** — auto-generated from file structure, configurable via `meta.json`
-- **Breadcrumbs** — shows path hierarchy for docs and API pages
-- **Prev/Next** — arrow navigation between pages and API endpoints
-- **Search** — full-text search with SQLite FTS5 across titles, headings, and body content
-- **Folder sorting** — via `order` in `meta.json`
-- **Page sorting** — via `order` in frontmatter or `pages` array in `meta.json`
-
-## Themes
-
-- **Default theme** — sidebar + content layout with sub-navigation bar
-- **Paper theme** — book-style single-column with reading progress
-- **Fanfold theme** — continuous-form line printer paper, monospace throughout
-- **Dark/light mode** — system preference or manual toggle
-
-## SEO & AI
-
-- **Meta tags** — auto-generated title, description, Open Graph, Twitter Card
-- **Sitemap** — auto-generated `sitemap.xml`
-- **robots.txt** — auto-generated
-- **llms.txt** — AI-discoverable documentation index
-- **`.md` URLs** — every page (docs and API) has a markdown URL for AI tools
-- **Open in AI** — copy as markdown, open in ChatGPT or Claude
-
-## Developer Experience
-
-- **CLI** — `chronicle dev`, `chronicle build`, `chronicle start`
-- **Hot reload** — instant updates during development
-- **Monorepo support** — works as a package in monorepos
-- **Docker support** — containerized deployment
-- **Zod-validated config** — `chronicle.yaml` with schema validation
-
-## Redirects
-
-Configure URL redirects in `chronicle.yaml` for migrating old routes.
-
-```yaml
-redirects:
- - from: /old-page
- to: /docs/getting-started
- - from: /legacy/api
- to: /apis
- permanent: true
-```
-
-- `permanent: false` (default) — 307 temporary redirect
-- `permanent: true` — 308 permanent redirect
-
-See [Configuration](/docs/configuration#redirects) for full reference.
-
-## Sorting
-
-### Pages
-
-Add `order` to frontmatter. Lower numbers appear first.
-
-```mdx
----
-title: Introduction
-order: 1
----
-```
-
-Or use `pages` array in `meta.json`:
-
-```json
-{
- "pages": ["introduction", "installation", "configuration"]
-}
-```
-
-### Folders
-
-Add `order` to the folder's `meta.json`:
-
-```json
-{
- "title": "Getting Started",
- "order": 1
-}
-```
-
-Folder sorting is controlled only by `meta.json` `order`. The index page frontmatter `order` does not affect folder position.
-
-## Markdown URLs
-
-Every page has a `.md` URL that returns raw markdown:
-
-- **Docs pages** — `/{slug}.md` returns the raw MDX content
-- **API endpoints** — `/apis/{spec}/{operationId}.md` generates markdown with parameters, examples, responses, and cURL
-
-```bash
-curl https://docs.example.com/docs/getting-started.md
-curl https://docs.example.com/apis/petstore/findPetsByStatus.md
-```
-
-The "Open in AI" dropdown uses these URLs to copy markdown, open in ChatGPT, or open in Claude.
-
-## API Reference Page
-
-### Overview
-
-The read-only overview shows endpoint title, method badge, path, authorisation fields, query/path parameters, request body, and response schemas. The right column has code snippets and response JSON.
-
-### Playground
-
-Click **Test request** in the navbar to open the playground dialog with editable fields, JSON body editor, auth type switching, and live response with status and timing.
-
-### View Documentation
-
-If the OpenAPI spec has `externalDocs`, a **View documentation** button appears in the navbar.
-
-## Health & Readiness
-
-- `GET /api/health` — liveness probe, always returns `200`
-- `GET /api/ready` — readiness probe, returns `200` when search index is built, `503` otherwise
diff --git a/docs/content/docs/guides/api-reference.mdx b/docs/content/docs/guides/api-reference.mdx
new file mode 100644
index 00000000..cf18793a
--- /dev/null
+++ b/docs/content/docs/guides/api-reference.mdx
@@ -0,0 +1,145 @@
+---
+title: API reference
+description: Turn an OpenAPI spec into browsable endpoint pages with a request tester.
+order: 2
+---
+
+Point Chronicle at an OpenAPI file and it renders a reference: one page per
+endpoint, with parameters, request and response schemas, code samples in four
+languages, and a panel for sending real requests.
+
+You write no pages for this. The spec is the source.
+
+## Set it up
+
+Put the spec next to `chronicle.yaml` and add an `api` entry:
+
+```yaml
+api:
+ - name: Petstore API
+ spec: ./petstore.yaml
+ basePath: /apis
+ server:
+ url: https://petstore.swagger.io/v2
+ description: Production
+```
+
+Restart and the reference is at `/apis`. Each endpoint gets its own URL under
+that, and they all appear in the sidebar grouped by tag, with a coloured badge
+for the method.
+
+`spec` accepts JSON or YAML, and both OpenAPI 3.x and Swagger 2.0. A Swagger 2.0
+file is converted as it loads, so you do not have to migrate it first.
+
+Several APIs at once is just several entries, each with its own `basePath`:
+
+```yaml
+api:
+ - name: Public API
+ spec: ./public.yaml
+ basePath: /apis
+ server: { url: https://api.example.com }
+ - name: Admin API
+ spec: ./admin.yaml
+ basePath: /admin-apis
+ server: { url: https://admin.example.com }
+```
+
+## What a reader gets
+
+**The endpoint page.** Method and path at the top, then the parameters — path,
+query and header — with types, required markers, descriptions and examples. Then
+the request body schema, then a response schema per status code. Nested objects
+expand in place.
+
+**Code samples.** cURL, Python, Go and TypeScript, generated from the endpoint
+and switchable from a tab strip. They include the real server URL and the auth
+header, so they are ready to paste.
+
+**A request tester.** The **Test request** button opens a panel with the
+parameters as editable fields and a JSON editor for the body. Sending shows the
+status, the timing, and the response body with syntax highlighting.
+
+**A markdown version.** Every endpoint answers at a `.md` URL with the whole
+thing — parameters, examples, responses and a cURL command — as plain text, for
+handing to an AI tool.
+
+## Authentication
+
+Declare how the API authenticates and the reference picks it up:
+
+```yaml
+api:
+ - name: Petstore API
+ spec: ./petstore.yaml
+ basePath: /apis
+ server:
+ url: https://petstore.swagger.io/v2
+ auth:
+ type: apiKey
+ header: Authorization
+ placeholder: "Bearer your-token"
+```
+
+The auth field then appears on every endpoint page and in the request tester,
+and the value a reader types is written into the code samples.
+
+If the spec declares `securitySchemes`, Chronicle reads those instead and offers
+the schemes it finds — API key, bearer token or basic auth. The `auth` config
+above is for a spec that does not say.
+
+### The request tester and CORS
+
+Requests go through a proxy on your docs server rather than straight from the
+browser. That is deliberate: most APIs do not allow cross-origin requests from a
+docs site, so a direct call would fail before it left the page.
+
+It also means the token a reader types reaches your docs server on its way to
+the API. It is not stored or logged, but the server does handle it — so tell
+readers to use a test credential on a docs site you do not control, and treat
+your own docs host as something that needs the same trust as the API behind it.
+
+This means the API has to be reachable from wherever your docs are hosted. An
+API on a private network works when you run `chronicle dev` on the same network
+and does not work from a public host.
+
+## Linking to your own guides
+
+If the spec has an `externalDocs` entry, a **View documentation** button appears
+on the endpoint page. Use it to send someone from a bare endpoint to the guide
+that explains why they would call it.
+
+## One spec per version
+
+A versioned site can give each version its own spec, pointed at that version's
+server:
+
+```yaml
+versions:
+ - dir: v1
+ label: "1.0"
+ content:
+ - dir: docs
+ label: Docs
+ api:
+ - name: REST API (v1)
+ spec: ./v1-openapi.yaml
+ basePath: /apis
+ server:
+ url: https://api.example.com/v1
+```
+
+That serves at `/v1/apis/...`. See
+[Versioned documentation](/docs/guides/versioning).
+
+## Things to know
+
+**The spec is read at startup.** Change the file and restart the dev server.
+
+**Endpoints are indexed for search** alongside your written pages, by method,
+path and summary, so `POST /pets` finds the endpoint.
+
+**A spec that fails to parse fails the build** rather than rendering an empty
+reference. The error names the file.
+
+See [`api`](/docs/reference/config) for every field.
diff --git a/docs/content/docs/guides/meta.json b/docs/content/docs/guides/meta.json
new file mode 100644
index 00000000..c9867da9
--- /dev/null
+++ b/docs/content/docs/guides/meta.json
@@ -0,0 +1 @@
+{ "title": "Guides", "order": 5 }
diff --git a/docs/content/docs/guides/migrate.mdx b/docs/content/docs/guides/migrate.mdx
new file mode 100644
index 00000000..1a3fa742
--- /dev/null
+++ b/docs/content/docs/guides/migrate.mdx
@@ -0,0 +1,134 @@
+---
+title: Move an existing docs site
+description: Point Chronicle at markdown you already have.
+order: 5
+---
+
+If you already have a folder of markdown, you do not need to move or rename any
+of it. Chronicle can read the folder where it sits.
+
+## Point init at your folder
+
+```bash
+chronicle init -c docs
+```
+
+This writes a `chronicle.yaml` that treats your existing `docs/` as the content
+directory. Nothing is moved, and the sample page is skipped because the folder
+already has files.
+
+Then:
+
+```bash
+chronicle dev
+```
+
+Most of a markdown site renders on the first try. What follows is the short list
+of things that usually need a look.
+
+## Add a title to every page
+
+Give every page a `title`. Nothing fails without one — the page renders as
+`Untitled` in the sidebar and the browser tab, which is easy to miss until a
+reader finds it.
+
+If your pages open with an `# H1` and no frontmatter, that heading is the title —
+move it up:
+
+```mdx
+# Installing the CLI → ---
+ title: Installing the CLI
+The CLI ships as a binary. ---
+
+ The CLI ships as a binary.
+```
+
+Delete the `#` when you do. Every theme prints `title` above the article, so
+leaving both shows the same words twice. See
+[Pages and frontmatter](/docs/writing/pages).
+
+A quick way to find the pages that still need it:
+
+```bash
+grep -rL "^title:" docs --include="*.md" --include="*.mdx"
+```
+
+## Rename .md to .mdx
+
+Both extensions are read, so this is optional. Renaming buys you components —
+callouts, tabs, diagrams — in pages that want them, and costs nothing in pages
+that do not.
+
+MDX is stricter than markdown in one way worth knowing: a bare `<` or `{` is
+read as the start of a component or an expression. If a page fails to parse
+after renaming, that is usually why. Wrap the character in backticks or escape
+it.
+
+## Order the pages
+
+Without `order`, pages sort after every page that has one. Add it to the pages
+whose position matters:
+
+```yaml
+---
+title: Introduction
+order: 1
+---
+```
+
+Folders become sidebar groups named after the folder. Give a folder a better
+name with a `meta.json`:
+
+```json
+{ "title": "Getting started", "order": 2 }
+```
+
+See [Navigation](/docs/writing/navigation).
+
+## Keep your old URLs working
+
+This is the step people skip and regret. If your paths change — a different
+prefix, a renamed folder, a page split in two — add redirects so existing links
+survive:
+
+```yaml
+redirects:
+ - from: /guide/install
+ to: /docs/getting-started/install
+ permanent: true
+```
+
+Check your analytics for the twenty most-visited pages and make sure each one
+still resolves. See [Links and redirects](/docs/writing/links).
+
+## Move your images
+
+Images referenced relatively keep working — put the file next to the page. Ones
+you served from a static folder belong in `public/`, which is served as-is.
+
+Content images get resized and re-encoded for free once they are next to a page,
+which files in `public/` do not. See [Images](/docs/writing/images).
+
+## Coming from a docs site with its own conventions
+
+Chronicle has no plugin system, so anything your previous tool did through
+plugins needs another home:
+
+| You had | In Chronicle |
+|---|---|
+| A sidebar or nav config file | Folders plus `order`. See [Navigation](/docs/writing/navigation) |
+| Custom admonition syntax | The `:::note` directives Chronicle ships. See [Components](/docs/writing/components) |
+| A theme you had customised | One of three themes, plus colour tokens. See [Themes](/docs/themes) |
+| Versioned docs in a plugin | The `versions` key. See [Versioned documentation](/docs/guides/versioning) |
+| A search integration | Built in. See [Search](/docs/guides/search) |
+
+## Check before you ship
+
+```bash
+chronicle build
+```
+
+The build fails on a page that cannot be parsed and names the file, so this is
+the fastest way to find the last few problems. Then look at the sidebar order,
+click through the twenty pages that matter most, and check that search finds
+them.
diff --git a/docs/content/docs/guides/search.mdx b/docs/content/docs/guides/search.mdx
new file mode 100644
index 00000000..2b4bdc1c
--- /dev/null
+++ b/docs/content/docs/guides/search.mdx
@@ -0,0 +1,76 @@
+---
+title: Search
+description: What search indexes, how readers reach it, and how it behaves in a static build.
+order: 3
+---
+
+Search is on by default. There is no service to sign up for and no API key — the
+index is built from your pages when the server starts.
+
+```yaml
+search:
+ enabled: true
+ placeholder: Search docs...
+```
+
+Turn it off with `enabled: false` if your site is small enough that the sidebar
+is faster than typing.
+
+## Reaching it
+
+A reader can click the search button in the sidebar or press Cmd +
+K — Ctrl + K on Windows and Linux. Results
+appear as they type.
+
+## What gets indexed
+
+Three fields per page, weighted so the best match wins:
+
+| Field | Comes from |
+|---|---|
+| Title | The page's `title` |
+| Headings | Every `##` and below |
+| Body | The page text |
+
+A result tells the reader which of the three matched. A heading match links
+straight to that heading rather than the top of the page, so a hit deep in a
+long page lands where the words actually are.
+
+API endpoints are indexed too, by method, path and summary — so `POST /pets`
+finds the endpoint alongside the guides that mention it.
+
+Pages with `draft: true` are left out, because they are removed from the
+navigation tree before the index is built.
+
+## Scoping to a version and section
+
+On a versioned site the index is per version. A reader on `/v1/docs` searches
+1.0 and does not get 3.0 results. Switching versions switches the index.
+
+Results also carry the content section they came from, so a hit in `Dev Docs` is
+labelled as such rather than looking like a page from the main docs.
+
+## Two engines, same behaviour
+
+Which engine runs depends on how you built the site, and readers should not be
+able to tell.
+
+**Server builds** use SQLite full-text search. The index is built in the
+background when the server starts, which is why `/api/ready` reports not-ready
+until it finishes — see [Monitoring](/docs/deploy/monitoring).
+
+**Static builds** have no server to query, so the index is written to a JSON file
+at build time and searched in the browser. This makes the first search on a
+static site download the index. It is fine for a few hundred pages and gets
+heavy well beyond that, which is a reason to prefer a server build for a large
+site. See [Deploy](/docs/deploy/hosting).
+
+## Things to know
+
+**The index is built once per run.** In development, adding a page updates the
+page tree immediately but the search index is built at startup — restart if a
+new page is not being found.
+
+**There is no ranking you can tune.** No per-page boost, no synonyms, no
+stop-word list. If a page is hard to find, the fix is a clearer `title` and
+better headings, which helps readers who never search anyway.
diff --git a/docs/content/docs/guides/sections.mdx b/docs/content/docs/guides/sections.mdx
new file mode 100644
index 00000000..35357829
--- /dev/null
+++ b/docs/content/docs/guides/sections.mdx
@@ -0,0 +1,125 @@
+---
+title: Multiple content sections
+description: Split a site into separate sections with their own navigation.
+order: 4
+---
+
+Some docs serve two audiences that share nothing. A product guide for people
+using the thing, and a developer guide for people building against it. Putting
+both in one sidebar makes each harder to read.
+
+A content directory is a section: its own folder, its own URL prefix, its own
+navigation tree.
+
+## Declaring sections
+
+Each entry in `content` is a section:
+
+```yaml
+content:
+ - dir: docs
+ label: Docs
+ - dir: dev
+ label: Dev Docs
+```
+
+```
+content/
+├── docs/ → /docs/...
+└── dev/ → /dev/...
+```
+
+The directory name is the URL prefix. The label is what readers see.
+
+## What changes with two or more
+
+A switcher appears at the top of the sidebar. The tree below it shows only the
+section you are in, so a reader in `Dev Docs` sees developer pages and nothing
+else. Previous and next links stay inside the section too.
+
+Search still covers everything, but each result is labelled with the section it
+came from — so a reader who searches across both can tell which is which.
+
+## Descriptions and icons
+
+On a landing page each section becomes a card, and these two fields are what
+make the card worth reading:
+
+```yaml
+content:
+ - dir: docs
+ label: Docs
+ description: Install it, configure it, and get your first site running.
+ icon: /icons/book.svg
+ - dir: dev
+ label: Dev Docs
+ description: Build against the API, extend the themes, run it in CI.
+ icon: /icons/code.svg
+```
+
+`icon` takes a path to a file in `public/`, or inline SVG markup.
+
+## The landing page
+
+With one section, `/` redirects straight into it — a landing page listing one
+thing is a wasted click.
+
+With several, you probably want the choice. Turn it on:
+
+```yaml
+latest:
+ label: "3.0"
+ landing: true
+```
+
+Now `/` lists the sections as cards. Leave it off and `/` redirects to the first
+section in the list.
+
+The `fanfold` theme prints its own landing page here instead of the card grid —
+see [Theme options](/docs/themes/options).
+
+## Where a section's root lands
+
+Visiting `/docs` normally serves `content/docs/index.mdx`. Set `index_page` to
+send it to a different page instead:
+
+```yaml
+content:
+ - dir: docs
+ label: Docs
+ index_page: overview
+```
+
+Now `/docs` resolves to `/docs/overview`. Useful when the page you want readers
+to start on is not the one called `index`.
+
+## Sections and versions
+
+Each version declares its own sections, so they can change over releases:
+
+```yaml
+content:
+ - dir: docs
+ label: Docs
+ - dir: dev
+ label: Dev Docs
+
+versions:
+ - dir: v1
+ label: "1.0"
+ content:
+ - dir: docs
+ label: Docs # 1.0 had no developer guide
+```
+
+A version may rename a section, drop one, or add one the current version does
+not have. See [Versioned documentation](/docs/guides/versioning).
+
+## Things to know
+
+**Section names must be unique**, and cannot collide with a version directory
+name. The config check catches both.
+
+**Two sections is usually enough.** Every section you add is another thing a
+reader has to choose between before they start reading. Folders inside one
+section are cheaper — see [Navigation](/docs/writing/navigation).
diff --git a/docs/content/docs/guides/versioning.mdx b/docs/content/docs/guides/versioning.mdx
new file mode 100644
index 00000000..7dde9e7a
--- /dev/null
+++ b/docs/content/docs/guides/versioning.mdx
@@ -0,0 +1,178 @@
+---
+title: Versioned documentation
+description: Keep docs for old releases online while you write the next one.
+order: 1
+---
+
+When you ship a new major version, the people still on the old one need its docs.
+Chronicle serves every version at once: the current one at the plain URLs, and
+each older one behind its own prefix.
+
+## The layout
+
+The current version lives in `content/`, exactly as it does on an unversioned
+site. Older versions live in `versions/`, one folder each:
+
+```
+my-docs/
+├── chronicle.yaml
+├── content/ ← the current version
+│ └── docs/
+│ └── index.mdx → /docs
+└── versions/
+ ├── v2/
+ │ └── docs/
+ │ └── index.mdx → /v2/docs
+ └── v1/
+ └── docs/
+ └── index.mdx → /v1/docs
+```
+
+The folder name under `versions/` becomes the URL prefix. Nothing else derives
+from it, so `v1`, `2024-06` and `legacy` are all fine.
+
+## The config
+
+Two keys. `latest` describes the version in `content/`, and `versions` lists the
+older ones:
+
+```yaml
+content:
+ - dir: docs
+ label: Docs
+
+latest:
+ label: "3.0"
+
+versions:
+ - dir: v2
+ label: "2.0"
+ content:
+ - dir: docs
+ label: Docs
+
+ - dir: v1
+ label: "1.0"
+ content:
+ - dir: docs
+ label: Docs
+```
+
+`latest` becomes required the moment you declare `versions` — without it the
+switcher has no name for the current version. The config check will tell you so
+at startup rather than letting the site render a blank entry.
+
+Each version declares its own `content`, because the sections of your docs
+change over releases. A version can rename a section, drop one, or add one that
+the current version does not have.
+
+## Cutting a new version
+
+The move is to copy the current docs sideways, then keep writing in `content/`:
+
+```bash
+# freeze today's docs as 2.0
+cp -r content versions/v2
+```
+
+Then add the entry to `versions`, and change `latest.label` to the new number:
+
+```yaml
+latest:
+ label: "3.0" # was 2.0
+
+versions:
+ - dir: v2
+ label: "2.0" # the copy you just made
+ content:
+ - dir: docs
+ label: Docs
+```
+
+`content/` is now 3.0 and `/v2/docs` serves what you froze. No page needed
+editing to make that happen.
+
+## What readers see
+
+A switcher appears in the sidebar, listing `latest` and every version. Picking
+one keeps them on the equivalent page where possible.
+
+Everything else scopes to the version they are in. The sidebar shows that
+version's pages, search only returns that version's results, and previous and
+next links stay inside it. A reader on `/v1/docs` will not be dropped into 3.0
+material by accident.
+
+## Marking a version deprecated
+
+Add a badge and it shows next to the version's name in the switcher:
+
+```yaml
+versions:
+ - dir: v1
+ label: "1.0"
+ badge:
+ label: deprecated
+ variant: warning
+ content:
+ - dir: docs
+ label: Docs
+```
+
+`variant` takes `accent`, `warning`, `danger`, `success`, `neutral` or
+`gradient`. `warning` for something you would rather people left, `danger` for
+one that is no longer supported at all.
+
+## Version landing pages
+
+By default, hitting a version's root redirects to its first content directory —
+`/v1` sends the reader to `/v1/docs`.
+
+Set `landing: true` to render a page listing that version's sections instead:
+
+```yaml
+versions:
+ - dir: v1
+ label: "1.0"
+ landing: true
+ content: [...]
+```
+
+This is worth it for a version with several sections and not worth it for one
+with a single section, where the landing page is a list of one.
+
+`latest.landing` does the same for `/`.
+
+## Versioned API references
+
+A version can carry its own OpenAPI spec, served under its prefix:
+
+```yaml
+versions:
+ - dir: v1
+ label: "1.0"
+ content:
+ - dir: docs
+ label: Docs
+ api:
+ - name: REST API (v1)
+ spec: ./v1-openapi.yaml
+ basePath: /apis
+ server:
+ url: https://api.example.com/v1
+```
+
+That renders at `/v1/apis/...`, pointing at the v1 server. See
+[API reference](/docs/guides/api-reference).
+
+## Things to know
+
+**A version directory name cannot collide with a content directory name.** If
+your content has `dir: docs`, no version may be called `docs` — the URLs would be
+ambiguous. The config check catches this.
+
+**Old versions are frozen by hand, not by git.** Chronicle reads whatever is in
+`versions/`. If you fix a typo in the current docs and want the fix in 2.0 as
+well, you edit both.
+
+**Every version is built and served.** Ten versions of a large site means ten
+sites' worth of pages in one build. Prune the versions nobody reads.
diff --git a/docs/content/docs/index.mdx b/docs/content/docs/index.mdx
index 4240051e..305360c2 100644
--- a/docs/content/docs/index.mdx
+++ b/docs/content/docs/index.mdx
@@ -1,95 +1,71 @@
---
-title: Getting Started
-description: Install and set up Chronicle, a config-driven documentation framework.
+title: Introduction
+description: What Chronicle is, when to reach for it, and where to go next.
order: 1
---
-# Getting Started
+Chronicle turns a folder of MDX files into a documentation site. You write pages,
+you describe the site in one YAML file, and that is the whole setup. There is no
+JavaScript config, no plugin list, and no build script to maintain.
-Chronicle is a config-driven documentation framework built on Vite, Nitro, and Apsara UI components. Write MDX content, configure with a single YAML file, and get a fully themed documentation site.
+It is built on Vite and Nitro, and renders with Apsara components. Pages are
+server-rendered, so they work with JavaScript turned off and load fast on a
+slow connection.
-## Installation
+## When to reach for it
-```bash
-bun add @raystack/chronicle
-```
-
-## Quick Start
-
-### 1. Initialize a new project
-
-```bash
-chronicle init
-```
+Chronicle suits a project that needs more than a README and less than a custom
+site. It is a good fit when you want:
-This creates:
-- `chronicle.yaml` — your site configuration
-- `content/` — content directory with a sample `index.mdx`
-- `.gitignore` — with `node_modules`, `dist`, `.output` entries
+- **Docs in your repository.** Pages are files. They review like code.
+- **More than one version live at once.** Old versions stay reachable at their
+ own URLs while you keep writing the new one.
+- **An API reference from your OpenAPI spec.** Point at the spec file and you
+ get browsable endpoint pages with a request tester.
+- **Something to hand to an AI tool.** Every page has a plain markdown URL, and
+ the site publishes an index at `llms.txt`.
-To use an existing directory as content (e.g. `docs/`):
+It is a poor fit if you need a full marketing site, a CMS with a web editor, or
+a design you control down to the pixel. Chronicle gives you a choice of three
+themes and no way to write a fourth.
-```bash
-chronicle init -c docs
-```
+## What you get
-### 2. Start the development server
+Write MDX and Chronicle handles the rest of a docs site: navigation built from
+your folders, full-text search, a table of contents per page, breadcrumbs,
+previous and next links, dark mode, social cards, a sitemap, and redirects for
+URLs you have moved.
-```bash
-chronicle dev
-```
+The pieces you will touch most often:
-Your docs site is now running at [http://localhost:3000](http://localhost:3000).
+| You want to | Go to |
+|---|---|
+| Get a site running | [Quick start](/docs/quick-start) |
+| Write and organise pages | [Writing docs](/docs/writing/pages) |
+| Publish a second version | [Versioned documentation](/docs/guides/versioning) |
+| Publish an API reference | [API reference](/docs/guides/api-reference) |
+| Look up a config key | [chronicle.yaml](/docs/reference/config) |
+| Put it on the internet | [Deploy](/docs/deploy/hosting) |
-### 3. Add content
+## How a project is laid out
-Create `.mdx` files in your content directory. Each file becomes a page. Use folders to create nested navigation.
+One config file, one content folder:
```
my-docs/
-├── chronicle.yaml
+├── chronicle.yaml # the whole site config
├── content/
-│ ├── index.mdx
-│ ├── getting-started.mdx
-│ └── guides/
-│ ├── installation.mdx
-│ └── configuration.mdx
+│ └── docs/ # a content directory
+│ ├── index.mdx # → /docs
+│ └── guides/
+│ └── setup.mdx # → /docs/guides/setup
└── .output/ # build output, gitignored
```
-### 4. Build for production
-
-```bash
-chronicle build
-chronicle start
-```
-
-Or use the combined command:
-
-```bash
-chronicle serve
-```
-
-## Project Structure
-
-A minimal Chronicle project looks like:
-
-```
-my-docs/
-├── chronicle.yaml # Site configuration
-├── content/
-│ ├── index.mdx # Home page
-│ └── guides/
-│ └── setup.mdx # Nested page at /guides/setup
-└── .output/ # Build output, gitignored
-```
-
-All configuration is done through `chronicle.yaml`. No additional config files needed.
+Files become URLs. Folders become groups in the sidebar. See
+[Project structure](/docs/structure) for how the pieces fit together.
-## Next Steps
+## Next
-- [CLI Commands](/docs/cli) — All available CLI commands and flags
-- [Configuration](/docs/configuration) — Full `chronicle.yaml` reference
-- [Frontmatter](/docs/frontmatter) — Page-level metadata options
-- [Components](/docs/components) — MDX components and admonitions
-- [Themes](/docs/themes) — Available themes and customization
+Start with the [quick start](/docs/quick-start) — it takes about two minutes and
+ends with a site running on your machine.
diff --git a/docs/content/docs/quick-start.mdx b/docs/content/docs/quick-start.mdx
new file mode 100644
index 00000000..92fba3b7
--- /dev/null
+++ b/docs/content/docs/quick-start.mdx
@@ -0,0 +1,91 @@
+---
+title: Quick start
+description: Install Chronicle and get a documentation site running on your machine.
+order: 2
+---
+
+This gets you from nothing to a running site. It takes about two minutes.
+
+## Install
+
+```bash
+bun add @raystack/chronicle
+```
+
+## Create a project
+
+```bash
+chronicle init
+```
+
+That writes three things:
+
+- `chronicle.yaml` — your site config
+- `content/docs/` — a content directory with a sample `index.mdx`
+- `.gitignore` — entries for `node_modules`, `dist` and `.output`
+
+Already have a docs folder you want to keep? Point `init` at it instead of
+letting it make a new one:
+
+```bash
+chronicle init -c docs
+```
+
+Nothing is overwritten. If the directory already has files, the sample page is
+skipped. See [Move an existing docs site](/docs/guides/migrate) for the longer
+version.
+
+## Start the dev server
+
+```bash
+chronicle dev
+```
+
+Your site is at [http://localhost:3000](http://localhost:3000). Edit a page and
+the browser updates without a reload.
+
+## Write a page
+
+Create `content/docs/hello.mdx`:
+
+```mdx
+---
+title: Hello
+description: My first page.
+order: 2
+---
+
+This is a page. It lives at `/docs/hello`.
+
+## A section
+
+Sections use `##` and below. The heading above the article comes from `title`,
+so a page never needs its own `#`.
+```
+
+Reload and it appears in the sidebar, in search, and in the previous and next
+links. Nothing else to register.
+
+## Build for production
+
+```bash
+chronicle build
+chronicle start
+```
+
+Or do both in one step while you are trying things out:
+
+```bash
+chronicle serve
+```
+
+`build` writes to `.output/`. `start` serves what `build` produced — it does not
+build for you, so run them in that order. See
+[Build and serve](/docs/deploy/build) for what the output contains and how
+presets change it.
+
+## Next
+
+- [Project structure](/docs/structure) — what each file and folder is for
+- [Pages and frontmatter](/docs/writing/pages) — every field a page can set
+- [chronicle.yaml](/docs/reference/config) — the full config reference
diff --git a/docs/content/docs/cli.mdx b/docs/content/docs/reference/cli.mdx
similarity index 82%
rename from docs/content/docs/cli.mdx
rename to docs/content/docs/reference/cli.mdx
index 3d615a27..3ea28eb0 100644
--- a/docs/content/docs/cli.mdx
+++ b/docs/content/docs/reference/cli.mdx
@@ -1,11 +1,9 @@
---
-title: CLI Commands
-description: Chronicle CLI commands reference.
-order: 2
+title: CLI commands
+description: Every Chronicle command and flag.
+order: 3
---
-# CLI Commands
-
Chronicle provides a CLI to initialize, develop, build, and serve your documentation site.
## init
@@ -54,7 +52,7 @@ chronicle build [options]
|------|-------------|---------|
| `--content ` | Content directory | `content` |
| `--config ` | Path to `chronicle.yaml` | `./chronicle.yaml` |
-| `--preset ` | Deploy preset (`vercel`, `cloudflare`, `node-server`) | — |
+| `--preset ` | Deploy preset. See [`preset`](/docs/reference/config) for the values | — |
## start
@@ -84,9 +82,9 @@ chronicle serve [options]
| `--config ` | Path to `chronicle.yaml` | `./chronicle.yaml` |
| `-p, --port ` | Port number | `3000` |
| `--host ` | Host address | `localhost` |
-| `--preset ` | Deploy preset (`vercel`, `cloudflare`, `node-server`) | — |
+| `--preset ` | Deploy preset. See [`preset`](/docs/reference/config) for the values | — |
-## Resolution Order
+## Resolution order
CLI flags take precedence over `chronicle.yaml` values, which take precedence over defaults.
@@ -95,3 +93,8 @@ CLI flags take precedence over `chronicle.yaml` values, which take precedence ov
| Content directory | `--content` | `content` | `content` |
| Deploy preset | `--preset` | `preset` | — |
| Config path | `--config` | — | `./chronicle.yaml` |
+
+`start` serves what `build` produced. It does not build, so run `build` first —
+or use `serve`, which does both.
+
+See [Build and serve](/docs/deploy/build) for what each preset writes.
diff --git a/docs/content/docs/configuration.mdx b/docs/content/docs/reference/config.mdx
similarity index 78%
rename from docs/content/docs/configuration.mdx
rename to docs/content/docs/reference/config.mdx
index 520b575c..f7ce2822 100644
--- a/docs/content/docs/configuration.mdx
+++ b/docs/content/docs/reference/config.mdx
@@ -1,30 +1,16 @@
---
-title: Configuration
-description: Full chronicle.yaml configuration reference.
-order: 3
+title: chronicle.yaml
+description: Every key the site config accepts.
+order: 1
---
-# Configuration
-
All site configuration lives in a single `chronicle.yaml` file in your project root. The config is validated using Zod — invalid fields produce clear errors at startup.
-## Project layout
-
-```
-my-docs-site/
-├── chronicle.yaml
-├── content/ ← latest
-│ ├── docs/
-│ └── dev/
-└── versions/ ← only if versions: is declared
- ├── v2/
- │ └── docs/
- └── v1/
- ├── docs/
- └── dev/
-```
+Only `site.title` and `content` are required. Everything else has a default or
+is optional.
-Content dirs declared in top-level `content:` are resolved under `content//` for the latest version; each `versions[].content[].dir` is resolved under `versions///`.
+For how the files on disk map to URLs, see
+[Project structure](/docs/structure).
## Full example
@@ -216,28 +202,57 @@ versions:
| `content` | `{dir, label}[]` | Content dirs for this version. Entries may rename, reorder, or omit top-level content dirs. |
| `api` | `ApiConfig[]` | Version-scoped API specs, rendered at `//apis/...`. Same shape as top-level `api:`. |
+See [Versioned documentation](/docs/guides/versioning) for how to cut a new version.
+
### preset
-Optional deploy preset. Can be overridden by `--preset`.
+Deploy target. Decides what `chronicle build` produces and where it writes.
+Overridden by `--preset`.
```yaml
-preset: vercel # vercel, cloudflare, or node-server
+preset: vercel
```
+| Value | Output |
+|-------|--------|
+| — (unset) | A Node server in `.output/` |
+| `node-server` | A Node server in `.output/` |
+| `vercel` | A Vercel function build in `.vercel/output/` |
+| `cloudflare` | A Cloudflare Workers build in `.output/` |
+| `static` | Pre-rendered HTML in `.output/public/` |
+| `vercel-static` | Pre-rendered HTML in `.vercel/output/` |
+| `cloudflare-pages` | Pre-rendered HTML in `.output/public/` |
+| `github-pages` | Pre-rendered HTML in `.output/public/` |
+
+The four static presets pre-render every page at build time and need no server
+to run. See [Deploy](/docs/deploy/hosting).
+
### logo
-Logo with theme-aware variants.
+The mark shown in the sidebar, used as the favicon, and drawn on social cards.
```yaml
logo:
- light: ./logo-light.png
- dark: ./logo-dark.png
+ light: /logo.svg
+ dark: /logo-dark.svg
```
| Field | Type | Description |
|-------|------|-------------|
-| `light` | `string` | Logo path or URL for light theme |
-| `dark` | `string` | Logo path or URL for dark theme |
+| `light` | `string` | Path or URL used on a light background |
+| `dark` | `string` | Path or URL used on a dark background |
+
+Files live in `public/`, so `public/logo.svg` is written as `/logo.svg`. Setting
+one is enough — the other falls back to it, which is what you want for a mark
+that reads on either background.
+
+Set neither and every theme shows the site's first letter in a tinted box. It is
+deliberately plain, so a site without a logo looks unfinished rather than
+borrowed.
+
+The same image becomes the favicon, replacing `public/favicon.ico` and
+`public/favicon.svg`. Those two are still used when no logo is set, so an
+explicit favicon keeps working.
### theme
@@ -251,9 +266,25 @@ theme:
| Field | Type | Description | Default |
|-------|------|-------------|---------|
| `name` | `'default' \| 'paper' \| 'fanfold'` | Theme to use | `default` |
-| `colors` | `Record` | Custom color overrides | — |
+| `colors` | `Record` | Colour overrides, keyed by design token | — |
-See [Themes](/docs/themes) for details on each theme.
+```yaml
+theme:
+ name: default
+ colors:
+ background-base-primary: "#fbfaf8"
+ foreground-accent-primary: "#0a3793"
+```
+
+Each key names an Apsara colour token, and the `--rs-color-` prefix is added for
+you. Write the whole custom property yourself — `--paper-ink` — to reach one
+outside that family.
+
+A value applies in both light and dark, since the config holds one value per
+token. Leave a token alone if the two themes should differ.
+
+See [Choosing a theme](/docs/themes) for which one to pick and
+[Theme options](/docs/themes/options) for what each gives you.
### navigation
@@ -318,7 +349,7 @@ Any query string already on `href` is preserved, and a `utm_*` param you set you
### search
-Search functionality powered by Fumadocs. Automatically scoped to the active version.
+Full-text search over titles, headings and page bodies. Scoped to the active version.
```yaml
search:
@@ -333,6 +364,8 @@ search:
When enabled, search is accessible via the navbar button or keyboard shortcut `Cmd+K` / `Ctrl+K`. Active version comes from the URL; switching versions scopes the index.
+See [Search](/docs/guides/search) for what gets indexed.
+
### api
OpenAPI specification configuration at the top level applies to the latest version (served at `/apis/...`). Version-scoped specs live under each `versions[].api`.
@@ -363,6 +396,8 @@ api:
| `auth.header` | `string` | Header name for auth token |
| `auth.placeholder` | `string` | Placeholder text in auth input |
+See [API reference](/docs/guides/api-reference) for what readers get from a spec.
+
### redirects
URL redirects for migrating old routes to new ones. Checked before all other routes.
@@ -384,6 +419,8 @@ redirects:
Use `permanent: true` when the old URL should never be used again — search engines and browsers will cache the redirect.
+See [Links and redirects](/docs/writing/links) for when to use each status.
+
### analytics
Analytics integration for tracking page views.
@@ -400,6 +437,8 @@ analytics:
| `enabled` | `boolean` | Enable/disable analytics | `false` |
| `googleAnalytics.measurementId` | `string` | Google Analytics measurement ID | — |
+See [Monitoring](/docs/deploy/monitoring) for what else a running site reports.
+
### authors
Optional registry of the people who write your docs. Pages reference an entry by
@@ -430,9 +469,11 @@ authors: [jane]
A frontmatter string that matches no key is still valid — it renders as a plain
name, so occasional contributors need no registry entry. See
-[Frontmatter](/docs/frontmatter) for the field itself, and browse the people
+[Frontmatter](/docs/reference/frontmatter) for the field itself, and browse the people
writing a site at `/authors`.
+See [Authors and bylines](/docs/writing/authors) for how bylines and author pages work.
+
### telemetry
Prometheus metrics export via OpenTelemetry. Served on a separate port.
@@ -452,6 +493,8 @@ telemetry:
Metrics are available at `http://localhost:/metrics` in Prometheus exposition format.
+See [Monitoring](/docs/deploy/monitoring) for the health and readiness endpoints.
+
## Defaults
When `chronicle.yaml` is missing or fields are omitted, these defaults apply:
diff --git a/docs/content/docs/frontmatter.mdx b/docs/content/docs/reference/frontmatter.mdx
similarity index 71%
rename from docs/content/docs/frontmatter.mdx
rename to docs/content/docs/reference/frontmatter.mdx
index 38a811ae..93671112 100644
--- a/docs/content/docs/frontmatter.mdx
+++ b/docs/content/docs/reference/frontmatter.mdx
@@ -1,14 +1,13 @@
---
-title: Frontmatter
-description: Page-level metadata options for MDX files.
-order: 4
+title: Frontmatter fields
+description: Every field a page can set in its frontmatter.
+order: 2
---
-# Frontmatter
-
-Every MDX file supports YAML frontmatter at the top of the file for page-level configuration.
+Every field a page can set. Only `title` is required.
-## Example
+For how to write a page day to day, see
+[Pages and frontmatter](/docs/writing/pages).
```mdx
---
@@ -21,21 +20,40 @@ authors:
- Jane Doe
---
-# Getting Started
-
Your content here...
```
+## Fields at a glance
+
+| Field | Type | What it does |
+|---|---|---|
+| `title` | `string` | Heading, sidebar label, browser tab. Falls back to `Untitled` |
+| `description` | `string` | Line under the heading, meta description, social card |
+| `order` | `number` | Sidebar position. Lower first. Decimals allowed |
+| `draft` | `boolean` | `true` removes the page from the site |
+| `short` | `string` | Shorter sidebar label. Read by `fanfold` |
+| `identifiers` | `string[]` | Extra header lines. Read by `fanfold` |
+| `icon` | `string` | Sidebar icon |
+| `authors` | `string \| string[]` | Byline, author pages, structured data |
+| `lastModified` | `string` | Date the page states as its last update |
+
## Fields
### title
-**Required.** The page title used in navigation, browser tab, and page heading.
+The page title used in navigation, the browser tab, and the heading above the
+article. A page without one renders as `Untitled`, so treat it as required even
+though nothing enforces it.
```yaml
title: Installation Guide
```
+Every theme prints this title above the article, so start your content with the
+first section rather than repeating the title as a `#` heading. A page that opens
+with `# Installation Guide` shows the same words twice. Use `##` and below for
+sections — those are what the table of contents lists.
+
### short
Optional short label for the sidebar. Use it when the full title is too long for
@@ -140,19 +158,6 @@ authors:
- Sam Patel
```
-### draft
-
-Set `draft: true` to keep a page out of the site. It is dropped from the
-navigation tree, so it does not appear in the sidebar, in breadcrumbs, in search,
-or in the previous/next links.
-
-```yaml
-draft: true
-```
-
-The file stays where it is, so this is the way to leave a page in the repository
-while it is still being written.
-
A single author can be written without the list:
```yaml
@@ -167,7 +172,7 @@ and on the generated social card. Every author also gets a page at
A byline shows two authors at most, collapsing the rest into a `+N` counter that
names them on hover. The avatar beside each name is drawn from the author's
initials unless the author has an `avatar` in the
-[authors registry](/docs/configuration).
+[authors registry](/docs/reference/config).
Registry keys work here too, and bring the author's bio, avatar, and profile
link along with them:
@@ -176,38 +181,20 @@ link along with them:
authors: [jane]
```
-## Navigation Ordering
-
-Sidebar navigation is determined by:
-
-1. **Frontmatter `order`** — Pages sorted by `order` value (ascending)
-2. **Decimal values** — Use `1`, `1.5`, `2` for fine-grained positioning
-3. **Folders** — Directories become collapsible groups, auto-named from the folder name (capitalized)
-4. **No `meta.json` required** — Ordering is entirely frontmatter-based
+### draft
-### Example structure
+Set `draft: true` to keep a page out of the site. It is dropped from the
+navigation tree, so it does not appear in the sidebar, in breadcrumbs, in search,
+or in the previous/next links.
+```yaml
+draft: true
```
-content/
-├── index.mdx # order: 1
-├── quickstart.mdx # order: 2
-├── guides/
-│ ├── setup.mdx # order: 1
-│ └── advanced.mdx # order: 2
-└── reference/
- ├── cli.mdx # order: 1
- └── config.mdx # order: 2
-```
-This produces sidebar navigation:
+The file stays where it is, so this is the way to leave a page in the repository
+while it is still being written.
+
+## Where ordering is explained
-```
-Home (order: 1)
-Quickstart (order: 2)
-▸ Guides
- Setup (order: 1)
- Advanced (order: 2)
-▸ Reference
- CLI (order: 1)
- Config (order: 2)
-```
+`order`, `short` and folder `meta.json` all shape the sidebar together. See
+[Navigation](/docs/writing/navigation).
diff --git a/docs/content/docs/reference/meta.json b/docs/content/docs/reference/meta.json
new file mode 100644
index 00000000..e79619ed
--- /dev/null
+++ b/docs/content/docs/reference/meta.json
@@ -0,0 +1 @@
+{ "title": "Reference", "order": 7 }
diff --git a/docs/content/docs/reference/routes.mdx b/docs/content/docs/reference/routes.mdx
new file mode 100644
index 00000000..802cc3a5
--- /dev/null
+++ b/docs/content/docs/reference/routes.mdx
@@ -0,0 +1,125 @@
+---
+title: Generated routes
+description: The URLs Chronicle serves that you never wrote a page for.
+order: 4
+---
+
+Beyond your pages, a Chronicle site answers at a set of URLs it generates
+itself. None of them need configuring. They are listed here so you know what is
+public and what you can point other tools at.
+
+## For search engines
+
+### /sitemap.xml
+
+Every page on the site, for crawlers.
+
+Absolute URLs need to know the site's address, so set `url` in the config or the
+sitemap has nothing to build them from:
+
+```yaml
+url: https://docs.example.com
+```
+
+On a versioned site, older versions are included alongside the current one.
+
+### /robots.txt
+
+Allows everything, and points at the sitemap:
+
+```
+User-agent: *
+Allow: /
+
+Sitemap: https://docs.example.com/sitemap.xml
+```
+
+The `Sitemap` line appears only when `url` is set.
+
+## For AI tools
+
+### The `.md` URL of any page
+
+Every page answers at its own path plus `.md`, returning the raw markdown with
+the frontmatter stripped:
+
+```bash
+curl https://docs.example.com/docs/quick-start.md
+```
+
+API endpoints do the same, and generate a full markdown document — parameters,
+examples, responses and a cURL command:
+
+```bash
+curl https://docs.example.com/apis/petstore/findPetsByStatus.md
+```
+
+This is what the **Open in AI** menu on each page copies, and what it hands to
+ChatGPT or Claude when a reader picks one.
+
+### /llms.txt
+
+An index of the site in the format AI tools look for: the site title, its
+description, and a link to every page's markdown URL.
+
+```
+# Chronicle
+
+Config-driven documentation framework
+
+- [Introduction](/docs.md)
+- [Quick start](/docs/quick-start.md)
+```
+
+Nothing to turn on. Every page you add appears here.
+
+## For social cards
+
+### /og
+
+Renders a social card as a PNG — the image people see when a page is shared in
+Slack, on a social network, or in a chat app.
+
+Pages point at this themselves in their meta tags, so you rarely call it
+directly. It takes what it draws from the query string:
+
+| Parameter | What it draws |
+|---|---|
+| `title` | The headline. Falls back to the site title |
+| `description` | A line under the headline |
+| `authors` | A `By …` line |
+
+The card also carries your `logo`, or the site's first letter when none is set.
+See [`logo`](/docs/reference/config).
+
+Cards are only referenced when `url` is set, because a social network needs an
+absolute URL to fetch the image from.
+
+## For the site itself
+
+These serve the running site's own front end. They are documented because they
+are reachable, not because you need them.
+
+| Route | What it returns |
+|---|---|
+| `/api/search` | Search results for a query, scoped to a version |
+| `/api/page` | A page's metadata, fetched when navigating |
+| `/api/specs` | The parsed OpenAPI specs for a version |
+| `/api/image` | A resized, re-encoded content image |
+| `/api/authors` | The author index |
+| `/api/health` | Liveness. Always `200` |
+| `/api/ready` | Readiness. `200` once the search index is built, `503` before |
+
+`/api/health` and `/api/ready` are the two worth wiring into a deployment. See
+[Monitoring](/docs/deploy/monitoring).
+
+## In a static build
+
+A static build has no server, so everything that would be computed per request
+is written out as a file during the build instead. `sitemap.xml`, `robots.txt`,
+`llms.txt`, every `.md` file and every social card are all generated then.
+
+The `/api/*` routes have no equivalent. Search reads an index file downloaded
+into the browser, images are resized during the build rather than on demand, and
+the health, readiness and API proxy routes do not exist. See
+[Build and serve](/docs/deploy/build).
diff --git a/docs/content/docs/structure.mdx b/docs/content/docs/structure.mdx
new file mode 100644
index 00000000..f821fee2
--- /dev/null
+++ b/docs/content/docs/structure.mdx
@@ -0,0 +1,117 @@
+---
+title: Project structure
+description: What each file and folder in a Chronicle project is for.
+order: 3
+---
+
+A Chronicle project is a config file and a folder of MDX. Everything else is
+either static files you supply or output Chronicle writes.
+
+```
+my-docs/
+├── chronicle.yaml # the whole site config
+├── content/ # pages for the current version
+│ ├── docs/
+│ └── dev/
+├── versions/ # only if you declare versions:
+│ ├── v2/
+│ │ └── docs/
+│ └── v1/
+│ ├── docs/
+│ └── dev/
+├── public/ # static files served as-is
+├── .output/ # build output, gitignored
+└── .cache/ # optimized images, gitignored
+```
+
+## chronicle.yaml
+
+Every setting lives here. There is no second config file and no JavaScript
+config. The file is checked against a schema when the server starts, so a typo
+or a wrong type fails immediately with a message naming the field rather than
+breaking a page later.
+
+Only two keys are required: `site.title` and `content`. See
+[chronicle.yaml](/docs/reference/config) for all seventeen.
+
+## content
+
+Pages for the current version. This folder does not become a URL segment — the
+directories inside it do.
+
+Each directory you name in `content:` is a **content directory**, a top-level
+section of the site with its own navigation:
+
+```yaml
+content:
+ - dir: docs
+ label: Docs
+ - dir: dev
+ label: Dev Docs
+```
+
+That maps `content/docs/` to `/docs/...` and `content/dev/` to `/dev/...`. A site
+with two or more content directories gets a switcher for moving between them —
+see [Multiple content sections](/docs/guides/sections).
+
+### Files become URLs
+
+```
+content/docs/index.mdx → /docs
+content/docs/hello.mdx → /docs/hello
+content/docs/guides/setup.mdx → /docs/guides/setup
+```
+
+`index.mdx` is the page for the directory holding it. `readme.mdx` works the same
+way, which is handy when the folder is also read on GitHub.
+
+Folders become groups in the sidebar. See
+[Navigation](/docs/writing/navigation) for naming and ordering them.
+
+## versions
+
+Only present if you declare `versions:` in the config. Each version is a folder,
+and the folder name doubles as the URL prefix:
+
+```
+versions/v1/docs/index.mdx → /v1/docs
+```
+
+The current version stays in `content/` with no prefix. See
+[Versioned documentation](/docs/guides/versioning).
+
+## public
+
+Static files, served from the root of the site with their paths unchanged.
+`public/logo.svg` is available at `/logo.svg`, which is what a `logo:` entry in
+the config points at.
+
+Use it for logos, favicons, and downloads. Images you reference from a page are
+better placed next to the page — see [Images](/docs/writing/images).
+
+## .output
+
+What `chronicle build` writes. Gitignore it.
+
+For a server build it holds a runnable server that `chronicle start` serves. For
+a static build it holds `.output/public`, a folder of HTML and assets you can
+upload anywhere. Building with the `vercel` preset writes to `.vercel/output`
+instead. See [Build and serve](/docs/deploy/build).
+
+## .cache
+
+Resized and re-encoded images, kept between restarts so the work is done once.
+Gitignore it. See [Images](/docs/writing/images).
+
+## Using it inside a monorepo
+
+Chronicle takes the config path as a flag, so the docs site does not have to sit
+at the repository root:
+
+```bash
+chronicle dev --config docs/chronicle.yaml
+```
+
+Paths inside the config resolve relative to `chronicle.yaml`, not to where you
+ran the command. A `spec: ./openapi.yaml` next to the config is found whichever
+directory you start from.
diff --git a/docs/content/docs/themes/index.mdx b/docs/content/docs/themes/index.mdx
new file mode 100644
index 00000000..a2626617
--- /dev/null
+++ b/docs/content/docs/themes/index.mdx
@@ -0,0 +1,79 @@
+---
+title: Choosing a theme
+description: Three built-in themes, and which one suits your docs.
+order: 1
+---
+
+Chronicle ships three themes. Set one in `chronicle.yaml`:
+
+```yaml
+theme:
+ name: default
+```
+
+You cannot write a fourth, so pick the one whose shape fits your material. You
+can recolour whichever you pick — see [Theme options](/docs/themes/options).
+
+## Which one
+
+| Theme | Shape | Reach for it when |
+|---|---|---|
+| `default` | Sidebar, content, table of contents | Reference docs people navigate and search |
+| `paper` | One column, reading progress | Guides and long explanations people read start to finish |
+| `fanfold` | Line-printer paper, monospace throughout | Systems and protocol docs, where a printed look fits the material |
+
+### default
+
+The layout most documentation uses, and the right first choice. A navigation
+tree on the left, the page in the middle, its headings on the right. Readers
+already know how to use it, and nothing about it will surprise them.
+
+Pick it when people arrive knowing what they want and need to find it.
+
+### paper
+
+A single column at a reading width, with a progress indicator down the side and
+no table of contents competing for attention. Typography is tuned for long
+stretches of prose.
+
+Pick it when your docs are more explanation than reference — a handbook, a set
+of guides, something read in order rather than searched.
+
+### fanfold
+
+Continuous-form line printer paper: tractor-feed strips down both edges, faint
+zebra banding behind the text, monospace type, and a printed header block on
+every page carrying the breadcrumb trail and a page counter.
+
+It reads two frontmatter fields the others ignore. `short` gives a page a code
+for the narrow rail, and `identifiers` prints extra lines in the header — the
+standard a page implements, its package, its command.
+
+Pick it for material that is already technical and enumerated: protocols, wire
+formats, command references. It is a strong look, and it is the wrong choice for
+a friendly product guide.
+
+## Trying them
+
+Switching costs one line, and no page content has to change:
+
+```yaml
+theme:
+ name: paper
+```
+
+Restart the dev server and click through your five longest pages. A theme that
+suits your material is obvious quickly, and so is one that does not.
+
+Two fields are only read by `fanfold`, so a page that sets `short` or
+`identifiers` loses nothing by moving to another theme — the fields are ignored,
+not broken.
+
+## Dark mode
+
+All three do light and dark, following the reader's system preference with a
+toggle to override it. There is nothing to configure.
+
+Check both when you pick a theme, and again if you set your own colours — a
+look that works in one is not guaranteed to work in the other, and half your
+readers will see each.
diff --git a/docs/content/docs/themes/meta.json b/docs/content/docs/themes/meta.json
new file mode 100644
index 00000000..93a3a105
--- /dev/null
+++ b/docs/content/docs/themes/meta.json
@@ -0,0 +1 @@
+{ "title": "Themes", "order": 6 }
diff --git a/docs/content/docs/themes.mdx b/docs/content/docs/themes/options.mdx
similarity index 68%
rename from docs/content/docs/themes.mdx
rename to docs/content/docs/themes/options.mdx
index 238a335d..a4215a49 100644
--- a/docs/content/docs/themes.mdx
+++ b/docs/content/docs/themes/options.mdx
@@ -1,19 +1,18 @@
---
-title: Themes
-description: Available themes and customization options.
-order: 7
+title: Theme options
+description: What each built-in theme gives you, and how to tune its colours.
+order: 2
---
-# Themes
-
-Chronicle ships with three built-in themes. Set the theme in your `chronicle.yaml`:
+What each theme gives you. For help picking one, see
+[Choosing a theme](/docs/themes).
```yaml
theme:
name: default
```
-## Default Theme
+## Default theme
A traditional documentation layout with three-column design.
@@ -37,7 +36,7 @@ theme:
- Breadcrumb navigation
- Active heading tracking in table of contents
-## Paper Theme
+## Paper theme
A book-style layout optimized for long-form reading.
@@ -59,7 +58,7 @@ theme:
- Optimized typography for long content
- Light and dark mode
-## Fanfold Theme
+## Fanfold theme
A continuous-form line printer look — tractor-feed strips down both edges, faint
zebra banding behind the page, and monospace type throughout.
@@ -78,7 +77,7 @@ theme:
### Features
- Header block prints the breadcrumb trail and a page counter, then whatever the
- page gives as [`identifiers`](/docs/frontmatter) — falling back to the site
+ page gives as [`identifiers`](/docs/reference/frontmatter) — falling back to the site
name and path
- Page title set in a dot-matrix face that steps down in size as titles get longer
- Everything on the sheet is held to one 80-column measure, so prose, tables,
@@ -90,7 +89,7 @@ theme:
prose in it has its columns measured at build time, and folds that column under
each row behind a toggle, so the paragraph reads at the full width of the sheet
instead of a narrow column
-- Uses a page's [`short`](/docs/frontmatter) label in the rail and the header trail
+- Uses a page's [`short`](/docs/reference/frontmatter) label in the rail and the header trail
- Its own landing page: a masthead over a register of every section
- Light and dark mode
@@ -107,3 +106,28 @@ Set it in `chronicle.yaml`:
latest:
landing: true
```
+
+## Recolouring a theme
+
+Every theme draws from a set of colour tokens. Override any of them under
+`theme.colors`:
+
+```yaml
+theme:
+ name: default
+ colors:
+ background-base-primary: "#fbfaf8"
+ foreground-accent-primary: "#0a3793"
+ border-base-primary: "#e6e1d8"
+```
+
+The `--rs-color-` prefix is added for you, so name the token on its own. To
+reach a variable outside that family — the ones a theme defines for itself, like
+`--paper-ink` or `--fan-rule` — write the whole property name instead.
+
+One value covers both light and dark. If a token needs to differ between them,
+leave it out and let the theme decide.
+
+Values have to look like colours: a hex code, `rgb()`, `hsl()`, `color-mix()`, a
+named colour, or a `var()` reference. Anything else is ignored rather than
+written into the page.
diff --git a/docs/content/docs/writing/authors.mdx b/docs/content/docs/writing/authors.mdx
new file mode 100644
index 00000000..2ea7aadb
--- /dev/null
+++ b/docs/content/docs/writing/authors.mdx
@@ -0,0 +1,85 @@
+---
+title: Authors and bylines
+description: Credit the people who write your docs, and give each of them a page.
+order: 6
+---
+
+Naming an author puts a byline on the page, credits them in the page's structured
+data and on its social card, and gives them a page listing everything they wrote.
+
+## Crediting a page
+
+Add `authors` to the page's frontmatter. Each entry is a plain string — either
+`Name ` or just a name:
+
+```yaml
+authors:
+ - Jane Doe
+ - Sam Patel
+```
+
+A single author does not need the list:
+
+```yaml
+authors: Jane Doe
+```
+
+That is all you need. Nothing has to be registered first, which keeps the door
+open for an occasional contributor.
+
+## The byline
+
+The byline shows two names at most. Any more collapse into a `+N` counter that
+names the rest on hover, so a page with six authors does not push its own title
+down the screen.
+
+Each name carries an avatar. Without a picture to use, it is drawn from the
+author's initials.
+
+## The authors registry
+
+Writing the same name and email on forty pages gets old, and it means forty
+places to edit when something changes. Put the details in `chronicle.yaml`
+instead:
+
+```yaml
+authors:
+ jane:
+ name: Jane Doe
+ bio: Writes about distributed systems.
+ avatar: /team/jane.png
+ url: https://github.com/jane
+ email: jane@example.com
+```
+
+Then reference the key from a page:
+
+```yaml
+authors: [jane]
+```
+
+The bio, avatar and profile link come along with it. See
+[`authors`](/docs/reference/config) for the full field list.
+
+A frontmatter string that matches no key is still valid — it renders as a plain
+name. So you can register the people who write often and spell out the rest.
+
+## Author pages
+
+Every author gets a page at `/authors/` listing everything they wrote, and
+every byline name links to it. The site's full list is at `/authors`.
+
+An author's `url` and `email` are shown on their page rather than in the byline,
+which keeps the byline to names and the contact details in one predictable place.
+
+## Where else authors show up
+
+| Place | What it uses |
+|---|---|
+| The byline | Name and avatar |
+| The author's page | Name, bio, avatar, `url`, `email` |
+| `Article` structured data | Author names, for search engines |
+| The social card | Author names, under the title |
+
+The social card is the image people see when a page is shared. See
+[Generated routes](/docs/reference/routes) for how it is made.
diff --git a/docs/content/docs/components.mdx b/docs/content/docs/writing/components.mdx
similarity index 99%
rename from docs/content/docs/components.mdx
rename to docs/content/docs/writing/components.mdx
index 84f3633a..434a3343 100644
--- a/docs/content/docs/components.mdx
+++ b/docs/content/docs/writing/components.mdx
@@ -1,11 +1,9 @@
---
title: Components
description: MDX components and admonitions supported in Chronicle.
-order: 6
+order: 3
---
-# Components
-
Chronicle provides built-in MDX components that enhance standard markdown with interactive elements and styled content.
## Callout
@@ -243,7 +241,7 @@ Images support both local and external sources:
Links are automatically handled:
```mdx
-[Internal link](/docs/configuration)
+[Internal link](/docs/reference/config)
[External link](https://github.com)
[Anchor link](#section)
```
diff --git a/docs/content/docs/image-optimization.mdx b/docs/content/docs/writing/images.mdx
similarity index 71%
rename from docs/content/docs/image-optimization.mdx
rename to docs/content/docs/writing/images.mdx
index a3d82f5a..35b6f9d4 100644
--- a/docs/content/docs/image-optimization.mdx
+++ b/docs/content/docs/writing/images.mdx
@@ -1,14 +1,40 @@
---
-title: Image Optimization
-description: Automatic image optimization with on-demand resizing and format conversion.
-order: 5
+title: Images
+description: How images resolve, and the resizing and format conversion Chronicle does for you.
+order: 4
---
-# Image Optimization
+## Adding an image
-Chronicle automatically optimizes content images via an on-demand `/api/image` endpoint. Images are resized, converted to modern formats (WebP/AVIF), and cached on disk.
+Put the file next to the page that uses it and reference it relatively:
-## How It Works
+```mdx
+
+```
+
+A path starting with `/` is resolved from the root of the content directory
+instead, which is what you want for an image several pages share:
+
+```mdx
+
+```
+
+Either way Chronicle rewrites the URL at build time and appends a hash of the
+file's contents. That hash is why an image can be cached forever by a browser and
+still update the moment you change the file.
+
+Images in `public/` are different — they are served exactly as you put them
+there, with no rewriting and no optimization. Use `public/` for a favicon or a
+download, and keep content images next to their pages.
+
+## Optimization
+
+Everything below happens on its own. There is nothing to turn on.
+
+Content images are resized, re-encoded to a modern format, and cached on disk,
+through an on-demand `/api/image` endpoint.
+
+## How it works
1. The remark plugin rewrites all image URLs to route through `/api/image`
2. On first request, the image is resized and converted based on browser support
diff --git a/docs/content/docs/writing/links.mdx b/docs/content/docs/writing/links.mdx
new file mode 100644
index 00000000..ae9a4203
--- /dev/null
+++ b/docs/content/docs/writing/links.mdx
@@ -0,0 +1,98 @@
+---
+title: Links and redirects
+description: How links between pages resolve, and how to keep old URLs working after you move one.
+order: 5
+---
+
+## Linking between pages
+
+Use the page's URL, starting from the root:
+
+```mdx
+See [Navigation](/docs/writing/navigation) for ordering.
+```
+
+Internal links are resolved when the page is built, so a link to a page that
+does not exist is caught then rather than becoming a 404 for a reader. Internal
+links also navigate without a full page load.
+
+Relative links work too, and are resolved against the current page:
+
+```mdx
+See [Navigation](./navigation) for ordering.
+```
+
+Prefer absolute paths for anything that crosses a folder. A relative link breaks
+quietly when you move the page holding it; an absolute one keeps working.
+
+### Linking to a section
+
+Add the heading's slug. Slugs are the heading text, lowercased, with spaces
+turned into hyphens:
+
+```mdx
+[Ordering pages](/docs/writing/navigation#ordering-pages)
+```
+
+### External links
+
+Write them as normal. They open in a new tab.
+
+```mdx
+[Nitro](https://nitro.build)
+```
+
+## Redirects
+
+Move or rename a page and its old URL stops working. Add a redirect so anyone
+holding the old link — a bookmark, a blog post, a search result — still lands in
+the right place.
+
+```yaml
+redirects:
+ - from: /docs/old-page
+ to: /docs/writing/pages
+ - from: /legacy/api
+ to: /apis
+ permanent: true
+```
+
+Redirects are checked before every other route, so they win over a real page at
+the same path.
+
+| Field | What it does |
+|---|---|
+| `from` | The old path |
+| `to` | Where to send it |
+| `permanent` | `true` sends 308, `false` (default) sends 307 |
+
+### Which status to use
+
+Use `permanent: true` when the old URL is never coming back. Browsers and search
+engines cache a 308 hard, so a mistake is expensive to undo — a reader whose
+browser cached it will keep being redirected after you remove the rule.
+
+Use the default 307 while you are still deciding. Nothing is cached, so you can
+change your mind.
+
+### A worked example
+
+Say you split one page into a folder of three. The old URL was `/docs/config`:
+
+```yaml
+redirects:
+ - from: /docs/config
+ to: /docs/reference/config
+ permanent: true
+```
+
+Anyone following an old link arrives at the new page. Meanwhile, update the
+links inside your own pages to point at the new URL directly — a redirect is for
+links you do not control, not a substitute for fixing your own.
+
+## Markdown links to your own pages
+
+Every page also answers at a `.md` URL that returns its raw source. This is what
+the "Open in AI" menu copies, and what an AI tool reads when it follows
+`llms.txt`. You do not have to do anything to get it. See
+[Generated routes](/docs/reference/routes).
diff --git a/docs/content/docs/writing/meta.json b/docs/content/docs/writing/meta.json
new file mode 100644
index 00000000..14b976ad
--- /dev/null
+++ b/docs/content/docs/writing/meta.json
@@ -0,0 +1 @@
+{ "title": "Writing docs", "order": 4 }
diff --git a/docs/content/docs/writing/navigation.mdx b/docs/content/docs/writing/navigation.mdx
new file mode 100644
index 00000000..13a14ce7
--- /dev/null
+++ b/docs/content/docs/writing/navigation.mdx
@@ -0,0 +1,140 @@
+---
+title: Navigation
+description: Order pages, name folders, and shape the sidebar with meta.json.
+order: 2
+---
+
+The sidebar is built from your folders. You do not write a navigation file — you
+name and order what is already there.
+
+## Ordering pages
+
+Add `order` to a page's frontmatter. Lower numbers come first.
+
+```mdx
+---
+title: Installation
+order: 1
+---
+```
+
+Pages with no `order` come after every page that has one, so you can order the
+important pages and leave the rest.
+
+### Slotting a page between two others
+
+`order` takes decimals. This is the whole reason it does — you can insert a page
+without renumbering its neighbours:
+
+```
+introduction.mdx order: 1
+installation.mdx order: 1.5 ← new page, lands between the two
+configuration.mdx order: 2
+```
+
+## Folders become groups
+
+A folder is a group in the sidebar. Its name is derived from the folder name,
+capitalised:
+
+```
+content/docs/
+├── index.mdx
+├── quick-start.mdx
+└── guides/
+ ├── setup.mdx
+ └── advanced.mdx
+```
+
+```
+Quick start
+▸ Guides
+ Setup
+ Advanced
+```
+
+Pages and folders sort against each other on one scale, so where a folder lands
+depends on its `order`, not on it being a folder. Without one it goes last.
+
+## meta.json
+
+Drop a `meta.json` in a folder to control the folder itself. The folder name is
+only a fallback.
+
+```json
+{
+ "title": "Getting started",
+ "order": 2
+}
+```
+
+| Field | What it does |
+|---|---|
+| `title` | The group label. Overrides the capitalised folder name |
+| `order` | Where the group sits. Shares one scale with page `order` |
+| `pages` | Explicit page order, by filename without the extension |
+| `root` | `true` makes this folder a navigation boundary |
+
+A folder's position comes from `meta.json` only. The `order` in its `index.mdx`
+frontmatter positions that page inside the group — it does not move the group.
+
+### One scale for pages and folders
+
+Pages and folders sort against each other using the same numbers, so a group can
+sit between two loose pages:
+
+```
+index.mdx order: 1
+quick-start.mdx order: 2
+guides/meta.json order: 3 ← the group lands here
+reference/meta.json order: 4
+```
+
+### Ordering by filename
+
+If you would rather list the pages than number them, use `pages`:
+
+```json
+{
+ "title": "Guides",
+ "pages": ["introduction", "installation", "configuration"]
+}
+```
+
+Anything not listed follows the listed pages.
+
+## Short labels for a narrow rail
+
+When a title is too long for the sidebar but the page is known by a code its
+readers already use, add `short`:
+
+```yaml
+title: Space Packet Protocol
+short: SPP
+```
+
+The sidebar shows `SPP` and keeps the full title on the link's tooltip. Headings,
+breadcrumbs, the browser tab and search all keep using `title`.
+
+This is read by the `fanfold` theme, whose rail is narrow enough to need it. The
+`default` and `paper` themes always show `title`.
+
+## Icons
+
+`icon` puts a small graphic next to a sidebar entry:
+
+```yaml
+icon: rectangle-stack
+```
+
+The available values are listed under
+[`icon`](/docs/reference/frontmatter) in the frontmatter reference.
+
+## What else the navigation drives
+
+The same tree feeds the breadcrumbs at the top of a page, the previous and next
+links at the bottom, and the order results come back in from search. Order the
+tree well and all four improve at once.
+
+A page with `draft: true` is removed from the tree, so it disappears from all of
+them. See [Pages and frontmatter](/docs/writing/pages).
diff --git a/docs/content/docs/writing/pages.mdx b/docs/content/docs/writing/pages.mdx
new file mode 100644
index 00000000..3c2cb1f7
--- /dev/null
+++ b/docs/content/docs/writing/pages.mdx
@@ -0,0 +1,119 @@
+---
+title: Pages and frontmatter
+description: How a file becomes a page, and the frontmatter you will use every day.
+order: 1
+---
+
+A page is an `.mdx` file in a content directory. Create the file and it is on the
+site — there is nothing to register.
+
+## Files become URLs
+
+The path under a content directory is the URL:
+
+```
+content/docs/index.mdx → /docs
+content/docs/hello.mdx → /docs/hello
+content/docs/guides/setup.mdx → /docs/guides/setup
+content/docs/guides/index.mdx → /docs/guides
+```
+
+`index.mdx` is the page for the folder that holds it. `readme.mdx` does the same
+job, which is useful when the folder is also browsed on GitHub.
+
+## Frontmatter
+
+Every page opens with a YAML block. `title` is the only one you always want:
+
+```mdx
+---
+title: Installing the CLI
+description: Get the command line tool onto your machine.
+order: 2
+---
+
+The CLI ships as a single binary.
+
+## Requirements
+```
+
+The four fields you will use on nearly every page:
+
+| Field | What it does |
+|---|---|
+| `title` | The heading above the article, the sidebar label, the browser tab. Always set it |
+| `description` | The line under the heading, plus the meta description and social card |
+| `order` | Where the page sits in the sidebar. Lower first |
+| `draft` | `true` keeps the page out of the site while you write it |
+
+There are nine more fields for narrower jobs. See
+[Frontmatter fields](/docs/reference/frontmatter) for the full list.
+
+## Do not repeat the title
+
+Every theme prints `title` above the article. A page that opens with its own
+`# Installing the CLI` shows the same words twice.
+
+```mdx
+---
+title: Installing the CLI
+---
+
+# Installing the CLI ← delete this
+
+The CLI ships as a single binary.
+```
+
+Start with your first sentence, and use `##` and below for sections. Those are
+what the table of contents lists — a `#` in the body never appears there.
+
+## Writing MDX
+
+MDX is markdown plus components. Everything you expect from markdown works:
+headings, lists, tables, links, code fences, block quotes, bold and italic.
+
+On top of that Chronicle gives you callouts, tabs, badges, collapsible sections
+and Mermaid diagrams. See [Components](/docs/writing/components).
+
+Code fences are highlighted by Shiki. Name the language on the fence:
+
+````mdx
+```bash
+chronicle dev
+```
+````
+
+A fence with no language is rendered as plain text rather than failing, so an
+ASCII diagram is safe to paste in.
+
+## Hiding a page while you write it
+
+Set `draft: true`:
+
+```mdx
+---
+title: Not finished yet
+draft: true
+---
+```
+
+The file stays where it is, but the page is dropped from the navigation tree — so
+it is out of the sidebar, breadcrumbs, search, and the previous and next links.
+This is how you keep unfinished work in the repository.
+
+## Reading time and last modified
+
+Reading time is measured from the page body and shown by the themes that have
+somewhere to put it. You do not set it.
+
+`lastModified` is a date you set yourself when you want the page to state one:
+
+```yaml
+lastModified: "2026-03-30"
+```
+
+## Next
+
+- [Navigation](/docs/writing/navigation) — ordering pages and naming folders
+- [Components](/docs/writing/components) — callouts, tabs, diagrams
+- [Frontmatter fields](/docs/reference/frontmatter) — all thirteen fields
diff --git a/docs/public/logo-dark.svg b/docs/public/logo-dark.svg
new file mode 100644
index 00000000..fcd8483f
--- /dev/null
+++ b/docs/public/logo-dark.svg
@@ -0,0 +1,3 @@
+
diff --git a/docs/public/logo.svg b/docs/public/logo.svg
new file mode 100644
index 00000000..623630b1
--- /dev/null
+++ b/docs/public/logo.svg
@@ -0,0 +1,3 @@
+
diff --git a/examples/basic/content/docs/api/endpoints.mdx b/examples/basic/content/docs/api/endpoints.mdx
index 68b236cb..2b76578c 100644
--- a/examples/basic/content/docs/api/endpoints.mdx
+++ b/examples/basic/content/docs/api/endpoints.mdx
@@ -4,8 +4,6 @@ description: Available CLI commands
order: 2
---
-# CLI Commands
-
## chronicle init
Initialize a new Chronicle project.
diff --git a/examples/basic/content/docs/api/overview.mdx b/examples/basic/content/docs/api/overview.mdx
index 2b36a29b..10be0a63 100644
--- a/examples/basic/content/docs/api/overview.mdx
+++ b/examples/basic/content/docs/api/overview.mdx
@@ -4,8 +4,6 @@ description: Overview of the Chronicle API
order: 1
---
-# API Overview
-
Chronicle provides a simple API for building documentation.
## Core Concepts
diff --git a/examples/basic/content/docs/components.mdx b/examples/basic/content/docs/components.mdx
index 94d62759..eb777adc 100644
--- a/examples/basic/content/docs/components.mdx
+++ b/examples/basic/content/docs/components.mdx
@@ -5,8 +5,6 @@ order: 3
authors: [jane]
---
-# Components
-
Live rendering of the MDX components Chronicle registers for content files.
## Badge
diff --git a/examples/basic/content/docs/features.mdx b/examples/basic/content/docs/features.mdx
index 6c31410b..c4761783 100644
--- a/examples/basic/content/docs/features.mdx
+++ b/examples/basic/content/docs/features.mdx
@@ -5,8 +5,6 @@ order: 2
draft: true
---
-# Features
-
Chronicle is a self-hosted documentation platform built with Vite + Nitro. Here's what it offers.
## Content
diff --git a/examples/basic/content/docs/getting-started.mdx b/examples/basic/content/docs/getting-started.mdx
index 69f16e5a..716cb179 100644
--- a/examples/basic/content/docs/getting-started.mdx
+++ b/examples/basic/content/docs/getting-started.mdx
@@ -7,8 +7,6 @@ authors:
- Sam Patel
---
-# Getting Started
-
Get up and running with Chronicle in minutes.
## Prerequisites
diff --git a/examples/basic/content/docs/guides/configuration.mdx b/examples/basic/content/docs/guides/configuration.mdx
index 95f5ac18..24494649 100644
--- a/examples/basic/content/docs/guides/configuration.mdx
+++ b/examples/basic/content/docs/guides/configuration.mdx
@@ -4,8 +4,6 @@ description: Configure Chronicle options
order: 2
---
-# Configuration
-
Chronicle uses a `chronicle.yaml` file for configuration.
## Basic Configuration
diff --git a/examples/basic/content/docs/guides/deployment.mdx b/examples/basic/content/docs/guides/deployment.mdx
index 89f54f1d..c6e9827d 100644
--- a/examples/basic/content/docs/guides/deployment.mdx
+++ b/examples/basic/content/docs/guides/deployment.mdx
@@ -4,8 +4,6 @@ description: Deploy Chronicle to production
order: 3
---
-# Deployment
-
Chronicle builds to a standalone Node.js server that can be deployed anywhere.
## Build
diff --git a/examples/basic/content/docs/guides/installation.mdx b/examples/basic/content/docs/guides/installation.mdx
index f43dc85c..8447adc1 100644
--- a/examples/basic/content/docs/guides/installation.mdx
+++ b/examples/basic/content/docs/guides/installation.mdx
@@ -4,8 +4,6 @@ description: Detailed installation instructions
order: 1
---
-# Installation
-
## System Requirements
| Requirement | Version |
diff --git a/examples/basic/content/docs/index.mdx b/examples/basic/content/docs/index.mdx
index 450435a2..1938c8af 100644
--- a/examples/basic/content/docs/index.mdx
+++ b/examples/basic/content/docs/index.mdx
@@ -4,7 +4,8 @@ description: Getting started with Chronicle documentation
order: 1
---
-# Heading 1
+The page title above comes from frontmatter, so the headings below start at
+`##`. A page body has no `#` of its own.
## Heading 2
diff --git a/examples/basic/content/docs/tips-&-tricks.mdx b/examples/basic/content/docs/tips-&-tricks.mdx
index 9a89d35b..3bc5ec30 100644
--- a/examples/basic/content/docs/tips-&-tricks.mdx
+++ b/examples/basic/content/docs/tips-&-tricks.mdx
@@ -4,6 +4,4 @@ description: Useful tips and tricks
order: 10
---
-# Tips & Tricks
-
Some helpful tips and tricks for using Chronicle.
diff --git a/examples/versioned/content/dev/api.mdx b/examples/versioned/content/dev/api.mdx
index ec45b23a..a00284e4 100644
--- a/examples/versioned/content/dev/api.mdx
+++ b/examples/versioned/content/dev/api.mdx
@@ -4,8 +4,6 @@ order: 2
authors: [jane, Sam Patel, Ana Ruiz ]
---
-# Dev API notes — latest
-
Latest `/dev/api`.
- [Dev Home](./index.mdx)
diff --git a/examples/versioned/content/dev/index.mdx b/examples/versioned/content/dev/index.mdx
index 1ff62d55..a9056ff6 100644
--- a/examples/versioned/content/dev/index.mdx
+++ b/examples/versioned/content/dev/index.mdx
@@ -3,8 +3,6 @@ title: Dev home (3.0)
order: 1
---
-# Dev — latest
-
Latest `/dev`.
- [API Notes](./api.mdx)
diff --git a/examples/versioned/content/docs/guide.mdx b/examples/versioned/content/docs/guide.mdx
index 30d3ba21..2dffea7b 100644
--- a/examples/versioned/content/docs/guide.mdx
+++ b/examples/versioned/content/docs/guide.mdx
@@ -4,8 +4,6 @@ order: 2
authors: [jane]
---
-# Guide — latest docs
-
Latest `/docs/guide`.
- [Docs Home](./index.mdx)
diff --git a/examples/versioned/content/docs/index.mdx b/examples/versioned/content/docs/index.mdx
index 289077b8..b24e5d4c 100644
--- a/examples/versioned/content/docs/index.mdx
+++ b/examples/versioned/content/docs/index.mdx
@@ -4,8 +4,6 @@ description: Latest docs landing
order: 1
---
-# Docs — latest
-
This is `/docs` on latest (3.0).
## Links
diff --git a/examples/versioned/versions/v1/dev/index.mdx b/examples/versioned/versions/v1/dev/index.mdx
index e92a6fe5..0b6d3154 100644
--- a/examples/versioned/versions/v1/dev/index.mdx
+++ b/examples/versioned/versions/v1/dev/index.mdx
@@ -3,6 +3,4 @@ title: Developer Guide (1.0)
order: 1
---
-# Developer Guide — v1
-
In v1, `dev/` was relabeled "Developer Guide" and came first.
diff --git a/examples/versioned/versions/v1/docs/index.mdx b/examples/versioned/versions/v1/docs/index.mdx
index 1da05910..481cc0f4 100644
--- a/examples/versioned/versions/v1/docs/index.mdx
+++ b/examples/versioned/versions/v1/docs/index.mdx
@@ -3,6 +3,4 @@ title: Docs home (1.0)
order: 2
---
-# Docs — v1
-
`/v1/docs` landing. Legacy version.
diff --git a/examples/versioned/versions/v2/docs/guide.mdx b/examples/versioned/versions/v2/docs/guide.mdx
index 100acedd..90ca2011 100644
--- a/examples/versioned/versions/v2/docs/guide.mdx
+++ b/examples/versioned/versions/v2/docs/guide.mdx
@@ -3,6 +3,4 @@ title: Guide (v2)
order: 2
---
-# Guide — v2
-
`/v2/docs/guide`.
diff --git a/examples/versioned/versions/v2/docs/index.mdx b/examples/versioned/versions/v2/docs/index.mdx
index bb623bbb..bed465b7 100644
--- a/examples/versioned/versions/v2/docs/index.mdx
+++ b/examples/versioned/versions/v2/docs/index.mdx
@@ -3,6 +3,4 @@ title: Docs home (2.0)
order: 1
---
-# Docs — v2
-
`/v2/docs` landing.
diff --git a/packages/chronicle/src/cli/commands/init.ts b/packages/chronicle/src/cli/commands/init.ts
index 39fe84bb..dd1a4989 100644
--- a/packages/chronicle/src/cli/commands/init.ts
+++ b/packages/chronicle/src/cli/commands/init.ts
@@ -15,14 +15,13 @@ export const defaultInitConfig: ChronicleConfig = {
search: { enabled: true, placeholder: 'Search documentation...' }
};
+// No `# Welcome`: themes print the frontmatter title above the article.
const sampleMdx = `---
title: Welcome
description: Getting started with your documentation
order: 1
---
-# Welcome
-
This is your documentation home page.
`;
diff --git a/packages/chronicle/src/cli/commands/static-generate.ts b/packages/chronicle/src/cli/commands/static-generate.ts
index 10760b2b..b61be5f4 100644
--- a/packages/chronicle/src/cli/commands/static-generate.ts
+++ b/packages/chronicle/src/cli/commands/static-generate.ts
@@ -22,7 +22,7 @@ import { buildLlmsTxt, type LlmsPage } from '@/lib/llms';
import { DEFAULT_WIDTH, DEFAULT_QUALITY, isLocalImage, isSvg, splitVersion } from '@/lib/image-utils';
import { isAnimatedImage } from '@/lib/image-animation';
import { getAssetVersion } from '@/lib/asset-version';
-import type { VersionContext } from '@/lib/version-source';
+import { contentSectionPrefixes, sectionOf, type VersionContext } from '@/lib/version-source';
import type { Frontmatter, PageNavLink } from '@/types';
import { buildAuthorIndex } from '@/lib/author-index';
import { normalizeAuthorList, resolveAuthors } from '@/lib/authors';
@@ -367,19 +367,33 @@ function flattenTreeUrls(tree: PageTreeRoot): { url: string; title: string }[] {
return result;
}
-function computeNavigation(tree: PageTreeRoot): Map {
+function computeNavigation(
+ tree: PageTreeRoot,
+ config: ChronicleConfig,
+): Map {
const navMap = new Map();
- const ordered = flattenTreeUrls(tree);
-
- for (let i = 0; i < ordered.length; i++) {
- navMap.set(ordered[i].url, {
- prev: i > 0
- ? { url: ordered[i - 1].url, title: ordered[i - 1].title }
- : null,
- next: i < ordered.length - 1
- ? { url: ordered[i + 1].url, title: ordered[i + 1].title }
- : null,
- });
+
+ // Chained per section, matching `getNavMap` in source.ts.
+ const prefixes = contentSectionPrefixes(config);
+ const bySection = new Map>();
+ for (const entry of flattenTreeUrls(tree)) {
+ const key = sectionOf(entry.url, prefixes) ?? '';
+ const group = bySection.get(key);
+ if (group) group.push(entry);
+ else bySection.set(key, [entry]);
+ }
+
+ for (const ordered of bySection.values()) {
+ for (let i = 0; i < ordered.length; i++) {
+ navMap.set(ordered[i].url, {
+ prev: i > 0
+ ? { url: ordered[i - 1].url, title: ordered[i - 1].title }
+ : null,
+ next: i < ordered.length - 1
+ ? { url: ordered[i + 1].url, title: ordered[i + 1].title }
+ : null,
+ });
+ }
}
return navMap;
@@ -1064,7 +1078,7 @@ export async function generateStaticSite(options: StaticGenerateOptions): Promis
const contentMirror = path.resolve(packageRoot, '.content');
const folderMeta = await scanFolderMeta(contentMirror, config);
const tree = buildPageTree(pages, config, folderMeta);
- const navMap = computeNavigation(tree);
+ const navMap = computeNavigation(tree, config);
// Generate all static assets
console.log(chalk.gray(' Generating page data files...'));
diff --git a/packages/chronicle/src/components/ui/logo.module.css b/packages/chronicle/src/components/ui/logo.module.css
new file mode 100644
index 00000000..166407a8
--- /dev/null
+++ b/packages/chronicle/src/components/ui/logo.module.css
@@ -0,0 +1,17 @@
+/* Explicit tokens rather than `currentColor`: Apsara colours its text
+ components, not their containers, so the inherited colour is black in both
+ themes and a mark drawn from it disappears on a dark ground. */
+.initial {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ flex-shrink: 0;
+ border-radius: var(--rs-radius-3);
+ background: var(--rs-color-background-neutral-secondary);
+ color: var(--rs-color-foreground-base-primary);
+ font-weight: 600;
+ line-height: 1;
+ letter-spacing: 0;
+ text-transform: none;
+ user-select: none;
+}
diff --git a/packages/chronicle/src/components/ui/logo.tsx b/packages/chronicle/src/components/ui/logo.tsx
new file mode 100644
index 00000000..db4f2831
--- /dev/null
+++ b/packages/chronicle/src/components/ui/logo.tsx
@@ -0,0 +1,87 @@
+'use client';
+
+import { useTheme } from '@raystack/apsara';
+import { cx } from 'class-variance-authority';
+import { siteInitial } from '@/lib/site-initial';
+import type { ChronicleConfig } from '@/types';
+import styles from './logo.module.css';
+
+interface SiteInitialProps {
+ title: string;
+ size?: number;
+ className?: string;
+ /** Omit to hide it from assistive technology. */
+ label?: string;
+}
+
+/** A site's first letter in a tinted box. The mark for a site that sets none. */
+export function SiteInitial({
+ title,
+ size = 28,
+ className,
+ label
+}: SiteInitialProps) {
+ return (
+
+ {siteInitial(title)}
+
+ );
+}
+
+interface LogoProps {
+ config: ChronicleConfig;
+ /** Box the logo is drawn in, in px. Square. */
+ size?: number;
+ className?: string;
+ /**
+ * Whether assistive technology should announce it. Pass `false` where the
+ * site name is already rendered beside it, or it is read out twice.
+ */
+ labelled?: boolean;
+}
+
+/**
+ * A site's logo: whichever of `logo.light` / `logo.dark` suits the active
+ * theme, falling back to the site's initial when it sets neither.
+ */
+export function Logo({
+ config,
+ size = 28,
+ className,
+ labelled = true
+}: LogoProps) {
+ const { resolvedTheme } = useTheme();
+ const logo = config.logo;
+
+ const src =
+ resolvedTheme === 'dark'
+ ? (logo?.dark ?? logo?.light)
+ : (logo?.light ?? logo?.dark);
+
+ if (src) {
+ return (
+
+ );
+ }
+
+ return (
+
+ );
+}
diff --git a/packages/chronicle/src/lib/remark-resolve-images.test.ts b/packages/chronicle/src/lib/remark-resolve-images.test.ts
index c4d9d1a7..18e2b1b2 100644
--- a/packages/chronicle/src/lib/remark-resolve-images.test.ts
+++ b/packages/chronicle/src/lib/remark-resolve-images.test.ts
@@ -141,3 +141,62 @@ describe('remark-resolve-images version stamping', () => {
expect(firstImageUrl(tree)).toBe(`/_content/docs/img.png?v=${PNG_HASH}`);
});
});
+
+describe('versioned pages', () => {
+ function setupVersionsDir(): string {
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), 'chronicle-remark-v-'));
+ const versionsDir = path.join(root, 'versions');
+ fs.mkdirSync(path.join(versionsDir, 'v1', 'docs'), { recursive: true });
+ fs.writeFileSync(path.join(versionsDir, 'v1', 'docs', 'img.png'), PNG_BYTES);
+ return versionsDir;
+ }
+
+ test('exports images for a page under versions/', async () => {
+ // `valueToExport` names `images`, so a page that never sets it fails the
+ // production build with a missing-export error. Every versioned page used
+ // to take that path, which meant no versioned site could be built.
+ const versionsDir = setupVersionsDir();
+ const { file } = await transform(
+ 'no images here',
+ undefined,
+ versionsDir,
+ path.join(versionsDir, 'v1', 'docs', 'page.mdx')
+ );
+ expect(file.data.images).toEqual([]);
+ });
+
+ test('resolves a relative image under versions/ against the mirror path', async () => {
+ const versionsDir = setupVersionsDir();
+ const { tree } = await transform(
+ '',
+ { optimize: false },
+ versionsDir,
+ path.join(versionsDir, 'v1', 'docs', 'page.mdx')
+ );
+ expect(firstImageUrl(tree)).toBe(`/_content/v1/docs/img.png?v=${PNG_HASH}`);
+ });
+
+ test('takes the deepest marker when a project sits under a versions dir', async () => {
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), 'chronicle-remark-d-'));
+ const nested = path.join(root, 'versions', 'project', 'content');
+ fs.mkdirSync(path.join(nested, 'docs'), { recursive: true });
+ fs.writeFileSync(path.join(nested, 'docs', 'img.png'), PNG_BYTES);
+ const { tree } = await transform(
+ '',
+ { optimize: false },
+ nested,
+ path.join(nested, 'docs', 'page.mdx')
+ );
+ expect(firstImageUrl(tree)).toBe(`/_content/docs/img.png?v=${PNG_HASH}`);
+ });
+
+ test('exports an empty list when the path is outside any content root', async () => {
+ const { file } = await transform(
+ 'stray file',
+ undefined,
+ '/tmp',
+ '/tmp/elsewhere/page.mdx'
+ );
+ expect(file.data.images).toEqual([]);
+ });
+});
diff --git a/packages/chronicle/src/lib/remark-resolve-images.ts b/packages/chronicle/src/lib/remark-resolve-images.ts
index 836de3e7..f4469db4 100644
--- a/packages/chronicle/src/lib/remark-resolve-images.ts
+++ b/packages/chronicle/src/lib/remark-resolve-images.ts
@@ -57,18 +57,40 @@ function finalizeUrl(url: string, optimize: boolean, version?: string): string {
const IMG_SRC_PATTERN = /(]*\bsrc=["'])([^"']+)(["'])/gi
+/**
+ * Splits a page's path at its content root. The part below the split is also
+ * its path under `/_content/`, which is what image URLs are built from —
+ * `content/docs/` mirrors as `docs/`, `versions/v1/docs/` as `v1/docs/`.
+ *
+ * Takes the deepest marker, since a project can sit inside a directory named
+ * `versions` and still keep its pages under `content/`.
+ */
+function splitContentRoot(
+ filePath: string,
+): { root: string; relative: string } | null {
+ let end = -1
+ for (const marker of ['/versions/', '/content/']) {
+ const idx = filePath.lastIndexOf(marker)
+ if (idx !== -1) end = Math.max(end, idx + marker.length)
+ }
+ if (end === -1) return null
+ return { root: filePath.slice(0, end), relative: filePath.slice(end) }
+}
+
const remarkResolveImages: Plugin<[RemarkResolveImagesOptions?]> = (options) => {
const optimize = options?.optimize ?? true
return async (tree, file) => {
+ // `valueToExport` names `images`, so returning without it fails the build.
+ file.data.images = []
+
const filePath = file.path?.replace(/\\/g, '/')
if (!filePath) return
- const contentIdx = filePath.lastIndexOf('/content/')
- if (contentIdx === -1) return
+ const split = splitContentRoot(filePath)
+ if (!split) return
- const relative = filePath.slice(contentIdx + '/content/'.length)
+ const { root: contentRoot, relative } = split
const dir = path.posix.dirname(relative)
- const contentRoot = filePath.slice(0, contentIdx + '/content/'.length)
const seen = new Set()
const images: string[] = []
diff --git a/packages/chronicle/src/lib/site-initial.test.ts b/packages/chronicle/src/lib/site-initial.test.ts
new file mode 100644
index 00000000..fbc833e9
--- /dev/null
+++ b/packages/chronicle/src/lib/site-initial.test.ts
@@ -0,0 +1,24 @@
+import { describe, expect, test } from 'bun:test'
+import { siteInitial } from './site-initial'
+
+describe('siteInitial', () => {
+ test('takes the first letter, uppercased', () => {
+ expect(siteInitial('Chronicle')).toBe('C')
+ expect(siteInitial('my documentation')).toBe('M')
+ })
+
+ test('ignores surrounding space', () => {
+ expect(siteInitial(' Chronicle ')).toBe('C')
+ })
+
+ test('keeps a whole codepoint', () => {
+ // Splitting on UTF-16 units would return half a surrogate pair.
+ expect(siteInitial('🚀 Launch')).toBe('🚀')
+ expect(siteInitial('日本語ドキュメント')).toBe('日')
+ })
+
+ test('falls back when there is nothing to take', () => {
+ expect(siteInitial('')).toBe('?')
+ expect(siteInitial(' ')).toBe('?')
+ })
+})
diff --git a/packages/chronicle/src/lib/site-initial.ts b/packages/chronicle/src/lib/site-initial.ts
new file mode 100644
index 00000000..eb281303
--- /dev/null
+++ b/packages/chronicle/src/lib/site-initial.ts
@@ -0,0 +1,8 @@
+/**
+ * The letter a site is reduced to when it sets no `logo`. Codepoint-safe, so an
+ * emoji or a non-Latin script survives being taken apart.
+ */
+export function siteInitial(title: string): string {
+ const first = Array.from(title.trim())[0]
+ return first ? first.toUpperCase() : '?'
+}
diff --git a/packages/chronicle/src/lib/source.ts b/packages/chronicle/src/lib/source.ts
index c377bd9a..6d9d6646 100644
--- a/packages/chronicle/src/lib/source.ts
+++ b/packages/chronicle/src/lib/source.ts
@@ -19,9 +19,11 @@ import {
loadConfig,
} from './config';
import {
+ contentSectionPrefixes,
filterPagesByVersion,
filterPageTreeByVersion,
resolveVersionFromUrl,
+ sectionOf,
type VersionContext,
} from './version-source';
import type { Frontmatter, PageNav, PageNavLink } from '@/types';
@@ -303,12 +305,25 @@ async function getNavMap(): Promise