diff --git a/scripts/build-search.mjs b/scripts/build-search.mjs
index fd857e5..5324f56 100644
--- a/scripts/build-search.mjs
+++ b/scripts/build-search.mjs
@@ -203,20 +203,28 @@ function writeLlms(root) {
const docMap = docPagesForLlms(root);
- // Order docs by the documentation tab's nav groups; skip landing pages.
+ // Order docs by the nav groups of every content tab (an OpenAPI tab has no
+ // groups of its own). Walking only the first one dropped the pages of any
+ // second content tab — e.g. a Guides tab — into the ungrouped "More" bucket.
const tabs = cfg.navigation?.tabs ?? [];
- const docTab = tabs.find((t) => !t.openapi) ?? tabs[0];
+ const docTabs = tabs.filter((t) => !t.openapi);
+ const walkTabs = docTabs.length ? docTabs : tabs.slice(0, 1);
+ // Group titles are only unique within a tab, so qualify them once there's
+ // more than one tab in play ("Guides · Overview").
+ const qualify = walkTabs.length > 1;
const sections = [];
const used = new Set();
- for (const g of docTab?.groups ?? []) {
- const pages = [];
- for (const p of g.pages ?? []) {
- const d = docMap.get(p.href);
- used.add(p.href);
- if (d && d.layout === "landing") continue;
- pages.push({ title: p.label ?? d?.title ?? p.href, url: p.href, lede: d?.lede ?? "" });
+ for (const tab of walkTabs) {
+ for (const g of tab.groups ?? []) {
+ const pages = [];
+ for (const p of g.pages ?? []) {
+ const d = docMap.get(p.href);
+ used.add(p.href);
+ if (d && d.layout === "landing") continue;
+ pages.push({ title: p.label ?? d?.title ?? p.href, url: p.href, lede: d?.lede ?? "" });
+ }
+ if (pages.length) sections.push({ group: qualify ? `${tab.label} · ${g.group}` : g.group, pages });
}
- if (pages.length) sections.push({ group: g.group, pages });
}
const leftover = [...docMap.values()].filter((d) => !used.has(d.url) && d.layout !== "landing");
const apis = apiResources(root);
diff --git a/site/docs/guides/fastapi.mdx b/site/docs/guides/fastapi.mdx
new file mode 100644
index 0000000..e637825
--- /dev/null
+++ b/site/docs/guides/fastapi.mdx
@@ -0,0 +1,229 @@
+---
+title: FastAPI
+lede: FastAPI already emits OpenAPI 3.1 with native webhooks. Dump it to a file at build time and shape the reference with openapi_extra.
+crumbs:
+ - label: Docs
+ href: /
+ - label: Guides
+ href: /guides
+ - label: FastAPI
+toc:
+ - id: emit-the-document
+ label: Emit the document
+ - id: tags-become-resources
+ label: Tags become resources
+ - id: order-the-sidebar
+ label: Order the sidebar
+ - id: events
+ label: Events
+ - id: code-samples
+ label: Code samples
+ - id: gotchas
+ label: Gotchas
+---
+
+FastAPI builds an OpenAPI document from your type hints and Pydantic models
+with no extra annotation, and since 0.99 it emits **3.1** — so native
+`webhooks` work out of the box. The only Markline-specific hook you need is
+`openapi_extra`, which merges arbitrary keys onto an operation.
+
+## Emit the document
+
+`app.openapi()` returns the document as a dict. Write it to `api/openapi.json`
+in your docs project as a build step rather than pointing the docs at a running
+server.
+
+```python
+# scripts/emit_openapi.py
+import json
+from pathlib import Path
+
+from app.main import app
+
+out = Path("../docs/api/openapi.json")
+out.parent.mkdir(parents=True, exist_ok=True)
+out.write_text(json.dumps(app.openapi(), indent=2))
+```
+
+```bash
+python scripts/emit_openapi.py && (cd ../docs && markline build)
+```
+
+The document's `info` block comes from the app constructor, and it matters —
+Markline derives the client name in generated code samples from the first word
+of `info.title`, and the version pill from `info.version`:
+
+```python
+from fastapi import FastAPI
+
+app = FastAPI(
+ title="Acme API", # → acme.accounts.create({ … }) in samples
+ version="1.4.2", # → the version pill
+ servers=[{"url": "https://api.acme.com"}],
+)
+```
+
+## Tags become resources
+
+Tags are what Markline groups by. Set them per-router so a whole module lands on
+one resource, and use slashes to nest:
+
+```python
+from fastapi import APIRouter
+
+router = APIRouter(prefix="/orders", tags=["store/orders"])
+```
+
+That renders a **Store** parent with an **Orders** child, routed at
+`/api-reference/store-orders`. See [Nested tags](/openapi#nested-tags).
+
+Control **resource** order — and add resource descriptions — with
+`openapi_tags`. Tags you omit sort after the listed ones, alphabetically:
+
+```python
+app = FastAPI(
+ title="Acme API",
+ openapi_tags=[
+ {"name": "accounts", "description": "Balances and statements."},
+ {"name": "payments"},
+ {"name": "store/orders"},
+ ],
+)
+```
+
+## Order the sidebar
+
+`openapi_extra` merges straight onto the operation object, which is exactly
+where `x-nav-order` belongs:
+
+```python
+@router.post("", openapi_extra={"x-nav-order": 1})
+async def create_account(body: AccountCreate) -> Account:
+ ...
+
+
+@router.get("", openapi_extra={"x-nav-order": 2})
+async def list_accounts() -> list[Account]:
+ ...
+```
+
+This is the case reordering can't fix: Markline reads verbs in a fixed order,
+so `GET /accounts` always precedes `POST /accounts` regardless of the order you
+declare the handlers in. `x-nav-order` is the only way to open the resource on
+**Create account**.
+
+Number them `10, 20, 30` and you can insert an endpoint later without touching
+its neighbours. Operations you don't annotate keep document order, after the
+ordered ones.
+
+## Events
+
+On 3.1 you have two options, and Markline reads both.
+
+**Native webhooks** — the idiomatic FastAPI route, available since 0.99:
+
+```python
+@app.webhooks.post("account.created")
+async def account_created(body: AccountCreatedEvent):
+ """A new account was opened."""
+```
+
+FastAPI emits this under the document root's `webhooks` object.
+
+A root webhook only attaches to a resource if its operation carries a
+matching `tags` entry — otherwise Markline parses it but has nowhere to show it.
+Pass `tags=["accounts"]` to the webhook decorator.
+
+**`x-events`** — lighter, and it gives you the emitter cross-link. Put it on the
+operation that causes the event and the endpoint gets a **Triggers** chip while
+the event gets an **Emitted by** back-link:
+
+```python
+@router.post(
+ "",
+ openapi_extra={
+ "x-nav-order": 1,
+ "x-events": {
+ "account.created": {
+ "summary": "A new account was opened",
+ "payload": {"$ref": "#/components/schemas/AccountCreatedEvent"},
+ "guide": "/guides/webhooks#account-created",
+ }
+ },
+ },
+)
+async def create_account(body: AccountCreate) -> Account:
+ ...
+```
+
+For that `$ref` to resolve, `AccountCreatedEvent` has to actually reach
+`components.schemas`. If no endpoint returns or accepts it, FastAPI won't emit
+it — reference the model from a response somewhere, or inline the payload
+schema instead of using `$ref`.
+
+Full behaviour in [Events & webhooks](/openapi#events-webhooks).
+
+## Code samples
+
+Replace the generated rail on an operation when you ship an SDK whose calls
+don't match Markline's inferred ones:
+
+```python
+@router.post(
+ "",
+ openapi_extra={
+ "x-codeSamples": [
+ {
+ "lang": "python",
+ "label": "Python SDK",
+ "source": 'client.accounts.create(email="ada@example.com")',
+ }
+ ]
+ },
+)
+async def create_account(body: AccountCreate) -> Account:
+ ...
+```
+
+One custom sample replaces the **entire** generated rail for that
+operation. List every language you want shown.
+
+No SDK at all? Skip the annotation and set `"codeSamples": ["curl"]` in
+`markline.json` — that suppresses the invented SDK snippets everywhere at once.
+
+## Gotchas
+
+**`operationId`s are ugly and unstable by default.** FastAPI derives them from
+the function name, path and method — `create_account_accounts_post`. Markline
+routes per-operation deep links and [MDX overlays](/openapi#mdx-overlays) off
+`operationId`, so those names end up in URLs and overlay filenames. Pin them:
+
+```python
+@router.post("", operation_id="createAccount")
+```
+
+Or normalise the whole app once, before emitting:
+
+```python
+for route in app.routes:
+ if isinstance(route, APIRoute):
+ route.operation_id = route.name
+```
+
+**`app.openapi()` caches.** It memoises into `app.openapi_schema`, so if you
+mutate routes after the first call you'll dump a stale document. In a one-shot
+emit script this never bites; in a longer script, set
+`app.openapi_schema = None` before re-reading.
+
+**`openapi_extra` merges, it doesn't validate.** A typo like `x-navorder` is
+silently ignored — Markline only reads `x-nav-order`, and only when the value is
+a **number**. `"x-nav-order": "1"` is dropped.
+
+**Only the first tag counts for grouping.** An operation with
+`tags=["accounts", "beta"]` lands on **Accounts**; the second tag doesn't create
+a second placement.
+
+**Pydantic aliases show up verbatim.** Markline renders the emitted schema, so
+`alias`/`serialization_alias` names are what your readers see. That's usually
+what you want — just be aware the docs follow the wire format, not your Python
+attribute names.
diff --git a/site/docs/guides/hono-zod-openapi.mdx b/site/docs/guides/hono-zod-openapi.mdx
new file mode 100644
index 0000000..b3baaa6
--- /dev/null
+++ b/site/docs/guides/hono-zod-openapi.mdx
@@ -0,0 +1,226 @@
+---
+title: Hono + Zod OpenAPI
+lede: Define routes and schemas once with @hono/zod-openapi, write the document to disk, and drop Markline's extensions straight into createRoute.
+crumbs:
+ - label: Docs
+ href: /
+ - label: Guides
+ href: /guides
+ - label: Hono + Zod OpenAPI
+toc:
+ - id: emit-the-document
+ label: Emit the document
+ - id: tags-become-resources
+ label: Tags become resources
+ - id: order-the-sidebar
+ label: Order the sidebar
+ - id: events
+ label: Events
+ - id: code-samples
+ label: Code samples
+ - id: gotchas
+ label: Gotchas
+---
+
+`@hono/zod-openapi` is the cleanest fit of the four. `createRoute()` takes an
+OpenAPI **operation object** — it destructures `method`, `path`, `request` and
+`responses` and spreads everything else through untouched — so Markline's
+extensions go in as literal keys, no decorator or post-process step in between.
+
+## Emit the document
+
+`OpenAPIHono` exposes the document directly. Write it to `api/openapi.json` as a
+build step:
+
+```ts
+// scripts/emit-openapi.ts
+import { mkdirSync, writeFileSync } from "node:fs";
+import { app } from "../src/app";
+
+const doc = app.getOpenAPI31Document({
+ openapi: "3.1.0",
+ info: { title: "Acme API", version: "1.4.2" }, // ← client name + version pill
+ servers: [{ url: "https://api.acme.com" }],
+});
+
+mkdirSync("../docs/api", { recursive: true });
+writeFileSync("../docs/api/openapi.json", JSON.stringify(doc, null, 2));
+```
+
+```jsonc
+// package.json
+"scripts": {
+ "docs:spec": "tsx scripts/emit-openapi.ts",
+ "docs:build": "npm run docs:spec && cd ../docs && markline build"
+}
+```
+
+`getOpenAPI31Document()` emits 3.1, which unlocks native root `webhooks`.
+`getOpenAPIDocument()` gives you 3.0 if you need it — Markline reads both.
+`app.doc()` / `app.doc31()` mount the same document on a route; use those for
+local inspection, not for the build.
+
+## Tags become resources
+
+`tags` on the route config is what Markline groups by, and slashes nest:
+
+```ts
+import { createRoute, z } from "@hono/zod-openapi";
+
+const createOrder = createRoute({
+ method: "post",
+ path: "/orders",
+ tags: ["store/orders"],
+ request: { body: { content: { "application/json": { schema: OrderCreate } } } },
+ responses: { 201: { description: "Created", content: { "application/json": { schema: Order } } } },
+});
+```
+
+That renders a **Store** parent with an **Orders** child, routed at
+`/api-reference/store-orders`. See [Nested tags](/openapi#nested-tags).
+
+Set **resource** order in the document config — omitted tags sort after the
+listed ones, alphabetically:
+
+```ts
+app.getOpenAPI31Document({
+ openapi: "3.1.0",
+ info: { title: "Acme API", version: "1.4.2" },
+ tags: [
+ { name: "accounts", description: "Balances and statements." },
+ { name: "payments" },
+ { name: "store/orders" },
+ ],
+});
+```
+
+## Order the sidebar
+
+`x-nav-order` is just another key on the route config:
+
+```ts
+const createAccount = createRoute({
+ method: "post",
+ path: "/accounts",
+ tags: ["accounts"],
+ operationId: "createAccount",
+ summary: "Create an account",
+ "x-nav-order": 1,
+ request: { body: { content: { "application/json": { schema: AccountCreate } } } },
+ responses: { 201: { description: "Created", content: { "application/json": { schema: Account } } } },
+});
+
+const listAccounts = createRoute({
+ method: "get",
+ path: "/accounts",
+ tags: ["accounts"],
+ operationId: "listAccounts",
+ summary: "List accounts",
+ "x-nav-order": 2,
+ responses: { 200: { description: "OK", content: { "application/json": { schema: z.array(Account) } } } },
+});
+```
+
+It type-checks: `RouteConfig` extends OpenAPI's `OperationObject`, which allows
+arbitrary `x-` keys.
+
+These two routes are the case nothing else can fix — they share a path, and
+Markline reads verbs in a fixed order, so `GET /accounts` would always come
+first no matter how you register them. Number your operations `10, 20, 30` and
+you can insert one later without renumbering.
+
+## Events
+
+On a 3.1 document you have both options.
+
+**Native webhooks**, registered on the underlying registry:
+
+```ts
+app.openAPIRegistry.registerWebhook({
+ method: "post",
+ path: "account.created",
+ tags: ["accounts"],
+ responses: { 200: { description: "Acknowledged" } },
+ request: { body: { content: { "application/json": { schema: AccountCreatedEvent } } } },
+});
+```
+
+A root webhook only attaches to a resource if it carries a matching
+`tags` entry. Leave `tags` off and Markline parses it but has nowhere to put
+it.
+
+**`x-events`** is lighter and gives you the emitter cross-link — the endpoint
+gets a **Triggers** chip, the event an **Emitted by** back-link:
+
+```ts
+const createAccount = createRoute({
+ method: "post",
+ path: "/accounts",
+ tags: ["accounts"],
+ "x-nav-order": 1,
+ "x-events": {
+ "account.created": {
+ summary: "A new account was opened",
+ payload: { $ref: "#/components/schemas/AccountCreatedEvent" },
+ guide: "/guides/webhooks#account-created",
+ },
+ },
+ // …
+});
+```
+
+For that `$ref` to resolve, register the schema under a name:
+
+```ts
+app.openAPIRegistry.register("AccountCreatedEvent", AccountCreatedEvent);
+```
+
+Or use `AccountCreatedEvent.openapi("AccountCreatedEvent")` at definition time —
+either way the component has to exist, or the payload renders empty. Full
+behaviour in [Events & webhooks](/openapi#events-webhooks).
+
+## Code samples
+
+```ts
+const createAccount = createRoute({
+ // …
+ "x-codeSamples": [
+ {
+ lang: "typescript",
+ label: "SDK",
+ source: 'await acme.accounts.create({ email: "ada@example.com" });',
+ },
+ ],
+});
+```
+
+One custom sample replaces the **entire** generated rail for that
+operation — list every language you want shown.
+
+No SDK? Skip this and set `"codeSamples": ["curl"]` in `markline.json`, which
+drops the invented `acme.accounts.create(…)` snippets everywhere at once.
+
+## Gotchas
+
+**Set `operationId` yourself.** Without it the generator falls back to a derived
+id, and Markline routes per-operation deep links and [MDX
+overlays](/openapi#mdx-overlays) off `operationId`. An explicit `operationId` keeps
+those URLs and overlay filenames stable across refactors.
+
+**Register schemas you `$ref`.** A `$ref` to
+`#/components/schemas/Foo` is only valid if `Foo` was registered — inline Zod
+schemas get inlined into the operation, not hoisted. `.openapi("Foo")` or
+`registry.register("Foo", schema)` hoists them, which also stops the same shape
+being duplicated across every operation that uses it.
+
+**Only the first tag groups.** `tags: ["accounts", "beta"]` lands the operation
+on **Accounts**; the second tag doesn't create a second placement.
+
+**`x-` keys are typed as `any`.** The spread-through is untyped, so a typo like
+`"x-navorder"` compiles fine and is silently ignored. Markline reads
+`x-nav-order`, and only when the value is a **number**.
+
+**`z.array()` at the top level of a response is fine, but name it.** An unnamed
+array response renders as an anonymous inline schema with no attribute table
+heading. `z.array(Account).openapi("AccountList")` gives readers something to
+anchor on.
diff --git a/site/docs/guides/index.mdx b/site/docs/guides/index.mdx
new file mode 100644
index 0000000..2949f08
--- /dev/null
+++ b/site/docs/guides/index.mdx
@@ -0,0 +1,96 @@
+---
+title: Framework guides
+lede: Point Markline at the OpenAPI document your backend already emits — then use four small extensions to shape the reference without touching your routes.
+crumbs:
+ - label: Docs
+ href: /
+ - label: Guides
+toc:
+ - id: what-each-guide-covers
+ label: What each guide covers
+ - id: the-contract
+ label: The contract
+ - id: pick-your-framework
+ label: Pick your framework
+ - id: not-listed
+ label: Not listed?
+---
+
+Markline doesn't ask you to describe your API twice. If your backend already
+produces an OpenAPI 3.0 or 3.1 document — and most do — that document *is* your
+reference. These guides cover the last mile: getting the document onto disk at
+build time, and annotating it so the generated site reads the way you'd write it
+by hand.
+
+## What each guide covers
+
+Every guide walks the same six steps, so you can skim across frameworks:
+
+1. **Emit the document** into `api/openapi.json` as a build step.
+2. **Tags become resources** — including nested `store/orders` tags.
+3. **Order the sidebar** with `x-nav-order`.
+4. **Document events** with `x-events`.
+5. **Replace generated code samples** with `x-codeSamples`.
+6. **Gotchas** — the framework-specific traps.
+
+## The contract
+
+The whole surface is four OpenAPI extensions plus your tags. Nothing here is
+Markline-specific plumbing: they're plain JSON keys on a standard document, so
+they survive `$ref` bundling, spec linting, and every other tool in the chain.
+
+| What you want | Where it goes | Reference |
+| --- | --- | --- |
+| Group endpoints into resources | `tags` on the operation | [Nested tags](/openapi#nested-tags) |
+| Reorder operations in a resource | `x-nav-order` on the operation | [Sidebar order](/openapi#sidebar-order) |
+| Document webhooks / async events | `x-events` on a tag or operation | [Events & webhooks](/openapi#events-webhooks) |
+| Hand-write the code rail | `x-codeSamples` on the operation | [Code samples](/openapi#code-samples) |
+
+Every one of these is optional. A plain, unannotated document already
+renders a complete reference — resource pages, parameter tables, generated
+cURL/Node/Python/Go samples, and the request explorer. The extensions are for
+when the default reading order isn't the one you want.
+
+## Pick your framework
+
+
+ Decorator-driven, via `@nestjs/swagger` and `@ApiExtension`.
+ Native OpenAPI 3.1, shaped with `openapi_extra`.
+ springdoc-openapi, with `@Extension` and `@ExtensionProperty`.
+ Schema-first TypeScript, extensions inline in `createRoute`.
+
+
+## Not listed?
+
+You don't need a guide. Markline reads a standard document, so any generator
+works — Django REST with drf-spectacular, Laravel, Rails with rswag, ASP.NET
+with Swashbuckle, Go with huma or swaggo, or a hand-written YAML file.
+
+The only thing a guide really buys you is the idiomatic way to attach an
+`x-` key in that framework. If yours has no clean hook, post-process the emitted
+document instead — the extensions are just JSON:
+
+```js
+// scripts/annotate-spec.mjs — run after your generator, before `markline build`
+import { readFileSync, writeFileSync } from "node:fs";
+
+const spec = JSON.parse(readFileSync("api/openapi.json", "utf8"));
+
+const order = {
+ createSession: 1,
+ endSession: 2,
+};
+
+for (const item of Object.values(spec.paths)) {
+ for (const op of Object.values(item)) {
+ const n = order[op.operationId];
+ if (n !== undefined) op["x-nav-order"] = n;
+ }
+}
+
+writeFileSync("api/openapi.json", JSON.stringify(spec, null, 2));
+```
+
+This is also the honest answer for tag-level `x-events` in frameworks
+whose decorators only attach to handlers — see the note at the end of the
+[NestJS guide](/guides/nestjs#gotchas).
diff --git a/site/docs/guides/nestjs.mdx b/site/docs/guides/nestjs.mdx
new file mode 100644
index 0000000..9cfa7dd
--- /dev/null
+++ b/site/docs/guides/nestjs.mdx
@@ -0,0 +1,225 @@
+---
+title: NestJS
+lede: Emit your OpenAPI document from @nestjs/swagger, snapshot it into your docs, and shape the reference with decorators that survive regeneration.
+crumbs:
+ - label: Docs
+ href: /
+ - label: Guides
+ href: /guides
+ - label: NestJS
+toc:
+ - id: emit-the-document
+ label: Emit the document
+ - id: tags-become-resources
+ label: Tags become resources
+ - id: order-the-sidebar
+ label: Order the sidebar
+ - id: events
+ label: Events
+ - id: code-samples
+ label: Code samples
+ - id: gotchas
+ label: Gotchas
+---
+
+NestJS is the framework Markline's OpenAPI extensions were designed against.
+`@nestjs/swagger` builds the document from decorators you already write, and
+`@ApiExtension` gives you a clean hook for everything else — so the annotations
+live next to the handler and survive every regeneration.
+
+## Emit the document
+
+`SwaggerModule.createDocument()` returns a plain object. Write it to
+`api/openapi.json` in your docs project and commit it, so the docs build never
+depends on a running service.
+
+```ts
+// scripts/emit-openapi.ts
+import { NestFactory } from '@nestjs/core';
+import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
+import { writeFileSync, mkdirSync } from 'node:fs';
+import { AppModule } from '../src/app.module';
+
+async function main() {
+ const app = await NestFactory.create(AppModule, { logger: false });
+
+ const config = new DocumentBuilder()
+ .setTitle('Acme API') // ← names the generated SDK samples
+ .setVersion('1.4.2') // ← drives the version pill
+ .addServer('https://api.acme.com')
+ .addBearerAuth()
+ .build();
+
+ const document = SwaggerModule.createDocument(app, config);
+
+ mkdirSync('../docs/api', { recursive: true });
+ writeFileSync('../docs/api/openapi.json', JSON.stringify(document, null, 2));
+ await app.close();
+}
+
+main();
+```
+
+```jsonc
+// package.json
+"scripts": {
+ "docs:spec": "ts-node scripts/emit-openapi.ts",
+ "docs:build": "npm run docs:spec && cd ../docs && markline build"
+}
+```
+
+`setTitle` is load-bearing. Markline derives the client name in generated
+code samples from the first word of `info.title` — `"Acme API"` produces
+`acme.payments.create({ … })`. See [how samples are
+named](/openapi#code-samples).
+
+## Tags become resources
+
+`@ApiTags` is what Markline groups by. Slash-separated tags nest in the sidebar
+without changing any URLs:
+
+```ts
+@ApiTags('store/orders')
+@Controller('orders')
+export class OrdersController {}
+```
+
+That renders a **Store** parent with an **Orders** child, still routed at
+`/api-reference/store-orders`. Depth is arbitrary. See [Nested
+tags](/openapi#nested-tags).
+
+Declare the tags at the document level to control **resource** order — anything
+you omit sorts after the listed tags, alphabetically:
+
+```ts
+const config = new DocumentBuilder()
+ .addTag('accounts')
+ .addTag('payments')
+ .addTag('store/orders')
+ .build();
+```
+
+## Order the sidebar
+
+`@ApiTags` fixes which resource an endpoint lands on; `x-nav-order` fixes where
+it sits *inside* that resource.
+
+```ts
+import { ApiExtension, ApiOperation, ApiTags } from '@nestjs/swagger';
+
+@ApiTags('accounts')
+@Controller('accounts')
+export class AccountsController {
+ @Post()
+ @ApiExtension('x-nav-order', 1)
+ @ApiOperation({ summary: 'Create an account' })
+ create() { /* … */ }
+
+ @Get()
+ @ApiExtension('x-nav-order', 2)
+ @ApiOperation({ summary: 'List accounts' })
+ list() { /* … */ }
+}
+```
+
+This is the case you **cannot** solve by reordering anything. Markline reads
+verbs in a fixed order, so `GET /accounts` always precedes `POST /accounts` no
+matter how the controller is written — `x-nav-order` is the only way to put
+**Create an account** first.
+
+A tiny composed decorator keeps it readable when you're ordering a whole
+controller:
+
+```ts
+import { applyDecorators } from '@nestjs/common';
+import { ApiExtension } from '@nestjs/swagger';
+
+export const NavOrder = (n: number) => applyDecorators(ApiExtension('x-nav-order', n));
+
+// @NavOrder(1)
+```
+
+Number them `10, 20, 30` rather than `1, 2, 3` and you can slot a new
+endpoint in later without touching its neighbours.
+
+## Events
+
+NestJS has no `@ApiCallbacks`, which is exactly why `x-events` exists. Annotate
+the handler that causes the event — Markline aggregates it onto the resource,
+adds a **Triggers** chip to the endpoint, and an **Emitted by** back-link on the
+event.
+
+```ts
+import { ApiExtension, ApiExtraModels, ApiTags, getSchemaPath } from '@nestjs/swagger';
+
+@ApiTags('accounts')
+@ApiExtraModels(AccountCreatedEvent)
+@Controller('accounts')
+export class AccountsController {
+ @Post()
+ @ApiExtension('x-events', {
+ 'account.created': {
+ summary: 'A new account was opened',
+ payload: { $ref: getSchemaPath(AccountCreatedEvent) },
+ guide: '/guides/webhooks#account-created',
+ },
+ })
+ create() { /* … */ }
+}
+```
+
+`@ApiExtraModels` is required — without it `getSchemaPath()` points at a schema
+that was never emitted into `components.schemas`. Full details in [Events &
+webhooks](/openapi#events-webhooks).
+
+## Code samples
+
+Markline generates cURL, Node, Python and Go rails from the operation itself.
+If you ship a real SDK whose signatures don't match, replace the rail for that
+operation:
+
+```ts
+@Post()
+@ApiExtension('x-codeSamples', [
+ {
+ lang: 'ruby',
+ label: 'Ruby',
+ source: "Acme::Account.create(\n email: \"ada@example.com\",\n)",
+ },
+])
+create() { /* … */ }
+```
+
+`x-codeSamples` is all-or-nothing per operation — one custom sample
+replaces the entire generated rail. List every language you want shown.
+
+If you don't ship an SDK at all, don't annotate every operation. Set
+`"codeSamples": ["curl"]` in `markline.json` and the invented
+`acme.accounts.create(…)` snippets disappear everywhere at once.
+
+## Gotchas
+
+**`@ApiExtension` values aren't restricted to objects.** The decorator enforces
+only that the key starts with `x-`; the value is cloned through as-is, so
+`@ApiExtension('x-nav-order', 1)` is valid. Repeated calls merge, so `x-events`
+and `x-nav-order` coexist on one handler.
+
+**`@ApiExtension` on a controller class applies to every route in it.** Handy
+for `x-events` that any endpoint in a resource can emit — and a trap for
+`x-nav-order`, where it would give every operation the same rank.
+
+**Operations need stable `operationId`s.** Markline routes per-operation deep
+links and MDX overlays off `operationId`. NestJS derives it from the controller
+and method name (`AccountsController_create`), so renaming a method breaks
+existing links. Pin the ones you care about with
+`@ApiOperation({ operationId: 'createAccount' })`.
+
+**There's no decorator for tag objects.** An event with no single triggering
+endpoint — delivered by a processor or a batch job — can't be attached via
+decorators. Either hang it off the closest operation, or add it under
+`tags[].x-events` in a [post-processing step](/guides#not-listed) after
+`createDocument()` and before you write the file.
+
+**Snapshot, don't proxy.** Pointing the docs build at a live `/api-json`
+endpoint couples your docs deploy to your API being up. Emit to a committed
+file; the diff also gives you a review surface for accidental API changes.
diff --git a/site/docs/guides/spring-boot.mdx b/site/docs/guides/spring-boot.mdx
new file mode 100644
index 0000000..9404895
--- /dev/null
+++ b/site/docs/guides/spring-boot.mdx
@@ -0,0 +1,230 @@
+---
+title: Spring Boot
+lede: Pull the document springdoc-openapi already serves at /v3/api-docs into your docs build, then shape the reference with swagger-core's @Extension annotations.
+crumbs:
+ - label: Docs
+ href: /
+ - label: Guides
+ href: /guides
+ - label: Spring Boot
+toc:
+ - id: emit-the-document
+ label: Emit the document
+ - id: tags-become-resources
+ label: Tags become resources
+ - id: order-the-sidebar
+ label: Order the sidebar
+ - id: events
+ label: Events
+ - id: code-samples
+ label: Code samples
+ - id: gotchas
+ label: Gotchas
+---
+
+springdoc-openapi introspects your Spring MVC or WebFlux controllers and serves
+the document at `/v3/api-docs`. Everything Markline needs beyond that comes from
+swagger-core's `@Extension` / `@ExtensionProperty` annotations — with one sharp
+edge around value types that's worth reading before you start.
+
+## Emit the document
+
+springdoc generates at runtime, so the build step is "start the app, take a
+snapshot." The official Maven plugin does exactly that during
+`integration-test`:
+
+```xml
+
+ org.springdoc
+ springdoc-openapi-maven-plugin
+ 1.4
+
+
+ integration-test
+ generate
+
+
+
+ http://localhost:8080/v3/api-docs
+ openapi.json
+ ${project.basedir}/../docs/api
+
+
+```
+
+Pair it with `spring-boot-maven-plugin`'s `start` / `stop` goals in
+`pre-integration-test` / `post-integration-test`. On Gradle, the
+`org.springdoc.openapi-gradle-plugin` equivalent does the same.
+
+If you'd rather not wire a plugin, the framework-agnostic version is two lines
+and works everywhere:
+
+```bash
+java -jar target/app.jar &
+until curl -sf localhost:8080/v3/api-docs -o ../docs/api/openapi.json; do sleep 1; done
+kill %1
+```
+
+Set the document metadata once — `info.title` names the client in generated code
+samples, `info.version` drives the version pill:
+
+```java
+@OpenAPIDefinition(
+ info = @Info(title = "Acme API", version = "1.4.2"),
+ servers = @Server(url = "https://api.acme.com")
+)
+@SpringBootApplication
+public class AcmeApplication { }
+```
+
+## Tags become resources
+
+`@Tag` on the controller is what Markline groups by, and slashes nest:
+
+```java
+@Tag(name = "store/orders")
+@RestController
+@RequestMapping("/orders")
+public class OrderController { }
+```
+
+That renders a **Store** parent with an **Orders** child, routed at
+`/api-reference/store-orders`. See [Nested tags](/openapi#nested-tags).
+
+Declare tags at the document level to fix **resource** order and give each one a
+description — anything you omit sorts after the listed tags, alphabetically:
+
+```java
+@OpenAPIDefinition(
+ info = @Info(title = "Acme API", version = "1.4.2"),
+ tags = {
+ @Tag(name = "accounts", description = "Balances and statements."),
+ @Tag(name = "payments"),
+ @Tag(name = "store/orders")
+ }
+)
+```
+
+## Order the sidebar
+
+Here's the sharp edge. `@ExtensionProperty.value()` is a **`String`**, and
+`parseValue()` defaults to `false` — so the obvious spelling emits
+`"x-nav-order": "1"`, a JSON *string*. Markline only honours `x-nav-order` when
+it's a **number**, so a string is silently ignored and nothing moves.
+
+Set `parseValue = true`:
+
+```java
+@Operation(
+ summary = "Create an account",
+ extensions = @Extension(properties = @ExtensionProperty(
+ name = "x-nav-order", value = "1", parseValue = true
+ ))
+)
+@PostMapping
+public Account create(@RequestBody AccountCreate body) { … }
+```
+
+Leave `parseValue` off and you get `"x-nav-order": "1"`. It's valid
+OpenAPI, it lints clean, and it does nothing. This is the single most common way
+to wire this up wrong on the JVM.
+
+Note the empty `@Extension` name: with no `name`, swagger-core promotes each
+property to a **top-level** extension key (prefixing `x-` if you left it off).
+Give `@Extension` a name and you get a nested object instead — which is what you
+want for `x-events` below, and not what you want here.
+
+This is also the case that can't be solved by reordering source. Markline reads
+verbs in a fixed order, so `GET /accounts` always precedes `POST /accounts`
+however the controller is written. Number your operations `10, 20, 30` and you
+can insert one later without renumbering its neighbours.
+
+## Events
+
+Give `@Extension` a **name** and its properties become a nested object — which
+is exactly the shape `x-events` wants. The payload is JSON, so it needs
+`parseValue = true` too:
+
+```java
+@Operation(
+ summary = "Create an account",
+ extensions = @Extension(name = "events", properties = @ExtensionProperty(
+ name = "account.created",
+ value = """
+ {
+ "summary": "A new account was opened",
+ "payload": { "$ref": "#/components/schemas/AccountCreatedEvent" },
+ "guide": "/guides/webhooks#account-created"
+ }
+ """,
+ parseValue = true
+ ))
+)
+@PostMapping
+public Account create(@RequestBody AccountCreate body) { … }
+```
+
+`@Extension(name = "events", …)` produces the key `x-events` — swagger-core
+prepends `x-` for you. Placed on the operation, the event inherits the
+controller's `@Tag`, so `POST /accounts` gets a **Triggers** chip and the event
+an **Emitted by** back-link.
+
+The `$ref` only resolves if `AccountCreatedEvent` reached
+`components.schemas`. If no endpoint returns it, add
+`@Schema(implementation = AccountCreatedEvent.class)` somewhere reachable, or
+inline the payload schema instead of using `$ref`.
+
+Full behaviour in [Events & webhooks](/openapi#events-webhooks).
+
+## Code samples
+
+`x-codeSamples` is an **array**, which the nested-object form can't express. Use
+the empty-name form with a JSON array as the value:
+
+```java
+@Operation(
+ extensions = @Extension(properties = @ExtensionProperty(
+ name = "x-codeSamples",
+ value = """
+ [{ "lang": "java", "label": "Java SDK",
+ "source": "acme.accounts().create(AccountCreate.of(\\"ada@example.com\\"));" }]
+ """,
+ parseValue = true
+ ))
+)
+```
+
+One custom sample replaces the **entire** generated rail for that
+operation — list every language you want shown.
+
+If you don't publish an SDK, don't annotate anything: set
+`"codeSamples": ["curl"]` in `markline.json` and the invented
+`acme.accounts.create(…)` snippets disappear everywhere at once.
+
+## Gotchas
+
+**`parseValue = true` on every non-string value.** Numbers, booleans, objects
+and arrays all arrive as strings otherwise. It's the first thing to check when
+an extension "doesn't work."
+
+**Blank values are dropped.** swagger-core skips any `@ExtensionProperty` whose
+name or value is blank, so an empty string won't clear an inherited value.
+
+**Empty `@Extension` name vs named.** No name → each property becomes its own
+top-level `x-` key. A name → one `x-` key holding a map of the properties.
+Picking the wrong one is the second most common failure.
+
+**`operationId`s collide and get suffixed.** springdoc derives them from the
+method name and appends `_1`, `_2` on collision. Markline routes per-operation
+deep links and [MDX overlays](/openapi#mdx-overlays) off `operationId`, so those
+suffixes leak into URLs and filenames — and shift when you add a method. Pin
+them with `@Operation(operationId = "createAccount")`.
+
+**Only the first tag groups.** A controller with two `@Tag`s puts its operations
+on the first one; the second doesn't create a second placement.
+
+**Text blocks and quotes.** A Java text block takes bare `"` happily, so the
+JSON keys and values above need no escaping. The exception is a quote that has
+to survive *into* the JSON string — write `\\"` so the runtime string holds
+`\"`, which the JSON parser then reads as a quote. Write `\"` and the block
+collapses it to `"`, terminating the JSON string early.
diff --git a/site/docs/openapi.mdx b/site/docs/openapi.mdx
index 8937543..8dc3644 100644
--- a/site/docs/openapi.mdx
+++ b/site/docs/openapi.mdx
@@ -8,15 +8,17 @@ toc:
label: What you get
- id: nested-tags
label: Nested tags
+ - id: sidebar-order
+ label: Sidebar order
- id: code-samples
label: Code samples
- - id: playground
+ - id: the-playground
label: The playground
- - id: events
+ - id: events-webhooks
label: Events & webhooks
- - id: overlays
+ - id: mdx-overlays
label: MDX overlays
- - id: ai-and-actions
+ - id: ai-page-actions
label: AI & page actions
- id: versions
label: Versions
@@ -105,6 +107,64 @@ admin/api/keys Admin
`@ApiTags` to get clean resource boundaries. Per-resource
[MDX overlays](#overlays) still key off the full slug (`store-orders.mdx`).
+## Sidebar order
+
+Within a resource, operations render in the order Markline reads them from the
+document. That's rarely the order you'd teach in — the "create a session"
+call is usually declared after the read endpoints, and it's the one that should
+open the page.
+
+Set `x-nav-order` (a number) on any operation to pin it:
+
+```jsonc
+"paths": {
+ "/session": {
+ "post": {
+ "operationId": "createSession",
+ "summary": "Create a session",
+ "x-nav-order": 1 // opens the resource
+ }
+ },
+ "/session/{id}": {
+ "delete": {
+ "operationId": "endSession",
+ "summary": "End a session",
+ "x-nav-order": 2
+ }
+ }
+}
+```
+
+- Operations carrying `x-nav-order` sort **ascending**, ahead of everything else.
+- Operations without it keep document order, after the ordered ones.
+- You annotate only what you care about — a spec with no `x-nav-order` anywhere
+ renders exactly as it did before.
+- Numbers are relative, not positional. Use `10, 20, 30` and you can insert
+ later without renumbering. Ties fall back to document order.
+- The order applies to all three surfaces that read the resource at once: the
+ sidebar tree, the **Endpoints** card, and the rendered section order.
+
+This orders operations **within** a tag. Resource order comes from the
+root `tags` array — list your tags there in the order you want them, and any
+tag you leave out sorts after the listed ones, alphabetically.
+
+### Why not just reorder the document?
+
+Two reasons the document's own order can't carry this:
+
+- **Methods on one path are fixed.** Markline reads verbs in a stable order —
+ `get`, `post`, `put`, `patch`, `delete`, `options`, `head` — so two operations
+ sharing a path item always come out GET before POST. Moving them around in the
+ document changes nothing. If you want **Create account** (`POST /accounts`)
+ above **List accounts** (`GET /accounts`), `x-nav-order` is the only lever.
+- **Generated documents get regenerated.** If your spec comes out of NestJS,
+ FastAPI, springdoc or similar, path order follows your route or handler
+ declaration order. Hand-sorting the emitted JSON is wiped on the next build,
+ and reordering handlers to fix the docs couples source layout to reading
+ order. An annotation on the handler survives regeneration.
+
+See the [framework guides](/guides) for the per-framework annotation.
+
## Code samples
Every endpoint gets a dark **code rail**. By default Markline *generates* four
@@ -344,9 +404,11 @@ export class CardsController {
it the `$ref` points at a schema that was never emitted.
- The event inherits `@ApiTags('cards')`, so it lands on the **Cards** resource
and `POST /cards` gets the **Triggers** chip.
-- `@ApiExtension` requires an `x-`-prefixed key and an object value — both hold.
- Placed on the **controller class** it applies to every route; on a **handler**,
- just that operation.
+- `@ApiExtension` only enforces that the key starts with `x-`; the value is
+ passed through as-is, so objects, arrays and plain numbers all work. Repeated
+ `@ApiExtension` calls merge, so `x-events` and `x-nav-order` can sit on the
+ same handler. Placed on the **controller class** it applies to every route; on
+ a **handler**, just that operation.
Reuse a shared set with a small composed decorator:
@@ -365,6 +427,11 @@ delivers), attach it to the most relevant operation, or add it under a tag's
`x-events` by post-processing the generated document before you write
`openapi.json` — NestJS has no decorator for tag objects.
+The [NestJS guide](/guides/nestjs) walks the whole setup end to end — emitting
+the document, tags, ordering, events and code samples. There are matching guides
+for [FastAPI](/guides/fastapi), [Spring Boot](/guides/spring-boot) and
+[Hono](/guides/hono-zod-openapi).
+
## MDX overlays
Layer authored MDX on top of the generated reference — full components
@@ -399,4 +466,4 @@ the spec's `info.version` (a semver `1.4.2` → `v1 · 1.4.2`; a date `2025-06-0
With multiple [versions](/versions) configured, each version ships its **own**
`/api/openapi.json`, served at `/api-reference/` — the selector switches
the spec in place and every link stays under that version's prefix. See
-[Versions & i18n](/versions#api-reference) for the folder layout.
+[Versions & i18n](/versions#api-reference-per-version) for the folder layout.
diff --git a/site/markline.json b/site/markline.json
index 06b0969..ae20656 100644
--- a/site/markline.json
+++ b/site/markline.json
@@ -56,6 +56,39 @@
}
]
},
+ {
+ "id": "guides",
+ "label": "Guides",
+ "href": "/guides",
+ "match": ["/guides"],
+ "groups": [
+ {
+ "group": "Overview",
+ "pages": [
+ { "href": "/guides", "label": "Framework guides" }
+ ]
+ },
+ {
+ "group": "Node & TypeScript",
+ "pages": [
+ { "href": "/guides/nestjs", "label": "NestJS" },
+ { "href": "/guides/hono-zod-openapi", "label": "Hono + Zod OpenAPI" }
+ ]
+ },
+ {
+ "group": "Python",
+ "pages": [
+ { "href": "/guides/fastapi", "label": "FastAPI" }
+ ]
+ },
+ {
+ "group": "JVM",
+ "pages": [
+ { "href": "/guides/spring-boot", "label": "Spring Boot" }
+ ]
+ }
+ ]
+ },
{
"id": "api-reference",
"label": "API reference",